mirror of
https://github.com/lukaszraczylo/claude-adam.git
synced 2026-06-09 23:19:12 +00:00
6d8ff37cb2
Bug fixes (HIGH):
- adam-observe.mjs: errorFingerprint no longer false-positives when
toolResponse.is_error === false; ERROR_RE only used as fallback when
is_error is undefined.
- adam-observe.mjs: resetSessionLocal now clears tool_window so retry_loop
cannot fire on the first tool of a new session by matching prior session.
- adam-archive.mjs: ts dedup uses Map<ts, count> instead of Set<ts>; two
journal entries sharing a millisecond are no longer both archived when
only one is referenced in source_entries.
- adam-nudge.mjs: only counts proposal filenames matching
/^\d{4}-\d{2}-\d{3}-/ pattern; README/notes in proposals/ no longer bump.
- skills/adam-self-improvement/SKILL.md: contradiction_flag veto now applied
at apply time (carry-over from earlier review).
Test isolation:
- adam/tests/run-tests.sh: ALWAYS runs against an isolated $HOME under
mktemp -d. Previously truncated live ~/.claude/adam/journal.jsonl on
every run — destructive on production state.
Conciseness:
- agents/adam.md: -19 LOC (cuts: vestigial cursor sentence, duplicate
not-do bullets, blast-radius bullet collapse, Inputs paths delegate to
SKILL.md, win-cluster-vs-struggle-cluster commentary already enforced
by cluster-key separation, # Overlap section spec compressed).
- skills/adam-self-improvement/SKILL.md: -4 LOC (framing paragraph, dead
catch-all bullet for non-eligible types).
Auto-prune script DELETED:
- The cumulative-count primitive cannot distinguish "never used" from
"used before tracking began"; mtime gate is meaningless for installed
files. Auto-prune deferred to v0.4 with a per-key lastSeen schema.
Cross-platform:
- macOS (BSD coreutils) and Linux (Alpine, glibc + musl) verified.
- All scripts use portable forms (stat -f || stat -c, mktemp -d -t).
- README documents platform support explicitly.
DX overhaul:
- install.sh: hardened — supports `curl | bash` via auto-clone,
--version=vX.Y.Z pinning, --yes / --dry-run flags, jq-based
settings.json merge with diff prompt and backup, conservative file
copy that detects local mtime drift and writes <file>.adam-new
instead of clobbering, idempotent across re-runs.
- adam-uninstall.sh: NEW. Soft-archives ~/.claude/adam/ to .bak.<ts>/
by default; --purge to delete; --yes for non-interactive; jq-based
settings.json cleanup with diff prompt.
- README.md: curl one-liner install + version-pinned variant at top,
What's New section through v0.3.1, upgrade-safe data files callout,
uninstaller documentation, platform support note, expanded rubric
showing skill_edit gate.
Test count: 27 passed, 0 failed (was 27 — no regression).
155 lines
5.9 KiB
JavaScript
155 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Test driver for ~/.claude/hooks/adam-observe.mjs.
|
|
// Usage: node test-hook.mjs (runs all tests in this file).
|
|
// Spawns the hook with synthesized stdin in a tmp HOME, asserts journal contents.
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const HOOK = join(fileURLToPath(new URL("../../hooks/adam-observe.mjs", import.meta.url)));
|
|
|
|
export function newTmpHome() {
|
|
const home = mkdtempSync(join(tmpdir(), "adam-test-"));
|
|
mkdirSync(join(home, ".claude/adam"), { recursive: true });
|
|
return home;
|
|
}
|
|
|
|
export function feed(home, input) {
|
|
const r = spawnSync("node", [HOOK], {
|
|
input: JSON.stringify(input),
|
|
env: { ...process.env, HOME: home },
|
|
encoding: "utf8",
|
|
timeout: 5000,
|
|
});
|
|
if (r.status !== 0) throw new Error(`hook exit ${r.status}: ${r.stderr}`);
|
|
return r;
|
|
}
|
|
|
|
export function readJournal(home) {
|
|
const p = join(home, ".claude/adam/journal.jsonl");
|
|
if (!existsSync(p)) return [];
|
|
return readFileSync(p, "utf8")
|
|
.trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
}
|
|
|
|
export function assert(cond, msg) {
|
|
if (!cond) { console.error(`FAIL: ${msg}`); process.exit(1); }
|
|
console.log(`ok: ${msg}`);
|
|
}
|
|
|
|
export function cleanup(home) { try { rmSync(home, { recursive: true, force: true }); } catch {} }
|
|
|
|
// Tests below this line — added by subsequent tasks.
|
|
|
|
function testCorrectionFreeStreak() {
|
|
const home = newTmpHome();
|
|
try {
|
|
for (let i = 0; i < 5; i++) {
|
|
feed(home, {
|
|
hook_event_name: "UserPromptSubmit",
|
|
session_id: "s1",
|
|
cwd: "/x",
|
|
prompt: `please continue with the work item ${i}`,
|
|
});
|
|
}
|
|
const j = readJournal(home);
|
|
const streaks = j.filter(e => e.type === "correction_free_streak");
|
|
assert(streaks.length === 1, "exactly one correction_free_streak after 5 clean prompts");
|
|
assert(streaks[0].streak === 5, "streak field is 5");
|
|
assert(streaks[0].session === "s1", "session id captured");
|
|
} finally { cleanup(home); }
|
|
}
|
|
|
|
function testStreakResetsOnSessionChange() {
|
|
const home = newTmpHome();
|
|
try {
|
|
// 4 in s1 (counter=4, no streak yet), then 1 in s2 (counter must reset → 1, no streak)
|
|
for (let i = 0; i < 4; i++) feed(home, { hook_event_name: "UserPromptSubmit", session_id: "s1", cwd: "/x", prompt: "ok" });
|
|
feed(home, { hook_event_name: "UserPromptSubmit", session_id: "s2", cwd: "/x", prompt: "ok" });
|
|
const j = readJournal(home);
|
|
assert(j.filter(e => e.type === "correction_free_streak").length === 0, "no streak when session changes mid-streak");
|
|
} finally { cleanup(home); }
|
|
}
|
|
|
|
function testCleanRecovery() {
|
|
const home = newTmpHome();
|
|
try {
|
|
// Trigger tool_error_loop: 3 PostToolUse with same error fingerprint.
|
|
for (let i = 0; i < 3; i++) {
|
|
feed(home, {
|
|
hook_event_name: "PostToolUse",
|
|
session_id: "s1", cwd: "/x",
|
|
tool_name: "Bash",
|
|
tool_input: { command: `echo ${i}` },
|
|
tool_response: { is_error: true, content: "error: command not found" },
|
|
});
|
|
}
|
|
// Then 3 clean PostToolUse events.
|
|
for (let i = 0; i < 3; i++) {
|
|
feed(home, {
|
|
hook_event_name: "PostToolUse",
|
|
session_id: "s1", cwd: "/x",
|
|
tool_name: "Read",
|
|
tool_input: { file_path: `/tmp/ok-${i}` },
|
|
tool_response: { content: "fine" },
|
|
});
|
|
}
|
|
const j = readJournal(home);
|
|
const recs = j.filter(e => e.type === "clean_recovery");
|
|
assert(recs.length === 1, "one clean_recovery emitted after 3 clean tools post-struggle");
|
|
assert(recs[0].recovered_from === "tool_error_loop", "recovered_from set");
|
|
} finally { cleanup(home); }
|
|
}
|
|
|
|
function testRecoveryResetsOnError() {
|
|
const home = newTmpHome();
|
|
try {
|
|
for (let i = 0; i < 3; i++) {
|
|
feed(home, {
|
|
hook_event_name: "PostToolUse", session_id: "s1", cwd: "/x",
|
|
tool_name: "Bash",
|
|
tool_input: { command: `cmd ${i}` },
|
|
tool_response: { is_error: true, content: "error: failed" },
|
|
});
|
|
}
|
|
feed(home, { hook_event_name: "PostToolUse", session_id: "s1", cwd: "/x",
|
|
tool_name: "Read", tool_input: { file_path: "/tmp/a" }, tool_response: { content: "ok" } });
|
|
feed(home, { hook_event_name: "PostToolUse", session_id: "s1", cwd: "/x",
|
|
tool_name: "Read", tool_input: { file_path: "/tmp/b" }, tool_response: { content: "ok" } });
|
|
feed(home, { hook_event_name: "PostToolUse", session_id: "s1", cwd: "/x",
|
|
tool_name: "Bash", tool_input: { command: "x" }, tool_response: { is_error: true, content: "error: again" } });
|
|
feed(home, { hook_event_name: "PostToolUse", session_id: "s1", cwd: "/x",
|
|
tool_name: "Read", tool_input: { file_path: "/tmp/c" }, tool_response: { content: "ok" } });
|
|
const j = readJournal(home);
|
|
assert(j.filter(e => e.type === "clean_recovery").length === 0, "no clean_recovery when error breaks the streak");
|
|
} finally { cleanup(home); }
|
|
}
|
|
|
|
function testActiveSkillsPayload() {
|
|
const home = newTmpHome();
|
|
try {
|
|
feed(home, { hook_event_name: "PreToolUse", session_id: "s1", cwd: "/x",
|
|
tool_name: "Skill", tool_input: { skill: "my-skill" } });
|
|
for (let i = 0; i < 5; i++) {
|
|
feed(home, { hook_event_name: "UserPromptSubmit", session_id: "s1", cwd: "/x", prompt: "ok" });
|
|
}
|
|
const j = readJournal(home);
|
|
const s = j.find(e => e.type === "correction_free_streak");
|
|
assert(s && Array.isArray(s.active_skills) && s.active_skills.includes("my-skill"),
|
|
"correction_free_streak payload includes active skill");
|
|
} finally { cleanup(home); }
|
|
}
|
|
|
|
async function main() {
|
|
testCorrectionFreeStreak();
|
|
testStreakResetsOnSessionChange();
|
|
testCleanRecovery();
|
|
testRecoveryResetsOnError();
|
|
testActiveSkillsPayload();
|
|
console.log("all tests passed");
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) main();
|