mirror of
https://github.com/lukaszraczylo/claude-adam.git
synced 2026-06-09 23:19:12 +00:00
012c40b9ab
Storage/window/exclusion split (#7): ISO-week journal rotation with safety fuse replaces size-based rotation (fixes silent under-counting when clusters straddle boundaries). Per-signal sliding windows via adam-window.mjs guard against stale signal accumulation. Legacy YYYY-MM-DD-<ts>.jsonl files remain readable. Error fingerprint normalization (#3): adam-observe.mjs extracts canonical error codes (ENOENT, ECONNREFUSED, etc.) and normalizes paths/timestamps/hex before hashing. 'Connection refused' and 'ECONNREFUSED' now cluster identically. Correction corpus expansion (#1): strong tokens (stop, wrong, undo, try again, different approach, etc.) fire on any occurrence. Weak tokens (no, actually, wait) require negation/contrast co-occurrence within 8 tokens. Kills the 'actually, I think...' false positive. Analyst observability (#6): mandatory clustering trace block; adam-explain.mjs parses to summary/full/json. Cluster decisions now surface rejection reasons (threshold, contradiction, window). Persisted to ~/.claude/adam/last-trace.txt. Dead_end nudge proposal type (#2): single-session auto-apply gate (>=3 dead_end events). Action appends to active-nudges.json, surfaced via adam-nudge.mjs at next SessionStart. Lower blast than skill_edit. Per-(skill, fingerprint) cooldown (#4): adam-cooldown.mjs replaces coarse per-skill check. proposal_fingerprint = djb2(skill_slug + cluster_id + normalized_diff_body). Legacy applied/rejected records gate via 'legacy' fingerprint fallback through resolveSkill helper (handles target_skill, skill, or target: <path>). task_completed scoring integration (#8): adam-score.mjs computes per-session urgency dampener (3 task_completed -> 0.5) and reinforcement candidates (skills cited in >=3 clean completions). New 'reinforcement' proposal type appends to reinforcements.jsonl on apply (no code/memory mutation). A/B effectiveness measurement (#5): every auto-applied edit appends to ab-tracking.jsonl. adam-ab-measure.mjs computes 7d pre/post signal-count delta per entry (improved / neutral / regressed / no_baseline / pending). Analyst surfaces regressions at top of /reflect output. Upgrade UX overhaul (#9): adam-upgrade.mjs implements --list/--diff/--accept /--accept-all. SessionStart nudge prints pending-merge warning when .adam-new files exist (latency ~20ms via fixed shortlist). install.sh emits unmissable final-message hint after creating any .adam-new file. Simplify pass: adam-utils.mjs deduplicates readJsonlSafe / listJsonlFiles / parseFrontmatter across 8 scripts. Net -46 LOC. Test coverage: 30 -> 87 tests. Every new feature has feature-validating assertions (false-case coverage included). T77 statically verifies install.sh references every adam-*.mjs source script (would have caught the missing adam-utils inclusion that review #2 surfaced).
132 lines
4.7 KiB
JavaScript
Executable File
132 lines
4.7 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/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);
|