@massu/core 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +153 -34
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -10,6 +10,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
10
  > redistributed under the **Apache License 2.0**. Full license + NOTICE:
11
11
  > `packages/core/assets/embedder/MODEL-LICENSE`.
12
12
 
13
+ ## [2.5.0] - 2026-08-01
14
+
15
+ ### Fixed
16
+
17
+ - **`install-hooks` ADDED a hook registration instead of REPLACING it, so every version bump
18
+ doubled the hooks — and that defect BLOCKED ITS OWN REMEDY.** `mergeHookEntries` deduped by
19
+ EXACT COMMAND STRING, and the emitted command embeds the version, so
20
+ `npx -y @massu/core@1.16.2 hook-runner user-prompt` and the `@2.4.0` form were two different
21
+ strings, two different hooks, BOTH kept. The command was simultaneously the payload and the
22
+ identity, so any payload change forged a new identity.
23
+
24
+ Measured, not theoretical: one workspace carried `1.15.5` x16 **and** `1.15.2` x15 at once,
25
+ with 15 hook names firing TWICE per event and writing double telemetry indefinitely. Worse,
26
+ because upgrading duplicated, nobody upgraded — 9 of 10 workspaces on this machine sat on a
27
+ pre-2.0.0 version whose `better-sqlite3` ABI did not match the running Node, and one had
28
+ **26,119 `NODE_MODULE_VERSION` hook failures**: a total massu outage. It stayed invisible
29
+ because a crashing hook still **exits 0**.
30
+
31
+ Registration identity is now the `hook-runner` subcommand — a stable CLI contract (`cli.ts`
32
+ dispatches on exactly that token), invariant under both a version bump and a launch-mechanism
33
+ change. Non-massu entries keep the historical exact-command semantics, so a customer's own
34
+ hooks are never touched or collapsed.
35
+
36
+ Proof it can fail (CR-72, planted in the real tree, restored byte-identical by sha256):
37
+ reverting the identity branch to exact-command dedup turns
38
+ `installer-registration-identity-drift-guard.test.ts` RED — that guard drives the REAL
39
+ `installHooks()` over a real temp project seeded with an older-version block, a node-direct
40
+ block and a customer hook.
41
+
42
+ ### Added
43
+
44
+ - **A version-stable launcher shim, cutting ~0.86s of npm bootstrap off each hook fire.**
45
+ `npx -y @massu/core@<v> …` spends ~0.86s starting npm before any massu code runs. Measured
46
+ interleaved, min-of-5: `npx` 0.97s, `npx --offline` 0.88s, `node <cli.js>` 0.11s — so the
47
+ cost is npm's own startup, NOT the registry, and no npx flag addresses it.
48
+
49
+ `install-hooks` now writes `~/.massu/bin/massu-hook` and emits
50
+ `"~/.massu/bin/massu-hook" <version> hook-runner <name>`. That path is VERSION-STABLE, and
51
+ the shim re-resolves the runtime AT FIRE TIME, exec'ing npx when it is absent.
52
+
53
+ This deliberately does NOT bake a versioned absolute path, which would reintroduce the class
54
+ P-003 (v1.9.4) eliminated: such a path is "invalidated by cache clears, global-install
55
+ relocations, or npx upgrades — silently 404-ing the hooks". Probing at emit time says nothing
56
+ about invalidation after emit. Three fail-safe layers instead — shim missing at emit emits
57
+ npx; runtime missing at fire execs npx; both present takes the fast path — so no single
58
+ missing artifact is fatal and a stale registration self-heals.
59
+
60
+ Measured after: fast path **0.11s vs npx 1.07s (9.7x)**. The fallback is mutation-proven — a
61
+ shim with its npx branch removed FAILS the same no-runtime case (exit 27), so the test is not
62
+ vacuous. `exec` in both branches preserves stdin, stdout and the exit code.
63
+
64
+ Semver MINOR: `massuHookIdentity`, `massuShimPath`, `massuShimBody`, `installMassuShim`,
65
+ `resolveMassuShim`, `massuRuntimeDir`, `massuRuntimeCliPath`, `resolveMassuRuntimeCli` and
66
+ `materializeMassuRuntime` are new exports. `MASSU_NO_NODE_DIRECT=1` forces the npx form.
67
+
68
+ ### Changed
69
+
70
+ - **CR-70 Layer 3 amended: installer symmetry is no longer npx-specific.** Both emitters read
71
+ one resolver and agree on mechanism AND pinned version. The drift-guard previously asserted
72
+ the literal `'npx'`, which pinned an implementation detail rather than the property — it
73
+ would go RED on a correct mechanism change and GREEN on a wrong one that used npx on both
74
+ sides. Three assertions were amended on the same grounds
75
+ (`init-hook-paths-no-absolute`, `init-mcp-json-pin`, `cli.test.ts`); each keeps its original
76
+ protection of a visible pinned version and a known launch mechanism.
77
+
13
78
  ## [2.4.0] - 2026-07-31
