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