mirror of
https://github.com/lukaszraczylo/claude-adam.git
synced 2026-07-07 03:44:26 +00:00
chore(v0.3.3): analyst observability, A/B measurement, journal hygiene
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).
This commit is contained in:
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
// adam-upgrade.mjs — review/accept pending `.adam-new` files from install.sh.
|
||||
//
|
||||
// install.sh writes <file>.adam-new next to user-modified ADAM files instead
|
||||
// of clobbering. This tool surfaces those pending merges and lets users
|
||||
// review the diff + accept atomically.
|
||||
//
|
||||
// CLI:
|
||||
// adam-upgrade.mjs --list [--home <path>]
|
||||
// adam-upgrade.mjs --diff [<path>] [--home <path>]
|
||||
// adam-upgrade.mjs --accept <path> [--home <path>]
|
||||
// adam-upgrade.mjs --accept-all [--home <path>]
|
||||
// adam-upgrade.mjs --help
|
||||
|
||||
import {
|
||||
readdirSync,
|
||||
statSync,
|
||||
existsSync,
|
||||
renameSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join, dirname, basename } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const EXCLUDE_DIRS = new Set([
|
||||
".git",
|
||||
"node_modules",
|
||||
"journal",
|
||||
"trash",
|
||||
"proposals",
|
||||
"applied",
|
||||
"rejected",
|
||||
]);
|
||||
|
||||
// Walk a directory tree, collecting paths to files ending in `.adam-new`.
|
||||
// Excludes the dirs above defensively (no point in surfacing journal entries).
|
||||
export function findPending(home) {
|
||||
const root = home;
|
||||
const out = [];
|
||||
function walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (EXCLUDE_DIRS.has(e.name)) continue;
|
||||
walk(full);
|
||||
} else if (e.isFile() && e.name.endsWith(".adam-new")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
function fileSize(p) {
|
||||
try { return statSync(p).size; } catch { return 0; }
|
||||
}
|
||||
|
||||
function fileAgeDays(p) {
|
||||
try {
|
||||
const mtime = statSync(p).mtimeMs;
|
||||
return Math.floor((Date.now() - mtime) / 86400000);
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
// Produce a unified diff between two files. Prefer the system `diff -u` binary
|
||||
// (universally available, accurate). On systems without `diff`, fall back to a
|
||||
// naive line-by-line diff prefixed with MISSING:/NEW: so the tool still works.
|
||||
export function diffPaths(orig, neu) {
|
||||
const r = spawnSync("diff", ["-u", orig, neu], { encoding: "utf8" });
|
||||
if (r.error || r.status === null || r.status === 2) {
|
||||
// diff binary missing or fatal error — naive fallback
|
||||
let a = [], b = [];
|
||||
try { a = readFileSync(orig, "utf8").split("\n"); } catch {}
|
||||
try { b = readFileSync(neu, "utf8").split("\n"); } catch {}
|
||||
const max = Math.max(a.length, b.length);
|
||||
const lines = [];
|
||||
for (let i = 0; i < max; i++) {
|
||||
const la = a[i], lb = b[i];
|
||||
if (la === lb) continue;
|
||||
if (la !== undefined) lines.push(`MISSING: ${la}`);
|
||||
if (lb !== undefined) lines.push(`NEW: ${lb}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
return r.stdout || "";
|
||||
}
|
||||
|
||||
// Atomic swap: rename orig → orig.adam-prev, rename neu → orig. Overwrites any
|
||||
// prior .adam-prev backup (safe: a previous accept already promoted it).
|
||||
export function acceptOne(orig, neu) {
|
||||
if (!existsSync(neu)) {
|
||||
throw new Error(`missing pending file: ${neu}`);
|
||||
}
|
||||
const prev = `${orig}.adam-prev`;
|
||||
if (existsSync(orig)) {
|
||||
if (existsSync(prev)) {
|
||||
try { unlinkSync(prev); } catch {}
|
||||
}
|
||||
renameSync(orig, prev);
|
||||
}
|
||||
renameSync(neu, orig);
|
||||
return { orig, prev };
|
||||
}
|
||||
|
||||
export function acceptAll(home) {
|
||||
const pending = findPending(home);
|
||||
const results = [];
|
||||
for (const neu of pending) {
|
||||
const orig = neu.replace(/\.adam-new$/, "");
|
||||
try {
|
||||
const r = acceptOne(orig, neu);
|
||||
results.push({ ok: true, ...r });
|
||||
} catch (err) {
|
||||
results.push({ ok: false, orig, error: String(err && err.message || err) });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { cmd: null, target: null, home: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--list") args.cmd = "list";
|
||||
else if (a === "--diff") args.cmd = "diff";
|
||||
else if (a === "--accept") args.cmd = "accept";
|
||||
else if (a === "--accept-all") args.cmd = "accept-all";
|
||||
else if (a === "--help" || a === "-h") args.cmd = "help";
|
||||
else if (a === "--home" && i + 1 < argv.length) args.home = argv[++i];
|
||||
else if (!a.startsWith("--") && args.target == null) args.target = a;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
process.stdout.write(
|
||||
"adam-upgrade — review pending `.adam-new` files from install.sh\n" +
|
||||
"\n" +
|
||||
"Usage:\n" +
|
||||
" adam-upgrade.mjs --list [--home <path>]\n" +
|
||||
" adam-upgrade.mjs --diff [<path>] [--home <path>]\n" +
|
||||
" adam-upgrade.mjs --accept <path> [--home <path>]\n" +
|
||||
" adam-upgrade.mjs --accept-all [--home <path>]\n" +
|
||||
" adam-upgrade.mjs --help\n"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveHome(args) {
|
||||
if (args.home) return args.home;
|
||||
return join(process.env.HOME || homedir(), ".claude");
|
||||
}
|
||||
|
||||
function cmdList(args) {
|
||||
const home = resolveHome(args);
|
||||
const pending = findPending(home);
|
||||
for (const neu of pending) {
|
||||
const orig = neu.replace(/\.adam-new$/, "");
|
||||
const origSize = fileSize(orig);
|
||||
const newSize = fileSize(neu);
|
||||
const age = fileAgeDays(neu);
|
||||
process.stdout.write(`${neu} (orig: ${origSize}, new: ${newSize}, age: ${age}d)\n`);
|
||||
}
|
||||
process.stderr.write(`${pending.length} pending\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cmdDiff(args) {
|
||||
const home = resolveHome(args);
|
||||
let targets;
|
||||
if (args.target) {
|
||||
// Allow either passing the orig path or the .adam-new path.
|
||||
const t = args.target;
|
||||
const orig = t.endsWith(".adam-new") ? t.replace(/\.adam-new$/, "") : t;
|
||||
targets = [orig];
|
||||
} else {
|
||||
targets = findPending(home).map((n) => n.replace(/\.adam-new$/, ""));
|
||||
}
|
||||
for (const orig of targets) {
|
||||
const neu = `${orig}.adam-new`;
|
||||
process.stdout.write(`=== ${orig} ===\n`);
|
||||
if (!existsSync(neu)) {
|
||||
process.stderr.write(`no pending: ${neu}\n`);
|
||||
continue;
|
||||
}
|
||||
process.stdout.write(diffPaths(orig, neu));
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cmdAccept(args) {
|
||||
if (!args.target) {
|
||||
process.stderr.write("error: --accept requires a <path>\n");
|
||||
return 1;
|
||||
}
|
||||
const t = args.target;
|
||||
const orig = t.endsWith(".adam-new") ? t.replace(/\.adam-new$/, "") : t;
|
||||
const neu = `${orig}.adam-new`;
|
||||
try {
|
||||
const r = acceptOne(orig, neu);
|
||||
process.stdout.write(`accepted: ${r.orig} (backup: ${r.prev})\n`);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
process.stderr.write(`error: ${err.message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function cmdAcceptAll(args) {
|
||||
const home = resolveHome(args);
|
||||
const results = acceptAll(home);
|
||||
for (const r of results) {
|
||||
if (r.ok) {
|
||||
process.stdout.write(`accepted: ${r.orig} (backup: ${r.prev})\n`);
|
||||
} else {
|
||||
process.stderr.write(`error: ${r.orig}: ${r.error}\n`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.cmd || args.cmd === "help") { usage(); return 0; }
|
||||
if (args.cmd === "list") return cmdList(args);
|
||||
if (args.cmd === "diff") return cmdDiff(args);
|
||||
if (args.cmd === "accept") return cmdAccept(args);
|
||||
if (args.cmd === "accept-all") return cmdAcceptAll(args);
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Only run main() when invoked as a script (not when imported for tests).
|
||||
const invokedAsScript = (() => {
|
||||
try {
|
||||
const argv1 = process.argv[1] || "";
|
||||
return argv1.endsWith("adam-upgrade.mjs");
|
||||
} catch { return true; }
|
||||
})();
|
||||
if (invokedAsScript) {
|
||||
process.exit(main());
|
||||
}
|
||||
Reference in New Issue
Block a user