14
79
 
15
80
  ### Fixed
package/dist/cli.js CHANGED
@@ -15980,14 +15980,23 @@ __export(init_exports, {
15980
15980
  getInstallerVersion: () => getInstallerVersion,
15981
15981
  initMemoryDir: () => initMemoryDir,
15982
15982
  installHooks: () => installHooks,
15983
+ installMassuShim: () => installMassuShim,
15983
15984
  isTemplateName: () => isTemplateName,
15984
15985
  listTemplates: () => listTemplates,
15986
+ massuHookIdentity: () => massuHookIdentity,
15987
+ massuRuntimeCliPath: () => massuRuntimeCliPath,
15988
+ massuRuntimeDir: () => massuRuntimeDir,
15989
+ massuShimBody: () => massuShimBody,
15990
+ massuShimPath: () => massuShimPath,
15991
+ materializeMassuRuntime: () => materializeMassuRuntime,
15985
15992
  mergeHooksConfig: () => mergeHooksConfig,
15986
15993
  parseInitArgs: () => parseInitArgs,
15987
15994
  printInitHelp: () => printInitHelp,
15988
15995
  registerMcpServer: () => registerMcpServer,
15989
15996
  renderConfigYaml: () => renderConfigYaml,
15990
15997
  resolveHooksDir: () => resolveHooksDir,
15998
+ resolveMassuRuntimeCli: () => resolveMassuRuntimeCli,
15999
+ resolveMassuShim: () => resolveMassuShim,
15991
16000
  resolveTemplatesDir: () => resolveTemplatesDir,
15992
16001
  runInit: () => runInit,
15993
16002
  validateWrittenConfig: () => validateWrittenConfig,
@@ -15997,6 +16006,7 @@ import { closeSync as closeSync4, existsSync as existsSync19, fsyncSync as fsync
15997
16006
  import { resolve as resolve9, basename as basename6, dirname as dirname12 } from "path";
15998
16007
  import { fileURLToPath as fileURLToPath3 } from "url";
15999
16008
  import { homedir as homedir7 } from "os";
16009
+ import { execFileSync as execFileSync2 } from "child_process";
16000
16010
  import { stringify as yamlStringify, parse as yamlParse } from "yaml";
16001
16011
  function detectFramework(projectRoot) {
16002
16012
  const result = {
@@ -16570,11 +16580,8 @@ function registerMcpServer(projectRoot) {
16570
16580
  return false;
16571
16581
  }
16572
16582
  const version = getInstallerVersion();
16573
- servers.massu = {
16574
- type: "stdio",
16575
- command: "npx",
16576
- args: ["-y", `@massu/core@${version}`]
16577
- };
16583
+ const shim = resolveMassuShim();
16584
+ servers.massu = shim !== null ? { type: "stdio", command: shim, args: [version] } : { type: "stdio", command: "npx", args: ["-y", `@massu/core@${version}`] };
16578
16585
  existing.mcpServers = servers;
16579
16586
  writeFileSync5(mcpPath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
16580
16587
  return true;
@@ -16591,7 +16598,98 @@ function resolveHooksDir() {
16591
16598
  }
16592
16599
  return "node_modules/@massu/core/dist/hooks";
16593
16600
  }
16594
- function hookCmd(version, hookName) {
16601
+ function massuRuntimeDir(version) {
16602
+ return resolve9(homedir7(), ".massu", "runtime", version);
16603
+ }
16604
+ function massuRuntimeCliPath(version) {
16605
+ return resolve9(massuRuntimeDir(version), "node_modules", "@massu", "core", "dist", "cli.js");
16606
+ }
16607
+ function resolveMassuRuntimeCli(version) {
16608
+ if (process.env.MASSU_NO_NODE_DIRECT === "1") return null;
16609
+ const cli = massuRuntimeCliPath(version);
16610
+ if (!existsSync19(cli)) return null;
16611
+ try {
16612
+ execFileSync2(process.execPath, [cli, "--version"], { stdio: "ignore", timeout: 2e4 });
16613
+ return cli;
16614
+ } catch {
16615
+ return null;
16616
+ }
16617
+ }
16618
+ function materializeMassuRuntime(version) {
16619
+ if (process.env.MASSU_NO_NODE_DIRECT === "1") return false;
16620
+ if (resolveMassuRuntimeCli(version) !== null) return true;
16621
+ const dir = massuRuntimeDir(version);
16622
+ try {
16623
+ mkdirSync10(dir, { recursive: true });
16624
+ execFileSync2(
16625
+ "npm",
16626
+ ["install", "--prefix", dir, `@massu/core@${version}`, "--no-audit", "--no-fund", "--loglevel=error"],
16627
+ { stdio: "ignore", timeout: 3e5 }
16628
+ );
16629
+ } catch {
16630
+ return false;
16631
+ }
16632
+ return resolveMassuRuntimeCli(version) !== null;
16633
+ }
16634
+ function massuShimPath() {
16635
+ return resolve9(homedir7(), ".massu", "bin", "massu-hook");
16636
+ }
16637
+ function massuShimBody() {
16638
+ return `#!/bin/sh
16639
+ # massu launcher shim \u2014 GENERATED, do not edit. Recreated by \`massu install-hooks\`.
16640
+ #
16641
+ # usage: massu-hook <version> <subcommand...>
16642
+ #
16643
+ # Resolves the massu runtime AT FIRE TIME so a deleted/moved runtime degrades to npx
16644
+ # instead of silently 404-ing the hook (the P-003 failure class). exec preserves stdin,
16645
+ # stdout, stderr and the exit code.
16646
+ set -u
16647
+ if [ "$#" -lt 1 ]; then
16648
+ echo "massu-hook: missing <version> argument" >&2
16649
+ exit 2
16650
+ fi
16651
+ MASSU_VERSION="$1"
16652
+ shift
16653
+ MASSU_CLI="\${HOME}/.massu/runtime/\${MASSU_VERSION}/node_modules/@massu/core/dist/cli.js"
16654
+ if [ -f "$MASSU_CLI" ]; then
16655
+ exec node "$MASSU_CLI" "$@"
16656
+ fi
16657
+ exec npx -y "@massu/core@\${MASSU_VERSION}" "$@"
16658
+ `;
16659
+ }
16660
+ function installMassuShim() {
16661
+ if (process.env.MASSU_NO_NODE_DIRECT === "1") return false;
16662
+ if (process.platform === "win32") return false;
16663
+ const shim = massuShimPath();
16664
+ try {
16665
+ mkdirSync10(dirname12(shim), { recursive: true });
16666
+ writeFileSync5(shim, massuShimBody(), "utf-8");
16667
+ chmodSync5(shim, 493);
16668
+ } catch {
16669
+ return false;
16670
+ }
16671
+ try {
16672
+ execFileSync2(shim, [], { stdio: "ignore", timeout: 1e4 });
16673
+ return false;
16674
+ } catch (e2) {
16675
+ const status = e2.status;
16676
+ return status === 2;
16677
+ }
16678
+ }
16679
+ function resolveMassuShim() {
16680
+ if (process.env.MASSU_NO_NODE_DIRECT === "1") return null;
16681
+ if (process.platform === "win32") return null;
16682
+ const shim = massuShimPath();
16683
+ if (!existsSync19(shim)) return null;
16684
+ try {
16685
+ execFileSync2(shim, [], { stdio: "ignore", timeout: 1e4 });
16686
+ return null;
16687
+ } catch (e2) {
16688
+ return e2.status === 2 ? shim : null;
16689
+ }
16690
+ }
16691
+ function hookCmd(version, hookName, shim) {
16692
+ if (shim !== null) return `"${shim}" ${version} hook-runner ${hookName}`;
16595
16693
  return `npx -y @massu/core@${version} hook-runner ${hookName}`;
16596
16694
  }
16597
16695
  function getHookTimeout(name) {
@@ -16599,11 +16697,12 @@ function getHookTimeout(name) {
16599
16697
  }
16600
16698
  function buildHooksConfig(_hooksDir) {
16601
16699
  const version = getInstallerVersion();
16700
+ const shim = resolveMassuShim();
16602
16701
  return {
16603
16702
  SessionStart: [
16604
16703
  {
16605
16704
  hooks: [
16606
- { type: "command", command: hookCmd(version, "session-start"), timeout: getHookTimeout("session-start") }
16705
+ { type: "command", command: hookCmd(version, "session-start", shim), timeout: getHookTimeout("session-start") }
16607
16706
  ]
16608
16707
  }
16609
16708
  ],
@@ -16618,26 +16717,26 @@ function buildHooksConfig(_hooksDir) {
16618
16717
  {
16619
16718
  matcher: "Bash|Write|Edit",
16620
16719
  hooks: [
16621
- { type: "command", command: hookCmd(version, "pre-tool-use-gate"), timeout: getHookTimeout("pre-tool-use-gate") }
16720
+ { type: "command", command: hookCmd(version, "pre-tool-use-gate", shim), timeout: getHookTimeout("pre-tool-use-gate") }
16622
16721
  ]
16623
16722
  }
16624
16723
  ],
16625
16724
  PostToolUse: [
16626
16725
  {
16627
16726
  hooks: [
16628
- { type: "command", command: hookCmd(version, "post-tool-use"), timeout: getHookTimeout("post-tool-use") },
16629
- { type: "command", command: hookCmd(version, "quality-event"), timeout: getHookTimeout("quality-event") },
16630
- { type: "command", command: hookCmd(version, "cost-tracker"), timeout: getHookTimeout("cost-tracker") }
16727
+ { type: "command", command: hookCmd(version, "post-tool-use", shim), timeout: getHookTimeout("post-tool-use") },
16728
+ { type: "command", command: hookCmd(version, "quality-event", shim), timeout: getHookTimeout("quality-event") },
16729
+ { type: "command", command: hookCmd(version, "cost-tracker", shim), timeout: getHookTimeout("cost-tracker") }
16631
16730
  ]
16632
16731
  },
16633
16732
  {
16634
16733
  matcher: "Edit|Write",
16635
16734
  hooks: [
16636
- { type: "command", command: hookCmd(version, "post-edit-context"), timeout: getHookTimeout("post-edit-context") },
16735
+ { type: "command", command: hookCmd(version, "post-edit-context", shim), timeout: getHookTimeout("post-edit-context") },
16637
16736
  // Auto-learning pipeline — classifies failures and detects fixes on
16638
16737
  // file changes. See Phase 5-6 of the autodetect plan.
16639
- { type: "command", command: hookCmd(version, "fix-detector"), timeout: getHookTimeout("fix-detector") },
16640
- { type: "command", command: hookCmd(version, "classify-failure"), timeout: getHookTimeout("classify-failure") }
16738
+ { type: "command", command: hookCmd(version, "fix-detector", shim), timeout: getHookTimeout("fix-detector") },
16739
+ { type: "command", command: hookCmd(version, "classify-failure", shim), timeout: getHookTimeout("classify-failure") }
16641
16740
  ]
16642
16741
  },
16643
16742
  {
@@ -16645,35 +16744,35 @@ function buildHooksConfig(_hooksDir) {
16645
16744
  hooks: [
16646
16745
  // Incident + rule enforcement pipelines fire on Write-only (incidents
16647
16746
  // are authored as .md files; rules are enforced after new-file drops).
16648
- { type: "command", command: hookCmd(version, "incident-pipeline"), timeout: getHookTimeout("incident-pipeline") },
16649
- { type: "command", command: hookCmd(version, "rule-enforcement-pipeline"), timeout: getHookTimeout("rule-enforcement-pipeline") }
16747
+ { type: "command", command: hookCmd(version, "incident-pipeline", shim), timeout: getHookTimeout("incident-pipeline") },
16748
+ { type: "command", command: hookCmd(version, "rule-enforcement-pipeline", shim), timeout: getHookTimeout("rule-enforcement-pipeline") }
16650
16749
  ]
16651
16750
  }
16652
16751
  ],
16653
16752
  Stop: [
16654
16753
  {
16655
16754
  hooks: [
16656
- { type: "command", command: hookCmd(version, "session-end"), timeout: getHookTimeout("session-end") },
16755
+ { type: "command", command: hookCmd(version, "session-end", shim), timeout: getHookTimeout("session-end") },
16657
16756
  // Session-end auto-learning aggregation (failure-class roll-up).
16658
- { type: "command", command: hookCmd(version, "auto-learning-pipeline"), timeout: getHookTimeout("auto-learning-pipeline") }
16757
+ { type: "command", command: hookCmd(version, "auto-learning-pipeline", shim), timeout: getHookTimeout("auto-learning-pipeline") }
16659
16758
  ]
16660
16759
  }
16661
16760
  ],
16662
16761
  PreCompact: [
16663
16762
  {
16664
16763
  hooks: [
16665
- { type: "command", command: hookCmd(version, "pre-compact"), timeout: getHookTimeout("pre-compact") }
16764
+ { type: "command", command: hookCmd(version, "pre-compact", shim), timeout: getHookTimeout("pre-compact") }
16666
16765
  ]
16667
16766
  }
16668
16767
  ],
16669
16768
  UserPromptSubmit: [
16670
16769
  {
16671
16770
  hooks: [
16672
- { type: "command", command: hookCmd(version, "user-prompt"), timeout: getHookTimeout("user-prompt") },
16673
- { type: "command", command: hookCmd(version, "intent-suggester"), timeout: getHookTimeout("intent-suggester") },
16771
+ { type: "command", command: hookCmd(version, "user-prompt", shim), timeout: getHookTimeout("user-prompt") },
16772
+ { type: "command", command: hookCmd(version, "intent-suggester", shim), timeout: getHookTimeout("intent-suggester") },
16674
16773
  // plan-living-memory-slice-1 P3-002: automatic relevant-recall.
16675
16774
  // Injects a compact "🧠 Relevant memory" block; fails open (empty).
16676
- { type: "command", command: hookCmd(version, "memory-recall"), timeout: getHookTimeout("memory-recall") }
16775
+ { type: "command", command: hookCmd(version, "memory-recall", shim), timeout: getHookTimeout("memory-recall") }
16677
16776
  ]
16678
16777
  }
16679
16778
  ]
@@ -16711,21 +16810,31 @@ function mergeHooksConfig(existing, additions) {
16711
16810
  }
16712
16811
  return merged;
16713
16812
  }
16813
+ function massuHookIdentity(command) {
16814
+ if (!/@massu\/core|[/\\]\.?massu[/\\]/.test(command)) return null;
16815
+ const m3 = /\bhook-runner\s+([A-Za-z0-9._-]+)/.exec(command);
16816
+ return m3 ? m3[1] : null;
16817
+ }
16714
16818
  function mergeHookEntries(existing, additions) {
16715
- const seen = /* @__PURE__ */ new Set();
16819
+ const seenCommands = /* @__PURE__ */ new Set();
16820
+ const seenIdentities = /* @__PURE__ */ new Set();
16716
16821
  const result = [];
16717
- for (const entry of additions ?? []) {
16718
- if (!entry || typeof entry.command !== "string") continue;
16719
- if (seen.has(entry.command)) continue;
16720
- seen.add(entry.command);
16721
- result.push(entry);
16722
- }
16723
- for (const entry of existing ?? []) {
16724
- if (!entry || typeof entry.command !== "string") continue;
16725
- if (seen.has(entry.command)) continue;
16726
- seen.add(entry.command);
16822
+ const take = (entry) => {
16823
+ if (!entry || typeof entry.command !== "string") return;
16824
+ const identity = massuHookIdentity(entry.command);
16825
+ if (identity !== null) {
16826
+ if (seenIdentities.has(identity)) return;
16827
+ seenIdentities.add(identity);
16828
+ seenCommands.add(entry.command);
16829
+ result.push(entry);
16830
+ return;
16831
+ }
16832
+ if (seenCommands.has(entry.command)) return;
16833
+ seenCommands.add(entry.command);
16727
16834
  result.push(entry);
16728
- }
16835
+ };
16836
+ for (const entry of additions ?? []) take(entry);
16837
+ for (const entry of existing ?? []) take(entry);
16729
16838
  return result;
16730
16839
  }
16731
16840
  function installHooks(projectRoot) {
@@ -18716,6 +18825,16 @@ async function runInstallHooks() {
18716
18825
  console.log("Massu AI - Hook Installation");
18717
18826
  console.log("============================");
18718
18827
  console.log("");
18828
+ const version = getInstallerVersion();
18829
+ const shimOk = installMassuShim();
18830
+ const runtimeOk = materializeMassuRuntime(version);
18831
+ if (shimOk && runtimeOk) {
18832
+ console.log(" Launcher: shim + runtime ready (fast path, ~9x cheaper per hook)");
18833
+ } else if (shimOk) {
18834
+ console.log(" Launcher: shim ready, runtime unavailable \u2014 shim will fall back to npx at fire time");
18835
+ } else {
18836
+ console.log(" Launcher: npx (shim unavailable \u2014 works, ~0.9s slower per hook)");
18837
+ }
18719
18838
  const { count } = installHooks(projectRoot);
18720
18839
  console.log(` Installed ${count} hooks in .claude/settings.local.json`);
18721
18840
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 73 total), 56 workflow commands, 11 agents, 20+ patterns",
6
6
  "main": "./dist/cli.js",