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
9 changes: 0 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,6 @@ jobs:
- name: unit, contract and behaviour tests
run: npm test

# A migration artifact: it pinned the premise that commit subjects were
# unchanged by the port. Cheap to keep, not load-bearing going forward.
- name: the hostname equivalence holds on this runner
run: |
from_shell=$(hostname -s)
from_node=$(node -e 'process.stdout.write(require("node:os").hostname().split(".")[0])')
echo "shell='$from_shell' node='$from_node'"
[ "$from_shell" = "$from_node" ] || { echo "::error::hostname -s and os.hostname() disagree"; exit 1; }

# The pack contains no shell at all. Hooks and pack scripts are TypeScript
# executed through their shebang.
- name: no shell crept back in
Expand Down
123 changes: 43 additions & 80 deletions README.md

Large diffs are not rendered by default.

31 changes: 0 additions & 31 deletions commands/approve-memories.md

This file was deleted.

66 changes: 66 additions & 0 deletions commands/resolve-memories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
description: Fix what the shared-memories Stop hook can't fix on its own, then push
argument-hint: "[reason]"
disable-model-invocation: true
---

# Resolve Shared Memories

The Stop hook commits and pushes every memory change at the end of each turn, and retries network failures and rejected pushes by itself. This command handles what retrying never fixes: a rebase conflict, a file the naming guardrail rejects, a checkout with no upstream, or a rebase someone left half-finished. Auth and git-identity failures need a human; diagnose them and say exactly what to run.

Arguments: $ARGUMENTS (optional reason for the commit subject).

All git commands run against the memories repo as `git -C .claude/.memories-repo …`, with `-- memories/` as the pathspec. Never `-C .claude/memories`: that puts git's cwd inside `memories/`, and the pathspec then matches nothing.

## 1. Read the state

- `git -C .claude/.memories-repo status --porcelain -- memories/`: dirty files
- `git -C .claude/.memories-repo rev-parse --abbrev-ref --symbolic-full-name '@{u}'`: the upstream (fails if none)
- `git -C .claude/.memories-repo rev-list '@{u}..HEAD' --count`: unpushed commits
- `git -C .claude/.memories-repo rev-parse --path-format=absolute --git-path rebase-merge --git-path rebase-apply --git-path MERGE_HEAD`: if any of the three printed paths exists, a rebase (or merge) is in progress. The paths must be absolute: a relative one resolves against the project root and checks the wrong `.git`

If nothing is dirty, nothing is unpushed, an upstream exists and no rebase or merge is in progress, report **"Nothing to resolve."** and stop.

## 2. A rebase or merge already in progress

Go straight to step 6 and finish it. Don't commit anything new first. For a merge (`MERGE_HEAD`), resolve the conflicts the same way, then finish with `git -C .claude/.memories-repo commit --no-edit`.

## 3. Filename guardrail

Every dirty file under `memories/` must match `^memories/(learning|decision)_[a-zA-Z0-9_-]+\.md$`, the pattern the Stop hook enforces, defined once in `runtime/lib/naming.mts`. For each offender:

- read it and propose a conforming name: `learning_<topic>_<specific>.md` for something learned, `decision_<domain>_<topic>.md` for a decision taken;
- ask the user if the type is unclear, or if the file looks like scratch that shouldn't be shared at all;
- rename it with `git -C .claude/.memories-repo mv` if tracked, plain `mv` if untracked.

## 4. No upstream

Set it: `git -C .claude/.memories-repo branch --set-upstream-to=origin/<current branch>`. If that remote branch doesn't exist, stop and tell the user. The branch is chosen during `mcs sync`.

## 5. Commit

- `git -C .claude/.memories-repo add -A -- memories/`
- Skip the commit if nothing got staged (only unpushed commits are pending).
- Subject: `<name>: resolve — <reason>`. `<name>` is the author git will use: the part of `git -C .claude/.memories-repo var GIT_AUTHOR_IDENT` before ` <`. `<reason>` is `$ARGUMENTS`, or a few words on what you fixed.
- If the commit fails because git has no identity, stop and tell the user to set `git config --global user.name` and `user.email`.

## 6. Rebase onto the team's work, resolving conflicts

`git -C .claude/.memories-repo pull --rebase --autostash`. For each conflicted file (`git -C .claude/.memories-repo diff --name-only --diff-filter=U`):

- **Both sides edited a memory**: write one version that keeps every fact from both sides. Drop only exact duplicates, and keep the file's existing structure. Show the user the merged result.
- **One side deleted it, the other edited it**: ask the user whether to keep the edited version or let the deletion stand.
- **A conflict outside `memories/`**: `git -C .claude/.memories-repo rebase --abort`, tell the user, and stop.

