openwolf-enhanced 1.28.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/dashboard/assets/{AISuggestions-C7WFvzLO.js → AISuggestions-CKD-5h-c.js} +1 -1
  3. package/dist/dashboard/assets/{ActivityLog-SsRbpBFt.js → ActivityLog-BhURSYyt.js} +1 -1
  4. package/dist/dashboard/assets/{ActivityTimeline-BoV30nDY.js → ActivityTimeline-CJBgc2Xw.js} +1 -1
  5. package/dist/dashboard/assets/{AnatomyBrowser-DwzsHn4z.js → AnatomyBrowser-CH3-GM_8.js} +1 -1
  6. package/dist/dashboard/assets/{BugLog-CPl4Q-JG.js → BugLog-DliFykQ1.js} +1 -1
  7. package/dist/dashboard/assets/{CerebrumViewer-_f6fBsNF.js → CerebrumViewer-DOn8HiUY.js} +1 -1
  8. package/dist/dashboard/assets/{CronStatus-1iBEPJK5.js → CronStatus-D4kJmhyD.js} +1 -1
  9. package/dist/dashboard/assets/{DesignQC-D4UU7lca.js → DesignQC-5Cw-aAH0.js} +1 -1
  10. package/dist/dashboard/assets/{LinkGraph-BWtI8Gh3.js → LinkGraph-CsIS8mCj.js} +1 -1
  11. package/dist/dashboard/assets/{MemoryViewer-BYRVG34g.js → MemoryViewer-CEFFPOoL.js} +1 -1
  12. package/dist/dashboard/assets/{NativeMemory-Dphtrqjt.js → NativeMemory-DuelxupG.js} +1 -1
  13. package/dist/dashboard/assets/{ProjectOverview-CTYohvjD.js → ProjectOverview-Dcc-Z6UO.js} +1 -1
  14. package/dist/dashboard/assets/{ProjectsAggregate-CMxNs35H.js → ProjectsAggregate-LvFcMjZk.js} +1 -1
  15. package/dist/dashboard/assets/{StatusBadge-CjrFCDOI.js → StatusBadge-CphWVA52.js} +1 -1
  16. package/dist/dashboard/assets/{TokenUsage-C5wJpMl8.js → TokenUsage-BMaSQoYU.js} +1 -1
  17. package/dist/dashboard/assets/{index-C74qneS9.js → index-D5e2y39w.js} +3 -3
  18. package/dist/dashboard/index.html +1 -1
  19. package/dist/hooks/post-write.js +7 -3
  20. package/dist/hooks/pre-write.js +13 -4
  21. package/dist/hooks/session-start.js +8 -1
  22. package/dist/hooks/shared.js +242 -10
  23. package/dist/hooks/stop.js +60 -13
  24. package/dist/src/cli/doctor-cmd.js +4 -2
  25. package/dist/src/cli/doctor-cmd.js.map +1 -1
  26. package/dist/src/cli/init.js +1 -1
  27. package/dist/src/cli/init.js.map +1 -1
  28. package/dist/src/cli/link-cmd.js +3 -1
  29. package/dist/src/cli/link-cmd.js.map +1 -1
  30. package/dist/src/daemon/file-watcher.js +8 -1
  31. package/dist/src/daemon/file-watcher.js.map +1 -1
  32. package/dist/src/daemon/llm-provider.js +70 -1
  33. package/dist/src/daemon/llm-provider.js.map +1 -1
  34. package/dist/src/daemon/wolf-daemon.js +47 -4
  35. package/dist/src/daemon/wolf-daemon.js.map +1 -1
  36. package/dist/src/hooks/post-write.js +7 -3
  37. package/dist/src/hooks/post-write.js.map +1 -1
  38. package/dist/src/hooks/pre-write.js +13 -4
  39. package/dist/src/hooks/pre-write.js.map +1 -1
  40. package/dist/src/hooks/session-start.js +8 -1
  41. package/dist/src/hooks/session-start.js.map +1 -1
  42. package/dist/src/hooks/shared.js +242 -10
  43. package/dist/src/hooks/shared.js.map +1 -1
  44. package/dist/src/hooks/stop.js +60 -13
  45. package/dist/src/hooks/stop.js.map +1 -1
  46. package/dist/src/mcp/server.js +2 -1
  47. package/dist/src/mcp/server.js.map +1 -1
  48. package/dist/src/scanner/anatomy-scanner.js +7 -3
  49. package/dist/src/scanner/anatomy-scanner.js.map +1 -1
  50. package/dist/src/utils/embeddings.js +6 -1
  51. package/dist/src/utils/embeddings.js.map +1 -1
  52. package/dist/src/utils/fs-safe.js +86 -4
  53. package/dist/src/utils/fs-safe.js.map +1 -1
  54. package/dist/src/utils/maintenance.js +39 -2
  55. package/dist/src/utils/maintenance.js.map +1 -1
  56. package/dist/src/utils/remote.js +56 -5
  57. package/dist/src/utils/remote.js.map +1 -1
  58. package/package.json +7 -2
  59. package/src/templates/OPENWOLF.md +6 -3
  60. package/src/templates/claude-rules-openwolf.md +6 -17
  61. package/src/templates/config.json +0 -1
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="./assets/index-C74qneS9.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-D5e2y39w.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="./assets/index-BNi8lsJE.css">
12
12
  </head>
