@massu/core 2.5.0 → 2.5.1

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,67 @@ 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.1] - 2026-08-09
14
+
15
+ ### Fixed
16
+
17
+ - **Hooks emitted an output field that does not exist, so no hook message ever reached
18
+ Claude.** Eleven hooks wrote `{"message": …}` to stdout. `message` is not in Claude Code's
19
+ hook output schema at all — it appears only in `Notification`/`FileChanged` *input* — so
20
+ every advisory string those hooks produced was discarded. The failure was self-inflicted in
21
+ an exact way: because `{"message": …}` is valid JSON, `UserPromptSubmit` parsed it as JSON
22
+ instead of treating it as plain text, so the one event that would have delivered the string
23
+ as stdout is precisely the event that dropped it. A slightly malformed string would have
24
+ arrived.
25
+
26
+ Replaced by a single chokepoint emitting the documented shape,
27
+ `{"hookSpecificOutput": {"hookEventName", "additionalContext"}}`. `hookEventName` is
28
+ REQUIRED, so the helper takes the event as a typed, required argument and the old
29
+ one-argument form is DELETED rather than deprecated — a regression to the silent shape is
30
+ now a compile error, not a silent no-op. An unrecognised event emits nothing and says why
31
+ on stderr. Output is truncated at the spec's 10,000-char cap by us rather than by the
32
+ runtime.
33
+
34
+ **Blocking behaviour is unchanged.** The `PreToolUse` gates deny via `process.exit(2)` plus
35
+ stderr, which never depended on this path; what was lost was their *advisory* (warn-level)
36
+ messages, and everything `rule-enforcement-pipeline` emits, since that hook only ever
37
+ advises.
38
+
39
+ ### Added
40
+
41
+ - **`scripts/ops/retag-release-to-npm-githead.sh`** — audits every release tag against the
42
+ commit npm actually published. A release tag is supposed to answer "which commit shipped as
43
+ X.Y.Z", and two things break that: the tag is created before the post-publish bookkeeping
44
+ commit and then moved forward, or it is never created at all. npm records `gitHead` with
45
+ every publish, so the answer is recoverable — this reads THAT and refuses anything else.
46
+
47
+ Dry-run by default; reports its denominator; FAILS CLOSED when the registry returns no
48
+ usable `gitHead` or names a commit absent from the repo, rather than guessing; never
49
+ force-pushes — it moves the local tag and prints the push command, so publishing a
50
+ rewritten ref stays deliberate.
51
+
52
+ First full audit: `50 tag(s) examined; 6 already correct, 34 drifted, 10 error(s)`. The 2.x
53
+ series is now 7/7 correct; 1.x is left as-is deliberately.
54
+
55
+ ### Fixed
56
+
57
+ - **The documented Stage-D release ceremony had no legal ending.** It ordered
58
+ `bump -> tag -> publish -> .mcp.json pin refresh`, but the pin refresh is a COMMIT, so
59
+ following it always leaves commits on top of a published-and-tagged release — exactly what
60
+ CR-64 refuses. Measured during the 2.5.0 release: gate `[11/22]` returned
61
+ `versionIsPublished:true, tagSha 90c82344, headSha a74648ae`.
62
+
63
+ Corrected to **pin -> publish -> tag**, so the published tree, npm's `gitHead`, the tag and
64
+ HEAD are one commit by construction. Fixed at both sites that state the order. The repo's
65
+ own history already worked this way — `v2.3.1`, `v2.3.0` and `v2.1.0` each tag their pin
66
+ commit — so the prose was the outlier, not the practice.
67
+
68
+ - **The same plan predicted a release does not touch `website/`.** The CHANGELOG is mirrored
69
+ into `website/`, so every release does; gate `[9/22]` failed on a 29h deploy lag. Replaced
70
+ the prediction with the measured fact and made `/massu-deploy` an explicit ceremony step.
71
+
72
+ Incident: `docs/incidents/2026-08-01-release-ceremony-had-no-legal-ending.md`
73
+
13
74
  ## [2.5.0] - 2026-08-01
14
75
 
15
76
  ### Fixed
@@ -41,7 +41,7 @@ Read these in parallel where possible:
41
41
  | 10 | `.claude/metrics/command-invocations.jsonl` | Command usage frequency — top/least used in last 7 days |
42
42
  | 11 | `.claude/metrics/bearings-history.jsonl` | Previous bearings runs — show trends |
