openwolf-enhanced 1.26.0 → 1.28.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 (95) hide show
  1. package/CHANGELOG.md +306 -0
  2. package/dist/dashboard/assets/{AISuggestions-DZmzksSA.js → AISuggestions-C7WFvzLO.js} +1 -1
  3. package/dist/dashboard/assets/{ActivityLog-BJ0omuzu.js → ActivityLog-SsRbpBFt.js} +1 -1
  4. package/dist/dashboard/assets/{ActivityTimeline-BWhxretI.js → ActivityTimeline-BoV30nDY.js} +1 -1
  5. package/dist/dashboard/assets/{AnatomyBrowser-DNVp6-_r.js → AnatomyBrowser-DwzsHn4z.js} +1 -1
  6. package/dist/dashboard/assets/{BugLog-DMJTuU9p.js → BugLog-CPl4Q-JG.js} +1 -1
  7. package/dist/dashboard/assets/{CerebrumViewer-BwcGr6pu.js → CerebrumViewer-_f6fBsNF.js} +1 -1
  8. package/dist/dashboard/assets/{CronStatus-Dwf_M4-S.js → CronStatus-1iBEPJK5.js} +1 -1
  9. package/dist/dashboard/assets/{DesignQC-DzHkh94I.js → DesignQC-D4UU7lca.js} +1 -1
  10. package/dist/dashboard/assets/{LinkGraph-kqQD--Yj.js → LinkGraph-BWtI8Gh3.js} +1 -1
  11. package/dist/dashboard/assets/{MemoryViewer-BCn9Fr-w.js → MemoryViewer-BYRVG34g.js} +1 -1
  12. package/dist/dashboard/assets/{NativeMemory-DPMZaP5E.js → NativeMemory-Dphtrqjt.js} +1 -1
  13. package/dist/dashboard/assets/{ProjectOverview-PWENJiC4.js → ProjectOverview-CTYohvjD.js} +1 -1
  14. package/dist/dashboard/assets/{ProjectsAggregate-Baj44DVk.js → ProjectsAggregate-CMxNs35H.js} +1 -1
  15. package/dist/dashboard/assets/{StatusBadge-Db61D51H.js → StatusBadge-CjrFCDOI.js} +1 -1
  16. package/dist/dashboard/assets/{TokenUsage-UefDh9w4.js → TokenUsage-C5wJpMl8.js} +1 -1
  17. package/dist/dashboard/assets/index-BNi8lsJE.css +2 -0
  18. package/dist/dashboard/assets/{index-e2PfOI0b.js → index-C74qneS9.js} +5 -5
  19. package/dist/dashboard/index.html +2 -2
  20. package/dist/hooks/post-bash.js +10 -5
  21. package/dist/hooks/post-read.js +24 -26
  22. package/dist/hooks/post-write.js +135 -118
  23. package/dist/hooks/pre-read.js +42 -36
  24. package/dist/hooks/pre-write.js +3 -3
  25. package/dist/hooks/precompact.js +2 -2
  26. package/dist/hooks/session-start.js +15 -6
  27. package/dist/hooks/shared.js +221 -14
  28. package/dist/hooks/stop.js +33 -7
  29. package/dist/src/cli/cron-cmd.js +2 -2
  30. package/dist/src/cli/cron-cmd.js.map +1 -1
  31. package/dist/src/cli/daemon-cmd.js +49 -21
  32. package/dist/src/cli/daemon-cmd.js.map +1 -1
  33. package/dist/src/cli/doctor-cmd.js +2 -1
  34. package/dist/src/cli/doctor-cmd.js.map +1 -1
  35. package/dist/src/cli/export-cmd.js +8 -0
  36. package/dist/src/cli/export-cmd.js.map +1 -1
  37. package/dist/src/cli/init.js +34 -8
  38. package/dist/src/cli/init.js.map +1 -1
  39. package/dist/src/cli/registry.js +68 -33
  40. package/dist/src/cli/registry.js.map +1 -1
  41. package/dist/src/cli/update.js +37 -6
  42. package/dist/src/cli/update.js.map +1 -1
  43. package/dist/src/daemon/cron-engine.js +70 -8
  44. package/dist/src/daemon/cron-engine.js.map +1 -1
  45. package/dist/src/daemon/file-watcher.js +4 -1
  46. package/dist/src/daemon/file-watcher.js.map +1 -1
  47. package/dist/src/daemon/llm-provider.js +9 -6
  48. package/dist/src/daemon/llm-provider.js.map +1 -1
  49. package/dist/src/daemon/wolf-daemon.js +69 -15
  50. package/dist/src/daemon/wolf-daemon.js.map +1 -1
  51. package/dist/src/designqc/designqc-capture.js +116 -6
  52. package/dist/src/designqc/designqc-capture.js.map +1 -1
  53. package/dist/src/designqc/designqc-engine.js +9 -3
  54. package/dist/src/designqc/designqc-engine.js.map +1 -1
  55. package/dist/src/hooks/post-bash.js +10 -5
  56. package/dist/src/hooks/post-bash.js.map +1 -1
  57. package/dist/src/hooks/post-read.js +24 -26
  58. package/dist/src/hooks/post-read.js.map +1 -1
  59. package/dist/src/hooks/post-write.js +135 -118
  60. package/dist/src/hooks/post-write.js.map +1 -1
  61. package/dist/src/hooks/pre-read.js +42 -36
  62. package/dist/src/hooks/pre-read.js.map +1 -1
  63. package/dist/src/hooks/pre-write.js +3 -3
  64. package/dist/src/hooks/pre-write.js.map +1 -1
  65. package/dist/src/hooks/precompact.js +2 -2
  66. package/dist/src/hooks/precompact.js.map +1 -1
  67. package/dist/src/hooks/session-start.js +15 -6
  68. package/dist/src/hooks/session-start.js.map +1 -1
  69. package/dist/src/hooks/shared.js +221 -14
  70. package/dist/src/hooks/shared.js.map +1 -1
  71. package/dist/src/hooks/stop.js +33 -7
  72. package/dist/src/hooks/stop.js.map +1 -1
  73. package/dist/src/scanner/anatomy-scanner.js +7 -3
  74. package/dist/src/scanner/anatomy-scanner.js.map +1 -1
  75. package/dist/src/utils/agent-hooks.js +50 -4
  76. package/dist/src/utils/agent-hooks.js.map +1 -1
  77. package/dist/src/utils/daemon-pid.js +56 -0
  78. package/dist/src/utils/daemon-pid.js.map +1 -0
  79. package/dist/src/utils/dashboard-auth.js +13 -1
  80. package/dist/src/utils/dashboard-auth.js.map +1 -1
  81. package/dist/src/utils/embeddings.js +9 -1
  82. package/dist/src/utils/embeddings.js.map +1 -1
  83. package/dist/src/utils/fs-safe.js +69 -11
  84. package/dist/src/utils/fs-safe.js.map +1 -1
  85. package/dist/src/utils/logger.js +21 -6
  86. package/dist/src/utils/logger.js.map +1 -1
  87. package/dist/src/utils/maintenance.js +100 -6
  88. package/dist/src/utils/maintenance.js.map +1 -1
  89. package/dist/src/utils/platform.js +39 -0
  90. package/dist/src/utils/platform.js.map +1 -1
  91. package/dist/src/utils/remote.js +2 -2
  92. package/dist/src/utils/remote.js.map +1 -1
  93. package/package.json +1 -1
  94. package/src/templates/config.json +34 -9
  95. package/dist/dashboard/assets/index-CVfvTpSA.css +0 -2
