Skip to content

Commit 2668d59

Browse files
mcp-tool-shopclaude
andcommitted
fix(memories): record the resolved path, not the raw pointer (FT-MR11)
`generateIndex` resolved each MEMORY.md reference correctly — trying the store directory, then its parent — and then discarded the result, storing `ref.path` verbatim. The canonical store writes pointers as `memory/foo.md`, where `memory/` is a namespace label for the store rather than a subdirectory of it, so the CLI's `rewritePathsAbsolute` re-applied the prefix against the store root alone and emitted a doubled `…/memory/memory/foo.md`. On the canonical store that left 420 of 492 published entries (85%) pointing at files that do not exist. The UserPromptSubmit hook reads that published index on every prompt, so every session was silently handed dead paths and fell back to paraphrasing one-line summaries — precisely what the store's own rule forbids. Entries now record the location that actually resolved, relative to the store root with POSIX separators, so both of the store's drifted layouts resolve and no memory file has to move. Store-relative rather than absolute keeps the on-disk index portable, which is the contract `rewritePathsAbsolute` depends on. Live index: 72/492 -> 492/492. The original fixture put MEMORY.md *above* its `memory/` directory, so every ref matched on the first base and the parent-base fallback was never under test — which is how this reached production. `fixtures/flat-store/` pins the real shape, with one flat topic and one genuinely-nested topic, and asserts every entry resolves once joined onto the store root. Also drop the hardcoded home directory from `DEFAULT_STORE`: it was an absolute literal containing a username, so the shipped default resolved on exactly one computer and leaked that username into a public package. Now derived from `homedir()`, matching `defaultDest()` directly below it. The remaining username literals in repo instructions and test data are gone too; `identity-scan` reads RESULT CLEAN where it previously reported HIT 12. Verify: 383 tests across all four packages, 0 fail, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 81507ed commit 2668d59

10 files changed

Lines changed: 160 additions & 15 deletions

File tree

.claude/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
The production data flow, running on this rig right now:
1717

