diff --git a/README.md b/README.md
index dae88e2..54ae357 100644
--- a/README.md
+++ b/README.md
@@ -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
git pull --ff-only"]
+ S([Session starts]) --> P["SessionStart hooks
stuck-state check · git pull --ff-only in the background"]
P --> W["Work session
continuous-learning writes
learning_*.md · decision_*.md"]
W --> T["Stop hook
auto-push"]
T -->|"filename guard
+ configurable push policy"| R
@@ -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 |
@@ -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
@@ -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 `: 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 `: 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
@@ -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
@@ -219,8 +221,9 @@ On engineer disks, the pack materializes as:
```
/.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
diff --git a/runtime/autopush.mjs b/runtime/autopush.mjs
new file mode 100755
index 0000000..5e205ef
--- /dev/null
+++ b/runtime/autopush.mjs
@@ -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);
diff --git a/runtime/autopush.mts b/runtime/autopush.mts
deleted file mode 100755
index 2c4694b..0000000
--- a/runtime/autopush.mts
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning
-import { commitMessage, stagedChanges } from "./lib/commit-message.mts";
-import { authorName, git, gitPresent, isWorkTree, operationInProgress, unpushedCount } from "./lib/git.mts";
-import { detail, failOpen, isJsonStream, readStdin, say, warn } from "./lib/hook-io.mts";
-import { RENAME_HINT, RESOLVE_COMMAND } from "./lib/naming.mts";
-import { memoriesRepo, projectRoot } from "./lib/paths.mts";
-import { badNames, uncommittedCount } from "./lib/pending.mts";
-import { syncToRemote } from "./lib/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})`);
-};
-
-failOpen(NAME, () => {
- if (!gitPresent()) return warn(`${NAME}: git not found; skipping`);
- if (!isJsonStream(readStdin())) return warn(`${NAME}: stdin is not valid JSON; skipping`);
-
- const project = projectRoot(import.meta.dirname);
- const repo = memoriesRepo(project);
- if (!isWorkTree(repo)) {
- return warn(`${NAME}: ${repo} is not a git worktree; skipping (project_root=${project})`);
- }
-
- const op = operationInProgress(repo);
- if (op !== null) {
- say(`Shared memories: a ${op} is in progress in .claude/.memories-repo — auto-push paused. Run ${RESOLVE_COMMAND} to finish it.`);
- return;
- }
-
- const uncommitted = uncommittedCount(repo);
- let unpushed = unpushedCount(repo);
- 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 = unpushedCount(repo);
- }
- }
-
- if (unpushed === 0) return;
-
- syncToRemote(repo, process.env.MEMORIES_PUSH_ATTEMPTS);
-});
diff --git a/runtime/lib/autopush-main.mts b/runtime/lib/autopush-main.mts
new file mode 100755
index 0000000..6a97c43
--- /dev/null
+++ b/runtime/lib/autopush-main.mts
@@ -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);
+ });
diff --git a/runtime/lib/git.mts b/runtime/lib/git.mts
index 815e455..60aebbd 100644
--- a/runtime/lib/git.mts
+++ b/runtime/lib/git.mts
@@ -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",
@@ -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;
}
/**
diff --git a/runtime/lib/pull-main.mts b/runtime/lib/pull-main.mts
new file mode 100644
index 0000000..62556ab
--- /dev/null
+++ b/runtime/lib/pull-main.mts
@@ -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"]);
+ });
diff --git a/runtime/lib/push.mts b/runtime/lib/push.mts
index f38702b..67447cc 100644
--- a/runtime/lib/push.mts
+++ b/runtime/lib/push.mts
@@ -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";
@@ -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);
diff --git a/runtime/lib/status-main.mts b/runtime/lib/status-main.mts
new file mode 100755
index 0000000..5585fc5
--- /dev/null
+++ b/runtime/lib/status-main.mts
@@ -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}_.md); for auth, check ssh-add or run mcs doctor.`,
+ );
+ });
diff --git a/runtime/pull.mjs b/runtime/pull.mjs
new file mode 100755
index 0000000..251beb2
--- /dev/null
+++ b/runtime/pull.mjs
@@ -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);
diff --git a/runtime/pull.mts b/runtime/pull.mts
deleted file mode 100755
index a15a3fe..0000000
--- a/runtime/pull.mts
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning
-import { aheadOfUpstream, git, gitPresent, hasUpstream, isWorkTree, operationInProgress } from "./lib/git.mts";
-import { additionalContext, failOpen, isJsonStream, readStdin, warn } from "./lib/hook-io.mts";
-import { RESOLVE_COMMAND } from "./lib/naming.mts";
-import { memoriesRepo, projectRoot } from "./lib/paths.mts";
-import { lingeringCount } from "./lib/pending.mts";
-
-const NAME = "memories_pull";
-
-failOpen(NAME, () => {
- if (!gitPresent()) return warn(`${NAME}: git not found; skipping`);
- if (!isJsonStream(readStdin())) return warn(`${NAME}: stdin is not valid JSON; skipping`);
-
- const project = projectRoot(import.meta.dirname);
- const repo = memoriesRepo(project);
- if (!isWorkTree(repo)) {
- return warn(`${NAME}: ${repo} is not a git worktree; skipping (project_root=${project})`);
- }
-
- const op = operationInProgress(repo);
- if (op !== null) {
- additionalContext(
- "SessionStart",
- `Shared memories: a ${op} 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;
- }
-
- // Without an upstream every Stop commits locally and nothing ever reaches the team.
- if (!hasUpstream(repo)) {
- 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;
- }
-
- // 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 = lingeringCount(repo);
- const unpushed = aheadOfUpstream(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}_.md); for auth, check ssh-add or run mcs doctor.`,
- );
-});
diff --git a/runtime/status.mjs b/runtime/status.mjs
new file mode 100755
index 0000000..80e0f75
--- /dev/null
+++ b/runtime/status.mjs
@@ -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/status-main.mts");
+main(import.meta.dirname);
diff --git a/scripts/doctor-memories.ts b/scripts/doctor-memories.ts
index dba5416..e9ccd0a 100755
--- a/scripts/doctor-memories.ts
+++ b/scripts/doctor-memories.ts
@@ -1,7 +1,7 @@
#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning
import { existsSync, lstatSync } from "node:fs";
import { join } from "node:path";
-import { commitGraphOff, hasUnionRule, hasUpstream, isWorkTree, operationInProgress } from "../runtime/lib/git.mts";
+import { commitGraphOff, hasUnionRule, hasUpstream, isWorkTree, probe } from "../runtime/lib/git.mts";
import { RESOLVE_COMMAND, USAGE_FILE } from "../runtime/lib/naming.mts";
import { badNames } from "../runtime/lib/pending.mts";
@@ -41,8 +41,8 @@ if (checkout && !commitGraphOff(repo)) {
// Stuck states that pause auto-push. Warnings, since reads still work and mcs sync can't fix them.
// A rebase detaches HEAD, which would also read as a missing upstream.
if (checkout) {
- const op = operationInProgress(repo);
- if (op !== null) {
+ const op = probe(repo);
+ if (op === "rebase" || op === "merge") {
warnings.push(`a ${op} is in progress in the checkout, so auto-push is paused (run ${RESOLVE_COMMAND})`);
} else if (!hasUpstream(repo)) {
warnings.push(`the checkout has no upstream branch, so memories never leave this machine (run ${RESOLVE_COMMAND})`);
diff --git a/techpack.yaml b/techpack.yaml
index dc7d3af..86537ce 100644
--- a/techpack.yaml
+++ b/techpack.yaml
@@ -50,15 +50,26 @@ components:
fileType: generic
# ── Hooks ─────────────────────────────────────────────────
+ - id: hook-memories-status
+ displayName: Flag stuck shared-memories state
+ description: On SessionStart, if a previous Stop hook's auto-push left uncommitted or unpushed work behind, a rebase is left half-finished, or the checkout has no upstream, emits a system-reminder so the engineer knows. Local git only, so the session never waits on the network.
+ hookEvent: SessionStart
+ hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning
+ hookTimeout: 10
+ hook:
+ source: runtime/status.mjs
+ destination: status.mjs
+
- id: hook-memories-pull
- displayName: Pull shared memories and flag stuck state
- description: Fast-forwards the shared memories checkout on SessionStart. If a previous Stop hook's auto-push left uncommitted or unpushed work behind, a rebase is left half-finished, or the checkout has no upstream, emits a system-reminder so the engineer knows.
+ displayName: Pull shared memories
+ description: Fast-forwards the shared memories checkout on SessionStart, in the background. Skips quietly mid-rebase or without an upstream; the status hook reports both.
hookEvent: SessionStart
hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning
hookTimeout: 15
+ hookAsync: true
hook:
- source: runtime/pull.mts
- destination: pull.mts
+ source: runtime/pull.mjs
+ destination: pull.mjs
- id: hook-memories-autopush
displayName: Sync shared memories
@@ -68,8 +79,8 @@ components:
hookTimeout: 30
hookAsync: true
hook:
- source: runtime/autopush.mts
- destination: autopush.mts
+ source: runtime/autopush.mjs
+ destination: autopush.mjs
# ── Commands ──────────────────────────────────────────────
- id: command-resolve-memories
diff --git a/tests/differential.test.ts b/tests/differential.test.ts
index 8b6c19a..7605ca9 100644
--- a/tests/differential.test.ts
+++ b/tests/differential.test.ts
@@ -4,7 +4,7 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSyn
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, test } from "node:test";
-import { operationInProgress } from "../runtime/lib/git.mts";
+import { probe } from "../runtime/lib/git.mts";
import {
commit,
dropUnionRule,
@@ -51,25 +51,25 @@ function assertParity(fx: Fixture): void {
check(fx.name, runHook(fx), fx.expect, fx.abortsDiffer);
}
-const pull: readonly Fixture[] = [
- { name: "pull: clean tree", hook: "pull" },
+const status: readonly Fixture[] = [
+ { name: "status: clean tree", hook: "status" },
{
- name: "pull: an untracked memory reports lingering state",
+ name: "status: an untracked memory reports lingering state",
expect: /lingering state/,
- hook: "pull",
+ hook: "status",
setup: repo => memory(repo, "learning_pending_one.md"),
},
{
- name: "pull: two untracked memories pluralise the same way",
- hook: "pull",
+ name: "status: two untracked memories pluralise the same way",
+ hook: "status",
setup: repo => {
memory(repo, "learning_pending_one.md");
memory(repo, "learning_pending_two.md");
},
},
{
- name: "pull: unpushed commit only",
- hook: "pull",
+ name: "status: unpushed commit only",
+ hook: "status",
setup: repo => {
memory(repo, "learning_committed_x.md");
git(repo, "add", "-A");
@@ -77,8 +77,8 @@ const pull: readonly Fixture[] = [
},
},
{
- name: "pull: uncommitted and unpushed together",
- hook: "pull",
+ name: "status: uncommitted and unpushed together",
+ hook: "status",
setup: repo => {
memory(repo, "learning_committed_x.md");
git(repo, "add", "-A");
@@ -87,36 +87,36 @@ const pull: readonly Fixture[] = [
},
},
{
- name: "pull: no upstream configured",
+ name: "status: no upstream configured",
expect: /no upstream branch/,
- hook: "pull",
+ hook: "status",
setup: repo => {
git(repo, "branch", "--unset-upstream");
memory(repo, "learning_pending_one.md");
},
},
{
- name: "pull: missing checkout",
- hook: "pull",
+ name: "status: missing checkout",
+ hook: "status",
setup: repo => rmSync(repo, { recursive: true, force: true }),
},
- { name: "pull: malformed stdin", hook: "pull", stdin: "not json" },
+ { name: "status: malformed stdin", hook: "status", stdin: "not json" },
{
- name: "pull: empty stdin still does the work",
- hook: "pull",
+ name: "status: empty stdin still does the work",
+ hook: "status",
stdin: "",
setup: repo => memory(repo, "learning_pending_one.md"),
},
{
- name: "pull: concatenated JSON stream is accepted",
- hook: "pull",
+ name: "status: concatenated JSON stream is accepted",
+ hook: "status",
stdin: '{"a":1} {"b":2}',
setup: repo => memory(repo, "learning_pending_one.md"),
},
];
-describe("pull — behaviour is pinned", () => {
- for (const fx of pull) test(fx.name, () => assertParity(fx));
+describe("status — behaviour is pinned", () => {
+ for (const fx of status) test(fx.name, () => assertParity(fx));
});
const autopush: readonly Fixture[] = [
@@ -299,7 +299,7 @@ const pathspec: readonly Fixture[] = [
},
{
name: "pathspec: SessionStart does not count a dirty root file as pending",
- hook: "pull",
+ hook: "status",
setup: repo => withDirtyRootFile(repo),
},
];
@@ -415,8 +415,7 @@ const edges: readonly Fixture[] = [
},
},
{
- name: "edge: SessionStart survives a failed fast-forward",
- expect: /lingering state/,
+ name: "edge: the background pull survives a failed fast-forward",
hook: "pull",
setup: (repo, project) => {
teammatePushes(project, "learning_theirs_a.md");
@@ -462,7 +461,7 @@ const edges: readonly Fixture[] = [
{
name: "edge: a rebase left in progress is flagged at SessionStart",
expect: /a rebase is in progress[^"]*\/resolve-memories/,
- hook: "pull",
+ hook: "status",
setup: rebaseLeftInProgress,
},
{
@@ -477,7 +476,7 @@ const edges: readonly Fixture[] = [
},
inspect: repo => {
execFileSync("chmod", ["-R", "u+w", join(repo, ".git", "objects")]);
- assert.equal(operationInProgress(repo), null, "the rebase was left behind");
+ assert.equal(probe(repo), null, "the rebase was left behind");
},
},
{
@@ -626,7 +625,7 @@ const usage: readonly Fixture[] = [
{
name: "usage: a dirty usage file alone is not lingering state at SessionStart",
expect: /^$/,
- hook: "pull",
+ hook: "status",
setup: repo => writeFileSync(usageFile(repo), `${USAGE_SEED}learning_seed_topic.md\t2026-10-03\n`),
},
{
diff --git a/tests/epipe.test.ts b/tests/epipe.test.ts
index 49a4efd..ef1c0c6 100644
--- a/tests/epipe.test.ts
+++ b/tests/epipe.test.ts
@@ -38,8 +38,8 @@ function runWithNoReader(hook: string, stdin: string, setup?: (repo: string) =>
describe("a hook whose reader has gone away", () => {
test("SessionStart still exits 0 and reports no stack", async () => {
- // Lingering state makes pull write its additionalContext into the closed pipe.
- const r = await runWithNoReader("pull.mts", "{}", repo => memory(repo, "learning_pending_one.md"));
+ // Lingering state makes status write its additionalContext into the closed pipe.
+ const r = await runWithNoReader("status.mjs", "{}", repo => memory(repo, "learning_pending_one.md"));
assert.equal(r.signal, null, "the hook must not die on SIGPIPE");
assert.equal(r.code, 0);
assert.doesNotMatch(r.stderr, /EPIPE/, "EPIPE must not surface as a failure");
diff --git a/tests/golden/edge-sessionstart-survives-a-failed-fast-forward.json b/tests/golden/edge-the-background-pull-survives-a-failed-fast-forward.json
similarity index 50%
rename from tests/golden/edge-sessionstart-survives-a-failed-fast-forward.json
rename to tests/golden/edge-the-background-pull-survives-a-failed-fast-forward.json
index 55f9021..ef790ed 100644
--- a/tests/golden/edge-sessionstart-survives-a-failed-fast-forward.json
+++ b/tests/golden/edge-the-background-pull-survives-a-failed-fast-forward.json
@@ -1,5 +1,5 @@
{
- "stdout": "{\n \"hookSpecificOutput\": {\n \"hookEventName\": \"SessionStart\",\n \"additionalContext\": \"Shared memories have lingering state: 1 unpushed commit(s). The previous Stop hook's auto-push didn't complete. The next Stop retries automatically. If it keeps failing: run /resolve-memories for a rebase conflict or misnamed files (must match memories/{learning,decision}_.md); for auth, check ssh-add or run mcs doctor.\"\n }\n}\n",
+ "stdout": "",
"stderr": "",
"code": 0,
"git": "status-all: \nlog-all: ours | seed\ntracked-all: memories/learning_ours_b.md | memories/learning_seed_topic.md\nstatus: \nlog: ours | seed\nhead-body: \nupstream: origin/main\nunpushed: 1\ntracked: memories/learning_ours_b.md | memories/learning_seed_topic.md\nworktree: learning_ours_b.md learning_seed_topic.md\nremote-log: teammate memory | seed\nremote-files: memories/learning_seed_topic.md | memories/learning_theirs_a.md"
diff --git a/tests/golden/incoming-a-fast-forward-alongside-our-own-untracked-memory.json b/tests/golden/incoming-a-fast-forward-alongside-our-own-untracked-memory.json
index 87f786e..486456d 100644
--- a/tests/golden/incoming-a-fast-forward-alongside-our-own-untracked-memory.json
+++ b/tests/golden/incoming-a-fast-forward-alongside-our-own-untracked-memory.json
@@ -1,5 +1,5 @@
{
- "stdout": "{\n \"hookSpecificOutput\": {\n \"hookEventName\": \"SessionStart\",\n \"additionalContext\": \"Shared memories have lingering state: 1 uncommitted file(s). The previous Stop hook's auto-push didn't complete. The next Stop retries automatically. If it keeps failing: run /resolve-memories for a rebase conflict or misnamed files (must match memories/{learning,decision}_.md); for auth, check ssh-add or run mcs doctor.\"\n }\n}\n",
+ "stdout": "",
"stderr": "",
"code": 0,
"git": "status-all: ?? memories/learning_ours_pending.md\nlog-all: teammate memory | seed\ntracked-all: memories/learning_seed_topic.md | memories/learning_theirs_new.md\nstatus: ?? memories/learning_ours_pending.md\nlog: teammate memory | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/learning_seed_topic.md | memories/learning_theirs_new.md\nworktree: learning_ours_pending.md learning_seed_topic.md learning_theirs_new.md\nremote-log: teammate memory | seed\nremote-files: memories/learning_seed_topic.md | memories/learning_theirs_new.md"
diff --git a/tests/golden/pull-an-untracked-memory-reports-lingering-state.json b/tests/golden/status-an-untracked-memory-reports-lingering-state.json
similarity index 100%
rename from tests/golden/pull-an-untracked-memory-reports-lingering-state.json
rename to tests/golden/status-an-untracked-memory-reports-lingering-state.json
diff --git a/tests/golden/pull-clean-tree.json b/tests/golden/status-clean-tree.json
similarity index 100%
rename from tests/golden/pull-clean-tree.json
rename to tests/golden/status-clean-tree.json
diff --git a/tests/golden/pull-concatenated-json-stream-is-accepted.json b/tests/golden/status-concatenated-json-stream-is-accepted.json
similarity index 100%
rename from tests/golden/pull-concatenated-json-stream-is-accepted.json
rename to tests/golden/status-concatenated-json-stream-is-accepted.json
diff --git a/tests/golden/pull-empty-stdin-still-does-the-work.json b/tests/golden/status-empty-stdin-still-does-the-work.json
similarity index 100%
rename from tests/golden/pull-empty-stdin-still-does-the-work.json
rename to tests/golden/status-empty-stdin-still-does-the-work.json
diff --git a/tests/golden/pull-malformed-stdin.json b/tests/golden/status-malformed-stdin.json
similarity index 82%
rename from tests/golden/pull-malformed-stdin.json
rename to tests/golden/status-malformed-stdin.json
index 74dfbf7..867d304 100644
--- a/tests/golden/pull-malformed-stdin.json
+++ b/tests/golden/status-malformed-stdin.json
@@ -1,6 +1,6 @@
{
"stdout": "",
- "stderr": "memories_pull: stdin is not valid JSON; skipping\n",
+ "stderr": "memories_status: stdin is not valid JSON; skipping\n",
"code": 0,
"git": "status-all: \nlog-all: seed\ntracked-all: memories/learning_seed_topic.md\nstatus: \nlog: seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/learning_seed_topic.md\nworktree: learning_seed_topic.md\nremote-log: seed\nremote-files: memories/learning_seed_topic.md"
}
diff --git a/tests/golden/pull-missing-checkout.json b/tests/golden/status-missing-checkout.json
similarity index 58%
rename from tests/golden/pull-missing-checkout.json
rename to tests/golden/status-missing-checkout.json
index e60a10e..04ace35 100644
--- a/tests/golden/pull-missing-checkout.json
+++ b/tests/golden/status-missing-checkout.json
@@ -1,6 +1,6 @@
{
"stdout": "",
- "stderr": "memories_pull: /work/project/.claude/.memories-repo is not a git worktree; skipping (project_root=/work/project)\n",
+ "stderr": "memories_status: /work/project/.claude/.memories-repo is not a git worktree; skipping (project_root=/work/project)\n",
"code": 0,
"git": "status-all: \nlog-all: \ntracked-all: \nstatus: \nlog: \nhead-body: \nupstream: (none)\nunpushed: 0\ntracked: \nworktree: \nremote-log: \nremote-files: "
}
diff --git a/tests/golden/pull-no-upstream-configured.json b/tests/golden/status-no-upstream-configured.json
similarity index 100%
rename from tests/golden/pull-no-upstream-configured.json
rename to tests/golden/status-no-upstream-configured.json
diff --git a/tests/golden/pull-two-untracked-memories-pluralise-the-same-way.json b/tests/golden/status-two-untracked-memories-pluralise-the-same-way.json
similarity index 100%
rename from tests/golden/pull-two-untracked-memories-pluralise-the-same-way.json
rename to tests/golden/status-two-untracked-memories-pluralise-the-same-way.json
diff --git a/tests/golden/pull-uncommitted-and-unpushed-together.json b/tests/golden/status-uncommitted-and-unpushed-together.json
similarity index 100%
rename from tests/golden/pull-uncommitted-and-unpushed-together.json
rename to tests/golden/status-uncommitted-and-unpushed-together.json
diff --git a/tests/golden/pull-unpushed-commit-only.json b/tests/golden/status-unpushed-commit-only.json
similarity index 100%
rename from tests/golden/pull-unpushed-commit-only.json
rename to tests/golden/status-unpushed-commit-only.json
diff --git a/tests/harness.ts b/tests/harness.ts
index 3d850a8..cbe8c83 100644
--- a/tests/harness.ts
+++ b/tests/harness.ts
@@ -8,14 +8,16 @@ import { USAGE_ATTRIBUTE } from "../runtime/lib/naming.mts";
const REPO = dirname(dirname(fileURLToPath(import.meta.url)));
-type HookName = "pull" | "autopush";
+type HookName = "status" | "pull" | "autopush";
const HOOK_FILE: Record = {
- pull: "pull.mts",
- autopush: "autopush.mts",
+ status: "status.mjs",
+ pull: "pull.mjs",
+ autopush: "autopush.mjs",
};
const EVENT: Record = {
+ status: "SessionStart",
pull: "SessionStart",
autopush: "Stop",
};
diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts
index 45d6ae8..fdfae06 100644
--- a/tests/manifest.test.ts
+++ b/tests/manifest.test.ts
@@ -47,11 +47,12 @@ describe("techpack manifest", () => {
});
describe("hook install contract", () => {
- const dests = [...manifest.matchAll(/destination:\s*(\S+\.mts)/g)].map(m => m[1] as string);
+ const dests = [...manifest.matchAll(/^\s*hook:\n\s*source:\s*\S+\n\s*destination:\s*(\S+)/gm)].map(m => m[1] as string);
- test("two hooks are registered, all TypeScript", () => {
- assert.equal(dests.length, 2, "expected two registered hook entry points");
- for (const d of dests) assert.ok(existsSync(join(REPO, "runtime", d)), `${d} is registered but not shipped`);
+ test("every shipped entry point is registered as a hook, and nothing else is", () => {
+ const shipped = readdirSync(join(REPO, "runtime")).filter(f => f.endsWith(".mjs"));
+ assert.ok(dests.length > 0, "the manifest registers no hooks");
+ assert.deepEqual([...dests].sort(), shipped.sort());
});
test("no shell survives anywhere in the pack", () => {
@@ -61,7 +62,7 @@ describe("hook install contract", () => {
test("every hook is declared with the interpreter its shebang names", () => {
// mcs runs ` ` and ignores the shebang; the suite runs the shebang.
- // `.mts` is in mcs's ambiguousExtensions, so a missing hookInterpreter falls back to bash.
+ // The library they import is `.mts`, which needs the type-stripping flag even from a `.mjs` entry.
const declared = [...manifest.matchAll(/^\s*hookInterpreter:\s*(.+?)\s*$/gm)].map(m => m[1]);
assert.equal(declared.length, dests.length, "each hook needs its own hookInterpreter");
for (const d of dests) {
@@ -84,10 +85,29 @@ describe("hook install contract", () => {
for (const d of dests) {
const body = readFileSync(join(REPO, "runtime", d), "utf8");
assert.doesNotMatch(body, /from "\.\.\//, `${d} reaches outside its own directory`);
- assert.match(body, /from "\.\/lib\//, `${d} does not import the shared library`);
+ assert.match(body, /import\("\.\/lib\//, `${d} does not import the shared library`);
}
});
+ test("every entry point enables the compile cache before any TypeScript loads", () => {
+ // Stripping even one `.mts` before the cache is on loads the stripper, which is
+ // most of the startup the cache exists to save. So the entry stays plain JS, and
+ // the library is imported dynamically, after the call.
+ for (const d of dests) {
+ const body = readFileSync(join(REPO, "runtime", d), "utf8");
+ const enable = body.indexOf("enableCompileCache");
+ assert.ok(enable >= 0, `${d} never enables the compile cache`);
+ assert.ok(enable < body.indexOf("import("), `${d} imports its library before enabling the cache`);
+ assert.doesNotMatch(body, /^\s*(?:import|export)\s[^(;]*["']\.{1,2}\//m, `${d} statically imports the library`);
+ }
+ });
+
+ test("only the network pull runs async at SessionStart", () => {
+ const block = (id: string): string => manifest.slice(manifest.indexOf(`- id: ${id}`)).split(/\n\s*- id: /)[0] ?? "";
+ assert.match(block("hook-memories-pull"), /hookAsync: true/);
+ assert.doesNotMatch(block("hook-memories-status"), /hookAsync/, "the status hook is what reaches Claude's context");
+ });
+
test("every entry point is under the typecheck", () => {
// This was `node --check`, which is parse-only and, on the declared 22.6.0
// floor, does not apply the `.mts`-implies-ESM mapping: it read every entry
@@ -108,7 +128,7 @@ describe("hook install contract", () => {
),
);
const unreachable = readdirSync(join(REPO, "runtime"), { recursive: true, encoding: "utf8" })
- .filter(f => f.endsWith(".ts") || f.endsWith(".mts"))
+ .filter(f => f.endsWith(".ts") || f.endsWith(".mts") || f.endsWith(".mjs"))
// readdirSync yields the platform separator; the globs are written with "/".
.map(f => `runtime/${f.split(sep).join("/")}`)
.filter(f => !reaches.some(re => re.test(f)));
diff --git a/tsconfig.json b/tsconfig.json
index c791561..90fb21c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,7 +15,9 @@
"erasableSyntaxOnly": true,
"allowImportingTsExtensions": true,
"noEmit": true,
- "skipLibCheck": true
+ "skipLibCheck": true,
+ "allowJs": true,
+ "checkJs": true
},
- "include": ["runtime/**/*.mts", "runtime/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"]
+ "include": ["runtime/*.mjs", "runtime/**/*.mts", "runtime/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"]
}