13
13
  <body>
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getWolfDir, ensureWolfDir, writeJSON, writeAtomic, parseAnatomy, serializeAnatomy, extractDescription, estimateFileTokens, getTokenRatios, appendMarkdown, timeShort, readStdin, normalizePath, getRetention, loadIgnore, readBugLog, isSecretFile, updateSession, isOutsideProject, tryWithLock, sessionFileFor } from "./shared.js";
3
+ import { getWolfDir, ensureWolfDir, writeJSON, writeAtomic, parseAnatomy, serializeAnatomy, extractDescription, estimateFileTokens, getTokenRatios, appendMarkdown, timeShort, readStdin, normalizePath, getRetention, loadIgnore, readBugLog, isSecretFile, updateSession, isOutsideProject, tryWithLock, sessionFileFor, capBuglogWithArchive } from "./shared.js";
4
4
  async function main() {
5
5
  ensureWolfDir();
6
6
  const wolfDir = getWolfDir();
@@ -320,9 +320,13 @@ function autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr) {
320
320
  last_seen: new Date().toISOString(),
321
321
  });
322
322
  // Keep buglog.json bounded — auto-detection can otherwise append on nearly every edit.
323
+ // Overflow is MOVED to buglog-archive.json, never dropped: the old slice(-max) deleted the
324
+ // oldest curated entries, which are the ones other files cite by id.
323
325
  const maxBugs = getRetention(wolfDir).buglog_max_entries;
324
- if (bugLog.bugs.length > maxBugs) {
325
- bugLog.bugs = bugLog.bugs.slice(-maxBugs);
326
+ const capped = capBuglogWithArchive(wolfDir, bugLog.bugs, maxBugs);
327
+ if (capped.archived.length) {
328
+ bugLog.bugs = capped.kept;
329
+ process.stderr.write(`[openwolf] buglog over ${maxBugs} entries — moved ${capped.archived.length} to buglog-archive.json\n`);
326
330
  }
327
331
  writeJSON(bugLogPath, bugLog);
328
332
  });
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, readStdin, readBugLog, tryWithLock, sessionFileFor } from "./shared.js";
3
+ import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, readStdin, readBugLog, tryWithLock, sessionFileFor, extractMarkdownSection } from "./shared.js";
4
4
  async function main() {
5
5
  ensureWolfDir();
6
6
  const wolfDir = getWolfDir();
@@ -46,18 +46,23 @@ async function main() {
46
46
  }
47
47
  process.exit(0);
48
48
  }
