@massu/core 2.5.0 → 2.5.2

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,237 @@ 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.2] - 2026-08-14
14
+
15
+ ### Fixed
16
+
17
+ - **The code-intelligence index was empty, and an empty index looked exactly like a project
18
+ with no imports.** Ten places across seven modules each answered "where does this project's
19
+ source live?" with the literal `src/`. In any layout that is not single-package every one of
20
+ them matched nothing: the import index consumed **0 of 1266** indexed files, the page index
21
+ found **0 of 69** real pages, cross-domain analysis saw one package of four, and two hooks
22
+ exited silently on every edit and every deletion. Nothing reported it, because a builder that
23
+ reads no rows returns `0` and the caller prints that as a normal result.
24
+
25
+ The layout now declares itself in `massu.config.yaml` and every consumer asks one module.
26
+ Everything is derived from declarations that already existed — `paths.source`,
27
+ `paths.monorepo_roots`, `framework.languages.*.source_dirs`, `paths.pages`,
28
+ `paths.components` — and one definition feeds both the SQL predicates and the in-JS path
29
+ guards, so widening a query cannot leave its recursion guard behind. A builder whose declared
30
+ source dirs match **0 rows of a non-empty** file table now raises an error naming the
31
+ denominator, the declaration, and the prefixes actually present. The contract is `0-of-N`,
32
+ never `0`: a repo with an empty or not-yet-built index legitimately matches nothing and must
33
+ not trip it.
34
+
35
+ Measured after, on the same corpus: 1128 of 1266 files consumed, 1096 import edges where the
36
+ table had been empty, 69 pages indexed, and every downstream read site non-empty.
37
+
38
+ - **A failed knowledge re-index was indistinguishable from a successful one.** The helper that
39
+ refreshes the markdown corpus caught every exception without binding it and returned a boolean
40
+ the only call site discarded. A corrupt database, a permissions error and a search-index
41
+ failure therefore produced byte-identical output to a healthy rebuild: the tool answered from
42
+ whatever the index held before and said nothing.
43
+
44
+ The error is now bound and recorded, and a failed refresh prepends a notice to the response so
45
+ the caller knows the answer may be stale. Loud is not fatal — the tool still answers from the
46
+ index it has, because turning a degradation into an outage for all twelve knowledge tools is
47
+ what the "degrade gracefully" contract exists to avoid.
48
+
49
+ The rebuild itself is no longer a side effect of dispatching. It cost 677 documents / 11,954
50
+ chunks on any file-modification under four scanned roots, and no caller had asked for it;
51
+ callers now state their intent explicitly, with no default, so an unrequested full rebuild
52
+ cannot happen by omission.
53
+
54
+ - **The session-end fix reminder fired on other sessions' work, and could not be discharged.**
55
+ Three defects compounded. Documentation was excluded from two of the three git reads but not
56
+ from the one whose text is actually scored, so the exclusion held only when a diff was
57
+ documentation-only — change any code file and prose was scored as code again. The diff was
58
+ read over the whole working tree with no notion of who made the changes, so on a machine where
59
+ several sessions hold one repository at once the reminder routinely demanded an artifact for
60
+ work the blocked session had not done. And the `autoLearning.fixDetection.enabled` switch,
61
+ which reads as the control for fix detection, never reached this reminder at all.
62
+
63
+ All three git reads now carry the documentation exclusion; the scan is intersected with files
64
+ the acting session actually touched, including the body read and not merely the gate; and the
65
+ switch gates both halves. When the actor cannot be established the reminder stays **silent** —
66
+ a demand that no legal action can discharge is one people route around, after which nothing is
67
+ enforced. The verdict carries a reason and a scanned-file count, so a negative result is never
68
+ an unexplained silence.
69
+
70
+ - **The reality gate's hook check was red forever, and the only way to green it was to
71
+ destroy its own evidence.** R-3 failed on the LIFETIME row count of
72
+ `.massu/hook-failures.jsonl`, an append-only log retained on purpose. From the first
73
+ failure ever recorded the gate stayed red permanently — at 11,116 rows it had long since
74
+ stopped carrying information, and a gate whose sole remedy is deleting its input is one
75
+ people learn to route around.
76
+
77
+ The predicate is now RECENCY: the check fails when a failure lies inside a bounded window
78
+ (default 24h, `MASSU_HOOK_FAILURE_WINDOW_HOURS`) and names the offending row's timestamp
79
+ and hook, while the historical backlog is reported as context and never truncated. The
80
+ denominator prints on every run, so "scanned 0 rows, found 0 failures" can never be
81
+ mistaken for a clean log.
82
+
83
+ An absent log is no longer collapsed into either answer. It is corroborated against the
84
+ independent `hook_health` table the same writer populates: rows there prove failures were
85
+ recorded in this checkout, so a missing file means the evidence was destroyed and the gate
86
+ fails; no rows means there is nothing to measure and the check skips with the reason
87
+ named. Treating absence as an error — as originally specified — would have made the
88
+ nightly gate permanently red in CI, where the log is gitignored, and on every fresh
89
+ install, where it is created lazily on first failure.
90
+
91
+ Not a package change: `scripts/` is not in the published `files` array.
92
+ Plan `plan-2026-08-11-hook-failure-signal-truthfulness`.
93
+
94
+ - **The release verifier reported FAIL on a publish that had fully succeeded.** It slept two
95
+ seconds, read the npm registry once, and treated "the replica has not caught up" and "the
96
+ publish did not land" as the same answer. npm is read-after-write eventually consistent, so
97
+ both states produce the identical value.
98
+
99
+ This is the blind-gate law with the polarity flipped: usually absence reads as PASS, here it
100
+ read as FAIL — the same defect, a check that cannot distinguish *not yet* from *not ever*.
101
+ The flipped polarity is what makes it dangerous, because a false FAIL on a release tool
102
+ invites a re-publish or a version bump against a registry where versions are immutable.
103
+
104
+ Replaced with a deadline-bounded poll (90s, 3s interval, overridable) that reports how long
105
+ it waited and, on failure, states plainly that the absence is not propagation lag. The
106
+ demand itself was correct and is unchanged — `+ @massu/core@x.y.z` on stdout is not a
107
+ receipt; only the timeout was wrong.
108
+
109
+ Not a package change: `scripts/` is not in the published `files` array.
110
+
111
+ - **`require()` of a TypeScript specifier held CI red in both repos for two days.** A
112
+ `require()` is resolved by whatever loader is running — not by `tsc`, and not by the vitest
113
+ transform. Node only gained require-of-TypeScript in 22.18; `packages/core` declares
114
+ `engines.node >= 22.16.0` and CI pins exactly that floor, so three such calls in
115
+ `hooks-stdout-convention.test.ts` passed on a Node 26 dev machine and threw
116
+ `SyntaxError: Unexpected identifier 'as'` on every runner. Replaced with a static ESM import
117
+ and guarded by a new drift-guard, which is a static property of the source and so goes red on
118
+ any Node version. A fourth call site in production code was found by enumerating the tree
119
+ rather than the failure, and stays: `require()` defers evaluation while a static import is
120
+ eager, and that laziness is what keeps a database load failure from silencing the hook-failure
121
+ signal it records.
122
+
123
+ - **Publication to the public mirror now goes through a pull request.** A direct push to the
124
+ mirror's `main` cannot be gated by its required status checks — checks have not run on a
125
+ commit that does not exist yet — so every sync reported `Bypassed rule violations` and landed
126
+ regardless. That is what hid the above for two days. `scripts/ops/publish-public-mirror.sh`
127
+ derives the required contexts from the live ruleset, polls each to a deadline, treats a
128
+ cancelled or missing verdict as *not* a pass, and merges by rebase only when every one is
129
+ SUCCESS. It enforces the verdict itself rather than relying on the ruleset, because the
130
+ ruleset grants the pushing account a standing bypass.
131
+
132
+ - **A bcrypt cost factor was a wall-clock budget in disguise.** `generateApiKey()` hashes at
133
+ cost 12 — ~350ms of deliberate CPU — in three tests that do not test hashing, which blew
134
+ vitest's 5000ms timeout under the concurrent coverage run while passing in isolation. Lowering
135
+ the cost factor would have been a security regression with nothing in the tree to catch it, so
136
+ `BCRYPT_COST` is now named and the property is asserted directly: the configured cost reaches
137
+ bcrypt, with the full key and never the plaintext-stored prefix.
138
+
139
+ - **Importing any of 18 hook modules executed the hook.** A bare `main()` at module scope means
140
+ importing the module reads stdin, does its work, and exits the host process. Enumerating the
141
+ tree found 15 with no guard and 2 more guarded by a filename suffix — invisible to a sweep for
142
+ "unguarded", and disarmed silently by a rename. All 18 now share one
143
+ `isDirectInvocation(import.meta.url)` helper, proven behaviourally against the built bundles.
144
+
145
+ - **A live destructive-path defect was hiding behind a permanently-red CI job.** Three sites in a
146
+ test harness widened to the filesystem root when a variable was empty, one of them under the
147
+ memory-store tree. The guard had been reporting it correctly on every run for long enough that
148
+ its redness stopped carrying information. Destructive paths there now go through a chokepoint
149
+ that cannot express the unsafe form. (Plan
150
+ `plan-2026-08-08-incident-coverage-range-discharge`: the CR-62 linkage ratchet shipped
151
+ 2026-08-09 gains its real-history differential, which shows all 10 post-ratchet obligated
152
+ commits discharging via containment or citation and none via the legacy fallback.)
153
+
154
+ None of these are package changes: `scripts/` and `__tests__/` are not in the published
155
+ `files` array, and `website/` is not published.
156
+
157
+ - **`massu watch` could start a file watcher that was dead for its entire lifetime.** chokidar
158
+ can return a watcher that emits `ready`, emits no `error`, and then delivers nothing, ever.
159
+ Interleaved A/B in a single load window: fsevents **8 of 60** dead against **0 of 60** for the
160
+ same directories with polling (Fisher's exact ≈ 0.006). The consequence for a user is silent
161
+ staleness — `.claude/` stops refreshing, `massu watch --status` looks healthy, and nothing is
162
+ logged. Nothing in the daemon would ever have noticed: its only reconcile trigger was a tick
163
+ gap over 30s, whose predicate is *"did the clock jump?"* while the property is *"might we have
164
+ missed events?"*, and a watcher dead from birth produces no tick gap at all.
165
+
166
+ The tick now writes a sentinel the watcher itself must echo back. Silence is treated as death:
167
+ log loudly, reconcile, and after two consecutive dead intervals close and re-watch. Escalation
168
+ fires on powers of two of the dead streak, so a permanently dead watcher cannot trade a
169
+ silent-staleness bug for a full refresh plus a watcher rebuild every ten seconds forever.
170
+ Polling was rejected as the fix — it trades a correctness bug for a battery-life bug.
171
+
172
+ Two constants here are claims about the outside world and were measured rather than assumed.
173
+ The canary shares ONE recursive FSEvents stream with the watched source directories (verified
174
+ by recording the paths streams are actually opened on — a canary on its own stream could have
175
+ been alive while the source watcher was dead), and its grace floor is set from measured echo
176
+ latency: ~48ms median, but a tail to 941ms under contention.
177
+ `scripts/ops/measure-canary-echo-latency.mjs` ships so the number can be re-checked instead of
178
+ trusted. Plan `plan-2026-08-12-watch-daemon-silent-dead-watcher`.
179
+
180
+ This one IS a package change: `packages/core/src/watch/daemon.ts` and the new
181
+ `packages/core/src/watch/canary.ts` are published.
182
+
183
+ ## [2.5.1] - 2026-08-09
184
+
185
+ ### Fixed
186
+
187
+ - **Hooks emitted an output field that does not exist, so no hook message ever reached
188
+ Claude.** Eleven hooks wrote `{"message": …}` to stdout. `message` is not in Claude Code's
189
+ hook output schema at all — it appears only in `Notification`/`FileChanged` *input* — so
190
+ every advisory string those hooks produced was discarded. The failure was self-inflicted in
191
+ an exact way: because `{"message": …}` is valid JSON, `UserPromptSubmit` parsed it as JSON
192
+ instead of treating it as plain text, so the one event that would have delivered the string
193
+ as stdout is precisely the event that dropped it. A slightly malformed string would have
194
+ arrived.
195
+
196
+ Replaced by a single chokepoint emitting the documented shape,
197
+ `{"hookSpecificOutput": {"hookEventName", "additionalContext"}}`. `hookEventName` is
198
+ REQUIRED, so the helper takes the event as a typed, required argument and the old
199
+ one-argument form is DELETED rather than deprecated — a regression to the silent shape is
200
+ now a compile error, not a silent no-op. An unrecognised event emits nothing and says why
201
+ on stderr. Output is truncated at the spec's 10,000-char cap by us rather than by the
202
+ runtime.
203
+
204
+ **Blocking behaviour is unchanged.** The `PreToolUse` gates deny via `process.exit(2)` plus
205
+ stderr, which never depended on this path; what was lost was their *advisory* (warn-level)
206
+ messages, and everything `rule-enforcement-pipeline` emits, since that hook only ever
207
+ advises.
208
+
209
+ ### Added
210
+
211
+ - **`scripts/ops/retag-release-to-npm-githead.sh`** — audits every release tag against the
212
+ commit npm actually published. A release tag is supposed to answer "which commit shipped as
213
+ X.Y.Z", and two things break that: the tag is created before the post-publish bookkeeping
214
+ commit and then moved forward, or it is never created at all. npm records `gitHead` with
215
+ every publish, so the answer is recoverable — this reads THAT and refuses anything else.
216
+
217
+ Dry-run by default; reports its denominator; FAILS CLOSED when the registry returns no
218
+ usable `gitHead` or names a commit absent from the repo, rather than guessing; never
219
+ force-pushes — it moves the local tag and prints the push command, so publishing a
220
+ rewritten ref stays deliberate.
221
+
222
+ First full audit: `50 tag(s) examined; 6 already correct, 34 drifted, 10 error(s)`. The 2.x
223
+ series is now 7/7 correct; 1.x is left as-is deliberately.
224
+
225
+ ### Fixed
226
+
227
+ - **The documented Stage-D release ceremony had no legal ending.** It ordered
228
+ `bump -> tag -> publish -> .mcp.json pin refresh`, but the pin refresh is a COMMIT, so
229
+ following it always leaves commits on top of a published-and-tagged release — exactly what
230
+ CR-64 refuses. Measured during the 2.5.0 release: gate `[11/22]` returned
231
+ `versionIsPublished:true, tagSha 90c82344, headSha a74648ae`.
232
+
233
+ Corrected to **pin -> publish -> tag**, so the published tree, npm's `gitHead`, the tag and
234
+ HEAD are one commit by construction. Fixed at both sites that state the order. The repo's
235
+ own history already worked this way — `v2.3.1`, `v2.3.0` and `v2.1.0` each tag their pin
236
+ commit — so the prose was the outlier, not the practice.
237
+
238
+ - **The same plan predicted a release does not touch `website/`.** The CHANGELOG is mirrored
239
+ into `website/`, so every release does; gate `[9/22]` failed on a 29h deploy lag. Replaced
240
+ the prediction with the measured fact and made `/massu-deploy` an explicit ceremony step.
241
+
242
+ Incident: `docs/incidents/2026-08-01-release-ceremony-had-no-legal-ending.md`
243
+
13
244
  ## [2.5.0] - 2026-08-01
14
245
 
15
246
  ### 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