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:
2026-05-13 01:02:33 +01:00
parent 7ddda26bb4
commit 012c40b9ab
18 changed files with 3064 additions and 81 deletions
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env node
// adam-ab-measure.mjs — A/B effectiveness measurement on auto-applied edits.
//
// Reads ~/.claude/adam/ab-tracking.jsonl (one line per auto-apply event,
// written by adam-self-improvement/SKILL.md), then for each entry old enough
// (>= --min-age-days; default 7) compares signal counts in the 7-day window
// BEFORE applied_at against the 7-day window AFTER applied_at across the
// full journal corpus (active + rotated). Surfaces regressions so /reflect
// can flag proposals that made things worse.
//
// CLI:
// adam-ab-measure.mjs [--home <path>] [--format json|table] [--min-age-days N]
//
// Output (default `table`): aligned columns sorted regressed-first.
// Output (`json`): array of deltas.
// Empty / missing tracking file → empty output, exit 0.
// Exit 1 only on I/O failure.
import { join } from "node:path";
import { homedir } from "node:os";
import { readJsonlSafe, listJsonlFiles } from "./adam-utils.mjs";
const DAY_MS = 86400000;
export const DEFAULT_PRE_WINDOW_DAYS = 7;
export const DEFAULT_MIN_AGE_DAYS = 7;
const REGRESSED_PCT = 25;
const IMPROVED_PCT = -25;
function parseArgs(argv) {
const args = { home: null, format: "table", minAgeDays: DEFAULT_MIN_AGE_DAYS, 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 === "--format" && i + 1 < argv.length) args.format = argv[++i];
else if (a === "--min-age-days" && i + 1 < argv.length) {
const n = Number(argv[++i]);
if (!Number.isNaN(n) && n >= 0) args.minAgeDays = n;
}
else if (a === "--help" || a === "-h") args.help = true;
}
return args;
}
function loadJournalAll(claudeHome) {
const adamRoot = join(claudeHome, "adam");
const sources = [join(adamRoot, "journal.jsonl"), ...listJsonlFiles(join(adamRoot, "journal"))];
const all = [];
for (const p of sources) for (const e of readJsonlSafe(p)) all.push(e);
return all;
}
function tsMs(e) {
if (!e || typeof e.ts !== "string") return NaN;
return Date.parse(e.ts);
}
// computeDeltas: pure function — entries = ab-tracking objects, journal = list
// of journal entries (any source). opts.now is unix ms; opts.minAgeDays is the
// floor for non-pending.
export function computeDeltas(entries, journal, opts = {}) {
const now = typeof opts.now === "number" ? opts.now : Date.now();
const minAgeDays = typeof opts.minAgeDays === "number" ? opts.minAgeDays : DEFAULT_MIN_AGE_DAYS;
const out = [];
for (const e of entries || []) {
if (!e || typeof e !== "object") continue;
const appliedAt = Number(e.applied_at);
if (!appliedAt || Number.isNaN(appliedAt)) continue;
const ageDays = (now - appliedAt) / DAY_MS;
// Symmetric window: same span applied to pre AND post sides. JSONL schema
// field stays `pre_window_days` for backward compat with existing
// ab-tracking.jsonl entries — local name reflects symmetry.
const windowDays = typeof e.pre_window_days === "number" ? e.pre_window_days : DEFAULT_PRE_WINDOW_DAYS;
const signals = Array.isArray(e.originating_signals)
? e.originating_signals.map((s) => (s && typeof s === "object" ? s.type : null)).filter(Boolean)
: [];
const sigSet = new Set(signals);
const base = {
proposal_id: e.proposal_id || "",
proposal_type: e.proposal_type || "",
target_skill: e.target_skill || "",
applied_at: appliedAt,
applied_at_iso: new Date(appliedAt).toISOString(),
signal_types: [...sigSet],
};
if (ageDays < minAgeDays) {
out.push({ ...base, pre_count: null, post_count: null, delta_pct: null, status: "pending" });
continue;
}
const preStart = appliedAt - windowDays * DAY_MS;
const postEnd = appliedAt + windowDays * DAY_MS;
let preCount = 0;
let postCount = 0;
for (const je of journal || []) {
if (!je || typeof je !== "object") continue;
if (!sigSet.has(je.type)) continue;
const t = tsMs(je);
if (Number.isNaN(t)) continue;
if (t >= preStart && t < appliedAt) preCount++;
else if (t >= appliedAt && t < postEnd) postCount++;
}
let status;
let deltaPct;
if (preCount === 0) {
status = "no_baseline";
deltaPct = null;
} else {
deltaPct = ((postCount - preCount) / preCount) * 100;
// Round to 2 dp for stable comparison + presentation.
deltaPct = Math.round(deltaPct * 100) / 100;
if (deltaPct <= IMPROVED_PCT) status = "improved";
else if (deltaPct >= REGRESSED_PCT) status = "regressed";
else status = "neutral";
}
out.push({ ...base, pre_count: preCount, post_count: postCount, delta_pct: deltaPct, status });
}
return out;
}
const STATUS_ORDER = { regressed: 0, neutral: 1, no_baseline: 2, improved: 3, pending: 4 };
function sortForTable(deltas) {
return [...deltas].sort((a, b) => {
const sa = STATUS_ORDER[a.status] ?? 99;
const sb = STATUS_ORDER[b.status] ?? 99;
if (sa !== sb) return sa - sb;
return a.applied_at - b.applied_at;
});
}
function padRight(s, n) { s = String(s); return s.length >= n ? s : s + " ".repeat(n - s.length); }
export function formatTable(deltas) {
if (!deltas || !deltas.length) return "";
const rows = sortForTable(deltas);
const headers = ["proposal_id", "target", "type", "applied_at(iso)", "pre/post", "delta%", "status"];
const data = rows.map((d) => [
d.proposal_id || "-",
d.target_skill || "-",
d.proposal_type || "-",
d.applied_at_iso || "-",
d.pre_count == null ? "-" : `${d.pre_count}/${d.post_count}`,
d.delta_pct == null ? "-" : `${d.delta_pct.toFixed(2)}`,
d.status || "-",
]);
const widths = headers.map((h, i) => Math.max(h.length, ...data.map((r) => String(r[i]).length)));
const lines = [];
lines.push(headers.map((h, i) => padRight(h, widths[i])).join(" | "));
lines.push(widths.map((w) => "-".repeat(w)).join("-+-"));
for (const r of data) lines.push(r.map((c, i) => padRight(c, widths[i])).join(" | "));
return lines.join("\n");
}
export function formatJson(deltas) {
return JSON.stringify(deltas || []);
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write("usage: adam-ab-measure.mjs [--home <path>] [--format json|table] [--min-age-days N]\n");
process.exit(0);
}
const claudeHome = args.home || join(homedir(), ".claude");
const trackingPath = join(claudeHome, "adam", "ab-tracking.jsonl");
try {
const entries = readJsonlSafe(trackingPath);
if (!entries.length) {
if (args.format === "json") process.stdout.write("[]\n");
// table mode prints nothing on empty input — exit 0.
process.exit(0);
}
const journal = loadJournalAll(claudeHome);
const deltas = computeDeltas(entries, journal, { minAgeDays: args.minAgeDays });
const out = args.format === "json" ? formatJson(deltas) : formatTable(deltas);
if (out) process.stdout.write(out + "\n");
process.exit(0);
} catch (e) {
process.stderr.write(`adam-ab-measure error: ${e.message}\n`);
process.exit(1);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
// adam-apply-reinforcement.mjs — apply-path for `reinforcement` proposals.
//
// Reads a proposal markdown file, validates the apply gate
// (confidence >= 4 AND blast_radius == "low" AND type == "reinforcement"),
// and on success appends one JSON line to ~/.claude/adam/reinforcements.jsonl
// of shape `{ts, skill_slug, count, source_session}`.
//
// CLI: adam-apply-reinforcement.mjs <proposal-path> [--home <path>]
// Output: JSON one-liner on stdout: {"status":"applied"|"gated", "reason":"..."}
// Exit: 0 on apply, 0 on gated, 1 on I/O or parse error.
//
// SKILL.md invokes this in the auto-apply path when the proposal type is
// `reinforcement`. No code/memory/skill modifications.
import { readFileSync, appendFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { parseFrontmatter } from "./adam-utils.mjs";
// Re-exported for backward compat — callers historically imported it from here.
export { parseFrontmatter };
function parseArgs(argv) {
const args = { home: null, path: 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 === "--help" || a === "-h") args.help = true;
else if (!args.path && !a.startsWith("--")) args.path = a;
}
return args;
}
export function checkGate(fm) {
if ((fm.type || "") !== "reinforcement") {
return { ok: false, reason: `type != reinforcement (got: ${fm.type || "<none>"})` };
}
const conf = Number(fm.confidence);
if (Number.isNaN(conf) || conf < 4) {
return { ok: false, reason: `confidence < 4 (got: ${fm.confidence ?? "<none>"})` };
}
if ((fm.blast_radius || "").toLowerCase() !== "low") {
return { ok: false, reason: `blast_radius != low (got: ${fm.blast_radius || "<none>"})` };
}
if (!fm.skill_slug) {
return { ok: false, reason: "skill_slug missing in frontmatter" };
}
return { ok: true };
}
export function buildEntry(fm, now = Date.now()) {
return {
ts: now,
skill_slug: String(fm.skill_slug),
count: Number(fm.count) || 0,
source_session: fm.source_session || "",
};
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.path) {
process.stdout.write("usage: adam-apply-reinforcement.mjs <proposal-path> [--home <path>]\n");
process.exit(args.help ? 0 : 1);
}
const claudeHome = args.home || join(homedir(), ".claude");
const outPath = join(claudeHome, "adam", "reinforcements.jsonl");
try {
const content = readFileSync(args.path, "utf8");
const fm = parseFrontmatter(content);
const gate = checkGate(fm);
if (!gate.ok) {
process.stdout.write(JSON.stringify({ status: "gated", reason: gate.reason }) + "\n");
process.exit(0);
}
const entry = buildEntry(fm);
const dir = dirname(outPath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
appendFileSync(outPath, JSON.stringify(entry) + "\n");
process.stdout.write(JSON.stringify({ status: "applied", path: outPath }) + "\n");
process.exit(0);
} catch (e) {
process.stderr.write(`adam-apply-reinforcement error: ${e.message}\n`);
process.exit(1);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+1 -38
View File
@@ -8,49 +8,12 @@
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { parseFrontmatter } from "./adam-utils.mjs";
const ROOT = join(homedir(), ".claude", "adam");
const JOURNAL = join(ROOT, "journal.jsonl");
const JOURNAL_DIR = join(ROOT, "journal");
function parseFrontmatter(content) {
const m = content.match(/^---\n([\s\S]*?)\n---/);
if (!m) return {};
const fm = {};
const lines = m[1].split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i];
const idx = line.indexOf(":");
if (idx === -1) { i++; continue; }
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key === "source_entries") {
const arr = [];
if (value.startsWith("[") && value.endsWith("]")) {
const inner = value.slice(1, -1)
.split(",")
.map(s => s.trim().replace(/^['"]|['"]$/g, ""));
arr.push(...inner.filter(Boolean));
fm[key] = arr;
i++;
continue;
}
i++;
while (i < lines.length && /^\s*-\s+/.test(lines[i])) {
const item = lines[i].replace(/^\s*-\s+/, "").trim().replace(/^['"]|['"]$/g, "");
if (item) arr.push(item);
i++;
}
fm[key] = arr;
continue;
}
fm[key] = value;
i++;
}
return fm;
}
function main() {
const proposalPath = process.argv[2];
if (!proposalPath) {
+191
View File
@@ -0,0 +1,191 @@
#!/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>]
//
// Output: 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, 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 === "--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 || "";
const diff = String(proposal.diff_body || proposal.proposed_change || "")
.replace(/\s+/g, " ")
.replace(/\n+$/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");
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();
}
+238
View File
@@ -0,0 +1,238 @@
#!/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);
}
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
// adam-nudge-eligibility.mjs — checks whether the current session has accrued
// enough `dead_end` entries to warrant emitting a cross-session nudge.
//
// CLI: adam-nudge-eligibility.mjs [--home <path>] [--session <id>]
// --home defaults to $HOME/.claude
// --session if absent, reads state.json `session_id` field
//
// Output (stdout): JSON one-liner
// eligible: {"eligible": true, "session_id": "...", "dead_end_count": N, "last_ts": "..."}
// not: {"eligible": false, "session_id": "...", "dead_end_count": N, "last_ts": "..."|null}
// Exit codes:
// 0 — read succeeded (eligible OR not)
// 1 — read failure / unable to resolve session
//
// Threshold: ≥3 dead_end entries within a single session_id across the active
// journal + all rotated journal/*.jsonl files. Threshold matches the
// "dead-end checkpoint" feedback rule.
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { readJsonlSafe, listJsonlFiles } from "./adam-utils.mjs";
export const DEAD_END_THRESHOLD = 3;
function parseArgs(argv) {
const args = { home: null, session: 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 === "--session" && i + 1 < argv.length) args.session = argv[++i];
else if (a === "--help" || a === "-h") args.help = true;
}
return args;
}
function resolveSession(home, fallback) {
if (fallback) return fallback;
const statePath = join(home, "adam", "state.json");
if (!existsSync(statePath)) return null;
try {
const st = JSON.parse(readFileSync(statePath, "utf8"));
return st && typeof st.session_id === "string" ? st.session_id : null;
} catch { return null; }
}
export function checkEligibility(home, sessionId) {
const adamRoot = join(home, "adam");
const sources = [
join(adamRoot, "journal.jsonl"),
...listJsonlFiles(join(adamRoot, "journal")),
];
let count = 0;
let lastTs = null;
for (const p of sources) {
for (const e of readJsonlSafe(p)) {
if (!e || e.type !== "dead_end") continue;
if (e.session !== sessionId && e.session_id !== sessionId) continue;
count++;
const ts = typeof e.ts === "string" ? e.ts : null;
if (ts && (!lastTs || ts > lastTs)) lastTs = ts;
}
}
return {
eligible: count >= DEAD_END_THRESHOLD,
session_id: sessionId,
dead_end_count: count,
last_ts: lastTs,
};
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write("usage: adam-nudge-eligibility.mjs [--home <path>] [--session <id>]\n");
process.exit(0);
}
const home = args.home || join(homedir(), ".claude");
const sessionId = resolveSession(home, args.session);
if (!sessionId) {
process.stderr.write("adam-nudge-eligibility: no session_id (pass --session or seed state.json)\n");
process.exit(1);
}
try {
const result = checkEligibility(home, sessionId);
process.stdout.write(JSON.stringify(result) + "\n");
process.exit(0);
} catch (e) {
process.stderr.write(`adam-nudge-eligibility error: ${e.message}\n`);
process.exit(1);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env node
// adam-score.mjs — computes per-session urgency dampeners + reinforcement
// candidates from `task_completed` signals.
//
// Effects:
// 1. Dampener:
// task_completed_count >= 3 → 0.5
// task_completed_count >= 1 → 0.75
// else → 1.0
// Analyst multiplies a cluster's urgency by the dampener of the session
// it originated from.
// 2. Reinforcement candidates: per skill, count of clean task_completed
// events citing it (via `active_skills` payload). Skills with count >= 3
// are surfaced as reinforcement proposal candidates (low blast,
// confidence ≥ 4 required for auto-apply, same gate as memory).
//
// CLI:
// adam-score.mjs [--home <path>] [--input <jsonl-path>]
//
// --input defaults to: stdout of adam-window.mjs (preferred) — if missing,
// falls back to the raw active journal.
//
// Output: JSON object
// {
// "sessions": [
// {"session_id": "...", "negative_count": N, "task_completed_count": M, "dampener": 1.0}
// ],
// "reinforcement_candidates": [
// {"skill_slug": "tdd-loop", "count": 3, "recent_ts": "..."}
// ]
// }
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { readJsonlSafe, listJsonlFiles } from "./adam-utils.mjs";
export const NEGATIVE_SIGNAL_TYPES = new Set([
"correction",
"tool_error_loop",
"dead_end",
"edit_churn",
"retry_loop",
"build_loop",
"weak_agent",
]);
export const REINFORCEMENT_THRESHOLD = 3;
function parseArgs(argv) {
const args = { home: null, input: 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 === "--input" && i + 1 < argv.length) args.input = argv[++i];
else if (a === "--help" || a === "-h") args.help = true;
}
return args;
}
function readAllStdin() {
try { return readFileSync(0, "utf8"); } catch { return ""; }
}
function entriesFromText(text) {
const out = [];
for (const line of (text || "").split("\n")) {
if (!line) continue;
try { out.push(JSON.parse(line)); } catch { /* skip */ }
}
return out;
}
function computeDampener(taskCompletedCount) {
if (taskCompletedCount >= 3) return 0.5;
if (taskCompletedCount >= 1) return 0.75;
return 1.0;
}
export function computeSessionScores(entries) {
const bySession = new Map();
for (const e of entries || []) {
if (!e || typeof e !== "object") continue;
const sid = e.session || e.session_id || "";
if (!sid) continue;
if (!bySession.has(sid)) {
bySession.set(sid, { session_id: sid, negative_count: 0, task_completed_count: 0 });
}
const slot = bySession.get(sid);
if (e.type === "task_completed") slot.task_completed_count++;
else if (NEGATIVE_SIGNAL_TYPES.has(e.type)) slot.negative_count++;
}
const out = [];
for (const slot of bySession.values()) {
out.push({
...slot,
dampener: computeDampener(slot.task_completed_count),
});
}
// Stable ordering by session_id for deterministic output.
out.sort((a, b) => (a.session_id < b.session_id ? -1 : a.session_id > b.session_id ? 1 : 0));
return out;
}
export function computeReinforcementCandidates(entries) {
const counts = new Map();
for (const e of entries || []) {
if (!e || e.type !== "task_completed") continue;
const skills = Array.isArray(e.active_skills) ? e.active_skills : [];
for (const slug of skills) {
if (!slug || typeof slug !== "string") continue;
if (!counts.has(slug)) counts.set(slug, { count: 0, recent_ts: null });
const slot = counts.get(slug);
slot.count++;
const ts = typeof e.ts === "string" ? e.ts : null;
if (ts && (!slot.recent_ts || ts > slot.recent_ts)) slot.recent_ts = ts;
}
}
const out = [];
for (const [slug, { count, recent_ts }] of counts.entries()) {
if (count < REINFORCEMENT_THRESHOLD) continue;
out.push({ skill_slug: slug, count, recent_ts });
}
out.sort((a, b) => b.count - a.count || (a.skill_slug < b.skill_slug ? -1 : 1));
return out;
}
function gatherInputEntries(args) {
if (args.input) return readJsonlSafe(args.input);
// Honor piped stdin only when it is non-empty AND not a TTY.
if (!process.stdin.isTTY) {
const piped = readAllStdin();
if (piped && piped.trim()) return entriesFromText(piped);
}
// Default fallback: active journal + rotated files.
const home = args.home || join(homedir(), ".claude");
const adamRoot = join(home, "adam");
const sources = [
join(adamRoot, "journal.jsonl"),
...listJsonlFiles(join(adamRoot, "journal")),
];
const all = [];
for (const p of sources) {
for (const e of readJsonlSafe(p)) all.push(e);
}
return all;
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write("usage: adam-score.mjs [--home <path>] [--input <jsonl-path>]\n");
process.exit(0);
}
try {
const entries = gatherInputEntries(args);
const sessions = computeSessionScores(entries);
const reinforcement_candidates = computeReinforcementCandidates(entries);
process.stdout.write(JSON.stringify({ sessions, reinforcement_candidates }) + "\n");
process.exit(0);
} catch (e) {
process.stderr.write(`adam-score error: ${e.message}\n`);
process.exit(1);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+251
View File
@@ -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());
}
+92
View File
@@ -0,0 +1,92 @@
// adam-utils.mjs — shared helpers used across adam-* scripts.
//
// Pure library: no shebang, not a CLI. Imported by adam-window.mjs,
// adam-score.mjs, adam-ab-measure.mjs, adam-nudge-eligibility.mjs (jsonl
// helpers) and adam-apply-reinforcement.mjs, adam-archive.mjs,
// adam-cooldown.mjs (parseFrontmatter).
//
// All helpers swallow read/parse failures by design — callers expect to keep
// going on a corrupt line/file rather than abort the whole pipeline.
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
// listJsonlFiles: list *.jsonl files in `dir`. Missing dir or read failure
// returns []. Filenames are joined with `dir` so callers can read directly.
export function listJsonlFiles(dir) {
if (!existsSync(dir)) return [];
try {
return readdirSync(dir)
.filter((n) => n.endsWith(".jsonl"))
.map((n) => join(dir, n));
} catch { return []; }
}
// readJsonlSafe: read a .jsonl file and return an array of parsed objects.
// Missing file, unreadable file, or any malformed line are silently skipped.
export function readJsonlSafe(path) {
if (!existsSync(path)) return [];
let buf;
try { buf = readFileSync(path, "utf8"); } catch { return []; }
const out = [];
for (const line of buf.split("\n")) {
if (!line) continue;
try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
}
return out;
}
// parseFrontmatter: parse a markdown YAML-ish frontmatter block into a flat
// object. Supports:
// - inline scalars key: value
// - inline arrays key: [a, b, c]
// - block-form arrays key:\n - a\n - b
// Quotes around scalar values are stripped. Comment-only lines (`# ...`) and
// keys with empty inline values that are NOT followed by a block array are
// skipped (preserves prior cooldown.mjs behavior). Missing frontmatter → {}.
export function parseFrontmatter(content) {
const m = content.match(/^---\n([\s\S]*?)\n---/);
if (!m) return {};
const out = {};
const lines = m[1].split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i];
const idx = line.indexOf(":");
if (idx === -1) { i++; continue; }
const key = line.slice(0, idx).trim();
if (!key || key.startsWith("#")) { i++; continue; }
const rawValue = line.slice(idx + 1).trim();
if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
const inner = rawValue.slice(1, -1)
.split(",")
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
.filter(Boolean);
out[key] = inner;
i++;
continue;
}
if (!rawValue) {
// Possible block-form array: look ahead for ` - item` lines.
const arr = [];
let j = i + 1;
while (j < lines.length && /^\s*-\s+/.test(lines[j])) {
const item = lines[j].replace(/^\s*-\s+/, "").trim().replace(/^['"]|['"]$/g, "");
if (item) arr.push(item);
j++;
}
if (arr.length) {
out[key] = arr;
i = j;
continue;
}
// Empty value, no block follow-up: skip (cooldown/apply-reinforcement
// expectation — empty scalars are noise).
i++;
continue;
}
out[key] = rawValue.replace(/^['"]|['"]$/g, "");
i++;
}
return out;
}
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env node
// adam-window.mjs — per-signal sliding-window filter over the ADAM journal.
//
// Reads all journal sources (active journal.jsonl + rotated journal/*.jsonl,
// including both new YYYY-Www.jsonl format and legacy YYYY-MM-DD-<ts>.jsonl
// size-rotated files), applies a per-signal-type age cutoff based on each
// entry's `ts` field, and emits the filtered JSONL stream to stdout.
//
// Exclusion: entries whose `ts` appears in any applied/*.md or rejected/*.md
// proposal frontmatter `source_entries` array are dropped (same semantics the
// adam agent previously enforced manually). Keeps actioned signals out of the
// next /reflect even if they're inside the analysis window.
//
// Usage: adam-window.mjs [--home <path>] default: $HOME/.claude
// Output: filtered JSONL on stdout. One-line summary on stderr.
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { readJsonlSafe, listJsonlFiles } from "./adam-utils.mjs";
// Per-signal sliding window in days. Source of truth — referenced by agents/adam.md.
export const SIGNAL_WINDOWS_DAYS = {
dead_end: 7,
correction: 30,
tool_error_loop: 30,
edit_churn: 14,
retry_loop: 14,
build_loop: 30,
weak_agent: 30,
subagent_dispatch_pattern: 30,
correction_free_streak: 60,
clean_recovery: 60,
task_completed: 60,
};
// Fallback window for unknown / future signal types.
export const DEFAULT_WINDOW_DAYS = 30;
const DAY_MS = 86400000;
function parseArgs(argv) {
const args = { home: null };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--home" && i + 1 < argv.length) {
args.home = argv[++i];
}
}
return args;
}
// Crude single-pass frontmatter source_entries extractor. Mirrors adam-archive.mjs
// parsing: handles both YAML block form and inline-array form. Only pulls the
// `source_entries` key — we don't need anything else for exclusion.
function extractSourceEntries(content) {
const m = content.match(/^---\n([\s\S]*?)\n---/);
if (!m) return [];
const lines = m[1].split("\n");
const out = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
if (key !== "source_entries") continue;
const value = line.slice(idx + 1).trim();
if (value.startsWith("[") && value.endsWith("]")) {
const inner = value.slice(1, -1).split(",")
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
.filter(Boolean);
out.push(...inner);
continue;
}
let j = i + 1;
while (j < lines.length && /^\s*-\s+/.test(lines[j])) {
const item = lines[j].replace(/^\s*-\s+/, "").trim().replace(/^['"]|['"]$/g, "");
if (item) out.push(item);
j++;
}
}
return out;
}
function buildExclusionSet(...dirs) {
const set = new Set();
for (const dir of dirs) {
if (!existsSync(dir)) continue;
let names;
try { names = readdirSync(dir); } catch { continue; }
for (const name of names) {
if (!name.endsWith(".md")) continue;
const p = join(dir, name);
try {
const content = readFileSync(p, "utf8");
for (const ts of extractSourceEntries(content)) set.add(ts);
} catch { /* skip */ }
}
}
return set;
}
function windowDaysFor(type) {
if (Object.prototype.hasOwnProperty.call(SIGNAL_WINDOWS_DAYS, type)) {
return SIGNAL_WINDOWS_DAYS[type];
}
return DEFAULT_WINDOW_DAYS;
}
export function filterEntries(entries, exclusionSet, now = new Date()) {
const nowMs = now.getTime();
const dropped = { stale: {}, excluded: 0, no_ts: 0 };
const kept = [];
for (const e of entries) {
if (!e || typeof e !== "object") continue;
if (!e.ts || typeof e.ts !== "string") {
dropped.no_ts++;
continue;
}
if (exclusionSet.has(e.ts)) {
dropped.excluded++;
continue;
}
const type = e.type || "unknown";
const days = windowDaysFor(type);
const tsMs = Date.parse(e.ts);
if (Number.isNaN(tsMs)) {
dropped.no_ts++;
continue;
}
if (nowMs - tsMs > days * DAY_MS) {
dropped.stale[type] = (dropped.stale[type] || 0) + 1;
continue;
}
kept.push(e);
}
return { kept, dropped };
}
function main() {
const args = parseArgs(process.argv.slice(2));
const claudeHome = args.home || join(homedir(), ".claude");
const adamRoot = join(claudeHome, "adam");
const activeJournal = join(adamRoot, "journal.jsonl");
const journalDir = join(adamRoot, "journal");
const appliedDir = join(adamRoot, "applied");
const rejectedDir = join(adamRoot, "rejected");
const sources = [activeJournal, ...listJsonlFiles(journalDir)];
const all = [];
for (const p of sources) {
for (const e of readJsonlSafe(p)) all.push(e);
}
const exclusion = buildExclusionSet(appliedDir, rejectedDir);
const { kept, dropped } = filterEntries(all, exclusion);
// Stable output: sort by ts ascending so downstream clustering sees chronological order.
kept.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
const out = kept.map((e) => JSON.stringify(e)).join("\n");
if (out) process.stdout.write(out + "\n");
const staleParts = Object.entries(dropped.stale).map(([t, n]) => `${t}=${n}`).join(",") || "none";
process.stderr.write(
`windowed: ${all.length} in, ${kept.length} out (stale: ${staleParts}; excluded: ${dropped.excluded}; no_ts: ${dropped.no_ts})\n`
);
}
if (import.meta.url === `file://${process.argv[1]}`) {
try { main(); } catch (e) {
process.stderr.write(`adam-window error: ${e.message}\n`);
process.exit(1);
}
}
Regular → Executable
View File