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
25 changes: 14 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ This pack implements a **closed-loop sharing system** that pulls the latest team

```mermaid
flowchart TD
S([Session starts]) --> P["SessionStart hook<br/>git pull --ff-only"]
S([Session starts]) --> P["SessionStart hooks<br/>stuck-state check · git pull --ff-only in the background"]
P --> W["Work session<br/>continuous-learning writes<br/>learning_*.md · decision_*.md"]
W --> T["Stop hook<br/>auto-push"]
T -->|"filename guard<br/>+ configurable push policy"| R
Expand All @@ -58,7 +58,7 @@ Captures still come from [`mcs-cli/memory`](https://github.com/mcs-cli/memory).

| Piece | What | How |
|-------|------|-----|
| **SessionStart Hook** | Pulls the latest team memories at session start | `git pull --ff-only` against the shared checkout; also flags what would keep your memories from reaching the team: lingering uncommitted/unpushed state, a rebase left half-finished, or a checkout with no upstream |
| **SessionStart Hooks** | Pull the latest team memories at session start, without making the session wait for the network | An async hook runs `git pull --ff-only` against the shared checkout; a sync one, local git only, flags what would keep your memories from reaching the team: lingering uncommitted/unpushed state, a rebase left half-finished, or a checkout with no upstream |
| **Stop Hook** | Commits and pushes every memory change after each Claude turn — additions, edits and deletions alike | Runs async; the filename guardrail blocks bad names; commits as the git user with a subject that says what changed; rebases and retries with jitter when a teammate pushed first |
| **/resolve-memories Slash Command** | Fixes what the Stop hook can't fix by retrying: merges rebase conflicts, renames files the guardrail rejects, restores a missing upstream | Commits, rebases and pushes once the blocker is gone; diagnoses auth and git-identity failures, which need a human |
| **Sparse Checkout + Symlink** | Keeps the shared repo invisible on disk | `.claude/.memories-repo/` is a blobless single-branch sparse clone; `.claude/memories` is a symlink Claude Code reads from |
Expand All @@ -68,7 +68,7 @@ Captures still come from [`mcs-cli/memory`](https://github.com/mcs-cli/memory).

1. **First `mcs sync`** — the configure script clones the shared repo sparsely into `.claude/.memories-repo/`, symlinks `.claude/memories` to it, and migrates any pre-existing local memories into the shared folder (conflicts are preserved for manual review)

2. **Session starts** — the SessionStart hook fast-forwards the shared checkout and warns about any lingering state (auth failure, rebase conflict, guardrail-rejected files, a rebase left in progress, no upstream)
2. **Session starts** — one SessionStart hook warns about any lingering state, while another fast-forwards the shared checkout in the background (auth failure, rebase conflict, guardrail-rejected files, a rebase left in progress, no upstream)

3. **During work** — Claude uses the [`continuous-learning`](https://github.com/mcs-cli/memory) skill to write new `learning_*.md` / `decision_*.md` files

Expand Down Expand Up @@ -103,10 +103,11 @@ flowchart TD

| Hook | Event | What It Does |
|------|-------|-------------|
| **pull.mts** | `SessionStart` | Fast-forwards the shared memories checkout; warns when previous state is stuck, a rebase is left in progress, or the checkout has no upstream |
| **autopush.mts** | `Stop` (async) | Commits every change under `memories/` as the git user, then rebases and pushes; the filename guardrail blocks bad names |
| **status.mjs** | `SessionStart` | Warns when previous state is stuck, a rebase is left in progress, or the checkout has no upstream. Local git only |
| **pull.mjs** | `SessionStart` (async) | Fast-forwards the shared memories checkout; skips quietly mid-rebase or without an upstream, which `status.mjs` reports |
| **autopush.mjs** | `Stop` (async) | Commits every change under `memories/` as the git user, then rebases and pushes; the filename guardrail blocks bad names |

Each runs as `node --experimental-strip-types --disable-warning=ExperimentalWarning <path>`: mcs prefixes the interpreter the hook declares in `hookInterpreter`, and never looks at the shebang. They install to `.claude/hooks/shared-memories/`, with the library they import beside them in `lib/`.
Each runs as `node --experimental-strip-types --disable-warning=ExperimentalWarning <path>`: mcs prefixes the interpreter the hook declares in `hookInterpreter`, and never looks at the shebang. They install to `.claude/hooks/shared-memories/`, with the library they import beside them in `lib/`. Each entry is a small plain-JS bootstrap that turns on Node's compile cache before importing its `lib/*-main.mts` body; from Node 23 the cache also skips type stripping, which is most of a hook's startup.

### Slash Commands

Expand Down Expand Up @@ -198,9 +199,10 @@ shared-memories/
├── commands/
│ └── resolve-memories.md # Slash command for conflicts and stuck pushes
├── runtime/ # Installed to .claude/hooks/shared-memories/
│ ├── pull.mts # SessionStart: pull + stuck-state warning
│ ├── autopush.mts # Stop: auto-commit + push (async)
│ └── lib/ # git, paths, naming, pending, commit-message, push
│ ├── status.mjs # SessionStart: stuck-state warning
│ ├── pull.mjs # SessionStart: pull (async)
│ ├── autopush.mjs # Stop: auto-commit + push (async)
│ └── lib/ # hook bodies (*-main.mts) and the library they share
├── scripts/ # Run in place from the pack checkout
│ ├── configure-memories.ts # Sparse clone + symlink + migration
│ ├── doctor-memories.ts # Setup health check
Expand All @@ -219,8 +221,9 @@ On engineer disks, the pack materializes as:
```
<project>/.claude/
├── hooks/shared-memories/ # run by mcs via each hook's declared hookInterpreter
│ ├── pull.mts
│ ├── autopush.mts
│ ├── status.mjs
│ ├── pull.mjs
│ ├── autopush.mjs
│ └── lib/ # imported as ./lib/… by the entries beside it
├── .memories-repo/ # sparse clone of MEMORIES_BRANCH
│ ├── README.md, LICENSE, etc. # any root-level files your repo ships
Expand Down
7 changes: 7 additions & 0 deletions runtime/autopush.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning
import module from "node:module";

// Before any .mts loads: from Node 23 the cache also skips type stripping, most of a hook's startup.
module.enableCompileCache?.();
const { main } = await import("./lib/autopush-main.mts");
main(import.meta.dirname);
67 changes: 0 additions & 67 deletions runtime/autopush.mts

This file was deleted.

66 changes: 66 additions & 0 deletions runtime/lib/autopush-main.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { commitMessage, stagedChanges } from "./commit-message.mts";
import { ahead, authorName, git, probe } from "./git.mts";
import { detail, failOpen, isJsonStream, readStdin, say, warn } from "./hook-io.mts";
import { RENAME_HINT, RESOLVE_COMMAND } from "./naming.mts";
import { memoriesRepo, projectRoot } from "./paths.mts";
import { badNames, uncommittedCount } from "./pending.mts";
import { syncToRemote } from "./push.mts";

const NAME = "memories_autopush";

/** A failed stage must not read as "nothing to commit"; let git speak and abort the turn. */
const stage = (repo: string, args: readonly string[]): void => {
const r = git(repo, args, { inheritStderr: true });
if (!r.ok) throw new Error(`git ${args.join(" ")} failed (exit ${r.exit ?? r.failure})`);
};

