mirror of
https://github.com/lukaszraczylo/claude-adam.git
synced 2026-06-10 23:29:03 +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
+190
@@ -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();
|
||||
}
|
||||
Executable
+91
@@ -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();
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Executable
+191
@@ -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();
|
||||
}
|
||||
Executable
+238
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+97
@@ -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();
|
||||
}
|
||||
Executable
+169
@@ -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();
|
||||
}
|
||||
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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+174
@@ -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
Reference in New Issue
Block a user