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"] }