diff --git a/.claude/scripts/tests/mod-timeouts.test.ts b/.claude/scripts/tests/mod-timeouts.test.ts new file mode 100644 index 00000000..6ae8edc9 --- /dev/null +++ b/.claude/scripts/tests/mod-timeouts.test.ts @@ -0,0 +1,49 @@ +/** + * The obsidian-mind mod runs the vault's hook scripts itself, with a timeout + * for each (.claude/skills/obsidian-mind/hooks/register.ts). Each must equal + * the timeout settings.json gives the same script as a hook, so the mod never + * waits longer, or gives up sooner, than the hook it replaces would. + */ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = join(dirname(fileURLToPath(import.meta.url)), "../../.."); +const REGISTER = join(REPO, ".claude/skills/obsidian-mind/hooks/register.ts"); + +/** Each script the mod runs, with the timeout it passes, in milliseconds. */ +export function modTimeouts(source: string): Map { + const found = new Map(); + for (const m of source.matchAll(/runScript\(\$, root, "([\w-]+\.ts)", [^;]*?, ([\d_]+)\)/g)) { + found.set(m[1]!, Number(m[2]!.replace(/_/g, ""))); + } + return found; +} + +/** The timeout settings.json gives the Claude hook that runs `script`, in milliseconds. */ +function settingsTimeout(script: string): number | undefined { + const settings = JSON.parse(readFileSync(join(REPO, ".claude/settings.json"), "utf-8")) as { + hooks: Record }>>; + }; + for (const groups of Object.values(settings.hooks)) { + for (const hook of groups.flatMap((g) => g.hooks)) { + if (hook.command.includes(`/.claude/scripts/${script}`) && hook.timeout !== undefined) return hook.timeout * 1000; + } + } + return undefined; +} + +describe("the mod's script timeouts", () => { + test("the reader finds every script the mod runs", () => { + const found = modTimeouts(readFileSync(REGISTER, "utf-8")); + assert.deepEqual([...found.keys()].sort(), ["session-start.ts", "stop-checklist.ts"]); + }); + + test("each equals the timeout settings.json gives the same script", () => { + for (const [script, ms] of modTimeouts(readFileSync(REGISTER, "utf-8"))) { + assert.equal(ms, settingsTimeout(script), `${script}: the mod waits ${ms} ms`); + } + }); +}); diff --git a/.claude/skills/obsidian-mind/.claude-plugin/plugin.json b/.claude/skills/obsidian-mind/.claude-plugin/plugin.json index baeb3d79..ea3a58c5 100644 --- a/.claude/skills/obsidian-mind/.claude-plugin/plugin.json +++ b/.claude/skills/obsidian-mind/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "obsidian-mind", "version": "8.6.0", - "description": "Delivers obsidian-mind's session context as an instruction file, so it survives compaction whole and reaches subagents. The vault's settings hooks stand down for each event it handles and run as before wherever it does not load.", + "description": "Delivers obsidian-mind's session context as an instruction file, so it survives compaction whole and reaches subagents, and shows the vault's Stop report as one line under the answer, handing the agent the full report with your next message (an urgent finding, if a vault defines one, gets a turn of its own). The vault's settings hooks stand down for each event it handles and run as before wherever it does not load.", "author": { "name": "Brenno Ferrari", "url": "https://github.com/breferrari/obsidian-mind" diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index 062cb7ab..06acc571 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -1,5 +1,6 @@ -import { atom, read, update, type EngineInterface, type Register } from "claude-code"; +import { atom, read, update, type EngineInterface, type PluginState, type Register } from "claude-code"; import { withSessionContext } from "./context.ts"; +import { carriesReport, fromPerson, parseStopReport, ranIn, summaryLine, withLine } from "./stop.ts"; /** * obsidian-mind's Claude Code mod (#262). @@ -17,30 +18,80 @@ import { withSessionContext } from "./context.ts"; * re-read whole after compaction and `/clear` instead of shrinking to a * pointer, and general-purpose subagents receive it. * + * Stop report (#266): `stop-checklist.ts` runs here with `om_mod: "report"`. + * When the findings changed, the user sees one line under the answer and the + * agent gets the full report with the next prompt, unseen. A finding marked + * urgent gets a turn of its own at once. + * * The switch is the event itself: the settings hook is passed * `om_mod: "standdown"` and exits, but only on an event this hook actually * handled. The work is done before `next`, so if it fails the hook throws, * Claude Code skips it, and the settings hook gets the original event and * runs as it would without the mod. + * + * What the hooks hand each other lives in `$.state`, not in module + * variables: the host keeps it for the session, across a hot reload. */ /** Where the delivered context is also written, so /memory opens what the model received. Gitignored. */ const CONTEXT_FILE = ".claude/session-context.md"; +/** The session context this session's instruction file carries; what prompt.context hands the model. */ +const sessionContext = atom({ plugin: "obsidian-mind", key: "context" } as const, null); +/** The report waiting to be delivered: its text, and its line and urgent finding until they are used. */ +const queued = atom({ plugin: "obsidian-mind", key: "queued" } as const, null); +/** Whether an urgent finding has had its turn since the person last spoke. */ +const urgentSpent = atom({ plugin: "obsidian-mind", key: "urgentSpent" } as const, false); +/** Bumped by every start that begins another conversation, so a report in flight across one is not put back. */ +const generation = atom({ plugin: "obsidian-mind", key: "generation" } as const, 0); /** - * The context this session's instruction file carries. In `$.state`, not a - * module variable: the host keeps it for the session, across a hot reload of - * this module. Each classic.SessionStart clears it before its run. + * The report a prompt took, until a turn starts with that prompt. A prompt + * enters when it is queued, not when its turn starts, and a queued prompt can + * be pulled back out of the queue; if a turn starts with another prompt + * first, this one never ran, and the report goes back in the queue. */ -const sessionContext = atom({ plugin: "obsidian-mind", key: "context" } as const, null); +const inFlight = atom({ plugin: "obsidian-mind", key: "inFlight" } as const, null); + +type Queued = NonNullable; + +/** + * Which report each session was last given, by session id, and whether it + * reached the agent: in `$.store`, not `$.state`, because the store outlives + * the process. A `claude --resume` in a new process then neither repeats a + * report the agent had nor loses one that was still waiting (the settings + * hook's dedupe is file-backed for the same reason). Only the most recent + * sessions are kept. + */ +const SHOWN = "shown"; +const SHOWN_KEEP = 20; +type Shown = { readonly key: string; readonly delivered: boolean }; -/** Run one of the vault's hook scripts with `input` on stdin; its stdout, or a throw. */ -async function runScript($: EngineInterface, root: string, script: string, input: object): Promise { +async function shownFor($: EngineInterface, sessionId: string): Promise { + const shown = ((await $.store.get(SHOWN)) ?? {}) as Record; + return shown[sessionId]; +} + +async function setShown($: EngineInterface, sessionId: string, entry: Shown | null): Promise { + const shown = { ...(((await $.store.get(SHOWN)) ?? {}) as Record) }; + delete shown[sessionId]; + if (entry !== null) shown[sessionId] = entry; + // Insertion order is recency: drop the oldest sessions past the cap. + const ids = Object.keys(shown); + for (const id of ids.slice(0, Math.max(0, ids.length - SHOWN_KEEP))) delete shown[id]; + await $.store.set(SHOWN, shown); +} + +/** + * Run one of the vault's hook scripts with `input` on stdin; its stdout, or a + * throw. `timeoutMs` matches the script's own timeout in settings.json, so the + * mod never waits longer than the hook it replaces would have. + */ +async function runScript($: EngineInterface, root: string, script: string, input: object, timeoutMs: number): Promise { const run = await $.process.run(["node", "--disable-warning=ExperimentalWarning", "--experimental-strip-types", `${root}/.claude/scripts/${script}`], { cwd: root, env: { CLAUDE_PROJECT_DIR: root }, stdin: JSON.stringify(input), - timeoutMs: 30_000, + timeoutMs, }); if (run.exitCode !== 0 || run.stdout.trim() === "") { throw new Error(`${script} exited ${run.exitCode}: ${run.stderr.slice(0, 300)}`); @@ -52,18 +103,28 @@ async function runScript($: EngineInterface, root: string, script: string, input export const register: Register = (on) => { on("classic.SessionStart", async ($, e, next) => { - // If this run fails, the settings hook runs instead. At a start that - // begins a conversation it prints the full layer, so the old context is - // cleared first (and the render redrawn) or it would ride beside the - // fresh one. At a compaction it prints only a pointer, trusting the - // static half to be in the conversation already; under the mod it never - // was, so there the last good context is kept rather than lost. + // Only a compaction continues the same conversation. Every other start + // (`/clear`, an in-process `/resume` or fork) may keep this process and + // its `$.state`, and a report about another conversation must not ride + // the first prompt of this one, so what was queued is dropped. if (e.source !== "compact") { + // A report dropped here was never marked delivered, so the session it + // was for gets it again at its next Stop; one it already had stays given. + await update($, queued, () => null); + await update($, urgentSpent, () => false); + await update($, generation, (now) => now + 1); + await update($, inFlight, () => null); + // If this run fails, the settings hook runs instead and prints the full + // layer, so the old context is cleared first (and the render redrawn) + // or it would ride beside the fresh one. At a compaction the hook + // prints only a pointer, trusting the static half to be in the + // conversation already; under the mod it never was, so there the last + // good context is kept rather than lost. await update($, sessionContext, () => null); $.ui.invalidate("prompt.context"); } const root = await $.session.root(); - const text = await runScript($, root, "session-start.ts", { ...e, om_mod: "deliver" }); + const text = await runScript($, root, "session-start.ts", { ...e, om_mod: "deliver" }, 30_000); await update($, sessionContext, () => text); // Not awaited: delivery does not depend on the file, so a slow, hung or // failed write never holds up the session. The file only backs what @@ -82,4 +143,141 @@ export const register: Register = (on) => { if (text === null) return below; return withSessionContext(below, `${await $.session.root()}/${CONTEXT_FILE}`, text); }); + + on("classic.Stop", async ($, e, next) => { + // A turn some Stop hook forced: the settings hook exits on its own. + if (e.stop_hook_active) return next(e); + const root = await $.session.root(); + let report: ReturnType; + try { + report = parseStopReport(await runScript($, root, "stop-checklist.ts", { ...e, om_mod: "report" }, 5_000)); + } catch (error) { + // The settings hook runs in this hook's place and hands over its own + // report; one still queued here would ride the same prompt beside it. + await update($, queued, () => null); + throw error; + } + // Per session, so a new session shows its first report even with the + // same findings, as the settings hook's dedupe does. + const sessionId = String(e.session_id); + const given = await shownFor($, sessionId); + const waiting = (record: { readonly sessionId: string; readonly key: string } | null | undefined) => + record?.sessionId === sessionId && record.key === report.key; + // The same findings again: skip them if the agent had them, or if they are + // still on their way in this process. Not delivered and not on their way + // (a resume in a new process, a dropped queue) means queue them again. + const already = given?.key === report.key && (given.delivered || waiting(await read($, queued)) || waiting((await read($, inFlight))?.record)); + if (!already) { + await setShown($, sessionId, { key: report.key, delivered: false }); + // The urgent finding rides inside the report too, so whatever happens + // to its own turn, the agent gets it with the report. + const text = report.urgent === undefined ? report.agentText : `${report.agentText}\n\nUrgent: ${report.urgent}`; + await update($, queued, (): Queued => ({ sessionId, key: report.key, report: text, line: summaryLine(report), urgent: report.urgent ?? null })); + } + return next({ ...e, om_mod: "standdown" } as typeof e); + }); + + on("turn.complete", async ($, e, next) => { + const done = await next(e); + // Only under a main-loop answer that completed: a subagent's turn, an + // interrupted one or one an error ended keeps the line for the next. + if (e.agentId !== undefined || e.reason !== "answer") return done; + // The line and the urgent finding are used once; the report stays queued for the next prompt. + let line: string | null = null; + let urgent: string | null = null; + await update($, queued, (now) => { + line = now?.line ?? null; + urgent = now?.urgent ?? null; + return now === null || now.line === null ? now : { ...now, line: null, urgent: null }; + }); + if (line === null) return done; + // One urgent turn per prompt the person sends: findings that keep + // changing while the agent fixes them must not chain turns. A finding + // that gets no turn still reaches the agent inside the queued report. + if (urgent !== null && !(await read($, urgentSpent))) { + await update($, urgentSpent, () => true); + // Never from classic.Stop: the engine refuses a submit that would wait + // on the turn the hook may be holding, and names turn.complete instead. + // Framed as this plugin's message, so the model knows it is not the + // person speaking. The report rides it (prompt.submit below); if the + // prompt never enters, the report stays queued for the next one. + $.prompt.submit({ text: urgent }).catch(() => {}); + } + return { ...done, text: withLine(done.text, e.answer, line) }; + }); + + on("prompt.submit", async ($, e, next) => { + // The person speaking renews the urgent allowance, once their prompt has entered. + const renew = async (entered: Awaited>) => { + if (entered.drop === undefined && fromPerson(e.origin)) await update($, urgentSpent, () => false); + return entered; + }; + if (!carriesReport(e.origin)) return renew(await next(e)); + // The whole record is taken before `next`, so two prompts entering at + // once cannot both carry it, and a report queued while this one enters + // is a different record that nothing here touches. Put back if this + // prompt never enters (dropped or blocked below, or a throw), unless a + // newer one was queued meanwhile. + let taken: Queued | null = null; + await update($, queued, (now) => { + taken = now; + return null; + }); + if (taken === null) return renew(await next(e)); + const record: Queued = taken; + const startedIn = await read($, generation); + // Put back only into the conversation it was taken from: a `/clear` or + // resume while this prompt was entering has dropped the queue on purpose. + // Read through `update`, whose function sees writes made during `next`. + const putBack = async () => { + let now = startedIn; + await update($, generation, (g) => { + now = g; + return g; + }); + if (now === startedIn) await update($, queued, (current) => current ?? record); + }; + // Held before `next`: the prompt's own turn can start inside `next` + // (observed on 2.1.288), and turn.start must find it there to count it run. + await update($, inFlight, () => ({ text: e.text, record })); + let entered: Awaited>; + try { + entered = await next({ ...e, context: [...(e.context ?? []), record.report] }); + } catch (error) { + await update($, inFlight, () => null); + await putBack(); + throw error; + } + if (entered.drop !== undefined) { + await update($, inFlight, () => null); + await putBack(); + return entered; + } + return renew(entered); + }); + + on("turn.start", async ($, e, next) => { + // Only the main loop's prompts carry the report; a turn begun without a + // prompt (a continuation, text "") says nothing about the queue. + let held: PluginState["obsidian-mind"]["inFlight"] = null; + await update($, inFlight, (now) => { + held = now; + return now === null || e.text === "" ? now : null; + }); + const waiting: PluginState["obsidian-mind"]["inFlight"] = held; + if (waiting === null || e.text === "") return next(e); + // Queued prompts run in order and may be folded into one turn, so a turn + // whose text holds the prompt's ran it: delivered, and remembered as + // delivered so no later start repeats it. Any other prompt's turn starting + // first means the one holding the report left the queue unrun. + if (ranIn(e.text, waiting.text)) { + const given = await shownFor($, waiting.record.sessionId); + if (given?.key === waiting.record.key) await setShown($, waiting.record.sessionId, { key: waiting.record.key, delivered: true }); + } else { + // A start that begins another conversation clears what is in flight, + // so a prompt still held here is from this one. + await update($, queued, (current) => current ?? waiting.record); + } + return next(e); + }); }; diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts new file mode 100644 index 00000000..73673bc8 --- /dev/null +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -0,0 +1,685 @@ +import { describe, expect, test } from "claude-code/testing"; +import { engine, type On, type Reply } from "./world.ts"; +import { carriesReport, fromPerson, parseStopReport, ranIn, summaryLine, withLine, type StopReport } from "./stop.ts"; + +// Run with `claude plugin test .claude/skills/obsidian-mind`. Each test's own +// `on` hooks sit beneath the mod and stand in for the engine and the vault. +// What the kit cannot establish is the order a live session raises them in +// (classic.Stop before turn.complete); that is checked in a live session. + +const HANDED = (key: string) => `Stop hook report, handed over with this message: ${key}`; + +const report = (key: string, extra: Partial = {}): StopReport => ({ + key, + claims: ["1 note(s) marked done but still in active/"], + agentText: HANDED(key), + ...extra, +}); + +/** The shared world beneath the mod, plus the prompt and answer stubs these tests steer. */ +function stopWorld(on: On, reply: () => Reply, store?: Readonly>) { + const base = engine(on, reply, store ? { store } : {}); + const world = { + runs: base.runs, + passedDown: base.passedDown.Stop, + submitted: [] as Array<{ text: string; context?: readonly string[]; origin?: unknown }>, + /** The next prompt is dropped below, or the next one throws below. */ + dropNext: false, + throwNext: false, + /** A line another hook below sets under the answer, if any. */ + lowerLine: null as string | null, + /** Runs inside the next prompt's submit, before it enters or is dropped. */ + duringNext: null as (() => Promise) | null, + }; + on("prompt.submit", async (_$, e) => { + world.submitted.push({ text: e.text, context: e.context, origin: e.origin }); + const during = world.duringNext; + world.duringNext = null; + if (during) await during(); + if (world.throwNext) { + world.throwNext = false; + throw new Error("failed below"); + } + // A settings hook below can block a prompt: it never enters. + if (world.dropNext) { + world.dropNext = false; + return { drop: "blocked by a hook below" }; + } + return { text: e.text, context: e.context }; + }); + on("turn.complete", (_$, e) => ({ text: world.lowerLine ?? e.answer })); + on("turn.start", (_$, e) => ({ turnId: e.turnId })); + return world; +} +type World = ReturnType; + +const ok = (r: StopReport) => () => ({ exitCode: 0, stdout: JSON.stringify({ report: r }) }); + +/** A main-loop answer that completed, as turn.complete receives it. */ +const answered = (extra: Record = {}) => + ({ answer: "the answer", durationMs: 1, isAborted: false, turnId: "t", reason: "answer", ...extra }) as never; + +/** Let an unawaited submit and its follow-up settle. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const LINE = "vault check: 1 note(s) marked done but still in active/ · the full report reaches the agent with your next message"; +const LINE_URGENT = "vault check: 1 note(s) marked done but still in active/ · the full report goes to the agent now"; + +describe("Stop report (#266)", () => { + test("a changed report: the settings hook stands down and the next prompt carries the full report, once", async ($, on) => { + const world = stopWorld(on, ok(report("k1"))); + await $.classic.Stop({ stop_hook_active: false }); + + expect(world.runs.length).toBe(1); + expect(JSON.parse(world.runs[0]?.init?.stdin ?? "{}")).toEqual(expect.objectContaining({ om_mod: "report" })); + expect(world.passedDown[0]?.["om_mod"]).toBe("standdown"); + + await $.prompt.submit({ text: "next" }); + expect(world.submitted[0]?.context).toEqual([HANDED("k1")]); + await $.prompt.submit({ text: "after" }); + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + test("the same findings again: nothing is queued a second time", async ($, on) => { + const world = stopWorld(on, ok(report("same"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "first" }); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "second" }); + + expect(world.runs.length).toBe(2); + expect(world.submitted[0]?.context).toEqual([HANDED("same")]); + expect(world.submitted[1]?.context ?? []).toEqual([]); + expect(world.passedDown[1]?.["om_mod"]).toBe("standdown"); + }); + + test("changed findings are queued again", async ($, on) => { + let key = "a"; + const world = stopWorld(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key) }) })); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "first" }); + key = "b"; + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "second" }); + + expect(world.submitted[1]?.context).toEqual([HANDED("b")]); + }); + + test("each session sees its own first report, even with the same findings", async ($, on) => { + // The shown identity carries the session id: the API does not say state + // is reset when a new session begins, so the key does not rely on it. + const world = stopWorld(on, ok(report("shared"))); + await $.classic.Stop({ stop_hook_active: false, session_id: "s1" }); + await $.prompt.submit({ text: "first" }); + await $.classic.Stop({ stop_hook_active: false, session_id: "s2" }); + await $.prompt.submit({ text: "second" }); + + expect(world.submitted[0]?.context).toEqual([HANDED("shared")]); + expect(world.submitted[1]?.context).toEqual([HANDED("shared")]); + }); + + test("a prompt dropped below before the answer completes leaves the line for that answer", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + world.dropNext = true; + await $.prompt.submit({ text: "blocked" }); + + expect((await $.turn.complete(answered())).text).toBe(LINE); + }); + + test("a prompt that is dropped below keeps the report for the next one", async ($, on) => { + const world = stopWorld(on, ok(report("kept"))); + await $.classic.Stop({ stop_hook_active: false }); + world.dropNext = true; + await $.prompt.submit({ text: "blocked" }); + await $.prompt.submit({ text: "entered" }); + + expect(world.submitted[1]?.context).toEqual([HANDED("kept")]); + }); + + test("a peer's message passes without the report; the person's next prompt gets it", async ($, on) => { + const world = stopWorld(on, ok(report("mine"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "from a peer", origin: { kind: "peer" } } as never); + await $.prompt.submit({ text: "typed" }); + + expect(world.submitted[0]?.context ?? []).toEqual([]); + expect(world.submitted[1]?.context).toEqual([HANDED("mine")]); + }); + + test("a forced turn passes straight through: no run, no flag", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: true }); + + expect(world.runs.length).toBe(0); + expect(world.passedDown[0]?.["om_mod"]).toBe(undefined); + }); + + test("when the script fails, the settings hook gets the original event and nothing is queued", async ($, on) => { + const world = stopWorld(on, () => ({ exitCode: 1, stdout: "" })); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "next" }); + + expect(world.runs.length).toBe(1); + expect(world.passedDown[0]?.["om_mod"]).toBe(undefined); + expect(world.submitted[0]?.context ?? []).toEqual([]); + }); + + test("an unusable report counts as a failure too", async ($, on) => { + const world = stopWorld(on, () => ({ exitCode: 0, stdout: '{"report":{"key":"k"}}' })); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "next" }); + + expect(world.runs.length).toBe(1); + expect(world.passedDown[0]?.["om_mod"]).toBe(undefined); + expect(world.submitted[0]?.context ?? []).toEqual([]); + }); +}); + +describe("the line under the answer (#266)", () => { + test("drawn once, under the next completed answer", async ($, on) => { + stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + + expect((await $.turn.complete(answered())).text).toBe(LINE); + expect((await $.turn.complete(answered())).text).toBe("the answer"); + }); + + test("a subagent turn, an interrupt, an error or a refusal keeps it for the next answer", async ($, on) => { + stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + for (const turn of [answered({ agentId: "a1" }), answered({ reason: "aborted" }), answered({ reason: "error" }), answered({ reason: "refusal" })]) { + expect((await $.turn.complete(turn)).text).toBe("the answer"); + } + + expect((await $.turn.complete(answered())).text).toBe(LINE); + }); + + test("a line a hook below set is kept, and ours follows it", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + world.lowerLine = "TL;DR: done"; + await $.classic.Stop({ stop_hook_active: false }); + + expect((await $.turn.complete(answered())).text).toBe(`TL;DR: done\n${LINE}`); + }); + + test("with no urgent finding, no prompt is submitted", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + + expect(world.submitted.length).toBe(0); + }); + + test("a prompt that took the report and then ran: delivered, not put back", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "typed" }); + await $.turn.start({ text: "typed", turnId: "t1" }); + await $.prompt.submit({ text: "after" }); + + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + test("a delivered report stays delivered when later turns start", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "typed" }); + await $.turn.start({ text: "typed", turnId: "t1" }); + await $.turn.start({ text: "a later prompt", turnId: "t2" }); + await $.prompt.submit({ text: "after" }); + + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + test("a /clear while the prompt entered: its report is not put back when another turn starts", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + world.duringNext = () => $.classic.SessionStart({ source: "clear", session_id: "B" } as never); + await $.prompt.submit({ text: "entering across the clear" }); + await $.turn.start({ text: "new conversation", turnId: "t1" }); + await $.prompt.submit({ text: "next" }); + + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + test("a held short prompt is not taken as run by another prompt that merely contains it", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "ok" }); + await $.turn.start({ text: "looks ok now", turnId: "t1" }); + await $.prompt.submit({ text: "next" }); + + expect(world.submitted[1]?.context).toEqual([HANDED("k")]); + }); + + test("when the mod's own Stop run fails, its older queued report is dropped: the settings hook hands over the fresh one", async ($, on) => { + let fail = false; + const world = stopWorld(on, () => (fail ? { exitCode: 1, stdout: "" } : { exitCode: 0, stdout: JSON.stringify({ report: report("old") }) })); + await $.classic.Stop({ stop_hook_active: false }); + fail = true; + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "next" }); + + expect(world.passedDown[1]?.["om_mod"]).toBe(undefined); + expect(world.submitted[0]?.context ?? []).toEqual([]); + }); + + test("a queued prompt pulled back before it ran: the report goes back to the queue", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "queued, then pulled back" }); + // Another prompt's turn starts first: the queue runs in order, so the first one never ran. + await $.turn.start({ text: "sent instead", turnId: "t1" }); + await $.prompt.submit({ text: "next" }); + + expect(world.submitted[1]?.context).toEqual([HANDED("k")]); + }); + + test("queued prompts folded into one turn: the one holding the report ran", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "first" }); + await $.turn.start({ text: "first\nsecond", turnId: "t1" }); + await $.prompt.submit({ text: "after" }); + + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + test("a turn begun without a prompt says nothing about the queue", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "typed" }); + await $.turn.start({ text: "", turnId: "t0" }); + await $.turn.start({ text: "typed", turnId: "t1" }); + await $.prompt.submit({ text: "after" }); + + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + test("a report in flight across a /clear is not put back when another turn starts", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + await $.prompt.submit({ text: "queued" }); + await $.classic.SessionStart({ source: "clear", session_id: "B" } as never); + await $.turn.start({ text: "new conversation", turnId: "t1" }); + await $.prompt.submit({ text: "next" }); + + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + test("a prompt in flight across a /clear does not put the old report back into the new conversation", async ($, on) => { + const world = stopWorld(on, ok(report("old"))); + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + world.duringNext = () => $.classic.SessionStart({ source: "clear", session_id: "B" } as never); + world.dropNext = true; + await $.prompt.submit({ text: "in flight, then blocked" }); + await $.prompt.submit({ text: "first in the new conversation" }); + + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + test("what each session was given is kept for the most recent sessions only", async ($, on) => { + // Twenty sessions already had report k; a twenty-first evicts the oldest, s1. + const shown = Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`s${i + 1}`, { key: "k", delivered: true }])); + const world = stopWorld(on, ok(report("k")), { shown }); + await $.classic.Stop({ stop_hook_active: false, session_id: "s21" }); + await $.prompt.submit({ text: "drain" }); + await $.classic.Stop({ stop_hook_active: false, session_id: "s20" }); + await $.prompt.submit({ text: "kept" }); + await $.classic.Stop({ stop_hook_active: false, session_id: "s1" }); + await $.prompt.submit({ text: "forgotten" }); + + expect(world.submitted[1]?.context ?? []).toEqual([]); + expect(world.submitted[2]?.context).toEqual([HANDED("k")]); + }); + + test("a report dropped by a resume is shown again when the same findings come back", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + // Resume B, then back to A, in this process: B's start drops A's report. + await $.classic.SessionStart({ source: "resume", session_id: "B" } as never); + await $.classic.SessionStart({ source: "resume", session_id: "A" } as never); + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + await $.prompt.submit({ text: "typed" }); + + expect(world.submitted[0]?.context).toEqual([HANDED("k")]); + }); + + test("a resume after the agent already had the report does not send it again", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + // As observed on 2.1.288: the prompt's turn starts inside its own submit. + world.duringNext = () => $.turn.start({ text: "first", turnId: "t1" }); + await $.prompt.submit({ text: "first" }); + await $.classic.SessionStart({ source: "resume", session_id: "A" } as never); + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + await $.prompt.submit({ text: "second" }); + + expect(world.submitted[0]?.context).toEqual([HANDED("k")]); + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + test("a report queued but never delivered survives a resume in a new process: it is queued again", async ($, on) => { + // A new process: nothing in $.state, only what the plugin's store kept from the old one. + const world = stopWorld(on, ok(report("k")), { shown: { A: { key: "k", delivered: false } } }); + await $.classic.SessionStart({ source: "resume", session_id: "A" } as never); + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + await $.prompt.submit({ text: "first after the resume" }); + + expect(world.submitted[0]?.context).toEqual([HANDED("k")]); + }); + + test("a report delivered before a resume in a new process is not sent again", async ($, on) => { + const world = stopWorld(on, ok(report("k")), { shown: { A: { key: "k", delivered: true } } }); + await $.classic.SessionStart({ source: "resume", session_id: "A" } as never); + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + await $.prompt.submit({ text: "first after the resume" }); + + expect(world.submitted[0]?.context ?? []).toEqual([]); + }); + + test("each report rides one prompt, its turn starting inside the submit, across many turns", async ($, on) => { + // The live bed's failure: turn.start fired inside next, before the prompt was held, + // so every other prompt re-delivered the same report. + const world = stopWorld(on, ok(report("k"))); + for (let turn = 1; turn <= 6; turn++) { + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + world.duringNext = () => $.turn.start({ text: `prompt ${turn}`, turnId: `t${turn}` }); + await $.prompt.submit({ text: `prompt ${turn}` }); + } + + const carried = world.submitted.map((p) => (p.context ?? []).length); + expect(carried).toEqual([1, 0, 0, 0, 0, 0]); + }); + + test("a compaction keeps the queued report, and what was shown stays shown", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.classic.SessionStart({ source: "compact" } as never); + await $.prompt.submit({ text: "first" }); + await $.classic.Stop({ stop_hook_active: false }); + await $.prompt.submit({ text: "second" }); + + expect(world.submitted[0]?.context).toEqual([HANDED("k")]); + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + test("a compaction does not grant another urgent turn", async ($, on) => { + let key = "a"; + const world = stopWorld(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, { urgent: `urgent ${key}` }) }) })); + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + await $.classic.SessionStart({ source: "compact" } as never); + key = "b"; + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + + expect(world.submitted.map((p) => p.text)).toEqual(["urgent a"]); + }); + + test("a compaction keeps what was queued", async ($, on) => { + stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.classic.SessionStart({ source: "compact" } as never); + + expect((await $.turn.complete(answered())).text).toBe(LINE); + }); + + for (const source of ["clear", "resume", "startup"]) { + test(`a ${source} in this process drops what the old conversation queued`, async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.classic.SessionStart({ source } as never); + + expect((await $.turn.complete(answered())).text).toBe("the answer"); + await $.prompt.submit({ text: "first in the new conversation" }); + expect(world.submitted.at(-1)?.context ?? []).toEqual([]); + }); + } + + test("once the person has the report, a line still waiting is dropped, not drawn late", async ($, on) => { + stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered({ reason: "refusal" })); + await $.prompt.submit({ text: "typed" }); + + expect((await $.turn.complete(answered())).text).toBe("the answer"); + }); + + test("a prompt dropped while a newer report was queued keeps the newer one", async ($, on) => { + let key = "old"; + const world = stopWorld(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key) }) })); + await $.classic.Stop({ stop_hook_active: false }); + world.duringNext = async () => { + key = "new"; + await $.classic.Stop({ stop_hook_active: false }); + }; + world.dropNext = true; + await $.prompt.submit({ text: "blocked" }); + await $.prompt.submit({ text: "typed" }); + + expect(world.submitted[1]?.context).toEqual([HANDED("new")]); + }); +}); + +describe("an urgent finding (#266)", () => { + const WITH_URGENT = (key: string, urgent: string) => `${HANDED(key)}\n\nUrgent: ${urgent}`; + + test("gets one turn of its own, framed as the plugin's, carrying the report with the finding in it", async ($, on) => { + const world = stopWorld(on, ok(report("k", { urgent: "push blocked" }))); + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + + expect(world.submitted.length).toBe(1); + expect(world.submitted[0]?.text).toBe("push blocked"); + expect(world.submitted[0]?.origin).toEqual(expect.objectContaining({ kind: "plugin", name: "obsidian-mind" })); + expect(world.submitted[0]?.context).toEqual([WITH_URGENT("k", "push blocked")]); + await $.prompt.submit({ text: "typed" }); + expect(world.submitted[1]?.context ?? []).toEqual([]); + }); + + for (const [how, set] of [ + ["dropped", (w: World) => (w.dropNext = true)], + ["failing", (w: World) => (w.throwNext = true)], + ] as const) { + test(`${how} below, its turn never starts and the next prompt carries the report with it`, async ($, on) => { + const world = stopWorld(on, ok(report("k", { urgent: "push blocked" }))); + await $.classic.Stop({ stop_hook_active: false }); + set(world); + await $.turn.complete(answered()); + await settle(); + await $.prompt.submit({ text: "typed" }); + + expect(world.submitted[1]?.context).toEqual([WITH_URGENT("k", "push blocked")]); + }); + } + + test("one urgent turn per prompt the person sends: the next finding waits, and fires again once they speak", async ($, on) => { + let key = "a"; + const world = stopWorld(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, { urgent: `urgent ${key}` }) }) })); + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + // The urgent turn ends with changed findings: no second turn of our own. + key = "b"; + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + expect(world.submitted.map((p) => p.text)).toEqual(["urgent a"]); + + // The person speaks and gets that report; their turn's new finding gets a turn again. + await $.prompt.submit({ text: "typed" }); + expect(world.submitted[1]?.context).toEqual([WITH_URGENT("b", "urgent b")]); + key = "c"; + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + expect(world.submitted.map((p) => p.text)).toEqual(["urgent a", "typed", "urgent c"]); + }); + + test("an urgent turn the person interrupts does not use up the next one", async ($, on) => { + let key = "a"; + const world = stopWorld(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, { urgent: `urgent ${key}` }) }) })); + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + await $.turn.complete(answered({ reason: "aborted" })); + await $.prompt.submit({ text: "typed" }); + key = "b"; + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + + expect(world.submitted.map((p) => p.text)).toEqual(["urgent a", "typed", "urgent b"]); + }); + + test("/clear gives the new conversation its own urgent turn", async ($, on) => { + let key = "a"; + const world = stopWorld(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, { urgent: `urgent ${key}` }) }) })); + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + await $.classic.SessionStart({ source: "clear" } as never); + key = "b"; + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + + expect(world.submitted.map((p) => p.text)).toEqual(["urgent a", "urgent b"]); + }); + + test("a newer report queued while a prompt was entering keeps its own line and urgent turn", async ($, on) => { + let key = "a"; + const world = stopWorld(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, key === "b" ? { urgent: "urgent b" } : {}) }) })); + await $.classic.Stop({ stop_hook_active: false }); + world.duringNext = async () => { + key = "b"; + await $.classic.Stop({ stop_hook_active: false }); + }; + await $.prompt.submit({ text: "typed" }); + expect(world.submitted[0]?.context).toEqual([HANDED("a")]); + + expect((await $.turn.complete(answered())).text).toBe(LINE_URGENT); + await settle(); + expect(world.submitted.map((p) => p.text)).toEqual(["typed", "urgent b"]); + }); + + test("a person's prompt dropped below does not renew the allowance: nothing of theirs reached the agent", async ($, on) => { + let key = "a"; + const world = stopWorld(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, { urgent: `urgent ${key}` }) }) })); + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + world.dropNext = true; + await $.prompt.submit({ text: "blocked" }); + key = "b"; + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + + expect(world.submitted.map((p) => p.text)).toEqual(["urgent a", "blocked"]); + }); + + test("a peer's message does not count as the person speaking", async ($, on) => { + let key = "a"; + const world = stopWorld(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, { urgent: `urgent ${key}` }) }) })); + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + await $.prompt.submit({ text: "from a peer", origin: { kind: "peer" } } as never); + key = "b"; + await $.classic.Stop({ stop_hook_active: false }); + await $.turn.complete(answered()); + await settle(); + + expect(world.submitted.map((p) => p.text)).toEqual(["urgent a", "from a peer"]); + }); +}); + +describe("parseStopReport", () => { + test("reads a complete report", () => { + const r = report("k", { urgent: "push blocked" }); + expect(parseStopReport(JSON.stringify({ report: r }))).toEqual(r); + }); + + test("refuses a report missing a field or with a wrong type", () => { + for (const bad of [ + {}, + { report: {} }, + { report: { ...report("k"), claims: "x" } }, + { report: { ...report("k"), claims: [1] } }, + { report: { ...report("k"), urgent: 1 } }, + ]) { + expect(() => parseStopReport(JSON.stringify(bad))).toThrow(); + } + }); +}); + +describe("summaryLine", () => { + test("names each finding in its own words and says where the rest went", () => { + expect(summaryLine(report("k", { claims: ["a", "b"] }))).toBe("vault check: a · b · the full report reaches the agent with your next message"); + }); + + test("an urgent finding says the report goes now", () => { + expect(summaryLine(report("k", { claims: ["a"], urgent: "push blocked" }))).toBe("vault check: a · the full report goes to the agent now"); + }); + + test("with no findings it still points at the checklist", () => { + expect(summaryLine(report("k", { claims: [] }))).toBe("vault check: wrap-up checklist · the full report reaches the agent with your next message"); + }); +}); + +describe("withLine", () => { + test("with nothing set below, the line is the text", () => { + expect(withLine("the answer", "the answer", "vault check: x")).toBe("vault check: x"); + }); + + test("a line another hook set below is kept, and ours follows it", () => { + expect(withLine("TL;DR: done", "the answer", "vault check: x")).toBe("TL;DR: done\nvault check: x"); + }); +}); + +describe("carriesReport", () => { + test("the person's own prompts carry it: typed, over Remote Control, through the SDK", () => { + for (const kind of ["composer", "bridge", "sdk"] as const) expect(carriesReport({ kind } as never)).toBe(true); + expect(carriesReport(undefined)).toBe(true); + }); + + test("only the person's own prompts count as the person speaking, never a plugin's", () => { + for (const kind of ["composer", "bridge", "sdk", "slack-ping"] as const) expect(fromPerson({ kind } as never)).toBe(true); + expect(fromPerson(undefined)).toBe(true); + expect(fromPerson({ kind: "plugin", name: "obsidian-mind" } as never)).toBe(false); + expect(fromPerson({ kind: "peer" } as never)).toBe(false); + }); + + test("this mod's own prompt carries it; another plugin's does not", () => { + expect(carriesReport({ kind: "plugin", name: "obsidian-mind" } as never)).toBe(true); + expect(carriesReport({ kind: "plugin", name: "someone-else" } as never)).toBe(false); + }); + + test("a peer, a notification or a schedule never consumes it", () => { + for (const kind of ["peer", "peer-send-message", "task-notification", "scheduled-trigger", "auto-continuation", "unclassified"] as const) { + expect(carriesReport({ kind } as never)).toBe(false); + } + }); +}); + +describe("ranIn", () => { + test("the same prompt, or one of the prompts folded into a turn, as whole lines", () => { + expect(ranIn("typed", "typed")).toBe(true); + expect(ranIn("first\nsecond", "second")).toBe(true); + expect(ranIn("first\n\nsecond", "first")).toBe(true); + expect(ranIn("first\nsecond line\nthird", "second line")).toBe(true); + }); + + test("never a substring: a short prompt inside another's text did not run", () => { + expect(ranIn("looks ok now", "ok")).toBe(false); + expect(ranIn("okay", "ok")).toBe(false); + expect(ranIn("a\nok then", "ok")).toBe(false); + }); +}); diff --git a/.claude/skills/obsidian-mind/hooks/stop.ts b/.claude/skills/obsidian-mind/hooks/stop.ts new file mode 100644 index 00000000..c1114ebc --- /dev/null +++ b/.claude/skills/obsidian-mind/hooks/stop.ts @@ -0,0 +1,82 @@ +import type { PromptOrigin } from "claude-code"; + +/** + * The Stop report as the mod receives it from `stop-checklist.ts` run with + * `om_mod: "report"` (#264), and how it is shown (#266). + */ +export type StopReport = { + /** The report's identity: the same findings give the same key. */ + readonly key: string; + /** One short claim per finding, e.g. "1 note(s) marked done but still in active/". */ + readonly claims: readonly string[]; + /** The full report, prefaced for the agent. */ + readonly agentText: string; + /** + * Set only for a finding that should not wait for the person's next + * message. The template's report has no such class today; a vault that adds + * one (say, an agent artifact in an unpushed commit) gets an immediate turn. + */ + readonly urgent?: string; +}; + +/** The report in `stop-checklist.ts`'s `report` output, or an error naming what was wrong. */ +export function parseStopReport(stdout: string): StopReport { + const report = (JSON.parse(stdout) as { report?: Partial }).report; + if ( + !report || + typeof report.key !== "string" || + !Array.isArray(report.claims) || + !report.claims.every((claim) => typeof claim === "string") || + typeof report.agentText !== "string" || + (report.urgent !== undefined && typeof report.urgent !== "string") + ) { + throw new Error(`stop-checklist.ts returned no usable report: ${stdout.slice(0, 200)}`); + } + return { key: report.key, claims: report.claims, agentText: report.agentText, ...(report.urgent !== undefined ? { urgent: report.urgent } : {}) }; +} + +/** + * The line drawn under the answer when the report changed: what drifted, in + * the report's own words, and where the rest went. Claude Code shows it after + * the mod's name (observed on 2.1.288: `obsidian-mind: …`). + */ +export function summaryLine(report: StopReport): string { + const what = report.claims.length > 0 ? report.claims.join(" · ") : "wrap-up checklist"; + // The settings hook's wording (lib/stop-report.ts SUMMARY_TRAILER), except that an + // urgent finding sends the report at once, in a turn of its own. + const where = report.urgent === undefined ? "the full report reaches the agent with your next message" : "the full report goes to the agent now"; + return `vault check: ${what} · ${where}`; +} + +/** + * The text to return from `turn.complete`. A hook below that already set a + * line of its own (its text differs from the answer) keeps it; ours goes + * after it rather than replacing it. + */ +export function withLine(textBelow: string, answer: string, line: string): string { + return textBelow !== answer && textBelow.trim() !== "" ? `${textBelow}\n${line}` : line; +} + +/** + * Whether a prompt from this origin should carry the queued report: the + * person's own prompts (typed, over Remote Control, or through the SDK) and + * this mod's urgent prompt. A peer's message, a notification or a schedule + * is not the person writing, and must not consume the report. + */ +/** + * Whether a turn that started with `turnText` ran the prompt `promptText`: + * the same text, or queued prompts folded into one turn with it among them as + * whole lines. Never a substring: a held "ok" is not run by "looks ok now". + */ +export function ranIn(turnText: string, promptText: string): boolean { + return `\n${turnText}\n`.includes(`\n${promptText}\n`); +} + +export function carriesReport(origin: PromptOrigin | undefined): boolean { + return fromPerson(origin) || (origin?.kind === "plugin" && origin.name === "obsidian-mind"); +} + +/** Whether the person sent this prompt: typed, over Remote Control, through the SDK, or as the session's owner pinging it from Slack. */ +export function fromPerson(origin: PromptOrigin | undefined): boolean { + return origin === undefined || origin.kind === "composer" || origin.kind === "bridge" || origin.kind === "sdk" || origin.kind === "slack-ping"; +} diff --git a/.claude/skills/obsidian-mind/hooks/world.ts b/.claude/skills/obsidian-mind/hooks/world.ts index 5cda21b7..073fad79 100644 --- a/.claude/skills/obsidian-mind/hooks/world.ts +++ b/.claude/skills/obsidian-mind/hooks/world.ts @@ -1,4 +1,4 @@ -import type { test } from "claude-code/testing"; +import { mock, type test } from "claude-code/testing"; // The engine and the vault beneath the mod, for the plugin tests. Each test's // `on` hooks sit below the mod; these stand in for the host's calls and for @@ -17,7 +17,7 @@ export type World = { /** Each script the mod ran: its argv and init (cwd, env, stdin). */ runs: Array<{ argv: readonly string[]; init?: { cwd?: string; env?: Record; stdin?: string } }>; /** Each settings-hook event the mod passed down, by event name. */ - passedDown: Record<"SessionStart", Array>>; + passedDown: Record<"SessionStart" | "Stop", Array>>; writes: Array<{ path: string; text: string }>; invalidated: string[]; }; @@ -27,9 +27,11 @@ export type World = { * on `$` is answered `{ value }`. `hangFirstWrite` makes the first `fs.write` * never settle, to prove delivery does not wait on it. */ -export function engine(on: On, reply: () => Reply, options: { hangFirstWrite?: boolean } = {}): World { - const world: World = { runs: [], passedDown: { SessionStart: [] }, writes: [], invalidated: [] }; +export function engine(on: On, reply: () => Reply, options: { hangFirstWrite?: boolean; store?: Readonly> } = {}): World { + const world: World = { runs: [], passedDown: { SessionStart: [], Stop: [] }, writes: [], invalidated: [] }; on("session.root", () => ({ value: ROOT })); + // The plugin's own key-value store, kept in memory for the test. + mock.store(on, options.store); on("process.run", (_$, e) => { world.runs.push(e as World["runs"][number]); const { exitCode, stdout, stderr = "", truncated = false } = reply(); @@ -48,5 +50,9 @@ export function engine(on: On, reply: () => Reply, options: { hangFirstWrite?: b world.passedDown.SessionStart.push(e as unknown as Record); return {}; }); + on("classic.Stop", (_$, e) => { + world.passedDown.Stop.push(e as unknown as Record); + return {}; + }); return world; } diff --git a/.claude/skills/obsidian-mind/types/index.d.ts b/.claude/skills/obsidian-mind/types/index.d.ts index 30ffbc7a..199f60dc 100644 --- a/.claude/skills/obsidian-mind/types/index.d.ts +++ b/.claude/skills/obsidian-mind/types/index.d.ts @@ -1,11 +1,30 @@ // The mod's per-session state: what the host keeps for it for the session, -// across a hot reload of the module. +// across a hot reload of the module. What must outlive the process (which +// report each session was shown) is in `$.store` instead; see register.ts. declare module "claude-code" { interface PluginState { "obsidian-mind": { /** The session context this session's instruction file carries; null when none was delivered. */ context: string | null; + /** + * The Stop report waiting to be delivered: the session it is for, its full text for the next + * prompt, and the line and urgent finding until the next completed + * answer uses them. Null when nothing is waiting. + */ + queued: { readonly sessionId: string; readonly key: string; readonly report: string; readonly line: string | null; readonly urgent: string | null } | null; + /** Whether an urgent finding has had its turn since the person last spoke. */ + urgentSpent: boolean; + /** Bumped by every start that begins another conversation (startup, /clear, resume, fork). */ + generation: number; + /** + * The report a prompt took, and the prompt's text, until a turn starts + * with that prompt. Null when none is. + */ + inFlight: { + readonly text: string; + readonly record: { readonly sessionId: string; readonly key: string; readonly report: string; readonly line: string | null; readonly urgent: string | null }; + } | null; }; } } diff --git a/.github/workflows/mod.yml b/.github/workflows/mod.yml index 4ad8252c..9e23d4bf 100644 --- a/.github/workflows/mod.yml +++ b/.github/workflows/mod.yml @@ -63,6 +63,10 @@ jobs: # A here-string, not a pipe: grep -q exits at its first match, and # under pipefail the tr it leaves writing fails a README that matches. grep -qxF -- "$line" <<<"$(sed 's/^[[:space:]]*//' "$readme" | tr -d '\r')" || { echo "::error::$readme does not show: $line"; exit 1; } + # And only once: a stale second copy (a translation artifact, an + # old version left behind) would show the reader the wrong list. + copies=$(grep -cE "register\.ts $kind[::]" "$readme" || true) + [ "$copies" = 1 ] || { echo "::error::$readme shows the $kind line $copies times"; exit 1; } done done - name: Test the mod diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a7ef02f0..ef47a130 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -218,6 +218,7 @@ Claude Code 2.1.287 added mods: plugins whose code runs inside Claude Code and c - **The hooks stay the engine.** Codex and Gemini have no mods, and on Claude Code the mod does not load everywhere (an older CLI, an untrusted folder, a session started in a subfolder, managed-only policy). So the mod adds no behaviour of its own: it runs the same scripts and only changes the channel their output reaches the session through. - **The switch rides on the event.** What the mod passes to `next()` is what the settings hooks read on stdin (`lib/om-mod.ts`). For an event it handles, the mod does its own work first and then passes `om_mod: "standdown"`, and the script exits. The flag exists only on events the mod's hook actually ran on: a mod that throws, crashed or never loaded sends the original event, and the hook runs as without it. An environment variable would outlive a dead mod and leave the hooks silent; answering the event without `next` would silence the user's own hooks too. - **SessionStart becomes an instruction file.** The mod runs `session-start.ts` with `om_mod: "deliver"` (the full layer, held to `eager_layer_instruction_budget_bytes`) and adds the output in `prompt.context` as a project instruction file. Claude Code re-reads instruction files after compaction and `/clear`, gives them to general-purpose subagents, and does not cut them at the hook limit. The text is kept in the mod's per-session state (`$.state`, which survives a hot reload of the module) and cleared (and redrawn) before every run that starts a conversation, so a run that fails there cannot leave an older context riding beside the full layer the settings hook then prints. A compaction keeps it: the hook's fallback there is only a pointer to a static half that, under the mod, was never in the conversation, so the last good context is the better thing to keep. It is also written to `.claude/session-context.md` (gitignored), which is where `/memory` points: unawaited, so delivery never waits on the file. The file is only what `/memory` shows: two runs seconds apart could in principle leave the older text on disk, and that case is accepted rather than guarded, since runs are minutes apart in practice. +- **Stop becomes a line under the answer.** The mod runs `stop-checklist.ts` with `om_mod: "report"`, which returns the report as data and claims no state; the mod keeps which report each session was last given, and whether it reached the agent, by session id, in `$.store` (a file Claude Code keeps per plugin, capped at the most recent sessions). A `claude --resume` in a new process then neither repeats a report the agent had nor loses one still waiting, matching the settings hook's file-backed handoff. A report counts as delivered when a turn starts with the prompt that carried it; that turn can start inside the prompt's own `prompt.submit` (observed on 2.1.288), so the prompt is recorded as carrying it before the hook calls `next`. What is waiting to be delivered is one record in `$.state`: the report, its line and its urgent finding, and the session it is for. When the findings change it draws one line under the next main-loop answer that completes (`turn.complete`; a subagent turn, an interrupt or an error keeps the line pending), after any line a hook below already set, and attaches the full report to the next prompt the person sends (`prompt.submit` context, never shown). Prompts from peers, notifications, schedules and other plugins do not carry it, and a prompt dropped below keeps it queued. A prompt enters when it is queued, not when its turn starts, and a queued prompt can be pulled back out of the queue unrun; so the report a prompt took is held until a turn starts with that prompt (or a folded turn that contains it), and if another prompt's turn starts first, the queue being first in, first out, it goes back to be delivered with the next prompt. A duplicate delivery is possible there; a lost one is not. A report marked `urgent` is submitted from `turn.complete` as a prompt of its own, framed as the plugin's rather than the person's. The finding also rides inside the queued report, so it reaches the agent whether or not its own turn happens, and one such turn runs per prompt the person sends that actually entered: findings that keep changing while the agent fixes them cannot chain turns. Once a prompt enters with the report, a line or urgent finding still waiting is dropped, unless a newer report was queued while it entered. Every session start except a compaction (startup, `/clear`, an in-process resume or fork) drops anything queued, since `$.state` is not reset there; a compaction continues the conversation and keeps it. A dropped report never reached the agent, so its session forgets having been shown it and the same findings show again; a report the agent already had stays shown. Each such start also bumps a generation counter, and a prompt that took the report and then failed to enter puts it back only if no such start happened meanwhile, so a report in flight across a `/clear` cannot land in the new conversation. State that may have changed while a hook awaited `next` is read through `update`, never `read`: every `$.state` read in one dispatch sees one moment. The report the mod receives carries its own framing (`MOD_PREFACE`), since the user saw a line rather than a summary and the prompt may be the plugin's. The submit never comes from `classic.Stop`: Claude Code refuses it there, because it would wait on the turn the hook may be holding. The template's report has no urgent class; the field is there for a vault that adds one. - **The work happens before `next`.** If the script fails, the mod's hook throws before passing the flag down, Claude Code skips it, and the settings hook delivers as usual. A failure after `next` resolved would keep the stood-down result, so nothing runs after it. --- diff --git a/CLAUDE.md b/CLAUDE.md index c2ce0fcb..0c95ac66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -446,7 +446,7 @@ Five lifecycle hooks in `.claude/settings.json`: | PreCompact | Before context compaction | Backs up session transcript to `thinking/session-logs/` | | Stop | After every response | Checklist + concrete vault-hygiene drift findings (same scan as SessionStart), shown once per session and again only when the findings change; hands drift to `om-tidy`. A Stop `systemMessage` reaches only the user, and every Stop output that reaches the agent is also printed in full for the user. So a changed report shows the user a one-line-per-section summary and saves the full report for the next prompt, where the UserPromptSubmit hook hands it to the agent unseen; the agent deals with the user's message first, then acts, asks, or leaves it. If the report cannot be saved, it goes out as Stop feedback instead. SessionEnd and a Stop without a session id show the full report. Also triggers the debounced QMD refresh. For thorough review, use `/om-wrap-up` instead. | -**On Claude Code 2.1.287+, the `obsidian-mind` mod** (`.claude/skills/obsidian-mind/`) delivers the SessionStart context as an instruction file instead: it runs `session-start.ts` itself and passes the settings hook `om_mod: "standdown"` for that event. The context then survives compaction whole and reaches general-purpose subagents (Explore and Plan skip instruction files by design); `/memory` shows it as `.claude/session-context.md`. Wherever the mod does not load (Codex, Gemini, older Claude Code, an untrusted folder, a session started in a vault subfolder), the hooks above run unchanged. +**On Claude Code 2.1.287+, the `obsidian-mind` mod** (`.claude/skills/obsidian-mind/`) delivers the SessionStart context as an instruction file instead: it runs `session-start.ts` itself and passes the settings hook `om_mod: "standdown"` for that event. The context then survives compaction whole and reaches general-purpose subagents (Explore and Plan skip instruction files by design); `/memory` shows it as `.claude/session-context.md`. It also presents the Stop report: it runs `stop-checklist.ts` with `om_mod: "report"`, stands the Stop hook down, draws one line under the answer when the findings changed, and hands you the full report with the user's next prompt (typed, over Remote Control, through the SDK or a Slack ping; a peer message, a notification or a scheduled prompt does not consume it), or with the mod's own prompt for an urgent finding, at most one per prompt the user sends. Wherever the mod does not load (Codex, Gemini, older Claude Code, an untrusted folder, a session started in a vault subfolder), the hooks above run unchanged. ## Write-Correctness Laws diff --git a/README.ja.md b/README.ja.md index c9941345..85cfac52 100644 --- a/README.ja.md +++ b/README.ja.md @@ -208,17 +208,18 @@ QMDは**3つの小さなモデルをローカルで**実行します。設定す Claude Code 2.1.287 以降では、ボールトは mod も同梱しています。`.claude/skills/obsidian-mind/` にある、Claude Code の内部で動くプラグインです。ボールト自身のフックスクリプトを実行し、その出力がセッションに届く経路だけを変えます。 - **セッションコンテキストは、`CLAUDE.md` と同じく instruction ファイルとして届きます。** コンパクションや `/clear` の後もそのまま全体が読み直され(フック出力ではポインタに縮みます)、汎用サブエージェントにも届き(フック出力は届きません)、Claude Code のフック出力の 10,000 文字制限で切られません。予算は `vault-manifest.json` の `eager_layer_instruction_budget_bytes` です。オープンタスクのように縮まないセクションは、それを超えることがあります。`/memory` には `.claude/session-context.md` として表示されます。 +- **Stop レポートは回答の下の 1 行になります。** 指摘が変わると、Claude の返答の下に `obsidian-mind: vault check: …` が表示され、Claude は完全なレポートを次のメッセージと一緒に、表示されない形で受け取ります。緊急とマークされた指摘は、代わりにすぐ Claude に届きます(あなたのメッセージ 1 通につき 1 回まで。2 件目はレポートの中で待ちます)。テンプレート自身のレポートにはそれはありません。 mod は処理するイベントごとに、対応するフックに待機を伝えます。mod が読み込まれない場所では、フックはこれまでどおり動きます。Codex と Gemini、古い Claude Code、ボールトのサブフォルダで始めたセッション(ボールトのルートで起動するか、そこへ `/cd` して `/clear`)、信頼していないフォルダです。mod は、ボールトに対する Claude Code の信頼確認を承認した後にだけ読み込まれます。 -mod はサンドボックス化されておらず、あなたの権限で動くコードです。フォルダを信頼する前に内容を確認してください。`claude plugin validate .claude/skills/obsidian-mind` が、フックするイベントと行う呼び出しをすべて一覧にします(ボールト自身のスクリプトを実行し、コンテキストファイルを書くだけです)。無効にするには、`.claude/settings.local.json` に `"enabledPlugins": { "obsidian-mind@skills-dir": false }` を追加します。 +mod はサンドボックス化されておらず、あなたの権限で動くコードです。フォルダを信頼する前に内容を確認してください。`claude plugin validate .claude/skills/obsidian-mind` が、フックするイベントと行う呼び出しをすべて一覧にします(ボールト自身のスクリプトを実行し、コンテキストファイルを書き、各セッションにどのレポートを見せたかを自分のストアに記録し、緊急の指摘があればプロンプトを送るだけです)。無効にするには、`.claude/settings.local.json` に `"enabledPlugins": { "obsidian-mind@skills-dir": false }` を追加します。 この版の mod で、その出力のうち確認すべき 2 行は次のとおりです: ```text - ❯ ./register.ts hooks: classic.SessionStart, prompt.context - ❯ ./register.ts calls: $.fs.write, $.process.run (via runScript), $.session.root, $.state.get, $.state.set, $.ui.invalidate + ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit, turn.start + ❯ ./register.ts calls: $.fs.write, $.process.run (via runScript), $.prompt.submit, $.session.root, $.state.get, $.state.set, $.store.get (via setShown, shownFor), $.store.set (via setShown), $.ui.invalidate ``` diff --git a/README.ko.md b/README.ko.md index eb2dd9fc..738febb4 100644 --- a/README.ko.md +++ b/README.ko.md @@ -208,17 +208,18 @@ QMD는 **세 개의 작은 모델을 로컬에서** 실행하므로, 설정할 A Claude Code 2.1.287 이상에서는 볼트가 mod도 함께 제공합니다. `.claude/skills/obsidian-mind/`에 있는, Claude Code 안에서 실행되는 플러그인입니다. 볼트 자체의 훅 스크립트를 실행하고, 그 출력이 세션에 전달되는 경로만 바꿉니다. - **세션 컨텍스트가 `CLAUDE.md`처럼 instruction 파일로 전달됩니다.** 컴팩션과 `/clear` 후에도 전체가 다시 읽히고(훅 출력은 포인터로 줄어듭니다), 범용 서브에이전트에도 전달되며(훅 출력은 전달되지 않습니다), Claude Code 훅 출력의 10,000자 제한에 잘리지 않습니다. 예산은 `vault-manifest.json`의 `eager_layer_instruction_budget_bytes`이며, 열린 작업처럼 줄어들지 않는 섹션은 이를 넘을 수 있습니다. `/memory`에는 `.claude/session-context.md`로 표시됩니다. +- **Stop 보고서가 답변 아래의 한 줄이 됩니다.** 발견 사항이 바뀌면 Claude의 답변 아래에 `obsidian-mind: vault check: …`가 표시되고, Claude는 전체 보고서를 다음 메시지와 함께 보이지 않게 받습니다. 긴급으로 표시된 발견 사항은 대신 즉시 Claude에게 전달됩니다(보내는 메시지 하나당 한 번까지이며, 두 번째는 보고서 안에서 기다립니다). 템플릿 자체의 보고서에는 그런 항목이 없습니다. mod는 처리하는 이벤트마다 해당 훅에 대기하라고 알립니다. mod가 로드되지 않는 곳에서는 훅이 이전과 똑같이 동작합니다. Codex와 Gemini, 이전 버전의 Claude Code, 볼트 하위 폴더에서 시작한 세션(볼트 루트에서 실행하거나, 그곳으로 `/cd`한 뒤 `/clear`), 신뢰하지 않은 폴더입니다. mod는 볼트에 대한 Claude Code의 신뢰 확인을 수락한 뒤에만 로드됩니다. -mod는 샌드박스 없이 사용자의 권한으로 실행되는 코드이므로, 폴더를 신뢰하기 전에 내용을 확인하세요. `claude plugin validate .claude/skills/obsidian-mind`가 훅하는 모든 이벤트와 수행하는 모든 호출을 나열합니다(볼트 자체의 스크립트를 실행하고 컨텍스트 파일을 쓰는 것이 전부입니다). 끄려면 `.claude/settings.local.json`에 `"enabledPlugins": { "obsidian-mind@skills-dir": false }`를 추가하세요. +mod는 샌드박스 없이 사용자의 권한으로 실행되는 코드이므로, 폴더를 신뢰하기 전에 내용을 확인하세요. `claude plugin validate .claude/skills/obsidian-mind`가 훅하는 모든 이벤트와 수행하는 모든 호출을 나열합니다(볼트 자체의 스크립트를 실행하고, 컨텍스트 파일을 쓰고, 각 세션에 어떤 보고서를 보여 줬는지 자체 저장소에 기록하고, 긴급한 발견 사항이 있으면 프롬프트를 보내는 것이 전부입니다). 끄려면 `.claude/settings.local.json`에 `"enabledPlugins": { "obsidian-mind@skills-dir": false }`를 추가하세요. 이 버전의 mod에서 출력 중 확인할 두 줄은 다음과 같습니다: ```text - ❯ ./register.ts hooks: classic.SessionStart, prompt.context - ❯ ./register.ts calls: $.fs.write, $.process.run (via runScript), $.session.root, $.state.get, $.state.set, $.ui.invalidate + ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit, turn.start + ❯ ./register.ts calls: $.fs.write, $.process.run (via runScript), $.prompt.submit, $.session.root, $.state.get, $.state.set, $.store.get (via setShown, shownFor), $.store.set (via setShown), $.ui.invalidate ``` diff --git a/README.md b/README.md index 50bc01fd..237b7ff9 100644 --- a/README.md +++ b/README.md @@ -215,17 +215,18 @@ Five lifecycle hooks handle routing automatically: On Claude Code 2.1.287 or later, the vault also ships a mod: `.claude/skills/obsidian-mind/`, a plugin whose code runs inside Claude Code. It runs the vault's own hook scripts and changes only how their output reaches the session: - **Session context arrives as an instruction file**, the way `CLAUDE.md` does. It is re-read whole after compaction and `/clear` (as hook output it shrinks to a pointer), it reaches general-purpose subagents (hook output never does), and it is not cut at Claude Code's 10,000-character hook limit. Its budget is `eager_layer_instruction_budget_bytes` in `vault-manifest.json`; sections that never shrink, such as open tasks, can still take it past that. `/memory` lists it as `.claude/session-context.md`. +- **The Stop report becomes one line under the answer.** When the findings change you see `obsidian-mind: vault check: …` beneath Claude's reply, and Claude gets the full report with your next message, unseen. A finding marked urgent gets Claude's attention at once instead, once per message you send (a second one waits inside the report); the template's own report has none. For each event it handles, the mod tells the matching hook to stand down. Wherever the mod does not load, the hooks run exactly as before: Codex and Gemini, older Claude Code, a session started in a vault subfolder (launch from the vault root, or `/cd` there and `/clear`), or a folder you have not trusted. It loads only after you accept Claude Code's trust prompt for the vault. -A mod is unsandboxed code that runs with your permissions, so check what it does before trusting the folder: `claude plugin validate .claude/skills/obsidian-mind` lists every event it hooks and every call it makes (it runs the vault's own scripts and writes the context file, nothing else). To turn it off, add `"enabledPlugins": { "obsidian-mind@skills-dir": false }` to `.claude/settings.local.json`. +A mod is unsandboxed code that runs with your permissions, so check what it does before trusting the folder: `claude plugin validate .claude/skills/obsidian-mind` lists every event it hooks and every call it makes (it runs the vault's own scripts, writes the context file, remembers in its own store which report each session was shown, and can submit a prompt for an urgent finding, nothing else). To turn it off, add `"enabledPlugins": { "obsidian-mind@skills-dir": false }` to `.claude/settings.local.json`. The two lines that matter in its output, for this version of the mod: ```text - ❯ ./register.ts hooks: classic.SessionStart, prompt.context - ❯ ./register.ts calls: $.fs.write, $.process.run (via runScript), $.session.root, $.state.get, $.state.set, $.ui.invalidate + ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit, turn.start + ❯ ./register.ts calls: $.fs.write, $.process.run (via runScript), $.prompt.submit, $.session.root, $.state.get, $.state.set, $.store.get (via setShown, shownFor), $.store.set (via setShown), $.ui.invalidate ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 7bea487f..5efd7720 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -208,17 +208,18 @@ QMD **在本地运行三个小模型**,因此不需要配置 API 密钥,没 在 Claude Code 2.1.287 及以上版本中,仓库还附带一个 mod:`.claude/skills/obsidian-mind/`,一个在 Claude Code 内部运行的插件。它运行仓库自身的钩子脚本,只改变其输出到达会话的方式: - **会话上下文像 `CLAUDE.md` 一样以 instruction 文件的形式送达。** 压缩和 `/clear` 之后会被完整地重新读取(作为钩子输出时会缩减为一个指针),能送达通用子代理(钩子输出送达不了),也不会被 Claude Code 钩子输出的 10,000 字符上限截断。其预算是 `vault-manifest.json` 中的 `eager_layer_instruction_budget_bytes`;像未完成任务这样不会缩减的部分仍可能超出它。`/memory` 中显示为 `.claude/session-context.md`。 +- **Stop 报告变成回答下方的一行。** 发现项变化时,你会在 Claude 的回复下方看到 `obsidian-mind: vault check: …`,而 Claude 会随你的下一条消息收到完整报告,对你不可见。标记为紧急的发现项则会立即送达 Claude(你每发送一条消息最多一次,第二个会在报告中等待);模板自身的报告中没有这类发现项。 对于它处理的每个事件,mod 会通知对应的钩子让出。在 mod 未加载的地方,钩子与以前完全一样地运行:Codex 和 Gemini、旧版 Claude Code、在仓库子文件夹中启动的会话(请在仓库根目录启动,或 `/cd` 到根目录后执行 `/clear`)、以及未信任的文件夹。只有在你接受了 Claude Code 对该仓库的信任提示之后,mod 才会加载。 -mod 是没有沙箱、以你的权限运行的代码,因此在信任该文件夹之前请先检查它的行为:`claude plugin validate .claude/skills/obsidian-mind` 会列出它挂钩的每个事件和发出的每个调用(它只运行仓库自身的脚本并写入上下文文件)。要关闭它,在 `.claude/settings.local.json` 中加入 `"enabledPlugins": { "obsidian-mind@skills-dir": false }`。 +mod 是没有沙箱、以你的权限运行的代码,因此在信任该文件夹之前请先检查它的行为:`claude plugin validate .claude/skills/obsidian-mind` 会列出它挂钩的每个事件和发出的每个调用(它只运行仓库自身的脚本、写入上下文文件、在自己的存储中记录每个会话看过哪份报告,并在有紧急发现项时提交一条提示)。要关闭它,在 `.claude/settings.local.json` 中加入 `"enabledPlugins": { "obsidian-mind@skills-dir": false }`。 对于这一版本的 mod,其输出中值得确认的两行如下: ```text - ❯ ./register.ts hooks: classic.SessionStart, prompt.context - ❯ ./register.ts calls: $.fs.write, $.process.run (via runScript), $.session.root, $.state.get, $.state.set, $.ui.invalidate + ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit, turn.start + ❯ ./register.ts calls: $.fs.write, $.process.run (via runScript), $.prompt.submit, $.session.root, $.state.get, $.state.set, $.store.get (via setShown, shownFor), $.store.set (via setShown), $.ui.invalidate ```