49
+ // One write should never produce a wall of warnings; the first few are the useful ones.
50
+ const MAX_CEREBRUM_WARNINGS = 3;
49
51
  function checkCerebrum(wolfDir, content) {
50
52
  const cerebrumContent = readMarkdown(path.join(wolfDir, "cerebrum.md"));
51
- const doNotRepeatSection = cerebrumContent.split("## Do-Not-Repeat")[1];
52
- if (!doNotRepeatSection)
53
+ // Anchored heading lookup, not a substring: an entry that quotes "## Do-Not-Repeat" inside its
54
+ // own prose used to win the split and reduce this check to a handful of unrelated bullets.
55
+ const entries = extractMarkdownSection(cerebrumContent, /^#{2,3}\s*Do[-\s]?Not[-\s]?Repeat/i);
56
+ if (!entries)
53
57
  return 0;
54
- const entries = doNotRepeatSection.split("## ")[0];
55
58
  const lines = entries.split("\n").filter((l) => l.trim().startsWith("[") || l.trim().startsWith("-"));
56
59
  let fired = 0;
57
60
  for (const line of lines) {
58
61
  const trimmed = line.trim().replace(/^[-*]\s*/, "").replace(/^\[[\d-]+\]\s*/, "");
59
62
  if (!trimmed)
60
63
  continue;
64
+ if (fired >= MAX_CEREBRUM_WARNINGS)
65
+ break;
61
66
  const patterns = [];
62
67
  const quotedMatches = trimmed.match(/"([^"]+)"/g) || trimmed.match(/'([^']+)'/g) || trimmed.match(/`([^`]+)`/g);
63
68
  if (quotedMatches) {
@@ -69,6 +74,10 @@ function checkCerebrum(wolfDir, content) {
69
74
  if (neverMatch)
70
75
  patterns.push(neverMatch[1]);
71
76
  for (const pattern of patterns) {
77
+ // Anything shorter than four characters ("id", "os", "npm") matches almost any file and
78
+ // would turn a correctly-scoped section into a warning machine.
79
+ if (pattern.trim().length < 4)
80
+ continue;
72
81
  try {
73
82
  const regex = new RegExp(`\\b${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
74
83
  if (regex.test(content)) {
@@ -97,7 +97,14 @@ async function main() {
97
97
  // starts must not clobber the read-modify-write.
98
98
  const ledgerPath = path.join(wolfDir, "token-ledger.json");
99
99
  tryWithLock(ledgerPath, () => {
100
- const ledger = readJSON(ledgerPath, { version: 1, lifetime: { total_sessions: 0 } });
100
+ // The full shape, including `sessions: []`. Writing the stub without it used to poison the
101
+ // file: the stop hook reads it (it exists now, so no default applies) and died in
102
+ // `ledger.sessions.findIndex` on every turn of every session until someone fixed it by hand.
103
+ const ledger = readJSON(ledgerPath, { version: 1, sessions: [], lifetime: { total_sessions: 0 } });
104
+ if (!Array.isArray(ledger.sessions))
105
+ ledger.sessions = [];
106
+ if (!ledger.lifetime || typeof ledger.lifetime !== "object")
107
+ ledger.lifetime = { total_sessions: 0 };
101
108
  ledger.lifetime.total_sessions++;
102
109
  writeJSON(ledgerPath, ledger);
103
110
  });
@@ -99,19 +99,51 @@ export class LockTimeoutError extends Error {
99
99
  this.name = "LockTimeoutError";
100
100
  }
101
101
  }
102
+ /**
103
+ * The lock could not be taken for a reason that waiting will not fix — a missing directory we
104
+ * could not create, a read-only filesystem, no permission. Extends LockTimeoutError on purpose:
105
+ * every caller already treats that as "skip the update and say so", which is exactly right here
106
+ * too, and a hook must never die on it (see tryWithLock).
107
+ */
108
+ export class LockUnavailableError extends LockTimeoutError {
109
+ code;
110
+ constructor(targetPath, code) {
111
+ super(targetPath, 0);
112
+ this.code = code;
113
+ this.message = `could not acquire the lock on ${path.basename(targetPath)} (${code}) — update skipped`;
114
+ this.name = "LockUnavailableError";
115
+ }
116
+ }
102
117
  function sleepSync(ms) {
103
118
  try {
104
119
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
105
120
  }
106
121
  catch { /* no-op */ }
107
122
  }
123
+ /**
124
+ * Is the lock file old enough to be considered abandoned?
125
+ *
126
+ * Age only. A pid liveness check was tried here and REMOVED on purpose: pids are recycled, so a
127
+ * lock left behind by a dead process whose number now belongs to an unrelated one would never be
128
+ * reclaimable again — a permanent lockout in exchange for a clock-skew problem nobody measured.
129
+ * Losing a lock occasionally beats never getting it back. The real defect was that reclaiming was
130
+ * not atomic; that is fixed at the call site.
131
+ */
132
+ function isStaleLock(lockPath, staleMs) {
133
+ return Date.now() - fs.statSync(lockPath).mtimeMs > staleMs;
134
+ }
108
135
  export function withLock(targetPath, fn) {
109
136
  const lockPath = targetPath + ".lock";
110
137
  const MAX_WAIT_MS = 1500; // well inside the tightest hook budget (5s)
111
138
  const STALE_MS = 5000;
139
+ // A lock that vanishes between our open and our stat is worth retrying at once — but only a few
140
+ // times. Without a bound, any error we did not anticipate turns the retry into a busy-loop that
141
+ // saturates a core for the whole budget and then fails anyway (bug-352).
142
+ const MAX_IMMEDIATE_RETRIES = 8;
112
143
  const start = Date.now();
113
144
  let held = false;
114
145
  let backoff = 2;
146
+ let immediateRetries = 0;
115
147
  while (Date.now() - start < MAX_WAIT_MS) {
116
148
  try {
117
149
  const fd = fs.openSync(lockPath, "wx");
@@ -123,16 +155,50 @@ export function withLock(targetPath, fn) {
123
155
  held = true;
124
156
  break;
125
157
  }
126
- catch {
158
+ catch (openErr) {
159
+ const code = openErr?.code;
160
+ // EEXIST is the only failure that means "somebody else holds it" — the one case where
161
+ // waiting can help. Everything else is a property of the filesystem, not of a competitor,
162
+ // and will still be true in 1500ms, so spinning on it burns CPU to reach a certain failure.
163
+ if (code !== "EEXIST") {
164
+ // The one recoverable non-EEXIST case: the directory does not exist yet. The caller is
165
+ // about to write into it anyway, so create it and try once more.
166
+ if (code === "ENOENT" && immediateRetries < MAX_IMMEDIATE_RETRIES) {
167
+ immediateRetries++;
168
+ try {
169
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
170
+ continue;
171
+ }
172
+ catch { /* fall through */ }
173
+ }
174
+ throw new LockUnavailableError(targetPath, code ?? "UNKNOWN");
175
+ }
127
176
  try {
128
- if (Date.now() - fs.statSync(lockPath).mtimeMs > STALE_MS) {
129
- fs.unlinkSync(lockPath);
177
+ if (isStaleLock(lockPath, STALE_MS)) {
178
+ // Claim the stale lock ATOMICALLY. `unlink` + create is not: B stats the old lock, A
179
+ // removes it and creates a fresh one, B then removes A's FRESH lock and creates its own
180
+ // — and both run the critical section. Measured with 20 processes and one stale lock
181
+ // left behind: 12 lost updates in 8 rounds, and 0 without it. Only the process whose
182
+ // rename succeeds may remove the file; everyone else falls through and waits.
183
+ const claim = `${lockPath}.stale-${process.pid}-${Date.now()}`;
184
+ try {
185
+ fs.renameSync(lockPath, claim);
186
+ fs.unlinkSync(claim);
187
+ }
188
+ catch { /* somebody claimed it first */ }
130
189
  continue;
131
190
  }
132
191
  }
133
192
  catch {
193
+ // Lock vanished between open and stat — retry immediately, but never unboundedly.
194
+ if (immediateRetries < MAX_IMMEDIATE_RETRIES) {
195
+ immediateRetries++;
196
+ continue;
197
+ }
198
+ sleepSync(backoff);
199
+ backoff = Math.min(backoff * 2, 50);
134
200
  continue;
135
- } // lock vanished — retry immediately
201
+ }
136
202
  sleepSync(backoff);
137
203
  backoff = Math.min(backoff * 2, 50);
138
204
  }
@@ -559,7 +625,14 @@ export function compactMemoryIfLarge(wolfDir, maxBytes) {
559
625
  flush();
560
626
  if (!changed)
561
627
  return;
562
- writeAtomic(p, () => out.join("\n"));
628
+ // Same lock as appendMarkdown: without it an append landing between the read above and this
629
+ // rename is written to the old inode and vanishes.
630
+ try {
631
+ withLock(p, () => writeAtomic(p, () => out.join("\n")));
632
+ }
633
+ catch {
634
+ writeAtomic(p, () => out.join("\n"));
635
+ }
563
636
  }
564
637
  export function readBugLog(wolfDir) {
565
638
  const raw = readJSON(path.join(wolfDir, "buglog.json"), { version: 1, bugs: [] });
@@ -568,6 +641,50 @@ export function readBugLog(wolfDir) {
568
641
  const o = (raw ?? {});
569
642
  return { version: o.version ?? 1, bugs: Array.isArray(o.bugs) ? o.bugs : [] };
570
643
  }
644
+ /**
645
+ * Cap buglog.json WITHOUT losing anything.
646
+ *
647
+ * The old `bugs.slice(-max)` cut from the front, so it deleted the OLDEST hand-written entries —
648
+ * exactly the ones other knowledge files cite by id ("see bug-084"). On a real project that had
649
+ * grown to 295 curated entries against a cap of 200, a single auto-detected fix would have thrown
650
+ * away 95 of them with no message. A cross-referenced logbook must never lose an entry; it may
651
+ * only move it somewhere the citation can still be resolved.
652
+ *
653
+ * Overflow now goes to `.wolf/buglog-archive.json`. Auto-detected noise is spent first (it is
654
+ * regenerable), curated entries only once that is exhausted — and even those are archived, not
655
+ * dropped. The archive is append-only and deduplicated by id; it is meant to grow.
656
+ */
657
+ export function capBuglogWithArchive(wolfDir, bugs, max) {
658
+ if (!Array.isArray(bugs) || bugs.length <= max || max < 0)
659
+ return { kept: bugs, archived: [] };
660
+ const isAuto = (b) => Array.isArray(b.tags) && b.tags.includes("auto-detected");
661
+ const overflow = bugs.length - max;
662
+ const drop = new Set();
663
+ for (let i = 0; i < bugs.length && drop.size < overflow; i++)
664
+ if (isAuto(bugs[i]))
665
+ drop.add(i);
666
+ for (let i = 0; i < bugs.length && drop.size < overflow; i++)
667
+ drop.add(i);
668
+ const kept = bugs.filter((_, i) => !drop.has(i));
669
+ const archived = bugs.filter((_, i) => drop.has(i));
670
+ appendBuglogArchive(wolfDir, archived);
671
+ return { kept, archived };
672
+ }
673
+ /** Append entries to `.wolf/buglog-archive.json`, skipping ids that are already in there. */
674
+ export function appendBuglogArchive(wolfDir, entries) {
675
+ if (!entries.length)
676
+ return 0;
677
+ const p = path.join(wolfDir, "buglog-archive.json");
678
+ const raw = readJSON(p, { version: 1, bugs: [] });
679
+ const o = Array.isArray(raw) ? { version: 1, bugs: raw } : (raw ?? {});
680
+ const existing = Array.isArray(o.bugs) ? o.bugs : [];
681
+ const seen = new Set(existing.map((b) => String(b.id)));
682
+ const added = entries.filter((b) => !seen.has(String(b.id)));
683
+ if (!added.length)
684
+ return 0;
685
+ writeJSON(p, { version: o.version ?? 1, bugs: [...existing, ...added] });
686
+ return added.length;
687
+ }
571
688
  export function readMarkdown(filePath) {
572
689
  try {
573
690
  return fs.readFileSync(filePath, "utf-8");
@@ -576,11 +693,64 @@ export function readMarkdown(filePath) {
576
693
  return "";
577
694
  }
578
695
  }
696
+ /**
697
+ * Append a line, under the same lock the compactors take.
698
+ *
699
+ * A bare `appendFileSync` races the tmp+rename of `compactMemoryIfLarge` and `consolidateMemory`:
700
+ * an append that lands between their read and their rename goes to the old inode and disappears
701
+ * with it. Both sides "work", the line is simply gone. If the lock cannot be had we still append —
702
+ * a rare race beats losing the line outright, which is what happens if we give up here.
703
+ */
579
704
  export function appendMarkdown(filePath, line) {
580
705
  const dir = path.dirname(filePath);
581
706
  if (!fs.existsSync(dir))
582
707
  fs.mkdirSync(dir, { recursive: true });
583
- fs.appendFileSync(filePath, line, "utf-8");
708
+ const write = () => fs.appendFileSync(filePath, line, "utf-8");
709
+ try {
710
+ withLock(filePath, write);
711
+ }
712
+ catch {
713
+ write();
714
+ }
715
+ }
716
+ /**
717
+ * Replace `previousLine` with `line`, or append if it is not there.
718
+ *
719
+ * The stop hook runs on EVERY turn and its numbers are cumulative for the whole session, so
720
+ * appending produced one near-identical "Session end" row per turn — 232 rows for 173 sessions in
721
+ * one real project, six of them consecutive and differing only in the timestamp. The ledger has
722
+ * replaced-by-id since 1.28.0; memory.md now does the same thing with the line itself as the key.
723
+ */
724
+ export function replaceOrAppendMarkdown(filePath, previousLine, line) {
725
+ if (!previousLine) {
726
+ appendMarkdown(filePath, line);
727
+ return;
728
+ }
729
+ const swap = () => {
730
+ let content;
731
+ try {
732
+ content = fs.readFileSync(filePath, "utf-8");
733
+ }
734
+ catch {
735
+ fs.appendFileSync(filePath, line, "utf-8");
736
+ return;
737
+ }
738
+ const at = content.lastIndexOf(previousLine);
739
+ if (at === -1) {
740
+ fs.appendFileSync(filePath, line, "utf-8");
741
+ return;
742
+ }
743
+ writeAtomic(filePath, () => content.slice(0, at) + line + content.slice(at + previousLine.length));
744
+ };
745
+ try {
746
+ withLock(filePath, swap);
747
+ }
748
+ catch {
749
+ try {
750
+ swap();
751
+ }
752
+ catch { /* memory.md is a nicety */ }
753
+ }
584
754
  }
585
755
  export function parseAnatomy(content) {
586
756
  const sections = new Map();
@@ -1176,14 +1346,38 @@ export function timeShort() {
1176
1346
  const d = new Date();
1177
1347
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
1178
1348
  }
1349
+ /**
1350
+ * Read the hook payload from stdin.
1351
+ *
1352
+ * Every hook starts here, so this function sets the floor for hook latency — and under Bun it was
1353
+ * the whole story. Measured on this repo: Bun starts a trivial script in 9ms against Node's 23ms,
1354
+ * but reading stdin through the Node stream shim costs Bun ~15ms and Node ~2ms, which cancelled
1355
+ * the entire runtime advantage (Bun 23ms vs Node 24ms for start+stdin). Bun's own
1356
+ * `Bun.stdin.text()` does the same job in ~1ms, taking the floor to 8ms.
1357
+ *
1358
+ * So: a Bun fast path, and the Node path left exactly as it was — switching Node to
1359
+ * `readFileSync(0)` measured no better (26ms vs 25ms) and would risk the blocking-read edge cases
1360
+ * for nothing.
1361
+ *
1362
+ * The 4s guard applies to both. It is not decoration: on Windows, stdin delivery from Claude Code
1363
+ * hooks can lag, and a hook that never resolves takes the tool call with it.
1364
+ */
1365
+ const STDIN_TIMEOUT_MS = 4000;
1179
1366
  export function readStdin() {
1367
+ const bunStdin = globalThis.Bun?.stdin;
1368
+ if (typeof bunStdin?.text === "function") {
1369
+ return Promise.race([
1370
+ bunStdin.text().then((t) => t || "{}").catch(() => "{}"),
1371
+ new Promise((resolve) => setTimeout(() => resolve("{}"), STDIN_TIMEOUT_MS)),
1372
+ ]);
1373
+ }
1180
1374
  return new Promise((resolve) => {
1181
1375
  const chunks = [];
1182
1376
  process.stdin.on("data", (chunk) => chunks.push(chunk));
1183
1377
  process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
1184
1378
  // If no stdin data after 4s, resolve with whatever we have so far.
1185
1379
  // On Windows, stdin delivery from Claude Code hooks can be slow.
1186
- setTimeout(() => resolve(chunks.length ? Buffer.concat(chunks).toString("utf-8") : "{}"), 4000);
1380
+ setTimeout(() => resolve(chunks.length ? Buffer.concat(chunks).toString("utf-8") : "{}"), STDIN_TIMEOUT_MS);
1187
1381
  });
1188
1382
  }
1189
1383
  export function normalizePath(p) {
@@ -1352,6 +1546,16 @@ export function stripPrivate(text) {
1352
1546
  export function blankPrivate(text) {
1353
1547
  return redact(text, (m) => m.replace(/[^\n]/g, ""));
1354
1548
  }
1549
+ /**
1550
+ * Same redaction, but the block is replaced by a visible marker.
1551
+ *
1552
+ * For a reader — the dashboard, a file viewer — silently deleting the region is worse than saying
1553
+ * that something is hidden: the reader cannot tell an empty section from a redacted one. Recall and
1554
+ * every egress path keep using strip/blankPrivate; this one is for display.
1555
+ */
1556
+ export function maskPrivate(text) {
1557
+ return redact(text, () => "[private — hidden by OpenWolf]");
1558
+ }
1355
1559
  // Structured session-summary scaffold written under each new session header in memory.md.
1356
1560
  // An HTML comment → invisible in rendered markdown (no clutter), but a clear prompt for the
1357
1561
  // agent to replace at session end with a consistent, greppable one-liner (see OPENWOLF.md).
@@ -1371,7 +1575,14 @@ function isStatusStub(s) {
1371
1575
  return /\{\{PROJECT_NAME\}\}/.test(s) || (/_<[^>]*>_/.test(s) && /nothing yet/.test(s));
1372
1576
  }
1373
1577
  // Body of a markdown section identified by its heading, up to the next h1/h2 heading.
1374
- function extractMarkdownSection(md, headingRe) {
1578
+ /**
1579
+ * Body of the first section whose heading matches `headingRe`, up to the next heading of the same
1580
+ * or a higher level. Exported because pre-write.ts needs the SAME anchored lookup: it used to find
1581
+ * its section with `split("## Do-Not-Repeat")`, a plain substring, which on a large cerebrum.md
1582
+ * matched a sentence that merely quoted the heading name — and then scanned 5 unrelated bullets
1583
+ * instead of the 175 real entries, silently, for as long as the file had grown.
1584
+ */
1585
+ export function extractMarkdownSection(md, headingRe) {
1375
1586
  const lines = md.split(/\r?\n/);
1376
1587
  let start = -1;
1377
1588
  for (let i = 0; i < lines.length; i++) {
@@ -1382,9 +1593,11 @@ function extractMarkdownSection(md, headingRe) {
1382
1593
  }
1383
1594
  if (start === -1)
1384
1595
  return "";
1596
+ const level = (lines[start].match(/^#+/)?.[0].length) ?? 2;
1385
1597
  const body = [];
1386
1598
  for (let i = start + 1; i < lines.length; i++) {
1387
- if (/^#{1,2}\s/.test(lines[i]))
1599
+ const m = lines[i].match(/^(#{1,6})\s/);
1600
+ if (m && m[1].length <= level)
1388
1601
  break;
1389
1602
  body.push(lines[i]);
1390
1603
  }
@@ -1422,11 +1635,30 @@ function memoryHeadline(md) {
1422
1635
  const BULLET_RE = /^\s*[-*] /gm;
1423
1636
  // Index of knowledge files the model can pull on demand, with entry counts and token cost —
1424
1637
  // so it knows what's available without us pre-dumping it (progressive disclosure).
1638
+ /** `openwolf.cerebrum.max_tokens` from config.json, with the template's own default. */
1639
+ function cerebrumBudget(read) {
1640
+ try {
1641
+ const cfg = JSON.parse(read("config.json"));
1642
+ const v = cfg.openwolf?.cerebrum?.max_tokens;
1643
+ if (typeof v === "number" && v > 0)
1644
+ return v;
1645
+ }
1646
+ catch { /* no config, or not JSON */ }
1647
+ return 2000;
1648
+ }
1425
1649
  function availabilityIndex(read, cerebrum, nativeDir) {
1426
1650
  const items = [];
1427
1651
  if (cerebrum.trim()) {
1428
1652
  const entries = (cerebrum.match(BULLET_RE) || []).length;
1429
- items.push(`- cerebrum.md — ${entries} entries, ~${estimateTokens(cerebrum, "prose")} tok (preferences, learnings, decisions, do-not-repeat) → Read or \`openwolf recall\``);
1653
+ const tok = estimateTokens(cerebrum, "prose");
1654
+ // `cerebrum.max_tokens` existed in every generated config.json since forever and was read by
1655
+ // nothing at all. Meanwhile OPENWOLF.md tells the model to read the whole file — which on a
1656
+ // grown project is ~126k tokens, 63× the configured budget. Honour the budget here: past it,
1657
+ // "Read" stops being offered and recall is the only route.
1658
+ const budget = cerebrumBudget(read);
1659
+ items.push(tok > budget
1660
+ ? `- cerebrum.md — ${entries} entries, ~${tok} tok — TOO LARGE TO READ (budget ${budget}); use \`openwolf recall <query>\`, not Read`
1661
+ : `- cerebrum.md — ${entries} entries, ~${tok} tok (preferences, learnings, decisions, do-not-repeat) → Read or \`openwolf recall\``);
1430
1662
  }
1431
1663
  const buglog = read("buglog.json");
1432
1664
  if (buglog.trim()) {
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getWolfDir, ensureWolfDir, readJSON, writeJSON, appendMarkdown, timeShort, getRetention, compactMemoryIfLarge, countSemanticEntries, tryWithLock, updateSession, sessionFileFor, readStdin, readTranscriptUsage, detectAgent, bookInjection } from "./shared.js";
3
+ import { getWolfDir, ensureWolfDir, readJSON, writeJSON, replaceOrAppendMarkdown, timeShort, getRetention, compactMemoryIfLarge, countSemanticEntries, tryWithLock, updateSession, sessionFileFor, readStdin, readTranscriptUsage, detectAgent, bookInjection } from "./shared.js";
4
4
  import { estimateTokens, getTokenRatios } from "./token-estimator.js";
5
5
  const NOTHING_BOOKED = {
6
6
  reads: 0, writes: 0, tokens: 0, anatomy_hits: 0, anatomy_misses: 0, repeated_reads_blocked: 0,
@@ -28,7 +28,14 @@ async function main() {
28
28
  // session into the ledger, so reading a different session's file here would attribute one
29
29
  // session's whole turn to another.
30
30
  const sessionFile = sessionFileFor(hooksDir, hookInput.session_id);
31
- const session = readJSON(sessionFile, {
31
+ // NORMALISE, do not assume. readJSON only applies these defaults when the file is MISSING —
32
+ // but post-bash, pre-write and post-read each create the file with their OWN partial shape when
33
+ // they run first (`{files_written:[], edit_counts:{}}`, `{}`), which happens whenever the
34
+ // session predates an `openwolf update` or session-start skipped its write under contention.
35
+ // `Object.keys(session.files_read)` then threw, main().catch() swallowed it, and the whole
36
+ // session produced no ledger entry, no memory.md line and no reminder — without one line of
37
+ // output. A real project carried such a file for days.
38
+ const SESSION_DEFAULTS = {
32
39
  session_id: "",
33
40
  started: "",
34
41
  files_read: {},
@@ -39,7 +46,15 @@ async function main() {
39
46
  repeated_reads_warned: 0,
40
47
  cerebrum_warnings: 0,
41
48
  stop_count: 0,
42
- });
49
+ };
50
+ const stored = readJSON(sessionFile, {});
51
+ const session = { ...SESSION_DEFAULTS, ...stored };
52
+ if (!session.files_read || typeof session.files_read !== "object")
53
+ session.files_read = {};
54
+ if (!Array.isArray(session.files_written))
55
+ session.files_written = [];
56
+ if (!session.edit_counts || typeof session.edit_counts !== "object")
57
+ session.edit_counts = {};
43
58
  session.stop_count++;
44
59
  // Only write to ledger if there's been activity
45
60
  const readCount = Object.keys(session.files_read).length;
@@ -96,7 +111,10 @@ async function main() {
96
111
  // the ledger lock — it's a plain file read; only the accumulation below runs under the lock.
97
112
  const realUsage = hookInput.transcript_path ? readTranscriptUsage(hookInput.transcript_path) : null;
98
113
  const sessionEntry = {
99
- id: session.session_id,
114
+ // The harness session id, not the minute-granular one from session-start: two sessions started
115
+ // in the same minute shared an id, and this hook REPLACES by id — so the second one's stop
116
+ // overwrote the first one's entry while total_sessions still counted both.
117
+ id: hookInput.session_id || session.session_id,
100
118
  agent: detectAgent(),
101
119
  started: session.started,
102
120
  ended: new Date().toISOString(),
@@ -151,6 +169,10 @@ async function main() {
151
169
  }
152
170
  // One entry per session, not per turn: the entry carries the session's cumulative state, so a
153
171
  // later stop REPLACES the earlier one instead of appending a near-duplicate under the same id.
172
+ // session-start writes a `{version, lifetime}` stub when the ledger is missing; without this
173
+ // guard every stop hook from then on died in findIndex — silently, for good.
174
+ if (!Array.isArray(ledger.sessions))
175
+ ledger.sessions = [];
154
176
  const existing = ledger.sessions.findIndex((s) => s.id === sessionEntry.id);
155
177
  if (existing === -1)
156
178
  ledger.sessions.push(sessionEntry);
@@ -164,12 +186,17 @@ async function main() {
164
186
  const booked = { ...NOTHING_BOOKED, ...(session.booked ?? {}) };
165
187
  const totalWritesNow = writeCount + unnamedWrites;
166
188
  const tokensNow = inputTokens + outputTokens;
167
- // Estimate savings: anatomy hits save ~200 tokens each, repeated reads blocked save their token count
168
- const savedFromAnatomy = session.anatomy_hits * 200;
169
- const savedFromRepeats = Object.values(session.files_read)
170
- .filter((r) => r.count > 1)
171
- .reduce((sum, r) => sum + r.tokens * (r.count - 1), 0);
172
- const savingsNow = savedFromAnatomy + savedFromRepeats;
189
+ // Savings, counted honestly.
190
+ //
191
+ // This used to credit ~200 tokens per anatomy hit and the FULL file size for every repeated read
192
+ // — as if both had been prevented. Neither is true: those hints go to stderr with exit 0, which
193
+ // the model never sees, and no read is ever blocked. A report that invents its own credit is
194
+ // worse than no report, so only genuinely avoided tokens are booked. `repeated_reads_warned`
195
+ // and `anatomy_hits` are still recorded; they are counters, not savings.
196
+ //
197
+ // What IS avoided: nothing measurable from the hooks today. The honest number is zero until a
198
+ // hint actually reaches the model (via additionalContext) or a read is actually prevented.
199
+ const savingsNow = 0;
173
200
  ledger.lifetime.total_reads += delta(readCount, booked.reads);
174
201
  // Unnamed writes (shell / other working dirs) count toward the lifetime total — they were real
175
202
  // work. They stay OUT of the session's named writes[] list (no path was recorded, by design).
@@ -227,6 +254,7 @@ async function main() {
227
254
  });
228
255
  // Write a session summary line to memory.md if there was meaningful activity
229
256
  const memoryPath = path.join(wolfDir, "memory.md");
257
+ let memoryLineNow = session.memory_line ?? "";
230
258
  // A session that wrote only through the shell, or only in another directory, still happened.
231
259
  // Without this, memory.md shows a gap exactly where the work was — which is how someone later
232
260
  // concludes the day was quiet.
@@ -236,7 +264,9 @@ async function main() {
236
264
  externalWrites > 0 ? `${externalWrites} outside this project root` : "",
237
265
  bashWrites > 0 ? `${bashWrites} through the shell` : "",
238
266
  ].filter(Boolean).join(", ");
239
- appendMarkdown(memoryPath, `| ${timeShort()} | Session end: ${unnamedWrites} untracked writes (${via}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`);
267
+ const line = `| ${timeShort()} | Session end: ${unnamedWrites} untracked writes (${via}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`;
268
+ replaceOrAppendMarkdown(memoryPath, session.memory_line ?? "", line);
269
+ memoryLineNow = line;
240
270
  }
241
271
  catch { /* memory.md is a nicety, not a dependency */ }
242
272
  }
@@ -244,7 +274,16 @@ async function main() {
244
274
  try {
245
275
  const uniqueFiles = new Set(session.files_written.map(w => path.basename(w.file)));
246
276
  const fileList = [...uniqueFiles].slice(0, 5).join(", ");
247
- appendMarkdown(memoryPath, `| ${timeShort()} | Session end: ${writeCount} writes across ${uniqueFiles.size} files (${fileList}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`);
277
+ const line = `| ${timeShort()} | Session end: ${writeCount} writes across ${uniqueFiles.size} files (${fileList}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`;
278
+ replaceOrAppendMarkdown(memoryPath, session.memory_line ?? "", line);
279
+ memoryLineNow = line;
280
+ }
281
+ catch { }
282
+ }
283
+ // Remember the row we just wrote so the next turn replaces it instead of adding another.
284
+ if (memoryLineNow !== (session.memory_line ?? "")) {
285
+ try {
286
+ updateSession(sessionFile, session, (t) => { t.memory_line = memoryLineNow; });
248
287
  }
249
288
  catch { }
250
289
  }
@@ -344,4 +383,12 @@ function checkSemanticSummaries(wolfDir, writeCount) {
344
383
  return null;
345
384
  return `${writeCount} files were changed this session but no meaningful summary was written to memory.md. Consider recording what you did and why.`;
346
385
  }
347
- main().catch(() => process.exit(0));
386
+ // A hook must never fail the turn — but it must not disappear either. Swallowing the error is how
387
+ // a broken session file cost a whole session's bookkeeping without a trace.
388
+ main().catch((err) => {
389
+ try {
390
+ process.stderr.write(`[openwolf] stop hook failed: ${err?.stack || err?.message || String(err)}\n`);
391
+ }
392
+ catch { /* stderr gone too — nothing left to do */ }
393
+ process.exit(0);
394
+ });
@@ -133,8 +133,10 @@ export async function doctorCommand(opts) {
133
133
  if (nd) {
134
134
  const h = nativeMemoryHealth(nd);
135
135
  console.log("\nClaude native memory (~/.claude/…/memory):");
136
- console.log(` ${h.topicFiles} topic files, ${humanBytes(h.footprintBytes)}; MEMORY.md index ${h.indexLines} lines (${h.indexedCount} referenced)`);
137
- if (h.indexCutoffExceeded)
136
+ console.log(` ${h.topicFiles} topic files, ${humanBytes(h.footprintBytes)}; MEMORY.md index ${h.indexLines} lines / ${humanBytes(h.indexBytes)} (${h.indexedCount} referenced)`);
137
+ if (h.indexBytesExceeded)
138
+ console.log(` ⚠ MEMORY.md is ${humanBytes(h.indexBytes)} — Claude Code stops loading it at ~24 KB, so the tail never reaches a session. Shorten the entries (one line, well under 200 chars each).`);
139
+ else if (h.indexCutoffExceeded)
138
140
  console.log(` ⚠ MEMORY.md > 200 lines — only the first 200 load at session start; the rest is invisible until you trim it.`);
139
141
  if (h.orphanCount)
140
142
  console.log(` ⚠ ${h.orphanCount} topic files not in the index → never surface on resume. Search them: \`openwolf recall <query>\``);