diff --git a/src/hooks/shared.ts b/src/hooks/shared.ts index 9a86a42..3a8b210 100644 --- a/src/hooks/shared.ts +++ b/src/hooks/shared.ts @@ -581,6 +581,13 @@ export function normalizePath(p: string): string { * Count non-mechanical semantic entries written to memory.md today. * Mechanical entries (auto-generated file ops, session-end lines) don't count. * Used by the stop hook to detect whether Claude wrote a meaningful summary. + * + * Rows are written as `| HH:MM | ... |` — that is the format `OPENWOLF.md` and + * the stop-hook reminder both ask for, and the format the hook uses for its own + * `Session end:` rows. Sessions are delimited by `## Session: YYYY-MM-DD HH:MM` + * headers written by session-start. So "today" is decided by the enclosing + * session header, not by the row itself; a `| YYYY-MM-DD | ...` row is still + * honoured for anything that writes an explicit date. */ export function countSemanticEntries(wolfDir: string): number { const memoryPath = path.join(wolfDir, "memory.md"); @@ -589,9 +596,18 @@ export function countSemanticEntries(wolfDir: string): number { const mechanical = /^\|\s*[\d:]+\s*\|\s*(Created|Edited|Multi-edited|Session end:|designqc:)/; const today = new Date().toISOString().slice(0, 10); const todayPrefix = `| ${today}`; + const sessionHeader = /^##\s+Session:\s*(\d{4}-\d{2}-\d{2})/; + const timeRow = /^\|\s*\d{1,2}:\d{2}\s*\|/; let count = 0; + let inTodaySession = false; for (const line of content.split("\n")) { - if (line.startsWith(todayPrefix) && !mechanical.test(line)) count++; + const header = sessionHeader.exec(line); + if (header) { + inTodaySession = header[1] === today; + continue; + } + if (mechanical.test(line)) continue; + if (line.startsWith(todayPrefix) || (inTodaySession && timeRow.test(line))) count++; } return count; } catch { diff --git a/tests/semantic-entries.test.ts b/tests/semantic-entries.test.ts new file mode 100644 index 0000000..68faa59 --- /dev/null +++ b/tests/semantic-entries.test.ts @@ -0,0 +1,81 @@ +import { test, describe } from "node:test"; +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +// The stop hook nags "no semantic summary was written to memory.md" whenever +// countSemanticEntries returns 0. Rows in memory.md are `| HH:MM | ... |` — the +// format OPENWOLF.md documents, the reminder asks for, and the hook itself uses +// for its `Session end:` rows — so counting only `| YYYY-MM-DD` rows matched +// nothing and the reminder fired on every stop, forever. + +const TODAY = new Date().toISOString().slice(0, 10); +const YESTERDAY = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + +function wolfDirWith(memory: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wolf-semantic-")); + fs.writeFileSync(path.join(dir, "memory.md"), memory, "utf-8"); + return dir; +} + +async function count(memory: string): Promise { + const { countSemanticEntries } = await import("../src/hooks/shared.ts"); + return countSemanticEntries(wolfDirWith(memory)); +} + +describe("countSemanticEntries", () => { + test("counts an HH:MM row inside today's session block", async () => { + assert.equal( + await count( + `# memory\n\n## Session: ${TODAY} 09:00\n\n| 09:30 | refactored the parser | src/parse.ts | green | ~4k |\n`, + ), + 1, + ); + }); + + test("ignores the hook's own mechanical rows", async () => { + assert.equal( + await count( + `# memory\n\n## Session: ${TODAY} 09:00\n\n` + + `| 09:10 | Created src/a.ts | new file | ~20 |\n` + + `| 09:11 | Edited src/a.ts | modified foo() | ~12 |\n` + + `| 09:12 | Multi-edited src/a.ts | 3 edits | ~40 |\n` + + `| 09:59 | Session end: 4 writes across 2 files (a.ts, b.ts) | 3 reads | ~5k |\n` + + `| 09:40 | designqc: captured 3 routes | .wolf/designqc-captures | ok | ~7k |\n`, + ), + 0, + ); + }); + + test("does not count rows from an earlier session", async () => { + assert.equal( + await count( + `# memory\n\n## Session: ${YESTERDAY} 21:00\n\n| 22:55 | shipped the worker pool | src/pool.ts | green | ~5k |\n`, + ), + 0, + ); + }); + + test("still honours a row with an explicit date prefix", async () => { + assert.equal(await count(`# memory\n\n| ${TODAY} | wrote the handoff | STATUS.md | ok | ~5k |\n`), 1); + }); + + test("counts only the rows under today's header when sessions are stacked", async () => { + assert.equal( + await count( + `# memory\n\n## Session: ${YESTERDAY} 21:00\n\n` + + `| 22:55 | yesterday's summary | a.ts | ok | ~5k |\n\n` + + `## Session: ${TODAY} 09:00\n\n` + + `| 09:30 | today's summary | b.ts | ok | ~5k |\n` + + `| 09:45 | another one today | c.ts | ok | ~5k |\n`, + ), + 2, + ); + }); + + test("returns 0 when memory.md is missing", async () => { + const { countSemanticEntries } = await import("../src/hooks/shared.ts"); + assert.equal(countSemanticEntries(fs.mkdtempSync(path.join(os.tmpdir(), "wolf-empty-"))), 0); + }); +});