From 6d8ff37cb2f832128d8791c68a2ab956a5388e61 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 10 May 2026 21:33:17 +0100 Subject: [PATCH] v0.3.1: code review pass + DX overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 instead of Set; 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 .adam-new instead of clobbering, idempotent across re-runs. - adam-uninstall.sh: NEW. Soft-archives ~/.claude/adam/ to .bak./ 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). --- README.md | 80 +++++++-- adam-uninstall.sh | 88 ++++++++++ adam/scripts/adam-archive.mjs | 9 +- adam/scripts/test-hook.mjs | 154 ++++++++++++++++ adam/tests/run-tests.sh | 93 +++++----- agents/adam.md | 35 ++-- hooks/adam-nudge.mjs | 3 +- hooks/adam-observe.mjs | 4 +- install.sh | 244 ++++++++++++++++++++++---- skills/adam-self-improvement/SKILL.md | 4 +- 10 files changed, 593 insertions(+), 121 deletions(-) create mode 100755 adam-uninstall.sh create mode 100644 adam/scripts/test-hook.mjs diff --git a/README.md b/README.md index 2169cdc..a087de2 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ Self-improvement layer for [Claude Code](https://claude.com/claude-code) that observes friction signals during your sessions and proposes targeted improvements (new skills, memory entries, agent edits) which you can review and apply. +## What's new + +- **v0.3.1** — code review pass: bug fixes (`errorFingerprint` no longer false-positives on `is_error: false`, archive script handles same-millisecond duplicates correctly, `tool_window` now clears on session change, nudge filters proposal filenames by pattern), prose conciseness cuts, hardened `install.sh` with curl one-liner + settings.json merge, `adam-uninstall.sh`, isolated test harness (no longer pollutes live `~/.claude/adam/` state). +- **v0.3.0** — causal diagnosis: every proposal carries a `# Diagnosis` block (Trigger/Action/Mismatch/Outcome with verbatim transcript quote) before drafting, plus optional `contradiction_flag` heuristic that vetoes auto-apply on obviously-conflicting `skill_edit` additions. +- **v0.2.1** — win signals (`correction_free_streak`, `clean_recovery`) feed `skill_edit` auto-apply under a strict gate (≤30 LOC, ≤2× byte cap, 7d cooldown, 30d blacklist on rejection). +- **v0.2.0** — actioned-entry archival via `adam-archive.mjs`; `cursor` field deprecated. + ## What it does A lightweight Node.js hook (`adam-observe.mjs`) runs on `UserPromptSubmit`, `PreToolUse`, and `PostToolUse` events. It detects: @@ -16,6 +23,8 @@ A lightweight Node.js hook (`adam-observe.mjs`) runs on `UserPromptSubmit`, `Pre | `edit_churn` | Same file edited 4× in a window | | `build_loop` | 2× build/test/compile commands fail in same session | | `subagent_dispatch_pattern` | Same subagent dispatched ≥3× cumulatively | +| `correction_free_streak` | 5 clean UserPromptSubmits in a row (no correction phrase) — feeds `skill_edit` reinforcement | +| `clean_recovery` | 3 clean PostToolUse events after a struggle signal — feeds `skill_edit` reinforcement | Detection is local, regex-based, zero LLM cost. Signals append to `~/.claude/adam/journal.jsonl`. @@ -38,34 +47,64 @@ LLM coding sessions reveal repeated friction the moment you stop and look. ADAM └── adam/ ├── journal.jsonl # append-only signal log (active observations) ├── journal/ # rotated daily logs + actioned-.jsonl per applied/rejected proposal - ├── state.json # per-session counters (cursor field is vestigial as of v0.2.0) + ├── state.json # per-session counters ├── usage.json # skill/agent invocation tallies + payload visibility counters ├── proposals/ # queued, awaiting review ├── applied/ # approved + auto-applied archive ├── rejected/ # rejected (with reason) ├── trash/ # soft-deleted artifacts (recoverable) ├── scripts/ # adam-archive.mjs (called by skill on apply/reject) - └── tests/run-tests.sh # 21 verification tests + └── tests/run-tests.sh # 27 verification tests (isolated tmpdir; never touches live state) ``` ## Install +### One-liner (recommended) + ```sh +curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/claude-adam/main/install.sh | bash +``` + +Pin a release for reproducibility: + +```sh +curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/claude-adam/v0.3.1/install.sh \ + | VERSION=v0.3.1 bash +``` + +The installer clones the repo to `/tmp`, copies files into `~/.claude/`, and offers to merge ADAM's hook entries into your `~/.claude/settings.json` (with a diff preview and `[y/N]` confirmation — your existing hooks are preserved). Pass `--yes` to skip the prompt; `--dry-run` to preview without writing. + +Requires `git`, `curl`, `jq`, and `node` 18+. + +### From a clone + +```sh +git clone https://github.com/lukaszraczylo/claude-adam +cd claude-adam ./install.sh ``` -The script copies files into `~/.claude/`. **It does NOT modify your `settings.json`** — wire the hook entries manually using `settings.json.example` as reference. Merging into existing settings prevents accidental clobber of your other hooks. +### Upgrade-safe -After install: -1. Run the test suite: `bash ~/.claude/adam/tests/run-tests.sh` — must show `18 passed, 0 failed`. -2. Add the hook entries from `settings.json.example` to `~/.claude/settings.json` (preserve your existing hooks; ADAM's are additive). -3. Restart Claude Code, or just run `/reflect` to trigger the skill — Claude Code v2.1.0+ auto-hot-reloads user-level skills, no restart needed. +These files are **never overwritten** if they already exist: + +- `~/.claude/adam/journal.jsonl` — your observation log +- `~/.claude/adam/state.json` — session counters +- `~/.claude/adam/usage.json` — invocation tallies + +If you've locally edited any installed file (e.g. `agents/adam.md`), the installer writes the new version to `.adam-new` and warns you instead of clobbering. + +After install: run `bash ~/.claude/adam/tests/run-tests.sh` to verify (expect `27 passed, 0 failed`), start a fresh Claude Code session, then run `/reflect`. ## Requirements - Claude Code v2.1.0+ (for auto skill hot-reload; older versions need session restart after `skill_new` proposals are applied) - Node.js 18+ (for the hook; tested on v22) -- Bash (for the test harness) +- Bash 4+, `git`, `curl`, `jq` (for installer + test harness) + +### Platform support + +Tested on **macOS** (Darwin / BSD coreutils) and **Linux** (Alpine, glibc + musl). The install / uninstall / test scripts are written to be portable: `stat` uses BSD `-f` with GNU `-c` fallback, `mktemp -d -t prefix.XXXXXX` works on both, no GNU-only flags. CI smoke verified `27 passed, 0 failed` under `alpine:latest`. ## Confidence rubric @@ -85,8 +124,16 @@ Sum: auto_apply_eligible requires ALL: confidence ≥ 4 blast_radius == low - type ∈ {memory, skill_new} + type ∈ {memory, skill_new, skill_edit} # skill_edit also passes the win-driven gate cross_session_evidence == true (single-session-only proposals always queue) + +skill_edit additionally requires (v0.2.1+): + win-signal evidence (correction_free_streak / clean_recovery cites target skill) + diff is append-only, ≤30 LOC, resulting size ≤2× original + no auto-edit to same target in past 7 days (cooldown) + no rejection-blacklist on target in past 30 days + contradiction heuristic does not flag (v0.3.0+) + # Diagnosis section present + structurally valid (v0.3.0+) ``` ## Lifecycle: how proposals become permanent @@ -108,9 +155,22 @@ Every proposal records the journal entry timestamps that fed its cluster (`sourc ## Uninstall +One-shot: + ```sh -rm -rf ~/.claude/{hooks/adam-*.mjs,agents/adam.md,skills/adam-self-improvement,commands/reflect.md,adam} +curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/claude-adam/main/adam-uninstall.sh | bash ``` + +The uninstaller archives `~/.claude/adam/` to `~/.claude/adam.bak./` (preserving your journal/proposals data), removes ADAM files, and offers to strip ADAM hook entries from `~/.claude/settings.json` with a diff prompt. Pass `--yes` to skip the prompt; `--purge` to delete the data archive instead of preserving it. + +Manual: + +```sh +mv ~/.claude/adam ~/.claude/adam.bak.$(date +%s) +rm -f ~/.claude/hooks/adam-*.mjs ~/.claude/agents/adam.md ~/.claude/commands/reflect.md +rm -rf ~/.claude/skills/adam-self-improvement +``` + Then remove the four `adam-*` hook entries from `~/.claude/settings.json`. ## License diff --git a/adam-uninstall.sh b/adam-uninstall.sh new file mode 100755 index 0000000..0458dcd --- /dev/null +++ b/adam-uninstall.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# ADAM uninstaller — reverses install.sh. +# Soft-archives ~/.claude/adam/ (your journal/proposals are preserved by default). +# Removes hook entries from settings.json with a diff prompt. +# +# Usage: ./adam-uninstall.sh [--yes] [--purge] +# --purge: also delete ~/.claude/adam/ data (destructive) + +set -euo pipefail + +DEST="${HOME}/.claude" +ASSUME_YES=0 +PURGE=0 +BAK="" + +for arg in "$@"; do + case "$arg" in + --yes|-y) ASSUME_YES=1 ;; + --purge) PURGE=1 ;; + --help|-h) sed -n '2,8p' "$0"; exit 0 ;; + *) echo "unknown: $arg" >&2; exit 1 ;; + esac +done + +log() { printf ' %s\n' "$*"; } +need() { command -v "$1" >/dev/null 2>&1 || { echo "missing: $1" >&2; exit 1; }; } +need jq + +[ -d "$DEST" ] || { echo "$DEST not found"; exit 1; } + +log "removing ADAM files" +rm -f "$DEST/hooks/adam-observe.mjs" "$DEST/hooks/adam-nudge.mjs" +rm -f "$DEST/agents/adam.md" "$DEST/commands/reflect.md" +rm -rf "$DEST/skills/adam-self-improvement" + +if [ -d "$DEST/adam" ]; then + if [ "$PURGE" = 1 ]; then + log "purging $DEST/adam (--purge)" + rm -rf "$DEST/adam" + else + BAK="$DEST/adam.bak.$(date +%s)" + log "archiving $DEST/adam -> $BAK" + mv "$DEST/adam" "$BAK" + fi +fi + +# settings.json — strip ADAM hook entries +SETTINGS="$DEST/settings.json" +if [ -f "$SETTINGS" ]; then + TMP="$(mktemp -t adam-uninstall.XXXXXX)" + jq ' + .hooks //= {} + | .hooks |= with_entries( + .value |= ( + map(.hooks |= map(select( + (.command // "") | test("adam-(observe|nudge)\\.mjs") | not + ))) + | map(select((.hooks // []) | length > 0)) + ) + ) + | .hooks |= with_entries(select((.value | length) > 0)) + ' "$SETTINGS" > "$TMP" + + if cmp -s "$SETTINGS" "$TMP"; then + log "settings.json already clean" + rm -f "$TMP" + else + log "" + log "settings.json changes:" + diff -u "$SETTINGS" "$TMP" | sed 's/^/ /' || true + log "" + if [ "$ASSUME_YES" = 1 ]; then REPLY=y + else printf ' apply? [y/N] '; read -r REPLY 0) { matched.push(line); + tsCounts.set(e.ts, remainingCount - 1); } else { remaining.push(line); } diff --git a/adam/scripts/test-hook.mjs b/adam/scripts/test-hook.mjs new file mode 100644 index 0000000..f482d39 --- /dev/null +++ b/adam/scripts/test-hook.mjs @@ -0,0 +1,154 @@ +#!/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(); diff --git a/adam/tests/run-tests.sh b/adam/tests/run-tests.sh index c4f4136..9be83d5 100755 --- a/adam/tests/run-tests.sh +++ b/adam/tests/run-tests.sh @@ -1,8 +1,23 @@ #!/usr/bin/env bash +# Test harness: ALWAYS runs against an isolated $HOME under mktemp. +# The hook/nudge/archive scripts being tested are sourced from the real $HOME +# but invoked with HOME="$TMP_HOME" so journal/state/usage write to the sandbox. set -euo pipefail -ROOT="$HOME/.claude/adam" -HOOK="$HOME/.claude/hooks/adam-observe.mjs" +REAL_HOME="$HOME" +HOOK="$REAL_HOME/.claude/hooks/adam-observe.mjs" +NUDGE="$REAL_HOME/.claude/hooks/adam-nudge.mjs" +ARCHIVE="$REAL_HOME/.claude/adam/scripts/adam-archive.mjs" + +TMP_HOME="$(mktemp -d -t adam-test.XXXXXX)" +trap 'rm -rf "$TMP_HOME"' EXIT INT TERM +mkdir -p "$TMP_HOME/.claude/adam/proposals" "$TMP_HOME/.claude/adam/applied" "$TMP_HOME/.claude/adam/rejected" "$TMP_HOME/.claude/adam/journal" + +ROOT="$TMP_HOME/.claude/adam" +HOOK_RUN() { HOME="$TMP_HOME" node "$HOOK" "$@"; } +NUDGE_RUN() { HOME="$TMP_HOME" node "$NUDGE" "$@"; } +ARCHIVE_RUN() { HOME="$TMP_HOME" node "$ARCHIVE" "$@"; } + PASS=0 FAIL=0 @@ -40,7 +55,7 @@ assert_grep() { echo "Test 1: user correction" reset_state echo '{"hook_event_name":"UserPromptSubmit","prompt":"no, that is wrong","session_id":"s1","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true assert_lines "$ROOT/journal.jsonl" 1 "correction creates journal entry" assert_grep "$ROOT/journal.jsonl" '"type":"correction"' "entry has correct type" @@ -49,7 +64,7 @@ echo "Test 2: retry loop" reset_state for i in 1 2 3; do echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls"},"session_id":"s1","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done assert_grep "$ROOT/journal.jsonl" '"type":"retry_loop"' "3x same Bash logs retry_loop" @@ -57,29 +72,29 @@ assert_grep "$ROOT/journal.jsonl" '"type":"retry_loop"' "3x same Bash logs retry echo "Test 3: usage counter" reset_state echo '{"hook_event_name":"PreToolUse","tool_name":"Skill","tool_input":{"skill":"foo"},"session_id":"s1","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true assert_grep "$ROOT/usage.json" '"skill:foo"' "Skill invocation increments usage counter" # --- Test 3b: agent prefix in usage counter --- echo "Test 3b: agent prefix" reset_state echo '{"hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{"subagent_type":"bar"},"session_id":"s1","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true assert_grep "$ROOT/usage.json" '"agent:bar"' "Agent invocation increments prefixed counter" # --- Test 4: weak agent --- echo "Test 4: weak agent" reset_state echo '{"hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"subagent_type":"x"},"session_id":"s1","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true echo '{"hook_event_name":"PostToolUse","tool_name":"Agent","tool_input":{"subagent_type":"x"},"session_id":"s1","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true assert_grep "$ROOT/journal.jsonl" '"type":"weak_agent"' "2x same agent logs weak_agent" # --- Test 5: hook never blocks (exit 0) --- echo "Test 5: hook always exit 0 even on garbage input" reset_state -if echo 'not json' | node "$HOOK" >/dev/null 2>&1; then +if echo 'not json' | HOOK_RUN >/dev/null 2>&1; then echo " PASS: garbage input exit 0"; PASS=$((PASS+1)) else echo " FAIL: garbage input non-zero exit"; FAIL=$((FAIL+1)) @@ -91,7 +106,7 @@ reset_state # Seed journal with > 5 MB to trigger rotation on next write head -c 5500000 /dev/urandom | base64 > "$ROOT/journal.jsonl" echo '{"hook_event_name":"UserPromptSubmit","prompt":"no, that is wrong","session_id":"s1","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true rotated=$(ls "$ROOT/journal/" 2>/dev/null | wc -l | tr -d ' ') if [ "$rotated" -ge "1" ]; then echo " PASS: journal rotated ($rotated archive present)"; PASS=$((PASS+1)) @@ -103,10 +118,9 @@ rm -f "$ROOT/journal/"*.jsonl 2>/dev/null # --- Test 7: nudge prints reminder when ≥3 proposals --- echo "Test 7: SessionStart nudge" -NUDGE="$HOME/.claude/hooks/adam-nudge.mjs" rm -f "$ROOT/proposals/"*.md 2>/dev/null -touch "$ROOT/proposals/a.md" "$ROOT/proposals/b.md" "$ROOT/proposals/c.md" -out=$(echo '{"hook_event_name":"SessionStart"}' | node "$NUDGE" 2>&1 || true) +touch "$ROOT/proposals/2026-05-10-001-memory-a.md" "$ROOT/proposals/2026-05-10-002-skill_new-b.md" "$ROOT/proposals/2026-05-10-003-skill_edit-c.md" +out=$(echo '{"hook_event_name":"SessionStart"}' | NUDGE_RUN 2>&1 || true) if echo "$out" | grep -q "3 proposals queued"; then echo " PASS: nudge prints reminder"; PASS=$((PASS+1)) else @@ -115,7 +129,7 @@ fi rm -f "$ROOT/proposals/"*.md echo "Test 8: nudge silent when 0 proposals" -out=$(echo '{"hook_event_name":"SessionStart"}' | node "$NUDGE" 2>&1 || true) +out=$(echo '{"hook_event_name":"SessionStart"}' | NUDGE_RUN 2>&1 || true) if [ -z "$out" ]; then echo " PASS: nudge silent"; PASS=$((PASS+1)) else @@ -127,7 +141,7 @@ echo "Test 9: tool_error_loop on repeated identical error" reset_state for i in 1 2 3; do echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"foo"},"tool_response":{"is_error":true,"content":"Error: command not found: foo"},"session_id":"s9","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done assert_grep "$ROOT/journal.jsonl" '"type":"tool_error_loop"' "3x same error logs tool_error_loop" @@ -136,7 +150,7 @@ echo "Test 10: dead_end after 8 tools without UserPromptSubmit" reset_state for i in 1 2 3 4 5 6 7 8; do echo "{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"step$i\"},\"session_id\":\"s10\",\"cwd\":\"/tmp/x\"}" \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done assert_grep "$ROOT/journal.jsonl" '"type":"dead_end"' "8x PostToolUse without prompt logs dead_end" @@ -145,13 +159,13 @@ echo "Test 11: dead_end counter resets on UserPromptSubmit" reset_state for i in 1 2 3 4 5 6 7; do echo "{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"step$i\"},\"session_id\":\"s11\",\"cwd\":\"/tmp/x\"}" \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done echo '{"hook_event_name":"UserPromptSubmit","prompt":"continue","session_id":"s11","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true for i in 8 9 10 11 12; do echo "{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"step$i\"},\"session_id\":\"s11\",\"cwd\":\"/tmp/x\"}" \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done if grep -qE '"type":"dead_end"' "$ROOT/journal.jsonl"; then echo " FAIL: dead_end fired despite reset"; FAIL=$((FAIL+1)) @@ -164,11 +178,11 @@ echo "Test 12: session change resets dead_end counter" reset_state for i in 1 2 3 4 5 6 7; do echo "{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"a$i\"},\"session_id\":\"sA\",\"cwd\":\"/tmp/x\"}" \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done # Now switch to session sB. First post-tool in new session should NOT trigger dead_end. echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"b1"},"session_id":"sB","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true if grep -qE '"type":"dead_end"' "$ROOT/journal.jsonl"; then echo " FAIL: dead_end fired across session boundary"; FAIL=$((FAIL+1)) else @@ -180,7 +194,7 @@ echo "Test 13: edit_churn fires after 4 edits to same file" reset_state for i in 1 2 3 4; do echo '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"/tmp/x.py"},"session_id":"sE","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done assert_grep "$ROOT/journal.jsonl" '"type":"edit_churn"' "4x edits to same file logs edit_churn" @@ -189,7 +203,7 @@ echo "Test 14: build_loop fires after 2 failed builds" reset_state for i in 1 2; do echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"go test ./..."},"tool_response":{"is_error":true,"content":"FAIL: TestFoo"},"session_id":"sB","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done assert_grep "$ROOT/journal.jsonl" '"type":"build_loop"' "2x failed test logs build_loop" @@ -198,7 +212,7 @@ echo "Test 15: subagent_dispatch_pattern fires after 3 same-type dispatches" reset_state for i in 1 2 3; do echo '{"hook_event_name":"PreToolUse","tool_name":"Agent","tool_input":{"subagent_type":"orchestrator"},"session_id":"sD","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done assert_grep "$ROOT/journal.jsonl" '"type":"subagent_dispatch_pattern"' "3x same subagent logs subagent_dispatch_pattern" @@ -207,7 +221,7 @@ echo "Test 16: build_loop ignores non-build commands" reset_state for i in 1 2 3; do echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls /nope"},"tool_response":{"is_error":true,"content":"No such file"},"session_id":"sN","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done if grep -qE '"type":"build_loop"' "$ROOT/journal.jsonl"; then echo " FAIL: build_loop fired on non-build command"; FAIL=$((FAIL+1)) @@ -217,7 +231,6 @@ fi # --- Test 17: adam-archive moves matching entries to actioned file --- echo "Test 17: adam-archive moves matching journal entries" -ARCHIVE="$HOME/.claude/adam/scripts/adam-archive.mjs" reset_state rm -f "$ROOT/journal/actioned-test-archive-001.jsonl" cat > "$ROOT/journal.jsonl" </dev/null 2>&1 || true +ARCHIVE_RUN /tmp/adam-test-17/proposal.md >/dev/null 2>&1 || true remaining=$(wc -l < "$ROOT/journal.jsonl" | tr -d ' ') archived=$(wc -l < "$ROOT/journal/actioned-test-archive-001.jsonl" 2>/dev/null | tr -d ' ' || echo 0) if [ "$remaining" = "1" ] && [ "$archived" = "2" ]; then @@ -265,7 +278,7 @@ type: memory # Why no source_entries EOF -node "$ARCHIVE" /tmp/adam-test-18/proposal.md >/dev/null 2>&1 || true +ARCHIVE_RUN /tmp/adam-test-18/proposal.md >/dev/null 2>&1 || true if [ -f "$ROOT/journal/actioned-test-noop-002.jsonl" ]; then echo " FAIL: archive file created when no source_entries"; FAIL=$((FAIL+1)) else @@ -284,7 +297,7 @@ echo "Test 19: correction_free_streak after 5 clean prompts" reset_state for i in 1 2 3 4 5; do echo "{\"hook_event_name\":\"UserPromptSubmit\",\"prompt\":\"please do step $i\",\"session_id\":\"sCF\",\"cwd\":\"/tmp/x\"}" \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done assert_grep "$ROOT/journal.jsonl" '"type":"correction_free_streak"' "5 clean prompts logs correction_free_streak" @@ -293,12 +306,12 @@ echo "Test 20: correction phrase breaks correction_free_streak" reset_state for i in 1 2 3 4; do echo "{\"hook_event_name\":\"UserPromptSubmit\",\"prompt\":\"please do step $i\",\"session_id\":\"sCB\",\"cwd\":\"/tmp/x\"}" \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done echo '{"hook_event_name":"UserPromptSubmit","prompt":"no, undo that","session_id":"sCB","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true echo '{"hook_event_name":"UserPromptSubmit","prompt":"go on","session_id":"sCB","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true if grep -qE '"type":"correction_free_streak"' "$ROOT/journal.jsonl"; then echo " FAIL: correction_free_streak fired despite intervening correction"; FAIL=$((FAIL+1)) else @@ -310,11 +323,11 @@ echo "Test 21: clean_recovery after struggle + 3 clean tools" reset_state for i in 1 2 3; do echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"foo"},"tool_response":{"is_error":true,"content":"Error: command not found: foo"},"session_id":"sR","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done for i in 1 2 3; do echo "{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Read\",\"tool_input\":{\"file_path\":\"/tmp/ok-$i\"},\"tool_response\":{\"content\":\"ok\"},\"session_id\":\"sR\",\"cwd\":\"/tmp/x\"}" \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done assert_grep "$ROOT/journal.jsonl" '"type":"clean_recovery"' "3 clean tools after struggle logs clean_recovery" assert_grep "$ROOT/journal.jsonl" '"recovered_from":"tool_error_loop"' "recovered_from set on clean_recovery" @@ -324,16 +337,16 @@ echo "Test 22: clean_recovery suppressed by intervening error" reset_state for i in 1 2 3; do echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"foo"},"tool_response":{"is_error":true,"content":"Error: command not found: foo"},"session_id":"sRE","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done for i in 1 2; do echo "{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Read\",\"tool_input\":{\"file_path\":\"/tmp/ok-$i\"},\"tool_response\":{\"content\":\"ok\"},\"session_id\":\"sRE\",\"cwd\":\"/tmp/x\"}" \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"x"},"tool_response":{"is_error":true,"content":"Error: again"},"session_id":"sRE","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true echo '{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/ok-3"},"tool_response":{"content":"ok"},"session_id":"sRE","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true if grep -qE '"type":"clean_recovery"' "$ROOT/journal.jsonl"; then echo " FAIL: clean_recovery fired despite intervening error"; FAIL=$((FAIL+1)) else @@ -344,10 +357,10 @@ fi echo "Test 23: correction_free_streak payload includes active skill" reset_state echo '{"hook_event_name":"PreToolUse","tool_name":"Skill","tool_input":{"skill":"caveman"},"session_id":"sAS","cwd":"/tmp/x"}' \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true for i in 1 2 3 4 5; do echo "{\"hook_event_name\":\"UserPromptSubmit\",\"prompt\":\"step $i\",\"session_id\":\"sAS\",\"cwd\":\"/tmp/x\"}" \ - | node "$HOOK" >/dev/null 2>&1 || true + | HOOK_RUN >/dev/null 2>&1 || true done assert_grep "$ROOT/journal.jsonl" '"active_skills":\["caveman"\]' "active_skills payload includes invoked skill" diff --git a/agents/adam.md b/agents/adam.md index 6706299..aaec2c8 100644 --- a/agents/adam.md +++ b/agents/adam.md @@ -18,16 +18,9 @@ You MUST obey these on every proposal: 4. **Verifiable success criterion** — every proposal has a `# Success criterion` section describing a runnable check. 5. **Naive then optimize** — first proposal for a pattern is the boring obvious solution. -## Inputs (passed in dispatch prompt) +## Inputs -- `journal_path`: `~/.claude/adam/journal.jsonl` -- `state_path`: `~/.claude/adam/state.json` (cursor) -- `usage_path`: `~/.claude/adam/usage.json` -- `proposals_dir`: `~/.claude/adam/proposals/` -- `applied_dir`: `~/.claude/adam/applied/` -- `rejected_dir`: `~/.claude/adam/rejected/` -- `transcripts_root`: `~/.claude/projects/` -- `skills_root`: `~/.claude/skills/` +Paths arrive via the dispatch prompt — see `~/.claude/skills/adam-self-improvement/SKILL.md` §1. ## Signal types @@ -89,7 +82,7 @@ The hook emits these `type` values into the journal: g. Apply feedback bias (step 1e) and multi-axis bonus. h. **Record `source_entries`**: list every journal entry timestamp that fed this cluster. Goes in proposal frontmatter as a YAML block-form array (one `- ""` per line). The skill consumes this on apply/reject to archive matching entries out of `journal.jsonl` and into `journal/actioned-.jsonl`. i. Emit proposal file to `proposals_dir/`. -7. Emit punch list to stdout (last message): `{"new":N, "high_confidence":[...], "queued":[...], "skipped":[...]}`. The `cursor` field in `state.json` is vestigial as of v0.2.0 — do not read or write it. +7. Emit punch list to stdout (last message): `{"new":N, "high_confidence":[...], "queued":[...], "skipped":[...]}`. ## Skill overlap rule @@ -144,10 +137,6 @@ Constraints: - ≤80 lines of body content. Karpathy "Surgical". - Slug MUST NOT collide with any existing skill name in `skills_root`. -When the main thread applies a `skill_new` proposal: -1. Creates `~/.claude/skills//` directory. -2. Writes the `# Proposed change` body to `/SKILL.md`. -3. Tells the user: "skill `` written. Activates immediately on next user turn (CC v2.1.0+ auto-hot-reload)." ## Memory drafting protocol (for `memory` proposals) @@ -236,10 +225,9 @@ A `skill_edit` proposal sets `auto_apply_eligible: true` ONLY when ALL hold: 6. Resulting SKILL.md size ≤ 2× current size. Record both byte counts in frontmatter fields `bytes_before`, `bytes_after`. 7. No entry in `applied_dir/` for the same `target` with `last_auto_edit` newer than 7 days ago (cooldown). 8. No entry in `rejected_dir/` for this `target` with `auto_apply_blacklist: true` newer than 30 days ago. +9. **Contradiction check passes.** Tokenize both the existing SKILL.md and the new appended section per the same tokenizer + stopword list as the skill-overlap rule. Search for negation tokens (`never`, `not`, `no`, `don't`, `avoid`, `forbid`, `stop`, `disable`) in the existing content; take a 6-token window around each match. If the new section contains an assertion token (`always`, `must`, `should`, `do`, `enable`, `yes`) whose surrounding 6-token window shares ≥2 content tokens with the existing negation window → flag as contradiction. Repeat in the inverse direction (negations in new section vs assertions in existing). On any flag: set `auto_apply_eligible: false` and add frontmatter field `contradiction_flag: ""`. Heuristic only — false positives queue for review, never silently auto-apply. -If any of (3)–(8) fails: still emit the proposal, but `auto_apply_eligible: false` — main thread queues for review. - -Win clusters do NOT override struggle clusters: a single `clean_recovery` cannot turn a `correction` cluster into a `skill_edit`. Struggle paths and win paths are independent. +If any of (3)–(9) fails: still emit the proposal, but `auto_apply_eligible: false` — main thread queues for review. ## Confidence rubric (deterministic — do NOT vibe) @@ -250,9 +238,7 @@ Sum: - Multi-axis cluster (≥2 distinct struggle types in same session): **+1** - Type-bias penalty from feedback loop (≥3 rejections, applied:rejected ratio <1:2 for this `type`): **-1** - Diagnosis flags `Mismatch: unclear` (causation could not be reconstructed from transcript context): **-1** -- Blast radius low (memory file or new isolated skill): **+1** -- Blast radius medium (new agent, new hook, edit existing skill): **0** -- Blast radius high (CLAUDE.md, settings.json hooks, edit agent, deletion): **-1** +- Blast radius: low **+1**, medium **0**, high **-1** (default per type — see Proposal types table) - Surgical (one file, ≤50 LOC for non-skill_new; ≤80 LOC for skill_new): **+1** - Touches deny-list (settings.json hooks/permissions, CLAUDE.md, deletions): **-3** @@ -317,6 +303,8 @@ source_entries: win_evidence: "" bytes_before: bytes_after: +# skill_edit only — populated when contradiction heuristic flags a conflict (sets auto_apply_eligible: false) +contradiction_flag: "" # optional — auto-populated from Diagnosis Mismatch line diagnosis_summary: "<≤120 chars, single sentence>" --- @@ -337,8 +325,8 @@ diagnosis_summary: "<≤120 chars, single sentence>" -# Overlap (skill_edit only) - +# Overlap + # Success criterion @@ -356,11 +344,8 @@ Print a single JSON line to stdout: ## What you must NOT do -- Do not read full transcripts — ~20 messages base context per cluster, +30 for skill_new solution synthesis (50 total cap). - Do not call other agents. - Do not write to `~/.claude/skills/`, `~/.claude/agents/`, `settings.json`, `CLAUDE.md`, or any existing skill/agent file directly. All changes go through proposal files for main-thread review and apply. - Do not delete files. Deletion proposals describe a soft-move; the main thread executes it. - Do not write outside `proposals_dir/` and `state_path`. -- Do not propose anything matching a `rejected/` entry (≥2 token overlap with rejection's `# Why`). - Do not invent trigger phrases for `skill_new` — every trigger must come from observed user input. -- Do not stack the cross-session and single-session repetition bonuses — pick whichever qualifies, never both. diff --git a/hooks/adam-nudge.mjs b/hooks/adam-nudge.mjs index 0e3fb51..9c11a7e 100755 --- a/hooks/adam-nudge.mjs +++ b/hooks/adam-nudge.mjs @@ -7,7 +7,8 @@ const PROPOSALS = join(homedir(), ".claude", "adam", "proposals"); const THRESHOLD = 3; try { - const files = readdirSync(PROPOSALS).filter(f => f.endsWith(".md")); + const PROPOSAL_RE = /^\d{4}-\d{2}-\d{2}-\d{3}-/; + const files = readdirSync(PROPOSALS).filter(f => PROPOSAL_RE.test(f) && f.endsWith(".md")); if (files.length >= THRESHOLD) { process.stdout.write(`adam: ${files.length} proposals queued. Run /reflect to review.\n`); } diff --git a/hooks/adam-observe.mjs b/hooks/adam-observe.mjs index aeaeffb..eab494e 100755 --- a/hooks/adam-observe.mjs +++ b/hooks/adam-observe.mjs @@ -105,7 +105,8 @@ function errorFingerprint(toolResponse) { } if (!text) return null; text = text.slice(0, 4000); - const isError = (toolResponse && toolResponse.is_error === true) || ERROR_RE.test(text); + const isError = toolResponse.is_error === true || + (toolResponse.is_error === undefined && ERROR_RE.test(text)); if (!isError) return null; const m = text.match(ERROR_RE); const idx = m && typeof m.index === "number" ? m.index : 0; @@ -131,6 +132,7 @@ function resetSessionLocal(state) { state.subagent_dispatch_emitted = {}; state.correctionFreeCounter = 0; state.recoveryWatch = null; + state.tool_window = []; } function ensureStateDefaults(state) { diff --git a/install.sh b/install.sh index e817d95..116696a 100755 --- a/install.sh +++ b/install.sh @@ -1,50 +1,216 @@ #!/usr/bin/env bash +# ADAM installer — pure bash + git + curl + jq. +# Idempotent. Safe for upgrades. Supports `curl | bash` via auto-clone. +# +# Usage: +# ./install.sh # local install from cwd +# curl -fsSL /install.sh | bash +# VERSION=v0.3.0 ./install.sh # pin a tag +# ./install.sh --yes # skip settings.json prompt +# ./install.sh --dry-run # show actions, write nothing + set -euo pipefail +REPO_GIT="https://github.com/lukaszraczylo/claude-adam.git" DEST="${HOME}/.claude" -SRC="$(cd "$(dirname "$0")" && pwd)" +ASSUME_YES=0 +DRY_RUN=0 +VERSION="${VERSION:-${BRANCH:-}}" # env var pin; empty = latest tag -echo "ADAM installer" -echo " source: $SRC" -echo " dest: $DEST" -echo +log() { printf ' %s\n' "$*"; } +warn() { printf ' ! %s\n' "$*" >&2; } +die() { printf ' ! %s\n' "$*" >&2; exit 1; } +run() { if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] %s\n' "$*"; else eval "$@"; fi; } -if [ ! -d "$DEST" ]; then - echo " ! $DEST does not exist. Is Claude Code installed?" - exit 1 +# --------------------------------------------------------------------- args +for arg in "$@"; do + case "$arg" in + --yes|-y) ASSUME_YES=1 ;; + --dry-run|-n) DRY_RUN=1 ;; + --version=*) VERSION="${arg#--version=}" ;; + --help|-h) sed -n '2,12p' "$0"; exit 0 ;; + *) die "unknown arg: $arg (try --help)" ;; + esac +done + +# --------------------------------------------------------------------- prereqs +need() { command -v "$1" >/dev/null 2>&1 || die "missing: $1 — $2"; } +need git "install: brew install git || apt install git" +need curl "install: brew install curl || apt install curl" +need jq "install: brew install jq || apt install jq" +command -v node >/dev/null 2>&1 || warn "node not found — hooks need node 18+; install: brew install node" + +# --------------------------------------------------------------------- locate source +# If invoked via `curl | bash`, $0 is bash and there are no local files. +PIPED=0 +SCRIPT_PATH="${BASH_SOURCE[0]:-$0}" +if [ ! -f "$SCRIPT_PATH" ] || [ "$SCRIPT_PATH" = "bash" ] || [ "$SCRIPT_PATH" = "-" ]; then + PIPED=1 +elif [ ! -d "$(dirname "$SCRIPT_PATH")/hooks" ]; then + PIPED=1 fi -mkdir -p \ - "$DEST/hooks" \ - "$DEST/agents" \ - "$DEST/skills/adam-self-improvement" \ - "$DEST/commands" \ - "$DEST/adam/proposals" \ - "$DEST/adam/applied" \ - "$DEST/adam/rejected" \ - "$DEST/adam/trash" \ - "$DEST/adam/journal" \ - "$DEST/adam/scripts" \ - "$DEST/adam/tests/fixtures" +CLEANUP_TMP="" +cleanup() { [ -n "$CLEANUP_TMP" ] && rm -rf "$CLEANUP_TMP" 2>/dev/null || true; } +trap cleanup EXIT INT TERM -cp "$SRC/hooks/adam-observe.mjs" "$DEST/hooks/" -cp "$SRC/hooks/adam-nudge.mjs" "$DEST/hooks/" -cp "$SRC/agents/adam.md" "$DEST/agents/" -cp "$SRC/skills/adam-self-improvement/SKILL.md" "$DEST/skills/adam-self-improvement/" -cp "$SRC/commands/reflect.md" "$DEST/commands/" -cp "$SRC/adam/scripts/adam-archive.mjs" "$DEST/adam/scripts/" -cp "$SRC/adam/tests/run-tests.sh" "$DEST/adam/tests/" -cp "$SRC/adam/tests/fixtures/seed-corrections.jsonl" "$DEST/adam/tests/fixtures/" +if [ "$PIPED" = 1 ]; then + log "running via curl|bash — cloning repo to tmp" + CLEANUP_TMP="$(mktemp -d -t claude-adam-install.XXXXXX)" + REF="$VERSION" + if [ -z "$REF" ]; then + # latest semver tag from remote (no local clone needed) + REF="$(git ls-remote --tags --refs "$REPO_GIT" \ + | awk -F/ '{print $NF}' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V | tail -1)" + [ -z "$REF" ] && REF="main" + fi + log "fetching $REF" + run "git clone --quiet --depth=1 --branch=\"$REF\" \"$REPO_GIT\" \"$CLEANUP_TMP\"" + SRC="$CLEANUP_TMP" +else + SRC="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" +fi -[ -f "$DEST/adam/journal.jsonl" ] || : > "$DEST/adam/journal.jsonl" -[ -f "$DEST/adam/state.json" ] || echo '{"cursor":0,"tool_window":[]}' > "$DEST/adam/state.json" -[ -f "$DEST/adam/usage.json" ] || echo '{}' > "$DEST/adam/usage.json" +log "ADAM installer" +log " source: $SRC" +log " dest: $DEST" +log " mode: $([ "$DRY_RUN" = 1 ] && echo dry-run || echo apply)$([ "$ASSUME_YES" = 1 ] && echo ' --yes' || true)" +log "" -echo " files installed." -echo -echo " next steps:" -echo " 1. bash $DEST/adam/tests/run-tests.sh # must show: 21 passed, 0 failed" -echo " 2. merge settings.json.example into $DEST/settings.json" -echo " 3. start a fresh Claude Code session, then run /reflect" -echo -echo " ADAM is dormant until you invoke /reflect." +[ -d "$DEST" ] || die "$DEST does not exist. Install Claude Code first: https://claude.com/claude-code" + +# --------------------------------------------------------------------- dirs +DIRS=( + "hooks" "agents" "skills/adam-self-improvement" "commands" + "adam/proposals" "adam/applied" "adam/rejected" "adam/trash" + "adam/journal" "adam/scripts" "adam/tests/fixtures" +) +for d in "${DIRS[@]}"; do run "mkdir -p \"$DEST/$d\""; done + +# .gitkeep markers so the layout survives `git init` for users who VCS ~/.claude +for d in adam/proposals adam/applied adam/rejected adam/trash adam/journal; do + [ -e "$DEST/$d/.gitkeep" ] || run ": > \"$DEST/$d/.gitkeep\"" +done + +# --------------------------------------------------------------------- file copy +# Conservative: if dest exists and differs from src AND user-modified after install, +# write to .adam-new and warn instead of clobbering. +copy_file() { + local src="$1" dst="$2" + [ -f "$src" ] || die "missing source file: $src" + if [ -f "$dst" ] && ! cmp -s "$src" "$dst"; then + if [ -f "$DEST/adam/.install-marker" ] \ + && [ "$(stat -f %m "$dst" 2>/dev/null || stat -c %Y "$dst")" \ + -gt "$(stat -f %m "$DEST/adam/.install-marker" 2>/dev/null || stat -c %Y "$DEST/adam/.install-marker")" ]; then + warn "modified locally, NOT overwriting: $dst" + warn " new version written to: $dst.adam-new (review and merge manually)" + run "cp \"$src\" \"$dst.adam-new\"" + return + fi + fi + run "cp \"$src\" \"$dst\"" + log " copied: ${dst#$HOME/}" +} + +# Hooks +copy_file "$SRC/hooks/adam-observe.mjs" "$DEST/hooks/adam-observe.mjs" +copy_file "$SRC/hooks/adam-nudge.mjs" "$DEST/hooks/adam-nudge.mjs" +# Agent / skill / command +copy_file "$SRC/agents/adam.md" "$DEST/agents/adam.md" +copy_file "$SRC/skills/adam-self-improvement/SKILL.md" "$DEST/skills/adam-self-improvement/SKILL.md" +copy_file "$SRC/commands/reflect.md" "$DEST/commands/reflect.md" +# Adam internals +copy_file "$SRC/adam/scripts/adam-archive.mjs" "$DEST/adam/scripts/adam-archive.mjs" +copy_file "$SRC/adam/tests/run-tests.sh" "$DEST/adam/tests/run-tests.sh" +copy_file "$SRC/adam/tests/fixtures/seed-corrections.jsonl" "$DEST/adam/tests/fixtures/seed-corrections.jsonl" + +# Preserve user data — never overwrite +[ -f "$DEST/adam/journal.jsonl" ] || run ": > \"$DEST/adam/journal.jsonl\"" +[ -f "$DEST/adam/state.json" ] || run "echo '{\"tool_window\":[]}' > \"$DEST/adam/state.json\"" +[ -f "$DEST/adam/usage.json" ] || run "echo '{}' > \"$DEST/adam/usage.json\"" + +# install marker — used by future runs to detect local mtime drift +run "touch \"$DEST/adam/.install-marker\"" + +# --------------------------------------------------------------------- settings.json +SETTINGS="$DEST/settings.json" +EXAMPLE="$SRC/settings.json.example" +[ -f "$EXAMPLE" ] || die "missing $EXAMPLE" + +# Build target settings via jq merge (preserves all user keys/hooks). +TMP_NEW="$(mktemp -t adam-settings.XXXXXX)" +TMP_DIFF="$(mktemp -t adam-settings-diff.XXXXXX)" +cleanup_full() { cleanup; rm -f "$TMP_NEW" "$TMP_DIFF" 2>/dev/null || true; } +trap cleanup_full EXIT INT TERM + +if [ -f "$SETTINGS" ]; then + jq --slurpfile add "$EXAMPLE" ' + . as $cur + | ($add[0].hooks // {}) as $new + | .hooks = ( + ($cur.hooks // {}) as $cur_hooks + | reduce ($new | keys[]) as $k ($cur_hooks; + .[$k] = ( + ((.[$k] // []) + $new[$k]) + | unique_by(tojson) + ) + ) + ) + ' "$SETTINGS" > "$TMP_NEW" +else + jq 'del(._comment)' "$EXAMPLE" > "$TMP_NEW" +fi + +if [ -f "$SETTINGS" ] && cmp -s "$SETTINGS" "$TMP_NEW"; then + log "settings.json already wired — no changes" +else + log "" + log "settings.json changes proposed:" + if [ -f "$SETTINGS" ]; then + diff -u "$SETTINGS" "$TMP_NEW" > "$TMP_DIFF" || true + else + diff -u /dev/null "$TMP_NEW" > "$TMP_DIFF" || true + fi + sed 's/^/ /' "$TMP_DIFF" + log "" + if [ "$ASSUME_YES" = 1 ]; then + REPLY=y + else + printf ' apply settings.json changes? [y/N] ' + read -r REPLY if pre-existing)" + ;; + *) + log " skipped — wire entries from $EXAMPLE manually" + ;; + esac +fi + +# --------------------------------------------------------------------- summary +log "" +log "installed:" +log " hooks/adam-observe.mjs, hooks/adam-nudge.mjs" +log " agents/adam.md" +log " skills/adam-self-improvement/SKILL.md" +log " commands/reflect.md" +log " adam/scripts/adam-archive.mjs" +log " adam/tests/run-tests.sh" +log "" +log "preserved (if existed):" +log " adam/journal.jsonl, adam/state.json, adam/usage.json" +log "" +log "next:" +log " 1. bash $DEST/adam/tests/run-tests.sh # expect: all passed" +log " 2. start a fresh Claude Code session" +log " 3. run /reflect to invoke the analyst" +log "" +log "ADAM is dormant until you run /reflect." +log "journal: $DEST/adam/journal.jsonl" +log "proposals: $DEST/adam/proposals/" diff --git a/skills/adam-self-improvement/SKILL.md b/skills/adam-self-improvement/SKILL.md index 162febc..99bfa17 100644 --- a/skills/adam-self-improvement/SKILL.md +++ b/skills/adam-self-improvement/SKILL.md @@ -5,8 +5,6 @@ description: Use when the user types /reflect, asks "what has adam learned", ask # adam-self-improvement -You are about to drive a review session for ADAM, the self-improvement layer. You operate in the **main thread** with the user present. The `adam` subagent does the heavy analysis; you orchestrate. - ## When to invoke - User types `/reflect` @@ -59,7 +57,6 @@ For each id in `high_confidence`: 7. Re-stat target. If new size exceeds `2 * current_bytes` (captured in step 2), revert via `Edit` (remove the just-appended section) and refuse — print refusal reason. 8. Add `last_auto_edit: ` to the proposal frontmatter before moving it. 9. Tell user: "skill `` extended (added lines) — auto-applied via win-evidence gate." - - **For other types under auto-apply**: apply via Write/Edit per `# Proposed change`. (Note: only `memory`, `skill_new`, and `skill_edit` qualify for auto-apply per the rubric.) - Move proposal to `~/.claude/adam/applied/-.md`. - **Archive consumed journal entries**: `node ~/.claude/adam/scripts/adam-archive.mjs ~/.claude/adam/applied/-.md` — moves entries listed in proposal's `source_entries` from `journal.jsonl` to `journal/actioned-.jsonl` so subsequent `/reflect` runs do not re-cluster them. @@ -112,6 +109,7 @@ Before writing any proposal: - For `deletion`: confirm both criteria (a) and (b) from the agent's special handling are documented in the proposal. - For `skill_new`: confirm the slug doesn't collide with any existing skill in `~/.claude/skills/`. If it does, refuse and ask user to rename. - For `skill_edit`: confirm the diff is append-only (no `-` lines that remove existing content) and that target SKILL.md exists. When auto-applying, ALSO re-verify the eligibility gate steps in §2 (cooldown, blacklist, byte cap) before any `Edit` call — never trust frontmatter alone. +- For `skill_edit` with `auto_apply_eligible: true`: confirm `contradiction_flag` is absent or null in frontmatter. Refuse auto-apply if `contradiction_flag` is set with any non-empty value (treat the agent's flag as a hard veto on auto-apply; user can still manually approve in walk-the-queue if they disagree with the heuristic). - For `memory`: confirm `# Proposed change` body starts with `---` frontmatter containing required fields `name`, `description`, `type`, `originSessionId`. Refuse if frontmatter missing — agent must redraft per the Memory drafting protocol. - Confirm `source_entries` is present in proposal frontmatter as a non-empty list (used for archive). Warn (do not refuse) if missing — legacy proposals from before v0.2.0 won't have it.