From 2668d5924e73cad51ca419aeb853ffa2e499b463 Mon Sep 17 00:00:00 2001 From: mcp-tool-shop <64996768+mcp-tool-shop@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:29:25 -0400 Subject: [PATCH 1/2] fix(memories): record the resolved path, not the raw pointer (FT-MR11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .claude/CLAUDE.md | 4 +- CHANGELOG.md | 21 ++++++++ packages/cli/src/refresh.ts | 18 +++++-- packages/memories/src/index-gen.ts | 40 ++++++++++++--- .../memories/src/tests/fixtures/MEMORY.md | 2 +- .../fixtures/flat-store/memory/MEMORY.md | 15 ++++++ .../fixtures/flat-store/memory/flat-topic.md | 15 ++++++ .../flat-store/memory/memory/nested-topic.md | 5 ++ packages/memories/src/tests/index-gen.test.ts | 51 ++++++++++++++++++- packages/memories/src/tests/parser.test.ts | 4 +- 10 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 packages/memories/src/tests/fixtures/flat-store/memory/MEMORY.md create mode 100644 packages/memories/src/tests/fixtures/flat-store/memory/flat-topic.md create mode 100644 packages/memories/src/tests/fixtures/flat-store/memory/memory/nested-topic.md 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/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 `; From a8cde16c49ec0b03588ca4b60ac3a6b37b2dbb07 Mon Sep 17 00:00:00 2001 From: mcp-tool-shop <64996768+mcp-tool-shop@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:31:24 -0400 Subject: [PATCH 2/2] fix(deps): bump brace-expansion 5.0.6 -> 5.0.9 to clear the audit gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `npm audit --audit-level=moderate` step went red on three high-severity brace-expansion DoS advisories (GHSA-3jxr-9vmj-r5cp, GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895). Not introduced here — the advisories postdate main's last green run on 2026-06-16, so main is red on this today too; it surfaced on this PR because this is the first run since. Dev-only transitive path: c8 -> test-exclude -> minimatch -> brace-expansion. `npm audit fix` resolves it inside the existing minimatch range, so the delta is five lockfile lines and one package. Audit now reports 0 vulnerabilities. Verify: 383 tests across all four packages, 0 fail, exit 0. Co-Authored-By: Claude Opus 5 --- package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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"