feat(#498): single Package Identity seam for /gsd:update + fix runtime undefined-name bug (#499)

* feat(#498): generated package-identity seam derived from package.json

Introduce a single source for GSD's published-package coordinates:
scripts/generate-package-identity.cjs (pure deriveIdentity + formatManualInstall
+ render) emits the generated get-shit-done/bin/lib/package-identity.cjs with
values baked from package.json at build time. Baking is required because the
installed tree carries only a synthetic {"type":"commonjs"} package.json, so a
runtime require('package.json').name resolves to undefined (#378). Reconciles

Wired into npm run build; a parity test fails CI if the committed file drifts
from package.json.

Refs #498

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#498): repoint update worker + check-latest-version at the seam

- check-latest-version.cjs sources PACKAGE_NAME from the package-identity seam
  instead of a re-typed literal (single source; #2992's constant guarantee is
  preserved since the seam bakes from package.json).
- gsd-check-update-worker.js no longer does require('../package.json').name
  (resolved to undefined in the installed tree → background update check
  silently broken, #378). It now delegates the latest-version lookup to
  checkLatestVersion(), collapsing the duplicated npm-view call onto the single
  deterministic adapter and inheriting its typed {ok,version,reason} surface.
- Move the PR #3102 Windows shell-gate contract test onto execNpm (where the
  spawn now lives) and assert the worker no longer spawns npm directly.
- Rewrite the #378 contract: worker must NOT use require(package.json).name and
  must delegate; check-latest-version PACKAGE_NAME is single-sourced from the seam.

Fixes #378-class runtime breakage. Refs #498

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#498): changeset for package-identity seam + update-check fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#498): drift-guard lint — value-check GSD coordinate literals against the seam

scripts/lint-package-identity-drift.cjs scans the runtime/code surface
(bin/, hooks/, scripts/, get-shit-done/) and asserts every GSD package name
and GitHub repo slug literal equals the Package Identity seam's current value.
Passes today; fails the moment a repoint isn't propagated (rename package.json,
regenerate the seam, and stale literals are reported until updated). This is
the second adapter that makes the seam real and a repoint mechanically safe.

Enforced via tests/issue-498-identity-drift-lint.test.cjs (scanRepo === [])
under npm test; also exposed as `npm run check:identity-drift`.

Refs #498

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#498): update-context projection — port update.md resolution to a tested seam

Add get-shit-done/bin/lib/update-context.cjs: a pure, injected-fs port of
update.md's ~280-line get_installed_version bash. resolveUpdateContext()
reproduces the full precedence cascade (preferred fast-path -> local probe ->
global probe via env overrides then $HOME -> LOCAL-if-distinct -> scope
cascade -> UNKNOWN) and returns the 4-field contract { installedVersion,
scope, runtime, gsdDir }. The fs is injected so every branch is finally
testable without a live multi-runtime install.

Expose it as `gsd-tools update-context [--config-dir <d>] [--runtime <r>] --json`.
Purely additive — update.md is unchanged in this commit; the workflow swap
follows separately.

Refs #498

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#498): swap update.md resolution to the update-context projection

Replace ~280 lines of inline runtime/scope/config-dir bash in update.md's
get_installed_version step with a call to `gsd-tools update-context --json`
(60 lines: derive PREFERRED_* from execution_context, resolve gsd-tools.cjs,
parse the 4-field JSON). Behavior is unchanged — the projection reproduces the
same cascade — but the logic is now tested in update-context.cjs instead of
untestable bash-in-markdown.

Relocate the #3608 antigravity-first-class contract onto the projection
(RUNTIME_DIRS order, inferPreferredRuntime, envRuntimeDirs) plus a behavioral
test; keep the execution_context path-classification assertion on update.md.
Re-point install.test's custom-config-dir assertion (kilo.jsonc/KILO_CONFIG)
to update-context.cjs where that detection now lives.

Full root suite: 2022 pass / 0 fail.

Refs #498

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#498): record Update Context Module in CONTEXT.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#498): CI — avoid bare gsd-tools in update.md; register new CLI modules

- update.md update-context invocation: resolve the PATH gsd-tools shim into a
  variable and call "$GSD_TOOLS" (never a bare `gsd-tools` command) — satisfies
  the #2851 workflow-bare-gsd-tools guard.
- Register package-identity.cjs and update-context.cjs in docs/INVENTORY.md
  (CLI Modules 76 -> 78 + rows) and regenerate docs/INVENTORY-MANIFEST.json,
  fixing inventory-counts and inventory-manifest-sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#498): make update-context + parity tests OS-agnostic (Windows CI)

Two Windows-only test failures, both test-portability (production code is fine —
the real-fs CLI integration test passed on Windows):

- update-context resolver tests + bug-3608 behavioral test used POSIX path-string
  keys in their fake fs, but the resolver builds lookups via path.join/resolve
  (backslash + drive letter on Windows) → keys never matched → everything
  resolved to UNKNOWN/claude. Normalize fake-fs keys and gsdDir comparisons
  through path.resolve so they match on both platforms.
- package-identity parity test compared render() (LF) to the committed file,
  which Windows git checks out as CRLF (no .gitattributes eol rule). Normalize
  line endings before comparing, matching the repo convention
  (autonomous-decomposition, bug-3707).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#498): update.md backup must use GSD_DIR (adversarial-review finding)

The get_installed_version rewrite emits GSD_DIR but dropped the probe-loop
variables LOCAL_DIR/GLOBAL_DIR. The backup_custom_files step still read those,
so RUNTIME_DIR went empty for every LOCAL/GLOBAL install and detect-custom-files
was skipped — and since the update then runs a clean install that wipes managed
dirs (commands/gsd, get-shit-done), user-added files could be deleted without
the intended backup.

Set RUNTIME_DIR="$GSD_DIR" directly (the resolved config dir; empty for
UNKNOWN scope, which still skips the backup). Add a structural regression
(tests/issue-498-update-backup-runtime-dir.test.cjs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#503): re-point Antigravity .agent detection at the #498 projection

#499 moves the runtime/scope detection cascade out of update.md inline bash
into get-shit-done/bin/lib/update-context.cjs. The #503 regression test asserted
on the inline RUNTIME_DIRS array, which no longer exists, so it would fail
against the projected update.md even though the .agent guarantee is preserved.

Rewrite it to verify the surviving surfaces:
 - behavioral: resolveUpdateContext resolves a LOCAL ./.agent install to the
   antigravity runtime (the original root cause, now covered by adding
   ['antigravity', '.agent'] to the projection RUNTIME_DIRS table)
 - update.md prose classifier still maps /.agent/ -> antigravity
 - the post-update cache-clear for-dir loop still includes .agent

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#498): finish de-hardcoding consumers + close adversarial-review parity gaps

Restore the consumer de-hardcoding that is the point of the seam, and close the
parity gaps an adversarial review (codex) found in the update-context projection.

De-hardcode the repo slug + install command in the changeset tooling — #516
only single-sourced the package NAME, leaving 'open-gsd/get-shit-done-redux'
hardcoded in scripts/changeset/cli.cjs and github-release-notes.cjs. Route both
through the seam's repoSlug/packageName so a rename is a regenerate, not a hand
edit. The drift-lint real scan now reports zero divergent coordinate literals.

Projection parity vs the old inline bash, as ONE consistent rule
(trustedVersionAt) applied on every path:
 - expand a leading ~/ in preferredConfigDir before the fast path (the bash ran
   expand_home first; a custom --config-dir ~/foo otherwise fell to UNKNOWN)
 - trust a version only when BOTH VERSION and the update.md marker exist — fast
   path AND LOCAL/GLOBAL cascade; a partial dir falls to 0.0.0 keeping scope
 - apply the same same-path dedup to the 0.0.0 fallback so a partial install
   probed from cwd===home is not misdetected as LOCAL

Adds regression tests for tilde expansion, VERSION-only (cascade + fast path),
and the cwd===home partial-install dedup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-30 16:53:44 -04:00
committed by GitHub
parent fbd555c2ce
commit 6f2520786d
24 changed files with 1326 additions and 661 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 498
---
The SessionStart update check now reports available updates again: the worker previously resolved the package name via `require('package.json').name`, which is `undefined` in the installed tree, so `npm view` always failed. Package coordinates now come from a single build-time Package Identity seam derived from package.json.

View File

@@ -106,6 +106,12 @@ Module owning validation for Installer Migration Module records and planned acti
### Installer Module
Primary installer for all runtimes. Single production file: `bin/install.js` (generated). Exports: `install(isGlobal, runtime[, configDir])` → typed result `{ runtime, configDir, settingsPath, settings, statuslineCommand, updateBannerCommand }`; `uninstall(isGlobal, runtime[, configDir])`; `installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)`; `uninstallRuntimeArtifacts(runtime, configDir, scope)`; `writeManifest(configDir, runtime)`. Runtime enum: `allRuntimes` (15 values: claude, antigravity, augment, cline, codebuddy, codex, copilot, cursor, gemini, hermes, kilo, opencode, qwen, trae, windsurf). Directory helpers: `getDirName(runtime)` → local dir name; `getGlobalDir(runtime[, explicitDir])` → global path (env-var–aware per runtime); `getConfigDirFromHome(runtime, isGlobal)` → shell-quoted path fragment. Runtime-specific helpers: `resolveKiloConfigPath(configDir)`, `configureKiloPermissions(isGlobal[, explicitDir])`. Layout-driven artifact copy/removal delegates to `get-shit-done/bin/lib/runtime-artifact-layout.cjs:resolveRuntimeArtifactLayout` (throws `TypeError` for unknown runtimes). Hermes uses nested `skills/gsd/<stem>/` layout (prefix: ''); other skill-runtimes use flat `skills/gsd-<stem>/` layout. See Skill Surface Budget Module and Runtime Artifact Layout Module.
### Package Identity Module [Planned]
Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `get-shit-done/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module.
### Update Context Module [Planned]
Module owning install detection for `/gsd:update`. `resolveUpdateContext({ home, cwd, env, fs, preferredConfigDir, preferredRuntime })` is a pure, injected-fs port of update.md's former ~280-line `get_installed_version` bash; it reproduces the full precedence cascade — preferred-config-dir fast path, local-over-global probe with same-path dedup, env-var overrides (`CLAUDE_CONFIG_DIR`, `OPENCODE_CONFIG`, `KILO_CONFIG`, `XDG_CONFIG_HOME`, `CODEX_HOME`, …), and semver validation — and returns the 4-field contract `{ installedVersion, scope, runtime, gsdDir }` (scope ∈ `LOCAL`/`GLOBAL`/`UNKNOWN`). Antigravity is modelled first-class (its `.gemini/antigravity{,-ide,-cli}` dirs probe before bare `.gemini`; #3608). Exposed to the workflow as `gsd-tools update-context [--config-dir <d>] [--runtime <r>] --json`; `loadUpdateContext` wires the real fs. The workflow keeps only the execution_context path → `PREFERRED_*` derivation (the one input it alone knows). Source: `get-shit-done/bin/lib/update-context.cjs`; tests: `tests/issue-498-update-context.test.cjs`. See Installer Module and Package Identity Module.
### Skill Surface Budget Module
Module owning which skills and agents are written to runtime config directories at install time (Phase 1) and at runtime via cluster-level toggles (Phase 2). Phase 1: `get-shit-done/bin/lib/install-profiles.cjs` defines named profiles (`core`, `standard`, `full`), computes transitive closure over `requires:` frontmatter, stages skills/agents to runtime config dirs, and persists the chosen profile in a `.gsd-profile` marker. Profile resolution precedence: explicit `--profile=` flag > `.gsd-profile` marker > `full`. `--minimal`/`--core-only` are back-compat aliases for `--profile=core`. Phase 2: `get-shit-done/bin/lib/surface.cjs` implements the `/gsd:surface` slash command for cluster-level enable/disable without reinstall; cluster definitions live in `get-shit-done/bin/lib/clusters.cjs`; per-runtime state persists in `<runtimeConfigDir>/.gsd-surface.json` independent from the `.gsd-profile` marker. See ADR-0011.

View File

@@ -328,6 +328,7 @@
"task-command-router.cjs",
"template.cjs",
"uat.cjs",
"update-context.cjs",
"validate-command-router.cjs",
"validate.cjs",
"verify-command-router.cjs",

View File

@@ -362,7 +362,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (77 shipped)
## CLI Modules (78 shipped)
Full listing: `get-shit-done/bin/lib/*.cjs`.
@@ -406,7 +406,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `milestone.cjs` | Milestone archival, requirements marking |
| `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers |
| `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table |
| `package-identity.cjs` | Single source of truth for the package's own identity — exports `PACKAGE_NAME` derived from `package.json` `name` so a rename is a one-line change (#516) |
| `package-identity.cjs` | Generated single source for GSD's published-package coordinates (npm name, bin name, repo slug, changelog URL, manual-install command), derived from package.json; read by the update worker, `check-latest-version`, and installer (#498) |
| `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` |
| `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler |
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |
@@ -436,6 +436,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `task-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools task` |
| `template.cjs` | Template selection and filling with variable substitution |
| `uat.cjs` | UAT file parsing, verification debt tracking, audit-uat support |
| `update-context.cjs` | Pure install-context resolver for `/gsd:update` — runtime/scope/config-dir/version detection (LOCAL/GLOBAL/UNKNOWN) ported from update.md bash; backs `gsd-tools update-context` (#498) |
| `validate-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools validate` |
| `validate.cjs` | Pure phase variant normalization helpers (`phaseVariants`, `buildRoadmapPhaseVariants`, `buildNotStartedPhaseVariants`) used by `verify.cjs` for W006/W007 checks; no I/O, no async |
| `verify-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools verify` |

View File

@@ -21,11 +21,12 @@
*/
const { execNpm } = require('./lib/shell-command-projection.cjs');
const { PACKAGE_NAME } = require('./lib/package-identity.cjs');
// Sourced from package.json via package-identity.cjs (#516). Do not
// parameterise — the whole point of this script is that the package name is
// not a runtime choice for the caller.
// Sourced from the single Package Identity seam (#498), not re-typed. The seam
// bakes the value from package.json at build time, so it is a code constant —
// still NOT a runtime choice for the caller (#2992) — and a rename propagates
// from one place (#378). The drift-guard lint forbids re-introducing a literal.
const { packageName: PACKAGE_NAME } = require('./lib/package-identity.cjs');
const CHECK_REASON = Object.freeze({
OK: 'ok',

View File

@@ -1615,6 +1615,38 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
break;
}
case 'update-context': {
// #498: resolve the installed GSD version, scope, runtime, and config dir
// for /gsd:update. Replaces ~280 lines of inline bash in update.md with a
// tested projection. Emits the contract as JSON: { installedVersion,
// scope, runtime, gsdDir }. Optional --config-dir / --runtime carry the
// workflow's execution_context hints (the one thing only it can know).
const { loadUpdateContext } = require('./lib/update-context.cjs');
const ucArgs = args.slice(1);
let preferredConfigDir = '';
let preferredRuntime = '';
for (let i = 0; i < ucArgs.length; i++) {
const a = ucArgs[i];
if (a.startsWith('--config-dir=')) { preferredConfigDir = a.slice('--config-dir='.length); continue; }
if (a.startsWith('--runtime=')) { preferredRuntime = a.slice('--runtime='.length); continue; }
if (a === '--config-dir') {
const v = ucArgs[i + 1];
if (v === undefined || v.startsWith('--')) error('Missing value for --config-dir', ERROR_REASON.USAGE);
preferredConfigDir = v; i++; continue;
}
if (a === '--runtime') {
const v = ucArgs[i + 1];
if (v === undefined || v.startsWith('--')) error('Missing value for --runtime', ERROR_REASON.USAGE);
preferredRuntime = v; i++; continue;
}
if (a === '--json') continue; // JSON is the only output; accepted for symmetry
if (a.startsWith('-')) error(`Unknown flag for update-context: ${a}`, ERROR_REASON.USAGE);
}
const ctx = loadUpdateContext({ preferredConfigDir, preferredRuntime });
process.stdout.write(JSON.stringify(ctx) + '\n');
break;
}
default: {
// #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim
// above split it so `command` here is the head ("foo"). Use

View File

@@ -1,19 +1,31 @@
// @generated by scripts/generate-package-identity.cjs from package.json — DO NOT EDIT.
// Single source for GSD package coordinates (issue #498). Regenerate with:
// node scripts/generate-package-identity.cjs
'use strict';
/**
* Single source of truth for the package name at runtime (#516).
*
* Why this exists: the package name `@opengsd/get-shit-done-redux` was
* hardcoded as a string literal in ~15 runtime .cjs/.js files. When the
* package is renamed (e.g. to `@opengsd/gsd-core`), changing this one
* require path propagates the new name everywhere in runtime code.
*
* Path: get-shit-done/bin/lib/package-identity.cjs
* Resolves package.json both in-repo (3 levels up) and when shipped
* (package root is always 3 dirs above this file).
*/
const pkg = require('../../../package.json');
const packageName = "@opengsd/get-shit-done-redux";
const binName = "get-shit-done-redux";
const repoSlug = "open-gsd/get-shit-done-redux";
const repoUrl = "https://github.com/open-gsd/get-shit-done-redux";
const changelogRawUrl = "https://raw.githubusercontent.com/open-gsd/get-shit-done-redux/main/CHANGELOG.md";
module.exports = {
PACKAGE_NAME: pkg.name,
};
function formatManualInstall({ packageName, binName, scope, runtime } = {}) {
const runtimeFlag = runtime ? ` --${runtime}` : '';
return `npx -y --package=${packageName}@latest -- ${binName}${runtimeFlag} --${scope}`;
}
function manualInstallCommand(opts = {}) {
return formatManualInstall({ packageName, binName, scope: opts.scope, runtime: opts.runtime });
}
module.exports = Object.freeze({
packageName,
// PACKAGE_NAME: back-compat alias for #516-era consumers. Baked here, so it
// survives the installed tree’s synthetic package.json (fixes the #378 undefined).
PACKAGE_NAME: packageName,
binName,
repoSlug,
repoUrl,
changelogRawUrl,
manualInstallCommand,
});

View File

@@ -0,0 +1,209 @@
'use strict';
/**
* Update-context resolver (issue #498, candidate 3).
*
* Faithful Node port of the ~280-line `get_installed_version` bash step in
* `workflows/update.md`. That logic resolved the installed GSD version, the
* install scope (LOCAL / GLOBAL / UNKNOWN), the target runtime, and the config
* dir — entirely as inline bash inside an LLM prompt, untestable through its
* interface. This module makes the same cascade a pure, injected-fs function so
* the workflow shrinks to: call → compare → confirm → install.
*
* The fs is injected ({ exists, readFile }) so every precedence branch is
* testable without a live multi-runtime install. `loadUpdateContext` wires the
* real fs for the CLI.
*/
const path = require('node:path');
// Runtime -> candidate relative dir. Order matters: it is the probe order, and
// mirrors the RUNTIME_DIRS array the bash used (a runtime may have several
// candidate dirs). Kept here, not derived from the installer's getDirName,
// because update detection probes ALL historical dirs per runtime.
const RUNTIME_DIRS = [
['claude', '.claude'],
['opencode', '.config/opencode'],
['opencode', '.opencode'],
['antigravity', '.gemini/antigravity-ide'],
['antigravity', '.gemini/antigravity-cli'],
['antigravity', '.gemini/antigravity'],
['antigravity', '.agent'], // local Antigravity install dir (#503; bin/install.js getDirName('antigravity'))
['gemini', '.gemini'],
['kilo', '.config/kilo'],
['kilo', '.kilo'],
['codex', '.codex'],
];
const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
function expandHome(p, home) {
if (!p) return '';
return p.startsWith('~/') ? path.join(home, p.slice(2)) : p;
}
function versionFile(dir) { return path.join(dir, 'get-shit-done', 'VERSION'); }
function markerFile(dir) { return path.join(dir, 'get-shit-done', 'workflows', 'update.md'); }
// Detection: a dir "has GSD" if it carries a VERSION file or the update.md
// workflow marker.
function hasInstall(fs, dir) {
return fs.exists(versionFile(dir)) || fs.exists(markerFile(dir));
}
// Read VERSION at dir; return a trimmed semver string, or null if missing/invalid.
function validVersionAt(fs, dir) {
const raw = fs.readFile(versionFile(dir));
if (raw == null) return null;
const trimmed = String(raw).trim();
return SEMVER_PREFIX.test(trimmed) ? trimmed : null;
}
// A version is TRUSTED only when BOTH the VERSION file and the update.md marker
// exist (and VERSION is valid semver) — the old inline cascade required both
// (update.md ~lines 230/241). A VERSION-only or marker-only dir is a partial
// install, so its version is not trusted (caller treats it as 0.0.0 = reinstall).
// One rule, applied on every path (fast path + LOCAL/GLOBAL cascade).
function trustedVersionAt(fs, dir) {
return dir && fs.exists(markerFile(dir)) ? validVersionAt(fs, dir) : null;
}
// Infer the preferred runtime from preferredConfigDir config files, then env.
function inferPreferredRuntime({ fs, env, preferredConfigDir }) {
if (preferredConfigDir) {
if (fs.exists(path.join(preferredConfigDir, 'kilo.json')) ||
fs.exists(path.join(preferredConfigDir, 'kilo.jsonc'))) return 'kilo';
if (fs.exists(path.join(preferredConfigDir, 'opencode.json')) ||
fs.exists(path.join(preferredConfigDir, 'opencode.jsonc'))) return 'opencode';
if (fs.exists(path.join(preferredConfigDir, 'config.toml'))) return 'codex';
}
if (env.CODEX_HOME) return 'codex';
if (env.ANTIGRAVITY_CONFIG_DIR) return 'antigravity';
if (env.GEMINI_CONFIG_DIR) return 'gemini';
if (env.KILO_CONFIG_DIR || env.KILO_CONFIG) return 'kilo';
if (env.OPENCODE_CONFIG_DIR || env.OPENCODE_CONFIG) return 'opencode';
if (env.CLAUDE_CONFIG_DIR) return 'claude';
return 'claude';
}
// Absolute env-override candidates, mirroring the bash ENV_RUNTIME_DIRS block.
function envRuntimeDirs({ env, home }) {
const out = [];
const ex = (v) => expandHome(v, home);
if (env.CLAUDE_CONFIG_DIR) out.push(['claude', ex(env.CLAUDE_CONFIG_DIR)]);
if (env.ANTIGRAVITY_CONFIG_DIR) out.push(['antigravity', ex(env.ANTIGRAVITY_CONFIG_DIR)]);
if (env.GEMINI_CONFIG_DIR) out.push(['gemini', ex(env.GEMINI_CONFIG_DIR)]);
if (env.KILO_CONFIG_DIR) out.push(['kilo', ex(env.KILO_CONFIG_DIR)]);
else if (env.KILO_CONFIG) out.push(['kilo', path.dirname(ex(env.KILO_CONFIG))]);
else if (env.XDG_CONFIG_HOME) out.push(['kilo', path.join(ex(env.XDG_CONFIG_HOME), 'kilo')]);
if (env.OPENCODE_CONFIG_DIR) out.push(['opencode', ex(env.OPENCODE_CONFIG_DIR)]);
else if (env.OPENCODE_CONFIG) out.push(['opencode', path.dirname(ex(env.OPENCODE_CONFIG))]);
else if (env.XDG_CONFIG_HOME) out.push(['opencode', path.join(ex(env.XDG_CONFIG_HOME), 'opencode')]);
if (env.CODEX_HOME) out.push(['codex', ex(env.CODEX_HOME)]);
return out;
}
// Stable reorder: entries whose runtime === preferred first, original order kept.
function preferFirst(entries, preferred) {
const pref = entries.filter(([rt]) => rt === preferred);
const rest = entries.filter(([rt]) => rt !== preferred);
return [...pref, ...rest];
}
/**
* Pure resolver. Returns { installedVersion, scope, runtime, gsdDir }.
*/
function resolveUpdateContext({ home, cwd, env = {}, fs, preferredConfigDir = '', preferredRuntime = '' }) {
// Expand a leading `~/` before any probe — the old inline bash ran
// `expand_home "$PREFERRED_CONFIG_DIR"` first, and a quoted shell path never
// tilde-expands, so a custom --config-dir like `~/custom-gsd` must resolve
// here or the fast path below silently misses the install (#498 parity).
preferredConfigDir = expandHome(preferredConfigDir, home);
const preferred = preferredRuntime || inferPreferredRuntime({ fs, env, preferredConfigDir });
// Fast path: a validated preferredConfigDir (custom --config-dir install).
if (preferredConfigDir && hasInstall(fs, preferredConfigDir)) {
const resolvedPref = path.resolve(preferredConfigDir);
let scope = 'GLOBAL';
for (const [, reldir] of RUNTIME_DIRS) {
if (path.resolve(cwd, reldir) === resolvedPref) { scope = 'LOCAL'; break; }
}
return {
installedVersion: trustedVersionAt(fs, preferredConfigDir) || '0.0.0',
scope,
runtime: preferred,
gsdDir: preferredConfigDir,
};
}
const orderedEnv = preferFirst(envRuntimeDirs({ env, home }), preferred);
const orderedRuntime = preferFirst(RUNTIME_DIRS, preferred);
// LOCAL probe (relative to cwd).
let localRuntime = '', localDir = '';
for (const [rt, reldir] of orderedRuntime) {
const cand = path.resolve(cwd, reldir);
if (hasInstall(fs, cand)) { localRuntime = rt; localDir = cand; break; }
}
// GLOBAL probe: absolute env candidates first, then $HOME-relative.
let globalRuntime = '', globalDir = '';
for (const [rt, absdir] of orderedEnv) {
if (hasInstall(fs, absdir)) { globalRuntime = rt; globalDir = path.resolve(absdir); break; }
}
if (!globalRuntime) {
for (const [rt, reldir] of orderedRuntime) {
const cand = path.resolve(home, reldir);
if (hasInstall(fs, cand)) { globalRuntime = rt; globalDir = cand; break; }
}
}
const localValid = trustedVersionAt(fs, localDir);
const isLocal = !!localValid && (!globalDir || localDir !== globalDir);
if (isLocal) {
return { installedVersion: localValid, scope: 'LOCAL', runtime: localRuntime, gsdDir: localDir };
}
const globalValid = trustedVersionAt(fs, globalDir);
if (globalValid) {
return { installedVersion: globalValid, scope: 'GLOBAL', runtime: globalRuntime, gsdDir: globalDir };
}
// A runtime dir was detected (VERSION or marker present) but is not a
// complete, valid install: keep scope/runtime/dir and report 0.0.0 so the
// caller re-installs (old inline `elif [ -n "$LOCAL_DIR" ]`). Apply the same
// same-path dedup as the trusted path so cwd===home does not misdetect as LOCAL.
if (localRuntime && (!globalDir || localDir !== globalDir)) {
return { installedVersion: '0.0.0', scope: 'LOCAL', runtime: localRuntime, gsdDir: localDir };
}
if (globalRuntime) {
return { installedVersion: '0.0.0', scope: 'GLOBAL', runtime: globalRuntime, gsdDir: globalDir };
}
return { installedVersion: '0.0.0', scope: 'UNKNOWN', runtime: 'claude', gsdDir: '' };
}
/**
* CLI wiring: resolve against the real filesystem.
*/
function loadUpdateContext(opts = {}) {
const nodeFs = require('node:fs');
const fs = {
exists: (p) => nodeFs.existsSync(p),
readFile: (p) => { try { return nodeFs.readFileSync(p, 'utf8'); } catch (e) { return null; } },
};
return resolveUpdateContext({
home: opts.home || require('node:os').homedir(),
cwd: opts.cwd || process.cwd(),
env: opts.env || process.env,
fs,
preferredConfigDir: opts.preferredConfigDir || '',
preferredRuntime: opts.preferredRuntime || '',
});
}
module.exports = {
resolveUpdateContext,
loadUpdateContext,
RUNTIME_DIRS,
inferPreferredRuntime,
envRuntimeDirs,
};

View File

@@ -9,285 +9,70 @@ Read all files referenced by the invoking prompt's execution_context before star
<process>
<step name="get_installed_version">
Detect whether GSD is installed locally or globally by checking both locations and validating install integrity.
Detect the installed GSD version, scope, runtime, and config dir.
First, derive `PREFERRED_CONFIG_DIR` and `PREFERRED_RUNTIME` from the invoking prompt's `execution_context` path:
- If the path contains `/get-shit-done/workflows/update.md`, strip that suffix and store the remainder as `PREFERRED_CONFIG_DIR`
- Path contains `/.codex/` -> `codex`
- Path contains `/.gemini/antigravity-ide/` -> `antigravity`
- Path contains `/.gemini/antigravity-cli/` -> `antigravity`
- Path contains `/.gemini/antigravity/` -> `antigravity`
- Path contains `/.agent/` -> `antigravity` (local Antigravity install dir; see bin/install.js getDirName('antigravity'))
- Path contains `/.gemini/` -> `gemini`
- Path contains `/.config/kilo/` or `/.kilo/`, or `PREFERRED_CONFIG_DIR` contains `kilo.json` / `kilo.jsonc` -> `kilo`
- Path contains `/.config/opencode/` or `/.opencode/`, or `PREFERRED_CONFIG_DIR` contains `opencode.json` / `opencode.jsonc` -> `opencode`
- Otherwise -> `claude`
First, derive `PREFERRED_CONFIG_DIR` and `PREFERRED_RUNTIME` from the invoking prompt's `execution_context` path — this is the one input only the workflow knows:
- If the path contains `/get-shit-done/workflows/update.md`, strip that suffix and store the remainder as `PREFERRED_CONFIG_DIR`.
- Infer `PREFERRED_RUNTIME` from the path: `/.codex/` -> `codex`; `/.gemini/antigravity-ide/`, `/.gemini/antigravity-cli/`, `/.gemini/antigravity/`, `/.agent/` -> `antigravity` (`.agent` is the local Antigravity install dir; see bin/install.js `getDirName('antigravity')`, #503); `/.gemini/` -> `gemini`; `/.config/kilo/` or `/.kilo/` -> `kilo`; `/.config/opencode/` or `/.opencode/` -> `opencode`; otherwise `claude`.
Use `PREFERRED_CONFIG_DIR` when available so custom `--config-dir` installs are checked before default locations.
Use `PREFERRED_RUNTIME` as the first runtime checked so `/gsd:update` targets the runtime that invoked it.
Kilo config precedence must match the installer: `KILO_CONFIG_DIR` -> `dirname(KILO_CONFIG)` -> `XDG_CONFIG_HOME/kilo` -> `~/.config/kilo`.
Then resolve the install context via the deterministic projection (#498). **Do NOT re-derive scope, runtime, or version by hand** — `update-context` owns that cascade in tested code (`get-shit-done/bin/lib/update-context.cjs`), the same way `check-latest-version` owns the package name (#2992):
```bash
expand_home() {
case "$1" in
"~/"*) printf '%s/%s\n' "$HOME" "${1#~/}" ;;
*) printf '%s\n' "$1" ;;
# Resolve gsd-tools.cjs WITHOUT yet knowing GSD_DIR. The running workflow lives
# at <PREFERRED_CONFIG_DIR>/get-shit-done/workflows/update.md, so its sibling
# bin/gsd-tools.cjs is the authoritative tool for THIS install. Fall back to a
# global copy, then to gsd-tools on PATH.
GSD_TOOLS=""
for cand in \
"$PREFERRED_CONFIG_DIR/get-shit-done/bin/gsd-tools.cjs" \
"$HOME/.claude/get-shit-done/bin/gsd-tools.cjs"; do
if [ -n "$cand" ] && [ -f "$cand" ]; then GSD_TOOLS="$cand"; break; fi
done
# Last resort: the gsd-tools shim on PATH — resolved to its absolute path and
# invoked via the variable (never a bare `gsd-tools` command; see #2851).
if [ -z "$GSD_TOOLS" ] && command -v gsd-tools >/dev/null 2>&1; then
GSD_TOOLS="$(command -v gsd-tools)"
fi
UC=""
if [ -n "$GSD_TOOLS" ]; then
case "$GSD_TOOLS" in
*.cjs) UC="$(node "$GSD_TOOLS" update-context --config-dir "$PREFERRED_CONFIG_DIR" --runtime "$PREFERRED_RUNTIME" --json 2>/dev/null)" ;;
*) UC="$("$GSD_TOOLS" update-context --config-dir "$PREFERRED_CONFIG_DIR" --runtime "$PREFERRED_RUNTIME" --json 2>/dev/null)" ;;
esac
}
# Runtime candidates: "<runtime>:<config-dir>" stored as an array.
# Using an array instead of a space-separated string ensures correct
# iteration in both bash and zsh (zsh does not word-split unquoted
# variables by default). Fixes #1173.
RUNTIME_DIRS=( "claude:.claude" "opencode:.config/opencode" "opencode:.opencode" "antigravity:.gemini/antigravity-ide" "antigravity:.gemini/antigravity-cli" "antigravity:.gemini/antigravity" "antigravity:.agent" "gemini:.gemini" "kilo:.config/kilo" "kilo:.kilo" "codex:.codex" )
ENV_RUNTIME_DIRS=()
# PREFERRED_CONFIG_DIR / PREFERRED_RUNTIME should be set from execution_context
# before running this block.
if [ -n "$PREFERRED_CONFIG_DIR" ]; then
PREFERRED_CONFIG_DIR="$(expand_home "$PREFERRED_CONFIG_DIR")"
if [ -z "$PREFERRED_RUNTIME" ]; then
if [ -f "$PREFERRED_CONFIG_DIR/kilo.json" ] || [ -f "$PREFERRED_CONFIG_DIR/kilo.jsonc" ]; then
PREFERRED_RUNTIME="kilo"
elif [ -f "$PREFERRED_CONFIG_DIR/opencode.json" ] || [ -f "$PREFERRED_CONFIG_DIR/opencode.jsonc" ]; then
PREFERRED_RUNTIME="opencode"
elif [ -f "$PREFERRED_CONFIG_DIR/config.toml" ]; then
PREFERRED_RUNTIME="codex"
fi
fi
fi
# If runtime is still unknown, infer from runtime env vars; fallback to claude.
if [ -z "$PREFERRED_RUNTIME" ]; then
if [ -n "$CODEX_HOME" ]; then
PREFERRED_RUNTIME="codex"
elif [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then
PREFERRED_RUNTIME="antigravity"
elif [ -n "$GEMINI_CONFIG_DIR" ]; then
PREFERRED_RUNTIME="gemini"
elif [ -n "$KILO_CONFIG_DIR" ]; then
PREFERRED_RUNTIME="kilo"
elif [ -n "$KILO_CONFIG" ]; then
PREFERRED_RUNTIME="kilo"
elif [ -n "$OPENCODE_CONFIG_DIR" ] || [ -n "$OPENCODE_CONFIG" ]; then
PREFERRED_RUNTIME="opencode"
elif [ -n "$CLAUDE_CONFIG_DIR" ]; then
PREFERRED_RUNTIME="claude"
else
PREFERRED_RUNTIME="claude"
fi
fi
# If execution_context already points at an installed config dir, trust it first.
# This covers custom --config-dir installs that do not live under the default
# runtime directories.
if [ -n "$PREFERRED_CONFIG_DIR" ] && { [ -f "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION" ] || [ -f "$PREFERRED_CONFIG_DIR/get-shit-done/workflows/update.md" ]; }; then
INSTALL_SCOPE="GLOBAL"
# Normalize a path for comparison: on Windows with Git Bash, pwd returns
# POSIX-style /c/Users/... but PREFERRED_CONFIG_DIR may carry C:/Users/...
# Convert Windows drive-letter paths to POSIX form so the comparison works
# on both Windows (Git Bash) and POSIX systems.
normalize_path() {
local p="$1"
case "$p" in
[A-Za-z]:/*)
local drive rest
drive="${p%%:*}"
rest="${p#?:}"
p="/$(printf '%s' "$drive" | tr '[:upper:]' '[:lower:]')$rest"
;;
esac
printf '%s' "$p"
}
normalized_preferred="$(normalize_path "$PREFERRED_CONFIG_DIR")"
for dir in .claude .config/opencode .opencode .gemini/antigravity-ide .gemini/antigravity-cli .gemini/antigravity .agent .gemini .config/kilo .kilo .codex; do
resolved_local="$(cd "./$dir" 2>/dev/null && pwd)"
normalized_local="$(normalize_path "$resolved_local")"
if [ -n "$normalized_local" ] && [ "$normalized_local" = "$normalized_preferred" ]; then
INSTALL_SCOPE="LOCAL"
break
fi
done
if [ -f "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION" ] && grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION"; then
INSTALLED_VERSION="$(cat "$PREFERRED_CONFIG_DIR/get-shit-done/VERSION")"
else
INSTALLED_VERSION="0.0.0"
fi
echo "$INSTALLED_VERSION"
echo "$INSTALL_SCOPE"
echo "${PREFERRED_RUNTIME:-claude}"
# 4-line output contract (#2993 CR): early-return path must also emit
# GSD_DIR or downstream check_latest_version misreads the install as
# UNKNOWN. PREFERRED_CONFIG_DIR is the resolved config dir we just
# validated above (line 95-96); it is the right GSD_DIR value for
# this fast path.
echo "$PREFERRED_CONFIG_DIR"
exit 0
fi
# Absolute global candidates from env overrides (covers custom config dirs).
if [ -n "$CLAUDE_CONFIG_DIR" ]; then
ENV_RUNTIME_DIRS+=( "claude:$(expand_home "$CLAUDE_CONFIG_DIR")" )
fi
if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then
ENV_RUNTIME_DIRS+=( "antigravity:$(expand_home "$ANTIGRAVITY_CONFIG_DIR")" )
fi
if [ -n "$GEMINI_CONFIG_DIR" ]; then
ENV_RUNTIME_DIRS+=( "gemini:$(expand_home "$GEMINI_CONFIG_DIR")" )
fi
if [ -n "$KILO_CONFIG_DIR" ]; then
ENV_RUNTIME_DIRS+=( "kilo:$(expand_home "$KILO_CONFIG_DIR")" )
elif [ -n "$KILO_CONFIG" ]; then
ENV_RUNTIME_DIRS+=( "kilo:$(dirname "$(expand_home "$KILO_CONFIG")")" )
elif [ -n "$XDG_CONFIG_HOME" ]; then
ENV_RUNTIME_DIRS+=( "kilo:$(expand_home "$XDG_CONFIG_HOME")/kilo" )
fi
if [ -n "$OPENCODE_CONFIG_DIR" ]; then
ENV_RUNTIME_DIRS+=( "opencode:$(expand_home "$OPENCODE_CONFIG_DIR")" )
elif [ -n "$OPENCODE_CONFIG" ]; then
ENV_RUNTIME_DIRS+=( "opencode:$(dirname "$(expand_home "$OPENCODE_CONFIG")")" )
elif [ -n "$XDG_CONFIG_HOME" ]; then
ENV_RUNTIME_DIRS+=( "opencode:$(expand_home "$XDG_CONFIG_HOME")/opencode" )
fi
if [ -n "$CODEX_HOME" ]; then
ENV_RUNTIME_DIRS+=( "codex:$(expand_home "$CODEX_HOME")" )
fi
# Reorder entries so preferred runtime is checked first.
ORDERED_RUNTIME_DIRS=()
for entry in "${RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
if [ "$runtime" = "$PREFERRED_RUNTIME" ]; then
ORDERED_RUNTIME_DIRS+=( "$entry" )
fi
done
ORDERED_ENV_RUNTIME_DIRS=()
for entry in "${ENV_RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
if [ "$runtime" = "$PREFERRED_RUNTIME" ]; then
ORDERED_ENV_RUNTIME_DIRS+=( "$entry" )
fi
done
for entry in "${ENV_RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
if [ "$runtime" != "$PREFERRED_RUNTIME" ]; then
ORDERED_ENV_RUNTIME_DIRS+=( "$entry" )
fi
done
for entry in "${RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
if [ "$runtime" != "$PREFERRED_RUNTIME" ]; then
ORDERED_RUNTIME_DIRS+=( "$entry" )
fi
done
# Check local first (takes priority only if valid and distinct from global)
LOCAL_VERSION_FILE="" LOCAL_MARKER_FILE="" LOCAL_DIR="" LOCAL_RUNTIME=""
for entry in "${ORDERED_RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
dir="${entry#*:}"
if [ -f "./$dir/get-shit-done/VERSION" ] || [ -f "./$dir/get-shit-done/workflows/update.md" ]; then
LOCAL_RUNTIME="$runtime"
LOCAL_VERSION_FILE="./$dir/get-shit-done/VERSION"
LOCAL_MARKER_FILE="./$dir/get-shit-done/workflows/update.md"
LOCAL_DIR="$(cd "./$dir" 2>/dev/null && pwd)"
break
fi
done
GLOBAL_VERSION_FILE="" GLOBAL_MARKER_FILE="" GLOBAL_DIR="" GLOBAL_RUNTIME=""
for entry in "${ORDERED_ENV_RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
dir="${entry#*:}"
if [ -f "$dir/get-shit-done/VERSION" ] || [ -f "$dir/get-shit-done/workflows/update.md" ]; then
GLOBAL_RUNTIME="$runtime"
GLOBAL_VERSION_FILE="$dir/get-shit-done/VERSION"
GLOBAL_MARKER_FILE="$dir/get-shit-done/workflows/update.md"
GLOBAL_DIR="$(cd "$dir" 2>/dev/null && pwd)"
break
fi
done
if [ -z "$GLOBAL_RUNTIME" ]; then
for entry in "${ORDERED_RUNTIME_DIRS[@]}"; do
runtime="${entry%%:*}"
dir="${entry#*:}"
if [ -f "$HOME/$dir/get-shit-done/VERSION" ] || [ -f "$HOME/$dir/get-shit-done/workflows/update.md" ]; then
GLOBAL_RUNTIME="$runtime"
GLOBAL_VERSION_FILE="$HOME/$dir/get-shit-done/VERSION"
GLOBAL_MARKER_FILE="$HOME/$dir/get-shit-done/workflows/update.md"
GLOBAL_DIR="$(cd "$HOME/$dir" 2>/dev/null && pwd)"
break
fi
done
fi
# Only treat as LOCAL if the resolved paths differ (prevents misdetection when CWD=$HOME)
IS_LOCAL=false
if [ -n "$LOCAL_VERSION_FILE" ] && [ -f "$LOCAL_VERSION_FILE" ] && [ -f "$LOCAL_MARKER_FILE" ] && grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' "$LOCAL_VERSION_FILE"; then
if [ -z "$GLOBAL_DIR" ] || [ "$LOCAL_DIR" != "$GLOBAL_DIR" ]; then
IS_LOCAL=true
fi
fi
if [ "$IS_LOCAL" = true ]; then
INSTALLED_VERSION="$(cat "$LOCAL_VERSION_FILE")"
INSTALL_SCOPE="LOCAL"
TARGET_RUNTIME="$LOCAL_RUNTIME"
RESOLVED_GSD_DIR="$LOCAL_DIR"
elif [ -n "$GLOBAL_VERSION_FILE" ] && [ -f "$GLOBAL_VERSION_FILE" ] && [ -f "$GLOBAL_MARKER_FILE" ] && grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+' "$GLOBAL_VERSION_FILE"; then
INSTALLED_VERSION="$(cat "$GLOBAL_VERSION_FILE")"
INSTALL_SCOPE="GLOBAL"
TARGET_RUNTIME="$GLOBAL_RUNTIME"
RESOLVED_GSD_DIR="$GLOBAL_DIR"
elif [ -n "$LOCAL_RUNTIME" ] && [ -f "$LOCAL_MARKER_FILE" ]; then
# Runtime detected but VERSION missing/corrupt: treat as unknown version, keep runtime target
INSTALLED_VERSION="0.0.0"
INSTALL_SCOPE="LOCAL"
TARGET_RUNTIME="$LOCAL_RUNTIME"
RESOLVED_GSD_DIR="$LOCAL_DIR"
elif [ -n "$GLOBAL_RUNTIME" ] && [ -f "$GLOBAL_MARKER_FILE" ]; then
INSTALLED_VERSION="0.0.0"
INSTALL_SCOPE="GLOBAL"
TARGET_RUNTIME="$GLOBAL_RUNTIME"
RESOLVED_GSD_DIR="$GLOBAL_DIR"
if [ -n "$UC" ]; then
INSTALLED_VERSION="$(printf '%s' "$UC" | jq -r '.installedVersion')"
INSTALL_SCOPE="$(printf '%s' "$UC" | jq -r '.scope')"
TARGET_RUNTIME="$(printf '%s' "$UC" | jq -r '.runtime')"
GSD_DIR="$(printf '%s' "$UC" | jq -r '.gsdDir')"
else
# No tool resolvable / projection failed -> treat as a fresh install.
INSTALLED_VERSION="0.0.0"
INSTALL_SCOPE="UNKNOWN"
TARGET_RUNTIME="claude"
RESOLVED_GSD_DIR=""
GSD_DIR=""
fi
echo "$INSTALLED_VERSION"
echo "$INSTALL_SCOPE"
echo "$TARGET_RUNTIME"
echo "$RESOLVED_GSD_DIR"
echo "$GSD_DIR"
```
Parse output:
- Line 1 = installed version (`0.0.0` means unknown version)
- Line 2 = install scope (`LOCAL`, `GLOBAL`, or `UNKNOWN`)
- Line 3 = target runtime (`claude`, `opencode`, `gemini`, `kilo`, or `codex`)
- Line 4 = resolved GSD config dir (e.g. `/Users/me/.claude`, `/Users/me/.gemini`); empty if scope is `UNKNOWN`. Capture this as `GSD_DIR` and pass it to subsequent steps so they don't have to re-derive the runtime path.
- If scope is `UNKNOWN`, proceed to install step using `--claude --global` fallback.
- Line 3 = target runtime (`claude`, `opencode`, `gemini`, `kilo`, `codex`, `antigravity`)
- Line 4 = resolved GSD config dir (e.g. `/Users/me/.claude`, `/Users/me/.gemini`); empty if scope is `UNKNOWN`. Capture this as `GSD_DIR` and pass it to subsequent steps so they don't re-derive the runtime path.
- If scope is `UNKNOWN`, proceed to install using the `--claude --global` fallback.
`update-context` reproduces the previous detection cascade — preferred-config-dir fast path, local-over-global with same-path dedup (so `CWD=$HOME` does not misdetect as LOCAL), env-var overrides (`CLAUDE_CONFIG_DIR`, `OPENCODE_CONFIG_DIR`, `KILO_CONFIG`, `XDG_CONFIG_HOME`, `CODEX_HOME`, …), and semver validation — but as a tested projection rather than ~280 lines of inline bash. Branch coverage lives in `tests/issue-498-update-context.test.cjs`.
If multiple runtime installs are detected and the invoking runtime cannot be determined from execution_context, ask the user which runtime to update before running install.
**If VERSION file missing:**
```
## GSD Update
**Installed version:** Unknown
Your installation doesn't include version tracking.
Running fresh install...
```
Proceed to install step (treat as version 0.0.0 for comparison).
**If VERSION file missing (version resolves to `0.0.0`):** report the installed version as Unknown and proceed to install (treated as `0.0.0` for comparison).
</step>
<step name="check_latest_version">
@@ -466,16 +251,10 @@ First, resolve the config directory (`RUNTIME_DIR`) from the install scope
detected in `get_installed_version`:
```bash
# RUNTIME_DIR is the resolved config directory (e.g. ~/.config/opencode, ~/.gemini)
# It should already be set from get_installed_version as GLOBAL_DIR or LOCAL_DIR.
# Use the appropriate variable based on INSTALL_SCOPE.
if [ "$INSTALL_SCOPE" = "LOCAL" ]; then
RUNTIME_DIR="$LOCAL_DIR"
elif [ "$INSTALL_SCOPE" = "GLOBAL" ]; then
RUNTIME_DIR="$GLOBAL_DIR"
else
RUNTIME_DIR=""
fi
# RUNTIME_DIR is the resolved config directory (e.g. ~/.config/opencode, ~/.gemini).
# get_installed_version emits it as GSD_DIR (LOCAL or GLOBAL install dir, or empty
# when scope is UNKNOWN). Empty RUNTIME_DIR skips the backup below.
RUNTIME_DIR="$GSD_DIR"
```
If `RUNTIME_DIR` is empty or does not exist, skip this step (no config dir to

View File

@@ -11,11 +11,14 @@
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { isSemverNewer } = require('../get-shit-done/bin/lib/semver-compare.cjs');
// Derive the published package name from package.json so this survives
// future renames and always matches the actual registry entry (#378).
const PACKAGE_NAME = require('../package.json').name;
// Latest-version lookup is delegated to the single deterministic adapter
// (#498). checkLatestVersion() owns the npm-view call, the timeout/semver
// policy, and the package name — sourced from the baked Package Identity seam.
// The previous `require('../package.json').name` (#378) resolved to undefined
// in the installed tree (only a {"type":"commonjs"} marker ships), so the
// background check never reported updates.
const { checkLatestVersion } = require('../get-shit-done/bin/check-latest-version.cjs');
// Authoritative list of managed hooks — shared with tests to retire source-grep
// assertions (pending-migration-to-typed-ir [#455]).
const { MANAGED_HOOKS } = require('./managed-hooks-registry.cjs');
@@ -69,20 +72,14 @@ if (configDir) {
} catch (e) {}
}
// Single adapter for the registry lookup (#498). checkLatestVersion() routes
// through the shell-projection seam, which already owns the Windows shell-flag
// policy, the timeout, and semver validation. A non-ok result leaves latest
// null, exactly as the previous inline try/catch did.
let latest = null;
try {
latest = execFileSync('npm', ['view', PACKAGE_NAME, 'version'], {
encoding: 'utf8',
timeout: 10000,
windowsHide: true,
// On Windows, 'npm' is distributed as npm.cmd. Node's execFileSync does
// not apply PATHEXT resolution and looks for a literal 'npm' binary,
// failing with ENOENT. Setting shell:true on Windows routes through
// cmd.exe which resolves npm.cmd via PATHEXT.
// POSIX (Linux/macOS) is left untouched — no shell spawn, no extra
// signal/exit-code semantics, no overhead.
shell: process.platform === 'win32',
}).trim();
const lv = checkLatestVersion();
if (lv && lv.ok) latest = lv.version;
} catch (e) {}
const result = {

View File

@@ -67,9 +67,11 @@
"sync:launcher": "node scripts/sync-runtime-launcher.cjs",
"check:env": "node scripts/check-env.cjs",
"check:alias-drift": "node scripts/check-alias-drift.cjs",
"check:identity-drift": "node scripts/lint-package-identity-drift.cjs",
"check:integrity": "node scripts/check-npm-integrity.cjs",
"build": "npm run build:hooks",
"build": "npm run generate:identity && npm run build:hooks",
"build:hooks": "node scripts/build-hooks.js",
"generate:identity": "node scripts/generate-package-identity.cjs",
"prepublishOnly": "npm run build:hooks",
"pretest": "npm run lint:skill-deps",
"pretest:coverage": "npm run lint:skill-deps",

View File

@@ -25,7 +25,7 @@ const {
compareSemverCore,
isStableTripletSemver,
} = require('../../get-shit-done/bin/lib/semver-compare.cjs');
const { PACKAGE_NAME } = require('../../get-shit-done/bin/lib/package-identity.cjs');
const { packageName, repoSlug: defaultRepoSlug } = require('../../get-shit-done/bin/lib/package-identity.cjs');
function parseArgs(argv) {
const opts = {
@@ -37,8 +37,8 @@ function parseArgs(argv) {
toRef: null,
changelog: null,
output: null,
repoSlug: 'open-gsd/get-shit-done-redux',
installCommand: `npx ${PACKAGE_NAME}@latest`,
repoSlug: defaultRepoSlug,
installCommand: `npx ${packageName}@latest`,
json: false,
};
if (argv.length === 0) return { ok: true, opts };

View File

@@ -4,7 +4,7 @@ const cp = require('node:child_process');
const path = require('node:path');
const { parseFragment } = require('./parse.cjs');
const { PACKAGE_NAME } = require('../../get-shit-done/bin/lib/package-identity.cjs');
const { packageName, repoSlug: defaultRepoSlug } = require('../../get-shit-done/bin/lib/package-identity.cjs');
const SECTION_ORDER = ['Fixed', 'Added', 'Changed', 'Deprecated', 'Removed', 'Security'];
@@ -146,8 +146,8 @@ function serializeGithubReleaseNotes({
ir,
fromRef,
toRef,
repoSlug = 'open-gsd/get-shit-done-redux',
installCommand = `npx ${PACKAGE_NAME}@latest`,
repoSlug = defaultRepoSlug,
installCommand = `npx ${packageName}@latest`,
}) {
if (installCommand.includes('`')) {
throw new Error('installCommand cannot contain backtick characters');

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env node
'use strict';
/**
* Single source for GSD's published-package coordinates (issue #498).
*
* `deriveIdentity(pkg)` is the pure core: it turns a parsed package.json into
* the coordinate record every consumer needs. The generated runtime module
* `get-shit-done/bin/lib/package-identity.cjs` bakes those values at build
* time, because the installed tree carries only a synthetic
* `{"type":"commonjs"}` package.json (no `.name`) — so a runtime
* `require('package.json').name` resolves to `undefined` (the #378 bug this
* seam retires). Baking from package.json reconciles #378 (renames survive)
* with #2992 (the value is never an LLM runtime choice).
*/
/**
* Parse `owner/name` out of a package.json `repository.url`, stripping the
* `git+` prefix and `.git` suffix npm conventionally adds.
*/
function parseRepoSlug(repository) {
const url = typeof repository === 'string' ? repository : (repository && repository.url) || '';
const m = url.replace(/^git\+/, '').replace(/\.git$/, '').match(/github\.com[/:]([^/]+\/[^/]+)$/);
return m ? m[1] : '';
}
/**
* Pure: package.json object -> the package identity coordinates.
*/
function deriveIdentity(pkg = {}) {
const packageName = pkg.name || '';
const binName = pkg.bin ? Object.keys(pkg.bin)[0] || '' : '';
const repoSlug = parseRepoSlug(pkg.repository);
const repoUrl = repoSlug ? `https://github.com/${repoSlug}` : '';
const changelogRawUrl = repoSlug
? `https://raw.githubusercontent.com/${repoSlug}/main/CHANGELOG.md`
: '';
return { packageName, binName, repoSlug, repoUrl, changelogRawUrl };
}
/**
* Pure: format the `npx` fallback install command. Shape matches the literal
* the update workflow embeds: `npx -y --package=<pkg>@latest -- <bin>
* [--<runtime>] --<scope>`. The runtime flag is omitted when not supplied.
*
* This function is the canonical source — `render()` serializes it verbatim
* into the generated module, so the runtime copy can never drift from it.
*/
function formatManualInstall({ packageName, binName, scope, runtime } = {}) {
const runtimeFlag = runtime ? ` --${runtime}` : '';
return `npx -y --package=${packageName}@latest -- ${binName}${runtimeFlag} --${scope}`;
}
const GENERATED_HEADER =
'// @generated by scripts/generate-package-identity.cjs from package.json — DO NOT EDIT.\n' +
'// Single source for GSD package coordinates (issue #498). Regenerate with:\n' +
'// node scripts/generate-package-identity.cjs\n';
/**
* Render the generated runtime module text for a derived identity. Values are
* baked as literals; `formatManualInstall` is embedded by `.toString()` so the
* runtime command builder is byte-identical to the tested source above.
*/
function render(identity) {
const { packageName, binName, repoSlug, repoUrl, changelogRawUrl } = identity;
const j = (v) => JSON.stringify(v);
return (
GENERATED_HEADER +
"'use strict';\n\n" +
`const packageName = ${j(packageName)};\n` +
`const binName = ${j(binName)};\n` +
`const repoSlug = ${j(repoSlug)};\n` +
`const repoUrl = ${j(repoUrl)};\n` +
`const changelogRawUrl = ${j(changelogRawUrl)};\n\n` +
`${formatManualInstall.toString()}\n\n` +
'function manualInstallCommand(opts = {}) {\n' +
' return formatManualInstall({ packageName, binName, scope: opts.scope, runtime: opts.runtime });\n' +
'}\n\n' +
'module.exports = Object.freeze({\n' +
' packageName,\n' +
' // PACKAGE_NAME: back-compat alias for #516-era consumers. Baked here, so it\n' +
' // survives the installed tree’s synthetic package.json (fixes the #378 undefined).\n' +
' PACKAGE_NAME: packageName,\n' +
' binName,\n' +
' repoSlug,\n' +
' repoUrl,\n' +
' changelogRawUrl,\n' +
' manualInstallCommand,\n' +
'});\n'
);
}
function main() {
const fs = require('node:fs');
const path = require('node:path');
const pkg = require(path.join(__dirname, '..', 'package.json'));
const out = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'package-identity.cjs');
fs.writeFileSync(out, render(deriveIdentity(pkg)));
process.stdout.write(`wrote ${path.relative(path.join(__dirname, '..'), out)}\n`);
}
if (require.main === module) main();
module.exports = { deriveIdentity, parseRepoSlug, formatManualInstall, render, main };

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
'use strict';
/**
* Drift-guard lint for the Package Identity seam (issue #498).
*
* The seam (`get-shit-done/bin/lib/package-identity.cjs`, derived from
* package.json) is the single source of GSD's published coordinates. Many
* runtime surfaces still carry a literal copy of those coordinates because
* they cannot `require()` the seam at runtime: the bash launcher snippet (and
* its byte-equal copies across ~85 workflows, kept in lockstep by the
* runtime-launcher parity test) and the installer's user-facing install/help
* strings.
*
* This lint makes those literals *value-checked*: every GSD package/repo
* coordinate that appears as a literal must equal the seam's current value.
* It passes today (the literals are correct) and FAILS the moment a repoint is
* not propagated — rename package.json, regenerate the seam, and every stale
* literal is reported until updated. That is what turns a repoint into a
* one-line change with mechanical enforcement.
*
* Scope: the runtime/code surface (bin/, hooks/, scripts/, get-shit-done/).
* Pure-prose docs and localized READMEs are intentionally out of scope.
*/
const fs = require('node:fs');
const path = require('node:path');
// A GSD package coordinate: a scoped npm name whose package part contains
// "get-shit-done" (so @opengsd/gsd-sdk and unrelated scopes never match).
const PACKAGE_RE = /@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]*get-shit-done[A-Za-z0-9._-]*/g;
// A GSD repo slug, only inside a GitHub URL context so it never overlaps the
// scoped package literal above. The `.git` suffix is trimmed before compare.
const SLUG_RE = /(?:github\.com[/:]|raw\.githubusercontent\.com\/)([A-Za-z0-9._-]+\/[A-Za-z0-9._-]*get-shit-done[A-Za-z0-9._-]*)/g;
function lineOf(text, index) {
let line = 1;
for (let i = 0; i < index && i < text.length; i++) {
if (text[i] === '\n') line++;
}
return line;
}
/**
* Pure: find every GSD coordinate literal in `text` that does not match the
* expected seam values. Returns [{ kind, found, expected, line }].
*/
function findCoordinateDrift(text, { packageName, repoSlug }) {
const out = [];
for (const m of text.matchAll(PACKAGE_RE)) {
if (m[0] !== packageName) {
out.push({ kind: 'package', found: m[0], expected: packageName, line: lineOf(text, m.index) });
}
}
for (const m of text.matchAll(SLUG_RE)) {
const slug = m[1].replace(/\.git$/, '');
if (slug !== repoSlug) {
out.push({ kind: 'slug', found: slug, expected: repoSlug, line: lineOf(text, m.index) });
}
}
return out;
}
// Directories scanned, relative to repo root.
const SCAN_DIRS = ['bin', 'hooks', 'scripts', 'get-shit-done'];
const SCAN_EXT = new Set(['.js', '.cjs', '.sh', '.md']);
// Files exempt because they ARE the source of truth / the tooling that defines
// the coordinate patterns. The generated seam holds the correct value by
// construction; the generator and this lint carry regex/templates, not stray
// literals.
const EXEMPT = new Set([
path.join('get-shit-done', 'bin', 'lib', 'package-identity.cjs'),
path.join('scripts', 'generate-package-identity.cjs'),
path.join('scripts', 'lint-package-identity-drift.cjs'),
]);
function walk(dir, acc) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return acc;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
walk(full, acc);
} else if (entry.isFile() && SCAN_EXT.has(path.extname(entry.name))) {
acc.push(full);
}
}
return acc;
}
/**
* Scan the repo's runtime/code surface and return all coordinate drift, each
* annotated with the repo-relative file path.
*/
function scanRepo(root) {
const seam = require(path.join(root, 'get-shit-done', 'bin', 'lib', 'package-identity.cjs'));
const expected = { packageName: seam.packageName, repoSlug: seam.repoSlug };
const violations = [];
for (const dir of SCAN_DIRS) {
const files = walk(path.join(root, dir), []);
for (const file of files) {
const rel = path.relative(root, file);
if (EXEMPT.has(rel)) continue;
let text;
try {
text = fs.readFileSync(file, 'utf8');
} catch (e) {
continue;
}
for (const d of findCoordinateDrift(text, expected)) {
violations.push({ file: rel, ...d });
}
}
}
return violations;
}
function main() {
const root = path.join(__dirname, '..');
const violations = scanRepo(root);
if (violations.length === 0) {
process.stdout.write('ok identity-drift: all GSD coordinate literals match the seam\n');
return;
}
process.stderr.write('identity-drift: stale GSD coordinate literal(s) found.\n');
process.stderr.write('Repoint by editing package.json, then `node scripts/generate-package-identity.cjs`,\n');
process.stderr.write('and update the value-checked materialization sites below:\n');
for (const d of violations) {
process.stderr.write(` ${d.file}:${d.line} ${d.kind} '${d.found}' != '${d.expected}'\n`);
}
process.exitCode = 1;
}
if (require.main === module) main();
module.exports = { findCoordinateDrift, scanRepo };

View File

@@ -1,204 +1,111 @@
// allow-test-rule: source-text-is-the-product
// update.md is loaded verbatim by the runtime as the /gsd-update workflow.
// The bash blocks inside it ARE the deployed program — the agent runs them.
// Asserting on the structural shape of those bash arrays is asserting on the
// deployed contract, identical to asserting on a workflow's instructions.
/**
* Bug #3608: get-shit-done/workflows/update.md does not model Antigravity as
* a first-class runtime, so /gsd-update invoked from an Antigravity install
* (~/.gemini/antigravity) classifies the runtime as base Gemini.
* Bug #3608: /gsd:update must model Antigravity as a first-class runtime, so an
* Antigravity install (~/.gemini/antigravity*) is not misclassified as base
* Gemini.
*
* The installer (bin/install.js) and SDK already treat Antigravity as a
* distinct runtime with its own config dir (~/.gemini/antigravity), env var
* (ANTIGRAVITY_CONFIG_DIR), and CLI flag (--antigravity). update.md must
* agree, or /gsd-update routes Antigravity installs through the base Gemini
* path.
* The installer (bin/install.js) and SDK already treat Antigravity as a distinct
* runtime with its own config dirs, env var (ANTIGRAVITY_CONFIG_DIR), and CLI
* flag (--antigravity). The update flow must agree.
*
* Order matters: every bash array / env-var ladder / scan list that contains
* a Gemini entry MUST list the more-specific Antigravity entry first.
* Relocation (#498): the update flow's runtime/scope detection moved out of
* ~280 lines of inline bash in update.md into the tested projection
* `get-shit-done/bin/lib/update-context.cjs` (resolveUpdateContext). The
* antigravity-first-class contract now lives there as data + behavior, so this
* test asserts it on the projection. The only piece still authored in update.md
* is the execution_context path classification (prose the agent applies), which
* this test still checks for antigravity-before-gemini ordering.
*
* Order matters: every probe list / env ladder that contains a Gemini entry
* MUST place the more-specific Antigravity entry first, else an install with
* both signals present falls through to gemini.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const UPDATE_MD = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md');
const ROOT = path.join(__dirname, '..');
const {
RUNTIME_DIRS,
inferPreferredRuntime,
envRuntimeDirs,
resolveUpdateContext,
} = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'update-context.cjs'));
const UPDATE_MD = path.join(ROOT, 'get-shit-done', 'workflows', 'update.md');
function readUpdateMd() {
return fs.readFileSync(UPDATE_MD, 'utf-8');
function runtimeOrder() {
return RUNTIME_DIRS.map(([rt]) => rt);
}
// Parse a single bash array literal from a line like:
// RUNTIME_DIRS=( "claude:.claude" "gemini:.gemini" ... )
// Returns the entries as an ordered list of "runtime:dir" strings.
function parseBashArray(content, varName) {
const re = new RegExp(`${varName}=\\(\\s*([^)]*?)\\s*\\)`, 'm');
const m = content.match(re);
if (!m) return null;
return [...m[1].matchAll(/"([^"]+)"/g)].map((mm) => mm[1]);
}
// Extract the runtime tokens (the part before ':') in declaration order.
function runtimeTokens(entries) {
return entries.map((e) => e.split(':')[0]);
}
function firstIndex(arr, token) {
return arr.indexOf(token);
}
describe('bug #3608: update.md models Antigravity as a first-class runtime', () => {
const content = readUpdateMd();
test('RUNTIME_DIRS contains antigravity before gemini', () => {
const entries = parseBashArray(content, 'RUNTIME_DIRS');
assert.ok(entries, 'RUNTIME_DIRS array literal not found in update.md');
const tokens = runtimeTokens(entries);
const antIdx = firstIndex(tokens, 'antigravity');
const gemIdx = firstIndex(tokens, 'gemini');
assert.notStrictEqual(antIdx, -1, 'RUNTIME_DIRS missing antigravity entry');
assert.notStrictEqual(gemIdx, -1, 'RUNTIME_DIRS missing gemini entry');
assert.ok(
antIdx < gemIdx,
`antigravity must precede gemini in RUNTIME_DIRS (got antigravity@${antIdx}, gemini@${gemIdx}). ` +
`Order matters: classification iterates this list and the first match wins.`,
);
describe('bug #3608 / #498: update-context models Antigravity as a first-class runtime', () => {
test('RUNTIME_DIRS lists antigravity before gemini', () => {
const order = runtimeOrder();
const antIdx = firstIndex(order, 'antigravity');
const gemIdx = firstIndex(order, 'gemini');
assert.notStrictEqual(antIdx, -1, 'RUNTIME_DIRS missing antigravity');
assert.notStrictEqual(gemIdx, -1, 'RUNTIME_DIRS missing gemini');
assert.ok(antIdx < gemIdx, `antigravity (@${antIdx}) must precede gemini (@${gemIdx}) — first match wins`);
});
test('RUNTIME_DIRS includes antigravity entries for 2.x + legacy dirs', () => {
const entries = parseBashArray(content, 'RUNTIME_DIRS');
assert.ok(entries);
const antEntries = entries.filter((e) => e.startsWith('antigravity:'));
assert.ok(antEntries.length >= 1, 'antigravity entry missing');
assert.ok(
antEntries.includes('antigravity:.gemini/antigravity-ide'),
'RUNTIME_DIRS must include antigravity:.gemini/antigravity-ide',
);
assert.ok(
antEntries.includes('antigravity:.gemini/antigravity-cli'),
'RUNTIME_DIRS must include antigravity:.gemini/antigravity-cli',
);
assert.ok(
antEntries.includes('antigravity:.gemini/antigravity'),
'RUNTIME_DIRS must include legacy antigravity:.gemini/antigravity fallback',
);
});
test('PREFERRED_RUNTIME env-var inference recognizes ANTIGRAVITY_CONFIG_DIR before GEMINI_CONFIG_DIR', () => {
// Extract the inference block — the if/elif ladder that maps env vars to runtime.
// Match from the comment marker through the closing `fi` of the inference block.
const blockMatch = content.match(
/If runtime is still unknown, infer from runtime env vars[\s\S]*?\r?\nfi\r?\n/,
);
assert.ok(blockMatch, 'env-var inference block not found');
const block = blockMatch[0];
const antPos = block.indexOf('ANTIGRAVITY_CONFIG_DIR');
const gemPos = block.indexOf('GEMINI_CONFIG_DIR');
assert.notStrictEqual(
antPos,
-1,
'env-var inference must check ANTIGRAVITY_CONFIG_DIR (used by bin/install.js)',
);
assert.notStrictEqual(gemPos, -1, 'env-var inference must check GEMINI_CONFIG_DIR');
assert.ok(
antPos < gemPos,
`ANTIGRAVITY_CONFIG_DIR must be checked before GEMINI_CONFIG_DIR ` +
`(got antigravity@${antPos}, gemini@${gemPos}) — otherwise an Antigravity ` +
`install with both env vars set falls through to gemini.`,
);
});
test('ENV_RUNTIME_DIRS appends an antigravity entry when ANTIGRAVITY_CONFIG_DIR is set', () => {
// Match the `if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then` block and require an
// `ENV_RUNTIME_DIRS+=( "antigravity:..." )` push inside it. The pushed value
// may contain nested `"$VAR"` quotes (bash command substitution), so we
// don't try to match the full string literal — just the leading runtime
// tag `"antigravity:`.
const re = /if \[ -n "\$ANTIGRAVITY_CONFIG_DIR" \];\s*then\s+ENV_RUNTIME_DIRS\+=\(\s*"antigravity:/;
assert.match(
content,
re,
'expected `if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then ENV_RUNTIME_DIRS+=( "antigravity:..." )`',
);
});
test('local-scope scan dir list includes 2.x antigravity dirs before .gemini', () => {
// Lines like: for dir in .claude .config/opencode .opencode .gemini ...
// The first iteration of the local scan ranges over a hardcoded list.
const forLoops = [...content.matchAll(/for dir in ([^;]+); do/g)];
assert.ok(forLoops.length > 0, 'no `for dir in ...; do` scan loops found');
for (const m of forLoops) {
const tokens = m[1].trim().split(/\s+/);
const antLegacyIdx = tokens.indexOf('.gemini/antigravity');
const antIdeIdx = tokens.indexOf('.gemini/antigravity-ide');
const antCliIdx = tokens.indexOf('.gemini/antigravity-cli');
const gemIdx = tokens.indexOf('.gemini');
if (gemIdx === -1) continue; // scan loop without .gemini — irrelevant
assert.notStrictEqual(
antIdeIdx,
-1,
`scan loop "for dir in ${m[1].trim()}" mentions .gemini but not .gemini/antigravity-ide — ` +
`the more-specific Antigravity 2.x dir must be present`,
);
assert.ok(
antIdeIdx < gemIdx,
`scan loop "for dir in ${m[1].trim()}": .gemini/antigravity-ide (idx ${antIdeIdx}) must precede .gemini (idx ${gemIdx})`,
);
assert.notStrictEqual(
antCliIdx,
-1,
`scan loop "for dir in ${m[1].trim()}" must include .gemini/antigravity-cli for Antigravity 2.x CLI installs`,
);
assert.ok(
antCliIdx < gemIdx,
`scan loop "for dir in ${m[1].trim()}": .gemini/antigravity-cli (idx ${antCliIdx}) must precede .gemini (idx ${gemIdx})`,
);
assert.notStrictEqual(
antLegacyIdx,
-1,
`scan loop "for dir in ${m[1].trim()}" must retain .gemini/antigravity legacy fallback`,
);
test('RUNTIME_DIRS includes antigravity 2.x (ide/cli) + legacy dirs', () => {
const dirs = RUNTIME_DIRS.filter(([rt]) => rt === 'antigravity').map(([, d]) => d);
assert.ok(dirs.includes('.gemini/antigravity-ide'), 'missing .gemini/antigravity-ide');
assert.ok(dirs.includes('.gemini/antigravity-cli'), 'missing .gemini/antigravity-cli');
assert.ok(dirs.includes('.gemini/antigravity'), 'missing legacy .gemini/antigravity fallback');
// All antigravity dirs precede the .gemini probe.
const order = RUNTIME_DIRS.map(([, d]) => d);
const gemIdx = order.indexOf('.gemini');
for (const d of dirs) {
assert.ok(order.indexOf(d) < gemIdx, `${d} must precede .gemini in the probe order`);
}
});
test('path-to-runtime classification documents antigravity 2.x and legacy paths before /.gemini/', () => {
// The markdown bullet list near the top of get_installed_version step.
// We assert the antigravity bullet exists and appears before the gemini bullet
// in the file (textual order = evaluation order in the prose contract).
const antIdeBullet = content.search(/Path contains `\/\.gemini\/antigravity-ide\/?` -> `antigravity`/);
const antCliBullet = content.search(/Path contains `\/\.gemini\/antigravity-cli\/?` -> `antigravity`/);
const antLegacyBullet = content.search(/Path contains `\/\.gemini\/antigravity\/?` -> `antigravity`/);
const gemBullet = content.search(/Path contains `\/\.gemini\/` -> `gemini`/);
test('env inference recognizes ANTIGRAVITY_CONFIG_DIR before GEMINI_CONFIG_DIR', () => {
const rt = inferPreferredRuntime({
fs: { exists: () => false },
env: { ANTIGRAVITY_CONFIG_DIR: '/x', GEMINI_CONFIG_DIR: '/y' },
preferredConfigDir: '',
});
assert.equal(rt, 'antigravity', 'both env vars set must resolve to antigravity, not gemini');
});
assert.notStrictEqual(
antIdeBullet,
-1,
'classification bullet for /.gemini/antigravity-ide/ -> antigravity is missing',
);
assert.notStrictEqual(
antCliBullet,
-1,
'classification bullet for /.gemini/antigravity-cli/ -> antigravity is missing',
);
assert.notStrictEqual(
antLegacyBullet,
-1,
'classification bullet for /.gemini/antigravity/ -> antigravity is missing',
);
assert.notStrictEqual(gemBullet, -1, 'classification bullet for /.gemini/ -> gemini is missing');
assert.ok(
antIdeBullet < gemBullet && antCliBullet < gemBullet && antLegacyBullet < gemBullet,
'all antigravity bullets must precede gemini bullet in path-classification list',
);
test('envRuntimeDirs emits an antigravity entry (before gemini) when ANTIGRAVITY_CONFIG_DIR is set', () => {
const entries = envRuntimeDirs({ env: { ANTIGRAVITY_CONFIG_DIR: '/x/ag', GEMINI_CONFIG_DIR: '/x/gem' }, home: '/home/u' });
const order = entries.map(([rt]) => rt);
assert.ok(order.includes('antigravity'), 'expected an antigravity env candidate');
assert.ok(order.indexOf('antigravity') < order.indexOf('gemini'), 'antigravity env candidate must precede gemini');
});
test('behavioral: an Antigravity install resolves to runtime "antigravity", not "gemini"', () => {
// Normalize paths so the fake fs matches the resolver's path.join/resolve
// lookups on Windows (backslash + drive) as well as POSIX.
const normKey = (p) => path.resolve(p).replace(/\\/g, '/').toLowerCase();
const HOME = '/home/u';
const agDir = path.join(HOME, '.gemini', 'antigravity');
const verFile = normKey(path.join(agDir, 'get-shit-done', 'VERSION'));
const markerFile = normKey(path.join(agDir, 'get-shit-done', 'workflows', 'update.md'));
const fakeFs = {
exists: (p) => normKey(p) === verFile || normKey(p) === markerFile,
readFile: (p) => (normKey(p) === verFile ? '1.40.0\n' : null),
};
const r = resolveUpdateContext({ home: HOME, cwd: path.resolve('/work'), env: {}, fs: fakeFs });
assert.equal(r.runtime, 'antigravity');
assert.equal(normKey(r.gsdDir), normKey(agDir));
});
test('update.md execution_context classification still lists antigravity paths before /.gemini/', () => {
const content = fs.readFileSync(UPDATE_MD, 'utf-8');
const antIde = content.indexOf('/.gemini/antigravity-ide/');
const gemBare = content.indexOf('`/.gemini/` -> `gemini`');
assert.notStrictEqual(antIde, -1, 'update.md must document the antigravity-ide execution_context path');
assert.notStrictEqual(gemBare, -1, 'update.md must document the bare /.gemini/ -> gemini classification');
assert.ok(antIde < gemBare, 'antigravity path classification must precede the bare /.gemini/ rule');
});
});

View File

@@ -1,33 +1,43 @@
/**
* Regression test for #378: gsd-check-update-worker.js must query
* the SCOPED package name (@opengsd/get-shit-done-redux) when calling
* `npm view <name> version`.
* Regression test for #378 / #498: the SessionStart update worker must end up
* querying the SCOPED package name (@opengsd/get-shit-done-redux) when it asks
* npm for the latest version.
*
* Background: the worker previously hardcoded the unscoped string
* 'get-shit-done-redux', which returns E404 from the npm registry because
* the published package is scoped. This caused `latest` to stay null and
* `update_available` to be permanently false — users never saw update
* notifications.
* Background (#378): the worker once hardcoded the unscoped 'get-shit-done-redux',
* which 404s from the registry, leaving update_available permanently false.
*
* The fix derives the name from package.json (most robust — survives
* future renames). This test locks the contract in two ways:
* Original #378 fix derived the name from `require('../package.json').name`.
* That is broken at runtime (#498): the installed tree carries only a synthetic
* `{"type":"commonjs"}` package.json (no `.name`), so post-install the worker
* queried `npm view undefined version` → latest stayed null → update_available
* permanently false. The old structural test passed only because it grepped the
* DEV tree, where package.json still has a name.
*
* 1. Structural: the worker must NOT contain the bare unscoped literal
* 'get-shit-done-redux' as a standalone npm view argument.
* 2. Derived: the worker must read the package name from package.json
* and the package.json name MUST be the scoped string
* '@opengsd/get-shit-done-redux'.
* New contract (#498): the worker no longer resolves the package name itself.
* It delegates the latest-version lookup to check-latest-version.cjs's
* `checkLatestVersion()`, whose `PACKAGE_NAME` is sourced from the baked Package
* Identity seam (`get-shit-done/bin/lib/package-identity.cjs`). The seam's value
* is a build-time constant, correct in every install layout, so the
* undefined-at-runtime failure cannot recur. This test locks that contract:
*
* Source-grep policy: this test reads hook source via readFileSync.
* The repo's lint-no-source-grep rule targets bin/lib/get-shit-done — hooks/
* is out of scope. The shape we need to lock (which string is passed as the
* npm view argument) only manifests at runtime against the live registry;
* a structural assertion is the minimum-cost contract.
* 1. Structural: worker must NOT contain the bare unscoped literal.
* 2. Structural: worker must NOT use `require(...package.json...).name`
* (the runtime-broken path).
* 3. Structural: worker delegates to check-latest-version's
* `checkLatestVersion` rather than calling `npm view` itself.
* 4. Single-source: check-latest-version's PACKAGE_NAME === the seam's
* packageName === the scoped '@opengsd/get-shit-done-redux'.
*
* Source-grep policy: this test reads hook source via readFileSync. The repo's
* lint-no-source-grep rule targets bin/lib/get-shit-done — hooks/ is out of
* scope. The behavior (correct name → no E404) only manifests at runtime
* against the live registry; structural assertions are the minimum-cost
* contract for the worker, the same rationale #378 carried.
*/
// allow-test-rule: structural assertion on hook npm-view argument; the
// behavior being tested (correct package name → no E404) only manifests at
// runtime against the live npm registry, which CI does not call.
// allow-test-rule: structural assertion on hook delegation; the behavior being
// tested (correct package name → no E404) only manifests at runtime against the
// live npm registry, which CI does not call.
'use strict';
@@ -38,61 +48,58 @@ const path = require('path');
const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js');
const PKG_PATH = path.join(__dirname, '..', 'package.json');
const SEAM = require('../get-shit-done/bin/lib/package-identity.cjs');
const { PACKAGE_NAME } = require('../get-shit-done/bin/check-latest-version.cjs');
describe('bug #378: update-check worker uses scoped package name', () => {
function workerCodeOnly() {
const src = fs.readFileSync(WORKER_PATH, 'utf8');
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
describe('bug #378 / #498: update worker queries the scoped name via the seam', () => {
test('worker file exists', () => {
assert.ok(fs.existsSync(WORKER_PATH), `worker not found at ${WORKER_PATH}`);
});
test('package.json name is the scoped @opengsd/get-shit-done-redux', () => {
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8'));
assert.equal(
pkg.name,
'@opengsd/get-shit-done-redux',
'package.json must declare the scoped name — this is what npm view must query',
);
assert.equal(pkg.name, '@opengsd/get-shit-done-redux');
});
test('worker does NOT hardcode the unscoped get-shit-done-redux as an npm view argument', () => {
const src = fs.readFileSync(WORKER_PATH, 'utf8');
// Strip comments so doc-prose mentions don't trigger the check.
const codeOnly = src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
// The unscoped bare string as a string literal used in code.
// A match here means the bug is present: npm view 'get-shit-done-redux'
// → E404 → update_available permanently false.
const unscopedLiteral = /['"]get-shit-done-redux['"]/;
test('worker does NOT hardcode the unscoped get-shit-done-redux as a string literal', () => {
assert.doesNotMatch(
codeOnly,
unscopedLiteral,
workerCodeOnly(),
/['"]get-shit-done-redux['"]/,
"Worker must not pass the unscoped 'get-shit-done-redux' to npm — it 404s.",
);
});
test('worker does NOT resolve the name via require(package.json).name (broken at runtime)', () => {
assert.doesNotMatch(
workerCodeOnly(),
/require\s*\(\s*['"][^'"]*package\.json['"]\s*\)\s*\.name/,
[
"Worker must not pass the unscoped 'get-shit-done-redux' to `npm view`.",
'That name returns E404, leaving update_available permanently false.',
'Use the scoped name from package.json: @opengsd/get-shit-done-redux.',
'require(package.json).name resolves to undefined in the installed tree',
'(only a {"type":"commonjs"} marker ships). The worker must delegate to',
'checkLatestVersion(), which sources the name from the baked seam.',
].join(' '),
);
});
test('worker derives package name from package.json (require + .name)', () => {
const src = fs.readFileSync(WORKER_PATH, 'utf8');
// Structural check: worker must load package.json and read .name from it.
// This is the robust form — survives future renames without code edits.
const requiresPkgJson = /require\s*\(\s*['"][^'"]*package\.json['"]\s*\)\.name/;
test('worker delegates the latest-version lookup to checkLatestVersion', () => {
const code = workerCodeOnly();
assert.match(
src,
requiresPkgJson,
[
'Worker must derive the npm view package name via',
"`require('../package.json').name` (or similar).",
'Hardcoding the scoped literal is less robust: a future rename',
'would silently break update checks again.',
].join(' '),
code,
/check-latest-version/,
'Worker must require check-latest-version.cjs and call checkLatestVersion().',
);
assert.match(code, /checkLatestVersion\s*\(/);
});
test('check-latest-version PACKAGE_NAME is single-sourced from the seam', () => {
assert.equal(PACKAGE_NAME, SEAM.packageName);
assert.equal(SEAM.packageName, '@opengsd/get-shit-done-redux');
});
});

View File

@@ -1,23 +1,28 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
// allow-test-rule: source-text-is-the-product
// update.md is a workflow file whose text IS the contract the runtime loads
// and executes (the embedded bash detection cascade). Asserting on its text
// tests the deployed behavior. Per CONTRIBUTING.md exception matrix.
// update.md's embedded classifier + cache-clear loop are workflow text the
// runtime loads and executes, so asserting on that text tests deployed
// behavior. The runtime/scope detection cascade itself moved out of inline
// bash into the update-context projection (issue #498), so the core guarantee
// is exercised behaviorally against resolveUpdateContext rather than by
// matching a `RUNTIME_DIRS=(...)` literal that no longer lives in update.md.
// Per CONTRIBUTING.md exception matrix.
/**
* Bug #503: /gsd:update misclassifies local Antigravity (.agent) installs as claude
*
* The installer places a LOCAL Antigravity install in ./.agent/
* (bin/install.js: getDirName('antigravity') === '.agent'). But the
* /gsd:update detection cascade in get-shit-done/workflows/update.md only
* knew the GLOBAL Antigravity layout (.gemini/antigravity{,-ide,-cli}), so a
* local .agent install fell through to the `Otherwise -> claude` default and
* the update refreshed Claude artifacts instead of the Antigravity install.
* (bin/install.js: getDirName('antigravity') === '.agent'). The /gsd:update
* detection cascade must map .agent -> antigravity across three surfaces:
* 1. the execution_context path classifier (update.md prose),
* 2. the RUNTIME_DIRS candidate table (now in the update-context projection),
* 3. the post-update cache-clear `for dir in` loop (update.md).
*
* The cascade has three coupled detection surfaces; .agent must be mapped to
* antigravity in all three:
* 1. the execution_context path classifier,
* 2. the RUNTIME_DIRS candidate array,
* 3. the LOCAL-scope discovery `for dir in ...` loop.
* Surface (2) is the original root cause and is now verified behaviorally: a
* LOCAL .agent install must resolve to the antigravity runtime. Before the fix
* (.agent absent from RUNTIME_DIRS) it fell through to UNKNOWN/claude.
*/
const { describe, test } = require('node:test');
@@ -25,50 +30,69 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const UPDATE_MD = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'),
'utf-8'
path.join(ROOT, 'get-shit-done', 'workflows', 'update.md'),
'utf-8',
);
const { resolveUpdateContext } = require(
path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'update-context.cjs'),
);
function normKey(p) { return path.resolve(p).replace(/\\/g, '/').toLowerCase(); }
function fakeFs(files) {
const set = new Map();
for (const [k, v] of Object.entries(files)) set.set(normKey(k), v);
return {
exists: (p) => set.has(normKey(p)),
readFile: (p) => { const k = normKey(p); return set.has(k) ? set.get(k) : null; },
};
}
describe('/gsd:update detects local Antigravity (.agent) installs (#503)', () => {
test('execution_context classifier maps a /.agent/ path to antigravity', () => {
// A rule line of the form: Path contains `/.agent/` -> `antigravity`
test('projection resolves a LOCAL ./.agent install to the antigravity runtime', () => {
const HOME = '/home/u';
const CWD = '/work/proj';
const agentDir = `${CWD}/.agent`;
const ffs = fakeFs({
[`${agentDir}/get-shit-done/VERSION`]: '1.40.0\n',
[`${agentDir}/get-shit-done/workflows/update.md`]: 'x',
});
const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs: ffs });
assert.equal(
r.runtime,
'antigravity',
`a local .agent install must map to the antigravity runtime, got "${r.runtime}"`,
);
assert.equal(r.scope, 'LOCAL');
assert.equal(r.installedVersion, '1.40.0');
});
test('execution_context classifier maps a /.agent/ path to antigravity (update.md)', () => {
const hasAgentClassifierRule =
/\/\.agent\/[^\n]*->[^\n]*antigravity/.test(UPDATE_MD);
assert.ok(
hasAgentClassifierRule,
'update.md classifier must map a `/.agent/` path to the `antigravity` runtime'
'update.md classifier must map a `/.agent/` path to the `antigravity` runtime',
);
});
test('RUNTIME_DIRS candidate array includes antigravity:.agent', () => {
const runtimeDirsLine = UPDATE_MD
.split('\n')
.find((l) => l.includes('RUNTIME_DIRS=('));
assert.ok(runtimeDirsLine, 'RUNTIME_DIRS array must exist in update.md');
assert.ok(
runtimeDirsLine.includes('antigravity:.agent'),
`RUNTIME_DIRS must contain "antigravity:.agent", got: ${runtimeDirsLine}`
);
});
test('every runtime-dir `for dir in` loop includes .agent', () => {
// Both the LOCAL-scope discovery loop AND the post-update cache-clear loop
// enumerate the runtime config dirs as a literal `.claude ... .codex` list.
// The same root cause (.agent missing from the runtime-dir list) breaks
// detection in the first and leaves a stale update indicator in the second,
// so ALL such loops must include .agent.
test('every runtime-dir `for dir in` loop in update.md includes .agent', () => {
// The LOCAL-scope discovery loop moved into the projection (#498); the
// post-update cache-clear loop remains inline and still enumerates the
// runtime config dirs as a literal `.claude ... .codex` list, so it must
// include .agent or a local Antigravity install keeps a stale indicator.
const runtimeDirLoops = UPDATE_MD
.split('\n')
.filter((l) => /for dir in .*\.claude.*\.codex/.test(l));
assert.ok(
runtimeDirLoops.length >= 2,
`expected at least 2 runtime-dir loops in update.md, found ${runtimeDirLoops.length}`
runtimeDirLoops.length >= 1,
`expected at least 1 runtime-dir loop in update.md, found ${runtimeDirLoops.length}`,
);
for (const loop of runtimeDirLoops) {
assert.ok(
/(^|\s)\.agent(\s|$)/.test(loop),
`every runtime-dir loop must include .agent, got: ${loop.trim()}`
`every runtime-dir loop must include .agent, got: ${loop.trim()}`,
);
}
});

View File

@@ -1,31 +1,30 @@
/**
* Tests for gsd-check-update-worker.js — Windows npm resolution platform gate.
* Tests for the Windows npm resolution platform gate.
*
* Background (issue #3103, PR #3102):
* On Windows, `npm` ships as `npm.cmd`. Node's execFileSync does not apply
* PATHEXT resolution (unlike execSync/exec) and fails with ENOENT. The fix
* is to spawn through a shell on Windows (cmd.exe resolves npm.cmd via
* PATHEXT). On POSIX, `npm` is a node-script symlink and resolves without
* a shell, so spawning `/bin/sh -c` is pure overhead and changes signal /
* exit-code semantics — undesirable.
* On Windows, `npm` ships as `npm.cmd`. Node's spawn does not apply PATHEXT
* resolution and fails with ENOENT. The fix is to spawn through a shell on
* Windows (cmd.exe resolves npm.cmd via PATHEXT). On POSIX, `npm` resolves
* without a shell, so spawning `/bin/sh -c` is pure overhead and changes
* signal / exit-code semantics — undesirable.
*
* This test locks the contract: shell must be platform-gated to win32 only,
* never an unconditional `shell: true`. A regression that re-introduces
* `shell: true` would change POSIX runtime behavior silently — exactly the
* cross-platform risk that adversarial review on PR #3102 flagged.
* Relocation (#498): the SessionStart worker no longer spawns npm itself. It
* delegates the latest-version lookup to check-latest-version's
* `checkLatestVersion()`, which routes through `execNpm` in the shell-command
* projection seam. The PR #3102 contract therefore now lives on `execNpm`.
* This test locks it there, and additionally locks that the worker does NOT
* re-introduce a direct npm spawn (which would re-open the gate question in a
* second place).
*
* Source-grep policy: this test reads the worker source via readFileSync.
* The repo's lint-no-source-grep rule (scripts/lint-no-source-grep.cjs)
* targets `.cjs` files in bin/lib/get-shit-done — `hooks/*.js` is out of
* scope. The behavior we need to lock is a single static-spawn-options
* shape, which only manifests at runtime under Windows; runtime testing
* would require a Windows CI lane. A structural assertion is the
* minimum-cost contract.
* Source-grep policy: these structural assertions read source via readFileSync.
* The behavior (Windows-only shell resolution) is platform-gated at runtime and
* cannot be reached on POSIX CI without a Windows lane; a structural assertion
* is the minimum-cost contract.
*/
// allow-test-rule: structural assertion on hook spawn-options shape; the
// behavior being tested (Windows-only shell resolution) is platform-gated
// at runtime and cannot be reached on POSIX CI without a Windows lane.
// allow-test-rule: structural assertion on spawn-options shape; the behavior
// (Windows-only shell resolution) is platform-gated at runtime and cannot be
// reached on POSIX CI without a Windows lane.
'use strict';
@@ -35,68 +34,53 @@ const fs = require('fs');
const path = require('path');
const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js');
const PROJECTION_PATH = path.join(
__dirname, '..', 'get-shit-done', 'bin', 'lib', 'shell-command-projection.cjs',
);
describe('gsd-check-update-worker: Windows npm spawn platform gate', () => {
test('worker file exists', () => {
assert.ok(fs.existsSync(WORKER_PATH), `worker not found at ${WORKER_PATH}`);
function codeOnly(file) {
return fs.readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
describe('execNpm: Windows npm spawn platform gate (PR #3102, relocated #498)', () => {
test('projection seam exists', () => {
assert.ok(fs.existsSync(PROJECTION_PATH), `not found at ${PROJECTION_PATH}`);
});
test('shell option is gated to process.platform === "win32"', () => {
const src = fs.readFileSync(WORKER_PATH, 'utf8');
const codeOnly = src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
// Locks the platform gate. Allows whitespace/quote variation around
// the comparison so trivial style fixes do not break the contract.
const platformGate =
/shell:\s*process\.platform\s*===\s*['"]win32['"]/;
test('execNpm gates shell to process.platform === "win32"', () => {
assert.match(
codeOnly,
platformGate,
codeOnly(PROJECTION_PATH),
/shell:\s*process\.platform\s*===\s*['"]win32['"]/,
[
'shell option must be `process.platform === "win32"`.',
'execNpm must gate shell to `process.platform === "win32"`.',
'A regression to `shell: true` would spawn /bin/sh -c on POSIX',
'(adds shell overhead, changes signal/exit semantics, can mask',
'windowsHide on some Node versions). See PR #3102.',
'(adds shell overhead, changes signal/exit semantics). See PR #3102.',
].join(' '),
);
});
test('no unconditional shell: true on the npm spawn', () => {
const src = fs.readFileSync(WORKER_PATH, 'utf8');
// Strip line and block comments so prose mentions of "shell:true" in
// documentation comments do not trigger the regression check.
const codeOnly = src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
// Reject literal `shell: true` in CODE only. The correct fix uses
// `shell: process.platform === 'win32'` (an expression, not the
// literal `true`), so this never matches the platform-gated form.
// Trailing `[,\s}]` ensures we match an object-property assignment,
// not an unrelated identifier.
const naiveShell = /shell\s*:\s*true\s*[,\s}]/;
assert.doesNotMatch(
codeOnly,
naiveShell,
'shell: true is forbidden — use `process.platform === "win32"` gate.',
);
});
test('execFileSync is still the spawn primitive (not exec/execSync)', () => {
const src = fs.readFileSync(WORKER_PATH, 'utf8');
// execFileSync is intentional: it does not invoke a shell on POSIX,
// unlike exec/execSync. A regression that swaps to execSync would
// silently always spawn a shell, defeating the platform gate.
assert.match(
src,
/execFileSync\s*\(\s*['"]npm['"]/,
'npm spawn must use execFileSync (not exec/execSync) to keep POSIX shell-free.',
codeOnly(PROJECTION_PATH),
/shell\s*:\s*true\s*[,\s}]/,
'shell: true is forbidden — use the `process.platform === "win32"` gate.',
);
});
});
describe('worker delegates the npm spawn (does not re-open the gate, #498)', () => {
test('worker does NOT spawn npm directly', () => {
const code = codeOnly(WORKER_PATH);
assert.doesNotMatch(
code,
/(execFileSync|spawnSync|execSync|exec)\s*\(\s*['"]npm['"]/,
'Worker must delegate to checkLatestVersion(), not spawn npm itself.',
);
});
test('worker requires check-latest-version for the lookup', () => {
assert.match(codeOnly(WORKER_PATH), /check-latest-version/);
});
});

View File

@@ -643,6 +643,11 @@ describe('Kilo source integration assertions', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
const updateWorkflowSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'), 'utf8');
// #498: update.md's runtime/scope/config-dir resolution moved into the tested
// projection get-shit-done/bin/lib/update-context.cjs. Custom-config-dir
// detection (kilo.jsonc, KILO_CONFIG) is now asserted there.
const updateContextSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'update-context.cjs'), 'utf8');
test('--kilo flag parsing exists', () => {
assert.ok(src.includes("args.includes('--kilo')"));
@@ -668,8 +673,11 @@ describe('Kilo source integration assertions', () => {
});
test('update workflow checks preferred custom config dirs', () => {
// update.md still derives the preferred config dir from execution_context…
assert.ok(updateWorkflowSrc.includes('PREFERRED_CONFIG_DIR'));
assert.ok(updateWorkflowSrc.includes('kilo.jsonc'));
assert.ok(updateWorkflowSrc.includes('KILO_CONFIG'));
// …and the custom-dir detection (kilo.jsonc config marker, KILO_CONFIG env)
// now lives in the tested update-context projection (#498).
assert.ok(updateContextSrc.includes('kilo.jsonc'));
assert.ok(updateContextSrc.includes('KILO_CONFIG'));
});
});

View File

@@ -0,0 +1,69 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
// Issue #498: the drift-guard lint. Every GSD package/repo coordinate that
// appears as a literal anywhere in the runtime/code surface must equal the
// value the Package Identity seam derives from package.json. This is what
// makes a repoint a one-line change: rename package.json, regenerate the seam,
// and any stale literal fails CI until it is updated.
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const { findCoordinateDrift } = require(
path.join(ROOT, 'scripts', 'lint-package-identity-drift.cjs'),
);
const SEAM = { packageName: '@opengsd/get-shit-done-redux', repoSlug: 'open-gsd/get-shit-done-redux' };
describe('Issue #498: findCoordinateDrift (pure)', () => {
test('a correct package literal is not drift', () => {
const v = findCoordinateDrift('run npx -y @opengsd/get-shit-done-redux@latest', SEAM);
assert.deepEqual(v, []);
});
test('a stale package literal (post-rename) is flagged', () => {
const v = findCoordinateDrift('npx @opengsd/get-shit-done-classic@latest', SEAM);
assert.equal(v.length, 1);
assert.equal(v[0].found, '@opengsd/get-shit-done-classic');
assert.equal(v[0].expected, SEAM.packageName);
assert.equal(v[0].kind, 'package');
});
test('a different package (@opengsd/gsd-sdk) is NOT a get-shit-done coordinate', () => {
assert.deepEqual(findCoordinateDrift("require('@opengsd/gsd-sdk')", SEAM), []);
});
test('a correct github repo slug is not drift', () => {
const v = findCoordinateDrift('https://github.com/open-gsd/get-shit-done-redux/issues', SEAM);
assert.deepEqual(v, []);
});
test('a stale repo slug in a github url is flagged', () => {
const v = findCoordinateDrift('https://github.com/tches/get-shit-done-classic.git', SEAM);
assert.equal(v.length, 1);
assert.equal(v[0].kind, 'slug');
assert.equal(v[0].found, 'tches/get-shit-done-classic');
});
test('reports 1-based line numbers', () => {
const text = 'line1\nnpx @opengsd/get-shit-done-OLD@latest\nline3';
const v = findCoordinateDrift(text, SEAM);
assert.equal(v[0].line, 2);
});
});
describe('Issue #498: the live repo passes the drift lint', () => {
test('scanRepo finds zero drift against the current seam', () => {
const { scanRepo } = require(path.join(ROOT, 'scripts', 'lint-package-identity-drift.cjs'));
const violations = scanRepo(ROOT);
assert.deepEqual(
violations,
[],
'stale GSD coordinate literal(s) found:\n' +
violations.map((d) => ` ${d.file}:${d.line} ${d.kind} '${d.found}' != '${d.expected}'`).join('\n'),
);
});
});

View File

@@ -0,0 +1,113 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
// Issue #498: single Package Identity seam.
// The package coordinates (npm name, bin name, repo slug, changelog URL) are
// DERIVED from package.json, not re-typed. deriveIdentity is the pure core;
// the generated runtime module get-shit-done/bin/lib/package-identity.cjs
// bakes those values at build time so it survives the install layout where
// the only package.json present is the synthetic {"type":"commonjs"} marker.
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const ROOT = path.join(__dirname, '..');
const { deriveIdentity, formatManualInstall, render } = require(
path.join(ROOT, 'scripts', 'generate-package-identity.cjs'),
);
const GENERATED = path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'package-identity.cjs');
describe('Issue #498: deriveIdentity (pure, package.json -> coordinates)', () => {
const FAKE_PKG = {
name: '@scope/example-pkg',
bin: { 'example-pkg': 'bin/install.js', 'extra-tool': 'x.cjs' },
repository: { type: 'git', url: 'git+https://github.com/acme/example-pkg.git' },
};
test('packageName is package.json .name', () => {
assert.equal(deriveIdentity(FAKE_PKG).packageName, '@scope/example-pkg');
});
test('binName is the FIRST bin key (primary launcher)', () => {
assert.equal(deriveIdentity(FAKE_PKG).binName, 'example-pkg');
});
test('repoSlug is owner/name parsed from repository.url (git+ and .git stripped)', () => {
assert.equal(deriveIdentity(FAKE_PKG).repoSlug, 'acme/example-pkg');
});
test('repoUrl is the cleaned https github url', () => {
assert.equal(deriveIdentity(FAKE_PKG).repoUrl, 'https://github.com/acme/example-pkg');
});
test('changelogRawUrl points at raw.githubusercontent main CHANGELOG', () => {
assert.equal(
deriveIdentity(FAKE_PKG).changelogRawUrl,
'https://raw.githubusercontent.com/acme/example-pkg/main/CHANGELOG.md',
);
});
test('derives the real GSD coordinates from the repo package.json', () => {
const real = require(path.join(ROOT, 'package.json'));
const id = deriveIdentity(real);
assert.equal(id.packageName, '@opengsd/get-shit-done-redux');
assert.equal(id.binName, 'get-shit-done-redux');
assert.equal(id.repoSlug, 'open-gsd/get-shit-done-redux');
});
});
describe('Issue #498: formatManualInstall (the npx fallback command)', () => {
test('global scope, no runtime -> npx with --global only', () => {
assert.equal(
formatManualInstall({ packageName: '@scope/example-pkg', binName: 'example-pkg', scope: 'global' }),
'npx -y --package=@scope/example-pkg@latest -- example-pkg --global',
);
});
test('local scope with runtime -> --<runtime> before --<scope>', () => {
assert.equal(
formatManualInstall({ packageName: '@scope/example-pkg', binName: 'example-pkg', scope: 'local', runtime: 'claude' }),
'npx -y --package=@scope/example-pkg@latest -- example-pkg --claude --local',
);
});
test('matches the literal update.md uses for the real package (global+claude)', () => {
const id = deriveIdentity(require(path.join(ROOT, 'package.json')));
assert.equal(
formatManualInstall({ packageName: id.packageName, binName: id.binName, scope: 'global', runtime: 'claude' }),
'npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux --claude --global',
);
});
});
describe('Issue #498: generated runtime module (baked, drift-checked)', () => {
test('the committed generated file is in sync with package.json (no drift)', () => {
// Normalize line endings: on Windows the file is checked out with CRLF
// (no .gitattributes eol rule), while render() emits LF. The repo's
// convention is to compare normalized content (see autonomous-decomposition,
// bug-3707). The sync check is about content, not the checkout's eol.
const norm = (s) => s.replace(/\r\n/g, '\n');
const expected = render(deriveIdentity(require(path.join(ROOT, 'package.json'))));
const actual = fs.readFileSync(GENERATED, 'utf8');
assert.equal(norm(actual), norm(expected),
'package-identity.cjs is stale — run `node scripts/generate-package-identity.cjs`');
});
test('requiring the generated module exposes the real coordinates', () => {
const id = require(GENERATED);
assert.equal(id.packageName, '@opengsd/get-shit-done-redux');
assert.equal(id.binName, 'get-shit-done-redux');
assert.equal(id.repoSlug, 'open-gsd/get-shit-done-redux');
});
test('generated manualInstallCommand closes over the baked coordinates', () => {
const id = require(GENERATED);
assert.equal(
id.manualInstallCommand({ scope: 'global', runtime: 'claude' }),
'npx -y --package=@opengsd/get-shit-done-redux@latest -- get-shit-done-redux --claude --global',
);
});
});

View File

@@ -0,0 +1,68 @@
/**
* Regression (#498, adversarial-review finding): the custom-file backup step in
* update.md must derive RUNTIME_DIR from GSD_DIR.
*
* The get_installed_version step was rewritten to call `gsd-tools update-context`
* and now emits GSD_DIR (the resolved config dir) instead of the old probe-loop
* variables LOCAL_DIR / GLOBAL_DIR. The backup_custom_files step still read
* LOCAL_DIR / GLOBAL_DIR, which are no longer assigned anywhere — so RUNTIME_DIR
* went empty for every LOCAL/GLOBAL install and detect-custom-files was skipped.
* Because the update then runs a clean install that wipes managed dirs
* (commands/gsd, get-shit-done), user-added files inside those dirs could be
* deleted without the intended backup.
*
* This locks the fix: RUNTIME_DIR comes from GSD_DIR, and the dead LOCAL_DIR /
* GLOBAL_DIR references are gone.
*
* Source-text-is-the-product: update.md's bash blocks ARE the deployed /gsd:update
* program; asserting their shape is asserting on the deployed contract.
*/
// allow-test-rule: structural assertion on the deployed update.md backup bash;
// the data-loss behavior only manifests against a real install during a clean
// reinstall, which CI does not perform.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const UPDATE_MD = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md');
function codeOnly(file) {
// Strip fenced-block prose is unnecessary here; we assert on the whole doc
// but ignore markdown comment prose by only matching shell-assignment forms.
return fs.readFileSync(file, 'utf8');
}
describe('#498 regression: update.md backup uses GSD_DIR, not the removed LOCAL_DIR/GLOBAL_DIR', () => {
const src = codeOnly(UPDATE_MD);
test('RUNTIME_DIR is assigned from GSD_DIR', () => {
assert.match(
src,
/RUNTIME_DIR="\$GSD_DIR"/,
'backup_custom_files must set RUNTIME_DIR="$GSD_DIR" (the resolved config dir from update-context)',
);
});
test('no shell assignment reads the removed LOCAL_DIR/GLOBAL_DIR probe variables', () => {
// The get_installed_version rewrite no longer assigns LOCAL_DIR/GLOBAL_DIR.
// Any RUNTIME_DIR="$LOCAL_DIR" / "$GLOBAL_DIR" would silently resolve to empty.
assert.doesNotMatch(
src,
/="\$(LOCAL_DIR|GLOBAL_DIR)"/,
'update.md still reads LOCAL_DIR/GLOBAL_DIR, which get_installed_version no longer sets — backup will be skipped',
);
});
test('detect-custom-files stays gated on a non-empty RUNTIME_DIR', () => {
assert.match(
src,
/\[ -n "\$RUNTIME_DIR" \][\s\S]*?detect-custom-files --config-dir "\$RUNTIME_DIR"/,
'backup must still skip when RUNTIME_DIR is empty (UNKNOWN scope)',
);
});
});

View File

@@ -0,0 +1,195 @@
'use strict';
process.env.GSD_TEST_MODE = '1';
// Issue #498 (candidate 3): resolveUpdateContext ports update.md's ~280-line
// get_installed_version bash into a pure, injected-fs function. It returns the
// same 4-field contract the workflow emits: { installedVersion, scope, runtime,
// gsdDir }. The fs is injected (exists/readFile) so the precedence cascade is
// finally testable without a live multi-runtime install.
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const nodeFs = require('node:fs');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const GSD_TOOLS = path.join(ROOT, 'get-shit-done', 'bin', 'gsd-tools.cjs');
const { resolveUpdateContext } = require(
path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'update-context.cjs'),
);
// Normalize a path to a platform-agnostic key: resolve to absolute, then
// lowercase forward-slash form. This makes the fake fs match the resolver's
// path.join/path.resolve lookups on Windows (backslash + drive letter) as well
// as POSIX, so these unit tests are not OS-coupled.
function normKey(p) { return path.resolve(p).replace(/\\/g, '/').toLowerCase(); }
// Build an injected fs from a map of absolute path -> contents. Marker files
// (VERSION, workflows/update.md) just need to "exist".
function fakeFs(files) {
const set = new Map();
for (const [k, v] of Object.entries(files)) set.set(normKey(k), v);
return {
exists: (p) => set.has(normKey(p)),
readFile: (p) => { const k = normKey(p); return set.has(k) ? set.get(k) : null; },
};
}
// Compare resolved-dir results without coupling to OS path style.
function sameDir(a, b) { return normKey(a) === normKey(b); }
const HOME = '/home/u';
const CWD = '/work/proj';
function ver(dir) { return `${dir}/get-shit-done/VERSION`; }
function marker(dir) { return `${dir}/get-shit-done/workflows/update.md`; }
describe('resolveUpdateContext: scope cascade', () => {
test('GLOBAL claude install under $HOME/.claude', () => {
const fs = fakeFs({ [ver(`${HOME}/.claude`)]: '1.40.0\n', [marker(`${HOME}/.claude`)]: 'x' });
const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs });
assert.equal(r.installedVersion, '1.40.0');
assert.equal(r.scope, 'GLOBAL');
assert.equal(r.runtime, 'claude');
assert.ok(sameDir(r.gsdDir, `${HOME}/.claude`), `gsdDir was ${r.gsdDir}`);
});
test('LOCAL install under ./.claude takes priority over global', () => {
const fs = fakeFs({
[ver(`${CWD}/.claude`)]: '1.39.0\n', [marker(`${CWD}/.claude`)]: 'x',
[ver(`${HOME}/.claude`)]: '1.40.0\n', [marker(`${HOME}/.claude`)]: 'x',
});
const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs });
assert.equal(r.scope, 'LOCAL');
assert.equal(r.installedVersion, '1.39.0');
assert.ok(sameDir(r.gsdDir, `${CWD}/.claude`), `gsdDir was ${r.gsdDir}`);
});
test('cwd === home does NOT misdetect as LOCAL (dedup)', () => {
const fs = fakeFs({ [ver(`${HOME}/.claude`)]: '1.40.0\n', [marker(`${HOME}/.claude`)]: 'x' });
const r = resolveUpdateContext({ home: HOME, cwd: HOME, env: {}, fs });
assert.equal(r.scope, 'GLOBAL');
});
test('runtime detected but VERSION missing -> 0.0.0, keep scope/runtime', () => {
const fs = fakeFs({ [marker(`${HOME}/.gemini`)]: 'x' });
const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs });
assert.equal(r.installedVersion, '0.0.0');
assert.equal(r.scope, 'GLOBAL');
assert.equal(r.runtime, 'gemini');
});
test('no install anywhere -> UNKNOWN / claude / empty gsdDir', () => {
const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs: fakeFs({}) });
assert.deepEqual(r, { installedVersion: '0.0.0', scope: 'UNKNOWN', runtime: 'claude', gsdDir: '' });
});
});
describe('resolveUpdateContext: runtime probing + env overrides', () => {
test('opencode global under $HOME/.config/opencode', () => {
const dir = `${HOME}/.config/opencode`;
const fs = fakeFs({ [ver(dir)]: '1.40.0\n', [marker(dir)]: 'x' });
const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs });
assert.equal(r.runtime, 'opencode');
assert.ok(sameDir(r.gsdDir, dir), `gsdDir was ${r.gsdDir}`);
});
test('CLAUDE_CONFIG_DIR env override locates a custom global dir', () => {
const custom = '/opt/claude-home';
const fs = fakeFs({ [ver(custom)]: '1.40.0\n', [marker(custom)]: 'x' });
const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: { CLAUDE_CONFIG_DIR: custom }, fs });
assert.equal(r.scope, 'GLOBAL');
assert.equal(r.runtime, 'claude');
assert.ok(sameDir(r.gsdDir, custom), `gsdDir was ${r.gsdDir}`);
});
test('preferredConfigDir fast-path: trusts a validated custom dir as GLOBAL', () => {
const custom = '/opt/gsd-x';
const fs = fakeFs({ [ver(custom)]: '1.41.0\n', [marker(custom)]: 'x' });
const r = resolveUpdateContext({
home: HOME, cwd: CWD, env: {}, fs,
preferredConfigDir: custom, preferredRuntime: 'kilo',
});
assert.equal(r.scope, 'GLOBAL');
assert.equal(r.runtime, 'kilo');
assert.ok(sameDir(r.gsdDir, custom), `gsdDir was ${r.gsdDir}`);
assert.equal(r.installedVersion, '1.41.0');
});
});
describe('gsd-tools update-context (CLI): emits the JSON contract', () => {
test('--config-dir fixture resolves to the documented 4-field JSON', () => {
const tmp = nodeFs.mkdtempSync(path.join(os.tmpdir(), 'gsd-uc-'));
try {
nodeFs.mkdirSync(path.join(tmp, 'get-shit-done', 'workflows'), { recursive: true });
nodeFs.writeFileSync(path.join(tmp, 'get-shit-done', 'VERSION'), '1.42.0\n');
nodeFs.writeFileSync(path.join(tmp, 'get-shit-done', 'workflows', 'update.md'), 'x');
const out = execFileSync(
process.execPath,
[GSD_TOOLS, 'update-context', '--config-dir', tmp, '--runtime', 'kilo', '--json'],
{ encoding: 'utf8', env: { ...process.env, GSD_TEST_MODE: '1' } },
);
const ctx = JSON.parse(out);
assert.deepEqual(Object.keys(ctx).sort(), ['gsdDir', 'installedVersion', 'runtime', 'scope']);
assert.equal(ctx.installedVersion, '1.42.0');
assert.equal(ctx.scope, 'GLOBAL');
assert.equal(ctx.runtime, 'kilo');
} finally {
nodeFs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe('resolveUpdateContext: parity with the old inline bash (adversarial-review)', () => {
test('preferredConfigDir with a leading ~/ is expanded before the fast path', () => {
// The old inline bash ran `expand_home "$PREFERRED_CONFIG_DIR"` first, so a
// custom --config-dir like ~/custom-gsd must resolve, not fall to UNKNOWN.
const fs = fakeFs({
[ver(`${HOME}/custom-gsd`)]: '1.41.0\n',
[marker(`${HOME}/custom-gsd`)]: 'x',
});
const r = resolveUpdateContext({
home: HOME, cwd: CWD, env: {}, fs, preferredConfigDir: '~/custom-gsd',
});
assert.equal(r.installedVersion, '1.41.0');
assert.equal(r.scope, 'GLOBAL');
assert.ok(sameDir(r.gsdDir, `${HOME}/custom-gsd`), `gsdDir was ${r.gsdDir}`);
});
test('a VERSION-only dir (no update.md marker) is NOT trusted as a real version', () => {
// The old cascade required BOTH VERSION and the update.md marker before
// trusting the version; a partial dir falls to 0.0.0 but keeps scope.
const fs = fakeFs({ [ver(`${HOME}/.claude`)]: '1.40.0\n' }); // marker absent
const r = resolveUpdateContext({ home: HOME, cwd: CWD, env: {}, fs });
assert.equal(r.installedVersion, '0.0.0', 'VERSION-only dir must not be trusted');
assert.equal(r.scope, 'GLOBAL');
assert.equal(r.runtime, 'claude');
assert.ok(sameDir(r.gsdDir, `${HOME}/.claude`), `gsdDir was ${r.gsdDir}`);
});
test('fast path also requires the marker: VERSION-only preferredConfigDir -> 0.0.0', () => {
// The "trust = VERSION + marker" rule is consistent across every path, not
// just the cascade. A custom --config-dir with VERSION but no marker is a
// partial install: keep the dir/scope, report 0.0.0.
const custom = '/opt/gsd-partial';
const fs = fakeFs({ [ver(custom)]: '1.41.0\n' }); // marker absent
const r = resolveUpdateContext({
home: HOME, cwd: CWD, env: {}, fs, preferredConfigDir: custom, preferredRuntime: 'kilo',
});
assert.equal(r.installedVersion, '0.0.0', 'VERSION-only fast path must not be trusted');
assert.equal(r.scope, 'GLOBAL');
assert.ok(sameDir(r.gsdDir, custom), `gsdDir was ${r.gsdDir}`);
});
test('partial install with cwd===home does NOT misdetect as LOCAL (fallback dedup)', () => {
// Same same-path dedup the trusted path uses must apply to the 0.0.0
// fallback: a VERSION-only ~/.claude probed from cwd===home is GLOBAL.
const fs = fakeFs({ [ver(`${HOME}/.claude`)]: '1.40.0\n' }); // marker absent
const r = resolveUpdateContext({ home: HOME, cwd: HOME, env: {}, fs });
assert.equal(r.installedVersion, '0.0.0');
assert.equal(r.scope, 'GLOBAL', 'cwd===home partial must be GLOBAL, not LOCAL');
assert.ok(sameDir(r.gsdDir, `${HOME}/.claude`), `gsdDir was ${r.gsdDir}`);
});
});