Port the pack from bash to TypeScript, with tests and CI - #5
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <path>` 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) <noreply@anthropic.com>
These need no shim because mcs runs them by a different mechanism than hooks. `configureProject.script` and `shellScript` doctor checks both go through `ScriptRunner.run`, which executes the file with `arguments: []` after chmodding it — so the shebang *is* honoured, and `#!/usr/bin/env -S node ...` works. They also run from the pack checkout rather than the installed project, so they import the runtime directly and nothing needs copying. Two defects in the original are reproduced deliberately, because behaviour parity is the constraint here and silently improving it would be a change nobody asked for. Both are commented as faithful-but-defective at the point they occur: the final `count=$(ls "$dir"/*.md ...)` exits 2 under `set -e -o pipefail` when the glob matches nothing, so a bootstrap install prints no "Done." line and returns non-zero; and paths are built by string concatenation, so an empty project path yields an absolute `/.claude/...` rather than a relative one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`jq` is gone as a dependency and `node` replaces it. The runtime ships as one verbose `copyPackFile` component with `fileType: generic`, which lands it in `.claude/shared-memories/` beside the hooks — the only mechanism a pack has for shipping a directory, since `hook.source` takes a single file. `configureProject` and both doctor checks now name the `.ts` files. No installed hook path changes: the three destinations keep their existing `.sh` names, so an existing `settings.local.json` stays valid and `mcs doctor` sees the same paths it did before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pack had no tests. These are the first, and they exist to prove the port changed nothing rather than to describe what the TypeScript does. How they were built: a differential harness ran the original bash and the new TypeScript against the same fixture — same directory, same git objects, restored in between — and diffed stdout byte for byte, exit code, stderr, and the resulting repository state. The harness was written *first, against the unmodified bash*, so it captured what the bash actually does rather than what the port's author believed. Once every fixture agreed, that verified output was frozen into `tests/golden/` and the bash was deleted. The goldens are the contract now; a drift is a change in what the pack does, not a stale test. Three details are deliberate. Fixtures carry an `expect` pattern asserted against the *recorded* output, so a fixture where nothing happens fails instead of agreeing vacuously — two were caught doing exactly that. The state snapshot is repo-wide rather than scoped to `memories/`, because a snapshot scoped like the code under test is blind to the damage the `-- memories/` pathspec exists to prevent. And fixture commits are stamped at a fixed past date, since `%cr` renders relative to now and two runs a second apart otherwise disagree. Machine- and day-dependent values — temp paths, hostname, dates, relative timestamps — are normalised. Everything else is byte-exact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
macOS only, and Node 22 and 24. mcs is a Swift tool distributed through Homebrew, so macOS is the only platform that can actually install this pack; testing Linux and Windows would prove a portability nobody can use yet.
Beyond typecheck and tests there are three guards. Shell may exist only as the three hook shims, so a `find` over `runtime`, `scripts` and `tests` fails the build if any `.sh` reappears and the shim count must be exactly three. `hostname -s` must equal `os.hostname().split(".")[0]`, which is the premise behind the commit subjects being unchanged. And `git status --porcelain` must be clean afterwards — a test that writes outside its temp directory is a bug wherever it lands, and that guard caught a real one during this work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dependency row, directory tree, and a Development section covering how to run the suite and how the goldens encode the behaviour contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
scripts/configure-memories.ts currently treats an unset MCS_PROJECT_PATH as "", which can target /.claude/... and mutate the filesystem root when run outside mcs sync.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Ports the shared-memories techpack implementation from large bash hooks/scripts to TypeScript executed directly by Node (type-stripped at runtime), and adds a golden-file harness plus CI to lock behavior to the previous bash outputs.
Changes:
- Replaces hook logic with TypeScript runtime modules and keeps only 3 bash hook shims (to satisfy mcs’
bash <hook>execution model). - Adds a differential/golden harness (
tests/golden/*.json) and node:test suites to pin stdout/stderr/exit codes and resulting git state. - Updates
techpack.yaml, docs, and introduces CI (macOS × Node 22/24) and strict TS config for erasable syntax.
File summaries
| File | Description |
|---|---|
| tsconfig.json | Strict TypeScript configuration aligned with Node type-stripping constraints (no emit). |
| package.json | Defines node:test + typecheck scripts and Node engine requirement. |
| techpack.yaml | Switches dependency from jq→node and installs runtime as a generic directory copy; rewires scripts to TS. |
| README.md | Updates documentation to reflect TS runtime, shims, development workflow, and test/golden contract. |
| .gitignore | Ignores node_modules/ for CI/local typecheck deps. |
| .github/workflows/ci.yml | Adds macOS CI: typecheck, tests, shim constraints, hostname equivalence, clean tree enforcement. |
| hooks/memories_pull.sh | Reduces to bash shim that execs TS entrypoint with fail-open missing-node guard. |
| hooks/memories_autopush.sh | Reduces to bash shim that execs TS entrypoint with fail-open missing-node guard. |
| hooks/memories_announce.sh | Reduces to bash shim that execs TS entrypoint with fail-open missing-node guard. |
| scripts/configure-memories.ts | TS port of configure/migration flow for cloning, linking, and importing/migrating memories. |
| scripts/doctor-memories.ts | TS setup health check for mcs doctor. |
| scripts/doctor-memories-remote.ts | TS remote reachability/auth probe for mcs doctor. |
| runtime/lib/git.ts | Centralized git spawning helpers with explicit stderr inheritance option. |
| runtime/lib/hook-io.ts | Hook stdin parsing + JSON-stream emulation used to match jq behavior. |
| runtime/lib/mode.ts | Mode parsing with “unknown reported” behavior preserved. |
| runtime/lib/naming.ts | Single source of truth for guardrail regexes and rename hint. |
| runtime/lib/paths.ts | Project root and repo path resolution shared by hooks. |
| runtime/lib/pending.ts | Computes pending state and guardrail inputs scoped to memories/. |
| runtime/lib/push.ts | Implements pull/rebase/push retry loop with jitter and conflict classification. |
| runtime/lib/report.ts | Review-mode report rendering + canonical state hashing. |
| runtime/hooks/pull.ts | SessionStart logic: fast-forward + pending-state additionalContext. |
| runtime/hooks/autopush.ts | Stop hook logic: auto/full/review behaviors, dedupe, and sync-to-remote. |
| runtime/hooks/announce.ts | PostToolUse hook: jq-like stream semantics to surface review-mode writes. |
| tests/harness.ts | Fixture harness to run hooks/scripts, normalize outputs, and snapshot repo state. |
| tests/unit.test.ts | Unit tests for core library behaviors (mode, naming, retry budget, JSON stream gate, etc.). |
| tests/differential.test.ts | Hook behavior tests pinned to goldens (parity contract). |
| tests/configure.test.ts | Configure script behavior tests pinned to goldens (parity contract). |
| tests/scripts.test.ts | Doctor script behavior tests pinned to goldens (parity contract). |
| tests/manifest.test.ts | Manifest and installation contract checks (shims, runtime install, references). |
| tests/golden/*.json (136 files) | Recorded bash behavior contract used to assert TS parity across fixtures. |
Review details
- Files reviewed: 166/168 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The bash ended with a summary line that could not survive an empty directory:
count=$(ls "$memories_link"/*.md 2>/dev/null | wc -l | tr -d ' ')
echo "Done. $count memory file(s) available at $memories_link."
With `set -euo pipefail`, an unmatched glob leaves the pattern literal, `ls` exits 2, `2>/dev/null` hides the message but not the status, `pipefail` promotes that 2 over `wc` and `tr`, and `set -e` kills the script. So a first `mcs sync` against a memories branch that has no `memories/` tree yet did all of its work correctly and then died silently with status 2, printing no "Done." line — the one case where a new adopter is watching most closely.
The port reproduced this faithfully at first, because behaviour parity was the constraint. It is now fixed: zero memories is a normal bootstrap, so it reports `Done. 0 memory file(s)` and exits 0. The dotfile exclusion the `*.md` glob implied is kept.
The fixture that recorded the old behaviour keeps its golden and gains a `deviation` block naming what changed and why, so the departure is visible in the test rather than edited into the recorded data. A deviation that matches its golden is rejected, so the marker cannot go stale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…esystem root
The bash built its paths by concatenation:
memories_link="$MCS_PROJECT_PATH/.claude/memories"
repo_dir="$MCS_PROJECT_PATH/.claude/.memories-repo"
Run outside `mcs sync` with the variable empty, that yields `/.claude/memories` and the script tries to clone into the filesystem root, failing deep inside git with a permission error rather than saying what was wrong. The port inherited the concatenation to stay byte-compatible, which reproduced it.
Now it fails fast with the reason, alongside the existing `MEMORIES_REPO_URL not resolved` guard.
Falling back to `process.cwd()` was the other option and is the wrong one here. The doctor scripts do exactly that, because their bash read `${MCS_PROJECT_PATH:-$PWD}` and they only ever read. This script clones, moves directories and creates symlinks, and its bash had no fallback at all — silently configuring whichever directory the caller happened to be standing in is worse than refusing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three diagrams replace prose and ASCII that could not carry the detail. The sharing loop was an ASCII block with a broken corner; it is now a flowchart that keeps its "filename guard + configurable push policy" annotation on the edge it describes, and the example memory filenames inside the repo node. The Stop hook gets a diagram of its own, because the guardrail-then-mode-dispatch ordering is the pack's core policy and a table cell cannot show it. The migration section gets one for the four paths a backed-up file can take: skipped as a conflict, held back as previously deleted, imported and pushed, or imported and left untracked pending a rename. Two stale sentences fixed: the migration failure path is a `try`/`catch` now rather than an `ERR` trap, and the test suites are behaviour suites rather than differential ones since the bash they were diffed against is gone. Also adds a contents line — the file is over 400 lines with no navigation — and lists `package.json`, `tsconfig.json` and the workflow in the directory structure. No information removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sharing-loop flowchart was left-to-right, which renders wide and cramped next to the two vertical ones. All three are top-down now. The Development section was an account of how the port went and a list of differences from the implementation it replaced. A reader of this README is working on the pack that exists, not the one that used to, so it now describes only that: how to run the suite, what `tests/golden/` pins, and what CI guards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three `hooks/*.sh` files existed only because mcs built a hook's command as `"bash .claude/hooks/" + destination`, so a hook file had to be bash no matter what its shebang said. With mcs executing hooks directly and honouring the shebang, they have no reason to exist and the pack now contains no shell at all.
The entry points move up beside the library, from `runtime/hooks/*.ts` to `runtime/*.ts`, so `./lib/...` resolves identically in the pack checkout and once installed. Each carries `#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning`, the same shebang the pack scripts already used, and the manifest registers `runtime/<entry>.ts` against a `<entry>.ts` destination. The library ships as a generic copy into `hooks/shared-memories/lib`, landing beside the entries mcs namespaces into that directory.
Behaviour is unchanged: every golden passes untouched, which is the evidence that executing the entry point directly produces exactly what invoking it through a shim did. The project root is still three levels up, since `.claude/hooks/shared-memories/<entry>.ts` sits at the same depth the shim did.
One capability is lost with the shim, and it is worth stating plainly. The shim opened with `command -v node || { echo "node not found; skipping" >&2; exit 0; }`, so a missing interpreter degraded to a no-op the way the old missing-`jq` guard did. A shebang cannot do that — with no Node on PATH the exec simply fails and the hook exits non-zero. The mitigation is the manifest, which declares `node` as a required dependency, the same way it declared `jq`.
`minMCSVersion` still needs bumping to the release that stopped prefixing hook commands with `bash`; there is a note on it in the manifest. On an older mcs these hooks are read as bash and fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three `hooks/*.sh` files existed only because mcs built a hook's command as `"bash .claude/hooks/" + destination`, so a hook file had to be bash no matter what its shebang said. With mcs executing hooks directly and honouring the shebang, they have no reason to exist and the pack now contains no shell at all.
The entry points move up beside the library, from `runtime/hooks/*.ts` to `runtime/*.ts`, so `./lib/...` resolves identically in the pack checkout and once installed. Each carries `#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning`, the same shebang the pack scripts already used, and the manifest registers `runtime/<entry>.ts` against a `<entry>.ts` destination. The library ships as a generic copy into `hooks/shared-memories/lib`, landing beside the entries mcs namespaces into that directory.
Behaviour is unchanged: every golden passes untouched, which is the evidence that executing the entry point directly produces exactly what invoking it through a shim did. The project root is still three levels up, since `.claude/hooks/shared-memories/<entry>.ts` sits at the same depth the shim did.
One capability is lost with the shim, and it is worth stating plainly. The shim opened with `command -v node || { echo "node not found; skipping" >&2; exit 0; }`, so a missing interpreter degraded to a no-op the way the old missing-`jq` guard did. A shebang cannot do that — with no Node on PATH the exec fails and the hook exits non-zero. The mitigation is the manifest, which declares `node` as a required dependency, the same way it declared `jq`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The block headed "on engineer disks, the pack materializes as" showed only the memories checkout and its symlink. Now that the entry points and the library they import install side by side under `.claude/hooks/shared-memories/`, that arrangement is load-bearing — it is what makes `./lib/...` resolve the same in the pack checkout and once installed — and it belongs in the picture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One per heading, as scanning anchors for a long document. Anchor links in the contents line are updated to match: an emoji is stripped from a generated slug but leaves the leading space behind, so every target gains a hyphen. Kept out of prose and tables deliberately — the value is in finding a section at a glance, and it disappears if everything is decorated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bguidolim
left a comment
There was a problem hiding this comment.
Requesting changes
The port itself is careful, and I want to say that first because most of what follows is about the edges rather than the translation. I ran 11 injected differential scenarios against both implementations (filenames with spaces, mixed case, non-ASCII, subdirectory-only backups, FIFOs, hard links, relative symlinks, non-ASCII deleted-history entries) and they came back byte-identical including exit codes and resulting .claude/ state. All five setup cases, all 24 stdout/stderr stream assignments, the 20-item cap and every exit path check out. The six tool-semantics defects the description documents are all real, verified against jq 1.7.1, BSD grep, bash 3.2 and git. The JSON stream scanner held across roughly 9,700 differential cases against real jq and never rejected anything jq accepted on well-formed JSON. Dropping the -- memories/ pathspec really is killed by a fixture, exactly as claimed.
Four things need to change before this can merge.
1. The hooks will not run. Dropping the shims moved the interpreter choice onto the shebang, but no shipped mcs consults it. Released 2026.9.2 hardcodes bash .claude/hooks/, and the interpreter branch at ~/Developer/mcs deliberately excludes .ts from inference, so it also falls back to bash. mcs pack validate from that build emits one warning per hook. The fix is a hookInterpreter line on each hook component plus a minMCSVersion that names the release shipping it. Details in the techpack.yaml thread.
2. The installed runtime has no package.json. Module type gets inherited from the consumer's project, so a consumer "type": "commonjs" kills every hook with a SyntaxError and exit 1, and a consumer package.json with no "type" prints a MODULE_TYPELESS_PACKAGE_JSON warning on stderr on every hook run. Both reproduce on Node 26. Fail-closed hooks are the one outcome this pack has always avoided, and the suite structurally cannot see it.
3. configure-memories.ts reports a push that never happened. With no global git identity, which is what a fresh machine looks like when configureProject first runs, the commit fails, the push succeeds vacuously, and the script prints "Pushed migrated memories to the shared branch" and exits 0 with the file staged and uncommitted. The bash exited 128 and turned mcs sync red. Same shape from the unchecked sparse-checkout set and the unchecked git add in the Stop hook.
4. The suite cannot pass on macOS. 37 of 200 fail on a clean checkout, from four harness portability bugs: the /private realpath prefix, ls -A | sort collation differing between glibc and BSD, one golden with the recorder's username baked in, and unscrubbed short SHAs. None is behavioural drift, which is good news about the port and bad news about the goldens, since it means they were recorded somewhere this pack cannot run. GitHub Actions has still never run on this PR, so none of it has been seen.
Not in the diff, so no inline thread
commands/approve-memories.md:17 still says "keep in sync with hooks/memories_autopush.sh allowed_pattern, hooks/memories_announce.sh regex, and scripts/configure-memories.sh allowed_pattern". None of those holds the pattern now, two are gone, and the third is deleted by this PR. The single definition is runtime/lib/naming.ts. This file ships to consumers and Claude reads it when someone runs /approve-memories.
manifest.test.ts has a test called "no stale keep-in-sync comments survive in the TypeScript", written for exactly this, and it scans runtime/lib, runtime/hooks and scripts only. It misses commands/, which is the one place a stale chain actually survived, and its sibling test in the same describe block already opens that file.
Coverage notes worth acting on
Merged V8 coverage across all child processes puts several things at zero: failOpen's catch (delete the try/catch and the suite stays green), the entire restore-on-failure path in the configure script, the Case-1 offline-safe short-circuit (moving preflight ahead of it is green), and push.code !== 1, which is the classification #4 landed. git push --quiet changed to --force survives all 70 hook fixtures, because gitState ignores its project argument, captures no file contents and never inspects the remote.
Also flagged inline, lower priority: the -1 sentinel in GitRun.code escaping into process.exit, ModeResult admitting impossible states, the addedModified projection stored beside its source, the unsound p.length >= 3 predicate, a trailing-newline divergence in announce.ts that loses the review-mode nudge, and a genuine harness flake from a cpSync that is now vestigial.
I have not run mcs sync, so the install path is still unexercised end to end. That remains the right thing to check on a Mac once the interpreter question is settled.
| hook: | ||
| source: hooks/memories_pull.sh | ||
| destination: memories_pull.sh | ||
| source: runtime/pull.ts |
There was a problem hiding this comment.
I validated this against the interpreter work at ~/Developer/mcs (bruno/hook-interpreters), not just the released binary, and the hooks land on bash either way.
On released 2026.9.2 the prefix is hardcoded (strings $(which mcs) gives bash .claude/hooks/ and nothing else), so the registered command is bash .claude/hooks/shared-memories/pull.ts:
$ bash runtime/pull.ts
runtime/pull.ts: line 2: import: command not found
runtime/pull.ts: line 11: syntax error near unexpected token `NAME,'
exit=2
Every SessionStart, Stop and Write/Edit then exits non-zero, so the pack fails closed on every event.
On the interpreter branch it is still bash, because .ts is deliberately excluded from inference. HookInterpreter.swift puts ts, mts, cts and tsx in ambiguousExtensions, on the grounds that .ts could mean tsx, bun, deno or node with --experimental-strip-types, and guessing wrong is worse than falling back with a warning. mcs pack validate from that build gives one warning per hook saying exactly that.
Adding this line to each of the three hook components, beside hookEvent rather than inside hook:, makes validate pass clean:
hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarningThree tokens against a maxTokens of 8, and --disable-warning=ExperimentalWarning matches the permitted argument shape. The brew: node component already satisfies the companion check that warns when no component installs the named binary.
minMCSVersion also needs to name the release that ships hookInterpreter. It is currently 2026.4.12, and every mcs before the interpreter release ignores the field silently, which puts the pack straight back on bash.
There was a problem hiding this comment.
Update: the interpreter support ships in 2026.9.3, so this is now two manifest lines rather than an open question.
minMCSVersion: "2026.9.3"plus hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning on each of the three hook components.
I checked what the version gate actually buys, and it is more than documentation. ExternalPackLoader throws incompatibleVersion before anything installs, so with minMCSVersion: "2026.9.3" set and the 2026.9.2 build running:
[ERROR] Invalid pack: Pack 'shared-memories' requires mcs >= 2026.9.3, current is 2026.9.2
0 passed 0 warnings 1 issues
That is the outcome you want. Without the gate, an older mcs ignores hookInterpreter as an unknown key and runs the hooks under bash with no diagnostic anywhere.
Two things worth knowing when you make the change.
The two lines have to land together. hookInterpreter on its own falls back to bash on 2026.9.2 and earlier. The gate on its own refuses to install on older mcs and then still runs under bash on newer, because nothing declares the interpreter. Neither half is useful alone.
Keep the value parseable. VersionCompare wants three numeric components and treats anything it cannot parse as incompatible, so a typo in minMCSVersion makes the pack refuse to load for everyone rather than degrading. CalVer is fine, 2026.9.3 parses as 2026/9/3 and compares the way you would expect.
On sequencing. If this merges before 2026.9.3 is out, anyone who syncs in the gap gets the refusal above and cannot install the pack until they upgrade. That is the safe direction compared to hooks that fail on every event, but it is a real window, so it is worth landing this close to the release rather than well ahead of it.
There was a problem hiding this comment.
Done, and thanks for checking it against the branch rather than just the released binary. I had only read Constants.HookCommand.projectPrefix on main, so I knew the released path was bash, but I would have guessed .ts was inferable and shipped something that silently fell back.
All three components now carry:
hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarningSuperseded by your 2026.9.3 update, replying there on the version gate.
There was a problem hiding this comment.
Both lines are in: minMCSVersion: "2026.9.3" plus hookInterpreter on each of the three hooks. A test asserts they travel together, since either alone is worse than useless.
One thing worth flagging back, because it caught me while fixing the module-type issue in your other thread. I renamed the runtime to .mts so module type stops depending on the consumer project, and mts is in ambiguousExtensions too, so that rename does not remove the need for hookInterpreter. The two threads read as alternatives if skimmed. They are independent and both are required.
On sequencing, agreed and no objection. I checked and 2026.9.2 is the latest tag today, so this cannot merge before the release without giving anyone who syncs in the gap a hard refusal. Your call entirely; I would rather refuse than run under bash on every event.
| type: configuration | ||
| installAction: | ||
| type: copyPackFile | ||
| source: runtime/lib |
There was a problem hiding this comment.
Two things about this destination.
No package.json ships with the lib. It lands at .claude/hooks/shared-memories/lib/, so Node walks up past .claude/ into the consumer's project to decide CommonJS or ESM. Both outcomes reproduce on Node 26 in this layout.
Consumer package.json with "type": "commonjs":
SyntaxError: Cannot use import statement outside a module
exit=1
Consumer package.json with no "type" field, which is the more common case:
[MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file://.../pull.ts is not specified
and it doesn't parse as CommonJS. Reparsing as ES module because module syntax was detected.
That prints on stderr on every hook run, and --disable-warning=ExperimentalWarning does not cover it because it is a different warning name. Declaring hookInterpreter does not help either, since node --experimental-strip-types <path> resolves module type exactly the way the shebang does.
Worth knowing when you fix it: a package.json inside lib/ does nothing, because it is a child of the entry point rather than an ancestor. I tested three placements. {"type":"module"} at hooks/shared-memories/package.json works, and renaming the runtime to .mts works with no package.json at all, since .mts is unconditionally ESM. Either also drops the reliance on Node's syntax detection, which was not the default before 22.7.
The suite cannot see this: makeProject creates no ancestor package.json, so it exercises one of the three consumer states and never the one that breaks.
The directory has two sources of truth. The shared-memories/ segment in the hook entry points comes from mcs, not from this manifest: DestinationCollisionResolver phase 0 always namespaces hooks into <pack-id>/, derived from pack.identifier. This component hardcodes the same segment with fileType: generic. They agree today and stop agreeing if the identifier changes. Phase 1b can also split them at sync time: a pre-existing untracked file at .claude/hooks/shared-memories/lib makes mcs retarget this to shared-memories/hooks/shared-memories/lib. It warns when it does, but the hooks then fail at import time rather than install time.
There was a problem hiding this comment.
Confirmed and fixed. This was the one that would have hurt.
Reproduced exactly as you describe on Node 26, in the installed layout:
consumer "type": "commonjs" SyntaxError: Cannot use import statement outside a module, exit 1
consumer with no "type" MODULE_TYPELESS_PACKAGE_JSON on stderr, every hook, every run
no package.json at all works
I went with .mts over shipping a package.json. Both work, and I tested both, but the package.json fix reintroduces the ancestor lookup and would break again the moment that file gets separated from the entries, which is exactly the phase 1b retarget you describe below. .mts makes module type a property of the file.
Your point about the suite being structurally unable to see it was the useful half. makeProject now writes {"type":"commonjs"} at the fixture project root, the hostile case, so all seventy hook fixtures run under it. Renaming one entry back to .ts fails 28 tests; before this it failed none.
On the two sources of truth for shared-memories/: still true, and I have not fixed it. The generic destination has to hardcode the segment mcs derives from pack.identifier, because the entries import ./lib/... and that has to resolve the same in the pack checkout and once installed. A manifest test now asserts the destination tracks the identifier, so renaming the pack fails loudly instead of at import time. That does not help with the phase 1b retarget, which I have no way to detect from this side.
| @@ -0,0 +1,58 @@ | |||
| #!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning | |||
There was a problem hiding this comment.
The shims carried command -v node >/dev/null || { echo "...: node not found; skipping" >&2; exit 0; }, and the comment above that line called it load-bearing. With the shims gone there is no interpreter guard left, so a missing or too-old node exits 127 or 9 and the hook fails closed.
Related: --experimental-strip-types arrived in Node 22.6, but engines says >=22 and the README says "Node.js 22 or newer". On 22.0 through 22.5 node dies on the unknown flag. CI's node: '22' resolves to current 22.x, so the matrix cannot reach that range. Worth raising the floor to 22.6 in engines, the README and the node component description.
There was a problem hiding this comment.
Right on both, and the guard is genuinely gone. There is no way to fail open on a missing interpreter once the shebang is what selects it: no Node means the exec fails and the hook exits non-zero, where the shim printed a skip line and exited 0. The manifest declaring node as a required dependency is the only mitigation left, same as it was for jq. If mcs wants hooks to stay fail-open on a missing interpreter, swallowing a non-zero hook exit is the place, and I would rather that lived there than in a wrapper per pack.
Node floor raised to 22.6 in engines, the README and the node component. CI pins 22.6.0 as an explicit matrix entry alongside 22 and 24, and the version step now asserts the runtime can actually strip types instead of echoing its version.
| say(" /approve-memories audit cleanup"); | ||
| } | ||
| const stageable = [...new Set([...pending.addedModified, ...pending.untracked])].filter(Boolean).sort(); | ||
| for (const f of stageable) git(repo, ["add", "--", f]); |
There was a problem hiding this comment.
Neither this git add nor the -A one on line 99 checks its result, and git() pipes stderr into a struct nobody reads, so git's own message is destroyed too. Control reaches the diff --cached --quiet below, which reports nothing staged, committed stays false, unpushed is still 0, and the hook returns having printed nothing at all.
With a stale index.lock in the memories repo, one new memory pending, auto mode:
this branch: (no output) exit 0, not pushed
main: fatal: Unable to create '.../.git/index.lock': File exists.
memories_autopush: aborted (rc=128) at line 322: git ... add -- "$f"
exit 0, not pushed
The bash had these as bare commands under set -e, so a failure printed git's message plus the trap line. This is not a faithful port of a || true. The SessionStart net does fire next session, but it prints the generic "check SSH auth (ssh-add), network, or file naming", which names none of the real causes (locked index, read-only index, ENOSPC, a path outside the sparse cone). For a transient lock that is tolerable. For a persistent one the engineer hunts SSH keys while git add fails every turn with a message the hook threw away.
Cheapest fix is inheritStderr: true on both calls so git at least speaks. Better is to count the failures and print once: "Shared memories: could not stage N file(s); will retry on next Stop."
There was a problem hiding this comment.
Fixed, and the failure mode was worse than not printing. Control fell through to diff --cached --quiet, which correctly reported nothing staged, so committed stayed false and the hook returned having said nothing and pushed nothing. Exactly the silent stop this pack exists to prevent.
Staging goes through a helper now that passes inheritStderr so git speaks, then throws, which failOpen turns into exit 0 with a diagnostic. That is what the trap did.
There is a fixture for it: a stale index.lock with one memory pending. It also turned out to be the first fixture that reaches failOpen, so the catch you flagged as uncovered in the hook-io thread is covered by the same change. Deleting the try/catch now fails.
| if (filePath === null || filePath === false) continue; | ||
| out.push(render(filePath)); | ||
| } | ||
| return lastErrored ? null : out.join("\n"); |
There was a problem hiding this comment.
filePaths() never strips a trailing newline, where the bash's file_path=$(...) stripped all of them. Verified against the bash hook with MEMORIES_AUTOPUSH_MODE=review:
{"tool_input":{"file_path":"/p/.claude/memories/learning_a_b.md\n"}}
bash: emits the full additionalContext this branch: emits nothing
{"tool_input":{"file_path":"...learning_a_b.md"}} {"tool_input":{"file_path":""}}
same divergence (jq -r prints one newline per result, so an empty last result adds a blank line)
Review mode loses the Claude-visible nudge, which is the whole job of this hook. One .replace(/\n+$/, "") restores parity. No fixture uses a file_path with a trailing newline or an empty-string file_path.
There was a problem hiding this comment.
Confirmed on both examples and fixed with the .replace(/\n+$/, "") you suggested.
Worth recording that my first attempt to reproduce this disagreed with you and I was wrong, not you. I used printf %b, which put a raw newline inside the JSON string and made the payload invalid, so jq bailed and both sides went silent. With the escape left intact jq emits ...md\n\n and $() strips both, which is your result.
Two fixtures, goldens recorded from the bash: a file_path with a trailing newline, and a valid path followed by an empty-string result.
| * machine, day or temp path is replaced by a placeholder. Everything else | ||
| * stays byte-exact. | ||
| */ | ||
| export function normalizeRun(r: RunResult, root: string): RunResult { |
There was a problem hiding this comment.
normalizeRun is why 37 of 200 tests fail on macOS, which is the only platform this pack installs on and the only one CI runs. All of it is harness portability and none of it is behavioural drift, but it does mean the goldens were recorded somewhere the pack cannot run.
Three gaps.
root comes from mkdtempSync(tmpdir()), which macOS realpaths to /private/var/..., so paths in the output never match the scrub. Pointing TMPDIR at a non-symlinked directory drops the failures from 37 to 19, which isolates this to 18 of them. realpathSync on the root at creation fixes it.
scrub normalizes the hostname but not the OS username, so tests/golden/edge-a-commit-failure-is-reported-and-retried-next-stop.json carries got 'brenno@<HOST>.(none)' and fails for everyone else, CI included.
Abbreviated SHAs are not scrubbed either. audited-twenty-one-previously-deleted-files-straddle-the-reason-cap.json pins deleted by cd4c2ef, and a different global git config is enough to change the fixture SHAs.
While you are in here: the spawns pass {...process.env} with no GIT_CONFIG_GLOBAL=/dev/null, no GIT_CONFIG_SYSTEM=/dev/null, no explicit -c user.name / -c user.email and no LC_ALL. A global core.hooksPath with a failing pre-commit fails 21 of 25 configure tests, and five goldens embed English git prose verbatim, so a translated git breaks them. push.ts:32 pins LC_ALL=C for the one call whose output it greps, and the harness could use the same discipline for output it compares byte for byte.
There was a problem hiding this comment.
All three fixed, and you were right that this was the real news: the goldens were recorded somewhere the pack cannot run.
mkdtempSync is wrapped in realpathSync, so the /private prefix stops defeating the scrub. normalizeRun scrubs the OS username and abbreviated SHAs as well as the hostname; the two goldens you named no longer carry my account name or cd4c2ef.
On the git config point, spawns now pin GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM and a fixture identity, so a global core.hooksPath cannot reach the tests.
I did not pin LC_ALL, and I want to flag why, because it was my first instinct too. LC_ALL=C switches bash ${var:0:80} from characters to bytes, so it moves the reference implementation rather than stabilising it. The two truncation fixtures failed the moment I set it, which is what they are for. The English-git-prose exposure you mention is real and still there; I did not find a portable way to pin messages without also pinning collation and character semantics.
Goldens re-recorded from the bash with the corrected harness. Twenty-one changed: twenty listing order, two SHAs, one username. Nothing else moved, which I checked by classifying every diff rather than eyeballing the count.
| `unpushed: ${upstream ? git(repo, "rev-list", "@{u}..HEAD", "--count") : "0"}`, | ||
| `tracked: ${git(repo, "ls-files", "--", "memories/").split("\n").filter(Boolean).sort().join(" | ")}`, | ||
| `review-shown: ${existsSync(join(repo, ".review-shown")) ? "present" : "absent"}`, | ||
| `worktree: ${execFileSync("bash", ["-c", `ls -A "${repo}/memories" 2>/dev/null | sort | tr '\\n' ' '`], { encoding: "utf8" }).trim()}`, |
There was a problem hiding this comment.
This snapshot and the one in runConfigure both shell out to ls -A | sort, and the recorded order is not reproducible on macOS. Twelve configure goldens hold "git": "memories .memories-repo ...", which is glibc collation ignoring the leading dot. BSD sort gives the opposite byte order in every locale I tried (C, C.UTF-8, en_US.UTF-8, en_GB.UTF-8, de_DE.UTF-8):
actual: '.memories-repo memories \nlearning_shared_seed.md'
expected: 'memories .memories-repo \nlearning_shared_seed.md'
No path is involved, so the realpath issue above does not explain these. This alone accounts for the 17 configure failures that survive a non-symlinked TMPDIR. readdirSync().sort() in Node removes the collation dependence and the shell-out together.
The same shape shows up in the product, though only cosmetically: badNames and stageable use JS .sort() where the bash used sort -u, and under en_US.UTF-8 those disagree (learning_a_b.md before learning_A_b.md in the shell, the reverse in JS). It only reorders the bad-filename warning list, but it does mean "byte-identical" holds in the recorder's locale rather than in general, and the goldens cannot detect it.
There was a problem hiding this comment.
Fixed with readdirSync().sort() in both snapshots, which takes the collation dependence and the shell-out together. The harness no longer spawns a shell at all. Twenty of the twenty-one golden diffs from the re-record were this.
The product-side half you flagged is still there and I left it deliberately. badNames and stageable use JS .sort() where the bash used sort -u, so under a UTF-8 locale they disagree on case ordering. It only reorders the offender list in the guardrail warning, and matching sort -u collation from JS is not something I can do portably. Worth knowing that "byte-identical" holds in the recorder locale rather than in general, which is your point and I would rather it were written down than implied.
| try { | ||
| fx.setup?.(repo, project); | ||
| const pristine = join(root, "pristine"); | ||
| cpSync(work, pristine, { recursive: true, verbatimSymlinks: true }); |
There was a problem hiding this comment.
This cpSync produces a real flake. Three occurrences in about seven runs, each on a different fixture, each unrelated to the code under test:
Error: ENOENT, No such file or directory '.../pristine/project/.claude/.memories-repo/.git/objects'
at cpSync (node:fs:3823:3)
at runHook (tests/harness.ts:192:3)
gc.autoDetach and maintenance.auto both default on, so the git commit and git push in makeProject leave a detached maintenance process mutating .git/objects while cpSync walks it. Another consequence of not pinning git config.
The fix is free, because the copy is vestigial now that the bash is gone: pristine is written, read once, and never touched again, so the write, remove and restore round trip is a no-op. Deleting it removes the flake and a chunk of runtime.
The doc comment just above still describes the two-run design ("Both implementations run in the SAME directory, sequentially, restoring the tree in between"), and differential.test.ts's eight "bash and TypeScript agree" describe names read the same way. The header at the top of this file states it correctly.
There was a problem hiding this comment.
Fixed by deleting it, for the reason you give: once only one implementation runs, pristine is written, read once, and never touched again, so the whole round trip is dead weight. That takes the flake with it. I had not connected it to gc.autoDetach walking .git/objects underneath the copy, which explains why it moved between fixtures.
The stale doc comment above it is gone, and the eight describe names now read "behaviour is pinned" rather than "bash and TypeScript agree", which stopped being true when the bash was deleted.
| cpSync(join(REPO, "runtime", "lib"), join(hooks, "lib"), { recursive: true }); | ||
| } | ||
|
|
||
| /** Executed directly, the way mcs runs a hook whose shebang selects the interpreter. */ |
There was a problem hiding this comment.
This comment describes how a future mcs will run hooks, not how any released one does. Current mcs invokes them as bash <path>, and even the interpreter branch resolves .ts to bash unless the component declares hookInterpreter (see the techpack.yaml thread). So the suite asserts a contract no shipped mcs implements, and it is the only thing standing between the pack and the fail-closed behaviour described there. Worth saying so here, and naming the mcs version the direct-execution path depends on.
There was a problem hiding this comment.
Fair, and it is now true rather than aspirational: with hookInterpreter and the minMCSVersion gate in place, the contract the suite asserts is the one the pack refuses to install without.
I did not add the version to the comment. It would be a fourth place naming 2026.9.3 after the manifest, the README and the manifest test, and the one that nothing checks. The comment says the shebang selects the interpreter and the manifest is what makes mcs honour it, which points at the file that has to stay right.
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| node: ['22', '24'] |
There was a problem hiding this comment.
Worth adding 22.6.0 as an explicit matrix entry, or removing the floor question by pinning engines to >=22.6. node: '22' resolves to current 22.x, so the matrix cannot reach the 22.0 to 22.5 range where --experimental-strip-types does not exist.
Two smaller notes on this file. The "tools the shims rely on are present" step asserts git, which the hooks call from TypeScript rather than from a shim, and only echoes node's version without asserting anything about it. Now that the interpreter is the thing in question, node is what is worth asserting. And the hostname equivalence check guards a one-time migration premise that normalizeRun scrubs on both sides anyway, so it can never fail. Fine to keep, but a comment saying it is a migration artifact would stop it reading as permanently load-bearing.
There was a problem hiding this comment.
Done. 22.6.0 is an explicit matrix entry now, alongside 22 and 24, and engines plus the README and the node component description all say 22.6.
The step is rewritten too. It was asserting git, which the hooks call from TypeScript rather than from a shim, and only echoing node version. It now asserts the runtime can actually strip types and fails with the version if not, which is the thing in question.
Hostname check kept with a comment saying it is a migration artifact. You are right that normalizeRun scrubs both sides so it cannot fail; it pinned the premise that commit subjects were unchanged by the port, and that premise has served its purpose.
The entry points and library installed to `.claude/hooks/shared-memories/`, and Node resolves module type by walking up from a file to the nearest `package.json`. That walk leaves `.claude/` and lands in the consumer's own project, so the pack inherited whatever that project declared. Two of the three possible states were broken:
consumer "type": "commonjs" SyntaxError: Cannot use import statement outside a module, exit 1
consumer with no "type" MODULE_TYPELESS_PACKAGE_JSON warning on stderr, every hook, every run
no package.json at all works
Fail-closed hooks are the one outcome this pack exists to avoid, and the first state produced them on every SessionStart, Stop and Write.
`.mts` is unconditionally ESM, so module type is a property of the file rather than of wherever it was copied to. Shipping a `{"type":"module"}` beside the entries also works and was the alternative, but it reintroduces the dependence on an ancestor lookup and would break again if that file were ever separated from the entries.
The suite could not see any of this: `makeProject` created no ancestor `package.json`, so it only exercised the third state. It now writes `{"type":"commonjs"}` at the fixture project root — the hostile case — so all seventy hook fixtures run under it. Renaming a single entry back to `.ts` now fails 28 tests; before this it failed none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`code: r.status ?? -1` collapsed three outcomes into one number. A missing binary and a signalled process both arrive as `status: null` and became `-1`, which `configure-memories.ts` then handed to `process.exit`, where the shell reads it as 255 and it collides with git's own 255 by two's-complement accident. `GitRun` now carries `exit: number | null` for the process's own status and `failure: string | null` for the errno or signal that explains its absence. The clone handler picks a real code, and the two places that render `error:` have something to print when stderr is empty because nothing ran. They still print nothing when stderr is legitimately empty, which is what `ls-remote` against a missing branch does, and what the goldens pin. `gitPresent()` tested `error === undefined`, which is false for a git that ran and was then killed, so the module held two disagreeing notions of whether git exists. It now asks the question `command -v git` asked: is the binary there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three calls threw away their result, and each turned a failure into a success story. The migration commit is the worst. When it fails the push below still succeeds, vacuously, because there is nothing to push; the success branch then prints "Pushed migrated memories to the shared branch" and exits 0 with the file staged and uncommitted. A fresh machine with no git identity is exactly the state `configureProject` first runs in, and the bash exited 128 there and turned `mcs sync` red. `sparse-checkout set` is the call that materialises `memories/`, since `clone --sparse` checks out root files only. Unchecked, the `mkdir` two lines below creates the very directory whose absence `linkMemories()`'s dangling-link guard exists to catch, so setup reports success with none of the team's memories on disk. It restores the backup and exits, mirroring the clone handler. The migration `git add` is the same shape, one loop earlier. Separately, `statSync` follows symlinks and throws on a dangling one, where the bash's `[ -f "$f" ] || continue` was merely false. One broken link in an engineer's existing `.claude/memories` aborted the whole import, and because the symlink then exists and resolves, the next sync takes the healthy path and never retries. Both are pinned by fixtures whose goldens were recorded from the bash: exit 128 with no push claimed, and the dangling link skipped with the rest imported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two separate losses, both in the direction this pack cannot afford. `announce` never stripped the trailing newline that `file_path=$(...)` did, and `jq -r` emits one newline per result. A `file_path` carrying a trailing newline, or any stream whose last result is an empty string, therefore ended with a newline that the pattern's `$` anchor rejected, and review mode silently lost the Claude-visible nudge, which is this hook's entire job. The Stop hook's `git add` discarded its result and piped git's stderr into a struct nobody read, so a locked index produced no output at all: control fell through to `diff --cached --quiet`, which correctly reported nothing staged, and the hook returned having said nothing and pushed nothing. The bash ran these bare under `set -e`, so a failure printed git's own message. Staging now lets git speak and aborts the turn, which `failOpen` turns into exit 0 with a diagnostic, matching what the trap did. The locked-index fixture is the first to exercise the abort path, so the harness gains a way to say that the abort diagnostic is a known stderr-only deviation: it now compares stderr with that one line removed from both sides, and asserts both sides had one. Everything outside it, including git's own message, must still match byte for byte. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The goldens were recorded somewhere this pack cannot run, and four harness bugs meant they could only ever pass there.
`mkdtempSync(tmpdir())` is realpathed now. macOS resolves the temp directory through `/private`, so the paths appearing in output never matched the scrub and every fixture that prints one failed.
`normalizeRun` scrubbed the hostname but not the OS username or abbreviated SHAs, so one golden carried the recorder's account name and another pinned a commit id that a different git config is enough to change.
Both directory snapshots shelled out to `ls -A | sort`, whose collation differs between glibc and BSD: twelve configure goldens recorded an order macOS does not produce. They use `readdirSync().sort()` now, which removes the collation dependence and the shell-out together, and the harness no longer spawns a shell at all.
Spawns pin `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM` and a fixture identity, so a developer's global config, including a `core.hooksPath` with a failing `pre-commit`, cannot reach the tests.
Locale is deliberately not pinned. `LC_ALL=C` was the obvious companion and is wrong here: it switches bash's `${var:0:80}` from characters to bytes, so it moves the reference implementation rather than stabilising it. The two truncation fixtures caught that, which is what they are for.
Finally, the `pristine` copy in `runHook` was vestigial once only one implementation ran, and racing git's detached maintenance process while walking `.git/objects` made it flake. Deleting it removes the flake and a round trip.
Goldens re-recorded from the bash with the corrected harness. Twenty-one changed: SHAs and username scrubbed, and the listing order that was glibc-specific.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`gitState` captured only the local repository, so a fixture could not tell a push that landed from one that silently did not. It now records the remote-tracking log and file list alongside the local ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`commands/approve-memories.md` still named three files as the other homes of the guardrail pattern. Two no longer exist and the third is deleted by this branch, and this file ships to consumers, where Claude reads it whenever someone runs `/approve-memories`. It now points at the single definition. The test written to catch exactly this scanned only `runtime` and `scripts`, so it missed the one place a stale chain survived; it covers `commands/` and `templates/` now, and reintroducing the old text fails it.
`ModeResult` was a product type, so `{ mode: "review", unrecognised: "banana" }` compiled and the rule that an unrecognised value implies `auto` lived only in the function body. It is a discriminated union now. The field is `unrecognised` rather than `unknown`, which collided with the type of the same name and read like a boolean, and the mode vocabulary is a single `MODES` tuple that the SessionStart warning builds its list from, so adding a mode cannot leave that message lying.
`Pending.addedModified` was a projection of `numstats` stored beside its source, and the two were already read inconsistently: the report took its header count from one and its rows from the other. It is derived by `modifiedPaths()` now, and the arrays are `readonly`.
`p.length >= 3` claimed a three-tuple it had not checked, which a tab in a path under `core.quotePath=false` would have falsified. `=== 3` is honest and costs nothing.
`paths.mts` described the layout from before the shims were dropped, and `gitState` took a `project` argument it never used.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.mts` is in mcs's `ambiguousExtensions`, so even the interpreter branch resolves these hooks to bash without being told otherwise. Each hook component now carries the interpreter, and `minMCSVersion` names the release that reads the field. The two lines only work together. `hookInterpreter` alone is ignored as an unknown key by older mcs and the hooks run under bash with no diagnostic; the gate alone refuses to install on older mcs and then still runs under bash on newer, because nothing declares an interpreter. With both, `ExternalPackLoader` refuses before anything installs and says why. Worth knowing about sequencing: 2026.9.3 is not released yet, so anyone syncing between this merging and that shipping gets a refusal rather than an install. That is the safe direction against hooks that fail on every event, but it is a real window. Also raises the Node floor to 22.6, where `--experimental-strip-types` landed. `engines` said `>=22` and the README said 22 or newer, while 22.0 through 22.5 die on the unknown flag; a bare `'22'` in the matrix resolves to current 22.x and could never reach that range, so CI pins `22.6.0` explicitly and now asserts the runtime can strip types rather than echoing its version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`process.exit(0)` does not wait for an async pipe write, so a long review report could be cut off mid-stream. `process.exitCode = 0` with a normal return gives the same status and lets the write finish. A 900-file review report now arrives whole, 1806 lines ending on the last line rather than wherever the buffer stopped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for this. I verified every claim against the code before acting on it rather than taking them on trust, and I could not find one that was wrong. Two of my own spot-checks were wrong before yours were, which is noted in the relevant threads. All four blocking items are addressed, plus the two out-of-diff findings and every inline note. Replies are on each thread; the short version: 1. Hooks will not run. 2. No 3. A push that never happened. Confirmed by differential, bash exits 128 where this branch exited 0 and claimed the push. The commit, the 4. The suite cannot pass on macOS. All four causes fixed: realpath on the temp root, username and abbreviated SHAs scrubbed, Goldens re-recorded from the bash with the corrected harness. Twenty-one changed, and I classified every diff rather than counting them: twenty listing order, two SHAs, one username. Out of diff. Coverage. One thing I did not do. The push exit-code classification. It is inherited from 206 tests, typecheck clean. GitHub Actions still has not run, since a fork PR from a first-time contributor needs a maintainer to approve the workflow. |
bguidolim
left a comment
There was a problem hiding this comment.
Approving. The four blockers from last round are closed, and I checked each one rather than taking it on trust: hookInterpreter on all three components plus minMCSVersion: "2026.9.3" (pack validate clean against a build that knows the field), the .mts rename holding on 22.6.0 both with no ancestor package.json and under a hostile {"type":"commonjs"} one, migration add and commit covered by fixture and golden, 206/206 on macOS.
One inline note. It's the only blocking thing, and it makes CI red rather than affecting anyone's install.
The rest is edge-case or cosmetic and fine for a later pass: failOpen can print an EPIPE stack trace if a reader closes early; the .review-shown read fails closed where the bash failed open; git() has no maxBuffer; the harness doesn't pin LC_ALL (25 of 206 fail under a German locale, 206/206 under LC_ALL=C); some docs still say a hook's shebang picks its interpreter; and differential.test.ts's "scalar precedes a valid path" name and comment contradict its own golden.
On your push.exit !== 1 question: it is constructible with remote.origin.pushurl. A valid fetch url with a broken pushurl gives pull --rebase exit 0 and push exit 128. Keep the classification and add a fixture when convenient.
|
|
||
| test("every entry point parses under type stripping", () => { | ||
| for (const d of dests) { | ||
| execFileSync(process.execPath, ["--experimental-strip-types", "--check", join(REPO, "runtime", d)]); |
There was a problem hiding this comment.
This fails on 22.6.0, the floor the PR declares. --check there doesn't apply the .mts-implies-ESM mapping, so it parses the file as CommonJS and dies on the first import: SyntaxError: Cannot use import statement outside a module. All three entry points, so 205/206 on 22.6.0 against 206/206 on current Node. The hooks themselves are fine there (I ran all three, they exit 0 with the right fail-open diagnostics), only --check is broken. ci.yml is new in this PR so that leg has never run, and it goes red on the first run after merge.
--check is parse-only anyway: no type checking, no module resolution. Worth noting that tsconfig.json's include ends in .ts, so it matches no .mts and these three files aren't in the typecheck either. Adding runtime/**/*.mts to include puts them under tsc and makes this gate redundant rather than just deleted.
There was a problem hiding this comment.
Correcting myself on one line above: CI has run, and this leg is already red on this exact head. It runs on your fork, which is why nothing shows here.
Run 33629821602 on 0770a6bc: test (22.6.0) failure, test (22) and test (24) green, failing on not ok 6 - every entry point parses under type stripping with SyntaxError: Cannot use import statement outside a module, 205 of 206. The last green run was d7261e9, when the matrix was still ['22','24'], so every run since a1b78f5 added 22.6.0 has been red.
No check reaches this PR because it comes from a fork and mcs-cli produces no fork-PR workflow runs (no run, no check suite, not even one pending approval). That is an org-level Actions setting, and separate from this finding. The rest of the comment stands.
The `node --check` gate fails on 22.6.0, the floor this pack declares. There `--check` does not apply the `.mts`-implies-ESM mapping, so it reads each entry point as CommonJS and dies on the first import. The matrix has carried 22.6.0 since a1b78f5 and that leg has been red on every run since. `tsc` subsumes the gate -- it parses and then some -- but `include` ended in `.ts`, and a pattern ending in `.ts` matches no `.mts`. So the three entry points plus lib/paths.mts, which only they import, were outside the typecheck entirely: 4 of 11 runtime modules unchecked. Verified by injecting a type error into pull.mts, invisible before and caught after. The test now pins the include instead, so removing that line turns the suite red rather than silently dropping the coverage back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed The The missing four — The gate is pinned rather than dropped. CI on Left alone deliberately, per your "fine for a later pass": the The push dismissed your approval, so this needs another look when you have a moment. |
The bash read the hash as `tr -d '[:space:]' < "$f" 2>/dev/null || true`, so an unreadable state file meant no dedupe. The port read it with a bare readFileSync behind an existsSync, so any read error -- EISDIR, EACCES, or the file vanishing between the check and the read when a second session's Stop hook removes it -- escaped into failOpen and swallowed the whole review report. Fail-closed is the one outcome this pack has always avoided. lastShownHash sits beside preview(), which already has this shape, and dropping the existsSync closes the race rather than narrowing it: no hash is ever "", so an unreadable file cannot collide with a real one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two ways the fail-open contract leaked. EPIPE arrives as an asynchronous `error` event on the stream, not at the write, so failOpen's try/catch never saw it and Node's default handler printed a stack trace and exited non-zero. The catch's own stderr write could throw for the same reason, escaping the handler meant to be the last line of defence. `git()` inherited spawnSync's 1MB cap on captured output, where `$(git ...)` was bounded only by memory. Past the cap spawnSync kills git and reports ENOBUFS, which `gitOut` turns into "" -- a large enough `status --porcelain` reading as "nothing pending" and a Stop hook quietly doing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LC_ALL was deliberately unpinned while the bash was the reference: LC_ALL=C
switches ${var:0:80} from characters to bytes, so pinning it would have moved
the reference rather than steadied it. The bash is gone and no test executes it
any more, so all the locale still reaches is git's own prose and collation --
and five goldens quote git's English verbatim. Unpinned, 25 of 206 fail under a
German locale.
The spawns had the same 1MB capture cap as git(), where a truncated read would
corrupt a comparison rather than fail it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… exist Declaring hookInterpreter moved the choice off the shebang for hooks, and the docs still described the old arrangement -- including harness.ts claiming mcs "always runs hooks through bash", which is what this PR exists to stop. Pack scripts are the case where the shebang really does decide, so the distinction is now drawn rather than blurred. The README also still listed the hooks as pull.ts / autopush.ts / announce.ts, which no longer exist under those names since the .mts rename, and gave the CI matrix as 22/24 rather than 22.6/22/24. The shebang test is renamed to what it asserts: the shebang and the declared interpreter agree. Both still matter, just not the way the name implied. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n push "announce is silent when a scalar precedes a valid path" records the opposite: jq neither aborts on an erroring value nor reports one once a later value succeeds, so the leading 42 is skipped and the path IS announced. Its own golden has always held the announcement. The mirror fixture, where the scalar trails, is the silent one and was right all along. Renamed with its golden. The pushurl case: a valid fetch url with a broken remote.origin.pushurl gives `pull --rebase` exit 0 and `push` exit 128, which is the reachable construction for the `exit !== 1` branch that looked close to dead. Asserted directly rather than pinned to a golden -- every golden here is a recording of the bash, and the bash is gone, so a hand-written one would claim to be a recording it isn't. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Second round: the whole "later pass" list is in, five commits on top of
The interpreter docs were staler than the one line. The leading-scalar fixture is renamed with its golden. Its golden always held the announcement, so the name and comment were the wrong half; the trailing-scalar mirror was right all along. The pushurl fixture is the one judgment call I'd flag. Your construction reproduces exactly — Nothing here touches install behaviour. Ready for another look. |
bguidolim
left a comment
There was a problem hiding this comment.
Turned the four small things from the last pass into suggestions you can commit from here, plus the EPIPE test as a paste-in file. None of these block anything.
The README ones are documentation drift the interpreter change left behind. The manifest.test.ts one is the same test with the assertion moved off the pattern string. Both test suggestions are verified against this head, in both directions.
| | **autopush.mts** | `Stop` (async) | Dispatches by `MEMORIES_AUTOPUSH_MODE` mode (`auto` / `full` / `review`); filename guardrail applies in every mode | | ||
| | **announce.mts** | `PostToolUse` (Write/Edit/MultiEdit) | In `review` mode only, surfaces the just-written memory to Claude's context so it mentions pending review in chat. Silent in `auto` and `full` | | ||
|
|
||
| Each is executed directly by mcs, with `#!/usr/bin/env -S node --experimental-strip-types` selecting the interpreter. They install to `.claude/hooks/shared-memories/`, with the library they import beside them in `lib/`. |
There was a problem hiding this comment.
The shebang is not what selects the interpreter for hooks. mcs prefixes the declared hookInterpreter, which this PR added, and the declared command also carries --disable-warning=ExperimentalWarning that this line drops. The shebang still matters for pack scripts and for the test harness, just not here.
| Each is executed directly by mcs, with `#!/usr/bin/env -S node --experimental-strip-types` selecting the interpreter. They install to `.claude/hooks/shared-memories/`, with the library they import beside them in `lib/`. | |
| Each runs as `node --experimental-strip-types --disable-warning=ExperimentalWarning <path>`: mcs prefixes the interpreter the hook declares in `hookInterpreter`, and never looks at the shebang. They install to `.claude/hooks/shared-memories/`, with the library they import beside them in `lib/`. |
| │ ├── pull.mts # SessionStart: pull + stuck-state warning | ||
| │ ├── autopush.mts # Stop: auto-commit + push (async) | ||
| │ ├── announce.mts # PostToolUse: review-mode nudge to Claude (sync) |
There was a problem hiding this comment.
Cosmetic: these three carry one extra space before the #, so their comments sit a column right of every other line in the tree.
| │ ├── pull.mts # SessionStart: pull + stuck-state warning | |
| │ ├── autopush.mts # Stop: auto-commit + push (async) | |
| │ ├── announce.mts # PostToolUse: review-mode nudge to Claude (sync) | |
| │ ├── pull.mts # SessionStart: pull + stuck-state warning | |
| │ ├── autopush.mts # Stop: auto-commit + push (async) | |
| │ ├── announce.mts # PostToolUse: review-mode nudge to Claude (sync) |
|
|
||
| TypeScript run directly by Node: no build step, no runtime dependencies, no lockfile. `tsconfig.json` sets `erasableSyntaxOnly`, so the syntax stays strippable — no `enum`, no `namespace`, no constructor parameter properties. | ||
|
|
||
| **`tests/golden/` is the behaviour contract.** Each file pins what the pack produces for one fixture: stdout, stderr, exit code, and the resulting repository state. The suite builds a throwaway project with a real git remote, installs the hooks the way mcs does and executes them through their shebang, and compares. A diff therefore means the pack's behaviour changed, not that a test went stale. |
There was a problem hiding this comment.
"the way mcs does" is the claim tests/harness.ts was just corrected to deny: mcs runs hooks through the declared hookInterpreter, the harness runs them through their shebang. The two agree today only because manifest.test.ts pins them to the same command, which is worth saying instead.
| **`tests/golden/` is the behaviour contract.** Each file pins what the pack produces for one fixture: stdout, stderr, exit code, and the resulting repository state. The suite builds a throwaway project with a real git remote, installs the hooks the way mcs does and executes them through their shebang, and compares. A diff therefore means the pack's behaviour changed, not that a test went stale. | |
| **`tests/golden/` is the behaviour contract.** Each file pins what the pack produces for one fixture: stdout, stderr, exit code, and the resulting repository state. The suite builds a throwaway project with a real git remote, installs the hooks, runs them through their shebang (pinned by `tests/manifest.test.ts` to the same command the manifest declares), and compares. A diff therefore means the pack's behaviour changed, not that a test went stale. |
| 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 | ||
| // point as CommonJS and died on the first import. `tsc` subsumes it, but only | ||
| // if `include` reaches these files -- a pattern ending in `.ts` matches no | ||
| // `.mts`, which left all three plus lib/paths.mts unchecked entirely. | ||
| const include = JSON.parse(readFileSync(join(REPO, "tsconfig.json"), "utf8")).include as string[]; | ||
| assert.ok(include.includes("runtime/**/*.mts"), "tsconfig include does not reach the .mts entry points"); | ||
| }); |
There was a problem hiding this comment.
This pins the literal glob rather than the property it stands for. Broadening include to runtime/**/* keeps every file covered but fails this test, and renaming an entry point loses coverage while it still passes. Resolving the globs against what is actually in runtime/ holds in both directions.
I ran this against the branch: it passes as it stands, still passes with include rewritten to runtime/**/*, and fails with runtime/**/*.mts dropped, naming all eleven files it would leave unchecked. One wart survives either way, so it is worth knowing: JSON.parse breaks the moment someone puts a comment in tsconfig.json.
| 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 | |
| // point as CommonJS and died on the first import. `tsc` subsumes it, but only | |
| // if `include` reaches these files -- a pattern ending in `.ts` matches no | |
| // `.mts`, which left all three plus lib/paths.mts unchecked entirely. | |
| const include = JSON.parse(readFileSync(join(REPO, "tsconfig.json"), "utf8")).include as string[]; | |
| assert.ok(include.includes("runtime/**/*.mts"), "tsconfig include does not reach the .mts entry points"); | |
| }); | |
| 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 | |
| // point as CommonJS and died on the first import. `tsc` subsumes it, but only | |
| // if `include` reaches these files -- a pattern ending in `.ts` matches no | |
| // `.mts`, which left all three plus lib/paths.mts unchecked entirely. | |
| const include = JSON.parse(readFileSync(join(REPO, "tsconfig.json"), "utf8")).include as string[]; | |
| // Resolve the globs instead of pinning one pattern string: rewriting | |
| // `include`, or adding a runtime file, then still has to keep the coverage. | |
| const reaches = include.map( | |
| (p) => | |
| new RegExp( | |
| `^${p | |
| .split("/") | |
| .map((s) => (s === "**" ? "\0" : s.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*"))) | |
| .join("/") | |
| .replace(/\0\//g, "(?:[^/]+/)*")}$`, | |
| ), | |
| ); | |
| const unreachable = readdirSync(join(REPO, "runtime"), { recursive: true, encoding: "utf8" }) | |
| .filter((f) => f.endsWith(".ts") || f.endsWith(".mts")) | |
| .map((f) => `runtime/${f}`) | |
| .filter((f) => !reaches.some((re) => re.test(f))); | |
| assert.deepEqual(unreachable, [], `tsconfig include does not reach: ${unreachable.join(", ")}`); | |
| }); |
| // catch below cannot see it, and the default handler prints a stack trace and | ||
| // exits non-zero — precisely what this contract rules out. | ||
| process.stdout.on("error", () => {}); | ||
| process.stderr.on("error", () => {}); |
There was a problem hiding this comment.
These two listeners are the fix for a crash, and nothing in the suite would notice if they went away. No suggestion block for this one, because it needs a reader that closes before the hook writes, and that cannot be done through the spawnSync harness. As a new file it works:
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { chmodSync, cpSync, mkdirSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { makeProject } from "./harness.ts";
const REPO = dirname(dirname(fileURLToPath(import.meta.url)));
/** Runs a hook with its stdout reader gone before it writes: the EPIPE case. */
function runWithNoReader(hook: string, env: Record<string, string>, stdin: string) {
const { root, project } = makeProject();
const hooks = join(project, ".claude", "hooks", "shared-memories");
mkdirSync(hooks, { recursive: true });
cpSync(join(REPO, "runtime", hook), join(hooks, hook));
chmodSync(join(hooks, hook), 0o755);
cpSync(join(REPO, "runtime", "lib"), join(hooks, "lib"), { recursive: true });
return new Promise<{ code: number | null; signal: string | null; stderr: string }>((resolve) => {
const child = spawn(join(hooks, hook), [], {
cwd: project,
env: { ...process.env, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (d) => (stderr += d));
child.stdout.destroy();
child.stdin.end(stdin);
child.on("close", (code, signal) => {
rmSync(root, { recursive: true, force: true });
resolve({ code, signal, stderr });
});
});
}
describe("a hook whose reader has gone away", () => {
test("announce still exits 0 and reports no stack", async () => {
const r = await runWithNoReader(
"announce.mts",
{ MEMORIES_AUTOPUSH_MODE: "review" },
JSON.stringify({ tool_input: { file_path: "/p/.claude/memories/learning_a_b.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");
});
});Verified both ways on this branch: it passes as the branch stands, and with these two lines removed it fails with the unhandled write EPIPE and exit 1.
Applying review suggestions. The hooks section still said the shebang selects the interpreter and quoted a command missing --disable-warning=ExperimentalWarning; mcs prefixes the declared hookInterpreter and never reads the shebang. The golden-contract section claimed the suite installs hooks "the way mcs does", which is the assertion harness.ts was corrected to deny -- the harness runs the shebang, and the two agree only because manifest.test.ts pins them to the same command. The tree alignment was my own drift: .ts -> .mts widened three entries by a character and pushed their comments a column right of the rest. Co-Authored-By: Bruno Guidolim <987360+bguidolim@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applying review suggestions. The include test pinned the literal "runtime/**/*.mts" rather than the property it stood for, so it failed on a broadening rewrite to runtime/**/* and passed while a renamed entry point silently lost coverage. It now resolves each glob against what is actually in runtime/. Verified three ways: passes as it stands, passes under runtime/**/*, and fails naming all eleven files when the .mts pattern is dropped. The two stream listeners in failOpen were a fix for a crash that nothing could observe -- the EPIPE case needs a reader that closes before the hook writes, which the spawnSync harness cannot express. epipe.test.ts drives it with spawn and destroys stdout, and fails with an unhandled write EPIPE and exit 1 when those listeners are removed. Known wart, left as is: both this and the previous form break if anyone puts a comment in tsconfig.json, since it is parsed as strict JSON. Co-Authored-By: Bruno Guidolim <987360+bguidolim@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All five applied in I re-ran the The tree alignment was mine. Two notes, neither worth acting on:
The Still open from the previous round, and genuinely your call: whether the pushurl case should become a recorded golden on the grounds that the TypeScript is the reference now, rather than the direct assertion it is today. Nothing here touches install behaviour. |
None of these were left open because they were hard; they were left open because I was deferring decisions that were mine to make. The tsconfig.json read is JSONC-tolerant. `tsc` accepts comments and trailing commas there -- verified by adding both and typechecking clean -- so parsing it as strict JSON meant a legal config turned a coverage gate into a parse error naming neither the file nor the cause. stripJsonComments scans in one pass so `//`, `/*` and a trailing `,` inside a string literal are left alone, with unit tests for each of those and for the pack's own tsconfig. readdirSync yields the platform separator, so the unreachable-files message read `runtime/lib\git.mts` off macOS. Message-only, but a diagnostic that misreports paths is worth one split/join. The pushurl case stays a direct assertion, and the reasoning is now in the file rather than in a question on the PR. A golden would add exactly one thing over these assertions: git's error prose byte for byte. Five goldens already quote git's English and that is this suite's known fragility, so extending it to a sixth -- for a branch whose contract is an exit code -- buys nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Closed the three I'd left open, in The The separator normalisation is in too. A diagnostic that misreports paths is worth one The pushurl case stays a direct assertion, and that reasoning is now in the file instead of being a question aimed at you. A golden would add exactly one thing over the assertions: git's error prose, byte for byte. Five goldens already quote git's English and that is this suite's known fragility, so a sixth buys nothing for a branch whose contract is an exit code. If you disagree, say so and I'll record it — but you shouldn't have had to arbitrate that one. No open questions from me now. |
|
Thanks for your contribution. |
Replaces ~900 lines of bash with TypeScript run directly by Node, and adds the pack's first tests and CI.
Behaviour is byte-identical to the bash, except for two deliberate bug fixes listed under Behaviour changes. 136 golden files recorded from the original implementation enforce that.
The pack contains no shell. 11 runtime modules and 3 pack scripts, with no build step, no dependencies and no lockfile.
Why not bash
The pack's failure mode is not pushing, which nobody notices. Bash is unusually good at producing exactly that.
exit 0. Combined withset -eand anERRtrap, that makes a typo and a healthy no-op indistinguishable.keep in sync with .... One definition now, with a test pinning the slash command's documented pattern to it.jq,grepandlsactually do versus what the code looked like it did. All six are below.What replaces it: one runtime, one string encoding, one path convention, and a type checker with an opinion.
No build step
Hooks and scripts are
.tsfiles executed straight by Node:Node strips type annotations at load and runs the result. There is no compiler, no
dist/, nonode_modules— the repo is the artifact, which matters for a pack that mcs copies onto engineers' machines with no install step of its own.Why the flag is passed unconditionally. Type stripping landed in Node 22.6 behind
--experimental-strip-typesand became the default in 23.6. Passing it is required on Node 22 and a no-op on 24+, so one command string works across the supported range. It is not a deprecated or removed flag — verified on Node 26: the file runs identically with and without it, and the flag is still accepted. CI exercises both halves of this, Node 22 (where the flag does the work) and Node 24 (where it does nothing).--disable-warning=ExperimentalWarningexists because Node 22/23 print an experimental-feature warning to stderr on every run, which would pollute hook output and any stderr assertion. Node 24+ is silent regardless.The constraint this buys. Stripping erases types; it cannot transform syntax. So
enum,namespaceand constructor parameter properties are rejected at runtime — the loader throws on theenumkeyword rather than compiling it away.tsconfig.jsonsetserasableSyntaxOnly, which turns that runtime failure into a typecheck error, and CI runs the typecheck. Both were verified non-vacuous by injecting anenumand a type error and watching each fail.From the mcs side
Hooks here are TypeScript executed through their shebang, which needs the mcs change that stopped prefixing hook commands with
bash. This section is what that side has to provide.What mcs has to do, and all of it except the first line is already true today:
hookInterpreter, which each hook component declares..mtsis in mcs'sambiguousExtensions, so inference alone falls back to bash.minMCSVersionis set to2026.9.3, the release that reads the field; the two are useless apart, and a test asserts they travel together.ComponentExecutoralready chmodsfileType: hookto0o755.<pack-id>/—DestinationCollisionResolveralready does this unconditionally, and the entries depend on it: they resolve the project root three levels up from their own location.bash .claude/hooks/...command already required.Nothing else changes.
ScriptRunner.runalready executes pack scripts this way —arguments: [], chmod first, shebang honoured — soconfigureProject.scriptand theshellScriptdoctor checks in this PR need no mcs change at all.What the pack registers, three entries and one directory:
What that produces on disk, and why the layout is what it is:
flowchart TD subgraph pack["pack checkout"] A["runtime/autopush.mts<br/>imports ./lib/git.mts"] B["runtime/lib/*.mts"] end A -->|"hook: source/destination<br/>chmod 755, namespaced"| C B -->|"copyPackFile, fileType: generic"| D subgraph inst["<project>/.claude/hooks/shared-memories/"] C["autopush.mts · pull.mts · announce.mts"] D["lib/*.mts"] end C -.->|"./lib/git.mts resolves<br/>the same in both trees"| D C -->|"../../.. is the project root,<br/>the depth the shim had"| E["<project>/"]The entries sit beside their library in the pack and beside it once installed, so
./lib/...needs no build-time rewriting and the repo stays runnable in place.The shebang, identical on all three, and matching the declared interpreter:
Why
.mtsrather than.ts. Node resolves module type by walking up to the nearestpackage.json, and that walk leaves.claude/and lands in the consumer's own project. A consumer declaring"type": "commonjs"broke every hook withSyntaxErrorand exit 1; a typeless one printed a warning on stderr on every run..mtsis unconditionally ESM, so module type is a property of the file rather than of wherever it was copied to.On sequencing.
2026.9.3is not released yet, so anyone syncing between this merging and that shipping gets a hard refusal fromExternalPackLoaderrather than an install. That is the safe direction against hooks that fail on every event, but it is a real window and worth landing close to the release.One capability is lost with the shim. It opened with
command -v node || { echo "node not found; skipping" >&2; exit 0; }, so a missing interpreter degraded to a no-op exactly as the old missing-jqguard did. A shebang cannot do that: with no Node onPATHthe exec fails and the hook exits non-zero. The mitigation is the manifest declaringnodeas a required dependency, the same way it declaredjq— but if mcs wants hooks to stay fail-open, swallowing a hook's non-zero exit is the place to do it.How the behaviour contract is enforced
tests/golden/holds 136 recordings of what the bash produced for a given fixture: stdout, stderr, exit code, and the resulting repository state. The suite runs the TypeScript against them. A drift is therefore a change in what the pack does, not a stale test.Those recordings were produced by running both implementations against the same fixture — same directory, same git objects, restored in between — and requiring them to agree before the bash was deleted. Reproducing that check needs only the merge base:
git show main:hooks/memories_autopush.sh.Three properties make the goldens worth trusting:
expectpattern asserted against the recording, so a fixture where nothing happens fails rather than agreeing with an equally empty result.memories/. A snapshot scoped like the code under test is blind to the damage the-- memories/pathspec exists to prevent.%crrenders relative to now. Re-recording the whole suite a second time produced zero diffs against the first.Gate strength: 28 mutations across every module, each verified by content hash to have actually applied and to have been reverted exactly. Every one that is not analytically equivalent turns the suite red.
The defects this surfaced
Six places where the obvious port is wrong, because each depends on what a tool does rather than what the code says.
1.
jqas a validity gate is a stream parserJSON.parseis the obvious port and it is wrong. To jq, empty input is valid, whitespace-only is valid, and concatenated values ({}{}) are valid;JSON.parsethrows on all three. Since that gate decides whether the hook works at all, the naive port converts empty stdin → do the work into exit 0 silently. The port emulates the stream semantics, checked against real jq over 23 inputs.2.
jqdoes not stop at an erroring value, and its status reflects only the last oneA bad value before a good one still announces; a bad value after one goes silent:
3.
// emptydropsfalse, andjq -rpretty-prints non-stringsa // btreatsnullandfalseas absent. Andjq -rrenders a non-string result as multi-line JSON —String(v)would give[object Object]where jq gives{\n "x": 1\n}:4.
grep's[[:space:]]includes CRThe review report previews the first non-blank line of a memory. In a CRLF file that line is
\r— blank togrep, not blank to/^[\t ]*$/:5.
${var:0:80}counts characters;String.slicecounts UTF-16 code unitsIdentical for
é, off by half for anything astral:6.
git clonewrites progress to stderr, and a pipedspawnSyncswallows itThe bash leaves the clone unredirected, so
Cloning into '...'reaches the user.git()now takes an explicit option so the calls the bash left alone still speak:Two bugs in the bash, now fixed
These were reproduced faithfully while parity was the working constraint, then fixed. Each is marked as a
deviationon its fixture: the golden still records what the bash did, so the departure is visible in the test rather than edited into the recorded data, and adeviationidentical to its golden is rejected so the marker cannot go stale.A first install reported failure. The bash ended with a summary line that could not survive an empty directory:
Under
set -euo pipefailan unmatched glob stays literal,lsexits 2,2>/dev/nullhides the message but not the status,pipefailpromotes that 2 overwcandtr, andset -ekills the script. Somcs syncagainst a branch with nomemories/tree yet did all its work correctly, then died silently with status 2 and no "Done." line — the moment a new adopter is watching most closely.const mds = entries(link).filter((f) => !f.startsWith(".") && f.endsWith(".md")); -if (mds.length === 0) process.exit(2); out(`Done. ${mds.length} memory file(s) available at ${link}.`);Zero memories is a normal bootstrap:
Done. 0 memory file(s), exit 0. The dotfile exclusion the*.mdglob implied is kept.An unset
MCS_PROJECT_PATHtargeted the filesystem root. Paths were built by concatenation:memories_link="$MCS_PROJECT_PATH/.claude/memories"Empty, that resolves to
/.claude/memories, and the script tries to clone into the filesystem root — failing deep inside git rather than saying what was wrong. It now fails fast, beside the existingMEMORIES_REPO_URLguard:Falling back to
process.cwd()was the alternative and is wrong here. The doctor scripts do that, because their bash read${MCS_PROJECT_PATH:-$PWD}and they only read. This script clones, moves directories and creates symlinks — silently configuring whichever directory the caller stood in is worse than refusing. Copilot flagged this same line independently and suggested the cwd fallback; that is the reasoning for the guard instead.Behaviour changes in full
Incidental — two, both stderr-only. No stdout byte, exit code, or git side effect changes.
nodeinstead ofjq.$LINENOand$BASH_COMMANDhave notry/catchequivalent.Deliberate — the two bug fixes above.
Everything else is byte-identical, enforced by the goldens.
Coverage the mutations forced
Four places where the suite was green and proving nothing, each now covered:
-- memories/pathspecgit add -Achanged nothing, because the state snapshot was also scoped tomemories/. Fixed by widening the snapshot and adding a fixture with a dirty root-levelREADME.md— the documented promise that teammates can edit the memories repo's README safely.pull --ff-onlyentirely kept the suite green: no fixture had the remote ahead, so the pack's core job had no test.*.mdcount filter.mdfiles, so counting all entries was indistinguishable.What ships
tsconfig.jsonis strict withnoUncheckedIndexedAccess,exactOptionalPropertyTypes,verbatimModuleSyntaxanderasableSyntaxOnly.CI is macOS × Node 22/24 — mcs is Homebrew-only, so macOS is the only platform that can install this pack. Beyond typecheck and tests it guards three things: that no
.shfile exists anywhere in the pack,hostname -sstill equallingos.hostname().split(".")[0](the premise behind commit subjects being unchanged), and a clean working tree afterwards.The README gains three mermaid diagrams — the sharing loop, the Stop hook's guardrail-then-mode dispatch, and the four paths a migrated file can take — plus a contents line and corrected wording where it described the bash.
Not verified
mcs synchas been run. mcs is macOS-only and this was developed on Linux, so the install path is reasoned from the mcs Swift source. Unconfirmed: thatmcs pack validateaccepts the verbosecopyPackFile/fileType: genericcomponent, and that the generic directory copy lands at.claude/shared-memories/. The suite reproduces the installed layout in a temp dir and executes the entry point through its shebang the way mcs will, but cannot exercise mcs itself. This is the one thing worth checking on a Mac before merge.Reading this
Commits are ordered to be read in sequence: scaffolding, shared library, hooks, scripts, manifest, tests, CI, docs, then the two bug fixes, the README revamp, and finally the commit that drops the shims. The two worth most attention are the last one (what the mcs change buys) and the tests commit (how the behaviour contract was established).
🤖 Generated with Claude Code