mirror of
https://github.com/lukaszraczylo/claude-adam.git
synced 2026-06-14 00:31:45 +00:00
fcddb6bf79
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.
217 lines
8.5 KiB
JavaScript
Executable File
217 lines
8.5 KiB
JavaScript
Executable File
#!/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));
|