feat(hooks): opt-in SessionStart update banner for non-statusline users (#2795) (#3035)

* feat(hooks): opt-in SessionStart update banner for non-statusline users (#2795)

When a user declines (or keeps a non-GSD) statusline at install time, the
installer now offers an opt-in SessionStart banner that surfaces GSD update
availability. The banner reads the existing
~/.cache/gsd/gsd-update-check.json cache (written by
gsd-check-update-worker.js) and emits a single systemMessage line only when
update_available is true:

    GSD update available: <installed> → <latest>. Run /gsd-update.

It is silent when up-to-date and rate-limits "check failed" diagnostics to
once per 24h via a sentinel file so a corrupt cache doesn't nag every
session. Removed cleanly by `npx get-shit-done-cc --uninstall` which strips
both the script and the SessionStart entry. The banner is never offered when
GSD's statusline is being installed (statusline already surfaces update
info, so re-prompting would be noise).

Implementation:
- hooks/gsd-update-banner.js — pure functions buildBannerOutput,
  shouldSuppressFailureWarning, readCache; thin main() wires them.
- bin/install.js — handleUpdateBanner() prompt, parseUpdateBannerInput(),
  buildUpdateBannerHookEntry(), buildUpdateBannerPromptText(); chained into
  installAllRuntimes() so finalize() receives both flags. updateBannerCommand
  computed alongside the other JS-hook commands; finishInstall() registers
  the SessionStart entry only when shouldInstallBanner === true and the
  hook file is present at the target.
- Hook ships in scripts/build-hooks.js HOOKS_TO_COPY, listed in
  MANAGED_HOOKS for stale-detection in gsd-check-update-worker.js, in the
  uninstall hook-removal lists in install.js, and in the
  rewriteLegacyManagedNodeHookCommands allowlist.

Tests:
- tests/feat-2795-update-banner.test.cjs — 22 tests, structural-IR
  assertions on parsed JSON envelopes (no raw-text matching). Covers
  pure-function branches (cache present/absent, parseError, rate-limit
  suppression, missing version fields), end-to-end hook invocation against
  fixture cache states, and install.js wiring (prompt text, input parsing,
  hook entry shape).
- tests/trae-install.test.cjs — updated install() return-shape assertion to
  include updateBannerCommand: null for the no-settings runtime.
- 6881/6881 tests pass.

Docs (bundled in same commit per the bundle-docs-with-code skill):
- docs/USER-GUIDE.md — new "Surface GSD Update Notifications Without GSD's
  Statusline" task section with opt-in/opt-out instructions.
- docs/FEATURES.md — REQ-HOOK-08 added; "Update Banner" subsection under
  the Hook System feature with cache flow + removal path.
- docs/INVENTORY.md — hook count 11 → 12, new row for gsd-update-banner.js.
- docs/INVENTORY-MANIFEST.json — regenerated.

Closes #2795

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

* fix(install): gate banner prompt on actual installability (CR #3035)

CodeRabbit findings on PR #3035:

- bin/install.js (Major): continueAfterStatusline gated banner prompt on
  the raw `shouldInstallStatusline` flag from handleStatusline. But
  finishInstall later silently skips the statusline write on local
  installs unless --force-statusline is set (#2248). Two consequences:
    1. Interactive local Claude/Gemini installs got neither a statusline
       nor a banner offer.
    2. Codex/Cursor/Copilot/Windsurf/Trae/Cline-only installs (where
       every result.updateBannerCommand is null) still got prompted even
       though the choice was silently ignored.
  Fix: derive willInstallStatusline = shouldInstallStatusline &&
  (isGlobal || forceStatusline), and gate the banner prompt on a
  canInstallBanner precondition computed from results[].updateBannerCommand.
  Pass the raw shouldInstallStatusline through to finalize unchanged so
  per-runtime statusline gating in finishInstall is unaffected.

- tests/feat-2795-update-banner.test.cjs (Minor): rate-limit suppression
  test parsed r1.stdout without first asserting r1.status === 0. Other
  e2e tests in this file (lines 210, 241) do this. A non-zero exit would
  surface as a cryptic SyntaxError instead of a status assertion failure.
  Fix applied verbatim.

6881/6881 tests pass.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-02 16:33:16 -04:00
committed by GitHub
parent 35fffe7f31
commit 95d2bc20f8
11 changed files with 732 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 2795
---
**Optional update banner for non-GSD statusline users** — when the installer detects you've declined or kept a non-GSD statusline, it now offers an opt-in `SessionStart` banner that surfaces update availability via the existing `~/.cache/gsd/gsd-update-check.json` cache. Silent when up-to-date, rate-limits failure diagnostics to once per 24h, removed cleanly by `npx get-shit-done-cc --uninstall`.

View File

@@ -569,6 +569,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner) {
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-update-banner.js',
'gsd-workflow-guard.js',
]);
let changed = false;
@@ -6474,7 +6475,7 @@ function uninstall(isGlobal, runtime = 'claude') {
// 4. Remove GSD hooks
const hooksDir = path.join(targetDir, 'hooks');
if (fs.existsSync(hooksDir)) {
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', 'gsd-update-banner.js', 'gsd-workflow-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
let hookCount = 0;
for (const hook of gsdHooks) {
const hookPath = path.join(hooksDir, hook);
@@ -6530,6 +6531,7 @@ function uninstall(isGlobal, runtime = 'claude') {
cmd.includes('gsd-session-state') || cmd.includes('gsd-context-monitor') ||
cmd.includes('gsd-phase-boundary') || cmd.includes('gsd-prompt-guard') ||
cmd.includes('gsd-read-guard') || cmd.includes('gsd-read-injection-scanner') ||
cmd.includes('gsd-update-banner') ||
cmd.includes('gsd-validate-commit') || cmd.includes('gsd-workflow-guard'));
for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool']) {
@@ -8116,7 +8118,7 @@ function install(isGlobal, runtime = 'claude') {
throw wrapped;
}
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCopilot) {
@@ -8129,22 +8131,22 @@ function install(isGlobal, runtime = 'claude') {
console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
}
// Copilot: no settings.json, no hooks, no statusline (like Codex)
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCursor) {
// Cursor uses skills — no config.toml, no settings.json hooks needed
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isWindsurf) {
// Windsurf uses skills — no config.toml, no settings.json hooks needed
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isTrae) {
// Trae uses skills — no settings.json hooks needed
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCline) {
@@ -8164,7 +8166,7 @@ function install(isGlobal, runtime = 'claude') {
].join('\n') + '\n';
fs.writeFileSync(clinerulesDest, clinerules);
console.log(` ${green}✓${reset} Wrote .clinerules`);
return { settingsPath: null, settings: null, statuslineCommand: null, runtime, configDir: targetDir };
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
// Configure statusline and hooks in settings.json
@@ -8509,13 +8511,30 @@ function install(isGlobal, runtime = 'claude') {
}
}
return { settingsPath, settings, statuslineCommand, runtime, configDir: targetDir };
// Compute the update-banner hook command alongside the others so
// installAllRuntimes can register it at finalize time when the user opts
// in (#2795). Computed here (not in finishInstall) so the same buildHookCommand
// / localCmd resolution logic is shared with the other JS hooks.
const updateBannerCommand = isOpencode || isKilo
? null
: (isGlobal
? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
: localCmd('gsd-update-banner.js'));
return {
settingsPath,
settings,
statuslineCommand,
updateBannerCommand,
runtime,
configDir: targetDir,
};
}
/**
* Apply statusline config, then print completion message
*/
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null) {
function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null, bannerOpts = {}) {
const isOpencode = runtime === 'opencode';
const isKilo = runtime === 'kilo';
const isCodex = runtime === 'codex';
@@ -8546,6 +8565,36 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
}
}
// Register the opt-in update banner (#2795) when the user accepted the
// banner offer at install time. Only applies to runtimes that own a
// settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
// cline either lack the surface or use a different config schema.
const { shouldInstallBanner, bannerCommand } = bannerOpts;
if (shouldInstallBanner && settings && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) {
if (!bannerCommand) {
console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
} else {
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
entry && entry.hooks && entry.hooks.some(h => h && h.command && h.command.includes('gsd-update-banner'))
);
const bannerHookFile = configDir ? path.join(configDir, 'hooks', 'gsd-update-banner.js') : null;
const bannerInstalled = bannerHookFile ? fs.existsSync(bannerHookFile) : false;
if (alreadyRegistered) {
// Idempotent re-install: don't double-register.
} else if (!bannerInstalled) {
console.warn(` ${yellow}⚠${reset} Skipped update banner — gsd-update-banner.js not found at target`);
} else {
const entry = buildUpdateBannerHookEntry(bannerCommand);
if (entry) {
settings.hooks.SessionStart.push(entry);
console.log(` ${green}✓${reset} Configured update banner hook (opt-in)`);
}
}
}
}
// Write settings when runtime supports settings.json.
// #3002 CR: defense-in-depth — re-run validateHookFields right before
// serialization. The push-site guards above already skip null-command
@@ -8797,6 +8846,90 @@ function promptRuntime(callback) {
});
}
// ─── Update banner (#2795) ──────────────────────────────────────────────────
/**
* Build the prompt text shown when offering the opt-in update banner.
* Pure function — no I/O. Exported for tests so they can assert against the
* rendered prompt structurally instead of grepping bin/install.js source.
*/
function buildUpdateBannerPromptText() {
return `
${yellow}Optional: GSD update banner${reset}
Without GSD's statusline, update notifications won't be visible. You can
install a SessionStart banner that surfaces a one-line message when a new
GSD release is available. The banner appears only at session start and
only when an update exists.
${cyan}1${reset}) ${dim}No banner (default)${reset}
${cyan}2${reset}) Install update banner
`;
}
/**
* Parse user input from the banner prompt. Returns true when the user opted
* in. Pure function — exported for direct unit testing.
*
* - Empty input or "1" → false (default: no banner).
* - "2" → true.
* - "y" / "yes" (case-insensitive) → true. Affirmative shortcuts.
*/
function parseUpdateBannerInput(answer) {
const input = (answer == null ? '' : String(answer)).trim().toLowerCase();
if (input === '2' || input === 'y' || input === 'yes') return true;
return false;
}
/**
* Build a SessionStart hook entry (settings.json shape) that runs the
* update-banner script. Returns null when the input command is empty so
* callers can warn-and-skip rather than writing { command: null } and
* tripping the runtime's hook schema (#3002).
*
* @param {string|null} bannerCommand - Result of buildHookCommand() / localCmd().
* @returns {{hooks: Array<{type: 'command', command: string}>}|null}
*/
function buildUpdateBannerHookEntry(bannerCommand) {
if (!bannerCommand) return null;
return {
hooks: [
{
type: 'command',
command: bannerCommand,
},
],
};
}
/**
* Interactive prompt that asks the user whether to install the opt-in
* update banner. Used by `installAllRuntimes` only when GSD's statusline
* was declined or skipped.
*
* @param {boolean} isInteractive
* @param {(shouldInstallBanner: boolean) => void} callback
*/
function handleUpdateBanner(isInteractive, callback) {
if (!isInteractive) {
// Never auto-install in non-interactive mode — user can re-run install
// interactively or hand-edit settings.json to opt in later.
callback(false);
return;
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log(buildUpdateBannerPromptText());
rl.question(` Choice ${dim}[1]${reset}: `, (answer) => {
rl.close();
callback(parseUpdateBannerInput(answer));
});
}
/**
* Prompt for install location
*/
@@ -9629,7 +9762,7 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
const statuslineRuntimes = ['claude', 'gemini'];
const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
const finalize = (shouldInstallStatusline) => {
const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
// Verify sdk/dist/cli.js is present and executable. The dist is shipped
// prebuilt in the tarball (fix/2441-sdk-decouple); gsd-sdk reaches users via
// the parent package's bin/gsd-sdk.js shim, so no sub-install is needed.
@@ -9646,7 +9779,8 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
useStatusline,
result.runtime,
isGlobal,
result.configDir
result.configDir,
{ shouldInstallBanner: !!shouldInstallBanner, bannerCommand: result.updateBannerCommand }
);
}
};
@@ -9654,10 +9788,49 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
printSummaries();
};
// Statusline first; if it won't actually be installed (declined, or local
// install without --force-statusline silently skips it per #2248), offer
// the opt-in update banner (#2795) as the secondary surface for update
// notifications. Skip the banner prompt entirely when no runtime in this
// install set can host the banner (e.g. Codex/Copilot/Cursor/Windsurf/
// Trae/Cline-only installs whose updateBannerCommand is null).
//
// CR #3035: gate on actual installability — `shouldInstallStatusline`
// returned by handleStatusline is the raw user choice, but
// `finishInstall` later skips the statusline write on local installs
// unless --force-statusline is set. Passing the raw flag to
// continueAfterStatusline previously caused two bugs: (1) interactive
// local installs got neither a statusline nor a banner offer, and (2)
// banner-incapable runtimes got prompted even though every
// updateBannerCommand was null.
const canInstallBanner = results.some((r) => r && r.updateBannerCommand);
const continueAfterStatusline = (shouldInstallStatusline) => {
const willInstallStatusline =
shouldInstallStatusline && (isGlobal || forceStatusline);
if (willInstallStatusline) {
finalize(true, false);
return;
}
if (!canInstallBanner) {
finalize(shouldInstallStatusline, false);
return;
}
handleUpdateBanner(isInteractive, (shouldInstallBanner) => {
finalize(shouldInstallStatusline, shouldInstallBanner);
});
};
if (primaryStatuslineResult) {
handleStatusline(primaryStatuslineResult.settings, isInteractive, finalize);
handleStatusline(primaryStatuslineResult.settings, isInteractive, continueAfterStatusline);
} else if (canInstallBanner) {
// No statusline-capable runtime, but at least one runtime can host the
// banner — still offer it.
handleUpdateBanner(isInteractive, (shouldInstallBanner) => {
finalize(false, shouldInstallBanner);
});
} else {
finalize(false);
// Nothing to prompt about — no statusline, no banner-capable runtime.
finalize(false, false);
}
}
@@ -9760,6 +9933,9 @@ if (process.env.GSD_TEST_MODE) {
allRuntimes,
parseRuntimeInput,
buildRuntimePromptText,
buildUpdateBannerPromptText,
parseUpdateBannerInput,
buildUpdateBannerHookEntry,
buildHookCommand,
resolveNodeRunner,
rewriteLegacyManagedNodeHookCommands,

View File

@@ -1032,14 +1032,25 @@ fix(03-01): correct auth token expiry
- REQ-HOOK-05: All hooks MUST include 3-second stdin timeout guard
- REQ-HOOK-06: All hooks MUST fail silently on any error
- REQ-HOOK-07: Context usage MUST normalize for autocompact buffer (16.5% reserved)
- REQ-HOOK-08: Update banner MUST be opt-in and silent unless an update is available (PR #2795)
**Statusline Display:**
```
```text
[⬆ /gsd-update │] model │ [current task │] directory [█████░░░░░ 50%]
```
Color coding: <50% green, <65% yellow, <80% orange, ≥80% red with skull emoji
**Update Banner (opt-in, when GSD statusline isn't used):**
When the user declines (or keeps a non-GSD) statusline, the installer offers a SessionStart banner that surfaces update availability without occupying statusline real estate. The banner reads `~/.cache/gsd/gsd-update-check.json` (written by `gsd-check-update-worker.js`) and emits one line only when an update is available:
```text
GSD update available: 1.39.0 → 1.40.0. Run /gsd-update.
```
The banner is silent when up-to-date and rate-limits "check failed" diagnostics to once per 24 hours. Removed cleanly by `npx get-shit-done-cc --uninstall` or by deleting the SessionStart entry that references `gsd-update-banner.js`.
### 38. Developer Profiling
**Command:** `/gsd-profile-user [--questionnaire] [--refresh]`

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-04-30",
"generated": "2026-05-02",
"families": {
"agents": [
"gsd-advisor-researcher",
@@ -296,6 +296,7 @@
"gsd-read-injection-scanner.js",
"gsd-session-state.sh",
"gsd-statusline.js",
"gsd-update-banner.js",
"gsd-validate-commit.sh",
"gsd-workflow-guard.js"
]

View File

@@ -400,7 +400,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
---
## Hooks (11 shipped)
## Hooks (12 shipped)
Full listing: `hooks/`.
@@ -410,6 +410,7 @@ Full listing: `hooks/`.
| `gsd-context-monitor.js` | `PostToolUse` / `AfterTool` | Injects agent-facing context warnings at 35%/25% remaining |
| `gsd-check-update.js` | `SessionStart` | Background check for new GSD versions |
| `gsd-check-update-worker.js` | (worker) | Background worker helper for check-update |
| `gsd-update-banner.js` | `SessionStart` | Opt-in banner surfacing update availability when GSD statusline isn't used (PR #2795) |
| `gsd-prompt-guard.js` | `PreToolUse` | Scans `.planning/` writes for prompt-injection patterns (advisory) |
| `gsd-workflow-guard.js` | `PreToolUse` | Detects file edits outside GSD workflow context (advisory, opt-in) |
| `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on unread files |

View File

@@ -1184,6 +1184,30 @@ Since v1.17, the installer backs up locally modified files to `gsd-local-patches
If `npx get-shit-done-cc` fails due to npm outages or network restrictions, see [docs/manual-update.md](manual-update.md) for a step-by-step manual update procedure that works without npm access.
### Surface GSD Update Notifications Without GSD's Statusline
GSD checks for new versions in the background and writes the result to `~/.cache/gsd/gsd-update-check.json`. By default, GSD's statusline (`hooks/gsd-statusline.js`) reads that cache and shows the update indicator. If you use a different statusline (for example `ccstatusline`) or none at all, the update info is invisible.
**Opt-in fix:** during interactive install, when you decline (or keep your existing) statusline, the installer offers a one-time prompt:
```text
Optional: GSD update banner
1) No banner (default)
2) Install update banner
```
Choose `2` (or type `y`/`yes`) and the installer registers `hooks/gsd-update-banner.js` as a `SessionStart` hook. From the next session onward, GSD prints a one-line `systemMessage` only when the cache reports an update available:
```text
GSD update available: 1.39.0 → 1.40.0. Run /gsd-update.
```
The banner is silent when no update is available. If the cache file is corrupt, GSD emits one diagnostic line (`GSD update check failed.`) and stays silent for 24 hours so a broken cache does not nag every session.
**Opt-out / removal:** delete the SessionStart hook entry that references `gsd-update-banner.js` from your runtime's `settings.json` (Claude Code: `~/.claude/settings.json`; Gemini: `~/.gemini/settings.json`). `npx get-shit-done-cc --uninstall` removes both the script and the registration in one pass.
The banner is not offered when GSD's statusline is installed — that channel already surfaces update info, so re-prompting would be noise.
### Workflow Diagnostics (`/gsd-forensics`)
When a workflow fails in a way that isn't obvious -- plans reference nonexistent files, execution produces unexpected results, or state seems corrupted -- run `/gsd-forensics` to generate a diagnostic report.

View File

@@ -56,6 +56,7 @@ const MANAGED_HOOKS = [
'gsd-read-injection-scanner.js',
'gsd-session-state.sh',
'gsd-statusline.js',
'gsd-update-banner.js',
'gsd-validate-commit.sh',
'gsd-workflow-guard.js',
];

134
hooks/gsd-update-banner.js Executable file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
// gsd-hook-version: {{GSD_VERSION}}
// SessionStart banner that surfaces GSD update availability when GSD's
// statusline isn't installed. Reads the cache that
// gsd-check-update-worker.js writes to ~/.cache/gsd/gsd-update-check.json.
//
// Opt-in by design: bin/install.js only registers this hook when the user
// declines to install (or replace) the GSD statusline. The presence of the
// SessionStart entry IS the opt-in — there is no separate runtime flag.
//
// See issue #2795 for the rationale.
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
// Suppress repeat parse-error banners for 24 hours so a genuinely broken
// cache file doesn't nag the user every session.
const RATE_LIMIT_SECONDS = 24 * 60 * 60;
/**
* Build the SessionStart JSON envelope to emit, given parsed cache state.
* Pure function — no I/O. Returns null when the hook should print nothing.
*
* @param {object} state
* @param {object|null} state.cache Parsed cache, or null if missing/unreadable.
* @param {boolean} state.parseError True iff cache file existed but JSON.parse failed.
* @param {boolean} state.suppressFailureWarning True when a recent failure warning already fired.
* @returns {{systemMessage: string}|null} JSON envelope, or null for silent exit.
*/
function buildBannerOutput(state) {
const { cache, parseError, suppressFailureWarning } = state || {};
if (parseError) {
if (suppressFailureWarning) return null;
return { systemMessage: 'GSD update check failed.' };
}
if (!cache) return null;
if (!cache.update_available) return null;
const installed = cache.installed || 'unknown';
const latest = cache.latest || 'unknown';
return {
systemMessage: `GSD update available: ${installed} → ${latest}. Run /gsd-update.`,
};
}
/**
* Read and parse the update-check cache file.
*
* @param {string} cacheFile
* @returns {{cache: object|null, parseError: boolean}}
*/
function readCache(cacheFile) {
let cache = null;
let parseError = false;
try {
if (fs.existsSync(cacheFile)) {
const raw = fs.readFileSync(cacheFile, 'utf8');
cache = JSON.parse(raw);
}
} catch (e) {
// Distinguish "file unreadable" from "JSON malformed": both fail-open to
// null cache, but a JSON parse error becomes a one-time diagnostic.
parseError = e instanceof SyntaxError;
}
return { cache, parseError };
}
/**
* Has a failure warning been emitted within the rate-limit window?
*
* @param {string} sentinelFile
* @param {number} nowSeconds
* @returns {boolean}
*/
function shouldSuppressFailureWarning(sentinelFile, nowSeconds) {
try {
if (!fs.existsSync(sentinelFile)) return false;
const last = parseInt(fs.readFileSync(sentinelFile, 'utf8').trim(), 10);
if (!Number.isFinite(last)) return false;
return nowSeconds - last < RATE_LIMIT_SECONDS;
} catch (e) {
return false;
}
}
function recordFailureWarning(sentinelFile, nowSeconds) {
try {
fs.writeFileSync(sentinelFile, String(nowSeconds));
} catch (e) {
// Best-effort: a non-writable cache dir means we'll re-warn next session,
// which is no worse than the un-instrumented baseline.
}
}
function main() {
const cacheDir = path.join(os.homedir(), '.cache', 'gsd');
const cacheFile = path.join(cacheDir, 'gsd-update-check.json');
const sentinelFile = path.join(cacheDir, 'banner-failure-warned-at');
const now = Math.floor(Date.now() / 1000);
const { cache, parseError } = readCache(cacheFile);
const suppressFailureWarning = parseError
? shouldSuppressFailureWarning(sentinelFile, now)
: false;
const output = buildBannerOutput({ cache, parseError, suppressFailureWarning });
if (parseError && !suppressFailureWarning) {
// Ensure cache dir exists before writing the sentinel — first-run case
// where ~/.cache/gsd was created by check-update but the parent dir got
// wiped between runs.
try {
fs.mkdirSync(cacheDir, { recursive: true });
} catch (e) {
// Best-effort: failure to create the dir means we'll re-warn next
// session, which is no worse than the un-instrumented baseline.
}
recordFailureWarning(sentinelFile, now);
}
if (output) {
process.stdout.write(JSON.stringify(output));
}
}
if (require.main === module) main();
module.exports = {
buildBannerOutput,
readCache,
shouldSuppressFailureWarning,
RATE_LIMIT_SECONDS,
};

View File

@@ -22,6 +22,7 @@ const HOOKS_TO_COPY = [
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-statusline.js',
'gsd-update-banner.js',
'gsd-workflow-guard.js',
// Community hooks (bash, opt-in via .planning/config.json hooks.community)
'gsd-session-state.sh',

View File

@@ -0,0 +1,361 @@
/**
* Tests for gsd-update-banner.js (#2795).
*
* The banner hook is an opt-in SessionStart consumer of the update cache that
* gsd-check-update-worker.js writes. When a user declines GSD's statusline,
* install.js may register this hook so update availability still surfaces in
* runtimes that use a non-GSD statusline.
*
* Tests follow the typed-IR convention (CONTRIBUTING.md "Prohibited: Raw Text
* Matching on Test Outputs"): assert on parsed JSON envelopes, not on raw
* stdout substrings.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-update-banner.js');
const {
buildBannerOutput,
shouldSuppressFailureWarning,
RATE_LIMIT_SECONDS,
} = require('../hooks/gsd-update-banner.js');
// ─── Pure function: buildBannerOutput ───────────────────────────────────────
describe('buildBannerOutput', () => {
test('returns null when cache is missing', () => {
const out = buildBannerOutput({
cache: null,
parseError: false,
suppressFailureWarning: false,
});
assert.equal(out, null);
});
test('returns null when update_available is false', () => {
const out = buildBannerOutput({
cache: { update_available: false, installed: '1.40.0', latest: '1.40.0' },
parseError: false,
suppressFailureWarning: false,
});
assert.equal(out, null);
});
test('returns banner envelope when update_available is true', () => {
const out = buildBannerOutput({
cache: { update_available: true, installed: '1.39.0', latest: '1.40.0' },
parseError: false,
suppressFailureWarning: false,
});
assert.ok(out, 'expected banner envelope');
assert.equal(typeof out.systemMessage, 'string');
assert.ok(
out.systemMessage.includes('1.39.0'),
'banner should name installed version'
);
assert.ok(
out.systemMessage.includes('1.40.0'),
'banner should name latest version'
);
assert.ok(
out.systemMessage.includes('/gsd-update'),
'banner should reference /gsd-update command'
);
});
test('returns failure diagnostic on parseError when not suppressed', () => {
const out = buildBannerOutput({
cache: null,
parseError: true,
suppressFailureWarning: false,
});
assert.ok(out, 'expected diagnostic envelope');
assert.equal(typeof out.systemMessage, 'string');
assert.ok(
/check failed/i.test(out.systemMessage),
'diagnostic should describe a failed check'
);
});
test('returns null on parseError when suppressed by rate limit', () => {
const out = buildBannerOutput({
cache: null,
parseError: true,
suppressFailureWarning: true,
});
assert.equal(out, null);
});
test('falls back to "unknown" when installed/latest missing', () => {
const out = buildBannerOutput({
cache: { update_available: true },
parseError: false,
suppressFailureWarning: false,
});
assert.ok(out);
assert.ok(
out.systemMessage.includes('unknown'),
'banner should degrade gracefully when versions are absent'
);
});
});
// ─── Pure function: shouldSuppressFailureWarning ────────────────────────────
describe('shouldSuppressFailureWarning', () => {
function tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-supp-'));
}
test('returns false when sentinel file is missing', () => {
const dir = tmpDir();
try {
const result = shouldSuppressFailureWarning(
path.join(dir, 'no-such-file'),
100
);
assert.equal(result, false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('returns true within rate-limit window', () => {
const dir = tmpDir();
try {
const f = path.join(dir, 'sentinel');
fs.writeFileSync(f, '1000');
const result = shouldSuppressFailureWarning(f, 1000 + RATE_LIMIT_SECONDS - 1);
assert.equal(result, true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('returns false outside rate-limit window', () => {
const dir = tmpDir();
try {
const f = path.join(dir, 'sentinel');
fs.writeFileSync(f, '1000');
const result = shouldSuppressFailureWarning(f, 1000 + RATE_LIMIT_SECONDS + 1);
assert.equal(result, false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('returns false when sentinel content is non-numeric', () => {
const dir = tmpDir();
try {
const f = path.join(dir, 'sentinel');
fs.writeFileSync(f, 'garbage-not-a-number');
const result = shouldSuppressFailureWarning(f, 100);
assert.equal(result, false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
// ─── End-to-end: spawn the hook against fixture cache states ────────────────
describe('gsd-update-banner.js end-to-end', () => {
function setupHome() {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-home-'));
fs.mkdirSync(path.join(home, '.cache', 'gsd'), { recursive: true });
return home;
}
function runHook(home) {
return spawnSync(process.execPath, [HOOK_PATH], {
env: { ...process.env, HOME: home, USERPROFILE: home },
encoding: 'utf8',
});
}
function writeCache(home, contents) {
fs.writeFileSync(
path.join(home, '.cache', 'gsd', 'gsd-update-check.json'),
typeof contents === 'string' ? contents : JSON.stringify(contents)
);
}
test('exits 0 with empty stdout when cache file missing', () => {
const home = setupHome();
try {
const r = runHook(home);
assert.equal(r.status, 0, `expected exit 0, got ${r.status} stderr=${r.stderr}`);
assert.equal(r.stdout.trim(), '');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test('emits valid SessionStart JSON when update_available=true', () => {
const home = setupHome();
try {
writeCache(home, {
update_available: true,
installed: '1.39.0',
latest: '1.40.0',
});
const r = runHook(home);
assert.equal(r.status, 0);
const parsed = JSON.parse(r.stdout);
assert.equal(typeof parsed.systemMessage, 'string');
assert.ok(parsed.systemMessage.includes('1.40.0'));
assert.ok(parsed.systemMessage.includes('/gsd-update'));
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test('exits silent when update_available=false', () => {
const home = setupHome();
try {
writeCache(home, {
update_available: false,
installed: '1.40.0',
latest: '1.40.0',
});
const r = runHook(home);
assert.equal(r.status, 0);
assert.equal(r.stdout.trim(), '');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test('emits failure diagnostic when cache JSON is malformed', () => {
const home = setupHome();
try {
writeCache(home, 'not json {{{{');
const r = runHook(home);
assert.equal(r.status, 0);
const parsed = JSON.parse(r.stdout);
assert.equal(typeof parsed.systemMessage, 'string');
assert.ok(/check failed/i.test(parsed.systemMessage));
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test('suppresses repeat failure diagnostic within 24h via sentinel', () => {
const home = setupHome();
try {
writeCache(home, 'not json');
const r1 = runHook(home);
assert.equal(
r1.status,
0,
`expected exit 0, got ${r1.status} stderr=${r1.stderr}`
);
const parsed1 = JSON.parse(r1.stdout);
assert.ok(/check failed/i.test(parsed1.systemMessage));
// Sentinel should now exist so the next run is silent
const sentinel = path.join(home, '.cache', 'gsd', 'banner-failure-warned-at');
assert.ok(fs.existsSync(sentinel), 'first run must record the warning sentinel');
const r2 = runHook(home);
assert.equal(r2.status, 0);
assert.equal(
r2.stdout.trim(),
'',
'subsequent run within rate-limit window must stay silent'
);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
test('handles cache present but update_available field absent (older cache schema)', () => {
const home = setupHome();
try {
writeCache(home, { installed: '1.40.0', latest: '1.40.0' });
const r = runHook(home);
assert.equal(r.status, 0);
assert.equal(r.stdout.trim(), '');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});
// ─── Install.js wiring: prompt + SessionStart entry registration ────────────
//
// These tests load bin/install.js as a module via GSD_TEST_MODE and assert on
// pure exported helpers. The shape mirrors how runtime-prompt-builder /
// statusline tests interact with install.js.
describe('install.js update-banner wiring', () => {
process.env.GSD_TEST_MODE = '1';
// Re-require fresh so test-mode exports are populated.
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
delete require.cache[installPath];
const installExports = require(installPath);
test('exports buildUpdateBannerPromptText for structural prompt assertions', () => {
assert.equal(
typeof installExports.buildUpdateBannerPromptText,
'function',
'install.js must export buildUpdateBannerPromptText so tests can assert without grepping source'
);
const text = installExports.buildUpdateBannerPromptText();
assert.equal(typeof text, 'string');
assert.ok(text.length > 0);
// Strip ANSI color escapes before structural assertions — the choice
// digits are wrapped in color codes so word-boundary regex against the
// raw text would miss them.
const stripped = text.replace(/\x1b\[[0-9;]*m/g, '');
// Prompt must offer at least two choices (default + opt-in).
assert.match(stripped, /\b1\b/);
assert.match(stripped, /\b2\b/);
});
test('parseUpdateBannerInput defaults to false on empty / "1"', () => {
assert.equal(typeof installExports.parseUpdateBannerInput, 'function');
assert.equal(installExports.parseUpdateBannerInput(''), false);
assert.equal(installExports.parseUpdateBannerInput(' '), false);
assert.equal(installExports.parseUpdateBannerInput('1'), false);
});
test('parseUpdateBannerInput returns true on "2"', () => {
assert.equal(installExports.parseUpdateBannerInput('2'), true);
assert.equal(installExports.parseUpdateBannerInput('2 '), true);
});
test('parseUpdateBannerInput accepts "y" / "yes" affirmative shortcuts', () => {
assert.equal(installExports.parseUpdateBannerInput('y'), true);
assert.equal(installExports.parseUpdateBannerInput('Y'), true);
assert.equal(installExports.parseUpdateBannerInput('yes'), true);
assert.equal(installExports.parseUpdateBannerInput('YES'), true);
});
test('buildUpdateBannerHookEntry produces a SessionStart hook entry', () => {
assert.equal(typeof installExports.buildUpdateBannerHookEntry, 'function');
const entry = installExports.buildUpdateBannerHookEntry(
'"/usr/local/bin/node" "/home/u/.claude/hooks/gsd-update-banner.js"'
);
assert.ok(entry, 'expected hook entry object');
assert.ok(Array.isArray(entry.hooks), 'entry.hooks must be an array');
assert.equal(entry.hooks.length, 1);
assert.equal(entry.hooks[0].type, 'command');
assert.ok(
entry.hooks[0].command.includes('gsd-update-banner.js'),
'command must reference the banner hook'
);
});
test('buildUpdateBannerHookEntry returns null on null command', () => {
assert.equal(installExports.buildUpdateBannerHookEntry(null), null);
assert.equal(installExports.buildUpdateBannerHookEntry(''), null);
});
});

View File

@@ -182,6 +182,7 @@ describe('Trae local install/uninstall', () => {
settingsPath: null,
settings: null,
statuslineCommand: null,
updateBannerCommand: null,
runtime: 'trae',
configDir: fs.realpathSync(targetDir),
});