fix(3610): unblock fresh Codex install when leftover bundled hooks present

`npx get-shit-done-cc@latest --codex` aborted with
"installer migration blocked pending user choice" listing 12 hooks/gsd-*
files. Those files are part of the GSD npm distribution
(hooks/gsd-prompt-guard.js, hooks/gsd-context-monitor.js, etc.), not
user-owned content, so asking the user to choose between keep/remove for
them was a UX bug, not a real choice. The installer is about to write
the fresh bundled versions in their place.

Root cause: `classifyPromptUserAction` in
get-shit-done/bin/lib/installer-migration-report.cjs knew two
unambiguous categories (`stale-sdk-build-artifact`, `user-facing-skill`)
but had no rule for the bundled GSD hooks. The first-time-baseline scan
classified them as `stale-gsd-looking` prompt-user blockers, and
`assertInstallerMigrationsUnblocked` threw.

A second gate compounded the bug: the safe-default resolver in
bin/install.js was wrapped in `if (!_migrationIsTty)`, so even with a
correct classification rule, TTY runs (every `npx get-shit-done-cc`
invocation) skipped the resolver and went straight to the hard throw.

Fix:
1) Add `hooks/gsd-<name>.(js|sh|cjs|mjs)` to `classifyPromptUserAction`
   as `bundled-gsd-hook` → `remove`. The regex is anchored at the
   top-level `hooks/` directory so nested paths like
   `hooks/gsd-helpers/index.js` (or any user-owned helper directory) do
   NOT auto-classify.
2) Remove the `!_migrationIsTty` gate from the resolver call in
   bin/install.js. The classifier-based path is unambiguous and must
   apply regardless of TTY; the env-override branch
   (GSD_INSTALLER_MIGRATION_RESOLVE) still applies only when isTty=false
   inside the resolver, preserving the #3541 semantic.

Regression test added
(tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs):

- Positive: hooks/gsd-*.{js,sh} → category=bundled-gsd-hook, choice=remove.
- Counter-test: hooks/my-custom-hook.js → classifier returns null
  (user files are preserved).
- Boundary: hooks/gsd-helpers/index.js → classifier returns null
  (nested directories don't auto-classify).
- End-to-end: 12 reporter-exact bundled hooks + empty manifest →
  resolver clears every blocker, assertInstallerMigrationsUnblocked
  does not throw.

Test exercises the real installer-migration code path
(`runInstallerMigrations` + `resolveInstallerMigrationPromptsForNonTty`
+ `assertInstallerMigrationsUnblocked`) — no source-grep, no raw text
matching on outputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-15 22:13:34 -04:00
parent 38c35a608d
commit f422a05450
4 changed files with 220 additions and 6 deletions

View File

@@ -8061,13 +8061,21 @@ function install(isGlobal, runtime = 'claude', options = {}) {
// #3541: non-interactive runs (typical /gsd-update via Claude Code) have
// no stdin TTY and therefore no way to answer prompt-user migration
// actions. Resolve safe categories by classification (stale SDK build
// artifacts → remove; user-facing skills → keep) and log every
// resolution; anything that cannot be safely defaulted falls through
// to assertInstallerMigrationsUnblocked, which now emits a grouped
// error with the documented resolution path.
// artifacts → remove; user-facing skills → keep; bundled GSD hooks →
// remove [#3610]) and log every resolution; anything that cannot be
// safely defaulted falls through to assertInstallerMigrationsUnblocked,
// which now emits a grouped error with the documented resolution path.
//
// #3610: the classifier-based resolution must run regardless of TTY.
// For unambiguous categories (e.g. `hooks/gsd-*` bundled hooks left
// behind by a previous version), there is no actual "user choice" to
// make — the file is a known GSD-managed artifact and the installer is
// about to write the fresh bundled version. Gating the resolver on
// `!isTTY` made `npx get-shit-done-cc@latest --codex` hard-abort with
// 12 blocked bundled hooks. The env-override branch (operator-supplied
// GSD_INSTALLER_MIGRATION_RESOLVE) still applies only in non-TTY mode.
const _migrationIsTty = process.stdin && process.stdin.isTTY === true;
if (!_migrationIsTty &&
Array.isArray(installerMigrationResult.blocked) &&
if (Array.isArray(installerMigrationResult.blocked) &&
installerMigrationResult.blocked.length > 0 &&
installerMigrationResult.plan &&
Array.isArray(installerMigrationResult.plan.actions)) {