Hooks
What v-songbird-foreman runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
$ npx -y skills add V-Songbird/foreman --agent claude-codeShips with v-songbird-foreman. Installing the plugin gets these hooks.
What fires, and when
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
- Matches
^(startup|clear)$node "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.js"
TaskCreated
node "${CLAUDE_PLUGIN_ROOT}/hooks/task-created.js"
TaskCompleted
node "${CLAUDE_PLUGIN_ROOT}/hooks/task-completed.js"
PreToolUse
- Matches
^(Edit|Write)$node "${CLAUDE_PLUGIN_ROOT}/hooks/guard-roadmap-edit.js"
PostToolUse
- Matches
^(Bash|PowerShell)$node "${CLAUDE_PLUGIN_ROOT}/hooks/post-commit.js"node "${CLAUDE_PLUGIN_ROOT}/hooks/context-fill.js" - Matches
^(Read|Edit|Write)$node "${CLAUDE_PLUGIN_ROOT}/hooks/ledger-recall.js"
Where it lives
- hooks/codex-task.jsGitHub
- hooks/context-fill.jsRunsGitHub
Read the script
"use strict"; // context-fill.js — PostToolUse hook on Bash/PowerShell, registered for // Claude Code only (hooks/hooks.json). Codex has no stable context-fill input // and its transcript is a different format that must never be read as Claude // usage, so hooks/codex-hooks.json does not register this file and main() // stays silent if a Codex payload reaches it anyway. // // The destination question ("How do you want to run this?") recommends one // option, and one of the facts that should move that recommendation is how // much room this session has left: a nearly-full session is the worst place // to execute a fresh task in. Nothing in a hook payload carries that number // — `context_window.used_percentage` exists only in the statusline JSON, // which a skill cannot invoke — so this hook derives it the one other way // available: the transcript's own last assistant `usage` block, summed. // // It speaks only when the session is at or above CONTEXT_SHARE of a // window the user actually configured, and only after one of the scripts a // crafting flow runs *before* that question. Below the line, or with no // configured window to measure against, it writes nothing and costs // nothing, which is the common case. The number is a fact for the flow to // apply; the rule that acts on it lives in skills/roadmap/destination-question.md, so the // wording of the recommendation stays in one place. // // Every failure path here is silence. A missing transcript, an unreadable // one, a format change in the .jsonl (officially unstable) — none of them // may break a pick, so the whole read is best-effort and the flow simply // falls back to its default recommendation. const fs = require("fs"); const os = require("os"); const path = require("path"); const { readInput, hostName } = require("./lib"); const WATCHED_TOOLS = new Set(["Bash", "PowerShell"]); // The scripts a crafting flow runs before it asks the destination question: // `roadmap.js` (pick's menu and its selected-entry read), `resolve-symbols.js` // (craft-prompt's pre-question read), and `safe-commit.js` (the tree probe the // destination question itself takes before offering the split). Anything else // — including `craft-handoff.js`, which runs *after* the answer — is past the // moment this number could change and is not worth a line. const PRE_QUESTION_SCRIPT = /scripts[\\/](roadmap|resolve-symbols|safe-commit)\.js/; // The share of the window at which executing in this session stops being the // obvious default. Deliberately not configurable: the window size underneath // it is already an assumption, so a second knob would only add precision this // number does not have. const CONTEXT_SHARE = 0.25; // The range `/autocompact` accepts, and so the only values worth believing. const WINDOW_MIN = 100000; const WINDOW_MAX = 1000000; // Read far enough back to clear the largest single tool result a turn can // hold, so the newest assistant record is inside the slice. const TAIL_BYTES = 1024 * 1024; function windowValue(raw) { const n = Number(raw); return Number.isFinite(n) && n >= WINDOW_MIN && n <= WINDOW_MAX ? n : null; } // How full the window may get before the host compacts on its own. The env // var wins over the setting `/autocompact` saves, matching the host's own // precedence. Null when the user set neither — no hook input carries the real // window size, so there is nothing else to read. function autoCompactWindow() { const fromEnv = windowValue(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW); if (fromEnv) return fromEnv; const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); try { const settings = JSON.parse(fs.readFileSync(path.join(dir, "settings.json"), "utf-8")); return windowValue(settings.autoCompactWindow); } catch { return null; // absent, unreadable, or not JSON — treat as unset } } // Context occupancy ≈ the most recent main-thread request: the last // non-sidechain assistant `usage`, summed across every numeric `*_tokens` // field (uncached input + cache read + cache write + output). A sidechain // record belongs to a subagent's own window, not this one. Null when the // transcript holds no usable record. function currentTokens(transcriptPath) { if (!transcriptPath || typeof transcriptPath !== "string") return null; let lines; try { const fd = fs.openSync(transcriptPath, "r"); try { const size = fs.fstatSync(fd).size; const start = Math.max(0, size - TAIL_BYTES); const buf = Buffer.alloc(size - start); fs.readSync(fd, buf, 0, buf.length, start); lines = buf.toString("utf-8").split("\n"); if (start > 0) lines = lines.slice(1); // drop the partial first line } finally { fs.closeSync(fd); } } catch { return null; } for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; if (!line) continue; let entry; try { entry = JSON.parse(line); } catch { continue; // torn or partial line } if (!entry || entry.type !== "assistant" || entry.isSidechain) continue; const usage = entry.message && entry.message.usage; if (!usage || typeof usage !== "object") continue; let sum = 0; for (const key of Object.keys(usage)) { if (/_tokens$/.test(key) && typeof usage[key] === "number") sum += usage[key]; } return sum; } return null; } // The one decision: is this session full enough that the destination question // should hear about it? Returns null for silence, or the numbers the message // is built from. An unconfigured window is silence: window sizes range from // 100k to 1M, so any stand-in number is wrong by up to 5x in one direction or // the other, and a wrong reading recommends a fresh session to someone who has // most of their window left. Silence just leaves the default recommendation // standing, which is the right answer when the share is unknowable. function assess(tokens, configuredWindow) { if (!Number.is - hooks/guard-roadmap-edit.jsRunsGitHub
Read the script
#!/usr/bin/env node "use strict"; const fs = require("fs"); const path = require("path"); const { readInput, projectDir, pluginDir, touchedPaths } = require("./lib"); const PLUGIN_ROOT = pluginDir(); const SCRIPT_PATH = path.join(PLUGIN_ROOT, "scripts", "roadmap.js"); const WATCHED_TOOLS = new Set(["apply_patch", "Edit", "Write"]); // ROADMAP.jsonl is a basename-only match, deliberately not path-aware — a // project having some unrelated file literally named ROADMAP.jsonl elsewhere // isn't worth distinguishing from the real one at this scale. // [Foreman: 132] archive.jsonl is the same file in a later life: the archived // half of the roadmap, written by the same CLI (archive/restore), so a hand // edit bypasses the same invariants. Unlike the roadmap, though, the name is // generic — so only this project's own copy counts, and some other tool's // archive.jsonl is not Foreman's to deny. const PROJECT_ARCHIVE = ".foreman/archive.jsonl"; // The lesson ledger, guarded for the same reason and on the same terms: the // CLI owns the append (inside the close's lock, with the format marker and // the 500-char refusal), and `notes.jsonl` is far too generic a name to match // on the basename alone. const PROJECT_NOTES = ".foreman/notes.jsonl"; // Which CLI verbs to name when the deny message fires, per file — a generic // "use the CLI" leaves the caller to guess which of thirteen verbs applies. const SCOPED_HINT = { [PROJECT_NOTES]: 'Record a lesson by passing `"lesson"` on that entry\'s `update-status` close, ' + "and read the store back with the `notes` verb.", }; function projectRelative(filePath, root) { return path .relative(root, path.resolve(root, String(filePath))) .replaceAll("\\", "/") .toLowerCase(); } /** The project-relative path this edit targets, or null when it targets none. */ function guardedPath(filePath, root) { if (!filePath) return null; const base = path.basename(String(filePath)).toLowerCase(); if (base === "roadmap.jsonl") return "ROADMAP.jsonl"; if (base !== "archive.jsonl" && base !== "notes.jsonl") return null; const rel = projectRelative(filePath, root); return rel === PROJECT_ARCHIVE || rel === PROJECT_NOTES ? rel : null; } function main(data = readInput()) { if (!WATCHED_TOOLS.has(data.tool_name)) return; const root = projectDir(data); // A project that never ran init has no roadmap to guard, and nothing // Foreman is entitled to say about it. if (!fs.existsSync(path.join(root, "ROADMAP.jsonl"))) return; const filePath = touchedPaths(data).find((file) => guardedPath(file, root) !== null); if (!filePath) return; const guarded = guardedPath(filePath, root); const scoped = SCOPED_HINT[guarded]; const payload = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `Foreman: direct ${data.tool_name} of ` + `${path.basename(filePath)} is blocked. Use ` + `node "${SCRIPT_PATH}" instead (add/update-status/annotate/update-deps/` + "correct/reassign-id/archive/restore/list/next-candidates/notes/" + "check-duplicate/doctor/migrate — run with --help for usage). " + (scoped ? `${scoped} ` : "") + "It enforces id computation and parse-before/after-write; a hand " + "edit bypasses both. If the file is corrupt and the CLI itself " + "can't read it, inspect the corruption and use an explicitly scoped shell repair. " + "This hook covers apply_patch and file edit tools; it does not parse arbitrary shell writes.", }, }; try { process.stdout.write(Buffer.from(JSON.stringify(payload), "utf-8")); } catch { // ignore } } if (require.main === module) { try { main(); } catch { process.exit(0); } } module.exports = { main, guardedPath }; - hooks/ledger-recall.jsRunsGitHub
Read the script
#!/usr/bin/env node "use strict"; // PostToolUse — the ledger's read-back half. Touching a file surfaces what // the project already recorded about it, at the moment it is about to change, // so a constraint is read before it's violated instead of discovered after. // Two things can surface: the documents its `[Foreman: 019]` anchors name // (see scripts/roadmap.js's anchorIdsIn), and the lessons closed tasks // recorded about the file itself. The handoff serves the same two facts one // step earlier, at dispatch; this catches the file nobody planned to touch. // // The document channel fires on every Read/Edit/Write regardless of // `ledger.enabled` -- once an anchor comment exists in a codebase it should // stay findable even in a project that never opted in. Only `dir` (default // docs/foreman) is taken from ledger-config, and only to read: Foreman never // writes a document there. // // Silence is the overwhelmingly common path (fires on every Read) and must // be near-free: no anchors, no matching doc file, or an unreadable/missing/ // binary target all produce zero stdout bytes, not just an empty block. const fs = require("fs"); const os = require("os"); const path = require("path"); const { readInput, projectDir, touchedPaths } = require("./lib"); const crypto = require("crypto"); const { anchorIdsIn } = require("../scripts/roadmap"); const { readLedger } = require("../scripts/ledger-config"); const ledger = require("../scripts/ledger"); const noteStaleness = require("../scripts/note-staleness"); const WATCHED_TOOLS = new Set(["apply_patch", "Read", "Edit", "Write"]); const MAX_BYTES = 512 * 1024; // [Foreman: 247] The lesson channel's own bounds, deliberately tighter than // the handoff's. This hook fires on every Read, so the whole branch has to be // worth its cost at the moment it actually finds something: at most two // lessons, and a git budget small enough that a pathological store cannot // spend the hook's five-second timeout. const NOTE_LIMIT = 2; const NOTE_GIT_BUDGET = 6; // Reads at most the first MAX_BYTES of `filePath`. null on anything that // isn't a readable regular file (missing, directory, permission-denied) -- // the caller treats null as "exit silently", same bucket as binary/unreadable. function readCapped(filePath) { let fd; try { fd = fs.openSync(filePath, "r"); } catch { return null; } try { const buf = Buffer.alloc(MAX_BYTES); const bytesRead = fs.readSync(fd, buf, 0, MAX_BYTES, 0); return buf.toString("utf-8", 0, bytesRead); } catch { return null; // e.g. EISDIR } finally { try { fs.closeSync(fd); } catch { // ignore } } } // Relative doc path for display, forward-slash regardless of how `dir` or // the OS path separator is spelled. function relDocPath(dir, id) { const segs = String(dir).split(/[\\/]+/).filter(Boolean); segs.push(`${id}.md`); return segs.join("/"); } /** * [Foreman: 247] The lessons recorded about the file being touched, newest * first, each with the freshness label the ledger never serves a line without. * * Returns [] on every quiet path -- feature off, empty store, no match -- so * the caller's cost when nothing matches is one stat and one small read. */ function lessonsFor(root, relPath) { // One stat before the config read: a project that has recorded nothing is // the common case, and it should cost exactly this much. if (!fs.existsSync(ledger.notesPath(root))) return []; if (!readLedger(root).enabled) return []; const { records, error } = ledger.read(root); if (error || !records.length) return []; const wanted = ledger.normalizeStorePath(relPath); if (!wanted) return []; // Exact file, never the folder-prefix overlap the handoff uses. The handoff // is answering "what has anyone learned near this work"; this is answering // "what is recorded about the file in front of you", and a whole directory's // lessons on every Read is noise, not retrieval. const matched = [...records] .reverse() .filter((record) => (record.paths || []).some((stored) => stored === wanted)); if (!matched.length) return []; const budget = noteStaleness.newBudget(NOTE_GIT_BUDGET); return noteStaleness .resolveAll(root, matched.slice(0, NOTE_LIMIT), budget) .map(({ record, label }) => `${record.lesson} ${label}`); } function lessonMessage(relPath, lessons) { const bullets = lessons.map((line) => `- ${line}`).join("\n"); return ( `Recorded about ${relPath} by earlier closed tasks -- recorded claims, ` + `verify against the code:\n${bullets}` ); } function contextMessage(relPaths) { return ( `This file carries decision docs (${relPaths.join(", ")}) -- read them ` + "before changing what they govern." ); } // Once-per-session-per-file-per-id-set latch, same shape as task-completed's // shouldGate: unreadable/missing state means "never emitted yet" (fail open // toward emitting again), an unwritable state just means the dedupe doesn't // stick for a later run. function latchStatePath(root) { const safe = crypto.createHash("sha1").update(String(root)).digest("hex").slice(0, 12); return path.join(os.tmpdir(), `foreman-decisionanchors-${safe}.json`); } function shouldEmit(root, key) { const p = latchStatePath(root); let state = { keys: [] }; try { const parsed = JSON.parse(fs.readFileSync(p, "utf-8")); if (parsed && Array.isArray(parsed.keys)) state = parsed; } catch { // missing or corrupt state -- treat as never emitted } if (state.keys.includes(key)) return false; try { fs.writeFileSync(p, JSON.stringify({ keys: [...state.keys, key] })); } catch { // best effort -- worst case this emits again next time } return true; } function write(payload) { try { process.stdout.write(Buffer.from(JSON.stringify(payload), "utf-8")); } catch { // ignore } } function main(data = readInput()) { if (data.hook_event_name && data.hook_event_name !== "PostToolUse") - hooks/lib.jsGitHub
- hooks/post-commit.jsRunsGitHub
Read the script
#!/usr/bin/env node "use strict"; const fs = require("fs"); const os = require("os"); const path = require("path"); const { readInput, projectDir: hookProjectDir, pluginDir, hostName } = require("./lib"); const crypto = require("crypto"); const { execFileSync } = require("child_process"); const { readEntries, today, trailerIdsIn } = require("../scripts/roadmap"); const { resolveHookScope } = require("../scripts/commit-evidence"); const { readConfigFile } = require("../scripts/foreman-config"); const { discoveryInstructions } = require("../scripts/discovery"); const PLUGIN_ROOT = pluginDir(); const SCRIPT_PATH = path.join(PLUGIN_ROOT, "scripts", "roadmap.js"); const WATCHED_TOOLS = new Set(["Bash", "PowerShell"]); const SEP = /\s*(?:&&|\|\||[;|\n])\s*/; // `git` takes global options before the subcommand, and several of them // carry their value in a SEPARATE token. A flags-only skip missed // `git -C sub commit` outright, and any regex loose enough to catch it // also fires on `git log --grep commit` or `git -c commit.gpgsign=false log`. // Walking the tokens is the only reading that gets all three right. const GIT_VALUE_FLAGS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"]); function projectDir(data) { if (process.env.FOREMAN_PROJECT_DIR || hostName() !== "codex") return hookProjectDir(data); // A legacy caller can identify its parent roadmap while cwd names the // submodule where the commit actually happened. Preserve only that scoped // relationship; an unrelated inherited root must not capture this event. if (data?.cwd && process.env.CLAUDE_PROJECT_DIR) { const legacyRoot = path.resolve(process.env.CLAUDE_PROJECT_DIR); const relative = path.relative(legacyRoot, path.resolve(data.cwd)); if ((!relative || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))) && fs.existsSync(path.join(legacyRoot, "ROADMAP.jsonl"))) return legacyRoot; } return hookProjectDir(data); } function isGitCommit(command) { return command.split(SEP).some((part) => { const tokens = part.trim().split(/\s+/); if (tokens.shift().toLowerCase() !== "git") return false; while (tokens.length && tokens[0].startsWith("-")) { if (GIT_VALUE_FLAGS.has(tokens.shift())) tokens.shift(); } return tokens[0] === "commit"; }); } // Confirmed against code.claude.com/docs/en/hooks.md: PostToolUse's Bash // exit code is a top-level `exit_code` field, not nested under // tool_response/tool_output (tool_response is stdout text, a string, not an // object) -- the field this checked before was never real, so this always // silently failed open. Fail open still, on a genuinely absent field. // // An exit-preserving PreToolUse wrapper (hush's preserve-exit-code) forces // the shell's own exit to 0 and embeds the real code in the output text as // a marker triplet ([[hush:exit= / N / ]]); PostToolUse hooks across // plugins run in parallel, so this hook sees that raw marker, never a // corrected field. When a marker is present it is the truth and the // top-level 0 is not; absent marker, same field check as before. const WRAPPED_EXIT_RES = [ /\[\[hush:exit=\s*(-?\d+)\s*\]\]/, // raw marker triplet (\s* spans the newlines) /\[hush: exit (-?\d+)\]/, // compressed form, in case ordering ever changes ]; function wrappedExitCode(data) { const r = data?.tool_response; const texts = typeof r === "string" ? [r] : r && typeof r === "object" ? [r.stdout, r.stderr, r.output].filter((t) => typeof t === "string") : []; for (const t of texts) { for (const re of WRAPPED_EXIT_RES) { const m = re.exec(t); if (m) return parseInt(m[1], 10); } } return undefined; } function commitFailed(data) { const wrapped = wrappedExitCode(data); if (typeof wrapped === "number") return wrapped !== 0; const response = data?.tool_response; const structured = response && typeof response === "object" ? response.exit_code ?? response.exitCode : undefined; // Codex does not guarantee an exit status in PostToolUse. Accept explicit // structured adapter fields, but never infer success/failure from raw text. const code = structured ?? data?.exit_code; return typeof code === "number" && code !== 0; } // The freshly-done follow-up nudge fires once per entry per day, not on // every commit of a busy day — a tmpdir state file keyed by project root // remembers which entries were already mentioned today. Best-effort both // ways: unreadable state means nudge again (fail open), unwritable state // means the dedup just doesn't stick. function freshlyDoneStatePath(root) { const safe = crypto.createHash("sha1").update(String(root)).digest("hex").slice(0, 12); return path.join(os.tmpdir(), `foreman-freshlydone-${safe}.json`); } function filterUnnudged(root, ids, todayStr) { const p = freshlyDoneStatePath(root); let state = { date: todayStr, ids: [] }; try { const parsed = JSON.parse(fs.readFileSync(p, "utf-8")); if (parsed && parsed.date === todayStr && Array.isArray(parsed.ids)) state = parsed; } catch { // missing or corrupt state — treat as a fresh day } const unnudged = ids.filter((id) => !state.ids.includes(id)); if (unnudged.length) { try { fs.writeFileSync(p, JSON.stringify({ date: todayStr, ids: [...state.ids, ...unnudged] })); } catch { // best effort } } return new Set(unnudged); } // [Foreman: 118] requireVerification defaults ON: a commit that looks like // it finishes a task is not evidence the task holds up, so the unconfigured // project gets the safe reading. Opting out is an explicit `false`, and a // corrupt config falls to the same safe default rather than the loose one. // // discoverySuggestions now defaults ON, the same polarity: work worth // tracking that nobody writes down is the failure this plugin exists to // prevent, and a project that never opened its config is exactly the one // losing th - hooks/session-start.jsRunsGitHub
Read the script
#!/usr/bin/env node "use strict"; // SessionStart — surface open roadmap entries (in_progress, and work left // awaiting_acceptance), plus an archive offer once terminal entries pile up. // // A destination session marks an entry in_progress and can die without // closing it (crash, abandoned clipboard paste, killed agent); nothing else // ever surfaces that, so the entry silently rots until someone happens to // run a review. An awaiting_acceptance entry rots the same way for the // opposite reason — it is finished and nobody has said yes. One // informational line at session start closes both loops. A second, separate // line offers the archive flow once done/dropped/rejected entries pile up — // that flow exists but nothing else ever suggests using it. // Silent whenever there is nothing to say; never fires for subagents // (SessionStart is a main-session-only event) or on resume/compact (the // matcher gates to startup|clear — resumed context already knows). const fs = require("fs"); const os = require("os"); const path = require("path"); const { readInput, hostName, projectDir, pluginDir } = require("./lib"); const crypto = require("crypto"); const { readEntries, today, TERMINAL_STATUSES } = require("../scripts/roadmap"); const { record: recordTrial, startSession: startTrialSession } = require("../scripts/trial-log"); const PLUGIN_ROOT = pluginDir(); const SCRIPT_PATH = path.join(PLUGIN_ROOT, "scripts", "roadmap.js"); // An entry untouched this long gets its last-activity date called out. const STALE_DAYS = 3; // razor: fixed ceiling, no config key — add one only once a user asks for it. const ARCHIVE_OFFER_THRESHOLD = 20; function daysBetween(fromYmd, toYmd) { const ms = new Date(toYmd) - new Date(fromYmd); return Number.isFinite(ms) ? Math.floor(ms / 86400000) : 0; } // Each host names the way back into those entries in its own terms. const RESUME_HINT = { claude: "/foreman:roadmap offers to resume, accept, or review.", codex: "ask Foreman to resume, accept, or review.", }; // [Foreman: 131] `awaiting_acceptance` entries are surfaced here too, tagged // so the two never blur: an in_progress entry may have died mid-work, an // awaiting one is finished and waiting on THIS user. Without them the state // would be the one open state nothing ever mentions — the opposite of why it // exists. function buildMessage(open, todayStr, host = hostName()) { const items = open.map((e) => { const stale = e.updated_at && daysBetween(e.updated_at, todayStr) >= STALE_DAYS ? `, no activity since ${e.updated_at}` : ""; const waiting = e.status === "awaiting_acceptance" ? ", awaiting your acceptance" : ""; return `${e.id} ("${e.title}"${waiting}${stale})`; }); return ( `[Foreman] Roadmap entries still open: ${items.join(", ")}. ` + "Informational only — don't act on this unless the user asks. If one " + "of these actually concluded, it can be closed via " + `echo '{"id":"<id>","status":"<done|dropped>","commit":"<sha>","notes":"..."}' | node "${SCRIPT_PATH}" update-status ` + `(commit first if code changed); ${RESUME_HINT[host]}` ); } // [Foreman: 180] Terminal entries (done/dropped/rejected) never get archived // on their own — nothing else ever suggests it, so a mature roadmap just // keeps accumulating them. One offer line, gated on a fixed count, closes // that loop the same way the open-entries line does. function buildArchiveOffer(count) { return ( `[Foreman] ${count} finished entries are still in the active roadmap — ` + "ask Foreman to archive the done ones to keep reads lean." ); } // [Foreman: 200] Nothing observes whether the user acted on the archive // offer in chat — a decline or an ignore looks identical to Foreman, so // without this it re-asks every single session forever once the threshold // is crossed. A tmpdir state file (same sha1-of-root keying as // post-commit.js's freshly-done dedup) remembers the last date it fired; // best-effort both ways: unreadable state means offer again (fail open), // unwritable state means the dedup just doesn't stick. function archiveOfferStatePath(root) { const safe = crypto.createHash("sha1").update(String(root)).digest("hex").slice(0, 12); return path.join(os.tmpdir(), `foreman-archiveoffer-${safe}.json`); } // razor: fixed ceiling, no config key — add one only once a user asks for it. const ARCHIVE_OFFER_RENUDGE_DAYS = 7; function shouldOfferArchive(root, todayStr) { const p = archiveOfferStatePath(root); let lastDate; try { const parsed = JSON.parse(fs.readFileSync(p, "utf-8")); // An unparseable stored date must fail open the same as a missing file — // daysBetween's NaN guard returns 0, and `0 < RENUDGE_DAYS` would // otherwise suppress the offer forever without ever rewriting it. if (parsed && typeof parsed.date === "string" && Number.isFinite(new Date(parsed.date).getTime())) { lastDate = parsed.date; } } catch { // missing or corrupt state — fail open, offer again } if (lastDate && daysBetween(lastDate, todayStr) < ARCHIVE_OFFER_RENUDGE_DAYS) return false; try { fs.writeFileSync(p, JSON.stringify({ date: todayStr })); } catch { // best effort } return true; } function main(data = readInput()) { // The matcher already gates to startup|clear; keep a defensive check so a // broader matcher edit can't silently make this fire on every compaction. if (data.source && data.source !== "startup" && data.source !== "clear") return; const root = projectDir(data); if (!fs.existsSync(path.join(root, "ROADMAP.jsonl"))) return; // [Foreman: 208] Mint this session's trial token and record its start // BEFORE any of the silent returns below. This event is the denominator // for sessions_since_init, so it must not depend on whether there happened // to be an open entry worth mentioning — or on the roadmap being readable. // Silent no-op unless the project opted in; never throws. - hooks/stop.jsGitHub
- hooks/task-completed.jsRunsGitHub
Read the script
#!/usr/bin/env node "use strict"; // Claude Code only (hooks/hooks.json). Codex emits no TaskCompleted event; it // uses hooks/codex-task.js check and the scoped, opt-in Stop adapter instead. // TaskCompleted — the mechanical mirror of task-created.js: instead of // mechanizing the OPEN transition, this gates the CLOSE. A task completing // while its named roadmap entry is still open (planned or in_progress) is // exactly how a real session once closed an entry `done` with uncommitted // code — the 0.16.2 prose rule ("close the entry, then complete the task") // can be ignored; this makes it harder to. // // Probed 2026-07-14 (headless CLI 2.1.210, brief §2.1/§4 M1) and re-probed // 2026-07-23 (CLI 2.1.216), 2026-08-13 (CLI 2.1.228), 2026-08-21 (CLI // 2.1.238), 2026-08-25 (CLI 2.1.241), 2026-08-28 (CLI 2.1.251), // 2026-09-03 (CLI 2.1.257) and 2026-09-05 (CLI 2.1.261), unchanged every time: // TaskCompleted accepts the same top-level // {"decision":"block","reason":"..."} shape as Stop/SubagentStop — a real // block (the TaskUpdate call itself returns success:false, updatedFields:[], // with the reason as its own tool_result text, not a system-reminder). No // harness-side retry after a block was observed (one firing per task_id // across all probe runs); a haiku driver that saw a genuine block still // described the completion as successful in its own prose despite quoting // the reason verbatim, so the reason text below is written as an imperative // instruction sequence rather than a description. // // That block is the ONLY output channel this event has. The 2026-07-23 // re-probe emitted, on TaskCompleted, `systemMessage`, // `hookSpecificOutput.additionalContext`, plain stdout and stderr: every one // left zero trace — no transcript attachment of any kind, no tool-result // text, no model mention — while the same hook on SessionStart, // UserPromptSubmit, PostToolUse and Stop produced hook_system_message and // hook_additional_context attachments for the first two fields. The gate // below therefore offers `off` and `block` only: an advisory mode on this // event cannot reach anyone, so it is not offered rather than shipped // silent. Do not add another output field here expecting it to arrive. // // This hook never writes to ROADMAP.jsonl — task-created.js stays the only // writing hook. It only reads (readEntries) and, at most, emits a block. const fs = require("fs"); const os = require("os"); const path = require("path"); const { readInput, projectDir, pluginDir } = require("./lib"); const crypto = require("crypto"); const { readEntries } = require("../scripts/roadmap"); // [Foreman: 134] Entry-to-commit facts come from the one interpreter, so this // gate resolves a commit in the same places every other status view does -- // including a submodule, where a root-only lookup found nothing. const { trailerShasFor } = require("../scripts/commit-evidence"); const { readConfigFile } = require("../scripts/foreman-config"); const { record: recordTrial, recordResumeRecovered } = require("../scripts/trial-log"); const { ENTRY_MARKER_RE, entryIdFromDescription } = require("./task-created"); // [Foreman: 131] `awaiting_acceptance` is deliberately NOT gated here, even // though it is an open status everywhere dependency and archive logic asks. // This gate exists to stop work from disappearing UNRECORDED — its block text // orders the session to close the entry `done`, which is exactly the move // `awaiting_acceptance` exists to withhold until the user says yes. An // awaiting entry is already recorded (status, commit, notes), so blocking // would demand an unauthorized close, and would deadlock every session with // no user to ask — background agents and any unattended runner's own // fold-back, both of which leave entries awaiting on purpose. The doctor's // `awaiting_without_evidence` warning covers the one case this gate would // otherwise catch: an awaiting entry with nothing recorded at all. const OPEN_STATUSES = new Set(["planned", "in_progress"]); const GATE_MODES = new Set(["off", "block"]); const PLUGIN_ROOT = pluginDir(); const SCRIPT_PATH = path.join(PLUGIN_ROOT, "scripts", "roadmap.js"); function readConfig(root) { // Absent config, or corrupt -- same safe default: readConfigFile hands // back {} for both, and this hook's event has no channel to warn on. const v = readConfigFile(root).config.taskCloseGate; return GATE_MODES.has(v) ? v : "off"; } // Once-only-per-task latch, same shape as post-commit.js's freshlyDone // dedupe: unreadable/missing state means "never gated yet" (fail open // toward gating again, the least-surprising choice), an unwritable state // just means the dedup doesn't stick for a later run. Keyed by // session_id+task_id, not task_id alone -- TaskCreate's task_id is a small // per-session counter that restarts at 1 in every fresh session, so a // bare task_id would let an unrelated session's task inherit an already- // latched id and skip the gate. function latchStatePath(root) { const safe = crypto.createHash("sha1").update(String(root)).digest("hex").slice(0, 12); return path.join(os.tmpdir(), `foreman-taskclosegate-${safe}.json`); } function shouldGate(root, taskId) { const p = latchStatePath(root); let state = { ids: [] }; try { const parsed = JSON.parse(fs.readFileSync(p, "utf-8")); if (parsed && Array.isArray(parsed.ids)) state = parsed; } catch { // missing or corrupt state -- treat as never gated } if (state.ids.includes(taskId)) return false; try { fs.writeFileSync(p, JSON.stringify({ ids: [...state.ids, taskId] })); } catch { // best effort -- worst case this task_id gates again next time } return true; } function closeCommand(id) { return ( `echo '{"id":"${id}","status":"done","commit":"<sha>"}' | node "${SCRIPT_PATH}" update-status ` + "(or `annotate` findings instead, for an investigation-only close with no commit)" ); } function blockReason(id) { return ( `[Fore - hooks/task-created.jsRunsGitHub
- hooks/windows-launcher.ps1GitHub
All 11 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Project continuity for Claude Code and Codex: a roadmap beside your code, grounded handoffs, and clear task status.
Repo: V-Songbird/foreman

