From 47b8f3518d15c315977e716c08197183934c0570 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Tue, 1 Sep 2026 23:59:25 +0200 Subject: [PATCH 01/34] Add a no-build TypeScript setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 22+ strips types natively, so the repo is the artifact: clone and run, no compile step between a change and testing it. `tsconfig.json` is strict and, importantly, sets `erasableSyntaxOnly` so the syntax stays strippable — no `enum`, no `namespace`, no constructor parameter properties. There are no runtime dependencies and no lockfile; `typescript` and `@types/node` install ad hoc in CI with `--no-save` and are never shipped. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 +++ package.json | 12 ++++++++++++ tsconfig.json | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 package.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 10179f4..e950fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ *.swo .idea/ .vscode/ + +# Node typecheck deps (CI + local only; runtime is zero-deps) +node_modules/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..1376bee --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "type": "module", + "description": "Hook and script sources for the shared-memories techpack. Zero runtime dependencies; run by `node --experimental-strip-types`. Typecheck deps install ad hoc in CI.", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test \"tests/**/*.test.ts\"", + "typecheck": "tsc --noEmit" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6f34b80 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["runtime/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"] +} From 7336dc3bd74102e82823b903885ee97f3a489b50 Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Tue, 1 Sep 2026 23:59:25 +0200 Subject: [PATCH 02/34] Add the library the hooks and scripts share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven modules, each a single responsibility: `git` wraps `execFileSync` with an explicit stdio triple and an `inheritStderr` option for the calls the bash leaves unredirected; `paths` resolves the project root three levels up from the entry point; `naming` holds the guardrail regex; `mode` parses `MEMORIES_AUTOPUSH_MODE`; `pending` classifies dirty files; `report` renders the review-mode report and hashes its dedupe state; `push` owns the pull/rebase/push retry loop. `naming.ts` is the notable one. The bash carried four copies of the filename pattern in four files, kept in step by a comment chain saying "keep in sync with ...". There is now one definition, and a test asserts the slash command's documented pattern still matches it. `hook-io` carries the stdin contract. It reproduces `jq`'s stream semantics rather than using `JSON.parse`, which is not a detail — see the hooks commit. Co-Authored-By: Claude Opus 5 (1M context) --- runtime/lib/git.ts | 48 ++++++++++++++++++ runtime/lib/hook-io.ts | 110 +++++++++++++++++++++++++++++++++++++++++ runtime/lib/mode.ts | 18 +++++++ runtime/lib/naming.ts | 7 +++ runtime/lib/paths.ts | 10 ++++ runtime/lib/pending.ts | 51 +++++++++++++++++++ runtime/lib/push.ts | 69 ++++++++++++++++++++++++++ runtime/lib/report.ts | 94 +++++++++++++++++++++++++++++++++++ 8 files changed, 407 insertions(+) create mode 100644 runtime/lib/git.ts create mode 100644 runtime/lib/hook-io.ts create mode 100644 runtime/lib/mode.ts create mode 100644 runtime/lib/naming.ts create mode 100644 runtime/lib/paths.ts create mode 100644 runtime/lib/pending.ts create mode 100644 runtime/lib/push.ts create mode 100644 runtime/lib/report.ts diff --git a/runtime/lib/git.ts b/runtime/lib/git.ts new file mode 100644 index 0000000..6127eff --- /dev/null +++ b/runtime/lib/git.ts @@ -0,0 +1,48 @@ +import { spawnSync } from "node:child_process"; + +export type GitRun = { readonly ok: boolean; readonly stdout: string; readonly stderr: string; readonly code: number }; + +export type GitOpts = { + readonly env?: Readonly>; + /** For calls the bash leaves unredirected, so git's own progress still reaches the user. */ + readonly inheritStderr?: boolean; +}; + +export function git(cwd: string, args: readonly string[], opts: GitOpts = {}): GitRun { + const r = spawnSync("git", ["-C", cwd, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", opts.inheritStderr === true ? "inherit" : "pipe"], + env: opts.env ? { ...process.env, ...opts.env } : process.env, + }); + return { ok: r.status === 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "", code: r.status ?? -1 }; +} + +/** stdout trimmed, or "" on any failure — the `$(... || true)` shape. */ +export function gitOut(cwd: string, args: readonly string[]): string { + return git(cwd, args).stdout.trim(); +} + +export function gitLines(cwd: string, args: readonly string[]): string[] { + return git(cwd, args) + .stdout.split("\n") + .filter((l) => l !== ""); +} + +export function isWorkTree(dir: string): boolean { + return git(dir, ["rev-parse", "--is-inside-work-tree"]).ok; +} + +export function hasUpstream(dir: string): boolean { + return git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).ok; +} + +export function unpushedCount(dir: string): number { + if (!hasUpstream(dir)) return 0; + const out = gitOut(dir, ["rev-list", "@{u}..HEAD", "--count"]); + const n = Number.parseInt(out, 10); + return Number.isNaN(n) ? 0 : n; +} + +export function gitPresent(): boolean { + return spawnSync("git", ["--version"], { stdio: "ignore" }).error === undefined; +} diff --git a/runtime/lib/hook-io.ts b/runtime/lib/hook-io.ts new file mode 100644 index 0000000..f407e09 --- /dev/null +++ b/runtime/lib/hook-io.ts @@ -0,0 +1,110 @@ +import { readFileSync } from "node:fs"; + +export function readStdin(): string { + try { + return readFileSync(0, "utf8"); + } catch { + return ""; + } +} + +export function parseJson(raw: string): unknown { + try { + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } +} + +/** Matches `jq -n` output: two-space indent, trailing newline. */ +export function emitJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +export function additionalContext(hookEventName: string, ctx: string): void { + emitJson({ hookSpecificOutput: { hookEventName, additionalContext: ctx } }); +} + +export function say(line: string): void { + process.stdout.write(`${line}\n`); +} + +export function warn(line: string): void { + process.stderr.write(`${line}\n`); +} + +/** The ERR-trap contract: nothing escapes, the hook always exits 0. */ +export function failOpen(name: string, body: () => void): void { + try { + body(); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + process.stderr.write(`${name}: aborted — ${detail}\n`); + } + process.exit(0); +} + +const NUMBER = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/; +const LITERAL = /^(true|false|null)/; + +function scanValue(s: string, start: number): number { + const c = s[start]; + if (c === "{" || c === "[") { + const close = c === "{" ? "}" : "]"; + let depth = 0; + for (let i = start; i < s.length; i++) { + const ch = s[i]; + if (ch === '"') { + i = scanString(s, i) - 1; + if (i < start) return -1; + continue; + } + if (ch === c) depth++; + else if (ch === close && --depth === 0) return i + 1; + } + return -1; + } + if (c === '"') return scanString(s, start); + const rest = s.slice(start); + const lit = LITERAL.exec(rest); + if (lit) return start + lit[0].length; + const num = NUMBER.exec(rest); + if (num) return start + num[0].length; + return -1; +} + +function scanString(s: string, start: number): number { + for (let i = start + 1; i < s.length; i++) { + if (s[i] === "\\") i++; + else if (s[i] === '"') return i + 1; + } + return -1; +} + +/** + * The values `jq` would read from stdin, or null if it would have failed. + * jq is a STREAM parser: empty input and several whitespace-separated values + * are both valid, and JSON.parse rejects both. Used as a validity gate, the + * difference decides whether a hook does its work at all. + */ +export function streamValues(raw: string): unknown[] | null { + const values: unknown[] = []; + let i = 0; + while (i < raw.length) { + while (i < raw.length && /\s/.test(raw[i] ?? "")) i++; + if (i >= raw.length) break; + const end = scanValue(raw, i); + if (end < 0) return null; + try { + values.push(JSON.parse(raw.slice(i, end))); + } catch { + return null; + } + i = end; + } + return values; +} + +export function isJsonStream(raw: string): boolean { + return streamValues(raw) !== null; +} diff --git a/runtime/lib/mode.ts b/runtime/lib/mode.ts new file mode 100644 index 0000000..590abab --- /dev/null +++ b/runtime/lib/mode.ts @@ -0,0 +1,18 @@ +export type Mode = "auto" | "full" | "review"; + +export type ModeResult = { readonly mode: Mode; readonly unknown: string | null }; + +export function resolveMode(raw: string | undefined): ModeResult { + switch (raw) { + case undefined: + case "": + case "auto": + return { mode: "auto", unknown: null }; + case "full": + return { mode: "full", unknown: null }; + case "review": + return { mode: "review", unknown: null }; + default: + return { mode: "auto", unknown: raw }; + } +} diff --git a/runtime/lib/naming.ts b/runtime/lib/naming.ts new file mode 100644 index 0000000..ffde79d --- /dev/null +++ b/runtime/lib/naming.ts @@ -0,0 +1,7 @@ +/** The one definition. Bash carried four copies kept in step by comment. */ +export const ALLOWED_PATTERN = /^memories\/(learning|decision)_[a-zA-Z0-9_-]+\.md$/; + +export const MEMORY_WRITE_PATTERN = /(^|.*\/)\.claude\/memories\/(learning|decision)_[a-zA-Z0-9_-]+\.md$/; + +export const RENAME_HINT = + "Rename to memories/learning__.md or memories/decision__.md so the guardrail accepts them."; diff --git a/runtime/lib/paths.ts b/runtime/lib/paths.ts new file mode 100644 index 0000000..c04dee8 --- /dev/null +++ b/runtime/lib/paths.ts @@ -0,0 +1,10 @@ +import { resolve } from "node:path"; + +/** Entries live at /.claude/shared-memories/hooks/, three levels down. */ +export function projectRoot(hookDir: string): string { + return resolve(hookDir, "..", "..", ".."); +} + +export function memoriesRepo(project: string): string { + return resolve(project, ".claude", ".memories-repo"); +} diff --git a/runtime/lib/pending.ts b/runtime/lib/pending.ts new file mode 100644 index 0000000..cdb3911 --- /dev/null +++ b/runtime/lib/pending.ts @@ -0,0 +1,51 @@ +import { gitLines, gitOut, unpushedCount } from "./git.ts"; +import { ALLOWED_PATTERN } from "./naming.ts"; + +export type NumStat = { readonly added: string; readonly deleted: string; readonly path: string }; + +export type Pending = { + readonly uncommitted: number; + readonly unpushed: number; + readonly untracked: string[]; + readonly numstats: NumStat[]; + readonly addedModified: string[]; + readonly deleted: string[]; +}; + +export function uncommittedCount(repo: string): number { + return gitLines(repo, ["status", "--porcelain", "--", "memories/"]).length; +} + +export function collect(repo: string, uncommitted: number, unpushed: number): Pending { + if (uncommitted === 0) { + return { uncommitted, unpushed, untracked: [], numstats: [], addedModified: [], deleted: [] }; + } + const untracked = gitLines(repo, ["ls-files", "--others", "--exclude-standard", "--full-name", "--", "memories/"]); + const numstats = gitLines(repo, ["diff", "--numstat", "--diff-filter=AM", "HEAD", "--", "memories/"]) + .map((l) => l.split("\t")) + .filter((p): p is [string, string, string] => p.length >= 3 && p[2] !== undefined) + .map(([added, deleted, path]) => ({ added, deleted, path })); + return { + uncommitted, + unpushed, + untracked, + numstats, + addedModified: numstats.map((n) => n.path), + deleted: gitLines(repo, ["diff", "--name-only", "--diff-filter=D", "HEAD", "--", "memories/"]), + }; +} + +/** `{ diff --name-only HEAD ; untracked } | sort -u`, then the guardrail. */ +export function badNames(repo: string, untracked: readonly string[]): string[] { + const tracked = gitLines(repo, ["diff", "--name-only", "HEAD", "--", "memories/"]); + const dirty = [...new Set([...tracked, ...untracked])].filter((f) => f !== "").sort(); + return dirty.filter((f) => !ALLOWED_PATTERN.test(f)); +} + +export function recountUnpushed(repo: string): number { + return unpushedCount(repo); +} + +export function headSha(repo: string): string { + return gitOut(repo, ["rev-parse", "HEAD"]); +} diff --git a/runtime/lib/push.ts b/runtime/lib/push.ts new file mode 100644 index 0000000..0669b39 --- /dev/null +++ b/runtime/lib/push.ts @@ -0,0 +1,69 @@ +import { git } from "./git.ts"; +import { say } from "./hook-io.ts"; + +export function pushAttempts(raw: string | undefined): number { + if (raw === undefined || raw === "") return 12; + if (!/^\d+$/.test(raw)) return 12; + const n = Number.parseInt(raw, 10); + return n === 0 ? 1 : n; +} + +/** Full jitter, capped at 1.5s, matching the bash `0.05 * 2^attempt` schedule. */ +export function jitterMs(attempt: number, random = Math.random): number { + const ceiling = Math.min(0.05 * 2 ** attempt, 1.5); + return Math.round(random() * ceiling * 1000); +} + +/** Synchronous: the hook must finish before the turn proceeds. */ +function sleep(ms: number): void { + if (ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** `printf ' %s\n' "$err"` indents the first line only. */ +function detail(err: string): void { + if (err !== "") process.stdout.write(` ${err}\n`); +} + +export function syncToRemote(repo: string, attemptsRaw: string | undefined): void { + const attempts = pushAttempts(attemptsRaw); + for (let attempt = 1; ; attempt++) { + // LC_ALL=C pins git's language so the conflict match survives a non-English locale. + const pull = git(repo, ["pull", "--rebase", "--autostash", "--quiet"], { env: { LC_ALL: "C" } }); + if (!pull.ok) { + const err = pull.stderr.replace(/\n$/, ""); + if (/conflict/i.test(err)) { + const abort = git(repo, ["rebase", "--abort"]); + if (!abort.ok) { + say( + "Shared memories: rebase conflict AND --abort failed — repo may be in a half-rebased state. Resolve manually in .claude/.memories-repo/memories.", + ); + detail(abort.stderr.replace(/\n$/, "")); + } else { + say("Shared memories: auto-push paused — rebase conflict. Resolve manually in .claude/.memories-repo/memories."); + } + } else { + say("Shared memories: pull --rebase failed (likely auth or network). Will retry on next Stop."); + detail(err); + } + return; + } + + const push = git(repo, ["push", "--quiet"]); + if (push.ok) return; + const err = push.stderr.replace(/\n$/, ""); + + // Exit code, not message text: only 1 means the remote rejected the update. + if (push.code !== 1) { + say("Shared memories: auto-push failed (not a rejected update — auth, network or repository). Will retry on next Stop."); + detail(err); + return; + } + if (attempt >= attempts) { + say(`Shared memories: auto-push failed after ${attempt} attempt(s). Will retry on next Stop.`); + detail(err); + return; + } + sleep(jitterMs(attempt)); + } +} diff --git a/runtime/lib/report.ts b/runtime/lib/report.ts new file mode 100644 index 0000000..3d408b2 --- /dev/null +++ b/runtime/lib/report.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { gitOut } from "./git.ts"; +import type { Pending } from "./pending.ts"; + +/** Only these three break terminal autolinking of file:// URLs; the bash leaves the rest. */ +export function urlEncodePath(p: string): string { + return p.replaceAll(" ", "%20").replaceAll("#", "%23").replaceAll("?", "%3F"); +} + +/** `tr -d '\000-\010\013\014\016-\037\177'`. Tab, LF and CR are deliberately NOT in that set. */ +const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g; + +export function preview(repo: string, file: string): string { + let text: string; + try { + text = readFileSync(join(repo, file), "utf8"); + } catch { + return ""; + } + // grep's [[:space:]] includes CR, so a CRLF file's blank line is blank here too. + const first = text.split("\n").find((l) => !/^[\t\n\v\f\r ]*$/.test(l)) ?? ""; + // bash ${var:0:80} counts characters, so an astral char is one, not two. + return Array.from(first.replace(CONTROL, "")).slice(0, 80).join(""); +} + +export function canonicalState(p: Pending, headSha: string): string { + const lines = [ + ...p.untracked.filter(Boolean).map((f) => `NEW\t${f}`), + ...p.addedModified.filter(Boolean).map((f) => `MOD\t${f}`), + ...p.deleted.filter(Boolean).map((f) => `DEL\t${f}`), + ]; + if (p.unpushed > 0 && headSha !== "") lines.push(`UNPUSHED\t${headSha}\t${p.unpushed}`); + return lines.sort().join("\n"); +} + +export function hashState(state: string): string { + return createHash("sha256").update(state).digest("hex"); +} + +export function describe(fileCount: number, unpushed: number): string { + if (fileCount > 0 && unpushed > 0) { + return `${fileCount} pending file(s) in memories/ and ${unpushed} unpushed commit(s)`; + } + if (fileCount > 0) return `${fileCount} pending file(s) in memories/`; + return `${unpushed} unpushed commit(s)`; +} + +export function renderReport(repo: string, p: Pending): string[] { + const out: string[] = []; + const news = p.untracked.filter(Boolean); + const mods = p.addedModified.filter(Boolean); + const dels = p.deleted.filter(Boolean); + out.push(`Shared memories [review mode]: ${describe(news.length + mods.length + dels.length, p.unpushed)}`); + + for (const f of news) { + out.push(`+ NEW `); + const pv = preview(repo, f); + if (pv !== "") out.push(` "${pv}"`); + } + + for (const { added, deleted, path } of p.numstats) { + if (path === "") continue; + out.push(`~ MOD (+${added} -${deleted})`); + out.push(` Diff: git -C .claude/.memories-repo diff -- ${path}`); + } + + // Per-file `git log` is one process each; the cap keeps a large audit predictable. + let idx = 0; + for (const f of dels) { + idx++; + if (idx <= 20) { + const last = gitOut(repo, ["log", "-1", "--format=%cr", "HEAD", "--", f]) || "?"; + out.push(`- DEL ${f} (last modified ${last})`); + } else { + out.push(`- DEL ${f}`); + } + out.push(` Recover: git -C .claude/.memories-repo checkout HEAD -- ${f}`); + } + + out.push(""); + if (p.unpushed > 0) { + out.push(` Note: ${p.unpushed} local commit(s) not yet on the remote.`); + out.push( + " Push: git -C .claude/.memories-repo pull --rebase --autostash && git -C .claude/.memories-repo push", + ); + out.push(""); + } + out.push("Approve all: /approve-memories [optional commit reason]"); + out.push("Discard local changes: git -C .claude/.memories-repo checkout -- memories/ \\"); + out.push(" && git -C .claude/.memories-repo clean -f -- memories/"); + return out; +} From a4e41531a3a6fda5aa6b6ce861e05dc7e8a1611a Mon Sep 17 00:00:00 2001 From: Brenno Ferrari Date: Tue, 1 Sep 2026 23:59:45 +0200 Subject: [PATCH 03/34] Port the three hooks to TypeScript behind bash shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shims are not a stylistic choice. mcs builds a hook's registered command as `Constants.HookCommand.projectPrefix + destination`, and that prefix is the literal string `"bash .claude/hooks/"` — so every installed hook is invoked as `bash ` and a `#!/usr/bin/env node` shebang is never consulted, exec bit or not. Each hook file therefore has to be bash. What is left of it is four lines that `exec` the TypeScript sibling. The `command -v node` guard in each shim is load-bearing rather than boilerplate. Today a missing `jq` prints "jq not found; skipping" and exits 0; without the guard a missing Node would make `exec` fail and the hook exit non-zero, which is a behaviour change on the one path that matters most — a memory system that fails closed is worse than one that does nothing. One real bug fixed along the way. The bash gates stdin with `jq '.' || exit 0`, and `jq` is a *stream* parser: empty input and concatenated values are both valid to it, while `JSON.parse` rejects both. Porting that gate naively turns "empty stdin" from *do the work* into *exit 0 silently* — precisely the silent stop this pack exists to prevent. `hook-io` reproduces the stream semantics instead. Co-Authored-By: Claude Opus 5 (1M context) --- hooks/memories_announce.sh | 43 +--- hooks/memories_autopush.sh | 392 +------------------------------------ hooks/memories_pull.sh | 102 +--------- runtime/hooks/announce.ts | 56 ++++++ runtime/hooks/autopush.ts | 120 ++++++++++++ runtime/hooks/pull.ts | 57 ++++++ 6 files changed, 254 insertions(+), 516 deletions(-) create mode 100644 runtime/hooks/announce.ts create mode 100644 runtime/hooks/autopush.ts create mode 100644 runtime/hooks/pull.ts diff --git a/hooks/memories_announce.sh b/hooks/memories_announce.sh index 5476c56..458db17 100755 --- a/hooks/memories_announce.sh +++ b/hooks/memories_announce.sh @@ -1,37 +1,8 @@ #!/bin/bash -set -euo pipefail -trap 'rc=$?; echo "memories_announce: aborted (rc=$rc) at line $LINENO: $BASH_COMMAND" >&2; exit 0' ERR - -# PostToolUse hook: surface review-mode memory writes to Claude's conversation. -# -# In review mode the Stop hook's pending-changes report goes to the terminal -# only — Stop runs hookAsync: true, so its stdout never re-enters Claude's -# context. This hook fills that gap by injecting additionalContext after a -# memory file write, so Claude can proactively mention pending review. -# -# Silent in auto/full modes — those auto-push and don't need a Claude-visible -# nudge. - -command -v jq >/dev/null 2>&1 || exit 0 - -input_data=$(cat) || exit 0 -file_path=$(echo "$input_data" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0 - -# hookMatcher in techpack.yaml scopes us to Write/Edit/MultiEdit, but those -# tools touch many paths; restrict to the exact memory-file naming convention -# the autopush guardrail accepts. A loose glob would announce phantom pending -# state for files autopush will silently reject (e.g. "learning_foo bar.md"). -# keep in sync with hooks/memories_autopush.sh allowed_pattern, -# scripts/configure-memories.sh allowed_pattern, and commands/approve-memories.md guardrail -[[ "$file_path" =~ (^|.*/)\.claude/memories/(learning|decision)_[a-zA-Z0-9_-]+\.md$ ]] || exit 0 - -# Review mode only — auto/full/unset/unknown all auto-push and need no nudge. -case "${MEMORIES_AUTOPUSH_MODE:-}" in - review) ;; - *) exit 0 ;; -esac - -msg="Memory file saved at $file_path (MEMORIES_AUTOPUSH_MODE=review). This memory will not auto-push. Mention the pending memory to the user before ending your turn so they can decide whether to approve or discard. If they approve, run /approve-memories." - -jq -n --arg ctx "$msg" \ - '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}' +# mcs registers hooks as `bash .claude/hooks//`, so this file must be +# bash. All logic is in the sibling TypeScript. The command -v guard keeps a +# missing interpreter fail-open, matching the jq guard it replaces. +command -v node >/dev/null 2>&1 || { echo "memories_announce: node not found; skipping" >&2; exit 0; } +d=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0 +exec node --experimental-strip-types --disable-warning=ExperimentalWarning \ + "$d/../../shared-memories/hooks/announce.ts" diff --git a/hooks/memories_autopush.sh b/hooks/memories_autopush.sh index 89890f2..4a21cb3 100755 --- a/hooks/memories_autopush.sh +++ b/hooks/memories_autopush.sh @@ -1,386 +1,8 @@ #!/bin/bash -set -euo pipefail -# Hooks must never fail-fast onto Claude Code, but silent aborts are -# undebuggable. Log the failing line/command to stderr before exiting 0. -trap 'rc=$?; echo "memories_autopush: aborted (rc=$rc) at line $LINENO: $BASH_COMMAND" >&2; exit 0' ERR - -# Stop hook: handle shared-memory file changes per MEMORIES_AUTOPUSH_MODE mode. -# -# Modes (read from .claude/settings.local.json's `env` block — mcs writes the -# value from the techpack prompt during `mcs sync`): -# -# auto — writes auto-pushed; deletions parked for manual review (default) -# full — writes AND deletions auto-pushed -# review — nothing auto; prints a per-turn pending-changes report -# -# In every mode, files that don't match memories/(learning_|decision_).md -# halt everything until renamed — naming policy is orthogonal to push policy. -# Unset, empty, or unknown values fall through to `auto`. -# -# After memory-audit (auto mode only) intentionally removes stale files, the -# user approves the deletions with the pack's slash command: -# /approve-memories audit cleanup - -command -v git >/dev/null 2>&1 || { echo "memories_autopush: git not found; skipping" >&2; exit 0; } -command -v jq >/dev/null 2>&1 || { echo "memories_autopush: jq not found; skipping" >&2; exit 0; } - -input_data=$(cat) || exit 0 -echo "$input_data" | jq '.' >/dev/null 2>&1 || { echo "memories_autopush: stdin is not valid JSON; skipping" >&2; exit 0; } - -# Anchor on the script's own path, not stdin `cwd`. The hook ships at -# /.claude/hooks/shared-memories/