From fcddb6bf793ccad83f240a67d742a49fa1161118 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 29 May 2026 13:13:59 +0100 Subject: [PATCH] feat(v0.6.3): release-update notifier (notify-only, SessionStart) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 28 ++++++++++--- adam/tests/run-tests.sh | 51 +++++++++++++++++++++++ agents/adam.md | 2 +- hooks/adam-nudge.mjs | 90 +++++++++++++++++++++++++++++++++++++++-- install.sh | 14 +++++++ 5 files changed, 175 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 995d536..fa95ab9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Watches the friction in your coding sessions, clusters the signals via an LLM an [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Version](https://img.shields.io/github/v/release/lukaszraczylo/claude-adam?label=version&color=blue)](https://github.com/lukaszraczylo/claude-adam/releases) -[![Tests](https://img.shields.io/badge/tests-134%20passing-brightgreen.svg)](./adam/tests/run-tests.sh) +[![Tests](https://img.shields.io/badge/tests-138%20passing-brightgreen.svg)](./adam/tests/run-tests.sh) [![Node](https://img.shields.io/badge/node-22%2B-339933.svg)](https://nodejs.org) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)]() @@ -54,7 +54,7 @@ The installer copies files into `~/.claude/`, offers to merge ADAM's hook entrie Then: ```sh -bash ~/.claude/adam/tests/run-tests.sh # expect: 134 passed, 0 failed +bash ~/.claude/adam/tests/run-tests.sh # expect: 138 passed, 0 failed # … start a fresh Claude Code session … /reflect # walks the proposal queue /reflect --explain # also shows the analyst's clustering trace @@ -63,10 +63,27 @@ bash ~/.claude/adam/tests/run-tests.sh # expect: 134 passed, 0 failed Pin a release for reproducibility: ```sh -curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/claude-adam/v0.6.0/install.sh \ - | VERSION=v0.6.0 bash +curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/claude-adam/v0.6.3/install.sh \ + | VERSION=v0.6.3 bash ``` +### Staying up to date + +`install.sh` records the installed release in `~/.claude/adam/.version`. The +SessionStart hook (`adam-nudge.mjs`) then checks the latest GitHub release **at +most once a day** (cached in `~/.claude/adam/.update-check.json`, network call +hard-capped at 1.5 s, fully best-effort — it never blocks or slows session +start). When a newer release exists it prints a one-line, **notify-only** prompt: + +``` +[adam] update available: v0.6.3 → v0.6.4. Apply: curl -fsSL …/install.sh | bash + (re-runs install.sh — resets ADAM's own /reflect-applied skill edits; apply when you're ready) +``` + +It is deliberately **not** auto-applied: re-running `install.sh` overwrites +ADAM's own `/reflect`-applied skill edits, so you decide when to take an update. +Disable the check entirely with `ADAM_NO_UPDATE_CHECK=1` in your environment. + ## How it works ```mermaid @@ -248,11 +265,12 @@ Or pass `--explain` to `/reflect` to render the full trace inline. │ ├── adam-apply-reinforcement.mjs # reinforcement proposal apply │ ├── adam-upgrade.mjs # .adam-new file UX (list/diff/accept) │ └── adam-archive.mjs # post-apply journal cleanup - └── tests/run-tests.sh # 134 isolated tests; never touches live state + └── tests/run-tests.sh # 138 isolated tests; never touches live state ``` ## What's new +- **v0.6.3** — release-update notifier. `install.sh` now writes a `~/.claude/adam/.version` marker; `adam-nudge.mjs` (SessionStart) compares it against the latest GitHub release at most once/day (cached, 1.5 s network cap, best-effort — never blocks) and prints a **notify-only** one-line update prompt. Deliberately not auto-applied: re-running the installer resets ADAM's own `/reflect`-applied skill edits, so you choose when to update. Opt out with `ADAM_NO_UPDATE_CHECK=1`. See "Staying up to date". 138 tests (up from 134). - **v0.6.2** — two fixes surfaced by running ADAM's loop on a large real journal. **(1) A/B volume normalization** (`adam-ab-measure.mjs`): regressions are now measured on the signal's *share* of total activity (rate = count / window-total), not raw count — so a generally busier journal after an apply no longer masquerades as a regression. Falls back to raw delta when the signal is the only activity in the window (preserves prior behavior + tests); output adds `raw_delta_pct`, `pre_total`, `post_total`, `normalized` for transparency. **(2) Memory frontmatter schema** (`agents/adam.md`, `SKILL.md`): the drafting protocol now emits the live auto-memory shape — `name` = slug + a `metadata: {node_type, type, originSessionId}` block — instead of flat `type:`/`originSessionId:`, so auto-applied memories load and categorize correctly. 134 tests (up from 132). - **v0.6.1** — new `file_reread` signal (MOSS §1 harness self-modification, proposed and approved through ADAM's own `/reflect` loop). Consecutive Reads of the same file at different `offset`/`limit` escaped `retry_loop`'s arg-hash dedup and leaked into `tool_error_loop`; `file_reread` now catches them (same file ≥3× in the 10-event window, offset-agnostic, guarded against double-counting byte-identical reads). Fully wired: detection (`adam-observe.mjs`), 14-day window (`adam-window.mjs`), severity divisor 3 (`adam-score.mjs`), file-basename clustering (`adam-batch.mjs`), and the analyst rubric/spec. 132 tests (up from 126). - **v0.6.0** — review hardening. Struggle signals now emit `active_skills`, so `silent_drift`'s primary cluster key and the §5b skill-attribution sub-clustering (+1 rubric bonus) actually fire (both were silently dead). `proposal_fingerprint` is now deterministically computable via `adam-cooldown.mjs --compute` instead of asking the LLM analyst to hand-compute a djb2 hash; spec now mandates a *stable* cluster id so fingerprints reproduce across runs. `reinforcement` proposals are correctly excluded from A/B tracking (the spec previously contradicted itself). `adam-nudge.mjs` pending-upgrade check now mirrors the full install set (`adam-utils`/`adam-batch`/`adam-rollback` were missing). Doc/test-count drift corrected. 126 tests (up from 114). diff --git a/adam/tests/run-tests.sh b/adam/tests/run-tests.sh index 788bfac..0745f93 100755 --- a/adam/tests/run-tests.sh +++ b/adam/tests/run-tests.sh @@ -2058,6 +2058,57 @@ else fi rm -f "$ROOT/ab-tracking.jsonl" +# --- Test 114: update notifier nudges from cache when a newer release exists (no network) --- +echo "Test 114: update notifier — cached newer release prints nudge" +reset_state +printf 'v0.6.2\n' > "$ROOT/.version" +node -e "require('fs').writeFileSync('$ROOT/.update-check.json', JSON.stringify({last_check: Date.now(), latest: 'v9.9.9'}))" +out=$(echo '{"hook_event_name":"SessionStart","session_id":"sUP"}' | NUDGE_RUN 2>/dev/null) +if echo "$out" | grep -q "update available: v0.6.2 → v9.9.9"; then + echo " PASS: update nudge printed from cache (offline)"; PASS=$((PASS+1)) +else + echo " FAIL: expected update nudge (got: $out)"; FAIL=$((FAIL+1)) +fi +rm -f "$ROOT/.version" "$ROOT/.update-check.json" + +# --- Test 115: update notifier silent when installed is current --- +echo "Test 115: update notifier — up-to-date is silent" +reset_state +printf 'v9.9.9\n' > "$ROOT/.version" +node -e "require('fs').writeFileSync('$ROOT/.update-check.json', JSON.stringify({last_check: Date.now(), latest: 'v9.9.9'}))" +out=$(echo '{"hook_event_name":"SessionStart","session_id":"sUP2"}' | NUDGE_RUN 2>/dev/null) +if echo "$out" | grep -q "update available"; then + echo " FAIL: nudged despite being current (got: $out)"; FAIL=$((FAIL+1)) +else + echo " PASS: no nudge when up-to-date"; PASS=$((PASS+1)) +fi +rm -f "$ROOT/.version" "$ROOT/.update-check.json" + +# --- Test 116: ADAM_NO_UPDATE_CHECK disables the notifier --- +echo "Test 116: ADAM_NO_UPDATE_CHECK opt-out" +reset_state +printf 'v0.6.2\n' > "$ROOT/.version" +node -e "require('fs').writeFileSync('$ROOT/.update-check.json', JSON.stringify({last_check: Date.now(), latest: 'v9.9.9'}))" +out=$(echo '{"hook_event_name":"SessionStart","session_id":"sUP3"}' | HOME="$TMP_HOME" ADAM_NO_UPDATE_CHECK=1 node "$NUDGE" 2>/dev/null) +if echo "$out" | grep -q "update available"; then + echo " FAIL: notifier ran despite opt-out (got: $out)"; FAIL=$((FAIL+1)) +else + echo " PASS: ADAM_NO_UPDATE_CHECK suppressed the check"; PASS=$((PASS+1)) +fi +rm -f "$ROOT/.version" "$ROOT/.update-check.json" + +# --- Test 117: no .version marker → notifier no-op (no crash) --- +echo "Test 117: missing .version marker → notifier silent, hook still runs" +reset_state +node -e "require('fs').writeFileSync('$ROOT/.update-check.json', JSON.stringify({last_check: Date.now(), latest: 'v9.9.9'}))" +out=$(echo '{"hook_event_name":"SessionStart","session_id":"sUP4"}' | NUDGE_RUN 2>/dev/null) +if echo "$out" | grep -q "update available"; then + echo " FAIL: nudged without a .version marker (got: $out)"; FAIL=$((FAIL+1)) +else + echo " PASS: no marker → no update nudge"; PASS=$((PASS+1)) +fi +rm -f "$ROOT/.update-check.json" + echo echo "Results: $PASS passed, $FAIL failed" [ "$FAIL" = "0" ] diff --git a/agents/adam.md b/agents/adam.md index 0fd911e..5a48292 100644 --- a/agents/adam.md +++ b/agents/adam.md @@ -516,7 +516,7 @@ MOSS's core thesis: "routing, hook ordering, state invariants, and dispatch live 2. `cross_session_evidence == true` (≥5 occurrences across ≥3 sessions) 3. `auto_apply_eligible: false` — **always**. Harness edits are never auto-applied. 4. `blast_radius: high` -5. Proposal includes a `# Test verification` section with the command `bash ~/.claude/adam/tests/run-tests.sh` and the expected result "134 passed, 0 failed" (or current pass count). The skill runs this test before applying. +5. Proposal includes a `# Test verification` section with the command `bash ~/.claude/adam/tests/run-tests.sh` and the expected result "138 passed, 0 failed" (or current pass count). The skill runs this test before applying. 6. Change is surgical: ≤30 LOC diff, single file. 7. `# Diagnosis` reconstructs the causal chain from harness-level behavior (not from text-artifact behavior). The mismatch must name a specific code path (function, regex, threshold) in the target file. diff --git a/hooks/adam-nudge.mjs b/hooks/adam-nudge.mjs index bb20592..f1be2ae 100755 --- a/hooks/adam-nudge.mjs +++ b/hooks/adam-nudge.mjs @@ -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)); diff --git a/install.sh b/install.sh index 7d00c22..b45c01a 100755 --- a/install.sh +++ b/install.sh @@ -143,6 +143,20 @@ copy_file "$SRC/adam/tests/fixtures/seed-corrections.jsonl" "$DEST/adam # install marker — used by future runs to detect local mtime drift run "touch \"$DEST/adam/.install-marker\"" +# version marker — records the installed release tag for the update notifier +# (adam-nudge.mjs compares it against the latest GitHub release). +ADAM_VERSION="" +if [ -n "$VERSION" ]; then + ADAM_VERSION="$VERSION" +elif [ "$PIPED" = 1 ] && [ -n "${REF:-}" ]; then + ADAM_VERSION="$REF" +else + ADAM_VERSION="$(git -C "$SRC" describe --tags --abbrev=0 2>/dev/null || true)" +fi +[ -z "$ADAM_VERSION" ] && ADAM_VERSION="unknown" +run "printf '%s\\n' \"$ADAM_VERSION\" > \"$DEST/adam/.version\"" +log " version marker: $ADAM_VERSION" + # --------------------------------------------------------------------- settings.json SETTINGS="$DEST/settings.json" EXAMPLE="$SRC/settings.json.example"