diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 17be1de..6a18579 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -16,7 +16,7 @@ The production data flow, running on this rig right now: ``` -canonical store C:/Users/mikey/.claude/projects/F--AI/memory/ (~330 .md files + MEMORY.md) +canonical store ~/.claude/projects/F--AI/memory/ (~330 .md files + MEMORY.md) │ claude-memories index + validate ← "Index Freshness Ritual" in global CLAUDE.md ▼ store dispatch table /index.json @@ -61,7 +61,7 @@ Decompose-by-secrets (Parnas 1972) is right for N humans, operationally broken f ## Working rules - **Read `ROADMAP.md` first** — it's the dispatch table for this repo, and each phase has a gate that halts on failure. -- Global rules (`C:/Users/mikey/.claude/CLAUDE.md`) and workspace rules (`E:/AI/.claude/CLAUDE.md`) apply here. +- Global rules (`~/.claude/CLAUDE.md`) and workspace rules (`E:/AI/.claude/CLAUDE.md`) apply here. - **Cost discipline:** no agent fleets, no Workflow orchestration without explicit pricing + director approval. This layer's work is deterministic-first: scripts, validators, hand edits. - The loadout-hook injects pointer lines on prompts — open the pointed file before acting; don't paraphrase from the summary line. - 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index bda9e63..a971894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,27 @@ ship together under `loadout-os`. ### Fixed +- **Index entries recorded the raw pointer instead of the resolved file** (FT-MR11) — + `generateIndex` resolved each MEMORY.md reference correctly (trying the store dir, + then its parent) and then discarded the result, storing `ref.path` verbatim. The + store's own convention writes pointers as `memory/foo.md`, where `memory/` is a + namespace label for the store rather than a subdirectory of it, so `refresh`'s + `rewritePathsAbsolute` re-applied the prefix 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** — and because the + UserPromptSubmit hook reads that published index on every prompt, every session was + silently handed dead paths and fell back to paraphrasing one-line summaries, which + is 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 store + layouts resolve. Live index went 72/492 → **492/492**. Regression fixture + `fixtures/flat-store/` pins the shape the original fixture never exercised: the + previous 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. +- **`DEFAULT_STORE` hardcoded one machine's home directory** — the shipped default + store path was an absolute literal containing a username, so it resolved on exactly + one computer and leaked that username into a public package. It is now derived from + `homedir()`, matching `defaultDest()` directly below it. + - **Matcher recall** (FT-K1) — domain entries were scored by pure coverage (`matched / declared keyword count`), which starved keyword-rich entries: a genuine 2–3 keyword match on the live 30+-keyword entries scored below the 0.1 inclusion floor. diff --git a/package-lock.json b/package-lock.json index 7b857e0..bb6ce2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -587,16 +587,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/c8": { @@ -1264,7 +1264,7 @@ }, "packages/cli": { "name": "@mcptoolshop/loadout-os", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "bin": { "loadout-os": "dist/loadout-os.js" diff --git a/packages/cli/src/refresh.ts b/packages/cli/src/refresh.ts index eaa8d66..73916a6 100644 --- a/packages/cli/src/refresh.ts +++ b/packages/cli/src/refresh.ts @@ -85,9 +85,21 @@ import { flagValue, } from "./console.js"; -/** Canonical memory store (holds MEMORY.md + topic files). */ -export const DEFAULT_STORE = - "C:/Users/mikey/.claude/projects/F--AI/memory"; +/** + * Canonical memory store (holds MEMORY.md + topic files). + * + * Derived from the running user's home directory, not hardcoded. The literal + * path baked in here was one machine's, which made this shipped default resolve + * on exactly one computer — and put a username into a public package. + * `defaultDest()` below already derived its path this way; this matches it. + */ +export const DEFAULT_STORE = join( + homedir(), + ".claude", + "projects", + "F--AI", + "memory", +); /** Default destination: the live global resolver index the hook reads. */ export function defaultDest(): string { diff --git a/packages/memories/src/index-gen.ts b/packages/memories/src/index-gen.ts index 6587220..979102f 100644 --- a/packages/memories/src/index-gen.ts +++ b/packages/memories/src/index-gen.ts @@ -6,7 +6,7 @@ */ import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { dirname, relative, resolve, sep } from "node:path"; import { estimateTokens, parseFrontmatter } from "@mcptoolshop/ai-loadout"; import type { LoadoutEntry, Budget, Frontmatter } from "@mcptoolshop/ai-loadout"; import { DEFAULT_TRIGGERS } from "@mcptoolshop/ai-loadout"; @@ -66,15 +66,16 @@ export function generateIndex( continue; } + const storePath = toStoreRelative(fileDir, fullPath); const content = readFileSync(fullPath, "utf-8"); const { frontmatter } = parseFrontmatter(content); if (frontmatter) { // Frontmatter is source of truth - entries.push(entryFromFrontmatter(frontmatter, ref, content)); + entries.push(entryFromFrontmatter(frontmatter, ref, content, storePath)); } else { // Auto-generate from name + content - entries.push(entryFromContent(ref, content)); + entries.push(entryFromContent(ref, content, storePath)); } } @@ -107,6 +108,28 @@ export function generateIndex( }; } +/** + * FT-MR11: record the path that ACTUALLY resolved, expressed relative to the + * store root (the directory holding MEMORY.md) with POSIX separators. + * + * `ref.path` is the pointer exactly as written in MEMORY.md, and the canonical + * store writes it `memory/foo.md` — a namespace label for the store, not a + * subdirectory of it. `resolveRefPath` already copes with that by falling back + * to the parent base, but recording `ref.path` threw that work away. The CLI's + * `rewritePathsAbsolute` then re-absolutized the raw pointer against the store + * root ALONE, re-applying the prefix and yielding a doubled `memory/memory/` + * segment. On the canonical store that broke 420 of 492 entries (85%): the + * UserPromptSubmit hook reads the published index on every prompt, so every + * session was silently handed paths to files that do not exist. + * + * Store-RELATIVE rather than absolute, because a portable on-disk index is the + * contract `rewritePathsAbsolute` depends on; POSIX separators because the + * index is written on Windows and read everywhere. + */ +function toStoreRelative(storeRoot: string, fullPath: string): string { + return relative(storeRoot, fullPath).split(sep).join("/"); +} + /** Max summary length — keep entry summaries compact in the dispatch table. */ const MAX_SUMMARY = 120; @@ -119,11 +142,12 @@ function entryFromFrontmatter( fm: Frontmatter, ref: MemoryRef, content: string, + storePath: string, ): LoadoutEntry { const lines = content.split("\n").length; return { id: fm.id, - path: ref.path, + path: storePath, keywords: fm.keywords, patterns: fm.patterns, priority: fm.priority, @@ -136,14 +160,18 @@ function entryFromFrontmatter( }; } -function entryFromContent(ref: MemoryRef, content: string): LoadoutEntry { +function entryFromContent( + ref: MemoryRef, + content: string, + storePath: string, +): LoadoutEntry { const id = nameToId(ref.name); const keywords = extractKeywords(ref.name, content); const lines = content.split("\n").length; return { id, - path: ref.path, + path: storePath, keywords, patterns: [], priority: "domain", diff --git a/packages/memories/src/tests/fixtures/MEMORY.md b/packages/memories/src/tests/fixtures/MEMORY.md index 38e463a..f407bb3 100644 --- a/packages/memories/src/tests/fixtures/MEMORY.md +++ b/packages/memories/src/tests/fixtures/MEMORY.md @@ -17,7 +17,7 @@ XRPL Lab — CLI training workbook → `memory/xrpl-lab.md` ## Prose (junk-shape regression — MEM-001) - Memory files: see `memory/index.json` for the generated dispatch table -Full frame in `C:/Users/mikey/.claude/projects/memory/user_profile.md` — read it if unsure +Full frame in `C:/Users/Public/.claude/projects/memory/user_profile.md` — read it if unsure See also: the post-proof balance tuning notes live at `memory/post-proof-balance-tuning.md` and cover wave-based tuning ## Edge cases (MEM-007 / MEM-004 / MEM-B08) diff --git a/packages/memories/src/tests/fixtures/flat-store/memory/MEMORY.md b/packages/memories/src/tests/fixtures/flat-store/memory/MEMORY.md new file mode 100644 index 0000000..fe6ce72 --- /dev/null +++ b/packages/memories/src/tests/fixtures/flat-store/memory/MEMORY.md @@ -0,0 +1,15 @@ +# Flat Store — FT-MR11 regression fixture + +MEMORY.md lives INSIDE the store directory, and its pointers carry a +`memory/` prefix that is a NAMESPACE LABEL for the store, not a subdirectory +of it. This is the canonical store's real shape, and the shape the original +`fixtures/MEMORY.md` never exercised — which is why the doubled-prefix bug +survived to production. + +## Flat — resolves via the PARENT base + +Flat Topic — file lives at `/flat-topic.md` → `memory/flat-topic.md` + +## Nested — resolves via the STORE base (the store's second, drifted layout) + +Nested Topic — file lives at `/memory/nested-topic.md` → `memory/nested-topic.md` diff --git a/packages/memories/src/tests/fixtures/flat-store/memory/flat-topic.md b/packages/memories/src/tests/fixtures/flat-store/memory/flat-topic.md new file mode 100644 index 0000000..e0ee07e --- /dev/null +++ b/packages/memories/src/tests/fixtures/flat-store/memory/flat-topic.md @@ -0,0 +1,15 @@ +--- +id: flat-topic +keywords: [flat, store, namespace, prefix] +patterns: [] +priority: domain +triggers: + task: true + plan: false + edit: false +--- + +# Flat Topic + +Referenced as `memory/flat-topic.md` but stored at the store root. Exercises +the frontmatter branch of the entry builder. diff --git a/packages/memories/src/tests/fixtures/flat-store/memory/memory/nested-topic.md b/packages/memories/src/tests/fixtures/flat-store/memory/memory/nested-topic.md new file mode 100644 index 0000000..b80ea7f --- /dev/null +++ b/packages/memories/src/tests/fixtures/flat-store/memory/memory/nested-topic.md @@ -0,0 +1,5 @@ +# Nested Topic + +Referenced as `memory/nested-topic.md` and genuinely stored under a nested +`memory/` directory. No frontmatter, so this exercises the auto-generated +branch of the entry builder. diff --git a/packages/memories/src/tests/index-gen.test.ts b/packages/memories/src/tests/index-gen.test.ts index c8321c2..cdafe9e 100644 --- a/packages/memories/src/tests/index-gen.test.ts +++ b/packages/memories/src/tests/index-gen.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { join, dirname } from "node:path"; +import { existsSync } from "node:fs"; +import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { analyzeMemoryMd } from "../analyze.js"; import { generateIndex } from "../index-gen.js"; @@ -132,3 +133,51 @@ describe("generateIndex", () => { ); }); }); + +/** + * FT-MR11 — the store-relative path contract. + * + * The original fixture puts MEMORY.md ABOVE its `memory/` directory, so every + * ref resolved against the first base and the parent-base fallback was never + * exercised. The canonical store is the other shape: MEMORY.md sits INSIDE the + * store and its pointers carry a `memory/` namespace prefix. Recording the raw + * pointer there made `loadout-os refresh` re-apply the prefix, breaking 420 of + * 492 live entries. These tests pin the resolved location instead. + */ +describe("generateIndex — store-relative paths (FT-MR11)", () => { + const FLAT_STORE = join(FIXTURES, "flat-store", "memory"); + const flatIndex = () => generateIndex(analyzeMemoryMd(join(FLAT_STORE, "MEMORY.md"))); + + it("strips the namespace prefix when the ref resolves via the parent base", () => { + const entry = flatIndex().entries.find((e) => e.id === "flat-topic"); + assert.ok(entry, "flat-topic should be indexed"); + // Written `memory/flat-topic.md`; actually lives at the store root. + assert.equal(entry.path, "flat-topic.md"); + }); + + it("keeps the nested segment when the ref genuinely resolves under the store", () => { + const entry = flatIndex().entries.find((e) => e.id === "nested-topic"); + assert.ok(entry, "nested-topic should be indexed"); + assert.equal(entry.path, "memory/nested-topic.md"); + }); + + it("records POSIX separators regardless of host platform", () => { + for (const entry of flatIndex().entries) { + assert.ok(!entry.path.includes("\\"), `entry ${entry.id} must not carry backslashes`); + } + }); + + it("every entry path resolves on disk once joined onto the store root", () => { + // This is the miniature of the live acceptance test: resolve(store, path) + // must exist for EVERY entry, which is exactly what the CLI's + // rewritePathsAbsolute does before publishing the global index. + const entries = flatIndex().entries; + assert.equal(entries.length, 2); + for (const entry of entries) { + assert.ok( + existsSync(resolve(FLAT_STORE, entry.path)), + `entry ${entry.id} path "${entry.path}" must resolve under the store root`, + ); + } + }); +}); diff --git a/packages/memories/src/tests/parser.test.ts b/packages/memories/src/tests/parser.test.ts index a139635..11f6b58 100644 --- a/packages/memories/src/tests/parser.test.ts +++ b/packages/memories/src/tests/parser.test.ts @@ -116,7 +116,7 @@ Claude Rules — optimizer → \`memory/claude-rules.md\` const content = `## Prose - Memory files: see \`memory/index.json\` for the generated dispatch table -Full frame in \`C:/Users/mikey/.claude/projects/memory/user_profile.md\` — read it if unsure +Full frame in \`C:/Users/Public/.claude/projects/memory/user_profile.md\` — read it if unsure See also: the post-proof balance tuning notes live at \`memory/post-proof-balance-tuning.md\` and cover wave-based tuning ## Real @@ -152,7 +152,7 @@ See also: the post-proof balance tuning notes live at \`memory/post-proof-balanc // there; only the relative topic ref survives. const content = `## Edge -- Drive Path — see \`C:/Users/mikey/memory/x.md\` → for more details here +- Drive Path — see \`C:/Users/Public/memory/x.md\` → for more details here - Glob Path — see \`memory/*.md\` → for all the files - Real One — see \`memory/real.md\` → for the real one `;