Then `git -C .claude/.memories-repo add -- <file>` and `GIT_EDITOR=true git -C .claude/.memories-repo rebase --continue`, repeating until the rebase completes. **Never finish this command with a rebase still in progress.** Either complete it or abort it.

## 7. Push

`git -C .claude/.memories-repo push`.

- Rejected because a teammate pushed meanwhile: go back to step 6, once.
- Any other failure (auth, network, repository): stop. Suggest `ssh-add` for SSH remotes, a credential refresh for HTTPS, and `mcs doctor` to confirm remote access. Don't retry in a loop; the next Stop retries on its own.

## 8. Report

Tell the user what was wrong, what you changed (renames, conflict merges, upstream), the commit subject, and whether the push landed.
5 changes: 0 additions & 5 deletions config/settings.json

This file was deleted.

58 changes: 0 additions & 58 deletions runtime/announce.mts

This file was deleted.

97 changes: 20 additions & 77 deletions runtime/autopush.mts
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning
import { existsSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { hostname } from "node:os";
import { join } from "node:path";
import { git, gitPresent, isWorkTree, unpushedCount } from "./lib/git.mts";
import { failOpen, isJsonStream, readStdin, say, warn } from "./lib/hook-io.mts";
import { resolveMode } from "./lib/mode.mts";
import { badNames, collect, headSha, modifiedPaths, uncommittedCount } from "./lib/pending.mts";
import { authorName, git, gitPresent, isWorkTree, operationInProgress, unpushedCount } from "./lib/git.mts";
import { commitMessage, stagedChanges } from "./lib/commit-message.mts";
import { detail, failOpen, isJsonStream, readStdin, say, warn } from "./lib/hook-io.mts";
import { badNames, uncommittedCount } from "./lib/pending.mts";
import { memoriesRepo, projectRoot } from "./lib/paths.mts";
import { RENAME_HINT } from "./lib/naming.mts";
import { canonicalState, hashState, lastShownHash, renderReport } from "./lib/report.mts";
import { RENAME_HINT, RESOLVE_COMMAND } from "./lib/naming.mts";
import { syncToRemote } from "./lib/push.mts";

const NAME = "memories_autopush";
Expand All @@ -19,12 +15,6 @@ const stage = (repo: string, args: readonly string[]): void => {
if (!r.ok) throw new Error(`git ${args.join(" ")} failed (exit ${r.exit ?? r.failure})`);
};

const today = (): string => {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
};

failOpen(NAME, () => {
if (!gitPresent()) return warn(`${NAME}: git not found; skipping`);
if (!isJsonStream(readStdin())) return warn(`${NAME}: stdin is not valid JSON; skipping`);
Expand All @@ -35,89 +25,42 @@ failOpen(NAME, () => {
return warn(`${NAME}: ${repo} is not a git worktree; skipping (project_root=${project})`);
}

const { mode } = resolveMode(process.env["MEMORIES_AUTOPUSH_MODE"]);
const reviewState = join(repo, ".review-shown");

const uncommitted = uncommittedCount(repo);
let unpushed = unpushedCount(repo);

if (uncommitted === 0 && unpushed === 0) {
if (mode === "review" && existsSync(reviewState)) rmSync(reviewState, { force: true });
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 pending = collect(repo, uncommitted, unpushed);
const uncommitted = uncommittedCount(repo);
let unpushed = unpushedCount(repo);
if (uncommitted === 0 && unpushed === 0) return;

if (uncommitted > 0) {
const bad = badNames(repo, pending.untracked);
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;
}
}

if (mode === "review") {
const state = canonicalState(pending, pending.unpushed > 0 ? headSha(repo) : "");
if (state === "") {
// Renames, typechanges, conflicts, or an unresolvable HEAD reach none of the filters.
if (uncommitted > 0) {
say(`Shared memories [review mode]: ${uncommitted} unclassified pending change(s) in memories/`);
say(" Inspect: git -C .claude/.memories-repo status -- memories/");
}
if (unpushed > 0) {
say(
`Shared memories [review mode]: ${unpushed} unpushed commit(s) (HEAD unresolvable — repo may be detached or mid-rebase)`,
);
say(" Inspect: git -C .claude/.memories-repo status");
}
return;
}

const hash = hashState(state);
if (lastShownHash(reviewState) === hash) return;

for (const line of renderReport(repo, pending)) say(line);

// Atomic, so a killed hook cannot leave an empty state file behind.
writeFileSync(`${reviewState}.tmp`, `${hash}\n`);
renameSync(`${reviewState}.tmp`, reviewState);
return;
}

let committed = false;

if (uncommitted > 0) {
if (mode === "auto") {
if (pending.deleted.length > 0) {
say(`Shared memories: ${pending.deleted.length} deleted memory file(s) left for manual review (not auto-pushed):`);
for (const f of pending.deleted) say(` - ${f}`);
say("If intentional (e.g. after memory-audit), approve them:");
say(" /approve-memories audit cleanup");
}
const stageable = [...new Set([...modifiedPaths(pending), ...pending.untracked])].filter(Boolean).sort();
for (const f of stageable) stage(repo, ["add", "--", f]);
} else {
stage(repo, ["add", "-A", "--", "memories/"]);
}
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 suffix = mode === "full" && pending.deleted.length > 0 ? " (includes deletions)" : "";
const msg = `auto: memories from ${hostname().split(".")[0]} ${today()}${suffix}`;
const msg = commitMessage(authorName(repo), stagedChanges(repo));
const commit = git(repo, ["commit", "-m", msg, "--quiet"]);
if (commit.ok) {
committed = true;
} else {
if (!commit.ok) {
say("Shared memories: commit failed; will retry on next Stop.");
const err = `${commit.stdout}${commit.stderr}`.replace(/\n$/, "");
if (err !== "") process.stdout.write(` ${err}\n`);
detail(`${commit.stdout}${commit.stderr}`.replace(/\n$/, ""));
return;
}
unpushed = unpushedCount(repo);
}
}

if (committed) unpushed = unpushedCount(repo);
if (unpushed === 0) return;

syncToRemote(repo, process.env["MEMORIES_PUSH_ATTEMPTS"]);
Expand Down
70 changes: 70 additions & 0 deletions runtime/lib/commit-message.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { gitLines } from "./git.mts";

export type Change =
| { readonly kind: "add" | "update" | "remove"; readonly path: string }
| { readonly kind: "rename"; readonly path: string; readonly from: string };

/** Every kind once: its body mark and summary word, in the order the body lists them. */
const KINDS = {
add: { mark: "+", past: "added" },
update: { mark: "~", past: "updated" },
rename: { mark: ">", past: "renamed" },
remove: { mark: "-", past: "removed" },
} as const;
const ORDER = Object.keys(KINDS) as Change["kind"][];

/** `memories/learning_a_b.md` → `learning_a_b`: the path prefix and extension carry nothing. */
const short = (p: string): string => p.replace(/^memories\//, "").replace(/\.md$/, "");

const names = (c: Change): string => (c.kind === "rename" ? `${short(c.from)} → ${short(c.path)}` : short(c.path));

/** One body line per change, `+ learning_a_b`. */
export const changeLine = (c: Change): string => `${KINDS[c.kind].mark} ${names(c)}`;

/** One `git diff --name-status` line. Copies (C) are new files as far as the team is concerned. */
export function parseNameStatus(line: string): Change | null {
const [status = "", a = "", b = ""] = line.split("\t");
if (a === "") return null;
switch (status[0]) {
case "A":
case "C":
return { kind: "add", path: b || a };
case "M":
case "T":
return { kind: "update", path: a };
case "D":
return { kind: "remove", path: a };
case "R":
return b === "" ? null : { kind: "rename", path: b, from: a };
default:
return null;
}
}

/** What is staged under memories/, in the order git lists it. */
export function stagedChanges(repo: string): Change[] {
return gitLines(repo, ["diff", "--cached", "--name-status", "-M", "--", "memories/"])
.map(parseNameStatus)
.filter((c): c is Change => c !== null);
}

const byKindThenPath = (x: Change, y: Change): number =>
ORDER.indexOf(x.kind) - ORDER.indexOf(y.kind) || (x.path < y.path ? -1 : x.path > y.path ? 1 : 0);

/**
* `<who>: add learning_x` for a single change; otherwise a count summary in the
* subject and one line per file in the body. Who and what, not which machine:
* the date is already in the commit.
*/
export function commitMessage(who: string, changes: readonly Change[]): string {
const [only] = changes;
if (only === undefined) return `${who}: update memories`;
if (changes.length === 1) return `${who}: ${only.kind} ${names(only)}`;

const summary = ORDER.flatMap((k) => {
const n = changes.filter((c) => c.kind === k).length;
return n > 0 ? [`${n} ${KINDS[k].past}`] : [];
});
const body = [...changes].sort(byKindThenPath).map(changeLine);
return `${who}: ${summary.join(", ")}\n\n${body.join("\n")}`;
}
Loading
Loading