Title
parseAnatomy/serializeAnatomy silently drop legitimate entries sized in anything other than tok (e.g. large model/asset directories) — not just on CRLF (related to #50/#24/#51)
Summary
post-write.js rewrites the entire .wolf/anatomy.md on every Write/Edit/MultiEdit call, by round-tripping the file through parseAnatomy() → (add/update one entry) → serializeAnatomy(). parseAnatomy() only recognizes lines matching:
/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/
The unit is hardcoded to tok. That's fine for the files anatomy.md's stated purpose covers (deciding whether to Read a text file into context, priced in tokens) — but a project's anatomy legitimately includes large, non-text directories (downloaded model checkpoints, datasets, media) that are never going to be Read, where the only meaningful "size" to note is disk size, e.g. (~16GB). Any bullet like that is a real, correctly-placed entry — it's just annotated in GB instead of tok — and it's silently parsed into nothing. Since serializeAnatomy() rebuilds the file purely from the parsed sections map, that entry is gone the moment any file anywhere in the project is written next, with no warning.
This is the same root defect family as #50/#24 (parser too strict → entries vanish on re-serialize), but it reproduces on plain LF line endings on Linux/macOS too — it's not CRLF-specific, and unlike the CRLF case it doesn't wipe an entire section, just the specific entries that don't fit the schema, which makes it much easier to miss over time.
(Separately, parseAnatomy also drops any free-form prose written directly under a ## section header rather than as a bullet at all. That case is arguably a misuse of anatomy.md's intended per-file schema, so it's not the core claim here — but it's worth fixing defensively at the same time, since a maintenance script silently destroying whatever content it doesn't recognize is a bad failure mode regardless of whether that content "should" have been there.)
Reproduction
Given this .wolf/anatomy.md (LF line endings, no CRLF involved):
# anatomy.md
> Auto-maintained by OpenWolf. Last scanned: 2026-01-01T00:00:00.000Z
> Files: 2 tracked | Anatomy hits: 0 | Misses: 0
## src/utils/
- `date-fns-wrapper.js` — thin wrapper around date-fns for consistent formatting (~120 tok)
## assets/models/ (downloaded checkpoints, not git-tracked)
- `base-model.safetensors` (~16GB) — pretrained backbone, downloaded from the model's HF repo
- `fine-tuned.safetensors` (~4GB) — our own fine-tune, NOT reproducible from upstream
- Have Claude Code (or anything driving the
PostToolUse: Write|Edit|MultiEdit hook) write/edit any file in the project — it doesn't need to be in src/utils/ or assets/models/, any unrelated file triggers a full rewrite.
- Re-open
.wolf/anatomy.md.
Expected: both assets/models/ bullets are preserved — they're real, correctly-formatted entries for real files, just sized in GB.
Actual: both are gone. ## assets/models/ is left with an empty body, because (~16GB) and (~4GB) don't match (~\d+\s+tok\)$ — the unit isn't tok. Note the second entry specifically flagged itself as irreplaceable (not reproducible from upstream) — exactly the kind of note you don't want silently erased.
Impact
- Silent — no warning, no error, no log line. You only notice by diffing or happening to remember content that used to be there.
- Hits a common, legitimate case head-on: any project that tracks large downloaded/generated assets (model checkpoints, datasets, media) alongside its source will naturally want to note them in GB/MB, not tokens — since these files are never going to be
Read into context in the first place. The schema has no way to represent that without being deleted on the next unrelated write.
- Compounds over a project's lifetime: every unrelated file write is another chance to lose a bit more, with no cumulative signal that it's happening.
Suggested fix
Preserve non-conforming lines verbatim instead of dropping them, and re-emit them on serialize. Patch below (tested: node --check on all four hook files, plus a round-trip test confirming prose and non-tok bullets survive repeated hook passes, and that adding a new real entry via post-write.js's logic still merges correctly alongside preserved raw lines).
hooks/shared.js:
export function parseAnatomy(content) {
const sections = new Map();
let currentSection = "";
for (const line of content.split("\n")) {
const sm = line.match(/^## (.+)/);
if (sm) {
currentSection = sm[1].trim();
if (!sections.has(currentSection))
sections.set(currentSection, []);
continue;
}
if (!currentSection)
continue;
const em = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/);
if (em) {
sections.get(currentSection).push({
file: em[1],
description: em[2] || "",
tokens: parseInt(em[3], 10),
});
}
+ else if (line.trim() !== "") {
+ // Preserve any non-conforming line (free-form prose, non-token-unit
+ // bullets like "(~350MB)", etc.) verbatim instead of silently
+ // discarding it on the next serialize pass.
+ sections.get(currentSection).push({ raw: line });
+ }
}
return sections;
}
export function serializeAnatomy(sections, metadata) {
const lines = [
"# anatomy.md",
"",
`> Auto-maintained by OpenWolf. Last scanned: ${metadata.lastScanned}`,
`> Files: ${metadata.fileCount} tracked | Anatomy hits: ${metadata.hits} | Misses: ${metadata.misses}`,
"",
];
const keys = [...sections.keys()].sort();
for (const key of keys) {
lines.push(`## ${key}`);
lines.push("");
- const entries = sections.get(key).sort((a, b) => a.file.localeCompare(b.file));
+ const all = sections.get(key);
+ const rawLines = all.filter((e) => e.raw !== undefined);
+ const entries = all.filter((e) => e.raw === undefined).sort((a, b) => a.file.localeCompare(b.file));
+ for (const r of rawLines) {
+ lines.push(r.raw);
+ }
+ if (rawLines.length && entries.length) {
+ lines.push("");
+ }
for (const e of entries) {
const desc = e.description ? ` — ${e.description}` : "";
lines.push(`- \`${e.file}\`${desc} (~${e.tokens} tok)`);
}
lines.push("");
}
return lines.join("\n");
}
hooks/post-write.js (the "Files: N tracked" counter should only count real entries, not preserved raw lines):
let fileCount = 0;
for (const [, list] of sections)
- fileCount += list.length;
+ fileCount += list.filter((e) => e.file !== undefined).length;
hooks/pre-read.js (guard the lookup loop against entries with no .file, now that a section's array can contain raw pass-through lines):
for (const [sectionKey, entries] of sections) {
for (const entry of entries) {
+ if (entry.file === undefined)
+ continue;
// Build the full relative path from the section key + filename for accurate matching
const entryRelPath = normalizePath(path.join(sectionKey, entry.file));
hooks/post-read.js (same guard in its fallback lookup):
for (const [sectionKey, entries] of sections) {
for (const entry of entries) {
+ if (entry.file === undefined)
+ continue;
const entryRelPath = normalizePath(path.join(sectionKey, entry.file));
The patch above is already applied and verified locally (tested with node --check on all four hook files, plus a round-trip test confirming non-tok-unit bullets and prose survive repeated hook passes).
Title
parseAnatomy/serializeAnatomysilently drop legitimate entries sized in anything other thantok(e.g. large model/asset directories) — not just on CRLF (related to #50/#24/#51)Summary
post-write.jsrewrites the entire.wolf/anatomy.mdon everyWrite/Edit/MultiEditcall, by round-tripping the file throughparseAnatomy()→ (add/update one entry) →serializeAnatomy().parseAnatomy()only recognizes lines matching:/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/The unit is hardcoded to
tok. That's fine for the files anatomy.md's stated purpose covers (deciding whether toReada text file into context, priced in tokens) — but a project's anatomy legitimately includes large, non-text directories (downloaded model checkpoints, datasets, media) that are never going to beRead, where the only meaningful "size" to note is disk size, e.g.(~16GB). Any bullet like that is a real, correctly-placed entry — it's just annotated in GB instead of tok — and it's silently parsed into nothing. SinceserializeAnatomy()rebuilds the file purely from the parsedsectionsmap, that entry is gone the moment any file anywhere in the project is written next, with no warning.This is the same root defect family as #50/#24 (parser too strict → entries vanish on re-serialize), but it reproduces on plain LF line endings on Linux/macOS too — it's not CRLF-specific, and unlike the CRLF case it doesn't wipe an entire section, just the specific entries that don't fit the schema, which makes it much easier to miss over time.
(Separately,
parseAnatomyalso drops any free-form prose written directly under a## sectionheader rather than as a bullet at all. That case is arguably a misuse of anatomy.md's intended per-file schema, so it's not the core claim here — but it's worth fixing defensively at the same time, since a maintenance script silently destroying whatever content it doesn't recognize is a bad failure mode regardless of whether that content "should" have been there.)Reproduction
Given this
.wolf/anatomy.md(LF line endings, no CRLF involved):PostToolUse: Write|Edit|MultiEdithook) write/edit any file in the project — it doesn't need to be insrc/utils/orassets/models/, any unrelated file triggers a full rewrite..wolf/anatomy.md.Expected: both
assets/models/bullets are preserved — they're real, correctly-formatted entries for real files, just sized in GB.Actual: both are gone.
## assets/models/is left with an empty body, because(~16GB)and(~4GB)don't match(~\d+\s+tok\)$— the unit isn'ttok. Note the second entry specifically flagged itself as irreplaceable (not reproducible from upstream) — exactly the kind of note you don't want silently erased.Impact
Readinto context in the first place. The schema has no way to represent that without being deleted on the next unrelated write.Suggested fix
Preserve non-conforming lines verbatim instead of dropping them, and re-emit them on serialize. Patch below (tested:
node --checkon all four hook files, plus a round-trip test confirming prose and non-tokbullets survive repeated hook passes, and that adding a new real entry viapost-write.js's logic still merges correctly alongside preserved raw lines).hooks/shared.js:export function parseAnatomy(content) { const sections = new Map(); let currentSection = ""; for (const line of content.split("\n")) { const sm = line.match(/^## (.+)/); if (sm) { currentSection = sm[1].trim(); if (!sections.has(currentSection)) sections.set(currentSection, []); continue; } if (!currentSection) continue; const em = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/); if (em) { sections.get(currentSection).push({ file: em[1], description: em[2] || "", tokens: parseInt(em[3], 10), }); } + else if (line.trim() !== "") { + // Preserve any non-conforming line (free-form prose, non-token-unit + // bullets like "(~350MB)", etc.) verbatim instead of silently + // discarding it on the next serialize pass. + sections.get(currentSection).push({ raw: line }); + } } return sections; } export function serializeAnatomy(sections, metadata) { const lines = [ "# anatomy.md", "", `> Auto-maintained by OpenWolf. Last scanned: ${metadata.lastScanned}`, `> Files: ${metadata.fileCount} tracked | Anatomy hits: ${metadata.hits} | Misses: ${metadata.misses}`, "", ]; const keys = [...sections.keys()].sort(); for (const key of keys) { lines.push(`## ${key}`); lines.push(""); - const entries = sections.get(key).sort((a, b) => a.file.localeCompare(b.file)); + const all = sections.get(key); + const rawLines = all.filter((e) => e.raw !== undefined); + const entries = all.filter((e) => e.raw === undefined).sort((a, b) => a.file.localeCompare(b.file)); + for (const r of rawLines) { + lines.push(r.raw); + } + if (rawLines.length && entries.length) { + lines.push(""); + } for (const e of entries) { const desc = e.description ? ` — ${e.description}` : ""; lines.push(`- \`${e.file}\`${desc} (~${e.tokens} tok)`); } lines.push(""); } return lines.join("\n"); }hooks/post-write.js(the "Files: N tracked" counter should only count real entries, not preserved raw lines):let fileCount = 0; for (const [, list] of sections) - fileCount += list.length; + fileCount += list.filter((e) => e.file !== undefined).length;hooks/pre-read.js(guard the lookup loop against entries with no.file, now that a section's array can contain raw pass-through lines):for (const [sectionKey, entries] of sections) { for (const entry of entries) { + if (entry.file === undefined) + continue; // Build the full relative path from the section key + filename for accurate matching const entryRelPath = normalizePath(path.join(sectionKey, entry.file));hooks/post-read.js(same guard in its fallback lookup):for (const [sectionKey, entries] of sections) { for (const entry of entries) { + if (entry.file === undefined) + continue; const entryRelPath = normalizePath(path.join(sectionKey, entry.file));The patch above is already applied and verified locally (tested with
node --checkon all four hook files, plus a round-trip test confirming non-tok-unit bullets and prose survive repeated hook passes).