@massu/core 2.5.5 → 2.5.7

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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,69 @@ 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.7] - 2026-11-10
14
+
15
+ ### Fixed
16
+
17
+ - **Cloud sync no longer queues uploads the server refuses.** When the massu cloud refused an upload
18
+ for a reason that retrying cannot change (for example `403 Sync not available for current plan
19
+ status`), massu queued it and retried forever. One project's memory database grew to 8.5 GB this way,
20
+ 7.6 GB of it refused uploads, and every reply re-sent a ~179 KB upload that was always refused. Now:
21
+ - a refusal that cannot succeed is never queued or retried;
22
+ - when the account itself is refused (401 or 403), massu records the server's reason, pauses sync,
23
+ clears the queue (learned rules are kept), and shows `MASSU CLOUD SYNC PAUSED` with that reason at
24
+ session start. It checks again once a day and resumes on its own when the account allows it;
25
+ - temporary failures (network, 5xx, 429) still queue, but the queue now holds at most 500 items and
26
+ nothing older than 30 days, and every item gets its turn.
27
+ - **`massu install-hooks` now upgrades a project completely.** It also moves the massu MCP server in
28
+ `.mcp.json` to the same version as the hooks (it never did before, so every upgrade left the server
29
+ behind), and it replaces hook entries written by much older installers, which used to stay beside the
30
+ current ones and fail on every tool call. Your own hooks and any hand-written massu entry are left alone.
31
+ - **A hook launched through a symlinked path now runs.** Started through a symlink, a hook decided it was
32
+ not the program being run and exited without doing anything; for the safety gate that meant allowing
33
+ the call. The commands `massu init` writes were not affected.
34
+
35
+ ### Added
36
+
37
+ - **The first prompt of a session is faster.** Session start now loads the memory-search model in the
38
+ background, so the first prompt no longer pays for it (measured: about 1.5 s for the first search,
39
+ 0.6 s after). Turn it off with `MASSU_RECALL_WARMUP=0`.
40
+ - **`scripts/massu-db-size-audit.sh`** (in the repository) reports where a massu database's space goes:
41
+ live data, space freed but not yet returned to the disk, the largest tables, and the sync queue.
42
+
43
+ ## [2.5.6] - 2026-11-09
44
+
45
+ ### Security
46
+
47
+ - **The safety gate blocks again.** In 2.5.2 through 2.5.5 the PreToolUse gate that `massu init`
48
+ installs warned about everything it should have blocked and then let the call run: commands
49
+ that read `.env` and other secret files, piping a download into a shell, `rm -rf /`, disk
50
+ formatting and writes to `/etc/passwd`. Claude Code blocks a tool call only when the hook exits
51
+ with code 2; the gate printed "BLOCKED" and exited 0. It now exits 2 for every blocking
52
+ finding. **Upgrade, then run `massu init` again** so your settings pick up the change below.
53
+ - **A gate that fails now blocks.** `massu init` sets Claude Code's `"onFailure": "block"`
54
+ (Claude Code 2.1.295 or later) on the gate, so a gate that crashes, times out or prints invalid
55
+ output blocks the call instead of letting it through. On older Claude Code, the `massu-hook`
56
+ launcher and `hook-runner` turn a crashed gate into a block.
57
+ - **The older hook names decide too.** Settings written before 1.12.0 may still name the
58
+ `security-gate` and `pre-delete-check` hooks. They used to warn only; they now make the same
59
+ decision as the gate.
60
+ - **More secret files are recognised:** secrets data files under any name, such as
61
+ `config.secrets.yaml` or `app.secret.json`. Source files such as `secrets.ts` are still allowed.
62
+
63
+ ### Fixed
64
+
65
+ - **The gate no longer refuses good calls under load.** A slow-arriving tool call could be
66
+ denied as "Unexpected end of JSON input" and pass on retry. The gate now reads its whole
67
+ input before deciding.
68
+
69
+ ### Added
70
+
71
+ - **`massu_cost_feature` reports real data.** Each session's priced cost is split across the
72
+ registered features whose files it touched, in proportion to those touches; activity on files
73
+ that belong to no registered feature is shown as `(no registered feature)`, so the rows add up
74
+ to the sessions' total. Sessions whose cost is unknown are left out.
75
+
13
76
  ## [2.5.5] - 2026-11-09
14
77
 
15
78
  ### Fixed