export const main = (hookDir: string): void =>
failOpen(NAME, () => {
const project = projectRoot(hookDir);
const repo = memoriesRepo(project);
const state = probe(repo);
if (state === "no-git") return warn(`${NAME}: git not found; skipping`);
if (!isJsonStream(readStdin())) return warn(`${NAME}: stdin is not valid JSON; skipping`);
if (state === "not-worktree") {
return warn(`${NAME}: ${repo} is not a git worktree; skipping (project_root=${project})`);
}

if (state !== null) {
say(`Shared memories: a ${state} is in progress in .claude/.memories-repo — auto-push paused. Run ${RESOLVE_COMMAND} to finish it.`);
return;
}

const uncommitted = uncommittedCount(repo);
let unpushed = ahead(repo) ?? 0;
if (uncommitted === 0 && unpushed === 0) return;

if (uncommitted > 0) {
const bad = badNames(repo);
if (bad.length > 0) {
say("Shared memories: skipping auto-push — unconventional filename(s):");
for (const f of bad) say(` - ${f}`);
say(RENAME_HINT);
say(`Or run ${RESOLVE_COMMAND} to have Claude rename them.`);
return;
}

stage(repo, ["add", "-A", "--", "memories/"]);

// Whether to commit is git's call; the parsed list only words the message, so a
// status it can't classify still commits (or fails loudly) instead of skipping.
if (!git(repo, ["diff", "--cached", "--quiet", "--", "memories/"]).ok) {
const msg = commitMessage(authorName(repo), stagedChanges(repo));
const commit = git(repo, ["commit", "-m", msg, "--quiet"]);
if (!commit.ok) {
say("Shared memories: commit failed; will retry on next Stop.");
detail(`${commit.stdout}${commit.stderr}`.replace(/\n$/, ""));
return;
}
unpushed = ahead(repo) ?? 0;
}
}

if (unpushed === 0) return;

syncToRemote(repo, process.env.MEMORIES_PUSH_ATTEMPTS);
});
43 changes: 27 additions & 16 deletions runtime/lib/git.mts
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,18 @@ export function hasUpstream(dir: string): boolean {
return git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).ok;
}

