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
+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({