Skip to content

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) #61

Description

@prghbla

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
  1. 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.
  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions