From ff5406681bb8e21e99bbcb7261dada9542487304 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 00:33:51 +0200 Subject: [PATCH 01/20] wip: Stop report via the mod (pre-rebase) --- .../skills/obsidian-mind/hooks/register.ts | 81 +++++++++-- .../skills/obsidian-mind/hooks/stop.test.ts | 134 ++++++++++++++++++ .claude/skills/obsidian-mind/hooks/stop.ts | 46 ++++++ .claude/skills/obsidian-mind/types/index.d.ts | 12 +- 4 files changed, 256 insertions(+), 17 deletions(-) create mode 100644 .claude/skills/obsidian-mind/hooks/stop.test.ts create mode 100644 .claude/skills/obsidian-mind/hooks/stop.ts diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index 062cb7ab..92e94374 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 { withSessionContext } from "./context.ts"; +import { atom, read, update, type EngineInterface, type Register } from 'claude-code' +import { withSessionContext } from './context.ts' +import { parseStopReport, summaryLine } from './stop.ts' /** * obsidian-mind's Claude Code mod (#262). @@ -17,22 +18,29 @@ 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 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. - */ -const sessionContext = atom({ plugin: "obsidian-mind", key: "context" } as const, null); +const sessionContext = atom({ plugin: 'obsidian-mind', key: 'context' } as const, null) +const shownReport = atom({ plugin: 'obsidian-mind', key: 'shownReport' } as const, null) +const pendingLine = atom({ plugin: 'obsidian-mind', key: 'pendingLine' } as const, null) +const pendingReport = atom({ plugin: 'obsidian-mind', key: 'pendingReport' } as const, null) +const pendingUrgent = atom({ plugin: 'obsidian-mind', key: 'pendingUrgent' } as const, null) /** 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 { @@ -76,10 +84,53 @@ export const register: Register = (on) => { return next({ ...e, om_mod: "standdown" } as typeof e); }); - on("prompt.context", async ($, e, next) => { - const below = await next(e); - const text = await read($, sessionContext); - if (text === null) return below; - return withSessionContext(below, `${await $.session.root()}/${CONTEXT_FILE}`, text); - }); -}; + on('prompt.context', async ($, e, next) => { + const below = await next(e) + const text = await read($, sessionContext) + 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() + const report = parseStopReport(await runScript($, root, 'stop-checklist.ts', { ...e, om_mod: 'report' })) + // Keyed by session too, so a new session shows its first report even + // with the same findings, as the settings hook's dedupe does. + const identity = `${e.session_id}:${report.key}` + if ((await read($, shownReport)) !== identity) { + await update($, shownReport, () => identity) + await update($, pendingLine, () => summaryLine(report)) + await update($, pendingReport, () => report.agentText) + await update($, pendingUrgent, () => 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; a subagent's turn draws nothing. + if (e.agentId !== undefined) return done + const line = await read($, pendingLine) + if (line === null) return done + await update($, pendingLine, () => null) + const urgent = await read($, pendingUrgent) + if (urgent !== null) { + // 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. + await update($, pendingUrgent, () => null) + $.prompt.submit({ text: urgent, asUser: true }).catch(() => { + // Not submitted: the report still rides the user's next prompt. + }) + } + return { ...done, text: line } + }) + + on('prompt.submit', async ($, e, next) => { + const report = await read($, pendingReport) + if (report === null) return next(e) + await update($, pendingReport, () => null) + return next({ ...e, context: [...(e.context ?? []), report] }) + }) +} 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..686f4b47 --- /dev/null +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from 'claude-code/testing' +import { parseStopReport, summaryLine, 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. +// turn.complete is not a call a test can raise, so the line under the answer +// is checked through summaryLine and in a live session. + +const ROOT = '/vault' +const report = (key: string, extra: Partial = {}): StopReport => ({ + key, + summary: 'Wrap-up checklist: archive completed work\nHygiene: 1 note(s) marked done but still in active/\nThe full report reaches the agent with your next message.', + claims: ['1 note(s) marked done but still in active/'], + agentText: `Stop hook report, handed over with this message: ${key}`, + ...extra, +}) + +type World = { runs: string[]; passedDown: Array>; submitted: Array<{ text: string; context?: readonly string[] }> } + +/** The world beneath the mod. `reply` is what stop-checklist.ts prints, per call. */ +function vault(on: Parameters[1], (...args: never[]) => unknown>>[1], reply: () => { exitCode: number; stdout: string }): World { + const world: World = { runs: [], passedDown: [], submitted: [] } + on('session.root', () => ({ value: ROOT })) + on('process.run', (_$, e) => { + world.runs.push(e.init?.stdin ?? '') + const { exitCode, stdout } = reply() + return { value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } } + }) + on('classic.Stop', (_$, e) => { + world.passedDown.push(e as unknown as Record) + return {} + }) + on('prompt.submit', (_$, e) => { + world.submitted.push({ text: e.text, context: e.context }) + return { text: e.text, context: e.context } + }) + return world +} + +const ok = (r: StopReport) => () => ({ exitCode: 0, stdout: JSON.stringify({ report: r }) }) + +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 = vault(on, ok(report('k1'))) + await $.classic.Stop({ stop_hook_active: false }) + + expect(world.runs.length).toBe(1) + expect(JSON.parse(world.runs[0] ?? '{}')).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(['Stop hook report, handed over with this message: 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 = vault(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(['Stop hook report, handed over with this message: 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 = vault(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(['Stop hook report, handed over with this message: b']) + }) + + // "Each session sees its own first report" needs no test of the mod's: what + // was shown lives in `$.state`, which the host keeps per session and resets + // on `/clear`, the two ways a new session starts. + + test('a forced turn passes straight through: no run, no flag', async ($, on) => { + const world = vault(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 = vault(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 = vault(on, () => ({ exitCode: 0, stdout: '{"report":{"key":"k"}}' })) + await $.classic.Stop({ stop_hook_active: false }) + + expect(world.runs.length).toBe(1) + expect(world.passedDown[0]?.['om_mod']).toBe(undefined) + }) +}) + +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'), 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('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') + }) +}) diff --git a/.claude/skills/obsidian-mind/hooks/stop.ts b/.claude/skills/obsidian-mind/hooks/stop.ts new file mode 100644 index 00000000..7616a58e --- /dev/null +++ b/.claude/skills/obsidian-mind/hooks/stop.ts @@ -0,0 +1,46 @@ +/** + * The Stop report as the mod receives it from `stop-checklist.ts` run with + * `om_mod: "report"` (#264), and the one line the user sees for it (#266). + */ +export type StopReport = { + /** The report's identity: the same findings give the same key. */ + readonly key: string + /** The one-line-per-section summary the settings hook would show. */ + readonly summary: 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 user'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' || + typeof report.summary !== 'string' || + !Array.isArray(report.claims) || + 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 report as StopReport +} + +/** + * 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 prefixes it + * with the mod's name. + */ +export function summaryLine(report: StopReport): string { + const what = report.claims.length > 0 ? report.claims.join(' · ') : 'wrap-up checklist' + return `vault check: ${what} · the full report reaches the agent with your next message` +} diff --git a/.claude/skills/obsidian-mind/types/index.d.ts b/.claude/skills/obsidian-mind/types/index.d.ts index 30ffbc7a..e4773f1b 100644 --- a/.claude/skills/obsidian-mind/types/index.d.ts +++ b/.claude/skills/obsidian-mind/types/index.d.ts @@ -5,7 +5,15 @@ 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; - }; + context: string | null + /** The session id and identity of the last Stop report shown. */ + shownReport: string | null + /** The line to draw under the next main-loop answer. */ + pendingLine: string | null + /** The full report, for the next prompt. */ + pendingReport: string | null + /** An urgent finding, for a turn of its own. */ + pendingUrgent: string | null + } } } From 1b6e075564d86f2932be20b1e07815f6f3bb7d7d Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 00:35:06 +0200 Subject: [PATCH 02/20] docs: the Stop report under the mod --- ARCHITECTURE.md | 3 ++- CLAUDE.md | 2 +- README.ja.md | 14 +++----------- README.ko.md | 3 ++- README.md | 3 ++- README.zh-CN.md | 14 +++----------- 6 files changed, 13 insertions(+), 26 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a7ef02f0..7a031842 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -217,7 +217,8 @@ 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. +- **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 before every run, so a run that fails can never leave an older context riding beside the hook's fresh output. 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 the identity of the last report it showed in `$.state`. When the findings change it draws one line under the main-loop answer (`turn.complete`) and attaches the full report to the next prompt (`prompt.submit` context, never shown). A report marked `urgent` is submitted as a prompt of its own from `turn.complete`: Claude Code refuses a submit from inside `classic.Stop`, 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..d94321c6 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 next prompt, the same contract as above. 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..1df4a9d4 100644 --- a/README.ja.md +++ b/README.ja.md @@ -207,20 +207,12 @@ 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` として表示されます。 +- **セッションコンテキストは、`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 に届きます。テンプレート自身のレポートにはそれはありません。 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 で、その出力のうち確認すべき 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 -``` - +mod はサンドボックス化されておらず、あなたの権限で動くコードです。フォルダを信頼する前に内容を確認してください。`claude plugin validate .claude/skills/obsidian-mind` が、フックするイベントと行う呼び出しをすべて一覧にします(Vault 自身のスクリプトを実行し、コンテキストファイルを書き、緊急の指摘があればプロンプトを送るだけです)。無効にするには、`.claude/settings.local.json` に `"enabledPlugins": { "obsidian-mind@skills-dir": false }` を追加します。 ### ⚡ トークン効率 diff --git a/README.ko.md b/README.ko.md index eb2dd9fc..4ac89882 100644 --- a/README.ko.md +++ b/README.ko.md @@ -208,10 +208,11 @@ 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에서 출력 중 확인할 두 줄은 다음과 같습니다: diff --git a/README.md b/README.md index 50bc01fd..133a38ab 100644 --- a/README.md +++ b/README.md @@ -215,10 +215,11 @@ 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; 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, 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: diff --git a/README.zh-CN.md b/README.zh-CN.md index 7bea487f..6f11f0b2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -207,20 +207,12 @@ 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`。 +- **会话上下文像 `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,其输出中值得确认的两行如下: - -```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 -``` - +mod 是没有沙箱、以你的权限运行的代码,因此在信任该文件夹之前请先检查它的行为:`claude plugin validate .claude/skills/obsidian-mind` 会列出它挂钩的每个事件和发出的每个调用(它只运行仓库自身的脚本、写入上下文文件,并在有紧急发现项时提交一条提示)。要关闭它,在 `.claude/settings.local.json` 中加入 `"enabledPlugins": { "obsidian-mind@skills-dir": false }`。 ### ⚡ Token 效率 From 42c4cd0906bff9649a806497496c630d93ecdfef Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 00:45:26 +0200 Subject: [PATCH 03/20] =?UTF-8?q?mod:=20Stop=20report=20audit=20fixes=20?= =?UTF-8?q?=E2=80=94=20session-keyed=20identity,=20origin=20filter,=20drop?= =?UTF-8?q?ped=20prompts=20keep=20the=20report,=20line=20after=20a=20lower?= =?UTF-8?q?=20hook's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/register.ts | 24 ++++--- .../skills/obsidian-mind/hooks/stop.test.ts | 69 ++++++++++++++++--- .claude/skills/obsidian-mind/hooks/stop.ts | 41 ++++++++--- ARCHITECTURE.md | 2 +- CLAUDE.md | 2 +- 5 files changed, 109 insertions(+), 29 deletions(-) diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index 92e94374..7874dee6 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -1,6 +1,6 @@ import { atom, read, update, type EngineInterface, type Register } from 'claude-code' import { withSessionContext } from './context.ts' -import { parseStopReport, summaryLine } from './stop.ts' +import { carriesReport, parseStopReport, summaryLine, withLine } from './stop.ts' /** * obsidian-mind's Claude Code mod (#262). @@ -110,8 +110,9 @@ export const register: Register = (on) => { on('turn.complete', async ($, e, next) => { const done = await next(e) - // Only under a main-loop answer; a subagent's turn draws nothing. - if (e.agentId !== undefined) return done + // 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 const line = await read($, pendingLine) if (line === null) return done await update($, pendingLine, () => null) @@ -119,18 +120,23 @@ export const register: Register = (on) => { if (urgent !== null) { // 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 full report rides it (prompt.submit below). await update($, pendingUrgent, () => null) - $.prompt.submit({ text: urgent, asUser: true }).catch(() => { - // Not submitted: the report still rides the user's next prompt. + $.prompt.submit({ text: urgent }).catch(() => { + // Not submitted: the report stays queued for the person's next prompt. }) } - return { ...done, text: line } + return { ...done, text: withLine(done.text, e.answer, line) } }) on('prompt.submit', async ($, e, next) => { const report = await read($, pendingReport) - if (report === null) return next(e) - await update($, pendingReport, () => null) - return next({ ...e, context: [...(e.context ?? []), report] }) + if (report === null || !carriesReport(e.origin)) return next(e) + const entered = await next({ ...e, context: [...(e.context ?? []), report] }) + // Cleared only once a prompt actually entered with it: a prompt dropped + // or blocked below keeps the report for the next one. + if (entered.drop === undefined) await update($, pendingReport, () => null) + return entered }) } diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index 686f4b47..05ad2095 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'claude-code/testing' -import { parseStopReport, summaryLine, type StopReport } from './stop.ts' +import { carriesReport, parseStopReport, 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. @@ -9,17 +9,16 @@ import { parseStopReport, summaryLine, type StopReport } from './stop.ts' const ROOT = '/vault' const report = (key: string, extra: Partial = {}): StopReport => ({ key, - summary: 'Wrap-up checklist: archive completed work\nHygiene: 1 note(s) marked done but still in active/\nThe full report reaches the agent with your next message.', claims: ['1 note(s) marked done but still in active/'], agentText: `Stop hook report, handed over with this message: ${key}`, ...extra, }) -type World = { runs: string[]; passedDown: Array>; submitted: Array<{ text: string; context?: readonly string[] }> } +type World = { runs: string[]; passedDown: Array>; submitted: Array<{ text: string; context?: readonly string[] }>; dropNext: boolean } /** The world beneath the mod. `reply` is what stop-checklist.ts prints, per call. */ function vault(on: Parameters[1], (...args: never[]) => unknown>>[1], reply: () => { exitCode: number; stdout: string }): World { - const world: World = { runs: [], passedDown: [], submitted: [] } + const world: World = { runs: [], passedDown: [], submitted: [], dropNext: false } on('session.root', () => ({ value: ROOT })) on('process.run', (_$, e) => { world.runs.push(e.init?.stdin ?? '') @@ -32,6 +31,11 @@ function vault(on: Parameters[1], (...args: neve }) on('prompt.submit', (_$, e) => { world.submitted.push({ text: e.text, context: e.context }) + // 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 } }) return world @@ -79,9 +83,28 @@ describe('Stop report (#266)', () => { expect(world.submitted[1]?.context).toEqual(['Stop hook report, handed over with this message: b']) }) - // "Each session sees its own first report" needs no test of the mod's: what - // was shown lives in `$.state`, which the host keeps per session and resets - // on `/clear`, the two ways a new session starts. + 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 = vault(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(['Stop hook report, handed over with this message: shared']) + expect(world.submitted[1]?.context).toEqual(['Stop hook report, handed over with this message: shared']) + }) + + test('a prompt that is dropped below keeps the report for the next one', async ($, on) => { + const world = vault(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(['Stop hook report, handed over with this message: kept']) + }) test('a forced turn passes straight through: no run, no flag', async ($, on) => { const world = vault(on, ok(report('k'))) @@ -104,9 +127,11 @@ describe('Stop report (#266)', () => { test('an unusable report counts as a failure too', async ($, on) => { const world = vault(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([]) }) }) @@ -117,7 +142,7 @@ describe('parseStopReport', () => { }) 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'), urgent: 1 } }]) { + 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() } }) @@ -132,3 +157,31 @@ describe('summaryLine', () => { 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("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) + } + }) +}) diff --git a/.claude/skills/obsidian-mind/hooks/stop.ts b/.claude/skills/obsidian-mind/hooks/stop.ts index 7616a58e..87021683 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.ts @@ -1,20 +1,20 @@ +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 the one line the user sees for it (#266). + * `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 - /** The one-line-per-section summary the settings hook would show. */ - readonly summary: 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 user'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. + * 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 } @@ -25,22 +25,43 @@ export function parseStopReport(stdout: string): StopReport { if ( !report || typeof report.key !== 'string' || - typeof report.summary !== '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 report as StopReport + 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 prefixes it - * with the mod's name. + * 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' return `vault check: ${what} · the full report reaches the agent with your next message` } + +/** + * 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. + */ +export function carriesReport(origin: PromptOrigin | undefined): boolean { + if (origin === undefined) return true + if (origin.kind === 'plugin') return origin.name === 'obsidian-mind' + return origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk' +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7a031842..dfcb2db4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -218,7 +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 before every run, so a run that fails can never leave an older context riding beside the hook's fresh output. 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 the identity of the last report it showed in `$.state`. When the findings change it draws one line under the main-loop answer (`turn.complete`) and attaches the full report to the next prompt (`prompt.submit` context, never shown). A report marked `urgent` is submitted as a prompt of its own from `turn.complete`: Claude Code refuses a submit from inside `classic.Stop`, 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. +- **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 the identity of the last report it showed (session id plus report key) in `$.state`. 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 report marked `urgent` is submitted from `turn.complete` as a prompt of its own, framed as the plugin's rather than the person's: Claude Code refuses a submit from inside `classic.Stop`, 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 d94321c6..dd9530ed 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`. 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 next prompt, the same contract as above. 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 next prompt the person sends (typed, over Remote Control or through the SDK; a peer message, a notification or a scheduled prompt does not consume it). 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 From fd75a6ec7ebd79dc8c350b5721e4c7409639ccaa Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 00:47:19 +0200 Subject: [PATCH 04/20] =?UTF-8?q?mod:=20a=20peer's=20prompt=20does=20not?= =?UTF-8?q?=20consume=20the=20Stop=20report=20=E2=80=94=20tested=20at=20th?= =?UTF-8?q?e=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .claude/skills/obsidian-mind/hooks/stop.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index 05ad2095..abe4b1a2 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -106,6 +106,16 @@ describe('Stop report (#266)', () => { expect(world.submitted[1]?.context).toEqual(['Stop hook report, handed over with this message: kept']) }) + test("a peer's message passes without the report; the person's next prompt gets it", async ($, on) => { + const world = vault(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(['Stop hook report, handed over with this message: mine']) + }) + test('a forced turn passes straight through: no run, no flag', async ($, on) => { const world = vault(on, ok(report('k'))) await $.classic.Stop({ stop_hook_active: true }) From bcd049e8e4178ffb00432d2521fd8ef97594edd4 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 00:55:36 +0200 Subject: [PATCH 05/20] =?UTF-8?q?mod:=20Stop=20audit=20round=202=20?= =?UTF-8?q?=E2=80=94=20/clear=20drops=20the=20queue,=20an=20urgent=20findi?= =?UTF-8?q?ng=20is=20never=20lost=20or=20chained,=20one=20prompt=20takes?= =?UTF-8?q?=20the=20report,=20turn.complete=20tested=20at=20the=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../obsidian-mind/.claude-plugin/plugin.json | 4 +- .../skills/obsidian-mind/hooks/register.ts | 77 +++++++--- .../skills/obsidian-mind/hooks/stop.test.ts | 139 +++++++++++++++++- .claude/skills/obsidian-mind/types/index.d.ts | 2 + 4 files changed, 194 insertions(+), 28 deletions(-) diff --git a/.claude/skills/obsidian-mind/.claude-plugin/plugin.json b/.claude/skills/obsidian-mind/.claude-plugin/plugin.json index baeb3d79..a62bdd73 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.", + "version": "1.0.0", + "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 7874dee6..3c9315a0 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -41,6 +41,13 @@ const shownReport = atom({ plugin: 'obsidian-mind', key: 'shownReport' } as cons const pendingLine = atom({ plugin: 'obsidian-mind', key: 'pendingLine' } as const, null) const pendingReport = atom({ plugin: 'obsidian-mind', key: 'pendingReport' } as const, null) const pendingUrgent = atom({ plugin: 'obsidian-mind', key: 'pendingUrgent' } as const, null) +/** Set while the turn our urgent prompt started is running, so it cannot start another. */ +const urgentTurn = atom({ plugin: 'obsidian-mind', key: 'urgentTurn' } as const, null) + +/** Fold an urgent finding that never got its own turn into the queued report, so it is not lost. */ +async function keepUrgent($: EngineInterface, urgent: string): Promise { + await update($, pendingReport, (report) => `${report ?? ''}${report ? '\n\n' : ''}Urgent, not yet seen: ${urgent}`) +} /** 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 { @@ -59,17 +66,21 @@ 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. - if (e.source !== "compact") { - await update($, sessionContext, () => null); - $.ui.invalidate("prompt.context"); + on('classic.SessionStart', async ($, e, next) => { + // A new conversation (startup, /clear) drops what an earlier one queued: + // `/clear` keeps the process and its `$.state`, and a report about the + // old conversation must not ride the first prompt of the new one. A + // compaction or a resume continues the conversation, so it keeps it. + if (e.source === 'startup' || e.source === 'clear') { + // One call per atom: the validator reads each state source statically. + await update($, pendingLine, () => null) + await update($, pendingReport, () => null) + await update($, pendingUrgent, () => null) + await update($, urgentTurn, () => null) } + // Cleared first: if this run fails, the settings hook delivers fresh + // output and no earlier context may ride beside it. + await update($, sessionContext, () => null); const root = await $.session.root(); const text = await runScript($, root, "session-start.ts", { ...e, om_mod: "deliver" }); await update($, sessionContext, () => text); @@ -113,19 +124,34 @@ export const register: Register = (on) => { // 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 turn our own urgent prompt started has ended: the next urgent + // finding waits for the person rather than starting another turn. + const wasUrgentTurn = (await read($, urgentTurn)) !== null + await update($, urgentTurn, () => null) const line = await read($, pendingLine) if (line === null) return done await update($, pendingLine, () => null) const urgent = await read($, pendingUrgent) if (urgent !== null) { - // 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 full report rides it (prompt.submit below). await update($, pendingUrgent, () => null) - $.prompt.submit({ text: urgent }).catch(() => { - // Not submitted: the report stays queued for the person's next prompt. - }) + if (wasUrgentTurn) { + // At most one turn of our own in a row: findings that keep changing + // while the agent fixes them must not chain turns. + await keepUrgent($, urgent) + } else { + // 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 full report rides it (prompt.submit + // below). If it never enters, the finding joins the queued report. + await update($, urgentTurn, () => urgent) + $.prompt.submit({ text: urgent }).then( + async (entered) => { + if (entered.drop !== undefined) await keepUrgent($, urgent) + }, + () => keepUrgent($, urgent), + ) + } } return { ...done, text: withLine(done.text, e.answer, line) } }) @@ -133,10 +159,19 @@ export const register: Register = (on) => { on('prompt.submit', async ($, e, next) => { const report = await read($, pendingReport) if (report === null || !carriesReport(e.origin)) return next(e) - const entered = await next({ ...e, context: [...(e.context ?? []), report] }) - // Cleared only once a prompt actually entered with it: a prompt dropped - // or blocked below keeps the report for the next one. - if (entered.drop === undefined) await update($, pendingReport, () => null) + // Taken before `next`, so two prompts entering at once cannot both carry + // it; put back if this one never enters (dropped or blocked below, or a + // throw), unless a newer report was queued meanwhile. + await update($, pendingReport, () => null) + const putBack = () => update($, pendingReport, (now) => now ?? report) + let entered: Awaited> + try { + entered = await next({ ...e, context: [...(e.context ?? []), report] }) + } catch (error) { + await putBack() + throw error + } + if (entered.drop !== undefined) await putBack() return entered }) } diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index abe4b1a2..201c58bc 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -3,8 +3,8 @@ import { carriesReport, parseStopReport, summaryLine, withLine, type StopReport // 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. -// turn.complete is not a call a test can raise, so the line under the answer -// is checked through summaryLine and in a live session. +// 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 ROOT = '/vault' const report = (key: string, extra: Partial = {}): StopReport => ({ @@ -14,11 +14,20 @@ const report = (key: string, extra: Partial = {}): StopReport => ({ ...extra, }) -type World = { runs: string[]; passedDown: Array>; submitted: Array<{ text: string; context?: readonly string[] }>; dropNext: boolean } +type World = { + runs: string[] + passedDown: Array> + submitted: Array<{ text: string; context?: readonly string[]; origin?: unknown }> + /** The next prompt is dropped below, or the next one throws below. */ + dropNext: boolean + throwNext: boolean + /** A line another hook below sets under the answer, if any. */ + lowerLine: string | null +} /** The world beneath the mod. `reply` is what stop-checklist.ts prints, per call. */ function vault(on: Parameters[1], (...args: never[]) => unknown>>[1], reply: () => { exitCode: number; stdout: string }): World { - const world: World = { runs: [], passedDown: [], submitted: [], dropNext: false } + const world: World = { runs: [], passedDown: [], submitted: [], dropNext: false, throwNext: false, lowerLine: null } on('session.root', () => ({ value: ROOT })) on('process.run', (_$, e) => { world.runs.push(e.init?.stdin ?? '') @@ -30,7 +39,11 @@ function vault(on: Parameters[1], (...args: neve return {} }) on('prompt.submit', (_$, e) => { - world.submitted.push({ text: e.text, context: e.context }) + world.submitted.push({ text: e.text, context: e.context, origin: e.origin }) + 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 @@ -38,11 +51,24 @@ function vault(on: Parameters[1], (...args: neve } return { text: e.text, context: e.context } }) + on('turn.complete', (_$, e) => ({ text: world.lowerLine ?? e.answer })) + on('fs.write', () => ({ value: undefined })) + on('ui.invalidate', () => ({ value: undefined })) + on('classic.SessionStart', () => ({})) return world } 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 HANDED = (key: string) => `Stop hook report, handed over with this message: ${key}` + 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 = vault(on, ok(report('k1'))) @@ -145,6 +171,109 @@ describe('Stop report (#266)', () => { }) }) +describe('the line under the answer (#266)', () => { + test('drawn once, under the next completed answer', async ($, on) => { + vault(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) => { + vault(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 = vault(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 = vault(on, ok(report('k'))) + await $.classic.Stop({ stop_hook_active: false }) + await $.turn.complete(answered()) + await settle() + + expect(world.submitted.length).toBe(0) + }) + + test('/clear drops what the old conversation queued; a compaction keeps it', async ($, on) => { + const world = vault(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) + + await $.classic.SessionStart({ source: 'clear' } 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([]) + }) +}) + +describe('an urgent finding (#266)', () => { + test("gets one turn of its own, framed as the plugin's, carrying the report", async ($, on) => { + const world = vault(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([HANDED('k')]) + await $.prompt.submit({ text: 'typed' }) + expect(world.submitted[1]?.context ?? []).toEqual([]) + }) + + test('dropped below, it joins the report the next prompt carries', async ($, on) => { + const world = vault(on, ok(report('k', { urgent: 'push blocked' }))) + await $.classic.Stop({ stop_hook_active: false }) + world.dropNext = true + await $.turn.complete(answered()) + await settle() + await $.prompt.submit({ text: 'typed' }) + + expect(world.submitted[1]?.context).toEqual([`${HANDED('k')}\n\nUrgent, not yet seen: push blocked`]) + }) + + test('failing below, it joins the report the next prompt carries', async ($, on) => { + const world = vault(on, ok(report('k', { urgent: 'push blocked' }))) + await $.classic.Stop({ stop_hook_active: false }) + world.throwNext = true + await $.turn.complete(answered()) + await settle() + await $.prompt.submit({ text: 'typed' }) + + expect(world.submitted[1]?.context).toEqual([`${HANDED('k')}\n\nUrgent, not yet seen: push blocked`]) + }) + + test('the turn it started cannot start another: a new urgent finding waits for the person', async ($, on) => { + let key = 'a' + const world = vault(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() + 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']) + await $.prompt.submit({ text: 'typed' }) + expect(world.submitted[1]?.context).toEqual([`${HANDED('b')}\n\nUrgent, not yet seen: urgent b`]) + }) +}) + describe('parseStopReport', () => { test('reads a complete report', () => { const r = report('k', { urgent: 'push blocked' }) diff --git a/.claude/skills/obsidian-mind/types/index.d.ts b/.claude/skills/obsidian-mind/types/index.d.ts index e4773f1b..2188d95b 100644 --- a/.claude/skills/obsidian-mind/types/index.d.ts +++ b/.claude/skills/obsidian-mind/types/index.d.ts @@ -14,6 +14,8 @@ declare module "claude-code" { pendingReport: string | null /** An urgent finding, for a turn of its own. */ pendingUrgent: string | null + /** The urgent finding whose turn is running, so that turn cannot start another. */ + urgentTurn: string | null } } } From e2a0f42715f04b98a10efa997ee59df52da5a17b Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 00:56:03 +0200 Subject: [PATCH 06/20] docs: the Stop queue's lifetime, urgent fallback and the mod's framing Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dfcb2db4..a466165b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -218,7 +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 before every run, so a run that fails can never leave an older context riding beside the hook's fresh output. 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 the identity of the last report it showed (session id plus report key) in `$.state`. 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 report marked `urgent` is submitted from `turn.complete` as a prompt of its own, framed as the plugin's rather than the person's: Claude Code refuses a submit from inside `classic.Stop`, 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. +- **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 the identity of the last report it showed (session id plus report key) in `$.state`. 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 report marked `urgent` is submitted from `turn.complete` as a prompt of its own, framed as the plugin's rather than the person's. At most one such turn runs in a row; an urgent finding that cannot get one (its prompt was dropped or failed, or the turn ending is itself an urgent one) joins the queued report instead. A new conversation (startup, `/clear`) drops anything queued, since nothing documents `$.state` being reset there; a compaction keeps it. 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. --- From 082cfebebd9b2278659b26952fcb6904d8ceaae7 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 01:02:07 +0200 Subject: [PATCH 07/20] =?UTF-8?q?mod:=20Stop=20audit=20round=203=20?= =?UTF-8?q?=E2=80=94=20the=20urgent=20finding=20rides=20inside=20the=20rep?= =?UTF-8?q?ort,=20one=20urgent=20turn=20per=20prompt=20the=20person=20send?= =?UTF-8?q?s,=20any=20start=20but=20a=20compaction=20drops=20the=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/register.ts | 73 ++++----- .../skills/obsidian-mind/hooks/stop.test.ts | 142 ++++++++++++++---- .claude/skills/obsidian-mind/hooks/stop.ts | 11 +- .claude/skills/obsidian-mind/types/index.d.ts | 4 +- 4 files changed, 158 insertions(+), 72 deletions(-) diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index 3c9315a0..09f57006 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -1,6 +1,6 @@ import { atom, read, update, type EngineInterface, type Register } from 'claude-code' import { withSessionContext } from './context.ts' -import { carriesReport, parseStopReport, summaryLine, withLine } from './stop.ts' +import { carriesReport, fromPerson, parseStopReport, summaryLine, withLine } from './stop.ts' /** * obsidian-mind's Claude Code mod (#262). @@ -41,13 +41,8 @@ const shownReport = atom({ plugin: 'obsidian-mind', key: 'shownReport' } as cons const pendingLine = atom({ plugin: 'obsidian-mind', key: 'pendingLine' } as const, null) const pendingReport = atom({ plugin: 'obsidian-mind', key: 'pendingReport' } as const, null) const pendingUrgent = atom({ plugin: 'obsidian-mind', key: 'pendingUrgent' } as const, null) -/** Set while the turn our urgent prompt started is running, so it cannot start another. */ -const urgentTurn = atom({ plugin: 'obsidian-mind', key: 'urgentTurn' } as const, null) - -/** Fold an urgent finding that never got its own turn into the queued report, so it is not lost. */ -async function keepUrgent($: EngineInterface, urgent: string): Promise { - await update($, pendingReport, (report) => `${report ?? ''}${report ? '\n\n' : ''}Urgent, not yet seen: ${urgent}`) -} +/** Set once an urgent finding got a turn of its own; cleared when the person next speaks. */ +const urgentSpent = atom({ plugin: 'obsidian-mind', key: 'urgentSpent' } as const, null) /** 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 { @@ -67,16 +62,16 @@ async function runScript($: EngineInterface, root: string, script: string, input export const register: Register = (on) => { on('classic.SessionStart', async ($, e, next) => { - // A new conversation (startup, /clear) drops what an earlier one queued: - // `/clear` keeps the process and its `$.state`, and a report about the - // old conversation must not ride the first prompt of the new one. A - // compaction or a resume continues the conversation, so it keeps it. - if (e.source === 'startup' || e.source === 'clear') { + // 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') { // One call per atom: the validator reads each state source statically. await update($, pendingLine, () => null) await update($, pendingReport, () => null) await update($, pendingUrgent, () => null) - await update($, urgentTurn, () => null) + await update($, urgentSpent, () => null) } // Cleared first: if this run fails, the settings hook delivers fresh // output and no earlier context may ride beside it. @@ -113,7 +108,9 @@ export const register: Register = (on) => { if ((await read($, shownReport)) !== identity) { await update($, shownReport, () => identity) await update($, pendingLine, () => summaryLine(report)) - await update($, pendingReport, () => report.agentText) + // The urgent finding rides inside the report too, so whatever happens + // to its own turn, the agent gets it with the report. + await update($, pendingReport, () => (report.urgent === undefined ? report.agentText : `${report.agentText}\n\nUrgent: ${report.urgent}`)) await update($, pendingUrgent, () => report.urgent ?? null) } return next({ ...e, om_mod: 'standdown' } as typeof e) @@ -124,39 +121,28 @@ export const register: Register = (on) => { // 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 turn our own urgent prompt started has ended: the next urgent - // finding waits for the person rather than starting another turn. - const wasUrgentTurn = (await read($, urgentTurn)) !== null - await update($, urgentTurn, () => null) const line = await read($, pendingLine) if (line === null) return done await update($, pendingLine, () => null) const urgent = await read($, pendingUrgent) - if (urgent !== null) { - await update($, pendingUrgent, () => null) - if (wasUrgentTurn) { - // At most one turn of our own in a row: findings that keep changing - // while the agent fixes them must not chain turns. - await keepUrgent($, urgent) - } else { - // 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 full report rides it (prompt.submit - // below). If it never enters, the finding joins the queued report. - await update($, urgentTurn, () => urgent) - $.prompt.submit({ text: urgent }).then( - async (entered) => { - if (entered.drop !== undefined) await keepUrgent($, urgent) - }, - () => keepUrgent($, urgent), - ) - } + await update($, pendingUrgent, () => null) + // 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)) === null) { + await update($, urgentSpent, () => urgent) + // 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) => { + if (fromPerson(e.origin)) await update($, urgentSpent, () => null) const report = await read($, pendingReport) if (report === null || !carriesReport(e.origin)) return next(e) // Taken before `next`, so two prompts entering at once cannot both carry @@ -171,7 +157,14 @@ export const register: Register = (on) => { await putBack() throw error } - if (entered.drop !== undefined) await putBack() + if (entered.drop !== undefined) { + await putBack() + return entered + } + // The report is with the agent: a line still waiting would say it is + // coming, and an urgent finding still waiting is already in it. + await update($, pendingLine, () => null) + await update($, pendingUrgent, () => null) return entered }) } diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index 201c58bc..e4be4c46 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'claude-code/testing' -import { carriesReport, parseStopReport, summaryLine, withLine, type StopReport } from './stop.ts' +import { carriesReport, fromPerson, parseStopReport, 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. @@ -23,11 +23,13 @@ type World = { throwNext: boolean /** A line another hook below sets under the answer, if any. */ lowerLine: string | null + /** Runs inside the next prompt's submit, before it enters or is dropped. */ + duringNext: (() => Promise) | null } /** The world beneath the mod. `reply` is what stop-checklist.ts prints, per call. */ function vault(on: Parameters[1], (...args: never[]) => unknown>>[1], reply: () => { exitCode: number; stdout: string }): World { - const world: World = { runs: [], passedDown: [], submitted: [], dropNext: false, throwNext: false, lowerLine: null } + const world: World = { runs: [], passedDown: [], submitted: [], dropNext: false, throwNext: false, lowerLine: null, duringNext: null } on('session.root', () => ({ value: ROOT })) on('process.run', (_$, e) => { world.runs.push(e.init?.stdin ?? '') @@ -38,8 +40,11 @@ function vault(on: Parameters[1], (...args: neve world.passedDown.push(e as unknown as Record) return {} }) - on('prompt.submit', (_$, e) => { + 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') @@ -66,7 +71,7 @@ const answered = (extra: Record = {}) => ({ answer: 'the answer /** 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 = 'vault check: 1 note(s) marked done but still in active/ · the full report reaches the agent with the next message' const HANDED = (key: string) => `Stop hook report, handed over with this message: ${key}` describe('Stop report (#266)', () => { @@ -207,21 +212,55 @@ describe('the line under the answer (#266)', () => { expect(world.submitted.length).toBe(0) }) - test('/clear drops what the old conversation queued; a compaction keeps it', async ($, on) => { - const world = vault(on, ok(report('k'))) + test('a compaction keeps what was queued', async ($, on) => { + vault(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 = vault(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) => { + vault(on, ok(report('k'))) + await $.classic.Stop({ stop_hook_active: false }) + await $.turn.complete(answered({ reason: 'refusal' })) + await $.prompt.submit({ text: 'typed' }) - await $.classic.SessionStart({ source: 'clear' } 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('a prompt dropped while a newer report was queued keeps the newer one', async ($, on) => { + let key = 'old' + const world = vault(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)', () => { - test("gets one turn of its own, framed as the plugin's, carrying the report", async ($, on) => { + 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 = vault(on, ok(report('k', { urgent: 'push blocked' }))) await $.classic.Stop({ stop_hook_active: false }) await $.turn.complete(answered()) @@ -230,47 +269,91 @@ describe('an urgent finding (#266)', () => { 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([HANDED('k')]) + expect(world.submitted[0]?.context).toEqual([WITH_URGENT('k', 'push blocked')]) await $.prompt.submit({ text: 'typed' }) expect(world.submitted[1]?.context ?? []).toEqual([]) }) - test('dropped below, it joins the report the next prompt carries', async ($, on) => { - const world = vault(on, ok(report('k', { urgent: 'push blocked' }))) + 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 = vault(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 = vault(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, { urgent: `urgent ${key}` }) }) })) await $.classic.Stop({ stop_hook_active: false }) - world.dropNext = true await $.turn.complete(answered()) await settle() - await $.prompt.submit({ text: 'typed' }) + // 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']) - expect(world.submitted[1]?.context).toEqual([`${HANDED('k')}\n\nUrgent, not yet seen: push blocked`]) + // 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('failing below, it joins the report the next prompt carries', async ($, on) => { - const world = vault(on, ok(report('k', { urgent: 'push blocked' }))) + test('an urgent turn the person interrupts does not use up the next one', async ($, on) => { + let key = 'a' + const world = vault(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, { urgent: `urgent ${key}` }) }) })) await $.classic.Stop({ stop_hook_active: false }) - world.throwNext = true 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[1]?.context).toEqual([`${HANDED('k')}\n\nUrgent, not yet seen: push blocked`]) + expect(world.submitted.map((p) => p.text)).toEqual(['urgent a', 'typed', 'urgent b']) }) - test('the turn it started cannot start another: a new urgent finding waits for the person', async ($, on) => { + test('/clear gives the new conversation its own urgent turn', async ($, on) => { let key = 'a' const world = vault(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']) - await $.prompt.submit({ text: 'typed' }) - expect(world.submitted[1]?.context).toEqual([`${HANDED('b')}\n\nUrgent, not yet seen: urgent b`]) + expect(world.submitted.map((p) => p.text)).toEqual(['urgent a', 'urgent b']) + }) + + test("a peer's message does not count as the person speaking", async ($, on) => { + let key = 'a' + const world = vault(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']) }) }) @@ -289,11 +372,11 @@ describe('parseStopReport', () => { 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') + expect(summaryLine(report('k', { claims: ['a', 'b'] }))).toBe('vault check: a · b · the full report reaches the agent with the next message') }) 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') + expect(summaryLine(report('k', { claims: [] }))).toBe('vault check: wrap-up checklist · the full report reaches the agent with the next message') }) }) @@ -313,6 +396,13 @@ describe('carriesReport', () => { 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'] 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) diff --git a/.claude/skills/obsidian-mind/hooks/stop.ts b/.claude/skills/obsidian-mind/hooks/stop.ts index 87021683..be6d739f 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.ts @@ -42,7 +42,7 @@ export function parseStopReport(stdout: string): StopReport { */ export function summaryLine(report: StopReport): string { const what = report.claims.length > 0 ? report.claims.join(' · ') : 'wrap-up checklist' - return `vault check: ${what} · the full report reaches the agent with your next message` + return `vault check: ${what} · the full report reaches the agent with the next message` } /** @@ -61,7 +61,10 @@ export function withLine(textBelow: string, answer: string, line: string): strin * is not the person writing, and must not consume the report. */ export function carriesReport(origin: PromptOrigin | undefined): boolean { - if (origin === undefined) return true - if (origin.kind === 'plugin') return origin.name === 'obsidian-mind' - return origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk' + return fromPerson(origin) || (origin?.kind === 'plugin' && origin.name === 'obsidian-mind') +} + +/** Whether the person sent this prompt: typed, over Remote Control, or through the SDK. */ +export function fromPerson(origin: PromptOrigin | undefined): boolean { + return origin === undefined || origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk' } diff --git a/.claude/skills/obsidian-mind/types/index.d.ts b/.claude/skills/obsidian-mind/types/index.d.ts index 2188d95b..84d99d5e 100644 --- a/.claude/skills/obsidian-mind/types/index.d.ts +++ b/.claude/skills/obsidian-mind/types/index.d.ts @@ -14,8 +14,8 @@ declare module "claude-code" { pendingReport: string | null /** An urgent finding, for a turn of its own. */ pendingUrgent: string | null - /** The urgent finding whose turn is running, so that turn cannot start another. */ - urgentTurn: string | null + /** The urgent finding that got a turn of its own; cleared when the person next speaks. */ + urgentSpent: string | null } } } From 6060fdd42f5b37dd364d6ca742c05c548a8e12a0 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 01:03:13 +0200 Subject: [PATCH 08/20] =?UTF-8?q?docs:=20the=20Stop=20queue=20after=20roun?= =?UTF-8?q?d=203=20=E2=80=94=20urgent=20inside=20the=20report,=20one=20urg?= =?UTF-8?q?ent=20turn=20per=20prompt,=20every=20start=20but=20a=20compacti?= =?UTF-8?q?on=20drops=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a466165b..e7568322 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -218,7 +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 before every run, so a run that fails can never leave an older context riding beside the hook's fresh output. 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 the identity of the last report it showed (session id plus report key) in `$.state`. 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 report marked `urgent` is submitted from `turn.complete` as a prompt of its own, framed as the plugin's rather than the person's. At most one such turn runs in a row; an urgent finding that cannot get one (its prompt was dropped or failed, or the turn ending is itself an urgent one) joins the queued report instead. A new conversation (startup, `/clear`) drops anything queued, since nothing documents `$.state` being reset there; a compaction keeps it. 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. +- **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 the identity of the last report it showed (session id plus report key) in `$.state`. 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 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: 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. Every session start except a compaction (startup, `/clear`, an in-process resume or fork) drops anything queued, since nothing documents `$.state` being reset there; a compaction continues the conversation and keeps it. 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. --- From 822fa767fdded05d94e726c08f39a77b026b6753 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 01:12:16 +0200 Subject: [PATCH 09/20] =?UTF-8?q?mod:=20Stop=20audit=20round=204=20?= =?UTF-8?q?=E2=80=94=20a=20newer=20report=20keeps=20its=20line,=20a=20resu?= =?UTF-8?q?me=20forgets=20what=20was=20shown,=20only=20an=20entered=20prom?= =?UTF-8?q?pt=20renews=20the=20urgent=20allowance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/register.ts | 28 +++++++++--- .../skills/obsidian-mind/hooks/stop.test.ts | 45 ++++++++++++++++++- .claude/skills/obsidian-mind/hooks/stop.ts | 4 +- ARCHITECTURE.md | 2 +- CLAUDE.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- 9 files changed, 74 insertions(+), 15 deletions(-) diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index 09f57006..b6e3695b 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -72,6 +72,8 @@ export const register: Register = (on) => { await update($, pendingReport, () => null) await update($, pendingUrgent, () => null) await update($, urgentSpent, () => null) + // What was dropped was never shown here, so the same findings show again. + await update($, shownReport, () => null) } // Cleared first: if this run fails, the settings hook delivers fresh // output and no earlier context may ride beside it. @@ -142,9 +144,13 @@ export const register: Register = (on) => { }) on('prompt.submit', async ($, e, next) => { - if (fromPerson(e.origin)) await update($, urgentSpent, () => null) + // 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, () => null) + return entered + } const report = await read($, pendingReport) - if (report === null || !carriesReport(e.origin)) return next(e) + if (report === null || !carriesReport(e.origin)) return renew(await next(e)) // Taken before `next`, so two prompts entering at once cannot both carry // it; put back if this one never enters (dropped or blocked below, or a // throw), unless a newer report was queued meanwhile. @@ -162,9 +168,19 @@ export const register: Register = (on) => { return entered } // The report is with the agent: a line still waiting would say it is - // coming, and an urgent finding still waiting is already in it. - await update($, pendingLine, () => null) - await update($, pendingUrgent, () => null) - return entered + // coming, and an urgent finding still waiting is already in it. Unless a + // newer report was queued while this prompt was entering: the line and + // the urgent finding waiting now are that one's. Checked through `update`, + // whose function sees writes made while `next` ran; `read` here may not. + let newer = false + await update($, pendingReport, (now) => { + newer = now !== null + return now + }) + if (!newer) { + await update($, pendingLine, () => null) + await update($, pendingUrgent, () => null) + } + return renew(entered) }) } diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index e4be4c46..786783b8 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -212,6 +212,17 @@ describe('the line under the answer (#266)', () => { expect(world.submitted.length).toBe(0) }) + test('a report dropped by a resume is shown again when the same findings come back', async ($, on) => { + const world = vault(on, ok(report('k'))) + await $.classic.Stop({ stop_hook_active: false, session_id: 'A' }) + await $.classic.SessionStart({ source: 'resume' } as never) + await $.classic.SessionStart({ source: 'resume' } 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 compaction keeps what was queued', async ($, on) => { vault(on, ok(report('k'))) await $.classic.Stop({ stop_hook_active: false }) @@ -341,6 +352,38 @@ describe('an urgent finding (#266)', () => { 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 = vault(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) + 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 = vault(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 = vault(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, { urgent: `urgent ${key}` }) }) })) @@ -397,7 +440,7 @@ describe('carriesReport', () => { }) test("only the person's own prompts count as the person speaking, never a plugin's", () => { - for (const kind of ['composer', 'bridge', 'sdk'] as const) expect(fromPerson({ kind } as never)).toBe(true) + 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) diff --git a/.claude/skills/obsidian-mind/hooks/stop.ts b/.claude/skills/obsidian-mind/hooks/stop.ts index be6d739f..087f7846 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.ts @@ -64,7 +64,7 @@ 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, or through the SDK. */ +/** 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' + return origin === undefined || origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk' || origin.kind === 'slack-ping' } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e7568322..86f66ad4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -218,7 +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 before every run, so a run that fails can never leave an older context riding beside the hook's fresh output. 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 the identity of the last report it showed (session id plus report key) in `$.state`. 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 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: 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. Every session start except a compaction (startup, `/clear`, an in-process resume or fork) drops anything queued, since nothing documents `$.state` being reset there; a compaction continues the conversation and keeps it. 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. +- **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 the identity of the last report it showed (session id plus report key) in `$.state`. 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 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. A start that drops the queue also forgets which report was shown, so the same findings show again. Every session start except a compaction (startup, `/clear`, an in-process resume or fork) drops anything queued, since nothing documents `$.state` being reset there; a compaction continues the conversation and keeps it. 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 dd9530ed..3c242d43 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`. 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 next prompt the person sends (typed, over Remote Control or through the SDK; a peer message, a notification or a scheduled prompt does not consume it). 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 next prompt the person sends (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 person 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 1df4a9d4..c209f6f3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -208,7 +208,7 @@ 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 に届きます。テンプレート自身のレポートにはそれはありません。 +- **Stop レポートは回答の下の 1 行になります。** 指摘が変わると、Claude の返答の下に `obsidian-mind: vault check: …` が表示され、Claude は完全なレポートを次のメッセージと一緒に、表示されない形で受け取ります。緊急とマークされた指摘は、代わりにすぐ Claude に届きます(あなたのメッセージ 1 通につき 1 回まで。2 件目はレポートの中で待ちます)。テンプレート自身のレポートにはそれはありません。 mod は処理するイベントごとに、対応するフックに待機を伝えます。mod が読み込まれない場所では、フックはこれまでどおり動きます。Codex と Gemini、古い Claude Code、ボールトのサブフォルダで始めたセッション(ボールトのルートで起動するか、そこへ `/cd` して `/clear`)、信頼していないフォルダです。mod は、ボールトに対する Claude Code の信頼確認を承認した後にだけ読み込まれます。 diff --git a/README.ko.md b/README.ko.md index 4ac89882..243d351d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -208,7 +208,7 @@ 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에게 전달됩니다. 템플릿 자체의 보고서에는 그런 항목이 없습니다. +- **Stop 보고서가 답변 아래의 한 줄이 됩니다.** 발견 사항이 바뀌면 Claude의 답변 아래에 `obsidian-mind: vault check: …`가 표시되고, Claude는 전체 보고서를 다음 메시지와 함께 보이지 않게 받습니다. 긴급으로 표시된 발견 사항은 대신 즉시 Claude에게 전달됩니다(보내는 메시지 하나당 한 번까지이며, 두 번째는 보고서 안에서 기다립니다). 템플릿 자체의 보고서에는 그런 항목이 없습니다. mod는 처리하는 이벤트마다 해당 훅에 대기하라고 알립니다. mod가 로드되지 않는 곳에서는 훅이 이전과 똑같이 동작합니다. Codex와 Gemini, 이전 버전의 Claude Code, 볼트 하위 폴더에서 시작한 세션(볼트 루트에서 실행하거나, 그곳으로 `/cd`한 뒤 `/clear`), 신뢰하지 않은 폴더입니다. mod는 볼트에 대한 Claude Code의 신뢰 확인을 수락한 뒤에만 로드됩니다. diff --git a/README.md b/README.md index 133a38ab..7dc5b6a8 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ 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; the template's own report has none. +- **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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 6f11f0b2..45b98adf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -208,7 +208,7 @@ 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;模板自身的报告中没有这类发现项。 +- **Stop 报告变成回答下方的一行。** 发现项变化时,你会在 Claude 的回复下方看到 `obsidian-mind: vault check: …`,而 Claude 会随你的下一条消息收到完整报告,对你不可见。标记为紧急的发现项则会立即送达 Claude(你每发送一条消息最多一次,第二个会在报告中等待);模板自身的报告中没有这类发现项。 对于它处理的每个事件,mod 会通知对应的钩子让出。在 mod 未加载的地方,钩子与以前完全一样地运行:Codex 和 Gemini、旧版 Claude Code、在仓库子文件夹中启动的会话(请在仓库根目录启动,或 `/cd` 到根目录后执行 `/clear`)、以及未信任的文件夹。只有在你接受了 Claude Code 对该仓库的信任提示之后,mod 才会加载。 From 5f8ba6922ed54419b89b536061e522d7a3e83e41 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 01:17:32 +0200 Subject: [PATCH 10/20] =?UTF-8?q?mod:=20Stop=20audit=20round=205=20?= =?UTF-8?q?=E2=80=94=20a=20resume=20forgets=20only=20a=20report=20that=20n?= =?UTF-8?q?ever=20reached=20the=20agent;=20compaction=20keeps=20report,=20?= =?UTF-8?q?shown=20identity=20and=20allowance,=20each=20pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/register.ts | 11 ++++-- .../skills/obsidian-mind/hooks/stop.test.ts | 39 +++++++++++++++++++ ARCHITECTURE.md | 2 +- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index b6e3695b..fd0b8bb5 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -68,12 +68,17 @@ export const register: Register = (on) => { // the first prompt of this one, so what was queued is dropped. if (e.source !== 'compact') { // One call per atom: the validator reads each state source statically. + let dropped = false + await update($, pendingReport, (now) => { + dropped = now !== null + return null + }) await update($, pendingLine, () => null) - await update($, pendingReport, () => null) await update($, pendingUrgent, () => null) await update($, urgentSpent, () => null) - // What was dropped was never shown here, so the same findings show again. - await update($, shownReport, () => null) + // A report dropped here never reached the agent, so the same findings + // show again; one it already has stays shown. + if (dropped) await update($, shownReport, () => null) } // Cleared first: if this run fails, the settings hook delivers fresh // output and no earlier context may ride beside it. diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index 786783b8..f1c2bdb9 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -223,6 +223,45 @@ describe('the line under the answer (#266)', () => { 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 = vault(on, ok(report('k'))) + await $.classic.Stop({ stop_hook_active: false, session_id: 'A' }) + await $.prompt.submit({ text: 'first' }) + await $.classic.SessionStart({ source: 'resume' } 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 compaction keeps the queued report, and what was shown stays shown', async ($, on) => { + const world = vault(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 = vault(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) => { vault(on, ok(report('k'))) await $.classic.Stop({ stop_hook_active: false }) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 86f66ad4..17aab447 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -218,7 +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 before every run, so a run that fails can never leave an older context riding beside the hook's fresh output. 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 the identity of the last report it showed (session id plus report key) in `$.state`. 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 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. A start that drops the queue also forgets which report was shown, so the same findings show again. Every session start except a compaction (startup, `/clear`, an in-process resume or fork) drops anything queued, since nothing documents `$.state` being reset there; a compaction continues the conversation and keeps it. 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. +- **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 the identity of the last report it showed (session id plus report key) in `$.state`. 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 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. A start that drops a queued report also forgets which report was shown, so the same findings show again; one the agent already had stays shown. That memory lives in `$.state`, so it lasts as long as the process: a `claude --resume` in a new process shows unchanged findings once more, where the settings hook's file-backed dedupe would not. Every session start except a compaction (startup, `/clear`, an in-process resume or fork) drops anything queued, since nothing documents `$.state` being reset there; a compaction continues the conversation and keeps it. 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. --- From 8d1890cd41e0185caf6ebdfb686cd45fff349e49 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 02:21:13 +0200 Subject: [PATCH 11/20] simplify: one queued-report record instead of three atoms; the urgent allowance a flag; the Stop run held to the hook's own 5s; the Stop suite on the shared test world Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/register.ts | 110 +++++++++--------- .../skills/obsidian-mind/hooks/stop.test.ts | 68 +++++------ .claude/skills/obsidian-mind/types/index.d.ts | 16 +-- 3 files changed, 89 insertions(+), 105 deletions(-) diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index fd0b8bb5..c853afa6 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -38,22 +38,25 @@ const CONTEXT_FILE = ".claude/session-context.md"; const sessionContext = atom({ plugin: 'obsidian-mind', key: 'context' } as const, null) const shownReport = atom({ plugin: 'obsidian-mind', key: 'shownReport' } as const, null) -const pendingLine = atom({ plugin: 'obsidian-mind', key: 'pendingLine' } as const, null) -const pendingReport = atom({ plugin: 'obsidian-mind', key: 'pendingReport' } as const, null) -const pendingUrgent = atom({ plugin: 'obsidian-mind', key: 'pendingUrgent' } as const, null) -/** Set once an urgent finding got a turn of its own; cleared when the person next speaks. */ -const urgentSpent = atom({ plugin: 'obsidian-mind', key: 'urgentSpent' } 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) -/** 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 { - 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, - }); - if (run.exitCode !== 0 || run.stdout.trim() === "") { - throw new Error(`${script} exited ${run.exitCode}: ${run.stderr.slice(0, 300)}`); +type Queued = { readonly report: string; readonly line: string | null; readonly urgent: string | null } + +/** + * 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 }, + ) + if (run.exitCode !== 0 || run.stdout.trim() === '') { + throw new Error(`${script} exited ${run.exitCode}: ${run.stderr.slice(0, 300)}`) } // A cut output would stand the hook down for part of what it delivers. if (run.isStdoutTruncated) throw new Error(`${script} printed more than process.run keeps`); @@ -67,25 +70,22 @@ export const register: Register = (on) => { // 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') { - // One call per atom: the validator reads each state source statically. let dropped = false - await update($, pendingReport, (now) => { + await update($, queued, (now) => { dropped = now !== null return null }) - await update($, pendingLine, () => null) - await update($, pendingUrgent, () => null) - await update($, urgentSpent, () => null) + await update($, urgentSpent, () => false) // A report dropped here never reached the agent, so the same findings // show again; one it already has stays shown. if (dropped) await update($, shownReport, () => null) } // Cleared first: if this run fails, the settings hook delivers fresh // output and no earlier context may ride beside it. - await update($, sessionContext, () => null); - const root = await $.session.root(); - const text = await runScript($, root, "session-start.ts", { ...e, om_mod: "deliver" }); - await update($, sessionContext, () => text); + await update($, sessionContext, () => null) + const root = await $.session.root() + 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 // /memory shows. Runs are minutes apart (startup, then a compaction), so @@ -108,17 +108,16 @@ export const register: Register = (on) => { // 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() - const report = parseStopReport(await runScript($, root, 'stop-checklist.ts', { ...e, om_mod: 'report' })) + const report = parseStopReport(await runScript($, root, 'stop-checklist.ts', { ...e, om_mod: 'report' }, 5_000)) // Keyed by session too, so a new session shows its first report even // with the same findings, as the settings hook's dedupe does. const identity = `${e.session_id}:${report.key}` if ((await read($, shownReport)) !== identity) { await update($, shownReport, () => identity) - await update($, pendingLine, () => summaryLine(report)) // The urgent finding rides inside the report too, so whatever happens // to its own turn, the agent gets it with the report. - await update($, pendingReport, () => (report.urgent === undefined ? report.agentText : `${report.agentText}\n\nUrgent: ${report.urgent}`)) - await update($, pendingUrgent, () => report.urgent ?? null) + const text = report.urgent === undefined ? report.agentText : `${report.agentText}\n\nUrgent: ${report.urgent}` + await update($, queued, (): Queued => ({ report: text, line: summaryLine(report), urgent: report.urgent ?? null })) } return next({ ...e, om_mod: 'standdown' } as typeof e) }) @@ -128,16 +127,20 @@ export const register: Register = (on) => { // 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 - const line = await read($, pendingLine) + // 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 - await update($, pendingLine, () => null) - const urgent = await read($, pendingUrgent) - await update($, pendingUrgent, () => null) // 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)) === null) { - await update($, urgentSpent, () => urgent) + 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 @@ -151,19 +154,26 @@ export const register: Register = (on) => { 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, () => null) + if (entered.drop === undefined && fromPerson(e.origin)) await update($, urgentSpent, () => false) return entered } - const report = await read($, pendingReport) - if (report === null || !carriesReport(e.origin)) return renew(await next(e)) - // Taken before `next`, so two prompts entering at once cannot both carry - // it; put back if this one never enters (dropped or blocked below, or a - // throw), unless a newer report was queued meanwhile. - await update($, pendingReport, () => null) - const putBack = () => update($, pendingReport, (now) => now ?? report) + 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 putBack = () => update($, queued, (now) => now ?? record) let entered: Awaited> try { - entered = await next({ ...e, context: [...(e.context ?? []), report] }) + entered = await next({ ...e, context: [...(e.context ?? []), record.report] }) } catch (error) { await putBack() throw error @@ -172,20 +182,6 @@ export const register: Register = (on) => { await putBack() return entered } - // The report is with the agent: a line still waiting would say it is - // coming, and an urgent finding still waiting is already in it. Unless a - // newer report was queued while this prompt was entering: the line and - // the urgent finding waiting now are that one's. Checked through `update`, - // whose function sees writes made while `next` ran; `read` here may not. - let newer = false - await update($, pendingReport, (now) => { - newer = now !== null - return now - }) - if (!newer) { - await update($, pendingLine, () => null) - await update($, pendingUrgent, () => null) - } return renew(entered) }) } diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index f1c2bdb9..ca975db7 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'claude-code/testing' +import { engine, type On, type Reply } from './world.ts' import { carriesReport, fromPerson, parseStopReport, summaryLine, withLine, type StopReport } from './stop.ts' // Run with `claude plugin test .claude/skills/obsidian-mind`. Each test's own @@ -6,40 +7,30 @@ import { carriesReport, fromPerson, parseStopReport, summaryLine, withLine, type // 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 ROOT = '/vault' +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: `Stop hook report, handed over with this message: ${key}`, + agentText: HANDED(key), ...extra, }) -type World = { - runs: string[] - passedDown: Array> - submitted: Array<{ text: string; context?: readonly string[]; origin?: unknown }> - /** The next prompt is dropped below, or the next one throws below. */ - dropNext: boolean - throwNext: boolean - /** A line another hook below sets under the answer, if any. */ - lowerLine: string | null - /** Runs inside the next prompt's submit, before it enters or is dropped. */ - duringNext: (() => Promise) | null -} - -/** The world beneath the mod. `reply` is what stop-checklist.ts prints, per call. */ -function vault(on: Parameters[1], (...args: never[]) => unknown>>[1], reply: () => { exitCode: number; stdout: string }): World { - const world: World = { runs: [], passedDown: [], submitted: [], dropNext: false, throwNext: false, lowerLine: null, duringNext: null } - on('session.root', () => ({ value: ROOT })) - on('process.run', (_$, e) => { - world.runs.push(e.init?.stdin ?? '') - const { exitCode, stdout } = reply() - return { value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } } - }) - on('classic.Stop', (_$, e) => { - world.passedDown.push(e as unknown as Record) - return {} - }) +/** The shared world beneath the mod, plus the prompt and answer stubs these tests steer. */ +function vault(on: On, reply: () => Reply) { + const base = engine(on, reply) + 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 @@ -57,11 +48,9 @@ function vault(on: Parameters[1], (...args: neve return { text: e.text, context: e.context } }) on('turn.complete', (_$, e) => ({ text: world.lowerLine ?? e.answer })) - on('fs.write', () => ({ value: undefined })) - on('ui.invalidate', () => ({ value: undefined })) - on('classic.SessionStart', () => ({})) return world } +type World = ReturnType const ok = (r: StopReport) => () => ({ exitCode: 0, stdout: JSON.stringify({ report: r }) }) @@ -72,7 +61,6 @@ const answered = (extra: Record = {}) => ({ answer: 'the answer 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 the next message' -const HANDED = (key: string) => `Stop hook report, handed over with this message: ${key}` describe('Stop report (#266)', () => { test('a changed report: the settings hook stands down and the next prompt carries the full report, once', async ($, on) => { @@ -80,11 +68,11 @@ describe('Stop report (#266)', () => { await $.classic.Stop({ stop_hook_active: false }) expect(world.runs.length).toBe(1) - expect(JSON.parse(world.runs[0] ?? '{}')).toEqual(expect.objectContaining({ om_mod: 'report' })) + 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(['Stop hook report, handed over with this message: k1']) + expect(world.submitted[0]?.context).toEqual([HANDED('k1')]) await $.prompt.submit({ text: 'after' }) expect(world.submitted[1]?.context ?? []).toEqual([]) }) @@ -97,7 +85,7 @@ describe('Stop report (#266)', () => { await $.prompt.submit({ text: 'second' }) expect(world.runs.length).toBe(2) - expect(world.submitted[0]?.context).toEqual(['Stop hook report, handed over with this message: same']) + expect(world.submitted[0]?.context).toEqual([HANDED('same')]) expect(world.submitted[1]?.context ?? []).toEqual([]) expect(world.passedDown[1]?.['om_mod']).toBe('standdown') }) @@ -111,7 +99,7 @@ describe('Stop report (#266)', () => { await $.classic.Stop({ stop_hook_active: false }) await $.prompt.submit({ text: 'second' }) - expect(world.submitted[1]?.context).toEqual(['Stop hook report, handed over with this message: b']) + expect(world.submitted[1]?.context).toEqual([HANDED('b')]) }) test('each session sees its own first report, even with the same findings', async ($, on) => { @@ -123,8 +111,8 @@ describe('Stop report (#266)', () => { await $.classic.Stop({ stop_hook_active: false, session_id: 's2' }) await $.prompt.submit({ text: 'second' }) - expect(world.submitted[0]?.context).toEqual(['Stop hook report, handed over with this message: shared']) - expect(world.submitted[1]?.context).toEqual(['Stop hook report, handed over with this message: shared']) + expect(world.submitted[0]?.context).toEqual([HANDED('shared')]) + expect(world.submitted[1]?.context).toEqual([HANDED('shared')]) }) test('a prompt that is dropped below keeps the report for the next one', async ($, on) => { @@ -134,7 +122,7 @@ describe('Stop report (#266)', () => { await $.prompt.submit({ text: 'blocked' }) await $.prompt.submit({ text: 'entered' }) - expect(world.submitted[1]?.context).toEqual(['Stop hook report, handed over with this message: kept']) + 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) => { @@ -144,7 +132,7 @@ describe('Stop report (#266)', () => { await $.prompt.submit({ text: 'typed' }) expect(world.submitted[0]?.context ?? []).toEqual([]) - expect(world.submitted[1]?.context).toEqual(['Stop hook report, handed over with this message: mine']) + expect(world.submitted[1]?.context).toEqual([HANDED('mine')]) }) test('a forced turn passes straight through: no run, no flag', async ($, on) => { diff --git a/.claude/skills/obsidian-mind/types/index.d.ts b/.claude/skills/obsidian-mind/types/index.d.ts index 84d99d5e..ee3657f3 100644 --- a/.claude/skills/obsidian-mind/types/index.d.ts +++ b/.claude/skills/obsidian-mind/types/index.d.ts @@ -8,14 +8,14 @@ declare module "claude-code" { context: string | null /** The session id and identity of the last Stop report shown. */ shownReport: string | null - /** The line to draw under the next main-loop answer. */ - pendingLine: string | null - /** The full report, for the next prompt. */ - pendingReport: string | null - /** An urgent finding, for a turn of its own. */ - pendingUrgent: string | null - /** The urgent finding that got a turn of its own; cleared when the person next speaks. */ - urgentSpent: string | null + /** + * The Stop report waiting to be delivered: 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 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 } } } From 9347fe1695191014297c56389e19cb476b8e7459 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 02:22:24 +0200 Subject: [PATCH 12/20] mod tests: a prompt dropped below leaves the line for the answer Co-Authored-By: Claude Opus 5.5 --- .claude/skills/obsidian-mind/hooks/stop.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index ca975db7..d8c7a24d 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -115,6 +115,15 @@ describe('Stop report (#266)', () => { 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 = vault(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 = vault(on, ok(report('kept'))) await $.classic.Stop({ stop_hook_active: false }) From 2cf34d1b98cd5266f82dbe41c81a2d91a3d1de86 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 10:06:48 +0200 Subject: [PATCH 13/20] review: which report each session saw lives in the plugin's store, so a resume in a new process does not repeat it; a report in flight across a /clear is not put back; the line's wording matches the hook's, 'now' when urgent; the mod's timeouts held to settings.json by a test; docs and READMEs to match Co-Authored-By: Claude Opus 5.5 --- .claude/scripts/tests/mod-timeouts.test.ts | 49 + .../obsidian-mind/.claude-plugin/plugin.json | 2 +- .../skills/obsidian-mind/hooks/register.ts | 204 ++-- .../skills/obsidian-mind/hooks/stop.test.ts | 877 +++++++++--------- .claude/skills/obsidian-mind/hooks/stop.ts | 39 +- .claude/skills/obsidian-mind/hooks/world.ts | 12 +- .claude/skills/obsidian-mind/types/index.d.ts | 17 +- ARCHITECTURE.md | 2 +- CLAUDE.md | 2 +- README.ja.md | 15 +- README.ko.md | 6 +- README.md | 6 +- README.zh-CN.md | 24 +- 13 files changed, 712 insertions(+), 543 deletions(-) create mode 100644 .claude/scripts/tests/mod-timeouts.test.ts 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 a62bdd73..ea3a58c5 100644 --- a/.claude/skills/obsidian-mind/.claude-plugin/plugin.json +++ b/.claude/skills/obsidian-mind/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "obsidian-mind", - "version": "1.0.0", + "version": "8.6.0", "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", diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index c853afa6..6d0724d9 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -1,6 +1,6 @@ -import { atom, read, update, type EngineInterface, type Register } from 'claude-code' -import { withSessionContext } from './context.ts' -import { carriesReport, fromPerson, parseStopReport, summaryLine, withLine } from './stop.ts' +import { atom, read, update, type EngineInterface, type PluginState, type Register } from "claude-code"; +import { withSessionContext } from "./context.ts"; +import { carriesReport, fromPerson, parseStopReport, summaryLine, withLine } from "./stop.ts"; /** * obsidian-mind's Claude Code mod (#262). @@ -36,14 +36,41 @@ import { carriesReport, fromPerson, parseStopReport, summaryLine, withLine } fro /** Where the delivered context is also written, so /memory opens what the model received. Gitignored. */ const CONTEXT_FILE = ".claude/session-context.md"; -const sessionContext = atom({ plugin: 'obsidian-mind', key: 'context' } as const, null) -const shownReport = atom({ plugin: 'obsidian-mind', key: 'shownReport' } as const, null) +/** 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) +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) +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); -type Queued = { readonly report: string; readonly line: string | null; readonly urgent: string | null } +type Queued = NonNullable; + +/** + * Which report each session was last shown, by session id: in `$.store`, not + * `$.state`, because the store outlives the process, so a `claude --resume` + * in a new process does not show unchanged findings again (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; + +async function shownFor($: EngineInterface, sessionId: string): Promise { + const shown = ((await $.store.get(SHOWN)) ?? {}) as Record; + return shown[sessionId]; +} + +async function setShown($: EngineInterface, sessionId: string, key: string | null): Promise { + const shown = { ...(((await $.store.get(SHOWN)) ?? {}) as Record) }; + delete shown[sessionId]; + if (key !== null) shown[sessionId] = key; + // 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 @@ -51,12 +78,14 @@ type Queued = { readonly report: string; readonly line: string | null; readonly * 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 }, - ) - if (run.exitCode !== 0 || run.stdout.trim() === '') { - throw new Error(`${script} exited ${run.exitCode}: ${run.stderr.slice(0, 300)}`) + 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, + }); + if (run.exitCode !== 0 || run.stdout.trim() === "") { + throw new Error(`${script} exited ${run.exitCode}: ${run.stderr.slice(0, 300)}`); } // A cut output would stand the hook down for part of what it delivers. if (run.isStdoutTruncated) throw new Error(`${script} printed more than process.run keeps`); @@ -64,28 +93,30 @@ async function runScript($: EngineInterface, root: string, script: string, input } export const register: Register = (on) => { - on('classic.SessionStart', async ($, e, next) => { + on("classic.SessionStart", async ($, e, next) => { // 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') { - let dropped = false + if (e.source !== "compact") { + let dropped: Queued | null = null; await update($, queued, (now) => { - dropped = now !== null - return null - }) - await update($, urgentSpent, () => false) + dropped = now; + return null; + }); + await update($, urgentSpent, () => false); + await update($, generation, (now) => now + 1); // A report dropped here never reached the agent, so the same findings - // show again; one it already has stays shown. - if (dropped) await update($, shownReport, () => null) + // show again in the session it was for; one it already has stays shown. + const lost: Queued | null = dropped; + if (lost !== null) await setShown($, lost.sessionId, null); } // Cleared first: if this run fails, the settings hook delivers fresh // output and no earlier context may ride beside it. - await update($, sessionContext, () => null) - const root = await $.session.root() - const text = await runScript($, root, 'session-start.ts', { ...e, om_mod: 'deliver' }, 30_000) - await update($, sessionContext, () => text) + await update($, sessionContext, () => null); + const root = await $.session.root(); + 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 // /memory shows. Runs are minutes apart (startup, then a compaction), so @@ -97,91 +128,102 @@ export const register: Register = (on) => { return next({ ...e, om_mod: "standdown" } as typeof e); }); - on('prompt.context', async ($, e, next) => { - const below = await next(e) - const text = await read($, sessionContext) - if (text === null) return below - return withSessionContext(below, `${await $.session.root()}/${CONTEXT_FILE}`, text) - }) + on("prompt.context", async ($, e, next) => { + const below = await next(e); + const text = await read($, sessionContext); + if (text === null) return below; + return withSessionContext(below, `${await $.session.root()}/${CONTEXT_FILE}`, text); + }); - on('classic.Stop', async ($, e, next) => { + 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() - const report = parseStopReport(await runScript($, root, 'stop-checklist.ts', { ...e, om_mod: 'report' }, 5_000)) - // Keyed by session too, so a new session shows its first report even - // with the same findings, as the settings hook's dedupe does. - const identity = `${e.session_id}:${report.key}` - if ((await read($, shownReport)) !== identity) { - await update($, shownReport, () => identity) + if (e.stop_hook_active) return next(e); + const root = await $.session.root(); + const report = parseStopReport(await runScript($, root, "stop-checklist.ts", { ...e, om_mod: "report" }, 5_000)); + // 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); + if ((await shownFor($, sessionId)) !== report.key) { + await setShown($, sessionId, report.key); // 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 => ({ report: text, line: summaryLine(report), urgent: report.urgent ?? null })) + const text = report.urgent === undefined ? report.agentText : `${report.agentText}\n\nUrgent: ${report.urgent}`; + await update($, queued, (): Queued => ({ sessionId, report: text, line: summaryLine(report), urgent: report.urgent ?? null })); } - return next({ ...e, om_mod: 'standdown' } as typeof e) - }) + return next({ ...e, om_mod: "standdown" } as typeof e); + }); - on('turn.complete', async ($, e, next) => { - const done = await next(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 + 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 + 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 + 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) + 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(() => {}) + $.prompt.submit({ text: urgent }).catch(() => {}); } - return { ...done, text: withLine(done.text, e.answer, line) } - }) + return { ...done, text: withLine(done.text, e.answer, line) }; + }); - on('prompt.submit', async ($, e, next) => { + 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)) + 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 + 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 putBack = () => update($, queued, (now) => now ?? record) - let entered: Awaited> + 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); + }; + let entered: Awaited>; try { - entered = await next({ ...e, context: [...(e.context ?? []), record.report] }) + entered = await next({ ...e, context: [...(e.context ?? []), record.report] }); } catch (error) { - await putBack() - throw error + await putBack(); + throw error; } if (entered.drop !== undefined) { - await putBack() - return entered + await putBack(); + return entered; } - return renew(entered) - }) -} + return renew(entered); + }); +}; diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index d8c7a24d..075d33f8 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -1,24 +1,24 @@ -import { describe, expect, test } from 'claude-code/testing' -import { engine, type On, type Reply } from './world.ts' -import { carriesReport, fromPerson, parseStopReport, summaryLine, withLine, type StopReport } from './stop.ts' +import { describe, expect, test } from "claude-code/testing"; +import { engine, type On, type Reply } from "./world.ts"; +import { carriesReport, fromPerson, parseStopReport, 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 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/'], + 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 vault(on: On, reply: () => Reply) { - const base = engine(on, reply) +function stopWorld(on: On, reply: () => Reply) { + const base = engine(on, reply); const world = { runs: base.runs, passedDown: base.passedDown.Stop, @@ -30,466 +30,507 @@ function vault(on: On, reply: () => Reply) { 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() + }; + 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') + 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' } + 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 })) - return world + return { text: e.text, context: e.context }; + }); + on("turn.complete", (_$, e) => ({ text: world.lowerLine ?? e.answer })); + return world; } -type World = ReturnType +type World = ReturnType; -const ok = (r: StopReport) => () => ({ exitCode: 0, stdout: JSON.stringify({ report: r }) }) +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 +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 the next message' - -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 = vault(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 = vault(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 = vault(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) => { +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 = vault(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 = vault(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 = vault(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')]) - }) + 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 = vault(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 = vault(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 = vault(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 = vault(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) => { - vault(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) => { - vault(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') + 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 = vault(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 = vault(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 report dropped by a resume is shown again when the same findings come back', async ($, on) => { - const world = vault(on, ok(report('k'))) - await $.classic.Stop({ stop_hook_active: false, session_id: 'A' }) - await $.classic.SessionStart({ source: 'resume' } as never) - await $.classic.SessionStart({ source: 'resume' } 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 = vault(on, ok(report('k'))) - await $.classic.Stop({ stop_hook_active: false, session_id: 'A' }) - await $.prompt.submit({ text: 'first' }) - await $.classic.SessionStart({ source: 'resume' } 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 compaction keeps the queued report, and what was shown stays shown', async ($, on) => { - const world = vault(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 = vault(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) => { - vault(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']) { + 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 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 shown is kept for the most recent sessions only", async ($, on) => { + const world = stopWorld(on, ok(report("k"))); + for (let i = 0; i <= 20; i++) await $.classic.Stop({ stop_hook_active: false, session_id: `s${i}` }); + // s0 is the oldest of 21: forgotten, so its findings show again; s20 is kept. + 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: "s0" }); + 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" }); + await $.prompt.submit({ text: "first" }); + await $.classic.SessionStart({ source: "resume" } 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 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 = vault(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([]) - }) + 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) => { - vault(on, ok(report('k'))) - await $.classic.Stop({ stop_hook_active: false }) - await $.turn.complete(answered({ reason: 'refusal' })) - await $.prompt.submit({ text: 'typed' }) + 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') - }) + 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 = vault(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key) }) })) - await $.classic.Stop({ stop_hook_active: false }) + 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' }) + 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')]) - }) -}) + 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}` +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 = vault(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) { + 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 = vault(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')]) - }) + 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 = vault(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() + 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']) + 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 = vault(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 = vault(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 = vault(on, () => ({ exitCode: 0, stdout: JSON.stringify({ report: report(key, key === 'b' ? { urgent: 'urgent b' } : {}) }) })) - await $.classic.Stop({ stop_hook_active: false }) + 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')]) + 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) - await settle() - expect(world.submitted.map((p) => p.text)).toEqual(['typed', 'urgent b']) - }) + 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 = vault(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']) - }) + 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 = vault(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() + 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"); + }); -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 the 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 the next message') - }) -}) + 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') - }) +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') - }) -}) + expect(withLine("TL;DR: done", "the answer", "vault check: x")).toBe("TL;DR: done\nvault check: x"); + }); +}); -describe('carriesReport', () => { +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) - }) + 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) - }) + 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) - }) + 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) + 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); } - }) -}) + }); +}); diff --git a/.claude/skills/obsidian-mind/hooks/stop.ts b/.claude/skills/obsidian-mind/hooks/stop.ts index 087f7846..6ff75d3b 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.ts @@ -1,4 +1,4 @@ -import type { PromptOrigin } from 'claude-code' +import type { PromptOrigin } from "claude-code"; /** * The Stop report as the mod receives it from `stop-checklist.ts` run with @@ -6,33 +6,33 @@ import type { PromptOrigin } from 'claude-code' */ export type StopReport = { /** The report's identity: the same findings give the same key. */ - readonly key: string + readonly key: string; /** One short claim per finding, e.g. "1 note(s) marked done but still in active/". */ - readonly claims: readonly string[] + readonly claims: readonly string[]; /** The full report, prefaced for the agent. */ - readonly agentText: string + 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 -} + 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 + const report = (JSON.parse(stdout) as { report?: Partial }).report; if ( !report || - typeof report.key !== 'string' || + 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') + !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)}`) + 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 } : {}) } + return { key: report.key, claims: report.claims, agentText: report.agentText, ...(report.urgent !== undefined ? { urgent: report.urgent } : {}) }; } /** @@ -41,8 +41,11 @@ export function parseStopReport(stdout: string): StopReport { * 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' - return `vault check: ${what} · the full report reaches the agent with the next message` + 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}`; } /** @@ -51,7 +54,7 @@ export function summaryLine(report: StopReport): string { * 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 + return textBelow !== answer && textBelow.trim() !== "" ? `${textBelow}\n${line}` : line; } /** @@ -61,10 +64,10 @@ export function withLine(textBelow: string, answer: string, line: string): strin * is not the person writing, and must not consume the report. */ export function carriesReport(origin: PromptOrigin | undefined): boolean { - return fromPerson(origin) || (origin?.kind === 'plugin' && origin.name === 'obsidian-mind') + 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' + 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..4423b378 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[]; }; @@ -28,8 +28,10 @@ export type World = { * 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: [] }; + 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); 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 ee3657f3..2663d1c6 100644 --- a/.claude/skills/obsidian-mind/types/index.d.ts +++ b/.claude/skills/obsidian-mind/types/index.d.ts @@ -1,21 +1,22 @@ // 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 session id and identity of the last Stop report shown. */ - shownReport: string | null + context: string | null; /** - * The Stop report waiting to be delivered: its full text for the next + * 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 report: string; readonly line: string | null; readonly urgent: string | null } | null + queued: { readonly sessionId: 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 - } + urgentSpent: boolean; + /** Bumped by every start that begins another conversation (startup, /clear, resume, fork). */ + generation: number; + }; } } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 17aab447..58fe7082 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -218,7 +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 before every run, so a run that fails can never leave an older context riding beside the hook's fresh output. 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 the identity of the last report it showed (session id plus report key) in `$.state`. 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 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. A start that drops a queued report also forgets which report was shown, so the same findings show again; one the agent already had stays shown. That memory lives in `$.state`, so it lasts as long as the process: a `claude --resume` in a new process shows unchanged findings once more, where the settings hook's file-backed dedupe would not. Every session start except a compaction (startup, `/clear`, an in-process resume or fork) drops anything queued, since nothing documents `$.state` being reset there; a compaction continues the conversation and keeps it. 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. +- **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 shown, by session id, in `$.store` (a file Claude Code keeps per plugin, capped at the most recent sessions), so a `claude --resume` in a new process does not show unchanged findings again, matching the settings hook's file-backed dedupe. 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 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 3c242d43..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`. 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 next prompt the person sends (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 person 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. +**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 c209f6f3..0bae871c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -207,12 +207,21 @@ 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 件目はレポートの中で待ちます)。テンプレート自身のレポートにはそれはありません。 +- **セッションコンテキストは、`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` が、フックするイベントと行う呼び出しをすべて一覧にします(Vault 自身のスクリプトを実行し、コンテキストファイルを書き、緊急の指摘があればプロンプトを送るだけです)。無効にするには、`.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, classic.Stop, turn.complete, prompt.submit + ❯ ./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 243d351d..920861ba 100644 --- a/README.ko.md +++ b/README.ko.md @@ -212,14 +212,14 @@ Claude Code 2.1.287 이상에서는 볼트가 mod도 함께 제공합니다. `.c 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 + ❯ ./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 7dc5b6a8..3a1ef8b5 100644 --- a/README.md +++ b/README.md @@ -219,14 +219,14 @@ On Claude Code 2.1.287 or later, the vault also ships a mod: `.claude/skills/obs 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, writes the context file, 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`. +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 + ❯ ./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 45b98adf..11a73b73 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -207,12 +207,30 @@ 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(你每发送一条消息最多一次,第二个会在报告中等待);模板自身的报告中没有这类发现项。 +- **会话上下文像 `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, classic.Stop, turn.complete, prompt.submit + ❯ ./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 +``` + + + +对于这一版本的 mod,其输出中值得确认的两行如下: + +```text + ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit + ❯ ./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 +``` + ### ⚡ Token 效率 From d9efae95a6691fd2379591f36560331bf1129cd3 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 10:16:28 +0200 Subject: [PATCH 14/20] review: a report taken by a prompt that is pulled back out of the queue unrun goes back to the queue (turn.start confirms delivery) Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/register.ts | 32 +++++++++++ .../skills/obsidian-mind/hooks/stop.test.ts | 54 +++++++++++++++++++ .claude/skills/obsidian-mind/types/index.d.ts | 9 ++++ ARCHITECTURE.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- 8 files changed, 100 insertions(+), 5 deletions(-) diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index 6d0724d9..6f4a5041 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -44,6 +44,13 @@ const queued = atom({ plugin: "obsidian-mind", key: "queued" } as const, null); 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 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 inFlight = atom({ plugin: "obsidian-mind", key: "inFlight" } as const, null); type Queued = NonNullable; @@ -106,6 +113,7 @@ export const register: Register = (on) => { }); await update($, urgentSpent, () => false); await update($, generation, (now) => now + 1); + await update($, inFlight, () => null); // A report dropped here never reached the agent, so the same findings // show again in the session it was for; one it already has stays shown. const lost: Queued | null = dropped; @@ -224,6 +232,30 @@ export const register: Register = (on) => { await putBack(); return entered; } + await update($, inFlight, () => ({ text: entered.text, record, generation: startedIn })); 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; + // Queued prompts run in order and may be folded into one turn, so a turn + // whose text holds the prompt's ran it: delivered. Any other prompt's turn + // starting first means the one holding the report left the queue unrun. + if (waiting !== null && e.text !== "" && !e.text.includes(waiting.text)) { + let now = waiting.generation; + await update($, generation, (g) => { + now = g; + return g; + }); + if (now === waiting.generation) 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 index 075d33f8..afbfe2b6 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -48,6 +48,7 @@ function stopWorld(on: On, reply: () => Reply) { 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; @@ -211,6 +212,59 @@ describe("the line under the answer (#266)", () => { 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 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" }); diff --git a/.claude/skills/obsidian-mind/types/index.d.ts b/.claude/skills/obsidian-mind/types/index.d.ts index 2663d1c6..ccb90572 100644 --- a/.claude/skills/obsidian-mind/types/index.d.ts +++ b/.claude/skills/obsidian-mind/types/index.d.ts @@ -17,6 +17,15 @@ declare module "claude-code" { urgentSpent: boolean; /** Bumped by every start that begins another conversation (startup, /clear, resume, fork). */ generation: number; + /** + * The report a prompt took, the prompt's text and the generation it was + * taken in, until a turn starts with that prompt. Null when none is. + */ + inFlight: { + readonly text: string; + readonly record: { readonly sessionId: string; readonly report: string; readonly line: string | null; readonly urgent: string | null }; + readonly generation: number; + } | null; }; } } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 58fe7082..4ab3d264 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -218,7 +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 before every run, so a run that fails can never leave an older context riding beside the hook's fresh output. 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 shown, by session id, in `$.store` (a file Claude Code keeps per plugin, capped at the most recent sessions), so a `claude --resume` in a new process does not show unchanged findings again, matching the settings hook's file-backed dedupe. 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 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. +- **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 shown, by session id, in `$.store` (a file Claude Code keeps per plugin, capped at the most recent sessions), so a `claude --resume` in a new process does not show unchanged findings again, matching the settings hook's file-backed dedupe. 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/README.ja.md b/README.ja.md index 0bae871c..85cfac52 100644 --- a/README.ja.md +++ b/README.ja.md @@ -218,7 +218,7 @@ mod はサンドボックス化されておらず、あなたの権限で動く この版の mod で、その出力のうち確認すべき 2 行は次のとおりです: ```text - ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit + ❯ ./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 920861ba..738febb4 100644 --- a/README.ko.md +++ b/README.ko.md @@ -218,7 +218,7 @@ mod는 샌드박스 없이 사용자의 권한으로 실행되는 코드이므 이 버전의 mod에서 출력 중 확인할 두 줄은 다음과 같습니다: ```text - ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit + ❯ ./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 3a1ef8b5..237b7ff9 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ A mod is unsandboxed code that runs with your permissions, so check what it does The two lines that matter in its output, for this version of the mod: ```text - ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit + ❯ ./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 11a73b73..536f6f5a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -218,7 +218,7 @@ mod 是没有沙箱、以你的权限运行的代码,因此在信任该文件 对于这一版本的 mod,其输出中值得确认的两行如下: ```text - ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit + ❯ ./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 ``` From 55b9c84325938b6eaca826ebf0899ad994b814ff Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 10:16:48 +0200 Subject: [PATCH 15/20] mod tests: a delivered report stays delivered; a /clear while a prompt enters is not undone by the put-back Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/stop.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index afbfe2b6..557c5095 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -222,6 +222,28 @@ describe("the line under the answer (#266)", () => { 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 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 }); From aef7a17aff2701d0ca2d30105448206903a6fa58 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 10:28:36 +0200 Subject: [PATCH 16/20] review: a failed compaction keeps the last good context; a failed Stop run drops the mod's queue so two reports never ride one prompt; a held prompt counts as run only as whole lines; the zh-CN README's garbled duplicate removed, and CI now refuses a second copy Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/register.ts | 25 +++++++++--- .../skills/obsidian-mind/hooks/stop.test.ts | 39 ++++++++++++++++++- .claude/skills/obsidian-mind/hooks/stop.ts | 9 +++++ .github/workflows/mod.yml | 4 ++ ARCHITECTURE.md | 2 +- README.zh-CN.md | 9 ----- 6 files changed, 71 insertions(+), 17 deletions(-) diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index 6f4a5041..92ac0c9c 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -1,6 +1,6 @@ import { atom, read, update, type EngineInterface, type PluginState, type Register } from "claude-code"; import { withSessionContext } from "./context.ts"; -import { carriesReport, fromPerson, parseStopReport, summaryLine, withLine } from "./stop.ts"; +import { carriesReport, fromPerson, parseStopReport, ranIn, summaryLine, withLine } from "./stop.ts"; /** * obsidian-mind's Claude Code mod (#262). @@ -118,10 +118,15 @@ export const register: Register = (on) => { // show again in the session it was for; one it already has stays shown. const lost: Queued | null = dropped; if (lost !== null) await setShown($, lost.sessionId, 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"); } - // Cleared first: if this run fails, the settings hook delivers fresh - // output and no earlier context may ride beside it. - await update($, sessionContext, () => null); const root = await $.session.root(); const text = await runScript($, root, "session-start.ts", { ...e, om_mod: "deliver" }, 30_000); await update($, sessionContext, () => text); @@ -147,7 +152,15 @@ export const register: Register = (on) => { // 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(); - const report = parseStopReport(await runScript($, root, "stop-checklist.ts", { ...e, om_mod: "report" }, 5_000)); + 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); @@ -248,7 +261,7 @@ export const register: Register = (on) => { // Queued prompts run in order and may be folded into one turn, so a turn // whose text holds the prompt's ran it: delivered. Any other prompt's turn // starting first means the one holding the report left the queue unrun. - if (waiting !== null && e.text !== "" && !e.text.includes(waiting.text)) { + if (waiting !== null && e.text !== "" && !ranIn(e.text, waiting.text)) { let now = waiting.generation; await update($, generation, (g) => { now = g; diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index 557c5095..0c0e7bee 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "claude-code/testing"; import { engine, type On, type Reply } from "./world.ts"; -import { carriesReport, fromPerson, parseStopReport, summaryLine, withLine, type StopReport } from "./stop.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. @@ -244,6 +244,28 @@ describe("the line under the answer (#266)", () => { 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 }); @@ -610,3 +632,18 @@ describe("carriesReport", () => { } }); }); + +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 index 6ff75d3b..c1114ebc 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.ts @@ -63,6 +63,15 @@ export function withLine(textBelow: string, answer: string, line: string): strin * 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"); } 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 4ab3d264..d4b5b3c4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -217,7 +217,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 before every run, so a run that fails can never leave an older context riding beside the hook's fresh output. 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. +- **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 shown, by session id, in `$.store` (a file Claude Code keeps per plugin, capped at the most recent sessions), so a `claude --resume` in a new process does not show unchanged findings again, matching the settings hook's file-backed dedupe. 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/README.zh-CN.md b/README.zh-CN.md index 536f6f5a..5efd7720 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -223,15 +223,6 @@ mod 是没有沙箱、以你的权限运行的代码,因此在信任该文件 ``` - -对于这一版本的 mod,其输出中值得确认的两行如下: - -```text - ❯ ./register.ts hooks: classic.SessionStart, prompt.context, classic.Stop, turn.complete, prompt.submit - ❯ ./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 -``` - - ### ⚡ Token 效率 obsidian-mind **不会**将整个 vault 加载到上下文中。它使用分层加载来控制 token 成本: From a6c45b244a16e6e75b032ec62e02a050c8b73a54 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 10:35:18 +0200 Subject: [PATCH 17/20] =?UTF-8?q?fix:=20hold=20a=20prompt=20in=20flight=20?= =?UTF-8?q?before=20next,=20since=20its=20turn=20starts=20inside=20next=20?= =?UTF-8?q?(2.1.288)=20=E2=80=94=20every=20other=20prompt=20re-delivered?= =?UTF-8?q?=20the=20report;=20the=20store=20remembers=20delivered,=20so=20?= =?UTF-8?q?a=20report=20still=20waiting=20survives=20a=20resume=20in=20a?= =?UTF-8?q?=20new=20process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/register.ts | 48 +++++++++++++------ .../skills/obsidian-mind/hooks/stop.test.ts | 29 ++++++++++- .claude/skills/obsidian-mind/types/index.d.ts | 4 +- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index 92ac0c9c..7ef453d0 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -55,24 +55,26 @@ const inFlight = atom({ plugin: "obsidian-mind", key: "inFlight" } as const, nul type Queued = NonNullable; /** - * Which report each session was last shown, by session id: in `$.store`, not - * `$.state`, because the store outlives the process, so a `claude --resume` - * in a new process does not show unchanged findings again (the settings + * 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 }; -async function shownFor($: EngineInterface, sessionId: string): Promise { - const shown = ((await $.store.get(SHOWN)) ?? {}) as Record; +async function shownFor($: EngineInterface, sessionId: string): Promise { + const shown = ((await $.store.get(SHOWN)) ?? {}) as Record; return shown[sessionId]; } -async function setShown($: EngineInterface, sessionId: string, key: string | null): Promise { - const shown = { ...(((await $.store.get(SHOWN)) ?? {}) as Record) }; +async function setShown($: EngineInterface, sessionId: string, entry: Shown | null): Promise { + const shown = { ...(((await $.store.get(SHOWN)) ?? {}) as Record) }; delete shown[sessionId]; - if (key !== null) shown[sessionId] = key; + 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]; @@ -164,12 +166,19 @@ export const register: Register = (on) => { // 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); - if ((await shownFor($, sessionId)) !== report.key) { - await setShown($, sessionId, report.key); + 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, report: text, line: summaryLine(report), urgent: report.urgent ?? null })); + 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); }); @@ -234,18 +243,22 @@ export const register: Register = (on) => { }); 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, generation: startedIn })); 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; } - await update($, inFlight, () => ({ text: entered.text, record, generation: startedIn })); return renew(entered); }); @@ -258,10 +271,15 @@ export const register: Register = (on) => { 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. Any other prompt's turn - // starting first means the one holding the report left the queue unrun. - if (waiting !== null && e.text !== "" && !ranIn(e.text, waiting.text)) { + // 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 { let now = waiting.generation; await update($, generation, (g) => { now = g; diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index 0c0e7bee..24c65689 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -349,8 +349,10 @@ describe("the line under the answer (#266)", () => { 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" } 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: "second" }); @@ -358,6 +360,31 @@ describe("the line under the answer (#266)", () => { 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) => { + const world = stopWorld(on, ok(report("k"))); + await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); + // A new process: nothing in $.state, only the plugin's store. + 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("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 }); diff --git a/.claude/skills/obsidian-mind/types/index.d.ts b/.claude/skills/obsidian-mind/types/index.d.ts index ccb90572..dbaf4fb6 100644 --- a/.claude/skills/obsidian-mind/types/index.d.ts +++ b/.claude/skills/obsidian-mind/types/index.d.ts @@ -12,7 +12,7 @@ declare module "claude-code" { * prompt, and the line and urgent finding until the next completed * answer uses them. Null when nothing is waiting. */ - queued: { readonly sessionId: string; readonly report: string; readonly line: string | null; readonly urgent: string | null } | null; + 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). */ @@ -23,7 +23,7 @@ declare module "claude-code" { */ inFlight: { readonly text: string; - readonly record: { readonly sessionId: string; readonly report: string; readonly line: string | null; readonly urgent: string | null }; + readonly record: { readonly sessionId: string; readonly key: string; readonly report: string; readonly line: string | null; readonly urgent: string | null }; readonly generation: number; } | null; }; From ab5cf7dbbf61c2f279b6bb70340e6b6a15b821d9 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 10:35:47 +0200 Subject: [PATCH 18/20] mod tests: a fresh process modelled with a seeded store, not a same-process resume Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/stop.test.ts | 18 +++++++++++++----- .claude/skills/obsidian-mind/hooks/world.ts | 4 ++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.claude/skills/obsidian-mind/hooks/stop.test.ts b/.claude/skills/obsidian-mind/hooks/stop.test.ts index 24c65689..c63f60f2 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -17,8 +17,8 @@ const report = (key: string, extra: Partial = {}): StopReport => ({ }); /** The shared world beneath the mod, plus the prompt and answer stubs these tests steer. */ -function stopWorld(on: On, reply: () => Reply) { - const base = engine(on, reply); +function stopWorld(on: On, reply: () => Reply, store?: Readonly>) { + const base = engine(on, reply, store ? { store } : {}); const world = { runs: base.runs, passedDown: base.passedDown.Stop, @@ -361,9 +361,8 @@ describe("the line under the answer (#266)", () => { }); test("a report queued but never delivered survives a resume in a new process: it is queued again", async ($, on) => { - const world = stopWorld(on, ok(report("k"))); - await $.classic.Stop({ stop_hook_active: false, session_id: "A" }); - // A new process: nothing in $.state, only the plugin's store. + // 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" }); @@ -371,6 +370,15 @@ describe("the line under the answer (#266)", () => { 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. diff --git a/.claude/skills/obsidian-mind/hooks/world.ts b/.claude/skills/obsidian-mind/hooks/world.ts index 4423b378..073fad79 100644 --- a/.claude/skills/obsidian-mind/hooks/world.ts +++ b/.claude/skills/obsidian-mind/hooks/world.ts @@ -27,11 +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 { +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); + mock.store(on, options.store); on("process.run", (_$, e) => { world.runs.push(e as World["runs"][number]); const { exitCode, stdout, stderr = "", truncated = false } = reply(); From 53dcfe28839ad9ec01657af837320803690c1eba Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 10:36:10 +0200 Subject: [PATCH 19/20] docs: the store remembers delivery; when a report counts as delivered Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d4b5b3c4..ef47a130 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -218,7 +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 shown, by session id, in `$.store` (a file Claude Code keeps per plugin, capped at the most recent sessions), so a `claude --resume` in a new process does not show unchanged findings again, matching the settings hook's file-backed dedupe. 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. +- **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. --- From 65d216864376bceaa566810cf06897fefbfdd645 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Sat, 3 Oct 2026 10:39:07 +0200 Subject: [PATCH 20/20] simplify: a start no longer edits the store or checks generations in turn.start, both redundant once delivery is remembered; the eviction test seeds delivered sessions so the cap matters Co-Authored-By: Claude Opus 5.5 --- .../skills/obsidian-mind/hooks/register.ts | 23 ++++++------------- .../skills/obsidian-mind/hooks/stop.test.ts | 11 +++++---- .claude/skills/obsidian-mind/types/index.d.ts | 5 ++-- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/.claude/skills/obsidian-mind/hooks/register.ts b/.claude/skills/obsidian-mind/hooks/register.ts index 7ef453d0..06acc571 100644 --- a/.claude/skills/obsidian-mind/hooks/register.ts +++ b/.claude/skills/obsidian-mind/hooks/register.ts @@ -108,18 +108,12 @@ export const register: Register = (on) => { // 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") { - let dropped: Queued | null = null; - await update($, queued, (now) => { - dropped = now; - return null; - }); + // 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); - // A report dropped here never reached the agent, so the same findings - // show again in the session it was for; one it already has stays shown. - const lost: Queued | null = dropped; - if (lost !== null) await setShown($, lost.sessionId, 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 @@ -245,7 +239,7 @@ export const register: Register = (on) => { }; // 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, generation: startedIn })); + await update($, inFlight, () => ({ text: e.text, record })); let entered: Awaited>; try { entered = await next({ ...e, context: [...(e.context ?? []), record.report] }); @@ -280,12 +274,9 @@ export const register: Register = (on) => { 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 { - let now = waiting.generation; - await update($, generation, (g) => { - now = g; - return g; - }); - if (now === waiting.generation) await update($, queued, (current) => current ?? waiting.record); + // 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 index c63f60f2..73673bc8 100644 --- a/.claude/skills/obsidian-mind/hooks/stop.test.ts +++ b/.claude/skills/obsidian-mind/hooks/stop.test.ts @@ -320,14 +320,15 @@ describe("the line under the answer (#266)", () => { expect(world.submitted[1]?.context ?? []).toEqual([]); }); - test("what each session was shown is kept for the most recent sessions only", async ($, on) => { - const world = stopWorld(on, ok(report("k"))); - for (let i = 0; i <= 20; i++) await $.classic.Stop({ stop_hook_active: false, session_id: `s${i}` }); - // s0 is the oldest of 21: forgotten, so its findings show again; s20 is kept. + 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: "s0" }); + await $.classic.Stop({ stop_hook_active: false, session_id: "s1" }); await $.prompt.submit({ text: "forgotten" }); expect(world.submitted[1]?.context ?? []).toEqual([]); diff --git a/.claude/skills/obsidian-mind/types/index.d.ts b/.claude/skills/obsidian-mind/types/index.d.ts index dbaf4fb6..199f60dc 100644 --- a/.claude/skills/obsidian-mind/types/index.d.ts +++ b/.claude/skills/obsidian-mind/types/index.d.ts @@ -18,13 +18,12 @@ declare module "claude-code" { /** Bumped by every start that begins another conversation (startup, /clear, resume, fork). */ generation: number; /** - * The report a prompt took, the prompt's text and the generation it was - * taken in, until a turn starts with that prompt. Null when none is. + * 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 }; - readonly generation: number; } | null; }; }