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:
2026-07-06 13:14:17 +01:00
parent 4d1276a73f
commit 2b6e8da885
9 changed files with 337 additions and 26 deletions
+1 -1
View File
@@ -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)]()
+115
View File
@@ -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();
}
+27 -4
View File
@@ -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) {
+110
View File
@@ -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" ]
+16 -12
View File
@@ -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: <cluster_id[,…]>` 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 <id>` 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": <ms>,
"expires_at_ts": <ms now + 7 days>,
"max_displays": 3,
"displays_used": 0,
"source_session": "<originating session_id>"
}
```yaml
type: nudge
source_session: <originating session_id>
cwd: <cluster's dominant cwd — becomes cue.cwd; adam-batch.mjs aggregates `cwds` per batch>
dead_end_count: <N ≥ 3 from adam-nudge-eligibility.mjs>
confidence: <int>
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 <proposal-path>` 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).
+22 -7
View File
@@ -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();
}
+34
View File
@@ -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({
+1 -1
View File
@@ -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"
+11 -1
View File
@@ -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: <their cluster_ids>` 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/<slug>/`, then `Write` the proposal's `# Proposed change` body to `~/.claude/skills/<slug>/SKILL.md`. After write, print: "skill `<slug>` written to `~/.claude/skills/<slug>/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: <now_ms>, expires_at_ts: <now_ms + 7*86400000>, max_displays: 3, displays_used: 0, source_session: <session_id from proposal>}`. 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/<id>-*.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 `<cwd basename>` (expires in 7 days)."
- **For `reinforcement`**: gated by `confidence >= 4 AND blast_radius == low` (same as memory). Apply by invoking the helper:
```bash