docs/adr/0009-shell-command-projection-module.md
We propose introducing a Shell Command Projection Module that owns projection from typed command intent to concrete shell/runtime-specific command text. GSD currently hand-builds hook commands, PATH repair commands, shim scripts, and other serialized OS-facing command strings across installer call sites. That drift has repeatedly produced cross-shell regressions (#2376, #2979, #3002, #3011, #3181, #3393, #3413). The proposed seam concentrates quoting, path-style, and runtime-wrapper policy in one module while keeping real subprocess execution on array-arg/non-shell paths.
get-shit-done/bin/lib/ as the single owner for runtime-aware command-text rendering.platform, shell, runtime, executable token, args, path policy) instead of prebuilt shell strings.settings.jsonspawnSync, execFileSync, SDK query dispatch) outside this seam. The module does not become a generic command runner.native Windows, POSIX slash, $HOME-relative, project-dir-relative, etc.).First migration slice should cover installer/runtime surfaces already proving this bug class:
.sh hook command construction$CLAUDE_PROJECT_DIR vs cwd-relative runtime paths)It should not in the first pass expand into workflow markdown ```bash blocks or replace safe internal subprocess APIs with shell-string execution.
bin/install.jsThese call sites should migrate behind the Shell Command Projection Module:
formatHookCommandForShell() — bin/install.js:605-608formatManagedHookScriptToken() — bin/install.js:610-614rewriteLegacyManagedNodeHookCommands() projection path — bin/install.js:639-718buildCodexHookBlock() — bin/install.js:752-768rewriteLegacyCodexHookBlock() — bin/install.js:784-812buildHookCommand() — bin/install.js:827-857localCmd() builder — bin/install.js:8855-8857bin/install.js:8858-8875, 9065-9067, 9090-9092, 9119-9121, 9143-9145, 9173-9176bin/install.js:9764-9768formatSdkPathDiagnostic() render path — bin/install.js:10075-10084, builder at 10532-10582buildWindowsShimTriple() — bin/install.js:10487-10510tests/bug-2979-hook-absolute-node.test.cjstests/sh-hook-paths.test.cjstests/bug-3011-sdk-path-diagnostic.test.cjstests/bug-3017-codex-hook-absolute-node.test.cjstests/bug-2376-opencode-windows-home-path.test.cjstests/bug-3020-install-shell-path-probe.test.cjstests/bug-3359-stale-gsd-sdk-path-version.test.cjsThe module should accept typed command intent rather than concatenated shell fragments. Example shape:
projectShellCommand({
platform: 'linux' | 'darwin' | 'win32',
shell: 'bash' | 'zsh' | 'cmd' | 'pwsh',
runtime: 'claude' | 'gemini' | 'codex' | 'opencode' | 'copilot' | 'antigravity' | 'generic',
executable: { kind: 'node' | 'bash' | 'pwsh' | 'literal', token: '...' },
args: ['...'],
pathStyle: 'native' | 'posix' | 'home-relative' | 'project-relative',
})
For user-facing multi-line guidance, the seam should return typed action IR first, then let the installer print it:
projectShellActions({ intent: 'prepend-path', platform, targetDir, runtime })
For generated shim/wrapper files, the seam should own script text rendering too:
projectShellScript({ shell: 'cmd' | 'pwsh' | 'sh', executable, argsTemplate })
CONTEXT.md should gain a canonical Shell Command Projection Module entry and future architecture reviews should treat out-of-seam command rendering as drift.hooks.shell_preference from #3082 should become an input policy consumed by this module or remain a higher-level runtime config concern.shell: 'bash' + platform: 'win32' or as a distinct shell target.bin/install.js.#3439#2376, #2979, #3002, #3011, #3017, #3020, #3082, #3181, #3393, #34130005-sdk-architecture-seam-map.md0008-installer-migration-module.md#3465–#3468)The seam grew beyond the original "rendering only" scope. The "does not become a generic command runner" and "does not replace safe internal subprocess APIs" constraints (Decision §17, Initial Scope §33) were intentionally superseded.
Scope now owned by shell-command-projection.cjs:
execGit, execNpm, execTool, probeTty (Phase 2, #3466)platformWriteSync, platformReadSync, platformEnsureDir, normalizeContent (Phase 3, #3467)atomicWriteFileSync / safeReadFile / normalizeMd removed from core.cjs (Phase 4, #3468)Result-shape invariant: all exec* return { exitCode, stdout, stderr } and never throw on non-zero exit. Platform-conditional logic (shell: process.platform === 'win32', probeTty Windows null return, .md-aware normalization) lives only at the seam.
Open question resolutions:
get-shit-done/bin/lib/, consumed by installer, planning workflow, and every fs/subprocess call site across the tool.hooks.shell_preference, Windows Git Bash modeling, shim/script builder migration timing): unresolved, carried forward as projection-design concerns independent of the I/O expansion.See CONTEXT.md "Shell Command Projection Module" entry for the canonical current-state description.