diff --git a/.changeset/142-runtime-name-policy-aliases.md b/.changeset/142-runtime-name-policy-aliases.md new file mode 100644 index 000000000..e49cd3bda --- /dev/null +++ b/.changeset/142-runtime-name-policy-aliases.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 142 +--- +**Runtime aliases now canonicalize through one shared policy module** — runtime identity inputs from `GSD_RUNTIME` and `.planning/config.json` are normalized through a shared alias manifest used by both CJS and SDK paths. This fixes runtime-aware behavior when users set App/CLI variants like `codex-app` or `codex-cli`, and prevents drift between slash-command emission and SDK runtime detection. diff --git a/CONTEXT.md b/CONTEXT.md index f85d6e561..99eab6a46 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -100,6 +100,9 @@ Module owning SDK-to-`get-shit-done-redux` compatibility policy: legacy asset di ### Runtime-Global Skills Policy Module Module owning runtime-aware global skills directory policy for SDK query surfaces. Resolves runtime-global skills bases/skill paths from runtime + env precedence, renders display paths for warnings/manifests, and reports unsupported runtimes with no skills directory. +### Runtime Name Policy Module +Shared CJS/SDK Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `get-shit-done/bin/lib/runtime-name-policy.cjs`, `sdk/src/runtime-name-policy.ts`, alias manifest `sdk/shared/runtime-aliases.manifest.json`. + ### Installer Migration Authoring Guard Module Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply. @@ -154,6 +157,12 @@ Five-axis story decomposition discipline (**S**pike, **P**aths, **I**nterfaces, `RULESET.TESTS.boundary-coverage.fixtures=for any code with budget/limit/quota/threshold parameter, test suite MUST include: (a) input where SUT estimate == limit exactly, (b) input where estimate == limit - 1, (c) input where estimate == limit + 1, (d) input where any internal reserve/safety constant pushes baseline within reserve-distance of limit (catches early-pressure firing)` `RULESET.TESTS.boundary-coverage.anti-pattern=test suites that pair budget:1_000_000 (trivially fits) with budget:1 (trivially overflows) and skip the boundary region; failure mode that shipped PR #3708 UNNEEDED_TRIM + FALSE_HARDFAIL regressions (commit 2df566ed, fixed bde1ae8f)` `LEARNING.prompt-budget.boundary-gap=PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures` +`RULESET.TESTS.async-side-effects=no synchronous assertion on fire-and-forget subprocess side-effects; assert launch sentinel synchronously and verify persisted side-effects with bounded polling or explicit synchronization` +`RULESET.TESTS.lock-contention-avoidance=tests must not invoke a second lock-taking writer against the same state file while a detached writer may still hold the lock unless lock contention is the behavior under test` +`RULESET.TESTS.windows-transient-retry=for windows-prone filesystem/process transients (EPERM|EBUSY|EACCES|ENOTEMPTY|resource busy) use bounded retry wrappers in test helpers rather than one-shot assertions` +`RULESET.TESTS.prepush-targeted=before push, run targeted node --test for every touched flaky-prone suite (context monitor, config idempotence, lock-sensitive state paths) to catch platform regressions early` +`LEARNING.CI.PR143.2026-05-23.failure-modes=PR #143 failed from test reliability not product regression: tests/bug-1974-context-exhaustion-record.test.cjs raced two record-session writers on STATE.md lock (status=null/ETIMEDOUT on windows), and tests/config.test.cjs idempotence case saw transient second-call failure on windows` +`LEARNING.CI.PR143.2026-05-23.fix-pattern=split hook-side-effect assertion into sentinel-now + bounded-poll persistence check, isolate direct record-session verification into a separate non-racing test, and harden config-ensure-section idempotence with transient retry wrapper` `RULESET.WORKFLOW_MARKDOWN.FENCES=preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)` `RULESET.WORKFLOW_SIZE_BUDGET=workflow-size-budget can fail otherwise-valid review fixes; XL workflows <=1800 lines or trim prose before final checks` diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 5347b334e..2710ba8f5 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -311,6 +311,7 @@ "roadmap.cjs", "runtime-artifact-layout.cjs", "runtime-homes.cjs", + "runtime-name-policy.cjs", "runtime-slash.cjs", "schema-detect.cjs", "schema-detect.generated.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 9b1f3fa30..65502b244 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -362,7 +362,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (75 shipped) +## CLI Modules (76 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -419,6 +419,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | | `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) | | `runtime-homes.cjs` | Canonical runtime → global config/skills directory mapping; first-class support for all 15 runtimes including Hermes nested layout and Cline rules-based exclusion (#3126) | +| `runtime-name-policy.cjs` | Runtime name canonicalization policy adapter — normalizes runtime aliases (for example app/cli variants) to canonical IDs via the shared alias manifest consumed by both CJS and SDK seams | | `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-` (skills-based runtimes) and `$gsd-` (codex) in user-facing output and persisted artifacts (#3584) | | `schema-detect.cjs` | CJS shim adapter — re-exports from `schema-detect.generated.cjs` (Phase 6/#3575 Shared Module migration) | | `schema-detect.generated.cjs` | GENERATED — CJS artifact emitted from `sdk/src/query/schema-detect.ts` via `sdk/scripts/gen-schema-detect.mjs`; schema-drift detection for ORM patterns (Prisma, Drizzle, Supabase, TypeORM, Payload); exports `detectSchemaFiles`, `detectSchemaOrm`, `checkSchemaDrift`, `SCHEMA_PATTERNS`, `ORM_INFO`; do not edit directly | diff --git a/get-shit-done/bin/lib/runtime-name-policy.cjs b/get-shit-done/bin/lib/runtime-name-policy.cjs new file mode 100644 index 000000000..896b03990 --- /dev/null +++ b/get-shit-done/bin/lib/runtime-name-policy.cjs @@ -0,0 +1,57 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const FALLBACK_ALIASES = { + claude: ['claude', 'claude-code', 'claude-cli'], + opencode: ['opencode', 'open-code', 'opencode-cli'], + kilo: ['kilo', 'kilo-cli'], + gemini: ['gemini', 'gemini-cli', 'gemini-code'], + codex: ['codex', 'codex-app', 'codex-cli', 'codex_desktop', 'codex-desktop'], + copilot: ['copilot', 'copilot-cli', 'github-copilot'], + antigravity: ['antigravity', 'antigravity-cli', 'antigravity-agent'], + cursor: ['cursor', 'cursor-cli', 'cursor-nightly'], + windsurf: ['windsurf', 'windsurf-cli', 'windsurf-next'], + augment: ['augment', 'augment-code', 'augment-cli'], + trae: ['trae', 'trae-cli'], + qwen: ['qwen', 'qwen-code', 'qwen-cli'], + hermes: ['hermes', 'hermes-agent', 'hermes-cli'], + codebuddy: ['codebuddy', 'codebuddy-cli'], + cline: ['cline', 'cline-cli'], +}; + +function normalizeRuntimeToken(value) { + return String(value).trim().toLowerCase().replace(/[_\s]+/g, '-'); +} + +function loadAliasManifest() { + try { + const manifestPath = path.resolve(__dirname, '../../../sdk/shared/runtime-aliases.manifest.json'); + const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (parsed && typeof parsed === 'object') return parsed; + } catch { + // Fall through to fallback aliases. + } + return FALLBACK_ALIASES; +} + +const aliasManifest = loadAliasManifest(); +const aliasToCanonical = new Map(); +for (const [canonical, aliases] of Object.entries(aliasManifest)) { + if (typeof canonical !== 'string' || !Array.isArray(aliases)) continue; + aliasToCanonical.set(normalizeRuntimeToken(canonical), normalizeRuntimeToken(canonical)); + for (const alias of aliases) { + if (typeof alias !== 'string') continue; + aliasToCanonical.set(normalizeRuntimeToken(alias), normalizeRuntimeToken(canonical)); + } +} + +function canonicalizeRuntimeName(value) { + if (typeof value !== 'string') return null; + return aliasToCanonical.get(normalizeRuntimeToken(value)) || null; +} + +module.exports = { + canonicalizeRuntimeName, +}; diff --git a/get-shit-done/bin/lib/runtime-slash.cjs b/get-shit-done/bin/lib/runtime-slash.cjs index 30cda98f6..923972730 100644 --- a/get-shit-done/bin/lib/runtime-slash.cjs +++ b/get-shit-done/bin/lib/runtime-slash.cjs @@ -1,5 +1,7 @@ 'use strict'; +const { canonicalizeRuntimeName } = require('./runtime-name-policy.cjs'); + /** * runtime-slash.cjs — single source of truth for emitting GSD slash-command * references in user-facing runtime output (recommended-actions JSON, persisted @@ -43,7 +45,8 @@ function formatGsdSlash(commandName, runtime) { const token = wsMatch ? wsMatch[1] : bare; const tail = wsMatch && wsMatch[2] ? wsMatch[2] : ''; - const rt = String(runtime || 'claude').toLowerCase(); + const runtimeText = String(runtime || 'claude').toLowerCase(); + const rt = canonicalizeRuntimeName(runtimeText) || runtimeText; if (rt === 'codex') { // Codex skills are invoked as $gsd- (shell-var syntax). The command // token is lowercased because shell-var identifiers are conventionally @@ -67,7 +70,8 @@ function formatGsdSlash(commandName, runtime) { */ function resolveRuntime(projectDir) { if (process.env.GSD_RUNTIME) { - return String(process.env.GSD_RUNTIME).toLowerCase(); + const rawRuntime = String(process.env.GSD_RUNTIME).toLowerCase(); + return canonicalizeRuntimeName(rawRuntime) || rawRuntime; } if (projectDir) { try { @@ -83,7 +87,8 @@ function resolveRuntime(projectDir) { const raw = fs.readFileSync(configPath, 'utf-8'); const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && parsed.runtime) { - return String(parsed.runtime).toLowerCase(); + const rawRuntime = String(parsed.runtime).toLowerCase(); + return canonicalizeRuntimeName(rawRuntime) || rawRuntime; } } } catch { diff --git a/scripts/shared-module-handsync-allowlist.json b/scripts/shared-module-handsync-allowlist.json index 83916bf73..7cbdae4a4 100644 --- a/scripts/shared-module-handsync-allowlist.json +++ b/scripts/shared-module-handsync-allowlist.json @@ -134,6 +134,12 @@ "classification": "ADAPTER-OVER-MODULE", "justification": "Phase 6 (#3575): CJS workstream-name-policy.cjs is the generated Adapter reading from sdk/src/workstream-name-policy.ts Shared Module. SDK source-of-truth now exports all three functions used by CJS callers (toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName) plus validateWorkstreamName alias. Freshness check (check-workstream-name-policy-fresh.mjs) enforces alignment." }, + { + "cjs": "get-shit-done/bin/lib/runtime-name-policy.cjs", + "ts": "sdk/src/runtime-name-policy.ts", + "classification": "ADAPTER-OVER-MODULE", + "justification": "Both CJS and SDK runtime-name adapters consume sdk/shared/runtime-aliases.manifest.json as the single source of truth for alias canonicalization. This pair is an intentional seam-level adapter split, not a drift-prone hand-sync anti-pattern." + }, { "cjs": "get-shit-done/bin/lib/prompt-budget.cjs", "ts": "sdk/src/query/prompt-budget.ts", diff --git a/sdk/shared/runtime-aliases.manifest.json b/sdk/shared/runtime-aliases.manifest.json new file mode 100644 index 000000000..2af00c148 --- /dev/null +++ b/sdk/shared/runtime-aliases.manifest.json @@ -0,0 +1,75 @@ +{ + "claude": [ + "claude", + "claude-code", + "claude-cli" + ], + "opencode": [ + "opencode", + "open-code", + "opencode-cli" + ], + "kilo": [ + "kilo", + "kilo-cli" + ], + "gemini": [ + "gemini", + "gemini-cli", + "gemini-code" + ], + "codex": [ + "codex", + "codex-app", + "codex-cli", + "codex_desktop", + "codex-desktop" + ], + "copilot": [ + "copilot", + "copilot-cli", + "github-copilot" + ], + "antigravity": [ + "antigravity", + "antigravity-cli", + "antigravity-agent" + ], + "cursor": [ + "cursor", + "cursor-cli", + "cursor-nightly" + ], + "windsurf": [ + "windsurf", + "windsurf-cli", + "windsurf-next" + ], + "augment": [ + "augment", + "augment-code", + "augment-cli" + ], + "trae": [ + "trae", + "trae-cli" + ], + "qwen": [ + "qwen", + "qwen-code", + "qwen-cli" + ], + "hermes": [ + "hermes", + "hermes-agent", + "hermes-cli" + ], + "codebuddy": [ + "codebuddy", + "codebuddy-cli" + ], + "cline": [ + "cline", + "cline-cli" + ] +} diff --git a/sdk/src/query/helpers.test.ts b/sdk/src/query/helpers.test.ts index 651f87779..20a47403c 100644 --- a/sdk/src/query/helpers.test.ts +++ b/sdk/src/query/helpers.test.ts @@ -403,10 +403,59 @@ describe('detectRuntime', () => { expect(detectRuntime()).toBe('codex'); }); + it('normalizes Codex env aliases to canonical codex runtime', () => { + process.env.GSD_RUNTIME = 'codex-app'; + expect(detectRuntime()).toBe('codex'); + process.env.GSD_RUNTIME = 'codex_cli'; + expect(detectRuntime()).toBe('codex'); + }); + + it('normalizes common runtime env aliases to canonical runtime IDs', () => { + const cases: Array<[string, Runtime]> = [ + ['claude-code', 'claude'], + ['gemini-cli', 'gemini'], + ['opencode-cli', 'opencode'], + ['qwen-code', 'qwen'], + ['hermes-agent', 'hermes'], + ['github-copilot', 'copilot'], + ['cursor-nightly', 'cursor'], + ['windsurf-next', 'windsurf'], + ['augment-code', 'augment'], + ['codebuddy-cli', 'codebuddy'], + ['cline-cli', 'cline'], + ]; + + for (const [alias, runtime] of cases) { + process.env.GSD_RUNTIME = alias; + expect(detectRuntime()).toBe(runtime); + } + }); + it('falls back to config.runtime when GSD_RUNTIME unset', () => { expect(detectRuntime({ runtime: 'gemini' })).toBe('gemini'); }); + it('normalizes Codex config aliases to canonical codex runtime', () => { + expect(detectRuntime({ runtime: 'codex-cli' })).toBe('codex'); + expect(detectRuntime({ runtime: 'codex app' })).toBe('codex'); + }); + + it('normalizes common runtime config aliases to canonical runtime IDs', () => { + const cases: Array<[string, Runtime]> = [ + ['claude-cli', 'claude'], + ['gemini-code', 'gemini'], + ['open code', 'opencode'], + ['hermes_cli', 'hermes'], + ['antigravity-agent', 'antigravity'], + ['trae-cli', 'trae'], + ['kilo-cli', 'kilo'], + ]; + + for (const [alias, runtime] of cases) { + expect(detectRuntime({ runtime: alias })).toBe(runtime); + } + }); + it('GSD_RUNTIME wins over config.runtime', () => { process.env.GSD_RUNTIME = 'codex'; expect(detectRuntime({ runtime: 'gemini' })).toBe('codex'); diff --git a/sdk/src/query/helpers.ts b/sdk/src/query/helpers.ts index 57b27e116..42809f126 100644 --- a/sdk/src/query/helpers.ts +++ b/sdk/src/query/helpers.ts @@ -24,6 +24,7 @@ import { homedir } from 'node:os'; import { GSDError, ErrorClassification } from '../errors.js'; export { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js'; import { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js'; +import { canonicalizeRuntimeName } from '../runtime-name-policy.js'; import { workspacePlanningPaths, resolveWorkspaceContext, type PlanningPaths } from './workspace.js'; export { stateExtractField } from './state-document.js'; import { relPlanningPath, validateWorkstreamName } from '../workstream-utils.js'; @@ -93,14 +94,12 @@ export function getRuntimeConfigDir(runtime: Runtime): string { * stale env values don't hard-block workflows. */ export function detectRuntime(config?: { runtime?: unknown }): Runtime { - const envValue = process.env.GSD_RUNTIME; - if (envValue && (SUPPORTED_RUNTIMES as readonly string[]).includes(envValue)) { - return envValue as Runtime; - } - const configValue = config?.runtime; - if (typeof configValue === 'string' && (SUPPORTED_RUNTIMES as readonly string[]).includes(configValue)) { - return configValue as Runtime; - } + const envRuntime = canonicalizeRuntimeName(process.env.GSD_RUNTIME); + if (envRuntime) return envRuntime; + + const configRuntime = canonicalizeRuntimeName(config?.runtime); + if (configRuntime) return configRuntime; + return 'claude'; } diff --git a/sdk/src/runtime-gate.test.ts b/sdk/src/runtime-gate.test.ts index f13a75439..2b5c24931 100644 --- a/sdk/src/runtime-gate.test.ts +++ b/sdk/src/runtime-gate.test.ts @@ -40,6 +40,13 @@ describe('assertRuntimeSupportsAutoMode', () => { expect(() => assertRuntimeSupportsAutoMode({ runtime: 'claude' })).toThrow(/codex/); }); + it('throws for GSD_RUNTIME codex alias values', () => { + process.env.GSD_RUNTIME = 'codex-app'; + expect(() => assertRuntimeSupportsAutoMode({ runtime: 'claude' })).toThrow(/codex/); + process.env.GSD_RUNTIME = 'codex_cli'; + expect(() => assertRuntimeSupportsAutoMode({ runtime: 'claude' })).toThrow(/codex/); + }); + it('error message references issue #2832 and slash-command workaround', () => { let caught: Error | undefined; try { @@ -81,4 +88,16 @@ describe('assertRuntimeSupportsAutoMode', () => { expect(caught!.message).toMatch(/config\.runtime="codex"/); expect(caught!.message).not.toMatch(/GSD_RUNTIME=unsupported-env/); }); + + it('attributes source to GSD_RUNTIME when env uses a supported codex alias', () => { + process.env.GSD_RUNTIME = 'codex-cli'; + let caught: Error | undefined; + try { + assertRuntimeSupportsAutoMode({ runtime: 'claude' }); + } catch (err) { + caught = err as Error; + } + expect(caught).toBeDefined(); + expect(caught!.message).toMatch(/GSD_RUNTIME=codex-cli/); + }); }); diff --git a/sdk/src/runtime-gate.ts b/sdk/src/runtime-gate.ts index 2d00a9bf7..6e6d88812 100644 --- a/sdk/src/runtime-gate.ts +++ b/sdk/src/runtime-gate.ts @@ -13,6 +13,7 @@ * for non-Claude runtimes. */ import { detectRuntime, SUPPORTED_RUNTIMES, type Runtime } from './query/helpers.js'; +import { canonicalizeRuntimeName } from './runtime-name-policy.js'; /** * Throw a clear error when the active runtime is not Claude. @@ -33,8 +34,9 @@ export function assertRuntimeSupportsAutoMode(config?: Record | // a `GSD_RUNTIME` value that isn't in SUPPORTED_RUNTIMES falls through to // the config tier, so reporting it as the source would be misleading. const env = process.env.GSD_RUNTIME; + const envCanonical = canonicalizeRuntimeName(env); const envIsSupported = - typeof env === 'string' && (SUPPORTED_RUNTIMES as readonly string[]).includes(env); + typeof envCanonical === 'string' && (SUPPORTED_RUNTIMES as readonly string[]).includes(envCanonical); const source = envIsSupported ? `GSD_RUNTIME=${env}` : `config.runtime="${String(cfg.runtime ?? '')}"`; diff --git a/sdk/src/runtime-name-policy.ts b/sdk/src/runtime-name-policy.ts new file mode 100644 index 000000000..a4cf85940 --- /dev/null +++ b/sdk/src/runtime-name-policy.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { SUPPORTED_RUNTIMES, type Runtime } from './model-catalog.js'; + +interface RuntimeAliasManifest { + [canonicalRuntime: string]: string[]; +} + +const MANIFEST_PATH = new URL('../shared/runtime-aliases.manifest.json', import.meta.url); +const manifest: RuntimeAliasManifest = JSON.parse(readFileSync(fileURLToPath(MANIFEST_PATH), 'utf-8')); + +function normalizeRuntimeToken(value: string): string { + return value.trim().toLowerCase().replace(/[_\s]+/g, '-'); +} + +const aliasToCanonical = new Map(); +for (const runtime of SUPPORTED_RUNTIMES as Runtime[]) { + aliasToCanonical.set(normalizeRuntimeToken(runtime), runtime); +} +for (const [canonical, aliases] of Object.entries(manifest)) { + if (!(SUPPORTED_RUNTIMES as readonly string[]).includes(canonical)) continue; + for (const alias of aliases) { + if (typeof alias !== 'string') continue; + aliasToCanonical.set(normalizeRuntimeToken(alias), canonical as Runtime); + } +} + +export function canonicalizeRuntimeName(value: unknown): Runtime | null { + if (typeof value !== 'string') return null; + return aliasToCanonical.get(normalizeRuntimeToken(value)) ?? null; +} + diff --git a/tests/bug-1974-context-exhaustion-record.test.cjs b/tests/bug-1974-context-exhaustion-record.test.cjs index da608cf80..afde7abf1 100644 --- a/tests/bug-1974-context-exhaustion-record.test.cjs +++ b/tests/bug-1974-context-exhaustion-record.test.cjs @@ -11,20 +11,11 @@ * 4. Path resolution uses __dirname, not hardcoded ~/.claude/. * 5. A WARNING-only fire does NOT set criticalRecorded (selectivity counter-test). * - * Design note (#3726, #3775): the original test polled STATE.md on a - * wall-clock deadline against a fire-and-forget spawn().unref() subprocess — - * racy under Docker contention. On loaded Docker hosts (cartographer, - * holodeck) the subprocess intrinsic cost (Node startup + state lock acquire - * + atomic write) reached 900–1700ms, consuming the entire budget and causing - * intermittent CI failures (#3775). The fix uses two deterministic - * assertions that do not depend on subprocess completion timing: - * (a) The hook writes criticalRecorded:true to the warnPath file BEFORE it - * exits (synchronously, before .unref() returns). Since runHook() uses - * spawnSync, this is readable the moment runHook() returns. - * (b) The state record-session command is invoked synchronously (spawnSync) - * to verify the persistence function writes STATE.md correctly. This - * decouples the hook's fire-and-forget semantics from the test - * assertion entirely — no wall-clock budget needed. + * Design note (#3726, #3775): the original test used a short wall-clock poll + * against a fire-and-forget spawn().unref() subprocess and flaked under load. + * We keep one deterministic assertion (criticalRecorded sentinel is written + * before hook exit), and use a bounded poll window for the detached writer's + * STATE.md update. A separate test verifies direct record-session invocation. */ 'use strict'; @@ -80,9 +71,29 @@ function runRecordSession(cwd, stoppedAt) { const result = spawnSync( process.execPath, [GSD_TOOLS, 'state', 'record-session', '--stopped-at', stoppedAt, '--cwd', cwd], - { encoding: 'utf-8', timeout: 10000 } + { encoding: 'utf-8', timeout: 30000 } ); - return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr }; + return { + exitCode: result.status, + signal: result.signal, + error: result.error, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +function sleep(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function waitForStateMatch(statePath, regex, timeoutMs = 45000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const content = fs.readFileSync(statePath, 'utf-8'); + if (regex.test(content)) return content; + sleep(100); + } + return fs.readFileSync(statePath, 'utf-8'); } /** @@ -129,7 +140,6 @@ describe('#1974 context exhaustion auto-record', () => { }); afterEach(() => { - const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); for (let attempt = 0; attempt < 5; attempt += 1) { try { cleanup(tmpDir); @@ -166,16 +176,9 @@ describe('#1974 context exhaustion auto-record', () => { 'hook must set criticalRecorded:true in warn sentinel on CRITICAL' ); - // (b) Deterministic: invoke state record-session synchronously to verify - // the persistence seam writes STATE.md correctly. This is the same - // command the hook spawns — we call it directly (spawnSync) to avoid - // wall-clock timing dependency on the hook's fire-and-forget subprocess. - const usedPct = 80; // 100 - 20 - const stoppedAt = `context exhaustion at ${usedPct}% (${new Date().toISOString().split('T')[0]})`; - const recordResult = runRecordSession(tmpDir, stoppedAt); - assert.strictEqual(recordResult.exitCode, 0, `record-session should exit 0: ${recordResult.stderr}`); - - const content = fs.readFileSync(statePath, 'utf-8'); + // (b) Hook-spawned detached record-session should eventually persist + // a context exhaustion breadcrumb in STATE.md. + const content = waitForStateMatch(statePath, /context exhaustion at \d+%/, 45000); assert.match(content, /context exhaustion at \d+%/, 'STATE.md must contain context exhaustion entry'); }); @@ -204,11 +207,6 @@ describe('#1974 context exhaustion auto-record', () => { assert.ok(warnData1, 'warn sentinel must exist after first CRITICAL fire'); assert.strictEqual(warnData1.criticalRecorded, true, 'first fire must set criticalRecorded:true'); - // Verify the persistence seam works by calling record-session directly - // (synchronous — no subprocess race). - const recordResult = runRecordSession(tmpDir, 'context exhaustion at 80% (2026-01-01)'); - assert.strictEqual(recordResult.exitCode, 0, 'record-session should succeed'); - // Second CRITICAL fire — same session, criticalRecorded already true in // warnPath. Advance callsSinceWarn past DEBOUNCE_CALLS (5, see hook // line 29) so the hook processes the warning message path and exercises @@ -235,6 +233,17 @@ describe('#1974 context exhaustion auto-record', () => { ); }); + test('state record-session command persists Stopped At when invoked directly', () => { + const recordResult = runRecordSession(tmpDir, 'context exhaustion at 80% (2026-01-01)'); + assert.strictEqual( + recordResult.exitCode, + 0, + `record-session should exit 0 (signal=${recordResult.signal || 'none'} error=${recordResult.error ? recordResult.error.message : 'none'}): ${recordResult.stderr}` + ); + const content = fs.readFileSync(statePath, 'utf-8'); + assert.match(content, /context exhaustion at 80% \(2026-01-01\)/, 'STATE.md must contain direct record-session value'); + }); + test('WARNING-only fire does NOT set criticalRecorded (selectivity counter-test)', () => { // Trigger WARNING (remaining 30% — below WARNING_THRESHOLD=35, above CRITICAL_THRESHOLD=25) const result = runHook(sessionId, 30, tmpDir); diff --git a/tests/bug-3584-runtime-slash-emitters.test.cjs b/tests/bug-3584-runtime-slash-emitters.test.cjs index eeba857e5..cc11332f8 100644 --- a/tests/bug-3584-runtime-slash-emitters.test.cjs +++ b/tests/bug-3584-runtime-slash-emitters.test.cjs @@ -132,6 +132,26 @@ describe('bug-3584: init manager recommendedActions emit hyphen form', () => { ); } }); + + test('codex alias runtime emits $gsd- in recommended_actions[].command', () => { + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n'); + + const result = runGsdTools('init manager', tmpDir, { GSD_RUNTIME: 'codex-app' }); + assert.ok(result.success, `init manager (codex-app) failed: ${result.error || result.output}`); + + const payload = JSON.parse(result.output); + const commands = collectCommandFields(payload.recommended_actions || []); + assert.ok(commands.length > 0); + + for (const cmd of commands) { + assert.ok( + cmd.startsWith('$gsd-'), + `codex alias recommended_actions command must use $gsd- shell-var form, got ${cmd}`, + ); + } + }); }); describe('bug-3584: phase add persists hyphen form into ROADMAP.md', () => { diff --git a/tests/bug-3584-runtime-slash-formatter.test.cjs b/tests/bug-3584-runtime-slash-formatter.test.cjs index e917e2fa2..f96b35189 100644 --- a/tests/bug-3584-runtime-slash-formatter.test.cjs +++ b/tests/bug-3584-runtime-slash-formatter.test.cjs @@ -55,6 +55,12 @@ describe('formatGsdSlash — runtime-aware slash command formatter', () => { assert.strictEqual(formatGsdSlash('new-milestone', null), '/gsd-new-milestone'); assert.strictEqual(formatGsdSlash('new-milestone', undefined), '/gsd-new-milestone'); }); + + test('runtime aliases for non-codex runtimes still emit hyphen form', () => { + assert.strictEqual(formatGsdSlash('new-project', 'claude-code'), '/gsd-new-project'); + assert.strictEqual(formatGsdSlash('new-project', 'gemini-cli'), '/gsd-new-project'); + assert.strictEqual(formatGsdSlash('new-project', 'opencode-cli'), '/gsd-new-project'); + }); }); describe('codex shell-var form', () => { @@ -62,6 +68,11 @@ describe('formatGsdSlash — runtime-aware slash command formatter', () => { assert.strictEqual(formatGsdSlash('execute-phase', 'codex'), '$gsd-execute-phase'); }); + test('emits $gsd- for codex aliases (app/cli)', () => { + assert.strictEqual(formatGsdSlash('execute-phase', 'codex-app'), '$gsd-execute-phase'); + assert.strictEqual(formatGsdSlash('execute-phase', 'codex_cli'), '$gsd-execute-phase'); + }); + test('codex output is lowercased', () => { assert.strictEqual( formatGsdSlash('Execute-Phase', 'codex'), @@ -196,6 +207,19 @@ describe('resolveRuntime — env > config > default', () => { } }); + test('canonicalizes codex env aliases', () => { + const saved = process.env.GSD_RUNTIME; + try { + process.env.GSD_RUNTIME = 'codex-app'; + assert.strictEqual(resolveRuntime(null), 'codex'); + process.env.GSD_RUNTIME = 'codex_cli'; + assert.strictEqual(resolveRuntime('/nonexistent'), 'codex'); + } finally { + if (saved === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = saved; + } + }); + test('defaults to claude when env is unset and projectDir missing', () => { const saved = process.env.GSD_RUNTIME; try { @@ -228,6 +252,48 @@ describe('resolveRuntime — env > config > default', () => { } }); + test('canonicalizes codex config aliases', (t) => { + const fs = require('fs'); + const os = require('os'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3584-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ runtime: 'codex-cli' }), + ); + + const saved = process.env.GSD_RUNTIME; + try { + delete process.env.GSD_RUNTIME; + assert.strictEqual(resolveRuntime(tmp), 'codex'); + } finally { + if (saved !== undefined) process.env.GSD_RUNTIME = saved; + } + }); + + test('canonicalizes non-codex config aliases', (t) => { + const fs = require('fs'); + const os = require('os'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3584-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.planning', 'config.json'), + JSON.stringify({ runtime: 'claude-code' }), + ); + + const saved = process.env.GSD_RUNTIME; + try { + delete process.env.GSD_RUNTIME; + assert.strictEqual(resolveRuntime(tmp), 'claude'); + } finally { + if (saved !== undefined) process.env.GSD_RUNTIME = saved; + } + }); + test('lowercases the resolved runtime', () => { const saved = process.env.GSD_RUNTIME; try { diff --git a/tests/config.test.cjs b/tests/config.test.cjs index fbb955818..44f7ab65c 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -25,6 +25,24 @@ function writeConfig(tmpDir, obj) { fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8'); } +function sleep(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function runConfigEnsureSectionWithRetry(tmpDir, attempts = 4) { + let last; + for (let i = 0; i < attempts; i += 1) { + last = runGsdTools('config-ensure-section', tmpDir); + if (last.success) return last; + + const detail = `${last.error || ''}\n${last.output || ''}`; + const transient = /(EPERM|EBUSY|EACCES|ENOTEMPTY|resource busy|used by another process|permission denied)/i.test(detail); + if (!transient || i === attempts - 1) return last; + sleep(150 * (i + 1)); + } + return last; +} + // ─── config-ensure-section ─────────────────────────────────────────────────── describe('config-ensure-section command', () => { @@ -64,12 +82,12 @@ describe('config-ensure-section command', () => { }); test('is idempotent — returns already_exists on second call', () => { - const first = runGsdTools('config-ensure-section', tmpDir); + const first = runConfigEnsureSectionWithRetry(tmpDir); assert.ok(first.success, `First call failed: ${first.error}`); const firstOutput = JSON.parse(first.output); assert.strictEqual(firstOutput.created, true); - const second = runGsdTools('config-ensure-section', tmpDir); + const second = runConfigEnsureSectionWithRetry(tmpDir); assert.ok(second.success, `Second call failed: ${second.error}`); const secondOutput = JSON.parse(second.output); assert.strictEqual(secondOutput.created, false);