Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <store>/index.json
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 15 additions & 3 deletions packages/cli/src/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 34 additions & 6 deletions packages/memories/src/index-gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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;

Expand All @@ -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,
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/memories/src/tests/fixtures/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions packages/memories/src/tests/fixtures/flat-store/memory/MEMORY.md
Original file line number Diff line number Diff line change
@@ -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 `<store>/flat-topic.md` → `memory/flat-topic.md`

## Nested — resolves via the STORE base (the store's second, drifted layout)

Nested Topic — file lives at `<store>/memory/nested-topic.md` → `memory/nested-topic.md`
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 50 additions & 1 deletion packages/memories/src/tests/index-gen.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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`,
);
}
});
});
4 changes: 2 additions & 2 deletions packages/memories/src/tests/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
`;
Expand Down