#!/usr/bin/env node // 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"; const HOME = process.env.HOME || homedir(); const CLAUDE_ROOT = join(HOME, ".claude"); 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. const PENDING_CHECK_PATHS = [ "hooks/adam-observe.mjs", "hooks/adam-nudge.mjs", "agents/adam.md", "skills/adam-self-improvement/SKILL.md", "commands/reflect.md", "adam/scripts/adam-archive.mjs", "adam/scripts/adam-upgrade.mjs", "adam/scripts/adam-window.mjs", "adam/scripts/adam-explain.mjs", "adam/scripts/adam-nudge-eligibility.mjs", "adam/scripts/adam-cooldown.mjs", "adam/scripts/adam-score.mjs", "adam/scripts/adam-ab-measure.mjs", "adam/scripts/adam-apply-reinforcement.mjs", "adam/scripts/adam-utils.mjs", "adam/scripts/adam-batch.mjs", "adam/scripts/adam-rollback.mjs", "adam/tests/run-tests.sh", ]; function readJson(path, fallback) { if (!existsSync(path)) return fallback; try { return JSON.parse(readFileSync(path, "utf8")); } catch { return fallback; } } function readSessionInput() { // SessionStart payload arrives on stdin; capture session_id if present. // We don't block on stdin — best-effort, non-blocking. try { const buf = readFileSync(0, "utf8"); if (!buf) return null; const parsed = JSON.parse(buf); return parsed && typeof parsed.session_id === "string" ? parsed.session_id : null; } catch { return null; } } function emitProposalReminder() { try { const PROPOSAL_RE = /^\d{4}-\d{2}-\d{2}-\d{3}-/; const files = readdirSync(PROPOSALS).filter((f) => PROPOSAL_RE.test(f) && f.endsWith(".md")); if (files.length >= THRESHOLD) { process.stdout.write(`adam: ${files.length} proposals queued. Run /reflect to review.\n`); } } catch { /* proposals dir absent → silent */ } } function emitActiveNudges(currentSession) { if (!existsSync(NUDGES_FILE)) return; const raw = readJson(NUDGES_FILE, null); if (!Array.isArray(raw)) return; const now = Date.now(); const kept = []; let mutated = false; for (const entry of raw) { if (!entry || typeof entry !== "object") { mutated = true; continue; } const expires = Number(entry.expires_at_ts || 0); if (!expires || expires <= now) { mutated = true; continue; } const sourceSession = entry.source_session || ""; const max = Number(entry.max_displays || 0); const used = Number(entry.displays_used || 0); if (max > 0 && used >= max) { mutated = true; continue; } // Cross-session gate: only print when current session differs. if (sourceSession && currentSession && sourceSession === currentSession) { kept.push(entry); continue; } if (typeof entry.message === "string" && entry.message) { process.stdout.write(entry.message + "\n"); const nextUsed = used + 1; mutated = true; if (max > 0 && nextUsed >= max) continue; // drop after exhaustion kept.push({ ...entry, displays_used: nextUsed }); } else { kept.push(entry); } } if (mutated) { try { writeFileSync(NUDGES_FILE, JSON.stringify(kept, null, 2)); } catch { /* swallow */ } } } function emitPendingUpgrades() { // Cheap: stat a fixed shortlist of `.adam-new` candidates. Non-fatal. try { let count = 0; for (const rel of PENDING_CHECK_PATHS) { const p = join(CLAUDE_ROOT, `${rel}.adam-new`); try { if (existsSync(p)) count++; } catch { /* per-path swallow */ } } if (count > 0) { process.stdout.write( `[adam] ${count} pending upgrade(s). Review: node ~/.claude/adam/scripts/adam-upgrade.mjs --list\n` ); } } catch { /* never break SessionStart */ } } // --- 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); return st && typeof st.session_id === "string" ? st.session_id : null; })(); const currentSession = stdinSession || stateSession || ""; emitProposalReminder(); emitActiveNudges(currentSession); emitPendingUpgrades(); await emitUpdateCheck(); } main().catch(() => { /* never block SessionStart */ }).finally(() => process.exit(0));