/** Commits on HEAD the upstream lacks. Assumes an upstream; `unpushedCount` checks first. */
export function aheadOfUpstream(dir: string): number {
const n = Number.parseInt(gitOut(dir, ["rev-list", "@{u}..HEAD", "--count"]), 10);
return Number.isNaN(n) ? 0 : n;
}

export function unpushedCount(dir: string): number {
return hasUpstream(dir) ? aheadOfUpstream(dir) : 0;
}
/** Why the hook can't run, the operation left in progress, or null when the checkout is ready. */
export type Probe = "no-git" | "not-worktree" | "rebase" | "merge" | null;

/**
* A rebase or merge someone left half-finished. Staging then would mark the
* conflicted files resolved, markers and all, and commit into the detached HEAD.
* Whether git runs, `dir` is a checkout, and a rebase or merge was left half-finished there, in one
* spawn. Staging mid-operation would mark the conflicted files resolved, markers and all, and commit
* into the detached HEAD.
*/
export function operationInProgress(dir: string): "rebase" | "merge" | null {
const paths = gitLines(dir, [
export function probe(dir: string): Probe {
const r = git(dir, [
"rev-parse",
"--is-inside-work-tree",
"--path-format=absolute",
"--git-path",
"rebase-merge",
Expand All @@ -82,10 +77,26 @@ export function operationInProgress(dir: string): "rebase" | "merge" | null {
"--git-path",
"MERGE_HEAD",
]);
const [rebaseMerge, rebaseApply, mergeHead] = paths.map(p => existsSync(p));
if (r.failure === "ENOENT") return "no-git";
if (!r.ok) return "not-worktree";
const [rebaseMerge, rebaseApply, mergeHead] = r.stdout
.trim()
.split("\n")
.slice(1)
.map(p => existsSync(p));
if (rebaseMerge === true || rebaseApply === true) return "rebase";
if (mergeHead === true) return "merge";
return null;
return mergeHead === true ? "merge" : null;
}

/** Commits on HEAD the upstream lacks, or null when there is no upstream. */
export function ahead(dir: string): number | null {
const r = git(dir, ["rev-list", "@{u}..HEAD", "--count"]);
if (r.ok) {
const n = Number.parseInt(r.stdout.trim(), 10);
return Number.isNaN(n) ? 0 : n;
}
// rev-list fails for more than a missing upstream (an unborn HEAD has one and nothing ahead of it).
return hasUpstream(dir) ? 0 : null;
}

/**
Expand Down
16 changes: 16 additions & 0 deletions runtime/lib/pull-main.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { git, hasUpstream, probe } from "./git.mts";
import { failOpen, isJsonStream, readStdin } from "./hook-io.mts";
import { memoriesRepo, projectRoot } from "./paths.mts";

const NAME = "memories_pull";

/** Async: nobody reads its output, so the status hook reports what stops it pulling. */
export const main = (hookDir: string): void =>
failOpen(NAME, () => {
if (!isJsonStream(readStdin())) return;
const repo = memoriesRepo(projectRoot(hookDir));
if (probe(repo) !== null || !hasUpstream(repo)) return;

// Usage rows written after the last Stop would otherwise block every incoming change to that file.
git(repo, ["pull", "--ff-only", "--autostash", "--quiet"]);
});
4 changes: 2 additions & 2 deletions runtime/lib/push.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { git, gitLines, operationInProgress } from "./git.mts";
import { git, gitLines, probe } from "./git.mts";
import { detail, say } from "./hook-io.mts";
import { RESOLVE_COMMAND, USAGE_FILE } from "./naming.mts";

Expand Down Expand Up @@ -86,7 +86,7 @@ export function syncToRemote(repo: string, attemptsRaw: string | undefined): voi
if (!/conflict/i.test(err)) {
// A failure mid-pick (a missing object, say) leaves the rebase behind, and that pauses every later Stop.
// Aborting now is safe: everything is committed or in the autostash.
const midRebase = operationInProgress(repo) === "rebase";
const midRebase = probe(repo) === "rebase";
const message = pullFailure(err, midRebase);
if (midRebase) abortRebase(repo, message);
else say(message);
Expand Down
50 changes: 50 additions & 0 deletions runtime/lib/status-main.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ahead, probe } from "./git.mts";
import { additionalContext, failOpen, isJsonStream, readStdin, warn } from "./hook-io.mts";
import { RESOLVE_COMMAND } from "./naming.mts";
import { memoriesRepo, projectRoot } from "./paths.mts";
import { lingeringCount } from "./pending.mts";

const NAME = "memories_status";

export const main = (hookDir: string): void =>
failOpen(NAME, () => {
// Runs beside the async pull: no read here may take the index lock the pull needs.
process.env.GIT_OPTIONAL_LOCKS = "0";
const project = projectRoot(hookDir);
const repo = memoriesRepo(project);
const state = probe(repo);
if (state === "no-git") return warn(`${NAME}: git not found; skipping`);
if (!isJsonStream(readStdin())) return warn(`${NAME}: stdin is not valid JSON; skipping`);
if (state === "not-worktree") {
return warn(`${NAME}: ${repo} is not a git worktree; skipping (project_root=${project})`);
}

if (state !== null) {
additionalContext(
"SessionStart",
`Shared memories: a ${state} is in progress in .claude/.memories-repo, so auto-push is paused and teammates' memories were not pulled. Run ${RESOLVE_COMMAND} to finish it.`,
);
return;
}

const unpushed = ahead(repo);
// Without an upstream every Stop commits locally and nothing ever reaches the team.
if (unpushed === null) {
additionalContext(
"SessionStart",
`Shared memories: the checkout in .claude/.memories-repo has no upstream branch, so memories are committed locally but never pushed or pulled. Run ${RESOLVE_COMMAND} to fix it.`,
);
return;
}

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

const joined = [uncommitted > 0 ? `${uncommitted} uncommitted file(s)` : "", unpushed > 0 ? `${unpushed} unpushed commit(s)` : ""]
.filter(Boolean)
.join(", ");
additionalContext(
"SessionStart",
`Shared memories have lingering state: ${joined}. The previous Stop hook's auto-push didn't complete. The next Stop retries automatically. If it keeps failing: run ${RESOLVE_COMMAND} for a rebase conflict or misnamed files (must match memories/{learning,decision}_<name>.md); for auth, check ssh-add or run mcs doctor.`,
);
});
7 changes: 7 additions & 0 deletions runtime/pull.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning
import module from "node:module";

// Before any .mts loads: from Node 23 the cache also skips type stripping, most of a hook's startup.
module.enableCompileCache?.();
const { main } = await import("./lib/pull-main.mts");
main(import.meta.dirname);
Loading
Loading