1818
```
19-
canonical store C:/Users/mikey/.claude/projects/F--AI/memory/ (~330 .md files + MEMORY.md)
19+
canonical store ~/.claude/projects/F--AI/memory/ (~330 .md files + MEMORY.md)
2020
│ claude-memories index + validate ← "Index Freshness Ritual" in global CLAUDE.md
2121
2222
store dispatch table <store>/index.json
@@ -61,7 +61,7 @@ Decompose-by-secrets (Parnas 1972) is right for N humans, operationally broken f
6161
## Working rules
6262

6363
- **Read `ROADMAP.md` first** — it's the dispatch table for this repo, and each phase has a gate that halts on failure.
64-
- Global rules (`C:/Users/mikey/.claude/CLAUDE.md`) and workspace rules (`E:/AI/.claude/CLAUDE.md`) apply here.
64+
- Global rules (`~/.claude/CLAUDE.md`) and workspace rules (`E:/AI/.claude/CLAUDE.md`) apply here.
6565
- **Cost discipline:** no agent fleets, no Workflow orchestration without explicit pricing + director approval. This layer's work is deterministic-first: scripts, validators, hand edits.
6666
- The loadout-hook injects pointer lines on prompts — open the pointed file before acting; don't paraphrase from the summary line.
6767
- Any new pipeline/script/SKILL.md authored here needs the six-standards compliance block (`workflow_standards.md`). Phase 6 (publish/deprecate/cutover) additionally requires a compensators table — no skip allowed.

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ ship together under `loadout-os`.
4545

4646
### Fixed
4747

48+
- **Index entries recorded the raw pointer instead of the resolved file** (FT-MR11) —
49+
`generateIndex` resolved each MEMORY.md reference correctly (trying the store dir,
50+
then its parent) and then discarded the result, storing `ref.path` verbatim. The
51+
store's own convention writes pointers as `memory/foo.md`, where `memory/` is a
52+
namespace label for the store rather than a subdirectory of it, so `refresh`'s
53+
`rewritePathsAbsolute` re-applied the prefix and emitted a doubled
54+
`…/memory/memory/foo.md`. On the canonical store that left **420 of 492 published
55+
entries (85%) pointing at files that do not exist** — and because the
56+
UserPromptSubmit hook reads that published index on every prompt, every session was
57+
silently handed dead paths and fell back to paraphrasing one-line summaries, which
58+
is precisely what the store's own rule forbids. Entries now record the location that
59+
actually resolved, relative to the store root with POSIX separators, so both store
60+
layouts resolve. Live index went 72/492 → **492/492**. Regression fixture
61+
`fixtures/flat-store/` pins the shape the original fixture never exercised: the
62+
previous fixture put MEMORY.md *above* its `memory/` directory, so every ref
63+
matched on the first base and the parent-base fallback was never under test.
64+
- **`DEFAULT_STORE` hardcoded one machine's home directory** — the shipped default
65+
store path was an absolute literal containing a username, so it resolved on exactly
66+
one computer and leaked that username into a public package. It is now derived from
67+
`homedir()`, matching `defaultDest()` directly below it.
68+
4869
- **Matcher recall** (FT-K1) — domain entries were scored by pure coverage
4970
(`matched / declared keyword count`), which starved keyword-rich entries: a genuine
5071
2–3 keyword match on the live 30+-keyword entries scored below the 0.1 inclusion floor.

packages/cli/src/refresh.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,21 @@ import {
8585
flagValue,
8686
} from "./console.js";
8787

88-
/** Canonical memory store (holds MEMORY.md + topic files). */
89-
export const DEFAULT_STORE =
90-
"C:/Users/mikey/.claude/projects/F--AI/memory";
88+
/**
89+
* Canonical memory store (holds MEMORY.md + topic files).
90+
*
91+
* Derived from the running user's home directory, not hardcoded. The literal
92+
* path baked in here was one machine's, which made this shipped default resolve
93+
* on exactly one computer — and put a username into a public package.
94+
* `defaultDest()` below already derived its path this way; this matches it.
95+
*/
96+
export const DEFAULT_STORE = join(
97+
homedir(),
98+
".claude",
99+
"projects",
100+
"F--AI",
101+
"memory",
102+
);
91103

92104
/** Default destination: the live global resolver index the hook reads. */
93105
export function defaultDest(): string {

packages/memories/src/index-gen.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
import { readFileSync } from "node:fs";
9-
import { dirname, resolve } from "node:path";
9+
import { dirname, relative, resolve, sep } from "node:path";
1010
import { estimateTokens, parseFrontmatter } from "@mcptoolshop/ai-loadout";
1111
import type { LoadoutEntry, Budget, Frontmatter } from "@mcptoolshop/ai-loadout";
1212
import { DEFAULT_TRIGGERS } from "@mcptoolshop/ai-loadout";
@@ -66,15 +66,16 @@ export function generateIndex(
6666
continue;
6767
}
6868

69+
const storePath = toStoreRelative(fileDir, fullPath);
6970
const content = readFileSync(fullPath, "utf-8");
7071
const { frontmatter } = parseFrontmatter(content);
7172

7273
if (frontmatter) {
7374
// Frontmatter is source of truth
74-
entries.push(entryFromFrontmatter(frontmatter, ref, content));
75+
entries.push(entryFromFrontmatter(frontmatter, ref, content, storePath));
7576
} else {
7677
// Auto-generate from name + content
77-
entries.push(entryFromContent(ref, content));
78+
entries.push(entryFromContent(ref, content, storePath));
7879
}
7980
}
8081

@@ -107,6 +108,28 @@ export function generateIndex(
107108
};
108109
}
109110

111+
/**
112+
* FT-MR11: record the path that ACTUALLY resolved, expressed relative to the
113+
* store root (the directory holding MEMORY.md) with POSIX separators.
114+
*
115+
* `ref.path` is the pointer exactly as written in MEMORY.md, and the canonical
116+
* store writes it `memory/foo.md` — a namespace label for the store, not a
117+
* subdirectory of it. `resolveRefPath` already copes with that by falling back
118+
* to the parent base, but recording `ref.path` threw that work away. The CLI's
119+
* `rewritePathsAbsolute` then re-absolutized the raw pointer against the store
120+
* root ALONE, re-applying the prefix and yielding a doubled `memory/memory/`
121+
* segment. On the canonical store that broke 420 of 492 entries (85%): the
122+
* UserPromptSubmit hook reads the published index on every prompt, so every
123+
* session was silently handed paths to files that do not exist.
124+
*
125+
* Store-RELATIVE rather than absolute, because a portable on-disk index is the
126+
* contract `rewritePathsAbsolute` depends on; POSIX separators because the
127+
* index is written on Windows and read everywhere.
128+
*/
129+
function toStoreRelative(storeRoot: string, fullPath: string): string {
130+
return relative(storeRoot, fullPath).split(sep).join("/");
131+
}
132+
110133
/** Max summary length — keep entry summaries compact in the dispatch table. */
111134
const MAX_SUMMARY = 120;
112135

@@ -119,11 +142,12 @@ function entryFromFrontmatter(
119142
fm: Frontmatter,
120143
ref: MemoryRef,
121144
content: string,
145+
storePath: string,
122146
): LoadoutEntry {
123147
const lines = content.split("\n").length;
124148
return {
125149
id: fm.id,
126-
path: ref.path,
150+
path: storePath,
127151
keywords: fm.keywords,
128152
patterns: fm.patterns,
129153
priority: fm.priority,
@@ -136,14 +160,18 @@ function entryFromFrontmatter(
136160
};
137161
}
138162

139-
function entryFromContent(ref: MemoryRef, content: string): LoadoutEntry {
163+
function entryFromContent(
164+
ref: MemoryRef,
165+
content: string,
166+
storePath: string,
167+
): LoadoutEntry {
140168
const id = nameToId(ref.name);
141169
const keywords = extractKeywords(ref.name, content);
142170
const lines = content.split("\n").length;
143171

144172
return {
145173
id,
146-
path: ref.path,
174+
path: storePath,
147175
keywords,
148176
patterns: [],
149177
priority: "domain",

packages/memories/src/tests/fixtures/MEMORY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ XRPL Lab — CLI training workbook → `memory/xrpl-lab.md`
1717
## Prose (junk-shape regression — MEM-001)
1818

1919
- Memory files: see `memory/index.json` for the generated dispatch table
20-
Full frame in `C:/Users/mikey/.claude/projects/memory/user_profile.md` — read it if unsure
20+
Full frame in `C:/Users/Public/.claude/projects/memory/user_profile.md` — read it if unsure
2121
See also: the post-proof balance tuning notes live at `memory/post-proof-balance-tuning.md` and cover wave-based tuning
2222

2323
## Edge cases (MEM-007 / MEM-004 / MEM-B08)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Flat Store — FT-MR11 regression fixture
2+
3+
MEMORY.md lives INSIDE the store directory, and its pointers carry a
4+
`memory/` prefix that is a NAMESPACE LABEL for the store, not a subdirectory
5+
of it. This is the canonical store's real shape, and the shape the original
6+
`fixtures/MEMORY.md` never exercised — which is why the doubled-prefix bug
7+
survived to production.
8+
9+
## Flat — resolves via the PARENT base
10+
11+
Flat Topic — file lives at `<store>/flat-topic.md``memory/flat-topic.md`
12+
13+
## Nested — resolves via the STORE base (the store's second, drifted layout)
14+
15+
Nested Topic — file lives at `<store>/memory/nested-topic.md``memory/nested-topic.md`
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
id: flat-topic
3+
keywords: [flat, store, namespace, prefix]
4+
patterns: []
5+
priority: domain
6+
triggers:
7+
task: true
8+
plan: false
9+
edit: false
10+
---
11+
12+
# Flat Topic
13+
14+
Referenced as `memory/flat-topic.md` but stored at the store root. Exercises
15+
the frontmatter branch of the entry builder.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Nested Topic
2+
3+
Referenced as `memory/nested-topic.md` and genuinely stored under a nested
4+
`memory/` directory. No frontmatter, so this exercises the auto-generated
5+
branch of the entry builder.

packages/memories/src/tests/index-gen.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it } from "node:test";
22
import assert from "node:assert/strict";
3-
import { join, dirname } from "node:path";
3+
import { existsSync } from "node:fs";
4+
import { join, dirname, resolve } from "node:path";
45
import { fileURLToPath } from "node:url";
56
import { analyzeMemoryMd } from "../analyze.js";
67
import { generateIndex } from "../index-gen.js";
@@ -132,3 +133,51 @@ describe("generateIndex", () => {
132133
);
133134
});
134135
});
136+
137+
/**
138+
* FT-MR11 — the store-relative path contract.
139+
*
140+
* The original fixture puts MEMORY.md ABOVE its `memory/` directory, so every
141+
* ref resolved against the first base and the parent-base fallback was never
142+
* exercised. The canonical store is the other shape: MEMORY.md sits INSIDE the
143+
* store and its pointers carry a `memory/` namespace prefix. Recording the raw
144+
* pointer there made `loadout-os refresh` re-apply the prefix, breaking 420 of
145+
* 492 live entries. These tests pin the resolved location instead.
146+
*/
147+
describe("generateIndex — store-relative paths (FT-MR11)", () => {
148+
const FLAT_STORE = join(FIXTURES, "flat-store", "memory");
149+
const flatIndex = () => generateIndex(analyzeMemoryMd(join(FLAT_STORE, "MEMORY.md")));
150+
151+
it("strips the namespace prefix when the ref resolves via the parent base", () => {
152+
const entry = flatIndex().entries.find((e) => e.id === "flat-topic");
153+
assert.ok(entry, "flat-topic should be indexed");
154+
// Written `memory/flat-topic.md`; actually lives at the store root.
155+
assert.equal(entry.path, "flat-topic.md");
156+
});
157+
158+
it("keeps the nested segment when the ref genuinely resolves under the store", () => {
159+
const entry = flatIndex().entries.find((e) => e.id === "nested-topic");
160+
assert.ok(entry, "nested-topic should be indexed");
161+
assert.equal(entry.path, "memory/nested-topic.md");
162+
});
163+
164+
it("records POSIX separators regardless of host platform", () => {
165+
for (const entry of flatIndex().entries) {
166+
assert.ok(!entry.path.includes("\\"), `entry ${entry.id} must not carry backslashes`);
167+
}
168+
});
169+
170+
it("every entry path resolves on disk once joined onto the store root", () => {
171+
// This is the miniature of the live acceptance test: resolve(store, path)
172+
// must exist for EVERY entry, which is exactly what the CLI's
173+
// rewritePathsAbsolute does before publishing the global index.
174+
const entries = flatIndex().entries;
175+
assert.equal(entries.length, 2);
176+
for (const entry of entries) {
177+
assert.ok(
178+
existsSync(resolve(FLAT_STORE, entry.path)),
179+
`entry ${entry.id} path "${entry.path}" must resolve under the store root`,
180+
);
181+
}
182+
});
183+
});

packages/memories/src/tests/parser.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Claude Rules — optimizer → \`memory/claude-rules.md\`
116116
const content = `## Prose
117117
118118
- Memory files: see \`memory/index.json\` for the generated dispatch table
119-
Full frame in \`C:/Users/mikey/.claude/projects/memory/user_profile.md\` — read it if unsure
119+
Full frame in \`C:/Users/Public/.claude/projects/memory/user_profile.md\` — read it if unsure
120120
See also: the post-proof balance tuning notes live at \`memory/post-proof-balance-tuning.md\` and cover wave-based tuning
121121
122122
## Real
@@ -152,7 +152,7 @@ See also: the post-proof balance tuning notes live at \`memory/post-proof-balanc
152152
// there; only the relative topic ref survives.
153153
const content = `## Edge
154154
155-
- Drive Path — see \`C:/Users/mikey/memory/x.md\` → for more details here
155+
- Drive Path — see \`C:/Users/Public/memory/x.md\` → for more details here
156156
- Glob Path — see \`memory/*.md\` → for all the files
157157
- Real One — see \`memory/real.md\` → for the real one
158158
`;

0 commit comments

Comments
 (0)