enh(#142): centralize runtime alias canonicalization seam (#143)

* fix(#142): canonicalize runtime aliases across cjs and sdk

* fix(#142): satisfy hand-sync and inventory parity gates

* test(#1974): remove record-session lock contention in context monitor spec

* test(config): retry transient config-ensure-section failures

* docs(context): capture PR #143 CI reliability findings

* fix(#142): bump CLI Modules inventory headline to 76 (runtime-name-policy + runtime-slash)

docs/INVENTORY.md had the two new .cjs rows listed but the headline
count stayed at 75; fs count is 76. inventory-counts.test.cjs caught
the drift.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-23 18:39:02 -04:00
committed by GitHub
parent 33ffc647e2
commit 63396dbb16
17 changed files with 420 additions and 47 deletions

View File

@@ -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.

View File

@@ -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`

View File

@@ -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",

View File

@@ -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-<cmd>` (skills-based runtimes) and `$gsd-<cmd>` (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 |

View File

@@ -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,
};

View File

@@ -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-<cmd> (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 {

View File

@@ -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",

View File

@@ -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"
]
}

View File

@@ -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');

View File

@@ -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';
}

View File

@@ -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/);
});
});

View File

@@ -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<string, unknown> |
// 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 ?? '')}"`;

View File

@@ -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<string, Runtime>();
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;
}

View File

@@ -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);

View File

@@ -132,6 +132,26 @@ describe('bug-3584: init manager recommendedActions emit hyphen form', () => {
);
}
});
test('codex alias runtime emits $gsd-<cmd> 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', () => {

View File

@@ -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-<cmd> 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 {

View File

@@ -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);