43
43
  | 12 | `.claude/commands/` (ls -d */`) | Detect folder-based skills (directories vs flat files) |
44
- | 13 | `bash scripts/memory-contradiction-check.sh` | Contradiction count, consolidation candidates |
44
+ | 13 | `~/.claude/bin/memory-contradiction-check . --baseline .claude/memory-contradiction-baseline.json` | Unresolved pairs + denominator; exit 2 = could-not-look, a FINDING |
45
45
  | 14 | `massu_cost_session` + `massu_cost_trend` MCP tools | Token spend this session, cost trend over time |
46
46
  | 15 | `session-state/deferred-verifications.md` | Pending production verifications from previous deploys |
47
47
 
@@ -104,12 +104,15 @@ If no significant work: skip this section.
104
104
  Run the memory contradiction scanner:
105
105
 
106
106
  ```bash
107
- bash scripts/memory-contradiction-check.sh
107
+ ~/.claude/bin/memory-contradiction-check . --baseline .claude/memory-contradiction-baseline.json
108
108
  ```
109
109
 
110
- Parse the output:
111
- - **CONTRADICTION_COUNT: 0** — Memory is clean. Proceed.
112
- - **CONTRADICTION_COUNT: N** — Display each contradiction to the user:
110
+ Read the EXIT CODE, and read the denominator it prints — a
111
+ verdict with no `memory files read` line means it aborted before reporting:
112
+ - **exit 0** — no unresolved contradictions. Quote `unresolved pairs : 0` as the evidence.
113
+ - **exit 1** — unresolved pairs, or a STALE baseline entry. Display each to the user:
114
+ - **exit 2** — it COULD NOT LOOK (missing corpus, unreadable file, bad baseline). This is
115
+ a FINDING, never a clean run. Report it as such and do not proceed as though clean.
113
116
 
114
117
  ```
115
118
  MEMORY HEALTH
@@ -122,7 +125,15 @@ MEMORY HEALTH
122
125
 
123
126
  **Do NOT auto-resolve contradictions.** Surface them so the user can decide which memory is stale.
124
127
 
125
- If the script is missing or fails, note: "Memory contradiction check unavailable — run `bash scripts/memory-contradiction-check.sh` manually."
128
+ **If the checker is missing, report that as a FAILED check — never as a benign note.**
129
+ This step was unrunnable in this repo from the day it was written until 2026-08-02: it
130
+ named `scripts/memory-contradiction-check.sh`, which never existed here (0 tracked
131
+ matches, 0 commits ever touching the path — it was copied from another repo's protocol).
132
+ The instruction to "note it as unavailable" is what let a mandatory check report nothing
133
+ for months, because "could not look" and "looked and found nothing" read identically.
134
+ The checker is now version-controlled at `~/.claude/bin/memory-contradiction-check`, has a
135
+ caller in `npm test` (`memory-contradiction-check-caller.test.ts`), a fixture suite, and a
136
+ live-fire attack — so its absence is itself a red test.
126
137
 
127
138
  ### Step 3: Update Session State
128
139
 
package/dist/cli.js CHANGED
@@ -1548,6 +1548,16 @@ var init_memory_vector = __esm({
1548
1548
  }
1549
1549
  });
1550
1550
 
1551
+ // src/lib/timestamps.ts
1552
+ function nowIso() {
1553
+ return (/* @__PURE__ */ new Date()).toISOString();
1554
+ }
1555
+ var init_timestamps = __esm({
1556
+ "src/lib/timestamps.ts"() {
1557
+ "use strict";
1558
+ }
1559
+ });
1560
+
1551
1561
  // src/db-backup.ts
1552
1562
  import { existsSync as existsSync6, mkdirSync as mkdirSync4, readdirSync as readdirSync2, statSync as statSync2, unlinkSync, copyFileSync, rmSync as rmSync4 } from "fs";
1553
1563
  import { resolve as resolve3, join as join3, basename, dirname as dirname4 } from "path";
@@ -3972,8 +3982,8 @@ function linkSessionToTask(db, sessionId, taskId) {
3972
3982
  }
3973
3983
  function addConversationTurn(db, sessionId, turnNumber, userPrompt, assistantResponse, toolCallsJson, toolCallCount, promptTokens, responseTokens) {
3974
3984
  const result = db.prepare(`
3975
- INSERT INTO conversation_turns (session_id, turn_number, user_prompt, assistant_response, tool_calls_json, tool_call_count, prompt_tokens, response_tokens)
3976
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3985
+ INSERT INTO conversation_turns (session_id, turn_number, user_prompt, assistant_response, tool_calls_json, tool_call_count, prompt_tokens, response_tokens, created_at)
3986
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3977
3987
  `).run(
3978
3988
  sessionId,
3979
3989
  turnNumber,
@@ -3982,14 +3992,15 @@ function addConversationTurn(db, sessionId, turnNumber, userPrompt, assistantRes
3982
3992
  toolCallsJson,
3983
3993
  toolCallCount,
3984
3994
  promptTokens,
3985
- responseTokens
3995
+ responseTokens,
3996
+ nowIso()
3986
3997
  );
3987
3998
  return Number(result.lastInsertRowid);
3988
3999
  }
3989
4000
  function addToolCallDetail(db, sessionId, turnNumber, toolName, inputSummary, inputSize, outputSize, success, filesInvolved) {
3990
4001
  db.prepare(`
3991
- INSERT INTO tool_call_details (session_id, turn_number, tool_name, tool_input_summary, tool_input_size, tool_output_size, tool_success, files_involved)
3992
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
4002
+ INSERT INTO tool_call_details (session_id, turn_number, tool_name, tool_input_summary, tool_input_size, tool_output_size, tool_success, files_involved, created_at)
4003
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3993
4004
  `).run(
3994
4005
  sessionId,
3995
4006
  turnNumber,
@@ -3998,7 +4009,8 @@ function addToolCallDetail(db, sessionId, turnNumber, toolName, inputSummary, in
3998
4009
  inputSize,
3999
4010
  outputSize,
4000
4011
  success ? 1 : 0,
4001
- filesInvolved ? JSON.stringify(filesInvolved) : null
4012
+ filesInvolved ? JSON.stringify(filesInvolved) : null,
4013
+ nowIso()
4002
4014
  );
4003
4015
  }
4004
4016
  function getLastProcessedLine(db, sessionId) {
@@ -4257,6 +4269,7 @@ var init_memory_db = __esm({
4257
4269
  init_db_driver();
4258
4270
  init_config();
4259
4271
  init_memory_vector();
4272
+ init_timestamps();
4260
4273
  init_db_backup();
4261
4274
  init_rule_delivery();
4262
4275
  init_rule_candidate_store();
@@ -20573,7 +20586,7 @@ import { createHash as createHash8 } from "crypto";
20573
20586
  function renderMemoryFiles(db, candidates, opts) {
20574
20587
  const config = opts.config ?? resolveMemoryFilesConfig();
20575
20588
  if (!config.renderEnabled) {
20576
- return EMPTY("render_disabled");
20589
+ return EMPTY("render_disabled", candidates.length);
20577
20590
  }
20578
20591
  const dryRun = opts.dryRun === true;
20579
20592
  const now = opts.now ?? Date.now();
@@ -20582,7 +20595,7 @@ function renderMemoryFiles(db, candidates, opts) {
20582
20595
  });
20583
20596
  const { memoryDir } = opts;
20584
20597
  if (!existsSync33(memoryDir)) {
20585
- return EMPTY("no_memory_dir");
20598
+ return EMPTY("no_memory_dir", candidates.length);
20586
20599
  }
20587
20600
  try {
20588
20601
  return withMemoryIndexLock(
@@ -20591,10 +20604,10 @@ function renderMemoryFiles(db, candidates, opts) {
20591
20604
  );
20592
20605
  } catch (err) {
20593
20606
  if (err instanceof MemoryIndexLockBusy) {
20594
- return EMPTY("lock_busy");
20607
+ return EMPTY("lock_busy", candidates.length);
20595
20608
  }
20596
20609
  audit("memory_file_render_refused", { reason: "unexpected_error", error: String(err) });
20597
- return EMPTY("error");
20610
+ return EMPTY("error", candidates.length);
20598
20611
  }
20599
20612
  }
20600
20613
  function renderLocked(db, candidates, o) {
@@ -20604,11 +20617,14 @@ function renderLocked(db, candidates, o) {
20604
20617
  try {
20605
20618
  tombstones = readTombstones(memoryDir);
20606
20619
  } catch {
20607
- return EMPTY("tombstone_ledger_unreadable");
20620
+ return EMPTY("tombstone_ledger_unreadable", candidates.length);
20608
20621
  }
20609
20622
  const ownerOf = relPathOwnerLookup(db);
20610
20623
  const planned = [];
20611
- for (const c2 of candidates) {
20624
+ let unchanged = 0;
20625
+ let capped = 0;
20626
+ for (let i = 0; i < candidates.length; i++) {
20627
+ const c2 = candidates[i];
20612
20628
  if (!isLocalOrigin(c2.origin)) {
20613
20629
  refusals.push({ name: c2.name, reason: "non_local_origin", detail: c2.origin || "unknown" });
20614
20630
  audit("memory_file_render_refused", { name: c2.name, reason: "non_local_origin" });
@@ -20664,7 +20680,10 @@ function renderLocked(db, candidates, o) {
20664
20680
  continue;
20665
20681
  }
20666
20682
  const next = composeFile(c2, home);
20667
- if (next !== null && next === existing) continue;
20683
+ if (next !== null && next === existing) {
20684
+ unchanged++;
20685
+ continue;
20686
+ }
20668
20687
  }
20669
20688
  const content = composeFile(c2, home);
20670
20689
  if (content === null) {
@@ -20672,9 +20691,24 @@ function renderLocked(db, candidates, o) {
20672
20691
  continue;
20673
20692
  }
20674
20693
  planned.push({ c: c2, relPath, absPath, content });
20675
- if (planned.length >= config.renderMaxFilesPerSession) break;
20694
+ if (planned.length >= config.renderMaxFilesPerSession) {
20695
+ capped = candidates.length - (i + 1);
20696
+ break;
20697
+ }
20698
+ }
20699
+ const accounted = planned.length + refusals.length + unchanged + capped;
20700
+ if (accounted !== candidates.length) {
20701
+ audit("memory_file_render_refused", {
20702
+ reason: "accounting_leak",
20703
+ considered: candidates.length,
20704
+ accounted
20705
+ });
20706
+ throw new RenderAccountingLeak(
20707
+ `render did not account for every candidate: considered=${candidates.length} but accounted=${accounted} (planned=${planned.length} refused=${refusals.length} unchanged=${unchanged} capped=${capped}). A candidate was dropped without a bucket.`
20708
+ );
20676
20709
  }
20677
20710
  const indexLines = buildIndexLines(db, memoryDir, planned, config);
20711
+ const books = { considered: candidates.length, unchanged, capped };
20678
20712
  if (dryRun) {
20679
20713
  return {
20680
20714
  enabled: true,
@@ -20682,11 +20716,20 @@ function renderLocked(db, candidates, o) {
20682
20716
  written: planned.map((p19) => p19.relPath),
20683
20717
  refusals,
20684
20718
  indexLines,
20685
- bytesWritten: 0
20719
+ bytesWritten: 0,
20720
+ ...books
20686
20721
  };
20687
20722
  }
20688
20723
  if (planned.length === 0 && indexLines.length === 0) {
20689
- return { enabled: true, dryRun: false, written: [], refusals, indexLines: [], bytesWritten: 0 };
20724
+ return {
20725
+ enabled: true,
20726
+ dryRun: false,
20727
+ written: [],
20728
+ refusals,
20729
+ indexLines: [],
20730
+ bytesWritten: 0,
20731
+ ...books
20732
+ };
20690
20733
  }
20691
20734
  if (!hasFreshBackup(memoryDir, home)) {
20692
20735
  try {
@@ -20696,7 +20739,7 @@ function renderLocked(db, candidates, o) {
20696
20739
  reason: "backup_failed",
20697
20740
  error: err instanceof BackupError ? err.message : String(err)
20698
20741
  });
20699
- return EMPTY("backup_failed");
20742
+ return EMPTY("backup_failed", candidates.length);
20700
20743
  }
20701
20744
  }
20702
20745
  const indexPath = o.indexPath ?? join19(memoryDir, "MEMORY.md");
@@ -20764,7 +20807,10 @@ function renderLocked(db, candidates, o) {
20764
20807
  errors: restored.errors
20765
20808
  });
20766
20809
  return {
20767
- ...EMPTY("rollback_incomplete"),
20810
+ ...EMPTY("rollback_incomplete", candidates.length),
20811
+ // The books were balanced before the write began; keep them rather than zeroing.
20812
+ unchanged,
20813
+ capped,
20768
20814
  enabled: true,
20769
20815
  refusals: [
20770
20816
  ...refusals,
@@ -20773,9 +20819,9 @@ function renderLocked(db, candidates, o) {
20773
20819
  };
20774
20820
  }
20775
20821
  audit("memory_file_render_refused", { reason: "write_failed", error: String(err) });
20776
- return { ...EMPTY("write_failed"), enabled: true, refusals };
20822
+ return { ...EMPTY("write_failed", candidates.length), enabled: true, refusals, unchanged, capped };
20777
20823
  }
20778
- return { enabled: true, dryRun: false, written, refusals, indexLines, bytesWritten };
20824
+ return { enabled: true, dryRun: false, written, refusals, indexLines, bytesWritten, ...books };
20779
20825
  }
20780
20826
  function sha2562(s) {
20781
20827
  return createHash8("sha256").update(s, "utf8").digest("hex");
@@ -20844,7 +20890,7 @@ function buildIndexLines(db, memoryDir, planned, config) {
20844
20890
  function oneLine(s) {
20845
20891
  return s.replace(/[\r\n]+/g, " ").slice(0, 120);
20846
20892
  }
20847
- var EMPTY;
20893
+ var RenderAccountingLeak, EMPTY;
20848
20894
  var init_memory_renderer = __esm({
20849
20895
  "src/memory-renderer.ts"() {
20850
20896
  "use strict";
@@ -20859,14 +20905,22 @@ var init_memory_renderer = __esm({
20859
20905
  init_memory_index_region();
20860
20906
  init_memory_files_config();
20861
20907
  init_memory_file_ingest();
20862
- EMPTY = (reason) => ({
20908
+ RenderAccountingLeak = class extends Error {
20909
+ };
20910
+ EMPTY = (reason, considered = 0) => ({
20863
20911
  enabled: false,
20864
20912
  dryRun: false,
20865
20913
  written: [],
20866
20914
  refusals: [],
20867
20915
  indexLines: [],
20868
20916
  bytesWritten: 0,
20869
- skippedReason: reason
20917
+ skippedReason: reason,
20918
+ // An early gate refused, so every candidate was dropped — by that ONE named reason.
20919
+ // Reporting `considered` here is what distinguishes "nothing to do" from "walked away
20920
+ // from 59 rows because the lock was busy".
20921
+ considered,
20922
+ unchanged: 0,
20923
+ capped: 0
20870
20924
  });
20871
20925
  }
20872
20926
  });
@@ -21057,25 +21111,46 @@ var init_memory_render_cli = __esm({
21057
21111
  function loadRenderCandidates(db) {
21058
21112
  const cfg = resolveMemoryFilesConfig();
21059
21113
  const typePlaceholders = RENDERABLE_MEMORY_TYPES.map(() => "?").join(", ");
21114
+ const WHERE = `WHERE importance >= ?
21115
+ AND COALESCE(expired_at_epoch, 0) = 0
21116
+ AND type IN (${typePlaceholders})`;
21117
+ const { population } = db.prepare(`SELECT COUNT(*) AS population FROM observations ${WHERE}`).get(cfg.renderMinImportance, ...RENDERABLE_MEMORY_TYPES);
21060
21118
  const rows = db.prepare(
21061
21119
  `SELECT id, title, detail, importance, COALESCE(origin, 'local') AS origin
21062
21120
  FROM observations
21063
- WHERE importance >= ?
21064
- AND COALESCE(expired_at_epoch, 0) = 0
21065
- AND type IN (${typePlaceholders})
21121
+ ${WHERE}
21066
21122
  ORDER BY importance DESC, created_at_epoch DESC
21067
- LIMIT 50`
21123
+ LIMIT ${CANDIDATE_WINDOW}`
21068
21124
  ).all(cfg.renderMinImportance, ...RENDERABLE_MEMORY_TYPES);
21069
- return rows.filter((r2) => !r2.title.startsWith("[memory-file]")).map((r2) => ({
21070
- observationId: r2.id,
21071
- name: r2.title,
21072
- title: r2.title,
21073
- body: r2.detail ?? "",
21074
- importance: r2.importance,
21075
- origin: r2.origin
21076
- }));
21125
+ const reingest = rows.filter((r2) => r2.title.startsWith("[memory-file]"));
21126
+ const kept = rows.filter((r2) => !r2.title.startsWith("[memory-file]"));
21127
+ const excluded = reingest.length ? [{ reason: EXCLUSION_MEMORY_FILE_REINGEST, count: reingest.length }] : [];
21128
+ const ledger = {
21129
+ population,
21130
+ windowed: rows.length,
21131
+ truncatedByWindow: Math.max(0, population - rows.length),
21132
+ excluded,
21133
+ returned: kept.length
21134
+ };
21135
+ const accounted = ledger.returned + ledger.truncatedByWindow + excluded.reduce((n, e2) => n + e2.count, 0);
21136
+ if (accounted !== population) {
21137
+ throw new CandidateAccountingLeak(
21138
+ `candidate ledger does not balance: population=${population} but accounted=${accounted} (returned=${ledger.returned} truncated=${ledger.truncatedByWindow} excluded=${JSON.stringify(excluded)}). A row was dropped without a bucket.`
21139
+ );
21140
+ }
21141
+ return {
21142
+ ledger,
21143
+ candidates: kept.map((r2) => ({
21144
+ observationId: r2.id,
21145
+ name: r2.title,
21146
+ title: r2.title,
21147
+ body: r2.detail ?? "",
21148
+ importance: r2.importance,
21149
+ origin: r2.origin
21150
+ }))
21151
+ };
21077
21152
  }
21078
- var RENDERABLE_MEMORY_TYPES;
21153
+ var RENDERABLE_MEMORY_TYPES, CANDIDATE_WINDOW, EXCLUSION_MEMORY_FILE_REINGEST, CandidateAccountingLeak;
21079
21154
  var init_memory_render_candidates = __esm({
21080
21155
  "src/memory-render-candidates.ts"() {
21081
21156
  "use strict";
@@ -21086,6 +21161,10 @@ var init_memory_render_candidates = __esm({
21086
21161
  "incident_near_miss",
21087
21162
  "cr_violation"
21088
21163
  ];
21164
+ CANDIDATE_WINDOW = 50;
21165
+ EXCLUSION_MEMORY_FILE_REINGEST = "memory_file_reingest";
21166
+ CandidateAccountingLeak = class extends Error {
21167
+ };
21089
21168
  }
21090
21169
  });
21091
21170
 
@@ -21165,13 +21244,13 @@ function pruneNoiseObservations(db, opts = {}) {
21165
21244
  const counts = countNoise(db);
21166
21245
  if (dryRun || counts.total === 0) return { counts, expired: 0, dryRun };
21167
21246
  const nowSec = opts.now ?? Math.floor(Date.now() / 1e3);
21168
- const nowIso = new Date(nowSec * 1e3).toISOString();
21247
+ const nowIso2 = new Date(nowSec * 1e3).toISOString();
21169
21248
  const res = db.prepare(
21170
21249
  `UPDATE observations
21171
21250
  SET expired_at = ?, expired_at_epoch = ?, valid_to = ?, valid_to_epoch = ?
21172
21251
  WHERE COALESCE(expired_at_epoch, 0) = 0
21173
21252
  AND ((${DA_PREDICATE}) OR (${FT_DECISION_PREDICATE}) OR (${DB_PREDICATE}))`
21174
- ).run(nowIso, nowSec, nowIso, nowSec);
21253
+ ).run(nowIso2, nowSec, nowIso2, nowSec);
21175
21254
  return { counts, expired: res.changes, dryRun: false };
21176
21255
  }
21177
21256
  var DA_PREDICATE, FT_DECISION_PREDICATE, DB_PREDICATE;
@@ -21216,7 +21295,7 @@ async function runMemoryRenderCli(sub, args2) {
21216
21295
  };
21217
21296
  }
21218
21297
  const cfg = resolveMemoryFilesConfig();
21219
- const candidates = loadRenderCandidates(db);
21298
+ const { candidates, ledger } = loadRenderCandidates(db);
21220
21299
  const result = renderMemoryFiles(db, candidates, {
21221
21300
  memoryDir,
21222
21301
  dryRun: true,
@@ -21231,6 +21310,19 @@ async function runMemoryRenderCli(sub, args2) {
21231
21310
  }
21232
21311
  lines.push(`Massu would write ${result.written.length} file(s):`);
21233
21312
  for (const f2 of result.written) lines.push(` ${f2}`);
21313
+ lines.push("", "Where every row went:");
21314
+ lines.push(` candidate population (type+importance+expiry) : ${ledger.population}`);
21315
+ if (ledger.truncatedByWindow > 0) {
21316
+ lines.push(` truncated by the row window : ${ledger.truncatedByWindow}`);
21317
+ }
21318
+ for (const e2 of ledger.excluded) {
21319
+ lines.push(` excluded: ${e2.reason.padEnd(30)} : ${e2.count}`);
21320
+ }
21321
+ lines.push(` -> reached the renderer : ${ledger.returned}`);
21322
+ lines.push(
21323
+ ` renderer: considered ${result.considered} = written ${result.written.length} + refused ${result.refusals.length} + unchanged ${result.unchanged} + capped ${result.capped}`
21324
+ );
21325
+ lines.push(` skipped: ${result.skippedReason ?? "(none)"}`);
21234
21326
  if (result.indexLines.length > 0) {
21235
21327
  lines.push("", "MEMORY.md \u2014 inside the massu:learned region only:");
21236
21328
  for (const l2 of result.indexLines) lines.push(` ${l2}`);
@@ -23389,7 +23481,7 @@ async function pullInstalledPackRules(db, opts = {}) {
23389
23481
  }
23390
23482
  const packs = parsePacks(envelope.packs_json);
23391
23483
  const result = { ...ZERO };
23392
- const nowIso = (/* @__PURE__ */ new Date()).toISOString();
23484
+ const nowIso2 = (/* @__PURE__ */ new Date()).toISOString();
23393
23485
  for (const pack of packs) {
23394
23486
  if (!isValidInstalledPack(pack)) continue;
23395
23487
  for (const rawRule of pack.rules) {
@@ -23406,7 +23498,7 @@ async function pullInstalledPackRules(db, opts = {}) {
23406
23498
  result.skipped += 1;
23407
23499
  continue;
23408
23500
  }
23409
- materializeCandidate(projectRoot, candidatePath, promptHash, rule, pack, signedOrgId, nowIso, opts.home ?? homedir20());
23501
+ materializeCandidate(projectRoot, candidatePath, promptHash, rule, pack, signedOrgId, nowIso2, opts.home ?? homedir20());
23410
23502
  result.materialized += 1;
23411
23503
  }
23412
23504
  }
@@ -23453,7 +23545,7 @@ function alreadyApplied(db, promptHash) {
23453
23545
  return false;
23454
23546
  }
23455
23547
  }
23456
- function materializeCandidate(projectRoot, candidatePath, promptHash, rule, pack, orgId, nowIso, home) {
23548
+ function materializeCandidate(projectRoot, candidatePath, promptHash, rule, pack, orgId, nowIso2, home) {
23457
23549
  const promptText = `${rule.title}: ${rule.description}`.replace(/\n+/g, " ").slice(0, 200) || `pack rule ${promptHash}`;
23458
23550
  const draftText = rule.pattern ?? rule.check ?? rule.description;
23459
23551
  const hardened = isExecutableDestination(rule.destination);
@@ -23469,7 +23561,7 @@ function materializeCandidate(projectRoot, candidatePath, promptHash, rule, pack
23469
23561
  score: 0,
23470
23562
  signals: [],
23471
23563
  prior_turn_files: [],
23472
- timestamp: nowIso,
23564
+ timestamp: nowIso2,
23473
23565
  session_id: `pack:${pack.slug}`,
23474
23566
  // Provenance (P2-002/P2-003): the applier's team-origin / hardened apply-gate
23475
23567
  // keys on this. `origin:'pack'` rides the SAME gate as a team origin
@@ -23481,7 +23573,7 @@ function materializeCandidate(projectRoot, candidatePath, promptHash, rule, pack
23481
23573
  origin: "pack",
23482
23574
  org_id: orgId,
23483
23575
  promoted_by: `pack:${pack.slug}`,
23484
- promoted_at: nowIso,
23576
+ promoted_at: nowIso2,
23485
23577
  signature_verified: true,
23486
23578
  ...applyMac ? { apply_mac: applyMac } : {},
23487
23579
  pack_slug: pack.slug,
@@ -34681,7 +34773,7 @@ function indexAllKnowledge(db) {
34681
34773
  files.push(...docsFiles);
34682
34774
  }
34683
34775
  const now = /* @__PURE__ */ new Date();
34684
- const nowIso = now.toISOString();
34776
+ const nowIso2 = now.toISOString();
34685
34777
  const nowEpoch = now.getTime();
34686
34778
  const transaction = db.transaction(() => {
34687
34779
  db.exec("DROP TRIGGER IF EXISTS kc_fts_delete");
@@ -34725,7 +34817,7 @@ function indexAllKnowledge(db) {
34725
34817
  const category = categorizeFile(filePath);
34726
34818
  const title = extractTitle(content, filePath);
34727
34819
  const description = extractDescription2(content);
34728
- const result = insertDoc.run(relPath, category, title, description, hash, nowIso, nowEpoch);
34820
+ const result = insertDoc.run(relPath, category, title, description, hash, nowIso2, nowEpoch);
34729
34821
  const docId = result.lastInsertRowid;
34730
34822
  stats.filesIndexed++;
34731
34823
  const sections = parseSections(content, filePath);
@@ -1339,6 +1339,16 @@ var init_memory_vector = __esm({
1339
1339
  }
1340
1340
  });
1341
1341
 
1342
+ // src/lib/timestamps.ts
1343
+ function nowIso() {
1344
+ return (/* @__PURE__ */ new Date()).toISOString();
1345
+ }
1346
+ var init_timestamps = __esm({
1347
+ "src/lib/timestamps.ts"() {
1348
+ "use strict";
1349
+ }
1350
+ });
1351
+
1342
1352
  // src/db-backup.ts
1343
1353
  import { existsSync as existsSync5, mkdirSync as mkdirSync4, readdirSync, statSync, unlinkSync, copyFileSync, rmSync as rmSync4 } from "fs";
1344
1354
  import { resolve as resolve3, join as join3, basename, dirname as dirname4 } from "path";
@@ -3599,8 +3609,8 @@ function linkSessionToTask(db, sessionId, taskId) {
3599
3609
  }
3600
3610
  function addConversationTurn(db, sessionId, turnNumber, userPrompt, assistantResponse, toolCallsJson, toolCallCount, promptTokens, responseTokens) {
3601
3611
  const result = db.prepare(`
3602
- INSERT INTO conversation_turns (session_id, turn_number, user_prompt, assistant_response, tool_calls_json, tool_call_count, prompt_tokens, response_tokens)
3603
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3612
+ INSERT INTO conversation_turns (session_id, turn_number, user_prompt, assistant_response, tool_calls_json, tool_call_count, prompt_tokens, response_tokens, created_at)
3613
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3604
3614
  `).run(
3605
3615
  sessionId,
3606
3616
  turnNumber,
@@ -3609,14 +3619,15 @@ function addConversationTurn(db, sessionId, turnNumber, userPrompt, assistantRes
3609
3619
  toolCallsJson,
3610
3620
  toolCallCount,
3611
3621
  promptTokens,
3612
- responseTokens
3622
+ responseTokens,
3623
+ nowIso()
3613
3624
  );
3614
3625
  return Number(result.lastInsertRowid);
3615
3626
  }
3616
3627
  function addToolCallDetail(db, sessionId, turnNumber, toolName, inputSummary, inputSize, outputSize, success, filesInvolved) {
3617
3628
  db.prepare(`
3618
- INSERT INTO tool_call_details (session_id, turn_number, tool_name, tool_input_summary, tool_input_size, tool_output_size, tool_success, files_involved)
3619
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3629
+ INSERT INTO tool_call_details (session_id, turn_number, tool_name, tool_input_summary, tool_input_size, tool_output_size, tool_success, files_involved, created_at)
3630
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3620
3631
  `).run(
3621
3632
  sessionId,
3622
3633
  turnNumber,
@@ -3625,7 +3636,8 @@ function addToolCallDetail(db, sessionId, turnNumber, toolName, inputSummary, in
3625
3636
  inputSize,
3626
3637
  outputSize,
3627
3638
  success ? 1 : 0,
3628
- filesInvolved ? JSON.stringify(filesInvolved) : null
3639
+ filesInvolved ? JSON.stringify(filesInvolved) : null,
3640
+ nowIso()
3629
3641
  );
3630
3642
  }
3631
3643
  function getLastProcessedLine(db, sessionId) {
@@ -3884,6 +3896,7 @@ var init_memory_db = __esm({
3884
3896
  init_db_driver();
3885
3897
  init_config();
3886
3898
  init_memory_vector();
3899
+ init_timestamps();
3887
3900
  init_db_backup();
3888
3901
  init_rule_delivery();
3889
3902
  init_rule_candidate_store();
@@ -3980,12 +3993,43 @@ import { tmpdir as tmpdir2 } from "os";
3980
3993
  import { join as join6 } from "path";
3981
3994
 
3982
3995
  // src/hooks/lib/write-hook-message.ts
3983
- function writeHookMessage(message) {
3984
- process.stdout.write(JSON.stringify({ message }) + "\n");
3996
+ var HOOK_EVENTS = [
3997
+ "UserPromptSubmit",
3998
+ "UserPromptExpansion",
3999
+ "SessionStart",
4000
+ "PreToolUse",
4001
+ "PostToolUse",
4002
+ "Stop",
4003
+ "SubagentStop",
4004
+ "PreCompact"
4005
+ ];
4006
+ var ADDITIONAL_CONTEXT_MAX_CHARS = 1e4;
4007
+ function isHookEvent(value) {
4008
+ return typeof value === "string" && HOOK_EVENTS.includes(value);
4009
+ }
4010
+ function writeHookContext(hookEventName, message) {
4011
+ if (!isHookEvent(hookEventName)) {
4012
+ process.stderr.write(
4013
+ `[massu] writeHookContext: refusing to emit \u2014 unrecognised hook event ${JSON.stringify(hookEventName)}. Known: ${HOOK_EVENTS.join(", ")}.
4014
+ `
4015
+ );
4016
+ return;
4017
+ }
4018
+ if (typeof message !== "string" || message.length === 0) return;
4019
+ let additionalContext = message;
4020
+ if (additionalContext.length > ADDITIONAL_CONTEXT_MAX_CHARS) {
4021
+ const marker = `
4022
+ [massu] \u2026truncated at ${ADDITIONAL_CONTEXT_MAX_CHARS} chars (spec cap).`;
4023
+ additionalContext = additionalContext.slice(0, ADDITIONAL_CONTEXT_MAX_CHARS - marker.length) + marker;
4024
+ }
4025
+ process.stdout.write(
4026
+ JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } }) + "\n"
4027
+ );
3985
4028
  }
3986
4029
 
3987
4030
  // src/hooks/auto-learning-pipeline.ts
3988
4031
  init_hook_failure_signal();
4032
+ var HOOK_EVENT = "Stop";
3989
4033
  var MAX_FULL_DIFF_BYTES = 2 * 1024 * 1024;
3990
4034
  function getSessionFlagPath(sessionId) {
3991
4035
  return join6(tmpdir2(), "massu-auto-learning", `fixes-${sessionId.slice(0, 12)}.jsonl`);
@@ -4104,7 +4148,7 @@ async function main() {
4104
4148
  lines.push("");
4105
4149
  lines.push("============================================================================");
4106
4150
  lines.push("");
4107
- writeHookMessage(lines.join("\n"));
4151
+ writeHookContext(HOOK_EVENT, lines.join("\n"));
4108
4152
  cleanup(flagPath);
4109
4153
  } catch (err) {
4110
4154
  recordHookFailure("auto-learning-pipeline", err);