mirror of
https://github.com/lukaszraczylo/claude-adam.git
synced 2026-06-10 23:29:03 +00:00
4b36d6c09e
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.
222 lines
8.6 KiB
JavaScript
Executable File
222 lines
8.6 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// adam-cooldown.mjs — per-(skill, proposal_fingerprint) cooldown / blacklist
|
|
// gate. Replaces the previous coarse per-skill cooldown.
|
|
//
|
|
// CLI:
|
|
// adam-cooldown.mjs --skill <slug> --fingerprint <hash> [--home <path>]
|
|
// adam-cooldown.mjs --compute --skill <slug> --cluster <id> [--diff-file <path>]
|
|
// → prints {"fingerprint":"<djb2_base36>"}; diff body read from --diff-file
|
|
// or stdin. This is how proposal_fingerprint is populated (the analyst
|
|
// runs it via Bash after drafting a proposal).
|
|
//
|
|
// Output (gate mode): JSON one-liner with shape
|
|
// { "status": "cool"|"cooldown"|"blacklisted",
|
|
// "reason": "<human-readable reason>",
|
|
// "blocked_by": { "file": "<basename>", "days_remaining": <int> } | null }
|
|
//
|
|
// Rules:
|
|
// - applied/*.md with target_skill == <skill> AND
|
|
// (proposal_fingerprint == <fingerprint> OR missing/legacy)
|
|
// within 7 days of `applied_at` → "cooldown"
|
|
// - rejected/*.md with same skill match AND
|
|
// auto_apply_blacklist: true within 30 days of applied_at → "blacklisted"
|
|
// - else "cool"
|
|
//
|
|
// Backward compat: proposals without `proposal_fingerprint` field are treated
|
|
// as fingerprint == "legacy" so historical applied/rejected records still
|
|
// produce coarse-grained gating until they age out of their windows.
|
|
|
|
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { parseFrontmatter } from "./adam-utils.mjs";
|
|
|
|
export const COOLDOWN_DAYS = 7;
|
|
export const BLACKLIST_DAYS = 30;
|
|
const DAY_MS = 86400000;
|
|
export const LEGACY_FINGERPRINT = "legacy";
|
|
|
|
function parseArgs(argv) {
|
|
const args = { home: null, skill: null, fingerprint: null, compute: false, cluster: null, diffFile: null, help: false };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === "--home" && i + 1 < argv.length) args.home = argv[++i];
|
|
else if (a === "--skill" && i + 1 < argv.length) args.skill = argv[++i];
|
|
else if (a === "--fingerprint" && i + 1 < argv.length) args.fingerprint = argv[++i];
|
|
else if (a === "--cluster" && i + 1 < argv.length) args.cluster = argv[++i];
|
|
else if (a === "--diff-file" && i + 1 < argv.length) args.diffFile = argv[++i];
|
|
else if (a === "--compute") args.compute = true;
|
|
else if (a === "--help" || a === "-h") args.help = true;
|
|
}
|
|
return args;
|
|
}
|
|
|
|
// Pull applied_at as epoch ms. Accept ms-number, ISO string, or fall back to
|
|
// the file's mtime so we never crash on legacy records.
|
|
function frontmatterTimestampMs(fm, filePath) {
|
|
const raw = fm.applied_at;
|
|
if (raw) {
|
|
const asNum = Number(raw);
|
|
if (!Number.isNaN(asNum) && asNum > 0) return asNum;
|
|
const asIso = Date.parse(raw);
|
|
if (!Number.isNaN(asIso)) return asIso;
|
|
}
|
|
try { return statSync(filePath).mtimeMs; } catch { return 0; }
|
|
}
|
|
|
|
function fingerprintMatches(recordFp, queryFp) {
|
|
// Missing / empty field on legacy records → coarse fallback: any fingerprint
|
|
// query matches (so the historical applied/rejected record still gates).
|
|
if (!recordFp || recordFp === LEGACY_FINGERPRINT) return true;
|
|
return recordFp === queryFp;
|
|
}
|
|
// Resolve a frontmatter record to its skill slug. Modern records use
|
|
// `target_skill`; legacy v0.2.x records used `target` with a full path
|
|
// (e.g. `skills/foo/SKILL.md`). Falls back through both before giving up.
|
|
function resolveSkill(fm) {
|
|
if (fm.target_skill) return fm.target_skill;
|
|
if (fm.skill) return fm.skill;
|
|
if (fm.target) {
|
|
const base = fm.target.split("/").filter(Boolean);
|
|
// skills/<slug>/SKILL.md → <slug>; <slug>.md → <slug>; else last segment
|
|
if (base.length >= 2 && base[base.length - 1] === "SKILL.md") {
|
|
return base[base.length - 2];
|
|
}
|
|
return base[base.length - 1].replace(/\.md$/, "");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function scanDir(dir, predicate) {
|
|
if (!existsSync(dir)) return [];
|
|
let names;
|
|
try { names = readdirSync(dir); } catch { return []; }
|
|
const out = [];
|
|
for (const name of names) {
|
|
if (!name.endsWith(".md")) continue;
|
|
const p = join(dir, name);
|
|
let content;
|
|
try { content = readFileSync(p, "utf8"); } catch { continue; }
|
|
const fm = parseFrontmatter(content);
|
|
const hit = predicate(fm, p, name);
|
|
if (hit) out.push(hit);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function checkCooldown(home, skill, fingerprint, now = Date.now()) {
|
|
const adamRoot = join(home, "adam");
|
|
const appliedDir = join(adamRoot, "applied");
|
|
const rejectedDir = join(adamRoot, "rejected");
|
|
|
|
// Applied → cooldown
|
|
const appliedHits = scanDir(appliedDir, (fm, p, name) => {
|
|
if (resolveSkill(fm) !== skill) return null;
|
|
if (!fingerprintMatches(fm.proposal_fingerprint, fingerprint)) return null;
|
|
const tsMs = frontmatterTimestampMs(fm, p);
|
|
if (!tsMs) return null;
|
|
const ageDays = (now - tsMs) / DAY_MS;
|
|
if (ageDays > COOLDOWN_DAYS) return null;
|
|
return { name, daysRemaining: Math.max(0, Math.ceil(COOLDOWN_DAYS - ageDays)) };
|
|
});
|
|
|
|
// Rejected → blacklisted (requires auto_apply_blacklist: true)
|
|
const blacklistHits = scanDir(rejectedDir, (fm, p, name) => {
|
|
if (resolveSkill(fm) !== skill) return null;
|
|
if (!fingerprintMatches(fm.proposal_fingerprint, fingerprint)) return null;
|
|
const flag = (fm.auto_apply_blacklist || "").toLowerCase();
|
|
if (flag !== "true") return null;
|
|
const tsMs = frontmatterTimestampMs(fm, p);
|
|
if (!tsMs) return null;
|
|
const ageDays = (now - tsMs) / DAY_MS;
|
|
if (ageDays > BLACKLIST_DAYS) return null;
|
|
return { name, daysRemaining: Math.max(0, Math.ceil(BLACKLIST_DAYS - ageDays)) };
|
|
});
|
|
|
|
if (blacklistHits.length) {
|
|
const h = blacklistHits[0];
|
|
return {
|
|
status: "blacklisted",
|
|
reason: `auto_apply_blacklist active on rejected/${h.name}`,
|
|
blocked_by: { file: h.name, days_remaining: h.daysRemaining },
|
|
};
|
|
}
|
|
if (appliedHits.length) {
|
|
const h = appliedHits[0];
|
|
return {
|
|
status: "cooldown",
|
|
reason: `applied within ${COOLDOWN_DAYS}d (applied/${h.name})`,
|
|
blocked_by: { file: h.name, days_remaining: h.daysRemaining },
|
|
};
|
|
}
|
|
return { status: "cool", reason: "no recent applied/rejected match", blocked_by: null };
|
|
}
|
|
|
|
// djb2 hash returned as base36 — deterministic, no deps.
|
|
function djb2(s) {
|
|
let h = 5381;
|
|
for (let i = 0; i < s.length; i++) {
|
|
h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; // xor variant, force u32
|
|
}
|
|
return h.toString(36);
|
|
}
|
|
|
|
export function computeProposalFingerprint(proposal) {
|
|
if (!proposal || typeof proposal !== "object") return LEGACY_FINGERPRINT;
|
|
const skill = proposal.skill_slug || proposal.target_skill || proposal.skill || "";
|
|
const cluster = proposal.signal_cluster_id || proposal.cluster_id || "";
|
|
// normalized_diff_body: whitespace (incl. newlines) collapsed to single
|
|
// spaces, then trimmed. Matches agents/adam.md §"Per-(skill, fingerprint)
|
|
// cooldown". (No trailing-newline strip needed — \s+ already absorbed them.)
|
|
const diff = String(proposal.diff_body || proposal.proposed_change || "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
return djb2(`${skill}\n${cluster}\n${diff}`);
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
process.stdout.write(
|
|
"usage: adam-cooldown.mjs --skill <slug> --fingerprint <hash> [--home <path>]\n" +
|
|
" adam-cooldown.mjs --compute --skill <slug> --cluster <id> [--diff-file <path>]\n"
|
|
);
|
|
process.exit(0);
|
|
}
|
|
// --compute: deterministically derive a proposal_fingerprint. The analyst
|
|
// invokes this (it has Bash) after drafting a proposal, then writes the
|
|
// result into proposal frontmatter so the cooldown gate keys on it.
|
|
if (args.compute) {
|
|
let diff = "";
|
|
if (args.diffFile) {
|
|
try { diff = readFileSync(args.diffFile, "utf8"); } catch { /* empty → still deterministic */ }
|
|
} else {
|
|
try { diff = readFileSync(0, "utf8"); } catch { /* no stdin */ }
|
|
}
|
|
const fp = computeProposalFingerprint({
|
|
skill_slug: args.skill || "",
|
|
signal_cluster_id: args.cluster || "",
|
|
diff_body: diff,
|
|
});
|
|
process.stdout.write(JSON.stringify({ fingerprint: fp }) + "\n");
|
|
process.exit(0);
|
|
}
|
|
if (!args.skill || !args.fingerprint) {
|
|
process.stderr.write("adam-cooldown: --skill and --fingerprint required\n");
|
|
process.exit(1);
|
|
}
|
|
const home = args.home || join(homedir(), ".claude");
|
|
try {
|
|
const result = checkCooldown(home, args.skill, args.fingerprint);
|
|
process.stdout.write(JSON.stringify(result) + "\n");
|
|
process.exit(0);
|
|
} catch (e) {
|
|
process.stderr.write(`adam-cooldown error: ${e.message}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
main();
|
|
}
|