Files
claude-adam/hooks/adam-nudge.mjs
T
lukaszraczylo 4b36d6c09e feat(v0.6.0): review hardening — live active_skills clustering, computable fingerprints
Full codebase review (multi-agent, adversarially verified) surfaced several
documented-but-dead mechanisms and doc/code drift. Fixes:

- adam-observe: struggle signals now emit `active_skills`, so silent_drift's
  primary cluster key AND §5b skill-attribution sub-clustering (+1 rubric
  bonus) actually fire — both were silently dead (no struggle signal carried
  the field).
- adam-cooldown: new `--compute` CLI deterministically derives
  proposal_fingerprint. The exported computeProposalFingerprint() was never
  called and the analyst was told to hand-compute a djb2 hash it cannot
  reproduce. Spec now mandates a *stable* cluster id so fingerprints reproduce
  across /reflect runs. Removed one dead normalization line.
- spec: reinforcement proposals excluded from A/B tracking — agents/adam.md
  contradicted itself (:376 included, :476 excluded); SKILL.md aligned.
- adam-nudge: PENDING_CHECK_PATHS now mirrors the full install set
  (adam-utils / adam-batch / adam-rollback were missing).
- adam-explain: synthesized clustering summary carries `regressions: 0`
  (structural consistency with parsed summaries).
- docs: test-count drift (87/94 -> 126) and "350-line hook" (-> ~600) fixed;
  adam-score header documents severity_sum/severity_by_type; adam-batch §4
  reference corrected.

Tests: +12 assertions (114 -> 126), all green. New regression tests cover the
active_skills fix and --compute, plus boundary gaps the review flagged:
retry_loop/weak_agent thresholds, A/B exact +/-25% deltas, cooldown 30d
blacklist edge.
2026-05-29 01:57:44 +01:00

135 lines
4.8 KiB
JavaScript
Executable File

#!/usr/bin/env node
// adam-nudge.mjs — SessionStart hook. Prints two kinds of 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).
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 THRESHOLD = 3;
// 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 */ }
}
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();
}
try { main(); } catch { /* never block SessionStart */ }
process.exit(0);