#!/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(); }