mirror of
https://github.com/lukaszraczylo/claude-adam.git
synced 2026-06-08 23:09:16 +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).
239 lines
8.4 KiB
JavaScript
Executable File
239 lines
8.4 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// adam-explain.mjs — render the analyst's clustering trace in summary / full / json modes.
|
|
//
|
|
// The adam agent ALWAYS emits a fenced ```trace block after its proposals. The
|
|
// adam-self-improvement skill persists the most recent trace to
|
|
// ~/.claude/adam/last-trace.txt. This tool parses and presents it.
|
|
//
|
|
// Trace line grammar (per agents/adam.md "Clustering trace (always emit)"):
|
|
// <cluster_id> | signal=<type> count=<N> sessions=<M> | gates: threshold=<pass|fail:<reason>>, cross_session=<pass|fail>, window=<in:<N>/out:<M>>, contradiction=<none|vetoed:[[memory-name]]> | decision: <proposal_emitted:<type>|skipped:<reason>>
|
|
// Trailing summary line:
|
|
// SUMMARY: considered=<N> emitted=<M> skipped=<N-M> reasons={threshold:X, contradiction:Y, window:Z, other:W}
|
|
//
|
|
// Usage: adam-explain.mjs [--input <path>] [--mode summary|full|json] [--home <path>]
|
|
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { homedir } from "node:os";
|
|
|
|
function parseArgs(argv) {
|
|
const args = { input: null, mode: "summary", home: null };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === "--input" && i + 1 < argv.length) args.input = argv[++i];
|
|
else if (a === "--mode" && i + 1 < argv.length) args.mode = argv[++i];
|
|
else if (a === "--home" && i + 1 < argv.length) args.home = argv[++i];
|
|
}
|
|
return args;
|
|
}
|
|
|
|
// Extract the inside of a ```trace ... ``` fenced block, or return the input
|
|
// verbatim if no fence is present (tolerant mode).
|
|
function extractFenced(text) {
|
|
const m = text.match(/```trace\s*\n([\s\S]*?)\n```/);
|
|
return m ? m[1] : text;
|
|
}
|
|
|
|
// Parse a single cluster line. Returns null when no recognisable structure.
|
|
function parseClusterLine(line) {
|
|
// Three pipe-separated chunks: id | signal=… count=… sessions=… | gates: … | decision: …
|
|
const parts = line.split("|").map((s) => s.trim());
|
|
if (parts.length < 4) return null;
|
|
const id = parts[0];
|
|
if (!id) return null;
|
|
const sigChunk = parts[1];
|
|
const gatesChunk = parts[2];
|
|
const decisionChunk = parts.slice(3).join("|").trim();
|
|
|
|
const sigM = sigChunk.match(/signal=(\S+)\s+count=(\d+)\s+sessions=(\d+)/);
|
|
if (!sigM) return null;
|
|
const signal = sigM[1];
|
|
const count = Number(sigM[2]);
|
|
const sessions = Number(sigM[3]);
|
|
|
|
if (!gatesChunk.startsWith("gates:")) return null;
|
|
const gatesBody = gatesChunk.slice("gates:".length).trim();
|
|
const gates = {};
|
|
// gates body is a comma-separated list of key=value with possible commas inside values
|
|
// we accept simple key=token,key=token,… — split on commas not inside [[ ]]
|
|
const tokens = [];
|
|
let depth = 0;
|
|
let buf = "";
|
|
for (const ch of gatesBody) {
|
|
if (ch === "[") depth++;
|
|
else if (ch === "]") depth--;
|
|
if (ch === "," && depth === 0) { tokens.push(buf.trim()); buf = ""; }
|
|
else buf += ch;
|
|
}
|
|
if (buf.trim()) tokens.push(buf.trim());
|
|
for (const t of tokens) {
|
|
const idx = t.indexOf("=");
|
|
if (idx === -1) continue;
|
|
gates[t.slice(0, idx).trim()] = t.slice(idx + 1).trim();
|
|
}
|
|
|
|
if (!decisionChunk.startsWith("decision:")) return null;
|
|
const decision = decisionChunk.slice("decision:".length).trim();
|
|
|
|
return { id, signal, count, sessions, gates, decision };
|
|
}
|
|
|
|
function parseSummaryLine(line) {
|
|
// SUMMARY: considered=N emitted=M skipped=K [regressions=R] reasons={...}
|
|
// `regressions` is optional (added in #5 — A/B measurement). Pre-existing
|
|
// traces lacking the token still parse.
|
|
const m = line.match(
|
|
/^SUMMARY:\s*considered=(\d+)\s+emitted=(\d+)\s+skipped=(\d+)(?:\s+regressions=(\d+))?\s+reasons=\{([^}]*)\}\s*$/
|
|
);
|
|
if (!m) return null;
|
|
const reasons = {};
|
|
for (const piece of m[5].split(",")) {
|
|
const kv = piece.trim();
|
|
if (!kv) continue;
|
|
const idx = kv.indexOf(":");
|
|
if (idx === -1) continue;
|
|
reasons[kv.slice(0, idx).trim()] = Number(kv.slice(idx + 1).trim()) || 0;
|
|
}
|
|
return {
|
|
considered: Number(m[1]),
|
|
emitted: Number(m[2]),
|
|
skipped: Number(m[3]),
|
|
regressions: m[4] != null ? Number(m[4]) : 0,
|
|
reasons,
|
|
};
|
|
}
|
|
|
|
export function parseTrace(text) {
|
|
const body = extractFenced(text || "");
|
|
const lines = body.split("\n").map((l) => l.replace(/\s+$/, "")).filter((l) => l.trim().length);
|
|
const clusters = [];
|
|
let summary = null;
|
|
const warnings = [];
|
|
for (const line of lines) {
|
|
if (line.startsWith("SUMMARY:")) {
|
|
const s = parseSummaryLine(line);
|
|
if (s) summary = s;
|
|
else warnings.push(`malformed summary line: ${line}`);
|
|
continue;
|
|
}
|
|
const c = parseClusterLine(line);
|
|
if (c) clusters.push(c);
|
|
else warnings.push(`malformed cluster line: ${line}`);
|
|
}
|
|
// Synthesize a summary from clusters when none provided AND clusters parsed.
|
|
if (!summary && clusters.length) {
|
|
const reasons = { threshold: 0, contradiction: 0, window: 0, other: 0 };
|
|
let emitted = 0;
|
|
for (const c of clusters) {
|
|
if (c.decision.startsWith("proposal_emitted")) emitted++;
|
|
else {
|
|
const r = (c.decision.match(/^skipped:(\S+)/) || [])[1] || "other";
|
|
reasons[r in reasons ? r : "other"]++;
|
|
}
|
|
}
|
|
summary = {
|
|
considered: clusters.length,
|
|
emitted,
|
|
skipped: clusters.length - emitted,
|
|
reasons,
|
|
};
|
|
}
|
|
return { clusters, summary, warnings };
|
|
}
|
|
|
|
function countByDecision(clusters) {
|
|
const counts = {};
|
|
for (const c of clusters) {
|
|
const key = c.decision.startsWith("proposal_emitted")
|
|
? "proposal_emitted"
|
|
: (c.decision.match(/^skipped:(\S+)/)?.[1] || "other");
|
|
counts[key] = (counts[key] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
export function formatSummary(parsed) {
|
|
const s = parsed.summary;
|
|
if (!s) return "no trace data";
|
|
const reasonStr = Object.entries(s.reasons)
|
|
.map(([k, v]) => `${k}:${v}`)
|
|
.join(", ");
|
|
const counts = countByDecision(parsed.clusters);
|
|
const breakdown = Object.entries(counts)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join(" ");
|
|
const head = `considered=${s.considered} emitted=${s.emitted} skipped=${s.skipped} reasons={${reasonStr}}`;
|
|
return breakdown ? `${head}\nclusters by decision: ${breakdown}` : head;
|
|
}
|
|
|
|
export function formatFull(parsed) {
|
|
const lines = [];
|
|
for (const c of parsed.clusters) {
|
|
const gatesStr = Object.entries(c.gates).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
lines.push(`${c.id} | signal=${c.signal} count=${c.count} sessions=${c.sessions} | gates: ${gatesStr} | decision: ${c.decision}`);
|
|
}
|
|
if (parsed.summary) {
|
|
const s = parsed.summary;
|
|
const reasonStr = Object.entries(s.reasons).map(([k, v]) => `${k}:${v}`).join(", ");
|
|
lines.push(`SUMMARY: considered=${s.considered} emitted=${s.emitted} skipped=${s.skipped} regressions=${s.regressions ?? 0} reasons={${reasonStr}}`);
|
|
}
|
|
// Histogram footer: only count actual rejection reasons from clusters.
|
|
const hist = {};
|
|
for (const c of parsed.clusters) {
|
|
const m = c.decision.match(/^skipped:(\S+)/);
|
|
if (m) hist[m[1]] = (hist[m[1]] || 0) + 1;
|
|
}
|
|
const histStr = Object.entries(hist).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
lines.push(`Rejection reasons: ${histStr || "none"}`);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function formatJson(parsed) {
|
|
return JSON.stringify({
|
|
clusters: parsed.clusters,
|
|
summary: parsed.summary,
|
|
}, null, 2);
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const claudeHome = args.home || join(homedir(), ".claude");
|
|
const defaultInput = join(claudeHome, "adam", "last-trace.txt");
|
|
const inputPath = args.input || defaultInput;
|
|
|
|
let raw;
|
|
try {
|
|
if (!existsSync(inputPath)) {
|
|
process.stderr.write(`adam-explain: input not found: ${inputPath}\n`);
|
|
process.exit(1);
|
|
}
|
|
raw = readFileSync(inputPath, "utf8");
|
|
} catch (e) {
|
|
process.stderr.write(`adam-explain: read failed: ${e.message}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const parsed = parseTrace(raw);
|
|
if (!parsed.clusters.length && !parsed.summary) {
|
|
process.stderr.write(`adam-explain: no parseable trace lines in ${inputPath}\n`);
|
|
process.exit(1);
|
|
}
|
|
for (const w of parsed.warnings) {
|
|
process.stderr.write(`adam-explain: warn: ${w}\n`);
|
|
}
|
|
|
|
const mode = args.mode || "summary";
|
|
let out;
|
|
if (mode === "full") out = formatFull(parsed);
|
|
else if (mode === "json") out = formatJson(parsed);
|
|
else out = formatSummary(parsed);
|
|
process.stdout.write(out + "\n");
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
try { main(); } catch (e) {
|
|
process.stderr.write(`adam-explain error: ${e.message}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|