feat(v0.6.3): release-update notifier (notify-only, SessionStart)

Adds a lightweight "new release available" notice without auto-installing —
because re-running install.sh overwrites ADAM's own /reflect-applied skill
edits, so the user must choose when to take an update.

- install.sh writes ~/.claude/adam/.version (the installed release tag) on
  every install. Derived from $VERSION / piped REF / `git describe --tags`.
- adam-nudge.mjs (SessionStart) compares .version against the latest GitHub
  release at most once/day. Cached in ~/.claude/adam/.update-check.json; the
  cache drives an instant nudge (no network on the hot path) and is refreshed
  best-effort with a 1.5s AbortController cap. fetch unavailable / offline /
  timeout / rate-limit / parse error all degrade to silent no-op. Opt out with
  ADAM_NO_UPDATE_CHECK=1. main() is now async; never blocks SessionStart.
- README: "Staying up to date" section; pin example bumped to v0.6.3.

Tests: 134 -> 138. Notifier verified fully offline (cache-driven): nudges when
a newer release is cached, silent when current, suppressed by the opt-out env,
and no-ops when the .version marker is absent.
This commit is contained in:
2026-05-29 13:13:59 +01:00
parent d929101af4
commit fcddb6bf79
5 changed files with 175 additions and 10 deletions
+86 -4
View File
@@ -1,9 +1,17 @@
#!/usr/bin/env node
// adam-nudge.mjs — SessionStart hook. Prints two kinds of reminders:
// adam-nudge.mjs — SessionStart hook. Prints reminders:
// 1. Pending proposals (≥3 queued in adam/proposals/).
// 2. Cross-session nudges (entries in adam/active-nudges.json whose
// source_session differs from the current session and that haven't
// expired or exhausted their max_displays).
// 3. Pending local-edit upgrades (`.adam-new` sidecars).
// 4. New-release notice: if a newer GitHub release exists than the installed
// `.version`, print a notify-only one-line update prompt. Cached + checked
// at most once/day, network call hard-capped at 1.5s, fully best-effort —
// never blocks SessionStart. Opt out with ADAM_NO_UPDATE_CHECK=1.
// NOTE: notify-only by design — applying an update re-runs install.sh,
// which resets ADAM's own /reflect-applied skill edits. The user chooses
// when to accept that, so we never auto-install.
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
@@ -14,7 +22,13 @@ const ADAM_ROOT = join(CLAUDE_ROOT, "adam");
const PROPOSALS = join(ADAM_ROOT, "proposals");
const NUDGES_FILE = join(ADAM_ROOT, "active-nudges.json");
const STATE_FILE = join(ADAM_ROOT, "state.json");
const VERSION_FILE = join(ADAM_ROOT, ".version");
const UPDATE_CHECK_FILE = join(ADAM_ROOT, ".update-check.json");
const THRESHOLD = 3;
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
const UPDATE_FETCH_TIMEOUT_MS = 1500;
const RELEASES_API = "https://api.github.com/repos/lukaszraczylo/claude-adam/releases/latest";
const INSTALL_ONELINER = "curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/claude-adam/main/install.sh | bash";
// Known installable paths (mirrors install.sh copy_file list). Checking a
// fixed shortlist keeps SessionStart latency under control vs full FS walk.
@@ -118,7 +132,75 @@ function emitPendingUpgrades() {
} catch { /* never break SessionStart */ }
}
function main() {
// --- update notifier (notify-only; see header note) ---
function readVersion() {
try { return readFileSync(VERSION_FILE, "utf8").trim() || null; } catch { return null; }
}
// Parse "vX.Y.Z" (leading v optional; pre-release/build suffix ignored).
function parseSemver(s) {
if (typeof s !== "string") return null;
const m = s.trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)/);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
// isNewer(a, b): true iff version a is strictly newer than b. Unparseable → false.
function isNewer(a, b) {
const pa = parseSemver(a), pb = parseSemver(b);
if (!pa || !pb) return false;
for (let i = 0; i < 3; i++) { if (pa[i] !== pb[i]) return pa[i] > pb[i]; }
return false;
}
async function fetchLatestTag() {
// Best-effort, hard-capped. Any failure (offline / timeout / rate-limit /
// parse / fetch-unavailable) returns null and the caller silently skips.
try {
if (typeof fetch !== "function") return null;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), UPDATE_FETCH_TIMEOUT_MS);
let tag = null;
try {
const res = await fetch(RELEASES_API, {
signal: ctrl.signal,
headers: { "User-Agent": "claude-adam-nudge", "Accept": "application/vnd.github+json" },
});
if (res && res.ok) {
const j = await res.json();
if (j && typeof j.tag_name === "string") tag = j.tag_name;
}
} finally { clearTimeout(timer); }
return tag;
} catch { return null; }
}
function printUpdateNudge(latest, installed) {
process.stdout.write(
`[adam] update available: ${installed}${latest}. Apply: ${INSTALL_ONELINER}\n` +
` (re-runs install.sh — resets ADAM's own /reflect-applied skill edits; apply when you're ready)\n`
);
}
async function emitUpdateCheck() {
if (process.env.ADAM_NO_UPDATE_CHECK) return; // explicit opt-out
const installed = readVersion();
if (!installed) return; // no marker → nothing to compare
const cache = readJson(UPDATE_CHECK_FILE, {}) || {};
const now = Date.now();
let nudged = false;
// Instant nudge from cache (no network).
if (cache.latest && isNewer(cache.latest, installed)) { printUpdateNudge(cache.latest, installed); nudged = true; }
// Refresh cache at most once/day, best-effort — drives the nudge on the NEXT run.
if (!cache.last_check || (now - Number(cache.last_check)) > UPDATE_CHECK_INTERVAL_MS) {
const latest = await fetchLatestTag();
const next = { last_check: now, latest: latest || cache.latest || null };
try { writeFileSync(UPDATE_CHECK_FILE, JSON.stringify(next)); } catch { /* swallow */ }
if (latest && !nudged && isNewer(latest, installed)) printUpdateNudge(latest, installed);
}
}
async function main() {
const stdinSession = readSessionInput();
const stateSession = (() => {
const st = readJson(STATE_FILE, null);
@@ -128,7 +210,7 @@ function main() {
emitProposalReminder();
emitActiveNudges(currentSession);
emitPendingUpgrades();
await emitUpdateCheck();
}
try { main(); } catch { /* never block SessionStart */ }
process.exit(0);
main().catch(() => { /* never block SessionStart */ }).finally(() => process.exit(0));