@@ -7,8 +7,8 @@
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-e2PfOI0b.js"></script>
11
- <link rel="stylesheet" crossorigin href="./assets/index-CVfvTpSA.css">
10
+ <script type="module" crossorigin src="./assets/index-C74qneS9.js"></script>
11
+ <link rel="stylesheet" crossorigin href="./assets/index-BNi8lsJE.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getWolfDir, ensureWolfDir, getCaptureConfig, redactSecrets, isFileWritingCommand, isNotableCommand, tailWithinBytes, timeShort, readStdin, withLock, readJSON, writeJSON, } from "./shared.js";
3
+ import { getWolfDir, ensureWolfDir, getCaptureConfig, redactSecrets, isFileWritingCommand, isNotableCommand, tailWithinBytes, timeShort, readStdin, tryWithLock, readJSON, writeJSON, sessionFileFor, } from "./shared.js";
4
4
  // PostToolUse:Bash — two jobs.
5
5
  //
6
6
  // 1. Count file-writing shell commands into the session tracker. post-write.ts only matches
@@ -66,7 +66,12 @@ async function main() {
66
66
  // COMMAND position of each shell segment, not the whole string — the old `\bopenwolf\b` skipped
67
67
  // every command that merely mentioned an openwolf PATH, which made the write counter blind for
68
68
  // exactly the sessions that work on an openwolf checkout.
69
- const invokesOpenwolf = cmd.split(/&&|\|\||[;|]/).some(segmentInvokesOpenwolf);
69
+ // Newlines separate commands exactly like `;` does, and they were missing from this split: a
70
+ // two-line Bash block whose second line ran `openwolf push` was tested as ONE segment starting
71
+ // with the first line's command, so the filter saw no openwolf invocation and captured the whole
72
+ // thing. Multi-line blocks are the normal shape of an agent's shell call, so this was the common
73
+ // case, not the edge one.
74
+ const invokesOpenwolf = cmd.split(/&&|\|\||[;|\n\r]/).some(segmentInvokesOpenwolf);
70
75
  if (invokesOpenwolf) {
71
76
  process.exit(0);
72
77
  return;
@@ -79,8 +84,8 @@ async function main() {
79
84
  // install exactly as blind as the bug it fixes. [bug-149]
80
85
  if (!failed && isFileWritingCommand(cmd)) {
81
86
  try {
82
- const sessionFile = path.join(wolfDir, "hooks", "_session.json");
83
- withLock(sessionFile, () => {
87
+ const sessionFile = sessionFileFor(path.join(wolfDir, "hooks"), input.session_id);
88
+ tryWithLock(sessionFile, () => {
84
89
  const session = readJSON(sessionFile, { files_written: [], edit_counts: {} });
85
90
  session.bash_writes = (session.bash_writes ?? 0) + 1;
86
91
  writeJSON(sessionFile, session);
@@ -103,7 +108,7 @@ async function main() {
103
108
  // concurrent Bash hooks don't clobber each other's read-modify-write.
104
109
  try {
105
110
  const logPath = path.join(wolfDir, "activity.log");
106
- withLock(logPath, () => {
111
+ tryWithLock(logPath, () => {
107
112
  let existing = "";
108
113
  try {
109
114
  existing = fs.readFileSync(logPath, "utf8");
@@ -1,10 +1,9 @@
1
1
  import * as path from "node:path";
2
- import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, estimateFileTokens, getTokenRatios, readStdin, normalizePath, loadIgnore, isSecretFile } from "./shared.js";
2
+ import { getWolfDir, ensureWolfDir, updateSession, readMarkdown, parseAnatomy, estimateFileTokens, getTokenRatios, readStdin, normalizePath, loadIgnore, isSecretFile, matchesAnatomyEntry, relativeToProject, sessionFileFor } from "./shared.js";
3
3
  async function main() {
4
4
  ensureWolfDir();
5
5
  const wolfDir = getWolfDir();
6
6
  const hooksDir = path.join(wolfDir, "hooks");
7
- const sessionFile = path.join(hooksDir, "_session.json");
8
7
  const raw = await readStdin();
9
8
  let input;
10
9
  try {
@@ -14,6 +13,7 @@ async function main() {
14
13
  process.exit(0);
15
14
  return;
16
15
  }
16
+ const sessionFile = sessionFileFor(hooksDir, input.session_id);
17
17
  const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
18
18
  const content = input.tool_output?.content ?? "";
19
19
  if (!filePath) {
@@ -23,11 +23,9 @@ async function main() {
23
23
  const normalizedFile = normalizePath(filePath);
24
24
  // Skip tracking for .wolf/ internal files — consistent with pre-read
25
25
  const projectDir = normalizePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
26
- // Separator required after the root see pre-read (sibling-prefix directories, e.g.
27
- // /root/orderflow2, must not count as inside /root/orderflow).
28
- const relToProject = normalizedFile.startsWith(projectDir + "/")
29
- ? normalizedFile.slice(projectDir.length + 1)
30
- : "";
26
+ // Shared with pre-read: separator-anchored, with a realpath second opinion so a project reached
27
+ // through a symlink is not silently dropped from tracking.
28
+ const relToProject = relativeToProject(filePath, projectDir);
31
29
  // Don't track reads of files outside the project root (upstream #56). relToProject is ""
32
30
  // both when the path is outside projectDir and when it equals the root itself — neither
33
31
  // is a trackable project file.
@@ -57,7 +55,7 @@ async function main() {
57
55
  for (const [sectionKey, entries] of sections) {
58
56
  for (const entry of entries) {
59
57
  const entryRelPath = normalizePath(path.join(sectionKey, entry.file));
60
- if (normalizedFile.endsWith(entryRelPath) || normalizedFile.endsWith("/" + entryRelPath)) {
58
+ if (matchesAnatomyEntry(normalizedFile, entryRelPath)) {
61
59
  tokens = entry.tokens;
62
60
  break;
63
61
  }
@@ -66,24 +64,24 @@ async function main() {
66
64
  break;
67
65
  }
68
66
  }
69
- const session = readJSON(sessionFile, { files_read: {} });
70
- if (session.files_read[normalizedFile]) {
71
- // Never let a re-read shrink the estimate to zero. A repeat read often arrives with an
72
- // empty tool_output.content, and if the file has no anatomy entry the fallback above
73
- // cannot recover a number — overwriting unconditionally would wipe the good first-read
74
- // estimate. That deflates inputTokens in stop.ts and, because savedFromRepeats multiplies
75
- // by (count - 1), silently zeroes the repeat-savings metric too.
76
- const prev = session.files_read[normalizedFile].tokens ?? 0;
77
- session.files_read[normalizedFile].tokens = Math.max(prev, tokens);
78
- }
79
- else {
80
- session.files_read[normalizedFile] = {
81
- count: 1,
82
- tokens,
83
- first_read: new Date().toISOString(),
84
- };
85
- }
86
- writeJSON(sessionFile, session);
67
+ updateSession(sessionFile, { files_read: {} }, (session) => {
68
+ if (session.files_read[normalizedFile]) {
69
+ // Never let a re-read shrink the estimate to zero. A repeat read often arrives with an
70
+ // empty tool_output.content, and if the file has no anatomy entry the fallback above
71
+ // cannot recover a number — overwriting unconditionally would wipe the good first-read
72
+ // estimate. That deflates inputTokens in stop.ts and, because savedFromRepeats multiplies
73
+ // by (count - 1), silently zeroes the repeat-savings metric too.
74
+ const prev = session.files_read[normalizedFile].tokens ?? 0;
75
+ session.files_read[normalizedFile].tokens = Math.max(prev, tokens);
76
+ }
77
+ else {
78
+ session.files_read[normalizedFile] = {
79
+ count: 1,
80
+ tokens,
81
+ first_read: new Date().toISOString(),
82
+ };
83
+ }
84
+ });
87
85
  process.exit(0);
88
86
  }
89
87
  main().catch(() => process.exit(0));
@@ -1,11 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getWolfDir, ensureWolfDir, readJSON, writeJSON, writeAtomic, parseAnatomy, serializeAnatomy, extractDescription, estimateFileTokens, getTokenRatios, appendMarkdown, timeShort, readStdin, normalizePath, getRetention, loadIgnore, readBugLog, isSecretFile } 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 } from "./shared.js";
4
4
  async function main() {
5
5
  ensureWolfDir();
6
6
  const wolfDir = getWolfDir();
7
7
  const hooksDir = path.join(wolfDir, "hooks");
8
- const sessionFile = path.join(hooksDir, "_session.json");
9
8
  const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
10
9
  const raw = await readStdin();
11
10
  let input;
@@ -16,6 +15,7 @@ async function main() {
16
15
  process.exit(0);
17
16
  return;
18
17
  }
18
+ const sessionFile = sessionFileFor(hooksDir, input.session_id);
19
19
  const toolName = input.tool_name ?? "Write";
20
20
  const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
21
21
  if (!filePath) {
@@ -37,10 +37,10 @@ async function main() {
37
37
  // nothing happened, and the STATUS.md reminder never fires. That is exactly how a handoff doc goes
38
38
  // stale for eleven slices without anyone noticing. The counter is a number only: no path, no name,
39
39
  // so #56 still holds.
40
- if (relPath === "" || relPath.startsWith("..")) {
41
- const session = readJSON(sessionFile, { files_written: [], edit_counts: {} });
42
- session.external_writes = (session.external_writes ?? 0) + 1;
43
- writeJSON(sessionFile, session);
40
+ if (isOutsideProject(relPath)) {
41
+ updateSession(sessionFile, { files_written: [], edit_counts: {} }, (session) => {
42
+ session.external_writes = (session.external_writes ?? 0) + 1;
43
+ });
44
44
  process.exit(0);
45
45
  return;
46
46
  }
@@ -65,57 +65,65 @@ async function main() {
65
65
  ? multiEdits.map((e) => e?.new_string ?? "").join("\n")
66
66
  : newStr;
67
67
  // 1. Update anatomy.md
68
- try {
69
- const anatomyPath = path.join(wolfDir, "anatomy.md");
70
- let anatomyContent;
71
- try {
72
- anatomyContent = fs.readFileSync(anatomyPath, "utf-8");
73
- }
74
- catch {
75
- anatomyContent = "# anatomy.md\n\n> Auto-maintained by OpenWolf.\n";
76
- }
77
- const sections = parseAnatomy(anatomyContent);
78
- const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath));
79
- const dir = path.dirname(relPathLocal);
80
- const fileName = path.basename(relPathLocal);
81
- const sectionKey = dir === "." ? "./" : dir + "/";
82
- let fileContent = "";
68
+ //
69
+ // Under the lock for its whole read-modify-write cycle. writeAtomic() prevents a TORN file, not a
70
+ // LOST update: two post-write hooks that each parse the index, add their own entry and serialise
71
+ // it both produce valid markdown, and the second one silently drops the first one's file. On a
72
+ // multi-edit turn that is the normal case, and the missing entry only shows up much later as an
73
+ // anatomy miss for a file that was indexed.
74
+ tryWithLock(path.join(wolfDir, "anatomy.md"), () => {
83
75
  try {
84
- fileContent = fs.readFileSync(absolutePath, "utf-8");
85
- }
86
- catch {
87
- fileContent = input.tool_input?.content ?? "";
88
- }
89
- const desc = extractDescription(absolutePath, fileContent).slice(0, 100);
90
- const tokens = estimateFileTokens(fileContent, absolutePath, getTokenRatios(wolfDir));
91
- if (!sections.has(sectionKey))
92
- sections.set(sectionKey, []);
93
- const entries = sections.get(sectionKey);
94
- const idx = entries.findIndex((e) => e.file === fileName);
95
- const existing = idx !== -1 ? entries[idx] : undefined;
96
- // Skip the full anatomy.md rewrite when nothing changed for this file.
97
- // Repeated edits to the same file (same description + token estimate) are the common
98
- // case; rewriting the entire anatomy.md on every one of them is pure overhead.
99
- if (!existing || existing.description !== desc || existing.tokens !== tokens) {
100
- if (idx !== -1) {
101
- entries[idx] = { file: fileName, description: desc, tokens };
76
+ const anatomyPath = path.join(wolfDir, "anatomy.md");
77
+ let anatomyContent;
78
+ try {
79
+ anatomyContent = fs.readFileSync(anatomyPath, "utf-8");
102
80
  }
103
- else {
104
- entries.push({ file: fileName, description: desc, tokens });
81
+ catch {
82
+ anatomyContent = "# anatomy.md\n\n> Auto-maintained by OpenWolf.\n";
83
+ }
84
+ const sections = parseAnatomy(anatomyContent);
85
+ const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath));
86
+ const dir = path.dirname(relPathLocal);
87
+ const fileName = path.basename(relPathLocal);
88
+ const sectionKey = dir === "." ? "./" : dir + "/";
89
+ let fileContent = "";
90
+ try {
91
+ fileContent = fs.readFileSync(absolutePath, "utf-8");
92
+ }
93
+ catch {
94
+ fileContent = input.tool_input?.content ?? "";
95
+ }
96
+ const desc = extractDescription(absolutePath, fileContent).slice(0, 100);
97
+ const tokens = estimateFileTokens(fileContent, absolutePath, getTokenRatios(wolfDir));
98
+ if (!sections.has(sectionKey))
99
+ sections.set(sectionKey, []);
100
+ const entries = sections.get(sectionKey);
101
+ const idx = entries.findIndex((e) => e.file === fileName);
102
+ const existing = idx !== -1 ? entries[idx] : undefined;
103
+ // Skip the full anatomy.md rewrite when nothing changed for this file.
104
+ // Repeated edits to the same file (same description + token estimate) are the common
105
+ // case; rewriting the entire anatomy.md on every one of them is pure overhead.
106
+ if (!existing || existing.description !== desc || existing.tokens !== tokens) {
107
+ if (idx !== -1) {
108
+ entries[idx] = { file: fileName, description: desc, tokens };
109
+ }
110
+ else {
111
+ entries.push({ file: fileName, description: desc, tokens });
112
+ }
113
+ let fileCount = 0;
114
+ for (const [, list] of sections)
115
+ fileCount += list.length;
116
+ const serialized = serializeAnatomy(sections, {
117
+ lastScanned: new Date().toISOString(),
118
+ fileCount,
119
+ hits: 0,
120
+ misses: 0,
121
+ });
122
+ writeAtomic(anatomyPath, () => serialized);
105
123
  }
106
- let fileCount = 0;
107
- for (const [, list] of sections)
108
- fileCount += list.length;
109
- const serialized = serializeAnatomy(sections, {
110
- lastScanned: new Date().toISOString(),
111
- fileCount,
112
- hits: 0,
113
- misses: 0,
114
- });
115
- writeAtomic(anatomyPath, () => serialized);
116
124
  }
117
- }
118
- catch { }
125
+ catch { }
126
+ });
119
127
  // 2. Append richer entry to memory.md
120
128
  try {
121
129
  const action = toolName === "Write" ? "Created" : toolName === "MultiEdit" ? "Multi-edited" : "Edited";
@@ -133,24 +141,28 @@ async function main() {
133
141
  catch { }
134
142
  // 3. Record in session tracker + track edit counts
135
143
  try {
136
- const session = readJSON(sessionFile, { files_written: [], edit_counts: {} });
137
- if (!session.edit_counts)
138
- session.edit_counts = {};
139
144
  const normalizedFile = normalizePath(filePath);
140
145
  const action = toolName === "Write" ? "create" : "edit";
141
146
  const fileContent = input.tool_input?.content ?? "";
142
147
  const tokens = estimateFileTokens(fileContent || writtenText, absolutePath, getTokenRatios(wolfDir));
143
- session.files_written.push({
144
- file: normalizedFile,
145
- action,
146
- tokens,
147
- at: new Date().toISOString(),
148
- });
149
148
  const editKey = normalizePath(path.relative(projectRoot, absolutePath));
150
- session.edit_counts[editKey] = (session.edit_counts[editKey] || 0) + 1;
151
- writeJSON(sessionFile, session);
152
- if (session.edit_counts[editKey] >= 3) {
153
- process.stderr.write(`⚠️ OpenWolf: ${baseName} has been edited ${session.edit_counts[editKey]} times this session. If you're fixing a bug, remember to log it to .wolf/buglog.json.\n`);
149
+ let edits = 0;
150
+ updateSession(sessionFile, { files_written: [], edit_counts: {} }, (session) => {
151
+ if (!session.files_written)
152
+ session.files_written = [];
153
+ if (!session.edit_counts)
154
+ session.edit_counts = {};
155
+ session.files_written.push({
156
+ file: normalizedFile,
157
+ action,
158
+ tokens,
159
+ at: new Date().toISOString(),
160
+ });
161
+ session.edit_counts[editKey] = (session.edit_counts[editKey] || 0) + 1;
162
+ edits = session.edit_counts[editKey];
163
+ });
164
+ if (edits >= 3) {
165
+ process.stderr.write(`⚠️ OpenWolf: ${baseName} has been edited ${edits} times this session. If you're fixing a bug, remember to log it to .wolf/buglog.json.\n`);
154
166
  }
155
167
  }
156
168
  catch { }
@@ -254,61 +266,66 @@ function extractCalls(code) {
254
266
  // ─── Auto Bug Detection ──────────────────────────────────────────
255
267
  function autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr) {
256
268
  const bugLogPath = path.join(wolfDir, "buglog.json");
257
- const bugLog = readBugLog(wolfDir); // tolerates legacy array format
258
- const relFile = normalizePath(path.relative(projectRoot, absolutePath));
259
- const basename = path.basename(absolutePath);
260
- const ext = path.extname(basename).toLowerCase();
261
- // Detect what kind of fix this is
262
- const detection = detectFixPattern(oldStr, newStr, ext, path.basename(relFile));
263
- if (!detection)
264
- return;
265
- // Check for recent duplicate (same file + same category within 30 min).
266
- // Widened from 5 min to cut down on repeated auto-detected entries for the same file.
267
- const recentDupe = bugLog.bugs.find(b => {
268
- if (path.basename(b.file) !== basename)
269
- return false;
270
- if (!b.tags.includes("auto-detected"))
271
- return false;
272
- if (!b.tags.includes(detection.category))
273
- return false;
274
- const bugTime = new Date(b.last_seen).getTime();
275
- return (Date.now() - bugTime) < 30 * 60 * 1000;
276
- });
277
- if (recentDupe) {
278
- recentDupe.occurrences++;
279
- recentDupe.last_seen = new Date().toISOString();
280
- // Append additional context
281
- if (detection.context && !recentDupe.fix.includes(detection.context)) {
282
- recentDupe.fix += ` | Also: ${detection.context}`;
269
+ // The read has to happen INSIDE the lock together with the write. Reading first and writing later
270
+ // is the lost-update shape: two edits in the same turn both computed `bug-041` from the same
271
+ // snapshot, and the second write erased the first entry while reusing its id.
272
+ tryWithLock(bugLogPath, () => {
273
+ const bugLog = readBugLog(wolfDir); // tolerates legacy array format
274
+ const relFile = normalizePath(path.relative(projectRoot, absolutePath));
275
+ const basename = path.basename(absolutePath);
276
+ const ext = path.extname(basename).toLowerCase();
277
+ // Detect what kind of fix this is
278
+ const detection = detectFixPattern(oldStr, newStr, ext, path.basename(relFile));
279
+ if (!detection)
280
+ return; // returns from the locked callback, not from autoDetectBugFix
281
+ // Check for recent duplicate (same file + same category within 30 min).
282
+ // Widened from 5 min to cut down on repeated auto-detected entries for the same file.
283
+ const recentDupe = bugLog.bugs.find(b => {
284
+ if (path.basename(b.file) !== basename)
285
+ return false;
286
+ if (!b.tags.includes("auto-detected"))
287
+ return false;
288
+ if (!b.tags.includes(detection.category))
289
+ return false;
290
+ const bugTime = new Date(b.last_seen).getTime();
291
+ return (Date.now() - bugTime) < 30 * 60 * 1000;
292
+ });
293
+ if (recentDupe) {
294
+ recentDupe.occurrences++;
295
+ recentDupe.last_seen = new Date().toISOString();
296
+ // Append additional context
297
+ if (detection.context && !recentDupe.fix.includes(detection.context)) {
298
+ recentDupe.fix += ` | Also: ${detection.context}`;
299
+ }
300
+ writeJSON(bugLogPath, bugLog);
301
+ return;
302
+ }
303
+ // Derive id from the max existing numeric id so it stays unique even after the
304
+ // bugs[] array is trimmed below (bugs.length + 1 would collide after a trim).
305
+ const maxId = bugLog.bugs.reduce((m, b) => {
306
+ const n = parseInt(String(b.id).replace(/\D/g, ""), 10);
307
+ return Number.isFinite(n) && n > m ? n : m;
308
+ }, 0);
309
+ const nextId = `bug-${String(maxId + 1).padStart(3, "0")}`;
310
+ bugLog.bugs.push({
311
+ id: nextId,
312
+ timestamp: new Date().toISOString(),
313
+ error_message: detection.summary,
314
+ file: relFile,
315
+ root_cause: detection.rootCause,
316
+ fix: detection.fix,
317
+ tags: ["auto-detected", detection.category, ext.replace(".", "") || "unknown"],
318
+ related_bugs: [],
319
+ occurrences: 1,
320
+ last_seen: new Date().toISOString(),
321
+ });
322
+ // Keep buglog.json bounded — auto-detection can otherwise append on nearly every edit.
323
+ const maxBugs = getRetention(wolfDir).buglog_max_entries;
324
+ if (bugLog.bugs.length > maxBugs) {
325
+ bugLog.bugs = bugLog.bugs.slice(-maxBugs);
283
326
  }
284
327
  writeJSON(bugLogPath, bugLog);
285
- return;
286
- }
287
- // Derive id from the max existing numeric id so it stays unique even after the
288
- // bugs[] array is trimmed below (bugs.length + 1 would collide after a trim).
289
- const maxId = bugLog.bugs.reduce((m, b) => {
290
- const n = parseInt(String(b.id).replace(/\D/g, ""), 10);
291
- return Number.isFinite(n) && n > m ? n : m;
292
- }, 0);
293
- const nextId = `bug-${String(maxId + 1).padStart(3, "0")}`;
294
- bugLog.bugs.push({
295
- id: nextId,
296
- timestamp: new Date().toISOString(),
297
- error_message: detection.summary,
298
- file: relFile,
299
- root_cause: detection.rootCause,
300
- fix: detection.fix,
301
- tags: ["auto-detected", detection.category, ext.replace(".", "") || "unknown"],
302
- related_bugs: [],
303
- occurrences: 1,
304
- last_seen: new Date().toISOString(),
305
328
  });
306
- // Keep buglog.json bounded — auto-detection can otherwise append on nearly every edit.
307
- const maxBugs = getRetention(wolfDir).buglog_max_entries;
308
- if (bugLog.bugs.length > maxBugs) {
309
- bugLog.bugs = bugLog.bugs.slice(-maxBugs);
310
- }
311
- writeJSON(bugLogPath, bugLog);
312
329
  }
313
330
  function detectFixPattern(oldStr, newStr, ext, fileLabel) {
314
331
  const oldLines = oldStr.split("\n");
@@ -1,11 +1,13 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, readStdin, normalizePath, loadIgnore, isSecretFile } from "./shared.js";
3
+ import { getWolfDir, ensureWolfDir, readJSON, updateSession, readMarkdown, parseAnatomy, readStdin, normalizePath, loadIgnore, isSecretFile, matchesAnatomyEntry, relativeToProject, sessionFileFor } from "./shared.js";
4
+ const SESSION_FALLBACK = {
5
+ session_id: "", files_read: {}, anatomy_hits: 0, anatomy_misses: 0, repeated_reads_warned: 0,
6
+ };
4
7
  async function main() {
5
8
  ensureWolfDir();
6
9
  const wolfDir = getWolfDir();
7
10
  const hooksDir = path.join(wolfDir, "hooks");
8
- const sessionFile = path.join(hooksDir, "_session.json");
9
11
  const raw = await readStdin();
10
12
  let input;
11
13
  try {
@@ -15,6 +17,8 @@ async function main() {
15
17
  process.exit(0);
16
18
  return;
17
19
  }
20
+ // After the parse, not before: the payload is what says which session this is.
21
+ const sessionFile = sessionFileFor(hooksDir, input.session_id);
18
22
  const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
19
23
  if (!filePath) {
20
24
  process.exit(0);
@@ -30,11 +34,7 @@ async function main() {
30
34
  // Skip tracking for .wolf/ internal files — they're infrastructure, not project files.
31
35
  // Counting them inflates anatomy miss rates since .wolf/ is excluded from anatomy scanning.
32
36
  const projectDir = normalizePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
33
- // Require the path SEPARATOR after the root — a bare startsWith(projectDir) also matched
34
- // sibling directories sharing the prefix (/root/orderflow2 counted as inside /root/orderflow).
35
- const relToProject = normalizedFile.startsWith(projectDir + "/")
36
- ? normalizedFile.slice(projectDir.length + 1)
37
- : "";
37
+ const relToProject = relativeToProject(filePath, projectDir);
38
38
  // Don't track files outside the project root (upstream #56).
39
39
  if (!relToProject) {
40
40
  process.exit(0);
@@ -56,27 +56,34 @@ async function main() {
56
56
  process.exit(0);
57
57
  return;
58
58
  }
59
- const session = readJSON(sessionFile, {
60
- session_id: "", files_read: {}, anatomy_hits: 0, anatomy_misses: 0,
61
- repeated_reads_warned: 0,
62
- });
59
+ // Snapshot read, used only to DECIDE what to print. Every mutation below re-reads under the lock
60
+ // and applies a delta, so a concurrent hook's update is never overwritten with this snapshot.
61
+ const session = readJSON(sessionFile, SESSION_FALLBACK);
63
62
  // Check if already read this session
64
63
  if (session.files_read[normalizedFile]) {
65
64
  const prev = session.files_read[normalizedFile];
66
65
  // If the file changed since we last read it (e.g. it was edited during the session),
67
66
  // a re-read is legitimate — refresh state and don't warn about a repeated read (upstream #41).
68
67
  if (fileMtime !== undefined && prev.mtime !== undefined && fileMtime !== prev.mtime) {
69
- prev.count++;
70
- prev.mtime = fileMtime;
71
- writeJSON(sessionFile, session);
68
+ updateSession(sessionFile, SESSION_FALLBACK, (s) => {
69
+ const e = s.files_read[normalizedFile];
70
+ if (e) {
71
+ e.count++;
72
+ e.mtime = fileMtime;
73
+ }
74
+ });
72
75
  process.exit(0);
73
76
  return;
74
77
  }
75
78
  process.stderr.write(`⚡ OpenWolf: ${path.basename(normalizedFile)} was already read this session (~${prev.tokens} tokens). Consider using your existing knowledge of this file.\n`);
76
- prev.count++;
77
- prev.mtime = fileMtime ?? prev.mtime;
78
- session.repeated_reads_warned++;
79
- writeJSON(sessionFile, session);
79
+ updateSession(sessionFile, SESSION_FALLBACK, (s) => {
80
+ const e = s.files_read[normalizedFile];
81
+ if (e) {
82
+ e.count++;
83
+ e.mtime = fileMtime ?? e.mtime;
84
+ }
85
+ s.repeated_reads_warned++;
86
+ });
80
87
  process.exit(0);
81
88
  return;
82
89
  }
@@ -90,7 +97,7 @@ async function main() {
90
97
  for (const entry of entries) {
91
98
  // Build the full relative path from the section key + filename for accurate matching
92
99
  const entryRelPath = normalizePath(path.join(sectionKey, entry.file));
93
- if (normalizedFile.endsWith(entryRelPath) || normalizedFile.endsWith("/" + entryRelPath)) {
100
+ if (matchesAnatomyEntry(normalizedFile, entryRelPath)) {
94
101
  process.stderr.write(`📋 OpenWolf anatomy: ${entry.file} — ${entry.description} (~${entry.tokens} tok)\n`);
95
102
  // If we indexed this big file's symbols, offer line ranges so the agent can read one
96
103
  // function with offset/limit instead of the whole file.
@@ -107,23 +114,22 @@ async function main() {
107
114
  if (found)
108
115
  break;
109
116
  }
110
- if (found) {
111
- session.anatomy_hits++;
112
- }
113
- else {
114
- session.anatomy_misses++;
115
- }
116
- // Record initial read entry (tokens will be updated in post-read)
117
- session.files_read[normalizedFile] = {
118
- count: 1,
119
- tokens: 0,
120
- first_read: new Date().toISOString(),
121
- // Whether anatomy.md already described this file. The ledger used to hardcode this as
122
- // false, which made the waste detector's "anatomy would have sufficed" pattern unreachable.
123
- anatomy_had_description: found,
124
- mtime: fileMtime,
125
- };
126
- writeJSON(sessionFile, session);
117
+ updateSession(sessionFile, SESSION_FALLBACK, (s) => {
118
+ if (found)
119
+ s.anatomy_hits++;
120
+ else
121
+ s.anatomy_misses++;
122
+ // Record initial read entry (tokens will be updated in post-read)
123
+ s.files_read[normalizedFile] = {
124
+ count: 1,
125
+ tokens: 0,
126
+ first_read: new Date().toISOString(),
127
+ // Whether anatomy.md already described this file. The ledger used to hardcode this as
128
+ // false, which made the waste detector's "anatomy would have sufficed" pattern unreachable.
129
+ anatomy_had_description: found,
130
+ mtime: fileMtime,
131
+ };
132
+ });
127
133
  process.exit(0);
128
134
  }
129
135
  main().catch(() => process.exit(0));
@@ -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, withLock } from "./shared.js";
3
+ import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, readStdin, readBugLog, tryWithLock, sessionFileFor } from "./shared.js";
4
4
  async function main() {
5
5
  ensureWolfDir();
6
6
  const wolfDir = getWolfDir();
@@ -29,8 +29,8 @@ async function main() {
29
29
  // ever incremented it, so the ledger/dashboard always showed 0.
30
30
  if (cerebrumWarnings > 0) {
31
31
  try {
32
- const sessionFile = path.join(wolfDir, "hooks", "_session.json");
33
- withLock(sessionFile, () => {
32
+ const sessionFile = sessionFileFor(path.join(wolfDir, "hooks"), input.session_id);
33
+ tryWithLock(sessionFile, () => {
34
34
  const session = readJSON(sessionFile, {});
35
35
  session.cerebrum_warnings = (session.cerebrum_warnings ?? 0) + cerebrumWarnings;
36
36
  writeJSON(sessionFile, session);
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readStdin, timestamp } from "./shared.js";
2
+ import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readStdin, timestamp, sessionFileFor } from "./shared.js";
3
3
  // PreCompact hook — compaction survival.
4
4
  //
5
5
  // Fires just before Claude Code / Codex compacts the context window. The in-flight session state
@@ -17,7 +17,7 @@ async function main() {
17
17
  }
18
18
  catch { }
19
19
  try {
20
- const session = readJSON(path.join(hooksDir, "_session.json"), {});
20
+ const session = readJSON(sessionFileFor(hooksDir, input.session_id), {});
21
21
  writeJSON(path.join(hooksDir, "_precompact-snapshot.json"), {
22
22
  at: timestamp(),
23
23
  trigger: input.trigger ?? "unknown",