mirror of
https://github.com/lukaszraczylo/claude-adam.git
synced 2026-10-19 06:17:45 +00:00
feat(nudge): ReContext-grounded cue reactivation + analyst refinements
Analyse claude-adam through ReContext (arXiv:2607.02509): close the access!=utilization gap with query-conditioned nudge replay + analyst tweaks. - nudge: optional cue.cwd gates SessionStart replay (adam-nudge.mjs) and new mid-session reactivation on UserPromptSubmit (adam-observe.mjs, cwd-gated, once/session, ADAM_NO_MIDSESSION_NUDGE opt-out). - new adam-apply-nudge.mjs builds message + cue deterministically from proposal frontmatter (wired into SKILL.md, install.sh, pending-check). - adam-batch: optional --max-batches/--top-fraction output cap (default off), reports capped. - analyst: utility-lift rubric penalty + skill_utility_path fed to both stages; adaptive re-diagnose for unclear/top-severity clusters; cross-type associative pass (agents/adam.md, SKILL.md). - tests: 140 -> 144 (cue gate, batch cap, apply-nudge, mid-session).
This commit is contained in:
@@ -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 <proposal-path> [--home <path>]
|
||||
// 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 || "<none>"})` };
|
||||
}
|
||||
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 <proposal-path> [--home <path>]\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();
|
||||
}
|
||||
@@ -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 <jsonl-path>] [--min-entries N] [--min-sessions N]\n");
|
||||
process.stdout.write("usage: adam-batch.mjs [--input <jsonl-path>] [--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) {
|
||||
|
||||
@@ -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" <<EOF
|
||||
[{"kind":"dead_end_reminder","message":"adam: cue-match checkpoint","created_at":$cue_now,"expires_at_ts":$cue_future,"max_displays":3,"displays_used":0,"source_session":"sOLD","cue":{"cwd":"/proj/a"}}]
|
||||
EOF
|
||||
out_match=$(echo '{"hook_event_name":"SessionStart","session_id":"sNEW","cwd":"/proj/a"}' | NUDGE_RUN 2>&1 || true)
|
||||
cat > "$ROOT/active-nudges.json" <<EOF
|
||||
[{"kind":"dead_end_reminder","message":"adam: cue-miss checkpoint","created_at":$cue_now,"expires_at_ts":$cue_future,"max_displays":3,"displays_used":0,"source_session":"sOLD","cue":{"cwd":"/proj/a"}}]
|
||||
EOF
|
||||
out_miss=$(echo '{"hook_event_name":"SessionStart","session_id":"sNEW","cwd":"/proj/b"}' | NUDGE_RUN 2>&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" <<EOF
|
||||
[{"kind":"dead_end_reminder","message":"adam: midsession checkpoint alpha","created_at":111,"expires_at_ts":$ms_future,"max_displays":3,"displays_used":0,"source_session":"sOLD","cue":{"cwd":"/proj/x"}}]
|
||||
EOF
|
||||
}
|
||||
reset_state; ms_nudge
|
||||
ms1=$(echo '{"hook_event_name":"UserPromptSubmit","session_id":"sMS","cwd":"/proj/x","prompt":"hello"}' | HOOK_RUN 2>&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" ]
|
||||
|
||||
Reference in New Issue
Block a user