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
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Each runs as `node --experimental-strip-types --disable-warning=ExperimentalWarn

| Script | When | What It Does |
|--------|------|-------------|
| **configure-memories.ts** | `mcs sync` | Sparse-clones the shared repo, sets up the symlink, migrates any pre-existing `.claude/memories/` into the shared folder |
| **configure-memories.ts** | `mcs sync` | Sparse-clones the shared repo, sets up the symlink, adds the `merge=union` rule for [the usage file](#-the-usage-file), migrates any pre-existing `.claude/memories/` into the shared folder |

### CLAUDE.local.md Section

Expand All @@ -130,7 +130,7 @@ Each runs as `node --experimental-strip-types --disable-warning=ExperimentalWarn

| Check | What It Verifies |
|-------|-----------------|
| **Shared memories setup** | Sparse checkout exists and the symlink resolves to a live git repo (auto-fixable via `mcs sync`) |
| **Shared memories setup** | Sparse checkout exists and the symlink resolves to a live git repo; warns when the usage file's `merge=union` rule is missing (auto-fixable via `mcs sync`) |
| **Shared memories remote access** | Auth + network reachability via `git ls-remote origin`; warns (doesn't fail) on issues since local reads still work |

### Dependencies
Expand Down Expand Up @@ -226,7 +226,8 @@ On engineer disks, the pack materializes as:
│ ├── README.md, LICENSE, etc. # any root-level files your repo ships
│ └── memories/
│ ├── learning_*.md
│ └── decision_*.md
│ ├── decision_*.md
│ └── .usage.tsv # last-read dates, written by the memory pack
└── memories -> .memories-repo/memories # symlink Claude Code reads
```

Expand Down Expand Up @@ -335,6 +336,19 @@ The Stop hook retries on every turn, so a network blip or a teammate pushing fir

---

## 📊 The Usage File

The memory pack records the day each memory was last opened in `memories/.usage.tsv`, and this pack shares it like any memory, so the team sees which memories anyone still reads. The memory pack owns the format; this pack only moves it:

- **Guardrail**: exactly `memories/.usage.tsv` is exempt from the naming rule.
- **Commits**: a change to it alone commits as `<you>: update memory usage`; beside memory edits it is left out of the message.
- **Merging**: `mcs sync` adds `memories/.usage.tsv merge=union` to the checkout's `.git/info/attributes`, so two clones updating it never conflict. Nothing is committed to the team's repo. A clone that hasn't re-synced still pushes: when the usage file is the only conflict, the Stop hook keeps both sides' lines and finishes the rebase itself. A conflict on any real memory still pauses for `/resolve-memories`.
- **SessionStart**: rows written after the last Stop are autostashed around the fast-forward, and don't count as lingering state; the next Stop pushes them.
- **Migration**: a pre-existing local `.usage.tsv` is appended onto the shared one. Duplicate rows are expected; the memory pack compacts them.
- **Doctor**: warns when the `merge=union` rule is missing.

---

## 🔧 Troubleshooting

```bash
Expand Down
1 change: 1 addition & 0 deletions commands/resolve-memories.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Set it: `git -C .claude/.memories-repo branch --set-upstream-to=origin/<current
`git -C .claude/.memories-repo pull --rebase --autostash`. For each conflicted file (`git -C .claude/.memories-repo diff --name-only --diff-filter=U`):

- **Both sides edited a memory**: write one version that keeps every fact from both sides. Drop only exact duplicates, and keep the file's existing structure. Show the user the merged result.
- **`memories/.usage.tsv`**: keep every line from both sides, in any order, and drop only the conflict markers. Duplicates are harmless; the memory pack compacts the file later. No need to show the user.
- **One side deleted it, the other edited it**: ask the user whether to keep the edited version or let the deletion stand.
- **A conflict outside `memories/`**: `git -C .claude/.memories-repo rebase --abort`, tell the user, and stop.

Expand Down
7 changes: 5 additions & 2 deletions runtime/lib/commit-message.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { gitLines } from "./git.mts";
import { USAGE_FILE } from "./naming.mts";

export type Change =
| { readonly kind: "add" | "update" | "remove"; readonly path: string }
Expand Down Expand Up @@ -56,9 +57,11 @@ const byKindThenPath = (x: Change, y: Change): number =>
* subject and one line per file in the body. Who and what, not which machine:
* the date is already in the commit.
*/
export function commitMessage(who: string, changes: readonly Change[]): string {
export function commitMessage(who: string, all: readonly Change[]): string {
// Read dates are bookkeeping; listing them would make every commit read as a memory edit.
const changes = all.filter((c) => c.path !== USAGE_FILE);
const [only] = changes;
if (only === undefined) return `${who}: update memories`;
if (only === undefined) return all.length > 0 ? `${who}: update memory usage` : `${who}: update memories`;
if (changes.length === 1) return `${who}: ${only.kind} ${names(only)}`;

const summary = ORDER.flatMap((k) => {
Expand Down
8 changes: 8 additions & 0 deletions runtime/lib/git.mts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { USAGE_FILE } from "./naming.mts";

export type GitRun = {
readonly ok: boolean;
Expand Down Expand Up @@ -95,6 +96,13 @@ export function authorName(dir: string): string {
return /^(.*?) </.exec(gitOut(dir, ["var", "GIT_AUTHOR_IDENT"]))?.[1]?.trim() || "unknown";
}

/** Whether git merges the usage file as a union, wherever the rule is set. */
export function hasUnionRule(dir: string): boolean {
// check-attr resolves paths against the cwd, and `dir` may be memories/ itself.
const top = gitOut(dir, ["rev-parse", "--show-toplevel"]);
return top !== "" && gitOut(top, ["check-attr", "merge", "--", USAGE_FILE]).endsWith(": merge: union");
}

/** Whether the binary exists, the question `command -v git` asked. */
export function gitPresent(): boolean {
const r = spawnSync("git", ["--version"], { stdio: "ignore" });
Expand Down
8 changes: 8 additions & 0 deletions runtime/lib/naming.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,11 @@ export const RENAME_HINT =

/** The slash command that fixes what retrying never will. Every hint names it through this. */
export const RESOLVE_COMMAND = "/resolve-memories";

/** Last-read dates the memory pack appends to. Exempt from the naming rule, and merged as a union. */
export const USAGE_FILE = "memories/.usage.tsv";

export const USAGE_ATTRIBUTE = `${USAGE_FILE} merge=union`;

/** The naming rule, plus the one bookkeeping file that is allowed to break it. */
export const isAllowedPath = (f: string): boolean => f === USAGE_FILE || ALLOWED_PATTERN.test(f);
13 changes: 10 additions & 3 deletions runtime/lib/pending.mts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { gitLines } from "./git.mts";
import { ALLOWED_PATTERN } from "./naming.mts";
import { isAllowedPath, USAGE_FILE } from "./naming.mts";

const dirtyLines = (repo: string): string[] => gitLines(repo, ["status", "--porcelain", "--", "memories/"]);

export function uncommittedCount(repo: string): number {
return gitLines(repo, ["status", "--porcelain", "--", "memories/"]).length;
return dirtyLines(repo).length;
}

/** Uncommitted memories a Stop should have pushed. The usage file is excluded: it gains rows after every Stop. */
export function lingeringCount(repo: string): number {
return dirtyLines(repo).filter((l) => l.slice(3) !== USAGE_FILE).length;
}

/** Dirty paths under memories/ that fail the naming rule: `{ diff --name-only HEAD ; untracked } | sort -u`. */
export function badNames(repo: string): string[] {
const tracked = gitLines(repo, ["diff", "--name-only", "HEAD", "--", "memories/"]);
const untracked = gitLines(repo, ["ls-files", "--others", "--exclude-standard", "--full-name", "--", "memories/"]);
const dirty = [...new Set([...tracked, ...untracked])].sort();
return dirty.filter((f) => !ALLOWED_PATTERN.test(f));
return dirty.filter((f) => !isAllowedPath(f));
}
67 changes: 53 additions & 14 deletions runtime/lib/push.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { git } from "./git.mts";
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { git, gitLines } from "./git.mts";
import { detail, say } from "./hook-io.mts";
import { RESOLVE_COMMAND } from "./naming.mts";
import { RESOLVE_COMMAND, USAGE_FILE } from "./naming.mts";

export function pushAttempts(raw: string | undefined): number {
if (raw === undefined || raw === "") return 12;
Expand All @@ -21,28 +23,65 @@ function sleep(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

const CONFLICT_MARKER = /^(<{7}|\|{7}|={7}|>{7})( |$)/;

const unmergedPaths = (repo: string): string[] => gitLines(repo, ["diff", "--name-only", "--diff-filter=U"]);

const PAUSED = `Shared memories: auto-push paused — rebase conflict. Run ${RESOLVE_COMMAND} to merge it.`;

// Clones synced before the union rule existed still conflict on the usage file,
// and that must never pause the memory pushes behind it. Returns why the rebase
// has to be aborted, or null once it finished.
function finishUsageOnlyRebase(repo: string): string | null {
let unmerged = unmergedPaths(repo);
if (unmerged.length === 0) return PAUSED;
for (; unmerged.length > 0; unmerged = unmergedPaths(repo)) {
if (unmerged.some((f) => f !== USAGE_FILE)) return PAUSED;

// Dropping the markers is exactly what `merge=union` writes, and it keeps rows
// the memory pack appended before this read; one landing before the write is lost.
const path = join(repo, USAGE_FILE);
try {
const lines = readFileSync(path, "utf8").split("\n");
writeFileSync(path, lines.filter((l) => !CONFLICT_MARKER.test(l)).join("\n"));
} catch {
break;
}
if (!git(repo, ["add", "--", USAGE_FILE]).ok) break;
if (git(repo, ["rebase", "--continue"], { env: { GIT_EDITOR: "true" } }).ok) return null;
}
return `Shared memories: could not finish the rebase after merging ${USAGE_FILE}. Will retry on next Stop.`;
}

function abortRebase(repo: string, why: string): void {
const abort = git(repo, ["rebase", "--abort"]);
if (!abort.ok) {
say(
`Shared memories: rebase conflict AND --abort failed — repo may be in a half-rebased state. Run ${RESOLVE_COMMAND} to finish it.`,
);
detail(abort.stderr.replace(/\n$/, ""));
} else {
say(why);
}
}

export function syncToRemote(repo: string, attemptsRaw: string | undefined): void {
const attempts = pushAttempts(attemptsRaw);
for (let attempt = 1; ; attempt++) {
// LC_ALL=C pins git's language so the conflict match survives a non-English locale.
const pull = git(repo, ["pull", "--rebase", "--autostash", "--quiet"], { env: { LC_ALL: "C" } });
if (!pull.ok) {
const err = pull.stderr.replace(/\n$/, "");
if (/conflict/i.test(err)) {
const abort = git(repo, ["rebase", "--abort"]);
if (!abort.ok) {
say(
`Shared memories: rebase conflict AND --abort failed — repo may be in a half-rebased state. Run ${RESOLVE_COMMAND} to finish it.`,
);
detail(abort.stderr.replace(/\n$/, ""));
} else {
say(`Shared memories: auto-push paused — rebase conflict. Run ${RESOLVE_COMMAND} to merge it.`);
}
} else {
if (!/conflict/i.test(err)) {
say("Shared memories: pull --rebase failed (likely auth or network). Will retry on next Stop.");
detail(err);
return;
}
const why = finishUsageOnlyRebase(repo);
if (why !== null) {
abortRebase(repo, why);
return;
}
return;
}

const push = git(repo, ["push", "--quiet"]);
Expand Down
7 changes: 4 additions & 3 deletions runtime/pull.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { additionalContext, failOpen, isJsonStream, readStdin, warn } from "./lib/hook-io.mts";
import { aheadOfUpstream, git, gitPresent, hasUpstream, isWorkTree, operationInProgress } from "./lib/git.mts";
import { RESOLVE_COMMAND } from "./lib/naming.mts";
import { uncommittedCount } from "./lib/pending.mts";
import { lingeringCount } from "./lib/pending.mts";
import { memoriesRepo, projectRoot } from "./lib/paths.mts";

const NAME = "memories_pull";
Expand Down Expand Up @@ -35,9 +35,10 @@ failOpen(NAME, () => {
return;
}

git(repo, ["pull", "--ff-only", "--quiet"]);
// Usage rows written after the last Stop would otherwise block every incoming change to that file.
git(repo, ["pull", "--ff-only", "--autostash", "--quiet"]);

const uncommitted = uncommittedCount(repo);
const uncommitted = lingeringCount(repo);
const unpushed = aheadOfUpstream(repo);
if (uncommitted === 0 && unpushed === 0) return;

Expand Down
57 changes: 50 additions & 7 deletions scripts/configure-memories.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning
import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { authorName, git, gitLines, gitOut, gitPresent, isWorkTree } from "../runtime/lib/git.mts";
import { ALLOWED_PATTERN, RESOLVE_COMMAND } from "../runtime/lib/naming.mts";
import {
appendFileSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
rmdirSync,
statSync,
symlinkSync,
unlinkSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { authorName, git, gitLines, gitOut, gitPresent, hasUnionRule, isWorkTree } from "../runtime/lib/git.mts";
import { ALLOWED_PATTERN, isAllowedPath, RESOLVE_COMMAND, USAGE_ATTRIBUTE, USAGE_FILE } from "../runtime/lib/naming.mts";
import { changeLine } from "../runtime/lib/commit-message.mts";

const out = (line: string) => process.stdout.write(`${line}\n`);
Expand Down Expand Up @@ -77,6 +90,29 @@ function restoreBackup(): void {
}
}

const readText = (p: string): string => {
try {
return readFileSync(p, "utf8");
} catch {
return "";
}
};

const appendLine = (p: string, text: string): void => {
const current = readText(p);
appendFileSync(p, `${current === "" || current.endsWith("\n") ? "" : "\n"}${text}`);
};

// Per clone, in info/attributes, so the team's repo never carries a file the pack owns.
function ensureUnionAttribute(checkout: string): void {
if (hasUnionRule(checkout)) return;
const path = gitOut(checkout, ["rev-parse", "--path-format=absolute", "--git-path", "info/attributes"]);
if (path === "") return;
mkdirSync(dirname(path), { recursive: true });
appendLine(path, `${USAGE_ATTRIBUTE}\n`);
out(`Added the merge=union rule for ${USAGE_FILE} to ${path}.`);
}

function stamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
Expand All @@ -99,6 +135,7 @@ function main(): void {

// Case 1: healthy.
if (isSymlink(link) && isWorkTree(link)) {
ensureUnionAttribute(link);
out("Shared memories already linked — leaving as-is.");
process.exit(0);
}
Expand Down Expand Up @@ -174,6 +211,7 @@ function main(): void {
mkdirSync(join(repoDir, "memories"), { recursive: true });
linkMemories();
}
ensureUnionAttribute(repoDir);

let imported = 0;
let skipped = 0;
Expand All @@ -196,7 +234,11 @@ function main(): void {
}
if (!entry.isFile()) continue;
const target = join(link, name);
if (exists(target)) {
// Reads from before the switch stay meaningful: readers take the latest date per memory.
if (`memories/${name}` === USAGE_FILE) {
appendLine(target, readFileSync(from, "utf8"));
unlinkSync(from);
} else if (exists(target)) {
skipped++;
} else if (deletedHistory.has(`memories/${name}`)) {
resurrected.push(name);
Expand Down Expand Up @@ -237,10 +279,11 @@ function main(): void {
if (imported > 0) {
const untracked = gitLines(repoDir, ["ls-files", "--others", "--exclude-standard", "--full-name", "--", "memories/"]);
const good = untracked.filter((f) => ALLOWED_PATTERN.test(f));
const bad = untracked.filter((f) => !ALLOWED_PATTERN.test(f));
const bad = untracked.filter((f) => !isAllowedPath(f));

if (good.length > 0) {
for (const f of good) {
// Unchanged, it stages nothing; merged, it travels with the memories it describes.
for (const f of exists(join(link, ".usage.tsv")) ? [...good, USAGE_FILE] : good) {
const add = git(repoDir, ["add", "--", f], { inheritStderr: true });
if (!add.ok) process.exit(add.exit ?? 1);
}
Expand Down
17 changes: 12 additions & 5 deletions scripts/doctor-memories.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning
import { existsSync, lstatSync } from "node:fs";
import { join } from "node:path";
import { isWorkTree } from "../runtime/lib/git.mts";
import { hasUnionRule, isWorkTree } from "../runtime/lib/git.mts";
import { USAGE_FILE } from "../runtime/lib/naming.mts";

// mcs shellScript doctor contract: 0 pass, 1 fail, 2 warn, 3 skip.
const project = process.env["MCS_PROJECT_PATH"] || process.cwd();
Expand All @@ -26,9 +27,15 @@ if (!isSymlink(link)) {
problems.push("symlink exists but doesn't resolve to a git checkout (run 'mcs sync' to repair)");
}

if (problems.length > 0) {
process.stdout.write("Shared memories setup is unhealthy:\n");
for (const p of problems) process.stdout.write(` - ${p}\n`);
process.exit(1);
// A warning only: without the rule the Stop hook still merges usage conflicts itself.
const warnings: string[] = [];
if (existsSync(join(repo, ".git")) && !hasUnionRule(repo)) {
warnings.push(`no merge=union rule for ${USAGE_FILE} in the checkout (run 'mcs sync' to add it)`);
}

if (problems.length + warnings.length > 0) {
process.stdout.write(`Shared memories setup ${problems.length > 0 ? "is unhealthy" : "needs attention"}:\n`);
for (const p of [...problems, ...warnings]) process.stdout.write(` - ${p}\n`);
process.exit(problems.length > 0 ? 1 : 2);
}
process.exit(0);
Loading
Loading