diff --git a/README.md b/README.md index 0062b88..131a362 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Watches the friction in your coding sessions, clusters the signals via an LLM an [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Version](https://img.shields.io/github/v/release/lukaszraczylo/claude-adam?label=version&color=blue)](https://github.com/lukaszraczylo/claude-adam/releases) -[![Tests](https://img.shields.io/badge/tests-140%20passing-brightgreen.svg)](./adam/tests/run-tests.sh) +[![Tests](https://img.shields.io/badge/tests-144%20passing-brightgreen.svg)](./adam/tests/run-tests.sh) [![Node](https://img.shields.io/badge/node-22%2B-339933.svg)](https://nodejs.org) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)]() diff --git a/adam/scripts/adam-apply-nudge.mjs b/adam/scripts/adam-apply-nudge.mjs new file mode 100644 index 0000000..efe406c --- /dev/null +++ b/adam/scripts/adam-apply-nudge.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// adam-apply-nudge.mjs — apply-path for `nudge` proposals. +// +// Reads a nudge proposal markdown file, validates the apply gate +// (type == "nudge" AND source_session present), and on success appends one +// entry to ~/.claude/adam/active-nudges.json. The message and the `cue` are +// built DETERMINISTICALLY in code from the proposal frontmatter — the analyst +// does not hand-author the JSON, so the cue (ReContext-style query-conditioning) +// is always populated when a cwd is known, not left to chance. +// +// Frontmatter consumed: type, source_session, cwd (optional — becomes cue.cwd), +// dead_end_count (optional — shapes the message), max_displays (optional, ≥1). +// +// CLI: adam-apply-nudge.mjs [--home ] +// Output: JSON one-liner on stdout: {"status":"applied"|"gated", "reason"|"path"} +// Exit: 0 on apply, 0 on gated, 1 on I/O or parse error. +// +// SKILL.md invokes this in the nudge auto-apply path. No code/memory/skill edits. + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import { parseFrontmatter } from "./adam-utils.mjs"; + +export { parseFrontmatter }; + +const SEVEN_DAYS_MS = 7 * 86400000; +const DEFAULT_MAX_DISPLAYS = 3; + +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 || "") !== "nudge") { + return { ok: false, reason: `type != nudge (got: ${fm.type || ""})` }; + } + if (!fm.source_session) { + return { ok: false, reason: "source_session missing in frontmatter" }; + } + return { ok: true }; +} + +export function buildEntry(fm, now = Date.now()) { + const cwd = fm.cwd ? String(fm.cwd) : ""; + const base = cwd ? cwd.split("/").filter(Boolean).pop() : ""; + const count = Number(fm.dead_end_count) > 0 ? Number(fm.dead_end_count) : 3; + const maxDisplays = Number(fm.max_displays) > 0 ? Number(fm.max_displays) : DEFAULT_MAX_DISPLAYS; + const message = `adam: a previous session hit ${count} dead-ends${base ? ` in ${base}` : ""} — consider a checkpoint before continuing.`; + const entry = { + kind: "dead_end_reminder", + message, + created_at: now, + expires_at_ts: now + SEVEN_DAYS_MS, + max_displays: maxDisplays, + displays_used: 0, + source_session: String(fm.source_session || ""), + }; + // The cue is what makes replay query-conditioned: bind the reminder to the + // project it came from so it only surfaces there (SessionStart + mid-session). + if (cwd) entry.cue = { cwd }; + return entry; +} + +export function appendNudge(nudgesPath, entry) { + let arr = []; + if (existsSync(nudgesPath)) { + try { + const parsed = JSON.parse(readFileSync(nudgesPath, "utf8")); + if (Array.isArray(parsed)) arr = parsed; + } catch { arr = []; } + } + arr.push(entry); + const dir = dirname(nudgesPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(nudgesPath, JSON.stringify(arr, null, 2)); + return arr.length; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help || !args.path) { + process.stdout.write("usage: adam-apply-nudge.mjs [--home ]\n"); + process.exit(args.help ? 0 : 1); + } + const claudeHome = args.home || join(homedir(), ".claude"); + const nudgesPath = join(claudeHome, "adam", "active-nudges.json"); + 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 count = appendNudge(nudgesPath, entry); + process.stdout.write(JSON.stringify({ status: "applied", path: nudgesPath, total: count, cue: entry.cue || null }) + "\n"); + process.exit(0); + } catch (e) { + process.stderr.write(`adam-apply-nudge error: ${e.message}\n`); + process.exit(1); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/adam/scripts/adam-batch.mjs b/adam/scripts/adam-batch.mjs index 06cd87b..429f2c6 100755 --- a/adam/scripts/adam-batch.mjs +++ b/adam/scripts/adam-batch.mjs @@ -83,7 +83,7 @@ function clusterKey(entry) { } function parseArgs(argv) { - const args = { input: null, minEntries: DEFAULT_MIN_ENTRIES, minSessions: DEFAULT_MIN_SESSIONS, help: false }; + const args = { input: null, minEntries: DEFAULT_MIN_ENTRIES, minSessions: DEFAULT_MIN_SESSIONS, maxBatches: null, topFraction: null, help: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--input" && i + 1 < argv.length) args.input = argv[++i]; @@ -95,6 +95,14 @@ function parseArgs(argv) { const n = Number(argv[++i]); if (!Number.isNaN(n) && n > 0) args.minSessions = n; } + else if (a === "--max-batches" && i + 1 < argv.length) { + const n = Number(argv[++i]); + if (!Number.isNaN(n) && n > 0) args.maxBatches = n; + } + else if (a === "--top-fraction" && i + 1 < argv.length) { + const n = Number(argv[++i]); + if (!Number.isNaN(n) && n > 0 && n < 1) args.topFraction = n; + } else if (a === "--help" || a === "-h") args.help = true; } return args; @@ -146,13 +154,28 @@ export function buildBatches(entries, opts = {}) { } batches.sort((a, b) => b.entry_count - a.entry_count); - return { batches, unbatched, total: (entries || []).length }; + + // Optional output cap (default: no cap — the full ranked list is returned). + // ReContext P2: relevance is concentrated, so surfacing only the top head keeps + // reviewer attention on the highest-value clusters. `topFraction` keeps the top + // ceil(f * N) after ranking (so the survivor count tracks corpus volume); + // `maxBatches` is a hard ceiling. The more restrictive of the two applies. + // Never silent: `capped` reports how many ranked batches were dropped by the cap. + let keepN = batches.length; + const frac = Number(opts.topFraction); + if (frac > 0 && frac < 1) keepN = Math.min(keepN, Math.max(1, Math.ceil(frac * batches.length))); + const maxB = Number(opts.maxBatches); + if (Number.isFinite(maxB) && maxB > 0) keepN = Math.min(keepN, maxB); + const capped = batches.length - keepN; + const kept = capped > 0 ? batches.slice(0, keepN) : batches; + + return { batches: kept, unbatched, capped, total: (entries || []).length }; } function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { - process.stdout.write("usage: adam-batch.mjs [--input ] [--min-entries N] [--min-sessions N]\n"); + process.stdout.write("usage: adam-batch.mjs [--input ] [--min-entries N] [--min-sessions N] [--max-batches N] [--top-fraction F]\n"); process.exit(0); } try { @@ -170,7 +193,7 @@ function main() { process.stderr.write("adam-batch: no input (use --input or pipe)\n"); process.exit(1); } - const result = buildBatches(entries, { minEntries: args.minEntries, minSessions: args.minSessions }); + const result = buildBatches(entries, { minEntries: args.minEntries, minSessions: args.minSessions, maxBatches: args.maxBatches, topFraction: args.topFraction }); process.stdout.write(JSON.stringify(result) + "\n"); process.exit(0); } catch (e) { diff --git a/adam/tests/run-tests.sh b/adam/tests/run-tests.sh index fcaef51..99bc35d 100755 --- a/adam/tests/run-tests.sh +++ b/adam/tests/run-tests.sh @@ -19,6 +19,7 @@ UPGRADE="$REAL_HOME/.claude/adam/scripts/adam-upgrade.mjs" BATCH="$REAL_HOME/.claude/adam/scripts/adam-batch.mjs" ROLLBACK="$REAL_HOME/.claude/adam/scripts/adam-rollback.mjs" SKILLUTIL="$REAL_HOME/.claude/adam/scripts/adam-skill-utility.mjs" +APPLYNUDGE="$REAL_HOME/.claude/adam/scripts/adam-apply-nudge.mjs" TMP_HOME="$(mktemp -d -t adam-test.XXXXXX)" trap 'rm -rf "$TMP_HOME"' EXIT INT TERM @@ -39,6 +40,7 @@ UPGRADE_RUN() { HOME="$TMP_HOME" node "$UPGRADE" "$@"; } BATCH_RUN() { HOME="$TMP_HOME" node "$BATCH" "$@"; } ROLLBACK_RUN(){ HOME="$TMP_HOME" node "$ROLLBACK" "$@"; } SKILLUTIL_RUN(){ HOME="$TMP_HOME" node "$SKILLUTIL" "$@"; } +APPLYNUDGE_RUN(){ HOME="$TMP_HOME" node "$APPLYNUDGE" "$@" --home "$TMP_HOME/.claude"; } PASS=0 FAIL=0 @@ -2176,6 +2178,114 @@ else fi rm -f "$SU_INPUT" +# --- Test 120: nudge cue.cwd gates surfacing on the current session's cwd --- +echo "Test 120: nudge cue.cwd — matching cwd prints, mismatch deferred (kept, not consumed)" +reset_state +cue_now=$(node -e 'console.log(Date.now())') +cue_future=$(node -e 'console.log(Date.now() + 7*86400000)') +cat > "$ROOT/active-nudges.json" <&1 || true) +cat > "$ROOT/active-nudges.json" <&1 || true) +cue_remaining=$(node -e 'const j=require("'"$ROOT"'/active-nudges.json");console.log(j.length)') +cue_used=$(node -e 'const j=require("'"$ROOT"'/active-nudges.json");console.log((j[0]||{}).displays_used)') +if echo "$out_match" | grep -q "cue-match checkpoint" \ + && ! echo "$out_miss" | grep -q "cue-miss checkpoint" \ + && [ "$cue_remaining" = "1" ] && [ "$cue_used" = "0" ]; then + echo " PASS: cue.cwd match prints; mismatch deferred without consuming a display"; PASS=$((PASS+1)) +else + echo " FAIL: match=$out_match miss=$out_miss remaining=$cue_remaining used=$cue_used"; FAIL=$((FAIL+1)) +fi +rm -f "$ROOT/active-nudges.json" + +# --- Test 121: adam-batch output cap (--max-batches / --top-fraction), default off --- +echo "Test 121: adam-batch caps ranked output and reports capped; no flag = no cap" +cap_input=$(cat <<'EOF' +{"ts":"2026-05-20T10:00:00Z","type":"correction","session":"s1","cwd":"/a","phrase":"alpha alpha"} +{"ts":"2026-05-20T11:00:00Z","type":"correction","session":"s2","cwd":"/a","phrase":"alpha alpha"} +{"ts":"2026-05-20T12:00:00Z","type":"retry_loop","session":"s1","cwd":"/a","tool":"Bash"} +{"ts":"2026-05-21T10:00:00Z","type":"tool_error_loop","session":"s1","cwd":"/a","fp":"ENOENT:x","count":3} +EOF +) +GET() { node -e 'let b="";process.stdin.on("data",d=>b+=d).on("end",()=>{const j=JSON.parse(b);console.log(eval("j."+process.argv[1]))})' "$1"; } +mb_out=$(echo "$cap_input" | BATCH_RUN --max-batches 2 2>/dev/null) +mb_bc=$(echo "$mb_out" | GET "batches.length"); mb_cap=$(echo "$mb_out" | GET "capped"); mb_top=$(echo "$mb_out" | GET "batches[0].signal_type") +none_out=$(echo "$cap_input" | BATCH_RUN 2>/dev/null) +none_bc=$(echo "$none_out" | GET "batches.length"); none_cap=$(echo "$none_out" | GET "capped") +tf_out=$(echo "$cap_input" | BATCH_RUN --top-fraction 0.5 2>/dev/null) +tf_bc=$(echo "$tf_out" | GET "batches.length"); tf_cap=$(echo "$tf_out" | GET "capped") +if [ "$mb_bc" = "2" ] && [ "$mb_cap" = "1" ] && [ "$mb_top" = "correction" ] \ + && [ "$none_bc" = "3" ] && [ "$none_cap" = "0" ] \ + && [ "$tf_bc" = "2" ] && [ "$tf_cap" = "1" ]; then + echo " PASS: --max-batches 2 → 2 batches (capped=1, head=correction); no flag → 3/capped=0; --top-fraction 0.5 → 2/capped=1"; PASS=$((PASS+1)) +else + echo " FAIL: mb($mb_bc,$mb_cap,$mb_top) none($none_bc,$none_cap) tf($tf_bc,$tf_cap)"; FAIL=$((FAIL+1)) +fi + +# --- Test 122: adam-apply-nudge builds a cue'd entry deterministically; gates non-nudge --- +echo "Test 122: adam-apply-nudge populates cue.cwd + generated message from frontmatter" +reset_state +rm -f "$ROOT/active-nudges.json" +cat > "$ROOT/proposals/nudge-prop-001.md" <<'EOF' +--- +type: nudge +source_session: sNUDGE +cwd: /Users/me/proj-alpha +dead_end_count: 4 +confidence: 4 +blast_radius: low +--- +# Nudge +checkpoint reminder +EOF +an_out=$(APPLYNUDGE_RUN "$ROOT/proposals/nudge-prop-001.md" 2>&1 || true) +an_status=$(echo "$an_out" | node -e 'let b="";process.stdin.on("data",d=>b+=d).on("end",()=>{try{console.log(JSON.parse(b).status)}catch{console.log("parse-error")}})') +an_cue=$(node -e 'const j=require("'"$ROOT"'/active-nudges.json");console.log((j[0]&&j[0].cue&&j[0].cue.cwd)||"")') +an_src=$(node -e 'const j=require("'"$ROOT"'/active-nudges.json");console.log((j[0]||{}).source_session||"")') +an_msg=$(node -e 'const j=require("'"$ROOT"'/active-nudges.json");console.log((j[0]||{}).message||"")') +cat > "$ROOT/proposals/nudge-prop-002.md" <<'EOF' +--- +type: memory +source_session: sX +--- +EOF +gate_status=$(APPLYNUDGE_RUN "$ROOT/proposals/nudge-prop-002.md" 2>&1 | node -e 'let b="";process.stdin.on("data",d=>b+=d).on("end",()=>{try{console.log(JSON.parse(b).status)}catch{console.log("parse-error")}})') +if [ "$an_status" = "applied" ] && [ "$an_cue" = "/Users/me/proj-alpha" ] && [ "$an_src" = "sNUDGE" ] \ + && echo "$an_msg" | grep -q "4 dead-ends in proj-alpha" && [ "$gate_status" = "gated" ]; then + echo " PASS: applied with cue.cwd + generated message; non-nudge proposal gated"; PASS=$((PASS+1)) +else + echo " FAIL: status=$an_status cue=$an_cue src=$an_src msg=$an_msg gate=$gate_status"; FAIL=$((FAIL+1)) +fi +rm -f "$ROOT/proposals/nudge-prop-00"*.md "$ROOT/active-nudges.json" + +# --- Test 123: mid-session nudge reactivation on UserPromptSubmit (cue.cwd gated) --- +echo "Test 123: adam-observe surfaces a cue-matched nudge mid-session — once/session, cwd-gated, opt-out" +ms_future=$(node -e 'console.log(Date.now() + 7*86400000)') +ms_nudge() { cat > "$ROOT/active-nudges.json" <&1 || true) +ms2=$(echo '{"hook_event_name":"UserPromptSubmit","session_id":"sMS","cwd":"/proj/x","prompt":"again"}' | HOOK_RUN 2>&1 || true) +reset_state; ms_nudge +ms_miss=$(echo '{"hook_event_name":"UserPromptSubmit","session_id":"sMS2","cwd":"/proj/y","prompt":"hello"}' | HOOK_RUN 2>&1 || true) +reset_state; ms_nudge +ms_off=$(echo '{"hook_event_name":"UserPromptSubmit","session_id":"sMS3","cwd":"/proj/x","prompt":"hello"}' | HOME="$TMP_HOME" ADAM_NO_MIDSESSION_NUDGE=1 node "$HOOK" 2>&1 || true) +if echo "$ms1" | grep -q "midsession checkpoint alpha" \ + && ! echo "$ms2" | grep -q "midsession checkpoint alpha" \ + && ! echo "$ms_miss" | grep -q "midsession checkpoint alpha" \ + && ! echo "$ms_off" | grep -q "midsession checkpoint alpha"; then + echo " PASS: surfaced once on cwd match; deduped 2nd prompt; silent on cwd mismatch + opt-out"; PASS=$((PASS+1)) +else + echo " FAIL: ms1=$ms1 || ms2=$ms2 || miss=$ms_miss || off=$ms_off"; FAIL=$((FAIL+1)) +fi +rm -f "$ROOT/active-nudges.json" + echo echo "Results: $PASS passed, $FAIL failed" [ "$FAIL" = "0" ] diff --git a/agents/adam.md b/agents/adam.md index d8ffb24..fa38426 100644 --- a/agents/adam.md +++ b/agents/adam.md @@ -17,6 +17,8 @@ The skill dispatches you in one of two stages (MOSS-inspired multi-stage pipelin If no `stage` is specified in the dispatch prompt, run **both stages sequentially** within a single pass (backward-compatible with pre-MOSS flow). +**Adaptive re-diagnose** (ReContext P5 — depth is task-dependent, not uniformly larger): a `stage=diagnose` dispatch may carry `redigest: ` and `prior_diagnoses_path`. When it does, re-diagnose ONLY those clusters: re-read each cluster's `context_window` (and up to ~30 extra transcript messages) *conditioned on the prior diagnosis*, and overwrite the trigger/action/mismatch/outcome for those `cluster_id`s in `/tmp/adam-diagnoses.json`. This is how an ambiguous cluster earns a second, deeper pass while clear clusters stay at one. Never re-diagnose a cluster more than once per `/reflect`. + ### Diagnose-stage output format When `stage=diagnose`, write `/tmp/adam-diagnoses.json` containing: @@ -161,12 +163,14 @@ The hook emits these `type` values into the journal: - `correction_free_streak`: cluster by `active_skills[0]`. Treat ≥3 streaks across ≥2 sessions naming the same skill as cross-session evidence. - `clean_recovery`: cluster by (`recovered_from`, `active_skills[0]`). A win cluster qualifies for `skill_edit` only when the named skill exists in `skills_root`. - `task_completed`: cluster by sorted `tool_kinds` tuple (the multi-tool recipe). Single entry qualifies for `skill_new` proposal (drafting protocol applies). Cross-session evidence requires ≥2 entries from distinct sessions with same tuple — without it, proposal queues, never auto-applies. Run the existing skill-overlap rule before drafting: if the recipe matches an existing skill's name/description tokens, route to `skill_edit` instead. +4b. **Cross-type associative pass** (ReContext P5 — surface evidence related to already-selected spans): after primary clustering, for each cluster gather active observations from *other* signal types that share the cluster's `session` or file basename (e.g. a `dead_end` and the `edit_churn` on the same file/session that led to it). Attach them to the cluster as `associated_entries` — do NOT merge cluster keys or re-count them for the rubric — so the diagnosis can reconstruct causation across signal types instead of from one isolated type. Cap at ~8 associated entries per cluster, nearest-in-time first. 5. **Multi-axis correlation**: for each session that produced ≥2 distinct struggle types (`tool_error_loop`, `dead_end`, `weak_agent`, `retry_loop`, `edit_churn`, `build_loop`, `silent_drift`, `file_reread`, `error_after_recovery`), tag clusters from that session as `multi_axis: true`. This grants +1 confidence at scoring. 5b. **Skill-attribution sub-clustering**: after primary clustering (step 4), for every struggle cluster (`tool_error_loop`, `dead_end`, `weak_agent`, `retry_loop`, `edit_churn`, `build_loop`, `silent_drift`, `file_reread`, `error_after_recovery`) that contains entries with non-empty `active_skills[0]`: - Split into per-skill sub-clusters keyed on `active_skills[0]`. Entries with empty `active_skills` stay in the original cluster. - If a sub-cluster has ≥3 entries AND names a skill that exists in `skills_root`, mark it as a candidate for `skill_edit` (struggle-driven variant; see "Struggle-driven `skill_edit` eligibility"). Otherwise treat the parent cluster normally. - The umbrella cluster (cross-skill) still emits its usual proposal type (memory, etc.) — sub-clusters do NOT replace it, they supplement it. + - **Utility cross-check**: for every `skill_edit`/`skill_new` candidate, look up the named skill in `skill_utility_path` (the pre-computed `adam-skill-utility.mjs --json` report). If the skill is reliably below baseline (negative `lift` + worst-tier `wLB` at ≥ its `--min` events), apply the utility-lift penalty (see rubric) — do NOT pile more text onto a skill that already correlates with friction; prefer a `memory` or a scoped new skill instead. 6. For each cluster qualifying under the rubric — ≥3 occurrences across ≥2 sessions, OR (for struggle types) ≥1 entry within a single session, OR (for `correction`) ≥3 occurrences across ≥2 cwds: a. If cluster topic matches a rejected idea via the rejected-ideas fuzzy set (≥2 token overlap with rejection's `# Why`), skip with reason `"rejected-similar"`. b. Pull ~20 messages of transcript context from `transcripts_root` to enrich. Never read full transcripts. @@ -442,6 +446,7 @@ Sum: - Cluster severity-sum ≥ 32: **+1** *(additive — a severity-sum of 32 gets +1 from the previous bullet AND +1 here, total +2.)* - Skill-attributed sub-cluster (≥3 entries naming the same `active_skills[0]` that exists in `skills_root`): **+1** - Type-bias penalty from feedback loop (≥3 rejections, applied:rejected ratio <1:2 for this `type`): **-1** +- Utility-lift penalty: proposal is a `skill_edit` (or a `skill_new` whose recipe duplicates an existing skill's scope) whose target skill is ranked reliably below baseline in `skill_utility_path` — negative `lift` AND worst-tier `wLB`, with event count ≥ its `--min`: **-2** *(adding text to a skill that already correlates with friction raises access while likely lowering utilization — SkillsInjector, cited in `adam-skill-utility.mjs`. Read the skill's row as a review prompt, not proof of causation.)* - Diagnosis flags `Mismatch: unclear` (causation could not be reconstructed from transcript context): **-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** @@ -471,24 +476,23 @@ Sum: ### `nudge` proposals -A `nudge` proposal does NOT modify any persistent rubric/skill/memory artifact. Its sole side-effect is to append an entry to `~/.claude/adam/active-nudges.json` so the next SessionStart hook surfaces a one-line reminder to the user in a *different* session. +A `nudge` proposal does NOT modify any persistent rubric/skill/memory artifact. Its sole side-effect is to append an entry to `~/.claude/adam/active-nudges.json` so a future session surfaces a one-line checkpoint reminder — at SessionStart (`adam-nudge.mjs`) and mid-session on the matching cwd (`adam-observe.mjs` on UserPromptSubmit). Trigger: `adam-nudge-eligibility.mjs --session ` returns `eligible: true` (i.e. ≥3 `dead_end` entries inside a single session). Distinguished from `skill_edit` precisely because there is no learning artifact to mutate — the action surfaces a checkpoint reminder, not a behavior change. -`active-nudges.json` entry shape (created by the skill at apply time): +Required proposal frontmatter (the entry is built deterministically by `adam-apply-nudge.mjs` — do NOT hand-author the JSON message/cue): -```json -{ - "kind": "dead_end_reminder", - "message": "adam: previous session hit 3 dead_ends — consider a checkpoint before continuing.", - "created_at": , - "expires_at_ts": , - "max_displays": 3, - "displays_used": 0, - "source_session": "" -} +```yaml +type: nudge +source_session: +cwd: +dead_end_count: +confidence: +blast_radius: low ``` +The helper writes `{kind, message, created_at, expires_at_ts: now+7d, max_displays: 3, displays_used: 0, source_session, cue: {cwd}}`. The `cue.cwd` binds the reminder to the project it came from: both surfaces (SessionStart + mid-session) fire the nudge only when the session's cwd matches, deferring it elsewhere without consuming a display (ReContext-style query-conditioned replay). Omit `cwd` from frontmatter only for a deliberately project-agnostic reminder (then the nudge has no cue and surfaces unconditionally at SessionStart, as legacy). + ### `reinforcement` proposals A `reinforcement` proposal is logged when `adam-score.mjs` reports `count >= 3` clean `task_completed` events citing the same `active_skills[0]` slug. Frontmatter MUST include `skill_slug`, `count`, `source_session`, `confidence`, `blast_radius: low`. Apply gate (`confidence >= 4 AND blast_radius == low`) is identical to the `memory` gate — when both hold, the skill invokes `~/.claude/adam/scripts/adam-apply-reinforcement.mjs ` which appends one JSON line to `~/.claude/adam/reinforcements.jsonl` of shape `{ts, skill_slug, count, source_session}`. No code/memory/skill modifications either side of the gate — reinforcements are a positive-only ledger, separate from `ab-tracking.jsonl` (A/B intentionally does NOT measure positive signals to avoid skewing regression detection). diff --git a/hooks/adam-nudge.mjs b/hooks/adam-nudge.mjs index f1be2ae..a7d7724 100755 --- a/hooks/adam-nudge.mjs +++ b/hooks/adam-nudge.mjs @@ -3,7 +3,9 @@ // 1. Pending proposals (≥3 queued in adam/proposals/). // 2. Cross-session nudges (entries in adam/active-nudges.json whose // source_session differs from the current session and that haven't -// expired or exhausted their max_displays). +// expired or exhausted their max_displays). An entry may carry an optional +// `cue.cwd`; when set, it only surfaces in a session whose cwd matches +// (otherwise it is deferred, not consumed). // 3. Pending local-edit upgrades (`.adam-new` sidecars). // 4. New-release notice: if a newer GitHub release exists than the installed // `.version`, print a notify-only one-line update prompt. Cached + checked @@ -47,6 +49,7 @@ const PENDING_CHECK_PATHS = [ "adam/scripts/adam-score.mjs", "adam/scripts/adam-ab-measure.mjs", "adam/scripts/adam-apply-reinforcement.mjs", + "adam/scripts/adam-apply-nudge.mjs", "adam/scripts/adam-utils.mjs", "adam/scripts/adam-batch.mjs", "adam/scripts/adam-rollback.mjs", @@ -59,13 +62,13 @@ function readJson(path, fallback) { } function readSessionInput() { - // SessionStart payload arrives on stdin; capture session_id if present. - // We don't block on stdin — best-effort, non-blocking. + // SessionStart payload arrives on stdin; return the parsed object so callers + // can read both session_id and cwd. We don't block on stdin — best-effort. try { const buf = readFileSync(0, "utf8"); if (!buf) return null; const parsed = JSON.parse(buf); - return parsed && typeof parsed.session_id === "string" ? parsed.session_id : null; + return parsed && typeof parsed === "object" ? parsed : null; } catch { return null; } } @@ -79,7 +82,7 @@ function emitProposalReminder() { } catch { /* proposals dir absent → silent */ } } -function emitActiveNudges(currentSession) { +function emitActiveNudges(currentSession, currentCwd) { if (!existsSync(NUDGES_FILE)) return; const raw = readJson(NUDGES_FILE, null); if (!Array.isArray(raw)) return; @@ -99,6 +102,16 @@ function emitActiveNudges(currentSession) { kept.push(entry); continue; } + // Cue gate (optional, additive): a nudge may carry `cue.cwd` binding it to + // the project it originated in. When set and the current session's cwd does + // not match, defer it — keep the entry, do NOT consume a display — instead of + // surfacing an irrelevant reminder. Nudges without a cue print unconditionally, + // exactly as before (ReContext P5/P7: replay should be conditioned on the cue). + if (entry.cue && typeof entry.cue === "object" && entry.cue.cwd && + currentCwd && entry.cue.cwd !== currentCwd) { + kept.push(entry); + continue; + } if (typeof entry.message === "string" && entry.message) { process.stdout.write(entry.message + "\n"); const nextUsed = used + 1; @@ -201,14 +214,16 @@ async function emitUpdateCheck() { } async function main() { - const stdinSession = readSessionInput(); + const payload = readSessionInput(); + const stdinSession = payload && typeof payload.session_id === "string" ? payload.session_id : null; const stateSession = (() => { const st = readJson(STATE_FILE, null); return st && typeof st.session_id === "string" ? st.session_id : null; })(); const currentSession = stdinSession || stateSession || ""; + const currentCwd = (payload && typeof payload.cwd === "string" && payload.cwd) || process.cwd(); emitProposalReminder(); - emitActiveNudges(currentSession); + emitActiveNudges(currentSession, currentCwd); emitPendingUpgrades(); await emitUpdateCheck(); } diff --git a/hooks/adam-observe.mjs b/hooks/adam-observe.mjs index 13e3e9d..386a65b 100755 --- a/hooks/adam-observe.mjs +++ b/hooks/adam-observe.mjs @@ -289,6 +289,37 @@ function errorFingerprint(toolResponse) { if (!normalized) return null; return `raw:${djb2(normalized)}`; } +// Mid-session nudge reactivation (ReContext P5/P7): on UserPromptSubmit, surface a +// stored active-nudge whose `cue.cwd` matches the current directory, so a deferred +// reminder lands when the user is actually working in that project instead of only +// at SessionStart. Read-only — SessionStart (adam-nudge.mjs) owns display accounting; +// this path surfaces each matching nudge at most once per session (state.surfaced_nudges) +// and respects expiry + max_displays. Opt out with ADAM_NO_MIDSESSION_NUDGE. +// UserPromptSubmit hook stdout is injected into the session context, so this is the +// reliable surface (unlike PostToolUse). Any failure is swallowed — never block a prompt. +function surfaceMatchingNudges(state, cwd) { + if (process.env.ADAM_NO_MIDSESSION_NUDGE || !cwd) return; + const nudgesPath = join(ROOT, "active-nudges.json"); + let raw; + try { raw = JSON.parse(readFileSync(nudgesPath, "utf8")); } catch { return; } + if (!Array.isArray(raw)) return; + const now = Date.now(); + if (!Array.isArray(state.surfaced_nudges)) state.surfaced_nudges = []; + for (const n of raw) { + if (!n || typeof n !== "object" || typeof n.message !== "string" || !n.message) continue; + if (Number(n.expires_at_ts || 0) <= now) continue; + const max = Number(n.max_displays || 0); + if (max > 0 && Number(n.displays_used || 0) >= max) continue; + const cue = n.cue && typeof n.cue === "object" ? n.cue : null; + if (!cue || !cue.cwd || cue.cwd !== cwd) continue; // cue-conditioned on cwd + const id = String(n.created_at || n.message); + if (state.surfaced_nudges.includes(id)) continue; + process.stdout.write(n.message + "\n"); + state.surfaced_nudges.push(id); + if (state.surfaced_nudges.length > 50) state.surfaced_nudges.shift(); + } +} + function resetFrictionCounters(state) { state.tools_since_user = 0; @@ -315,6 +346,7 @@ function resetSessionLocal(state) { state.task_tool_kinds = {}; state.task_tool_count = 0; state.task_corrections = 0; + state.surfaced_nudges = []; } function ensureStateDefaults(state) { @@ -339,6 +371,7 @@ function ensureStateDefaults(state) { if (!Array.isArray(state.recentRecoveries)) state.recentRecoveries = []; if (typeof state.session_post_count !== "number") state.session_post_count = 0; if (!Array.isArray(state.context_ring)) state.context_ring = []; + if (!Array.isArray(state.surfaced_nudges)) state.surfaced_nudges = []; } function main() { @@ -364,6 +397,7 @@ function main() { if (event === "UserPromptSubmit") { const prompt = (input.prompt || "").slice(0, 200); pushContext(state, { event: "user", prompt: excerpt(prompt, CONTEXT_EXCERPT_LEN), ts }); + surfaceMatchingNudges(state, cwd); if (isCorrection(prompt)) { const last = state.tool_window[state.tool_window.length - 1] || {}; appendJournal({ diff --git a/install.sh b/install.sh index c16f058..46cf247 100755 --- a/install.sh +++ b/install.sh @@ -126,7 +126,7 @@ copy_file "$SRC/adam/scripts/adam-archive.mjs" "$DEST/adam copy_file "$SRC/adam/scripts/adam-upgrade.mjs" "$DEST/adam/scripts/adam-upgrade.mjs" # v0.3.3 helper scripts — invoked from SKILL.md / hooks / analyst flow for _adam_script in adam-utils adam-window adam-explain adam-nudge-eligibility adam-cooldown \ - adam-score adam-ab-measure adam-apply-reinforcement adam-batch adam-rollback \ + adam-score adam-ab-measure adam-apply-reinforcement adam-apply-nudge adam-batch adam-rollback \ adam-skill-utility; do copy_file "$SRC/adam/scripts/${_adam_script}.mjs" \ "$DEST/adam/scripts/${_adam_script}.mjs" diff --git a/skills/adam-self-improvement/SKILL.md b/skills/adam-self-improvement/SKILL.md index 8dfce85..883178c 100644 --- a/skills/adam-self-improvement/SKILL.md +++ b/skills/adam-self-improvement/SKILL.md @@ -107,6 +107,7 @@ Inputs: - batches_path: /tmp/adam-batches.json # pre-clustered evidence batches - scores_path: /tmp/adam-scores.json - ab_regressions_path: /tmp/adam-ab-regressions.json +- skill_utility_path: /tmp/adam-skill-utility.json # per-skill utility Δ (worst-first); already computed in pre-analysis - journal_path: ~/.claude/adam/journal.jsonl # raw — fallback only - state_path: ~/.claude/adam/state.json - usage_path: ~/.claude/adam/usage.json @@ -131,6 +132,8 @@ Wait for return. If all diagnoses are removed or the file is missing/empty, print "adam: no valid diagnoses — nothing to implement" and skip to §6. +**Adaptive re-diagnose** (ReContext P5 — spend depth only where it is needed): after validation, collect validated diagnoses whose `mismatch` is `unclear` OR whose cluster severity-sum is top-tier (≥32, per `scores_path`); take at most the top 3 by severity. If any exist, re-dispatch **one** more `stage=diagnose` pass carrying `redigest: ` and `prior_diagnoses_path: /tmp/adam-diagnoses.json` (the analyst refines only those clusters in place), then re-run the inter-stage validation above on the refreshed file. Do this at most once per `/reflect`; clear clusters are never re-diagnosed. If none qualify, go straight to Stage 2. + **Stage 2 — Implement**: Use the Agent tool with `subagent_type: "adam"` and prompt: ``` @@ -143,6 +146,7 @@ Inputs: - windowed_journal_path: /tmp/adam-windowed-journal.jsonl - scores_path: /tmp/adam-scores.json - ab_regressions_path: /tmp/adam-ab-regressions.json +- skill_utility_path: /tmp/adam-skill-utility.json # per-skill utility Δ — drives the utility-lift rubric penalty - state_path: ~/.claude/adam/state.json - usage_path: ~/.claude/adam/usage.json - proposals_dir: ~/.claude/adam/proposals/ @@ -200,7 +204,13 @@ For each id that passed verification: - Apply the change: - **For `skill_new`**: `mkdir -p ~/.claude/skills//`, then `Write` the proposal's `# Proposed change` body to `~/.claude/skills//SKILL.md`. After write, print: "skill `` written to `~/.claude/skills//SKILL.md` — activates immediately — Claude Code v2.1.0+ auto-hot-reloads user-level skills, no restart needed." - **For `memory`**: `Write` the proposal's `# Proposed change` body (which MUST include the auto-memory frontmatter — see "Memory drafting protocol" in `agents/adam.md`) to the path in `target`. Then update `MEMORY.md` index with a one-line pointer. - - **For `nudge`**: low-blast auto-apply path. Single-session evidence is sufficient — skip the cross-session gate. Append a new entry to `~/.claude/adam/active-nudges.json` (create the file with `[]` if absent) with shape `{kind, message, created_at: , expires_at_ts: , max_displays: 3, displays_used: 0, source_session: }`. Do NOT modify any skill, memory, agent, or CLAUDE.md. Tell user: "nudge queued — surfaces on next SessionStart in a different session (expires in 7 days)." + - **For `nudge`**: low-blast auto-apply path. Single-session evidence is sufficient — skip the cross-session gate. Apply by invoking the helper (do NOT hand-write the JSON — the helper builds the message and the `cue` deterministically from frontmatter): + + ```bash + node ~/.claude/adam/scripts/adam-apply-nudge.mjs ~/.claude/adam/proposals/-*.md --home ~/.claude + ``` + + The proposal frontmatter MUST carry `type: nudge`, `source_session`, and `cwd` (the cluster's dominant `cwd`, from `adam-batch.mjs`'s per-batch `cwds`), plus `dead_end_count`. The helper appends one entry to `~/.claude/adam/active-nudges.json` (creating it if absent) with `cue: {cwd}` populated — so the reminder is query-conditioned: it surfaces at SessionStart *and* mid-session (via `adam-observe.mjs`) only when the user is working in that codebase. Output: `{"status":"applied"|"gated", ...}` — on `gated` leave the proposal in `proposals/`, on `applied` continue to archive. Do NOT modify any skill, memory, agent, or CLAUDE.md. Tell user: "nudge queued — surfaces when you return to `` (expires in 7 days)." - **For `reinforcement`**: gated by `confidence >= 4 AND blast_radius == low` (same as memory). Apply by invoking the helper: ```bash