package/dist/cli.js CHANGED
@@ -2490,6 +2490,8 @@ __export(memory_db_exports, {
2490
2490
  MEMORY_FILE_TITLE_LIKE: () => MEMORY_FILE_TITLE_LIKE,
2491
2491
  MEMORY_FILE_TITLE_PREFIX: () => MEMORY_FILE_TITLE_PREFIX,
2492
2492
  MEMORY_SCHEMA_VERSION: () => MEMORY_SCHEMA_VERSION,
2493
+ PENDING_SYNC_MAX_AGE_DAYS: () => PENDING_SYNC_MAX_AGE_DAYS,
2494
+ PENDING_SYNC_MAX_ITEMS: () => PENDING_SYNC_MAX_ITEMS,
2493
2495
  TOOL_COST_EVENTS_RETENTION_DAYS: () => TOOL_COST_EVENTS_RETENTION_DAYS,
2494
2496
  USAGE_COUNTER_ARMED_KEY: () => USAGE_COUNTER_ARMED_KEY,
2495
2497
  addConversationTurn: () => addConversationTurn,
@@ -2498,6 +2500,7 @@ __export(memory_db_exports, {
2498
2500
  addSummary: () => addSummary,
2499
2501
  addToolCallDetail: () => addToolCallDetail,
2500
2502
  addUserPrompt: () => addUserPrompt,
2503
+ allPendingSync: () => allPendingSync,
2501
2504
  appendIncidentToFailureClass: () => appendIncidentToFailureClass,
2502
2505
  armUsageCounter: () => armUsageCounter,
2503
2506
  assignImportance: () => assignImportance,
@@ -2505,6 +2508,7 @@ __export(memory_db_exports, {
2505
2508
  createSession: () => createSession,
2506
2509
  deduplicateFailedAttempt: () => deduplicateFailedAttempt,
2507
2510
  dequeuePendingSync: () => dequeuePendingSync,
2511
+ discardPendingSync: () => discardPendingSync,
2508
2512
  embedMissingObservations: () => embedMissingObservations,
2509
2513
  endSession: () => endSession,
2510
2514
  enqueueRulePromotionEvent: () => enqueueRulePromotionEvent,
@@ -3487,6 +3491,35 @@ function initMemorySchema(db) {
3487
3491
  }
3488
3492
  function enqueueSyncPayload(db, payload) {
3489
3493
  db.prepare("INSERT INTO pending_sync (payload) VALUES (?)").run(payload);
3494
+ const overflow = db.prepare(
3495
+ `SELECT id, retry_count, last_error, payload FROM pending_sync
3496
+ WHERE created_at < datetime('now', ?)
3497
+ OR id NOT IN (SELECT id FROM pending_sync ORDER BY created_at DESC, id DESC LIMIT ?)
3498
+ LIMIT 10000`
3499
+ ).all(`-${PENDING_SYNC_MAX_AGE_DAYS} days`, PENDING_SYNC_MAX_ITEMS);
3500
+ if (overflow.length > 0) {
3501
+ discardPendingSync(db, overflow, `over the queue limit (${PENDING_SYNC_MAX_ITEMS} items / ${PENDING_SYNC_MAX_AGE_DAYS} days)`);
3502
+ }
3503
+ }
3504
+ function discardPendingSync(db, rows, why) {
3505
+ if (rows.length === 0) return 0;
3506
+ const rescued = rescueLearningFromStalePayloads(db, rows);
3507
+ for (let i = 0; i < rows.length; i += 500) {
3508
+ const ids = rows.slice(i, i + 500).map((r3) => r3.id);
3509
+ db.prepare(`DELETE FROM pending_sync WHERE id IN (${ids.map(() => "?").join(",")})`).run(...ids);
3510
+ }
3511
+ if (rescued > 0) {
3512
+ process.stderr.write(
3513
+ `[massu] RESCUED ${rescued} learned rule(s) from ${rows.length} discarded payload(s) \u2014 they were re-queued for delivery, NOT discarded.
3514
+ `
3515
+ );
3516
+ }
3517
+ process.stderr.write(`[massu] cloud sync: discarded ${rows.length} queued upload(s): ${why}.
3518
+ `);
3519
+ return rows.length;
3520
+ }
3521
+ function allPendingSync(db) {
3522
+ return db.prepare("SELECT id, retry_count, last_error, payload FROM pending_sync").all();
3490
3523
  }
3491
3524
  function getMemoryMeta(db, key) {
3492
3525
  const row = db.prepare("SELECT value FROM memory_meta WHERE key = ?").get(key);
@@ -3689,7 +3722,9 @@ function dequeuePendingSync(db, limit = 10) {
3689
3722
  }
3690
3723
  }
3691
3724
  return db.prepare(
3692
- "SELECT id, payload, retry_count FROM pending_sync ORDER BY created_at ASC LIMIT ?"
3725
+ // Least-retried first: oldest-first retried the same 10 items until they were discarded
3726
+ // while every newer item waited at retry 0 (2026-11-09: 43,492 never attempted).
3727
+ "SELECT id, payload, retry_count FROM pending_sync ORDER BY retry_count ASC, created_at ASC LIMIT ?"
3693
3728
  ).all(limit);
3694
3729
  }
3695
3730
  function removePendingSync(db, id) {
@@ -4362,7 +4397,7 @@ function scoreFailureClasses(db, matchText, filePath, promptContext, weights) {
4362
4397
  }
4363
4398
  return bestMatch;
4364
4399
  }
4365
- var MEMORY_SCHEMA_VERSION, TOOL_COST_EVENTS_RETENTION_DAYS, MAX_DRAFT_TEXT_LEN, FUNNEL_EVENT_OUTBOX_CAP, _temporalColCache, CONSOLIDATION_LESSON_EVIDENCE, MEMORY_FILE_TITLE_PREFIX, MEMORY_FILE_TITLE_LIKE, USAGE_COUNTER_ARMED_KEY;
4400
+ var MEMORY_SCHEMA_VERSION, TOOL_COST_EVENTS_RETENTION_DAYS, PENDING_SYNC_MAX_ITEMS, PENDING_SYNC_MAX_AGE_DAYS, MAX_DRAFT_TEXT_LEN, FUNNEL_EVENT_OUTBOX_CAP, _temporalColCache, CONSOLIDATION_LESSON_EVIDENCE, MEMORY_FILE_TITLE_PREFIX, MEMORY_FILE_TITLE_LIKE, USAGE_COUNTER_ARMED_KEY;
4366
4401
  var init_memory_db = __esm({
4367
4402
  "src/memory-db.ts"() {
4368
4403
  "use strict";
@@ -4377,6 +4412,8 @@ var init_memory_db = __esm({
4377
4412
  init_memory_embed_sweep();
4378
4413
  MEMORY_SCHEMA_VERSION = 2;
4379
4414
  TOOL_COST_EVENTS_RETENTION_DAYS = 90;
4415
+ PENDING_SYNC_MAX_ITEMS = 500;
4416
+ PENDING_SYNC_MAX_AGE_DAYS = 30;
4380
4417
  MAX_DRAFT_TEXT_LEN = 16384;
4381
4418
  FUNNEL_EVENT_OUTBOX_CAP = 2e4;
4382
4419
  _temporalColCache = /* @__PURE__ */ new WeakMap();
@@ -5893,6 +5930,42 @@ var init_hook_timeouts = __esm({
5893
5930
  }
5894
5931
  });
5895
5932
 
5933
+ // src/lib/hook-registry.ts
5934
+ function getExpectedHookFiles() {
5935
+ return REGISTERED_HOOKS.map((name) => `${name}.js`);
5936
+ }
5937
+ var REGISTERED_HOOKS, DECISION_HOOKS;
5938
+ var init_hook_registry = __esm({
5939
+ "src/lib/hook-registry.ts"() {
5940
+ "use strict";
5941
+ REGISTERED_HOOKS = [
5942
+ "auto-learning-pipeline",
5943
+ "classify-failure",
5944
+ "cost-tracker",
5945
+ "fix-detector",
5946
+ "incident-pipeline",
5947
+ "intent-suggester",
5948
+ "memory-recall",
5949
+ "post-edit-context",
5950
+ "post-tool-use",
5951
+ "pre-compact",
5952
+ // P-E-019 (1.12.0): security-gate + pre-delete-check are kept registered
5953
+ // for back-compat (operator settings.json may still reference them
5954
+ // individually), but new installs emit only `pre-tool-use-gate` —
5955
+ // the consolidated single-spawn PreToolUse hook.
5956
+ "pre-delete-check",
5957
+ "pre-tool-use-gate",
5958
+ "quality-event",
5959
+ "rule-enforcement-pipeline",
5960
+ "security-gate",
5961
+ "session-end",
5962
+ "session-start",
5963
+ "user-prompt"
5964
+ ];
5965
+ DECISION_HOOKS = ["pre-delete-check", "pre-tool-use-gate", "security-gate"];
5966
+ }
5967
+ });
5968
+
5896
5969
  // src/detect/manifest-registry.ts
5897
5970
  var manifest_registry_exports = {};
5898
5971
  __export(manifest_registry_exports, {
@@ -16410,6 +16483,7 @@ ${import_picocolors2.default.gray(m2)} ${s}
16410
16483
  // src/commands/init.ts
16411
16484
  var init_exports = {};
16412
16485
  __export(init_exports, {
16486
+ SUPERSEDED_MASSU_HOOKS: () => SUPERSEDED_MASSU_HOOKS,
16413
16487
  applyVariantTemplate: () => applyVariantTemplate,
16414
16488
  buildConfigFromDetection: () => buildConfigFromDetection,
16415
16489
  buildHooksConfig: () => buildHooksConfig,
@@ -16432,6 +16506,7 @@ __export(init_exports, {
16432
16506
  mergeHooksConfig: () => mergeHooksConfig,
16433
16507
  parseInitArgs: () => parseInitArgs,
16434
16508
  printInitHelp: () => printInitHelp,
16509
+ refreshMcpServerPin: () => refreshMcpServerPin,
16435
16510
  registerMcpServer: () => registerMcpServer,
16436
16511
  renderConfigYaml: () => renderConfigYaml,
16437
16512
  resolveHooksDir: () => resolveHooksDir,
@@ -17016,6 +17091,32 @@ function getInstallerVersion() {
17016
17091
  "getInstallerVersion: could not resolve @massu/core package.json. This indicates a corrupt install. Re-install via `npx -y @massu/core init`."
17017
17092
  );
17018
17093
  }
17094
+ function refreshMcpServerPin(projectRoot) {
17095
+ const mcpPath = resolve12(projectRoot, ".mcp.json");
17096
+ if (!existsSync21(mcpPath)) return "absent";
17097
+ let doc;
17098
+ try {
17099
+ doc = JSON.parse(readFileSync17(mcpPath, "utf-8"));
17100
+ } catch {
17101
+ return "custom";
17102
+ }
17103
+ const servers = doc.mcpServers ?? {};
17104
+ const cur = servers.massu;
17105
+ if (!cur) return "absent";
17106
+ const args2 = Array.isArray(cur.args) ? cur.args.map(String) : [];
17107
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
17108
+ const shimForm = typeof cur.command === "string" && /[/\\]massu-hook$/.test(cur.command) && args2.length === 1 && SEMVER.test(args2[0]);
17109
+ const npxForm = cur.command === "npx" && args2.length === 2 && args2[0] === "-y" && /^@massu\/core@/.test(args2[1]);
17110
+ if (!shimForm && !npxForm) return "custom";
17111
+ const version = getInstallerVersion();
17112
+ const shim = resolveMassuShim();
17113
+ const next = shim !== null ? { type: "stdio", command: shim, args: [version] } : { type: "stdio", command: "npx", args: ["-y", `@massu/core@${version}`] };
17114
+ if (JSON.stringify({ ...cur, ...next }) === JSON.stringify(cur)) return "current";
17115
+ servers.massu = { ...cur, ...next };
17116
+ doc.mcpServers = servers;
17117
+ writeFileSync5(mcpPath, JSON.stringify(doc, null, 2) + "\n", "utf-8");
17118
+ return "updated";
17119
+ }
17019
17120
  function registerMcpServer(projectRoot) {
17020
17121
  const mcpPath = resolve12(projectRoot, ".mcp.json");
17021
17122
  let existing = {};
@@ -17102,6 +17203,22 @@ fi
17102
17203
  MASSU_VERSION="$1"
17103
17204
  shift
17104
17205
  MASSU_CLI="\${HOME}/.massu/runtime/\${MASSU_VERSION}/node_modules/@massu/core/dist/cli.js"
17206
+ # A DECIDING hook's exit code is its verdict: 0 allows, 2 denies. If the launch itself fails
17207
+ # (a runtime that cannot load exits 1), Claude Code would RUN the call, so for these hooks
17208
+ # any other status becomes a deny. Every other subcommand keeps the exec path below.
17209
+ if [ "\${1:-}" = hook-runner ]; then
17210
+ case "\${2:-}" in
17211
+ ${DECISION_HOOKS.join("|")})
17212
+ if [ -f "$MASSU_CLI" ]; then node "$MASSU_CLI" "$@"; else npx -y "@massu/core@\${MASSU_VERSION}" "$@"; fi
17213
+ st=$?
17214
+ if [ "$st" -ne 0 ] && [ "$st" -ne 2 ]; then
17215
+ echo "MASSU SECURITY GATE \u2014 BLOCKED: the $2 hook ended without a verdict (exit $st)" >&2
17216
+ exit 2
17217
+ fi
17218
+ exit "$st"
17219
+ ;;
17220
+ esac
17221
+ fi
17105
17222
  if [ -f "$MASSU_CLI" ]; then
17106
17223
  exec node "$MASSU_CLI" "$@"
17107
17224
  fi
@@ -17149,6 +17266,7 @@ function getHookTimeout(name) {
17149
17266
  function hookEntry(version, name, shim) {
17150
17267
  const entry = { type: "command", command: hookCmd(version, name, shim), timeout: getHookTimeout(name) };
17151
17268
  if (BACKGROUND_HOOKS.has(name)) entry.async = true;
17269
+ if (DECISION_HOOKS.includes(name)) entry.onFailure = "block";
17152
17270
  return entry;
17153
17271
  }
17154
17272
  function buildHooksConfig(_hooksDir, shim = resolveMassuShim()) {
@@ -17263,12 +17381,33 @@ function mergeHooksConfig(existing, additions) {
17263
17381
  }
17264
17382
  merged[event] = Array.from(byMatcher.values());
17265
17383
  }
17384
+ const emitted = /* @__PURE__ */ new Set();
17385
+ for (const groups of Object.values(additions ?? {})) {
17386
+ for (const g2 of groups) {
17387
+ for (const h2 of g2.hooks ?? []) {
17388
+ const id = typeof h2?.command === "string" ? massuHookIdentity(h2.command) : null;
17389
+ if (id) emitted.add(id);
17390
+ }
17391
+ }
17392
+ }
17393
+ for (const event of Object.keys(merged)) {
17394
+ merged[event] = merged[event].map((g2) => ({
17395
+ ...g2,
17396
+ hooks: (g2.hooks ?? []).filter((h2) => {
17397
+ const id = typeof h2?.command === "string" ? massuHookIdentity(h2.command) : null;
17398
+ const by = id ? SUPERSEDED_MASSU_HOOKS[id] : void 0;
17399
+ return !(by && emitted.has(by));
17400
+ })
17401
+ })).filter((g2) => (g2.hooks ?? []).length > 0);
17402
+ }
17266
17403
  return merged;
17267
17404
  }
17268
17405
  function massuHookIdentity(command) {
17269
17406
  if (!/@massu\/core|[/\\]\.?massu[/\\]/.test(command)) return null;
17270
17407
  const m3 = /\bhook-runner\s+([A-Za-z0-9._-]+)/.exec(command);
17271
- return m3 ? m3[1] : null;
17408
+ if (m3) return m3[1];
17409
+ const legacy = /@massu[/\\]core[/\\]dist[/\\]hooks[/\\]([a-z0-9-]+)\.js/.exec(command);
17410
+ return legacy ? legacy[1] : null;
17272
17411
  }
17273
17412
  function mergeHookEntries(existing, additions) {
17274
17413
  const seenCommands = /* @__PURE__ */ new Set();
@@ -17694,7 +17833,7 @@ async function promptStackConfirm() {
17694
17833
  function capitalize(str) {
17695
17834
  return str.charAt(0).toUpperCase() + str.slice(1);
17696
17835
  }
17697
- var __filename2, __dirname2, FRAMEWORK_TO_TEMPLATE_ID, TEMPLATE_NAMES;
17836
+ var __filename2, __dirname2, FRAMEWORK_TO_TEMPLATE_ID, TEMPLATE_NAMES, SUPERSEDED_MASSU_HOOKS;
17698
17837
  var init_init = __esm({
17699
17838
  "src/commands/init.ts"() {
17700
17839
  "use strict";
@@ -17705,6 +17844,7 @@ var init_init = __esm({
17705
17844
  init_settings_local();
17706
17845
  init_memory_path();
17707
17846
  init_hook_timeouts();
17847
+ init_hook_registry();
17708
17848
  init_detect();
17709
17849
  init_drift();
17710
17850
  __filename2 = fileURLToPath3(import.meta.url);
@@ -17726,6 +17866,10 @@ var init_init = __esm({
17726
17866
  "swift-ios",
17727
17867
  "multi-runtime"
17728
17868
  ];
17869
+ SUPERSEDED_MASSU_HOOKS = Object.freeze({
17870
+ "security-gate": "pre-tool-use-gate",
17871
+ "pre-delete-check": "pre-tool-use-gate"
17872
+ });
17729
17873
  }
17730
17874
  });
17731
17875
 
@@ -18291,41 +18435,6 @@ var init_license = __esm({
18291
18435
  }
18292
18436
  });
18293
18437
 
18294
- // src/lib/hook-registry.ts
18295
- function getExpectedHookFiles() {
18296
- return REGISTERED_HOOKS.map((name) => `${name}.js`);
18297
- }
18298
- var REGISTERED_HOOKS;
18299
- var init_hook_registry = __esm({
18300
- "src/lib/hook-registry.ts"() {
18301
- "use strict";
18302
- REGISTERED_HOOKS = [
18303
- "auto-learning-pipeline",
18304
- "classify-failure",
18305
- "cost-tracker",
18306
- "fix-detector",
18307
- "incident-pipeline",
18308
- "intent-suggester",
18309
- "memory-recall",
18310
- "post-edit-context",
18311
- "post-tool-use",
18312
- "pre-compact",
18313
- // P-E-019 (1.12.0): security-gate + pre-delete-check are kept registered
18314
- // for back-compat (operator settings.json may still reference them
18315
- // individually), but new installs emit only `pre-tool-use-gate` —
18316
- // the consolidated single-spawn PreToolUse hook.
18317
- "pre-delete-check",
18318
- "pre-tool-use-gate",
18319
- "quality-event",
18320
- "rule-enforcement-pipeline",
18321
- "security-gate",
18322
- "session-end",
18323
- "session-start",
18324
- "user-prompt"
18325
- ];
18326
- }
18327
- });
18328
-
18329
18438
  // src/preflight.ts
18330
18439
  import { existsSync as existsSync22 } from "fs";
18331
18440
  function assertCodegraphUsable(dbPath) {
@@ -18412,6 +18521,7 @@ var init_preflight = __esm({
18412
18521
  var hook_runner_exports = {};
18413
18522
  __export(hook_runner_exports, {
18414
18523
  HOOK_NAME_TO_FILE: () => HOOK_NAME_TO_FILE,
18524
+ decisionExitCode: () => decisionExitCode,
18415
18525
  resolveHookFile: () => resolveHookFile,
18416
18526
  runHookRunner: () => runHookRunner
18417
18527
  });
@@ -18443,6 +18553,16 @@ function resolveHookFile(hookName) {
18443
18553
  `Hook file not found for "${hookName}". Searched: ${candidates.join(", ")}. This indicates a broken @massu/core install. Re-run \`npx -y @massu/core init\`.`
18444
18554
  );
18445
18555
  }
18556
+ function decisionExitCode(hookName, code) {
18557
+ if (!DECISION_HOOKS.includes(hookName) || code === 0 || code === 2) return code;
18558
+ process.stderr.write(
18559
+ `MASSU SECURITY GATE \u2014 BLOCKED
18560
+
18561
+ The ${hookName} hook ended without a verdict (exit ${code}). A gate that did not decide has not approved this call.
18562
+ `
18563
+ );
18564
+ return 2;
18565
+ }
18446
18566
  async function runHookRunner(args2) {
18447
18567
  const hookName = args2[0];
18448
18568
  if (!hookName) {
@@ -18471,11 +18591,7 @@ Recognized: ${Object.keys(HOOK_NAME_TO_FILE).join(", ")}
18471
18591
  env: { ...process.env, MASSU_HOOK_RUNTIME: "1" }
18472
18592
  });
18473
18593
  child.on("exit", (code, signal) => {
18474
- if (signal) {
18475
- resolvePromise({ exitCode: 128 });
18476
- return;
18477
- }
18478
- resolvePromise({ exitCode: code ?? 0 });
18594
+ resolvePromise({ exitCode: decisionExitCode(hookName, signal ? 128 : code ?? 0) });
18479
18595
  });
18480
18596
  child.on("error", (err) => {
18481
18597
  process.stderr.write(`massu hook-runner: failed to spawn hook "${hookName}": ${err.message}
@@ -19293,6 +19409,10 @@ async function runInstallHooks() {
19293
19409
  }
19294
19410
  const { count } = installHooks(projectRoot);
19295
19411
  console.log(` Installed ${count} hooks in .claude/settings.local.json`);
19412
+ const mcp = refreshMcpServerPin(projectRoot);
19413
+ if (mcp === "updated") console.log(` Updated the massu MCP server in .mcp.json to ${version} (commit this file)`);
19414
+ else if (mcp === "current") console.log(` .mcp.json already runs massu ${version}`);
19415
+ else if (mcp === "custom") console.log(" .mcp.json has a hand-written massu entry \u2014 left unchanged; check its version");
19296
19416
  console.log("");
19297
19417
  console.log("Hooks will activate on your next Claude Code session.");
19298
19418
  console.log("");
@@ -33605,12 +33725,13 @@ function handleCostFeature(args2, db) {
33605
33725
  ORDER BY total_cost DESC
33606
33726
  `).all(`-${days} days`);
33607
33727
  if (rows.length === 0) {
33608
- return text7(`No feature cost data found in the last ${days} days. Feature costs are attributed automatically when sessions work on registered features. Ensure features are registered, then costs are tracked per-session. Try: ${p8("cost_trend")} { days: 30 } for session-level cost trends instead.`);
33728
+ return text7(`No feature cost data found in the last ${days} days. Each session's priced cost is allocated across registered features (massu_sentinel_register) by the files it touched; sessions with unknown cost are skipped. Try: ${p8("cost_trend")} { days: 30 } for session-level cost trends instead.`);
33609
33729
  }
33610
33730
  const totalCost = rows.reduce((sum, r3) => sum + r3.total_cost, 0);
33611
33731
  const lines = [
33612
33732
  `## Feature Cost Attribution (${days} days)`,
33613
- `Total: $${totalCost.toFixed(2)} across ${rows.length} features`,
33733
+ `Total: $${totalCost.toFixed(2)} across ${rows.length} feature row(s)`,
33734
+ "Each session's priced cost is ALLOCATED across the registered features whose files it touched, in proportion to those touches. It is an allocation by activity, not a measured per-feature cost. Sessions with unknown cost are not included.",
33614
33735
  "",
33615
33736
  "| Feature | Cost | Tokens | Sessions | % of Total |",
33616
33737
  "|---------|------|--------|----------|------------|"
@@ -2086,6 +2086,8 @@ __export(memory_db_exports, {
2086
2086
  MEMORY_FILE_TITLE_LIKE: () => MEMORY_FILE_TITLE_LIKE,
2087
2087
  MEMORY_FILE_TITLE_PREFIX: () => MEMORY_FILE_TITLE_PREFIX,
2088
2088
  MEMORY_SCHEMA_VERSION: () => MEMORY_SCHEMA_VERSION,
2089
+ PENDING_SYNC_MAX_AGE_DAYS: () => PENDING_SYNC_MAX_AGE_DAYS,
2090
+ PENDING_SYNC_MAX_ITEMS: () => PENDING_SYNC_MAX_ITEMS,
2089
2091
  TOOL_COST_EVENTS_RETENTION_DAYS: () => TOOL_COST_EVENTS_RETENTION_DAYS,
2090
2092
  USAGE_COUNTER_ARMED_KEY: () => USAGE_COUNTER_ARMED_KEY,
2091
2093
  addConversationTurn: () => addConversationTurn,
@@ -2094,6 +2096,7 @@ __export(memory_db_exports, {
2094
2096
  addSummary: () => addSummary,
2095
2097
  addToolCallDetail: () => addToolCallDetail,
2096
2098
  addUserPrompt: () => addUserPrompt,
2099
+ allPendingSync: () => allPendingSync,
2097
2100
  appendIncidentToFailureClass: () => appendIncidentToFailureClass,
2098
2101
  armUsageCounter: () => armUsageCounter,
2099
2102
  assignImportance: () => assignImportance,
@@ -2101,6 +2104,7 @@ __export(memory_db_exports, {
2101
2104
  createSession: () => createSession,
2102
2105
  deduplicateFailedAttempt: () => deduplicateFailedAttempt,
2103
2106
  dequeuePendingSync: () => dequeuePendingSync,
2107
+ discardPendingSync: () => discardPendingSync,
2104
2108
  embedMissingObservations: () => embedMissingObservations,
2105
2109
  endSession: () => endSession,
2106
2110
  enqueueRulePromotionEvent: () => enqueueRulePromotionEvent,
@@ -3083,6 +3087,35 @@ function initMemorySchema(db) {
3083
3087
  }
3084
3088
  function enqueueSyncPayload(db, payload) {
3085
3089
  db.prepare("INSERT INTO pending_sync (payload) VALUES (?)").run(payload);
3090
+ const overflow = db.prepare(
3091
+ `SELECT id, retry_count, last_error, payload FROM pending_sync
3092
+ WHERE created_at < datetime('now', ?)
3093
+ OR id NOT IN (SELECT id FROM pending_sync ORDER BY created_at DESC, id DESC LIMIT ?)
3094
+ LIMIT 10000`
3095
+ ).all(`-${PENDING_SYNC_MAX_AGE_DAYS} days`, PENDING_SYNC_MAX_ITEMS);
3096
+ if (overflow.length > 0) {
3097
+ discardPendingSync(db, overflow, `over the queue limit (${PENDING_SYNC_MAX_ITEMS} items / ${PENDING_SYNC_MAX_AGE_DAYS} days)`);
3098
+ }
3099
+ }
3100
+ function discardPendingSync(db, rows, why) {
3101
+ if (rows.length === 0) return 0;
3102
+ const rescued = rescueLearningFromStalePayloads(db, rows);
3103
+ for (let i = 0; i < rows.length; i += 500) {
3104
+ const ids = rows.slice(i, i + 500).map((r) => r.id);
3105
+ db.prepare(`DELETE FROM pending_sync WHERE id IN (${ids.map(() => "?").join(",")})`).run(...ids);
3106
+ }
3107
+ if (rescued > 0) {
3108
+ process.stderr.write(
3109
+ `[massu] RESCUED ${rescued} learned rule(s) from ${rows.length} discarded payload(s) \u2014 they were re-queued for delivery, NOT discarded.
3110
+ `
3111
+ );
3112
+ }
3113
+ process.stderr.write(`[massu] cloud sync: discarded ${rows.length} queued upload(s): ${why}.
3114
+ `);
3115
+ return rows.length;
3116
+ }
3117
+ function allPendingSync(db) {
3118
+ return db.prepare("SELECT id, retry_count, last_error, payload FROM pending_sync").all();
3086
3119
  }
3087
3120
  function getMemoryMeta(db, key) {
3088
3121
  const row = db.prepare("SELECT value FROM memory_meta WHERE key = ?").get(key);
@@ -3285,7 +3318,9 @@ function dequeuePendingSync(db, limit = 10) {
3285
3318
  }
3286
3319
  }
3287
3320
  return db.prepare(
3288
- "SELECT id, payload, retry_count FROM pending_sync ORDER BY created_at ASC LIMIT ?"
3321
+ // Least-retried first: oldest-first retried the same 10 items until they were discarded
3322
+ // while every newer item waited at retry 0 (2026-11-09: 43,492 never attempted).
3323
+ "SELECT id, payload, retry_count FROM pending_sync ORDER BY retry_count ASC, created_at ASC LIMIT ?"
3289
3324
  ).all(limit);
3290
3325
  }
3291
3326
  function removePendingSync(db, id) {
@@ -3958,7 +3993,7 @@ function scoreFailureClasses(db, matchText, filePath, promptContext, weights) {
3958
3993
  }
3959
3994
  return bestMatch;
3960
3995
  }
3961
- var MEMORY_SCHEMA_VERSION, TOOL_COST_EVENTS_RETENTION_DAYS, MAX_DRAFT_TEXT_LEN, FUNNEL_EVENT_OUTBOX_CAP, _temporalColCache, CONSOLIDATION_LESSON_EVIDENCE, MEMORY_FILE_TITLE_PREFIX, MEMORY_FILE_TITLE_LIKE, USAGE_COUNTER_ARMED_KEY;
3996
+ var MEMORY_SCHEMA_VERSION, TOOL_COST_EVENTS_RETENTION_DAYS, PENDING_SYNC_MAX_ITEMS, PENDING_SYNC_MAX_AGE_DAYS, MAX_DRAFT_TEXT_LEN, FUNNEL_EVENT_OUTBOX_CAP, _temporalColCache, CONSOLIDATION_LESSON_EVIDENCE, MEMORY_FILE_TITLE_PREFIX, MEMORY_FILE_TITLE_LIKE, USAGE_COUNTER_ARMED_KEY;
3962
3997
  var init_memory_db = __esm({
3963
3998
  "src/memory-db.ts"() {
3964
3999
  "use strict";
@@ -3973,6 +4008,8 @@ var init_memory_db = __esm({
3973
4008
  init_memory_embed_sweep();
3974
4009
  MEMORY_SCHEMA_VERSION = 2;
3975
4010
  TOOL_COST_EVENTS_RETENTION_DAYS = 90;
4011
+ PENDING_SYNC_MAX_ITEMS = 500;
4012
+ PENDING_SYNC_MAX_AGE_DAYS = 30;
3976
4013
  MAX_DRAFT_TEXT_LEN = 16384;
3977
4014
  FUNNEL_EVENT_OUTBOX_CAP = 2e4;
3978
4015
  _temporalColCache = /* @__PURE__ */ new WeakMap();
@@ -4058,7 +4095,7 @@ var init_hook_failure_signal = __esm({
4058
4095
  init_config();
4059
4096
  import { execFileSync } from "child_process";
4060
4097
  import { createHash } from "crypto";
4061
- import { existsSync as existsSync10, readFileSync as readFileSync7, unlinkSync as unlinkSync2, readdirSync as readdirSync2, statSync as statSync2, writeFileSync as writeFileSync3, mkdirSync as mkdirSync7 } from "fs";
4098
+ import { existsSync as existsSync11, readFileSync as readFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync2, statSync as statSync3, writeFileSync as writeFileSync4, mkdirSync as mkdirSync7 } from "fs";
4062
4099
  import { tmpdir as tmpdir2 } from "os";
4063
4100
  import { join as join6, dirname as dirname8 } from "path";
4064
4101
 
@@ -4100,20 +4137,48 @@ function writeHookContext(hookEventName, message) {
4100
4137
  // src/hooks/auto-learning-pipeline.ts
4101
4138
  init_hook_failure_signal();
4102
4139
 
4140
+ // src/lib/safe-write.ts
4141
+ import {
4142
+ writeFileSync as writeFileSync3,
4143
+ renameSync,
4144
+ openSync as openSync2,
4145
+ fsyncSync as fsyncSync2,
4146
+ closeSync as closeSync2,
4147
+ existsSync as existsSync9,
4148
+ realpathSync,
4149
+ unlinkSync as unlinkSync2,
4150
+ accessSync as accessSync2,
4151
+ statSync as statSync2,
4152
+ chmodSync as chmodSync3,
4153
+ constants
4154
+ } from "fs";
4155
+ function isSameRealPath(a, b) {
4156
+ try {
4157
+ return realpathSync(a) === realpathSync(b);
4158
+ } catch {
4159
+ return false;
4160
+ }
4161
+ }
4162
+
4103
4163
  // src/hooks/lib/is-direct-invocation.ts
4104
- import { pathToFileURL } from "url";
4164
+ import { fileURLToPath as fileURLToPath2, pathToFileURL } from "url";
4105
4165
  function isDirectInvocation(moduleUrl) {
4106
4166
  const entry = process.argv[1];
4107
4167
  if (!entry) return false;
4108
4168
  try {
4109
- return moduleUrl === pathToFileURL(entry).href;
4169
+ if (moduleUrl === pathToFileURL(entry).href) return true;
4170
+ } catch {
4171
+ return false;
4172
+ }
4173
+ try {
4174
+ return isSameRealPath(fileURLToPath2(moduleUrl), entry);
4110
4175
  } catch {
4111
4176
  return false;
4112
4177
  }
4113
4178
  }
4114
4179
 
4115
4180
  // src/hooks/lib/session-touched-files.ts
4116
- import { readFileSync as readFileSync6, existsSync as existsSync9, realpathSync } from "fs";
4181
+ import { readFileSync as readFileSync6, existsSync as existsSync10, realpathSync as realpathSync2 } from "fs";
4117
4182
  import { relative, isAbsolute, resolve as resolve5 } from "path";
4118
4183
  var FILE_WRITING_TOOLS = Object.freeze({
4119
4184
  Edit: ["file_path"],
@@ -4123,7 +4188,7 @@ var FILE_WRITING_TOOLS = Object.freeze({
4123
4188
  });
4124
4189
  function canonical(p) {
4125
4190
  try {
4126
- return realpathSync(p);
4191
+ return realpathSync2(p);
4127
4192
  } catch {
4128
4193
  return p;
4129
4194
  }
@@ -4171,7 +4236,7 @@ function transcriptTouchedFiles(root, transcriptPath) {
4171
4236
  return out;
4172
4237
  }
4173
4238
  function flagFileTouchedFiles(root, flagPath) {
4174
- if (!existsSync9(flagPath)) return null;
4239
+ if (!existsSync10(flagPath)) return null;
4175
4240
  let raw;
4176
4241
  try {
4177
4242
  raw = readFileSync6(flagPath, "utf-8");
@@ -4592,7 +4657,7 @@ function readShownKeys(ackPath) {
4592
4657
  }
4593
4658
  function writeShownKeys(ackPath, keys) {
4594
4659
  mkdirSync7(dirname8(ackPath), { recursive: true });
4595
- writeFileSync3(ackPath, JSON.stringify({ shown: [...keys].sort() }), "utf-8");
4660
+ writeFileSync4(ackPath, JSON.stringify({ shown: [...keys].sort() }), "utf-8");
4596
4661
  }
4597
4662
  function freshMatches(matchKeys, shown) {
4598
4663
  return [...new Set(matchKeys)].filter((k) => !shown.has(k));
@@ -4602,7 +4667,7 @@ function recentIncidentFiles(dir, nowMs = Date.now()) {
4602
4667
  try {
4603
4668
  return readdirSync2(dir).filter((f) => f.endsWith(".md")).filter((f) => {
4604
4669
  try {
4605
- return nowMs - statSync2(join6(dir, f)).mtimeMs <= RECENT_INCIDENT_WINDOW_MS;
4670
+ return nowMs - statSync3(join6(dir, f)).mtimeMs <= RECENT_INCIDENT_WINDOW_MS;
4606
4671
  } catch {
4607
4672
  return false;
4608
4673
  }
@@ -4616,7 +4681,7 @@ function readPostImageFrom(root) {
4616
4681
  return (rel) => {
4617
4682
  try {
4618
4683
  const abs = join6(root, rel);
4619
- if (statSync2(abs).size > MAX_POST_IMAGE_BYTES) return null;
4684
+ if (statSync3(abs).size > MAX_POST_IMAGE_BYTES) return null;
4620
4685
  return readFileSync7(abs, "utf-8");
4621
4686
  } catch {
4622
4687
  return null;
@@ -4729,7 +4794,7 @@ async function main() {
4729
4794
  const autoLearn = config.autoLearning;
4730
4795
  const flagPath = getSessionFlagPath(hookInput.session_id);
4731
4796
  let sessionFixes = [];
4732
- if (existsSync10(flagPath)) {
4797
+ if (existsSync11(flagPath)) {
4733
4798
  try {
4734
4799
  sessionFixes = readFileSync7(flagPath, "utf-8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
4735
4800
  } catch {
@@ -4821,16 +4886,16 @@ async function main() {
4821
4886
  }
4822
4887
  function cleanup(flagPath) {
4823
4888
  try {
4824
- if (existsSync10(flagPath)) unlinkSync2(flagPath);
4889
+ if (existsSync11(flagPath)) unlinkSync3(flagPath);
4825
4890
  const dir = join6(tmpdir2(), "massu-auto-learning");
4826
- if (existsSync10(dir)) {
4891
+ if (existsSync11(dir)) {
4827
4892
  const now = Date.now();
4828
4893
  for (const file of readdirSync2(dir)) {
4829
4894
  const fullPath = join6(dir, file);
4830
4895
  try {
4831
- const stat = statSync2(fullPath);
4896
+ const stat = statSync3(fullPath);
4832
4897
  if (now - stat.mtimeMs > 864e5) {
4833
- unlinkSync2(fullPath);
4898
+ unlinkSync3(fullPath);
4834
4899
  }
4835
4900
  } catch {
4836
4901
  }