@pcircle/memesh 4.9.4 → 4.10.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.
Files changed (159) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/AGENTS.md +75 -13
  5. package/README.de.md +5 -5
  6. package/README.md +5 -5
  7. package/README.zh-TW.md +5 -5
  8. package/dashboard/dist/index.html +8 -8
  9. package/dist/cli/view-live.d.ts.map +1 -1
  10. package/dist/cli/view-live.js +0 -427
  11. package/dist/cli/view-live.js.map +1 -1
  12. package/dist/core/agent-message-inbox.d.ts +6 -0
  13. package/dist/core/agent-message-inbox.d.ts.map +1 -1
  14. package/dist/core/agent-message-inbox.js +28 -1
  15. package/dist/core/agent-message-inbox.js.map +1 -1
  16. package/dist/core/briefing-index.d.ts +30 -0
  17. package/dist/core/briefing-index.d.ts.map +1 -0
  18. package/dist/core/briefing-index.js +140 -0
  19. package/dist/core/briefing-index.js.map +1 -0
  20. package/dist/core/briefing-level.d.ts +22 -0
  21. package/dist/core/briefing-level.d.ts.map +1 -0
  22. package/dist/core/briefing-level.js +75 -0
  23. package/dist/core/briefing-level.js.map +1 -0
  24. package/dist/core/briefing.d.ts +7 -0
  25. package/dist/core/briefing.d.ts.map +1 -1
  26. package/dist/core/briefing.js +72 -12
  27. package/dist/core/briefing.js.map +1 -1
  28. package/dist/core/capture-liveness.d.ts +137 -0
  29. package/dist/core/capture-liveness.d.ts.map +1 -0
  30. package/dist/core/capture-liveness.js +448 -0
  31. package/dist/core/capture-liveness.js.map +1 -0
  32. package/dist/core/config.d.ts +2 -0
  33. package/dist/core/config.d.ts.map +1 -1
  34. package/dist/core/config.js +5 -1
  35. package/dist/core/config.js.map +1 -1
  36. package/dist/core/delegation.d.ts +47 -0
  37. package/dist/core/delegation.d.ts.map +1 -0
  38. package/dist/core/delegation.js +174 -0
  39. package/dist/core/delegation.js.map +1 -0
  40. package/dist/core/doctor-fixes.d.ts +1 -0
  41. package/dist/core/doctor-fixes.d.ts.map +1 -1
  42. package/dist/core/doctor-fixes.js +21 -0
  43. package/dist/core/doctor-fixes.js.map +1 -1
  44. package/dist/core/doctor.d.ts +8 -0
  45. package/dist/core/doctor.d.ts.map +1 -1
  46. package/dist/core/doctor.js +148 -3
  47. package/dist/core/doctor.js.map +1 -1
  48. package/dist/core/note-derive.d.ts +14 -0
  49. package/dist/core/note-derive.d.ts.map +1 -0
  50. package/dist/core/note-derive.js +66 -0
  51. package/dist/core/note-derive.js.map +1 -0
  52. package/dist/core/note-ingest.d.ts +34 -0
  53. package/dist/core/note-ingest.d.ts.map +1 -0
  54. package/dist/core/note-ingest.js +445 -0
  55. package/dist/core/note-ingest.js.map +1 -0
  56. package/dist/core/operations.d.ts +10 -1
  57. package/dist/core/operations.d.ts.map +1 -1
  58. package/dist/core/operations.js +129 -14
  59. package/dist/core/operations.js.map +1 -1
  60. package/dist/core/paths.d.ts +1 -0
  61. package/dist/core/paths.d.ts.map +1 -1
  62. package/dist/core/paths.js +4 -0
  63. package/dist/core/paths.js.map +1 -1
  64. package/dist/core/schema-export.d.ts.map +1 -1
  65. package/dist/core/schema-export.js +15 -5
  66. package/dist/core/schema-export.js.map +1 -1
  67. package/dist/core/serializer.d.ts +2 -0
  68. package/dist/core/serializer.d.ts.map +1 -1
  69. package/dist/core/serializer.js +119 -3
  70. package/dist/core/serializer.js.map +1 -1
  71. package/dist/core/session-insight.d.ts +44 -0
  72. package/dist/core/session-insight.d.ts.map +1 -0
  73. package/dist/core/session-insight.js +216 -0
  74. package/dist/core/session-insight.js.map +1 -0
  75. package/dist/core/task-state-store.d.ts +4 -0
  76. package/dist/core/task-state-store.d.ts.map +1 -1
  77. package/dist/core/task-state-store.js +18 -5
  78. package/dist/core/task-state-store.js.map +1 -1
  79. package/dist/core/task-state.d.ts +5 -0
  80. package/dist/core/task-state.d.ts.map +1 -1
  81. package/dist/core/task-state.js +70 -0
  82. package/dist/core/task-state.js.map +1 -1
  83. package/dist/core/turn-signal.d.ts +22 -0
  84. package/dist/core/turn-signal.d.ts.map +1 -0
  85. package/dist/core/turn-signal.js +89 -0
  86. package/dist/core/turn-signal.js.map +1 -0
  87. package/dist/core/types.d.ts +14 -3
  88. package/dist/core/types.d.ts.map +1 -1
  89. package/dist/core/update-entrypoint.d.ts +19 -0
  90. package/dist/core/update-entrypoint.d.ts.map +1 -0
  91. package/dist/core/update-entrypoint.js +222 -0
  92. package/dist/core/update-entrypoint.js.map +1 -0
  93. package/dist/core/update-notice.d.ts +69 -0
  94. package/dist/core/update-notice.d.ts.map +1 -0
  95. package/dist/core/update-notice.js +188 -0
  96. package/dist/core/update-notice.js.map +1 -0
  97. package/dist/core/work-topology.d.ts +1 -0
  98. package/dist/core/work-topology.d.ts.map +1 -1
  99. package/dist/core/work-topology.js +3 -0
  100. package/dist/core/work-topology.js.map +1 -1
  101. package/dist/host-runtime/codex-session.d.ts +10 -0
  102. package/dist/host-runtime/codex-session.d.ts.map +1 -1
  103. package/dist/host-runtime/codex-session.js +1 -1
  104. package/dist/host-runtime/codex-session.js.map +1 -1
  105. package/dist/knowledge-graph.d.ts.map +1 -1
  106. package/dist/knowledge-graph.js +24 -3
  107. package/dist/knowledge-graph.js.map +1 -1
  108. package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
  109. package/dist/mcp/server.js +1438 -122
  110. package/dist/mcp/server.js.map +3 -3
  111. package/dist/skills-manifest.json +61 -36
  112. package/dist/storage/fts-index.d.ts +1 -0
  113. package/dist/storage/fts-index.d.ts.map +1 -1
  114. package/dist/storage/fts-index.js +5 -0
  115. package/dist/storage/fts-index.js.map +1 -1
  116. package/dist/transports/cli/cli.d.ts.map +1 -1
  117. package/dist/transports/cli/cli.js +7846 -5613
  118. package/dist/transports/cli/cli.js.map +3 -3
  119. package/dist/transports/http/server.d.ts.map +1 -1
  120. package/dist/transports/http/server.js +48 -28
  121. package/dist/transports/http/server.js.map +1 -1
  122. package/dist/transports/mcp/handlers.d.ts +31 -7
  123. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  124. package/dist/transports/mcp/handlers.js +49 -7
  125. package/dist/transports/mcp/handlers.js.map +1 -1
  126. package/dist/transports/schemas.d.ts +5 -2
  127. package/dist/transports/schemas.d.ts.map +1 -1
  128. package/dist/transports/schemas.js +30 -3
  129. package/dist/transports/schemas.js.map +1 -1
  130. package/docs/platforms/README.md +1 -0
  131. package/docs/platforms/agent-messaging.md +39 -8
  132. package/package.json +11 -6
  133. package/scripts/hooks/_generated/agent-message-inbox.js +28 -1
  134. package/scripts/hooks/_generated/briefing-index.js +147 -0
  135. package/scripts/hooks/_generated/briefing-level.js +82 -0
  136. package/scripts/hooks/_generated/capture-liveness.js +455 -0
  137. package/scripts/hooks/_generated/core-paths.js +4 -0
  138. package/scripts/hooks/_generated/fts-index.js +5 -0
  139. package/scripts/hooks/_generated/task-state.js +70 -0
  140. package/scripts/hooks/_generated/update-notice.js +195 -0
  141. package/scripts/hooks/_generated/work-topology.js +3 -0
  142. package/scripts/hooks/_shared.js +923 -27
  143. package/scripts/hooks/_stop-notes.js +504 -0
  144. package/scripts/hooks/auto-update-runner.mjs +22 -0
  145. package/scripts/hooks/decision-nudge.js +32 -5
  146. package/scripts/hooks/guard-check.js +30 -3
  147. package/scripts/hooks/post-commit.js +329 -133
  148. package/scripts/hooks/pre-compact.js +17 -2
  149. package/scripts/hooks/pre-edit-recall.js +416 -64
  150. package/scripts/hooks/session-start.js +617 -150
  151. package/scripts/hooks/session-summary.js +217 -41
  152. package/scripts/hooks/user-prompt-intent.js +124 -22
  153. package/scripts/upgrade-plugin.sh +3 -4
  154. package/skills/memesh/SKILL.md +45 -23
  155. package/dist/cli/assets/d3.v7.min.js +0 -2
  156. package/dist/core/graph.d.ts +0 -26
  157. package/dist/core/graph.d.ts.map +0 -1
  158. package/dist/core/graph.js +0 -74
  159. package/dist/core/graph.js.map +0 -1
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'url';
7
7
  import { existsSync, readFileSync, unlinkSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
8
8
  import {
9
9
  buildReferenceContext,
10
+ hasBriefingContent,
10
11
  ensurePrivateDir,
11
12
  getDbPath,
12
13
  getMemeshDirFromDbPath,
@@ -23,7 +24,18 @@ import {
23
24
  // Aliased: this file already has a local `const memeshDir` (a resolved
24
25
  // db-path-derived directory string) — the helper here is the MEMESH_DIR/
25
26
  // home resolver the update-check cache itself uses.
27
+ advanceGraceState,
28
+ captureLivenessNotice,
29
+ captureLivenessVerdict,
30
+ graceInEffect,
31
+ parseGraceState,
32
+ ensurePrivateDir as ensurePrivateDirShared,
26
33
  memeshDir as memeshHomeDir,
34
+ parseHookOutcomes,
35
+ hookErrorReason,
36
+ recordHookOutcome,
37
+ summarizeHookOutcomes,
38
+ HOOK_OUTCOMES_FILENAME,
27
39
  parseTaskState,
28
40
  readRepoState,
29
41
  readAutoUpdateConsent,
@@ -31,10 +43,23 @@ import {
31
43
  finalizeUpdatePromptClaim,
32
44
  readUpdatePromptClaim,
33
45
  readUpdateCheckCache,
46
+ resolveUpdateNotice,
47
+ shouldRefreshUpdateCache,
48
+ claimJustUpgradedMarker,
49
+ isStrictlyOlder,
50
+ isUpdateCheckEnabled,
34
51
  repoStateLines,
35
52
  resolvePluginRoot,
36
53
  resolveSessionLimit,
37
- taskStateLines,
54
+ briefingTaskStateLines,
55
+ resolveBriefingLevel,
56
+ resolveMessageRecipient,
57
+ waitingMessageLines,
58
+ briefingLevelPolicy,
59
+ sessionStartAppendsWorkPackageNotice,
60
+ WORK_PACKAGE_NOTICE,
61
+ readHookConfigResult,
62
+ HOOK_CONFIG_UNREADABLE_REASON,
38
63
  homeDir,
39
64
  taskStateName,
40
65
  writeCitationRule,
@@ -42,17 +67,21 @@ import {
42
67
  writePrivateJson,
43
68
  } from './_shared.js';
44
69
  import { MemeshDatabase } from './_generated/sqlite.js';
45
- import { unreadDeliveryCount, unreadInboxLines } from './_generated/agent-message-inbox.js';
70
+ import {
71
+ buildBriefingIndex,
72
+ INDEX_CANDIDATE_CAP,
73
+ INDEX_EXCLUDED_TYPES,
74
+ INDEX_SNIPPET_FETCH_CHARS,
75
+ } from './_generated/briefing-index.js';
46
76
 
47
77
  const require = createRequire(import.meta.url);
48
78
 
49
- // Codex round 37: dist/core/install-channel.js is emitted as ESM
50
- // (the project's tsconfig produces NodeNext modules). On Node 20.x
51
- // `require()` against an ESM file throws ERR_REQUIRE_ESM, which
52
- // silently downgraded all install-channel detection to 'unknown' on
53
- // the supported floor. Pre-load the module via dynamic `import()`
54
- // at hook startup using a top-level await once at process init,
55
- // not on every call. Falls back to null if the dist file is
79
+ // dist/core/install-channel.js is emitted as ESM (the project's tsconfig
80
+ // produces NodeNext modules). On Node 20.x `require()` against an ESM file
81
+ // throws ERR_REQUIRE_ESM, which would silently downgrade all install-channel
82
+ // detection to 'unknown' on the supported floor. So the module is pre-loaded
83
+ // via dynamic `import()` at hook startup using a top-level await — once at
84
+ // process init, not on every call. Falls back to null if the dist file is
56
85
  // missing (source checkout pre-build) or fails to load.
57
86
  let _installChannelMod = null;
58
87
  try {
@@ -102,32 +131,26 @@ function buildDeprecationBanner(currentVersion, cache) {
102
131
  `⚠️ MeMesh ${currentVersion} is DEPRECATED by maintainers.`,
103
132
  ` ${msg}`,
104
133
  ];
105
- // Codex round 36: emit a remediation line for EVERY deprecation
106
- // banner including the cases where the cached `latestVersion`
107
- // is null, equal to current, or stale. The previous gate omitted
108
- // the action line whenever the cache didn't yet show a strictly-
109
- // newer version, leaving users with a security warning and no
110
- // follow-up step. doctor / CLI status / dashboard already point
111
- // at `memesh update` (or channel equivalents) in those uncertain
112
- // cases, and the session-start banner should match — `npm`
113
- // resolves @latest at install time, so the command works even
114
- // when our local cache is uncertain.
134
+ // A remediation line is emitted for EVERY deprecation banner — including
135
+ // when the cached `latestVersion` is null, equal to current, or stale — so
136
+ // a security warning always carries a follow-up step. doctor / CLI status /
137
+ // dashboard point at `memesh update` (or channel equivalents) in those
138
+ // uncertain cases too, and the session-start banner matches: `npm`
139
+ // resolves @latest at install time, so the command works even when our
140
+ // local cache is uncertain.
115
141
  const knownUpgradeTarget = Boolean(
116
142
  cache.latestVersion && cache.latestVersion !== currentVersion,
117
143
  );
118
- // Codex round 39: the SessionStart hook reads ONLY cached cache
119
- // data there's no fresh lookup happening on this code path.
120
- // That means `freshness === 'fresh'` (the strict rule the
121
- // dashboard / `memesh status` use to authoritatively say
122
- // "no upgrade target yet") can never apply here. Round 38 used a
123
- // 24h-window heuristic to fire the no-target message anyway, but
124
- // codex correctly flagged that as suppressing the upgrade hint
125
- // exactly when a security-advisory fix could ship within the
126
- // window. Conservative remediation: always recommend
127
- // `memesh update` (which is a harmless no-op when there's truly
128
- // no target, and immediately applies a freshly-published fix
129
- // when there is one). The "no target yet" message remains
130
- // available in `memesh status` (fresh lookup) and the dashboard
144
+ // The SessionStart hook reads ONLY cached update data — no fresh lookup
145
+ // happens on this code path. That means `freshness === 'fresh'` (the strict
146
+ // rule the dashboard / `memesh status` use to authoritatively say "no
147
+ // upgrade target yet") can never apply here. A 24h-window heuristic that
148
+ // fired the no-target message anyway would suppress the upgrade hint
149
+ // exactly when a security-advisory fix could ship within the window, so
150
+ // the remediation is conservative: always recommend `memesh update` (a
151
+ // harmless no-op when there's truly no target, and it immediately applies
152
+ // a freshly-published fix when there is one). The "no target yet" message
153
+ // remains available in `memesh status` (fresh lookup) and the dashboard
131
154
  // (after a Check now click).
132
155
  // Tailor the remediation hint to the install channel. `memesh
133
156
  // update` and `autoUpdate` only work for npm-global installs;
@@ -155,9 +178,8 @@ function buildDeprecationBanner(currentVersion, cache) {
155
178
  } else if (channel === 'source-checkout') {
156
179
  lines.push(` Source checkout: pull and rebuild (\`git pull && npm install && npm run build\`).`);
157
180
  } else if (channel === 'npm-local') {
158
- // Codex round 30: the cached `latestVersion` may itself be
159
- // stale (cache TTL is 24h and we're already showing a stale
160
- // banner). Pinning a specific version risks installing an
181
+ // The cached `latestVersion` may itself be stale (cache TTL is 24h
182
+ // and we're already showing a stale banner). Pinning a specific version risks installing an
161
183
  // already-superseded build that's part of the same security
162
184
  // advisory. `@latest` always resolves to the registry's
163
185
  // current dist-tag at install time, which is the right
@@ -191,33 +213,59 @@ function buildDeprecationBanner(currentVersion, cache) {
191
213
  */
192
214
  const UPDATE_BANNER_THROTTLE_MS = 24 * 60 * 60 * 1000;
193
215
 
216
+ // `isStrictlyOlder` now comes from the shared update-notice leaf (one
217
+ // semver rule for hooks, CLI and the resolver).
218
+
219
+ const CHECK_FAILED_BANNER_THROTTLE_MS = 24 * 60 * 60 * 1000;
220
+
194
221
  /**
195
- * Return true iff `a` is strictly older than `b` under semver-ish
196
- * ordering. Compares the dot-separated numeric portion of each version
197
- * componentwise (so 4.2.10 > 4.2.9, unlike string compare). Anything
198
- * after the first non-numeric char falls back to lex compare on the
199
- * trailing fragment — fine for the prerelease / 4-segment build tags
200
- * memesh uses (e.g. 4.2.5-rc.1).
222
+ * Banner lines for the two update states that are NOT a consent prompt:
223
+ * JUST_UPGRADED (said once, then the receipt is consumed) and CHECK_FAILED
224
+ * (unknown is not "up to date"; throttled to once a day). Everything else
225
+ * returns [] so the caller falls through to its consent/banner logic.
201
226
  */
202
- function isStrictlyOlder(a, b) {
203
- const parse = (v) => {
204
- const [main, ...rest] = String(v).split(/[-+]/);
205
- const nums = main.split('.').map((s) => Number.parseInt(s, 10));
206
- return { nums, tail: rest.join('-') };
207
- };
208
- const pa = parse(a);
209
- const pb = parse(b);
210
- const len = Math.max(pa.nums.length, pb.nums.length);
211
- for (let i = 0; i < len; i++) {
212
- const ai = Number.isFinite(pa.nums[i]) ? pa.nums[i] : 0;
213
- const bi = Number.isFinite(pb.nums[i]) ? pb.nums[i] : 0;
214
- if (ai !== bi) return ai < bi;
227
+ function updateNoticeBanner(installedVersion, cache) {
228
+ if (!installedVersion) return { kind: 'UNKNOWN_VERSION', lines: [] };
229
+ const notice = resolveUpdateNotice({
230
+ dir: memeshHomeDir(), currentVersion: installedVersion, cache, updateCheckEnabled: isUpdateCheckEnabled(),
231
+ });
232
+ if (notice.kind === 'JUST_UPGRADED') {
233
+ // Take the receipt atomically: two host hooks starting together must not
234
+ // both announce. The loser sees null and says nothing about it.
235
+ const claimed = claimJustUpgradedMarker(memeshHomeDir());
236
+ if (!claimed) return { kind: 'UP_TO_DATE', lines: [] };
237
+ return {
238
+ kind: notice.kind,
239
+ lines: [`✅ MeMesh upgraded ${claimed.from} ${claimed.to}. Hosts already running keep the old version until they restart; this session's hooks and MCP server pick up ${claimed.to} on the next start.`],
240
+ };
241
+ }
242
+ if (notice.kind === 'CHECK_FAILED') return { kind: notice.kind, lines: buildCheckFailedBanner(installedVersion, notice.reason) };
243
+ return { kind: notice.kind, lines: [] };
244
+ }
245
+
246
+ /**
247
+ * The update status is UNKNOWN (no completed check, or the registry lookup
248
+ * failed and the last good answer is older than a day). Silence here would
249
+ * read as "up to date", so say it — once a day per installed version.
250
+ */
251
+ function buildCheckFailedBanner(currentVersion, reason) {
252
+ try {
253
+ const fs = require('fs');
254
+ const dir = memeshHomeDir();
255
+ try { ensurePrivateDir(dir); } catch { /* best-effort */ }
256
+ const versionTag = /^[0-9A-Za-z.+-]+$/.test(currentVersion) ? currentVersion : 'unknown';
257
+ const markerPath = join(dir, `last-check-failed-banner.${versionTag}.lock`);
258
+ let stat;
259
+ try { stat = fs.statSync(markerPath); } catch { stat = null; }
260
+ if (stat && Date.now() - stat.mtimeMs < CHECK_FAILED_BANNER_THROTTLE_MS) return [];
261
+ try {
262
+ fs.writeFileSync(markerPath, String(Date.now()), { mode: 0o600 });
263
+ try { fs.chmodSync(markerPath, 0o600); } catch { /* non-POSIX */ }
264
+ } catch { /* best-effort */ }
265
+ } catch {
266
+ return [];
215
267
  }
216
- // Numeric prefix tied. A prerelease tail counts as OLDER than no
217
- // tail (semver: 1.0.0-rc.1 < 1.0.0); otherwise lex on tail.
218
- if (pa.tail && !pb.tail) return true;
219
- if (!pa.tail && pb.tail) return false;
220
- return pa.tail < pb.tail;
268
+ return [`ℹ️ MeMesh could not confirm whether an update exists (${reason}). Update status is unknown, not current run \`memesh status\` to retry.`];
221
269
  }
222
270
 
223
271
  function buildUpdateAvailableBanner(currentVersion, cache, getChannel) {
@@ -333,7 +381,13 @@ function detectInstallChannelHook(pluginRoot) {
333
381
 
334
382
  function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
335
383
  if (!sessionId || sessionId === 'unknown' || !cache || cache.currentVersion !== currentVersion) return null;
336
- if (!cache.latestVersion || !isStrictlyOlder(currentVersion, cache.latestVersion)) return null;
384
+ // One resolver decides. Snoozed ("Not now" within its window), disabled
385
+ // ("Never ask again"), a just-landed upgrade, or a failed check all mean:
386
+ // no consent prompt this session. Those states get their own banner line.
387
+ const notice = resolveUpdateNotice({
388
+ dir: memeshHomeDir(), currentVersion, cache, updateCheckEnabled: isUpdateCheckEnabled(),
389
+ });
390
+ if (notice.kind !== 'UPGRADE_AVAILABLE') return null;
337
391
  const existing = readAutoUpdateConsent(sessionId, currentVersion, cache.latestVersion, channel);
338
392
  if (existing?.decision) return null;
339
393
  // Claim the session-level notice before emitting it. A resumed hook or a
@@ -354,7 +408,7 @@ function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
354
408
  ? ' Project-local install: run `npm install @pcircle/memesh@latest` in the project that installed it.'
355
409
  : ' Update it through the tool or package manager that installed MeMesh.';
356
410
  return {
357
- system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. This installation cannot be upgraded automatically from this session.\n${action}`,
411
+ system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. This installation cannot be upgraded automatically from this session.\n${action}\n Reply “Not now” to snooze this version (24h, then longer), or “Never ask again” to stop these checks.`,
358
412
  context: `MeMesh ${cache.latestVersion} is available for ${target}, but this channel has no safe in-session installer. Show the user the channel-specific update action and do not claim that an Upgrade reply will install it.`,
359
413
  };
360
414
  }
@@ -363,8 +417,8 @@ function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
363
417
  // session notice has been claimed.
364
418
  if (!writeAutoUpdateConsent(sessionId, currentVersion, cache.latestVersion, channel, 'pending')) return null;
365
419
  return {
366
- system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. Reply “Upgrade” to install it, or “Not now” to skip for this session.`,
367
- context: `MeMesh update consent is pending for this session. Ask the user whether to upgrade from ${currentVersion} to ${cache.latestVersion} for the ${target}. Wait for an explicit Upgrade or Not now response; do not install without affirmative consent.`,
420
+ system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. Reply “Upgrade” to install it, “Not now” to snooze this version (24h, then longer), or “Never ask again” to stop these checks.`,
421
+ context: `MeMesh update consent is pending for this session. Ask the user whether to upgrade from ${currentVersion} to ${cache.latestVersion} for the ${target}. Wait for an explicit Upgrade, Not now, or Never ask again response; do not install without affirmative consent.`,
368
422
  };
369
423
  }
370
424
 
@@ -443,14 +497,13 @@ function spawnFreshUpdateCheck(installedVersion) {
443
497
  // the banner marker above: marker and cache must share a directory.
444
498
  const dir = memeshHomeDir();
445
499
  try { ensurePrivateDir(dir); } catch { /* best-effort */ }
446
- // Codex round 37: scope the throttle marker to the installed
447
- // version. The marker was machine-global, so a refresh started
448
- // by a global 4.1.3 install would suppress refreshes for a
449
- // sibling project-local 4.1.1 for the next 5 minutes and the
450
- // shared cache it wrote would carry version 4.1.3, so the
451
- // 4.1.1 session would skip its banner because
452
- // `cache.currentVersion !== currentVersion`. Per-version
453
- // markers ensure each install gets its own refresh window.
500
+ // The throttle marker is scoped to the installed version. A
501
+ // machine-global marker would let a refresh started by a global 4.1.3
502
+ // install suppress refreshes for a sibling project-local 4.1.1 for the
503
+ // next 5 minutes and the shared cache it wrote would carry version
504
+ // 4.1.3, so the 4.1.1 session would skip its banner because
505
+ // `cache.currentVersion !== currentVersion`. Per-version markers give
506
+ // each install its own refresh window.
454
507
  // Sanitize version for filesystem (semver chars only, no path
455
508
  // separators); fall back to 'unknown' if missing.
456
509
  const versionTag = typeof installedVersion === 'string'
@@ -458,13 +511,12 @@ function spawnFreshUpdateCheck(installedVersion) {
458
511
  ? installedVersion
459
512
  : 'unknown';
460
513
  const markerPath = join(dir, `last-fresh-refresh.${versionTag}.lock`);
461
- // Single-owner claim: O_EXCL atomic create. Codex round 27
462
- // caught that the previous temp+rename+readback pattern was
463
- // racy both peers' renames are destructive, so each could
464
- // read its own token back and both would spawn a refresh.
465
- // O_EXCL is the standard POSIX/libuv primitive that lets at
466
- // most one process succeed. The updater runner uses the same O_EXCL
467
- // ownership primitive for its separate update lock.
514
+ // Single-owner claim: O_EXCL atomic create. A temp+rename+readback
515
+ // pattern is racy both peers' renames are destructive, so each could
516
+ // read its own token back and both would spawn a refresh. O_EXCL is the
517
+ // standard POSIX/libuv primitive that lets at most one process succeed.
518
+ // The updater runner uses the same O_EXCL ownership primitive for its
519
+ // separate update lock.
468
520
  const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
469
521
  const claim = () => {
470
522
  try {
@@ -563,6 +615,11 @@ function runPostBannerUpdateTasks() {
563
615
  if (!installedVersion) return;
564
616
  // Auto-update spawn moved to Stop hook (v4.1.4) to avoid TOCTOU race
565
617
  // where npm install -g overwrites dist/ while peer hooks are still reading it.
618
+ // Two TTLs (update-notice.ts): a current answer is re-verified hourly, a
619
+ // known upgrade is not re-fetched for 12h; a failed or missing check is
620
+ // always retried. "Never ask again" also stops the background refresh.
621
+ if (!isUpdateCheckEnabled()) return;
622
+ if (!shouldRefreshUpdateCache(installedVersion, readUpdateCheckCache(installedVersion))) return;
566
623
  spawnFreshUpdateCheck(installedVersion);
567
624
  } catch {
568
625
  // Best-effort — never crash the hook on a network or fs hiccup.
@@ -611,27 +668,120 @@ function captureTargetUnwritable() {
611
668
  return null;
612
669
  }
613
670
 
671
+ /**
672
+ * Once per 24h, one line when automatic capture has gone quiet (#327).
673
+ *
674
+ * The verdict is computed in the HOOK layer because a hook cannot import
675
+ * `src/` — but from the SAME leaf `memesh doctor` uses, so the banner and
676
+ * the report can never disagree about whether capture is alive. The hook
677
+ * sees only `hook-outcomes.jsonl`: doctor additionally reads the database for
678
+ * per-type trends and the `hook_runs` heartbeats, so doctor can reach a FAIL
679
+ * this line never will. That is the right asymmetry — a SessionStart line is
680
+ * a nudge towards `memesh doctor`, not a replacement for it.
681
+ *
682
+ * Suppression after the next successful write needs no marker of its own: a
683
+ * `wrote` record makes the hook non-silent and the verdict returns to PASS,
684
+ * so this returns null on its own. Only the throttle needs a file.
685
+ *
686
+ * It reads the JSONL and nothing else — never the database. SessionStart is
687
+ * on the critical path of every session start with a 10s budget, and a
688
+ * banner line is not worth a query against a graph that may be mid-write.
689
+ */
690
+ const CAPTURE_LIVENESS_THROTTLE_MS = 24 * 60 * 60 * 1000;
691
+ const CAPTURE_GRACE_FILE = 'capture-liveness-grace.json';
692
+
693
+ /**
694
+ * Count this session against the post-install / post-upgrade grace, and say
695
+ * whether the grace is still on.
696
+ *
697
+ * The counter is keyed to the installed version, so an upgrade resets it —
698
+ * an upgrade replaces the hooks, which is exactly when a legitimate run of
699
+ * skips is expected and a warning would be noise.
700
+ */
701
+ function captureGraceInEffect(dir, installedVersion) {
702
+ try {
703
+ let raw = null;
704
+ try { raw = readFileSync(join(dir, CAPTURE_GRACE_FILE), 'utf8'); } catch { /* first session */ }
705
+ const next = advanceGraceState(parseGraceState(raw), installedVersion, Date.now());
706
+ try {
707
+ ensurePrivateDirShared(dir);
708
+ const gracePath = join(dir, CAPTURE_GRACE_FILE);
709
+ require('fs').writeFileSync(gracePath, JSON.stringify(next), { mode: 0o600 });
710
+ try { require('fs').chmodSync(gracePath, 0o600); } catch { /* best-effort hardening */ }
711
+ } catch { /* best-effort — a lost count only shortens the grace */ }
712
+ return graceInEffect(next, Date.now());
713
+ } catch {
714
+ // Cannot tell how new this install is → assume it is new. Silence is
715
+ // the safe default for a nudge; doctor still reports the truth.
716
+ return true;
717
+ }
718
+ }
719
+
720
+ function captureLivenessBannerLine(installedVersion) {
721
+ try {
722
+ // The banner must read the outcome file from the SAME place the hooks
723
+ // write it. recordHookOutcome writes beside the database
724
+ // (getMemeshDirFromDbPath = dirname(MEMESH_DB_PATH)); reading via
725
+ // memeshHomeDir (= MEMESH_DIR) instead split the two whenever a DB path
726
+ // override pointed elsewhere, and the banner silently read nothing —
727
+ // the exact "banner and report can never disagree" failure this exists
728
+ // to prevent. The grace counter and throttle lock move with it so the
729
+ // three pieces of this mechanism stay beside the same file.
730
+ const dir = getMemeshDirFromDbPath();
731
+ if (captureGraceInEffect(dir, installedVersion ?? 'unknown')) return null;
732
+ let raw = null;
733
+ try { raw = readFileSync(join(dir, HOOK_OUTCOMES_FILENAME), 'utf8'); } catch { return null; }
734
+ const verdict = captureLivenessVerdict({ hooks: summarizeHookOutcomes(parseHookOutcomes(raw)), types: [] });
735
+ const line = captureLivenessNotice(verdict);
736
+ if (!line) return null;
737
+
738
+ // Same throttle primitive as the update banner: file mtime, touched
739
+ // BEFORE the line is emitted so two sessions starting at once cannot
740
+ // both print it.
741
+ const markerPath = join(dir, 'last-capture-liveness-notice.lock');
742
+ let stat;
743
+ try { stat = require('fs').statSync(markerPath); } catch { stat = null; }
744
+ if (stat && Date.now() - stat.mtimeMs < CAPTURE_LIVENESS_THROTTLE_MS) return null;
745
+ try {
746
+ ensurePrivateDirShared(dir);
747
+ require('fs').writeFileSync(markerPath, String(Date.now()), { mode: 0o600 });
748
+ try { require('fs').chmodSync(markerPath, 0o600); } catch { /* best-effort hardening */ }
749
+ } catch { /* best-effort — worst case the line shows twice */ }
750
+ return line;
751
+ } catch {
752
+ // Diagnostics must never cost a session its banner.
753
+ return null;
754
+ }
755
+ }
756
+
614
757
  /**
615
758
  * Build a "base message + optional deprecation banner" combined
616
759
  * single-line systemMessage payload. Keeps stdout a single JSON
617
760
  * object on every empty/no-DB exit path so Claude Code's hook
618
761
  * contract holds.
619
762
  */
620
- function combineWithBanner(baseMessage) {
763
+ function combineWithBanner(baseMessage, { skipUpdateBanner = false } = {}) {
621
764
  let lines = [];
765
+ // Hoisted out of the try: the capture-liveness grace is keyed to the
766
+ // installed version, and it must still be counted when the update-banner
767
+ // block below throws (a missing package.json must not silently disable the
768
+ // grace and start warning on a fresh install).
769
+ let livenessVersion = null;
622
770
  try {
623
771
  const pluginRoot = resolvePluginRoot(import.meta.url);
624
772
  const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
625
773
  const installedVersion = typeof pkg.version === 'string' ? pkg.version : null;
774
+ livenessVersion = installedVersion;
626
775
  const cache = readUpdateCheckCache(installedVersion);
627
776
  if (installedVersion) {
628
777
  const deprecation = buildDeprecationBanner(installedVersion, cache);
629
- // Deprecation owns the spot when present; the update-available
630
- // banner is the fallback for the much more common "not flagged,
631
- // just out of date" case.
778
+ const notice = updateNoticeBanner(installedVersion, cache);
632
779
  if (deprecation.length > 0) {
633
780
  lines = deprecation;
634
- } else {
781
+ } else if (notice.lines.length > 0) {
782
+ lines = notice.lines;
783
+ } else if (notice.kind === 'UPGRADE_AVAILABLE' && !skipUpdateBanner) {
784
+ // Snoozed, disabled, failed or current: the resolver already said no.
635
785
  lines = buildUpdateAvailableBanner(
636
786
  installedVersion, cache, () => detectInstallChannelHook(pluginRoot));
637
787
  }
@@ -639,10 +789,26 @@ function combineWithBanner(baseMessage) {
639
789
  } catch {
640
790
  // Best-effort — fall through to base message only.
641
791
  }
792
+ // The capture-liveness line rides the same single systemMessage as every
793
+ // other banner — stdout must stay one JSON document.
794
+ const liveness = captureLivenessBannerLine(livenessVersion);
795
+ if (liveness) lines = [...lines, liveness];
642
796
  if (lines.length === 0) return baseMessage;
643
797
  return [...lines.filter((l) => l.length > 0), '', baseMessage].join('\n');
644
798
  }
645
799
 
800
+ // #360: the ONE reason string for "this session's memory injection resolved
801
+ // to nothing" — every exit path that ends up with a falsy `memoryContext`
802
+ // passes this through `output()`'s `recorded` argument, so a genuinely silent
803
+ // session (`minimal` on an empty project, the no-database and
804
+ // no-entities-table early exits) always leaves a trace of WHY nothing was
805
+ // injected, instead of the generic `session-start-banner` outcome marker that
806
+ // carries no `reason`. One helper, not three copies of the template string,
807
+ // so the wording cannot drift between call sites.
808
+ function nothingToInjectReason(level, detail) {
809
+ return `briefing-level: nothing to inject at "${level}" — ${detail}`;
810
+ }
811
+
646
812
  let input = '';
647
813
  process.stdin.setEncoding('utf8');
648
814
  process.stdin.on('data', (chunk) => { input += chunk; });
@@ -653,13 +819,70 @@ process.stdin.on('end', async () => {
653
819
  let captureWarning = null;
654
820
  const withCaptureWarning = (msg) => {
655
821
  if (!captureWarning) return msg;
656
- return `${captureWarning}\n${msg.replace(/^◉ MeMesh ready · /, '◉ MeMesh · ')}`;
822
+ // The populated-database path prepends an update banner ahead of the
823
+ // "◉ MeMesh ready · ..." summary line (`finalMessage = [...bannerLines,
824
+ // '', summary].join('\n')`), so that line is not at the START of `msg`
825
+ // whenever a banner is present — an un-flagged `^` only anchors to the
826
+ // whole string's start, so the demotion below would silently do nothing
827
+ // and "MeMesh ready" would leak through even while a write failure was
828
+ // being reported in the very same message. `m` anchors `^` to the start
829
+ // of ANY line instead.
830
+ return `${captureWarning}\n${msg.replace(/^◉ MeMesh ready · /m, '◉ MeMesh · ')}`;
657
831
  };
658
832
  try {
659
833
  try {
660
834
  const data = JSON.parse(input);
661
835
  const projectName = getProjectName(data.cwd);
662
836
 
837
+ // #360 — resolve the briefing level ONCE, before any exit path, so every
838
+ // emit below (including the no-database and empty-database early
839
+ // returns) agrees on what to include. env > config > default; an
840
+ // unknown value on either source is not a silent fallback — it is
841
+ // traced AND recorded on the hook's outcome channel, the same
842
+ // discipline every other silent-skip path in this file follows.
843
+ const configRead = readHookConfigResult(process.env);
844
+ const briefingResolution = resolveBriefingLevel(process.env, configRead.config);
845
+ const briefingLevel = briefingResolution.level;
846
+ const briefingPolicy = briefingLevelPolicy(briefingLevel);
847
+ if (briefingResolution.invalid) {
848
+ const { source, value } = briefingResolution.invalid;
849
+ try {
850
+ process.stderr.write(
851
+ `[memesh session-start] invalid ${source} briefing level ${value} — using "${briefingLevel}"\n`,
852
+ );
853
+ } catch { /* stderr gone */ }
854
+ recordHookOutcome(process.env, {
855
+ hook: 'session-start',
856
+ outcome: 'notified',
857
+ reason: `briefing-level: invalid ${source} value ${value}, using ${briefingLevel}`,
858
+ });
859
+ }
860
+ // The check above only catches an unusable VALUE for one known key
861
+ // (`briefing`) inside an otherwise-parseable config object. A config.json
862
+ // that is not even that — truncated JSON, a bare array/string/number/null
863
+ // at the top level — is swallowed by `readHookConfig()` into an empty
864
+ // `{}`, so every setting in it (not just `briefing`) reads as "not set"
865
+ // with no trace anywhere, while the CLI/MCP side (`core/config.ts`'s
866
+ // `warnUnreadable()`) reports this exact state. `readHookConfigResult()`'s
867
+ // `state` is what makes it visible here too — recorded once, regardless
868
+ // of which settings would have been affected, since the whole document
869
+ // was unusable, not one field.
870
+ if (configRead.state === 'unreadable') {
871
+ recordHookOutcome(process.env, {
872
+ hook: 'session-start',
873
+ outcome: 'notified',
874
+ reason: HOOK_CONFIG_UNREADABLE_REASON,
875
+ });
876
+ }
877
+ // The work-package notice is identical boilerplate every session (#360)
878
+ // — only `full` still carries it. `undefined`, not a conditional string
879
+ // literal at each call site: output()'s memoryContext parameter treats
880
+ // undefined as "nothing to add" the same way it always has.
881
+ // Read through the named predicate, not `.workPackageNotice` off the
882
+ // policy object directly — this file is the ONLY caller that should ever
883
+ // decide this (see the field comment in briefing-level.ts).
884
+ const workPackageNotice = sessionStartAppendsWorkPackageNotice(briefingLevel) ? WORK_PACKAGE_NOTICE : undefined;
885
+
663
886
  // Self-heal the citation contract.
664
887
  //
665
888
  // `install-hooks` writes it too, but a PLUGIN install never runs that
@@ -730,7 +953,6 @@ process.stdin.on('end', async () => {
730
953
  // With no database there is nothing to recall either — the warning IS
731
954
  // the whole truth, and "memories will be created as you work" would
732
955
  // contradict it one line later.
733
- const emptySummary = combineWithBanner(captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work');
734
956
  let consent = null;
735
957
  let consentVersion = null;
736
958
  let consentCache = null;
@@ -742,8 +964,36 @@ process.stdin.on('end', async () => {
742
964
  const channel = detectInstallChannelHook(pluginRoot);
743
965
  consent = buildUpdateConsentPrompt(data.session_id, consentVersion, consentCache, channel);
744
966
  } catch { /* best-effort */ }
745
- output(consent ? `${consent.system}\n${emptySummary}` : emptySummary,
746
- consent ? `${consent.context}\n\n${workPackageGuidance}` : workPackageGuidance);
967
+ // The consent prompt IS the update message for this session; the
968
+ // routine "update available" banner must not repeat it one line later
969
+ // (the database path has had this rule since the notice was added; the
970
+ // no-database path printed both). Deprecation, a just-landed upgrade
971
+ // and a failed check still render through combineWithBanner.
972
+ // A session that was already shown the notice (claim exists, answered
973
+ // or not) must not get the routine banner either — same rule as the
974
+ // database path.
975
+ const alreadyNoticed = consent !== null || (consentVersion !== null
976
+ && readUpdatePromptClaim(data.session_id, consentVersion, consentCache?.latestVersion) !== null);
977
+ const emptySummary = combineWithBanner(
978
+ captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work',
979
+ { skipUpdateBanner: alreadyNoticed },
980
+ );
981
+ const noDbContext = consent
982
+ ? [consent.context, workPackageNotice].filter(Boolean).join('\n\n')
983
+ : workPackageNotice;
984
+ output(
985
+ consent ? `${consent.system}\n${emptySummary}` : emptySummary,
986
+ noDbContext,
987
+ // Nothing was injected (no notice at this level, no database to read
988
+ // from) — record why, the same as the schema-present empty path
989
+ // further down. `standard`/`full` reach this exact same branch: at
990
+ // `full` `noDbContext` is always truthy (the notice), so this never
991
+ // overrides anything for that level; at `standard` it can also be
992
+ // falsy here, which correctly gets the same reason (the record is
993
+ // level-agnostic, matching the schema-present path's own
994
+ // `!memoryContext` predicate, not a `minimal`-only check).
995
+ !noDbContext ? { outcome: 'notified', reason: nothingToInjectReason(briefingLevel, 'no database yet') } : null,
996
+ );
747
997
  if (consent) finalizeUpdatePromptClaim(data.session_id, consentVersion, consentCache?.latestVersion);
748
998
  return;
749
999
  }
@@ -770,7 +1020,14 @@ process.stdin.on('end', async () => {
770
1020
  "SELECT name FROM sqlite_master WHERE type='table' AND name='entities'"
771
1021
  ).get();
772
1022
  if (!tableCheck) {
773
- output(combineWithBanner(captureWarning ?? '◉ MeMesh ready · database initialised but no memories stored yet'));
1023
+ output(
1024
+ combineWithBanner(captureWarning ?? '◉ MeMesh ready · database initialised but no memories stored yet'),
1025
+ workPackageNotice,
1026
+ // Same reason mechanism as the no-database exit above and the
1027
+ // schema-present exit below — see `nothingToInjectReason`'s own
1028
+ // comment for why this is not `minimal`-gated explicitly.
1029
+ !workPackageNotice ? { outcome: 'notified', reason: nothingToInjectReason(briefingLevel, 'database has no entities table yet') } : null,
1030
+ );
774
1031
  return;
775
1032
  }
776
1033
 
@@ -905,8 +1162,11 @@ process.stdin.on('end', async () => {
905
1162
  // gives them a separate render budget. The project keeps `sessionLimit`
906
1163
  // slots and its full character budget. The column is absent on
907
1164
  // pre-namespace schemas; then this branch is simply empty.
1165
+ // #360: minimal/standard skip these two queries outright rather than
1166
+ // fetch-then-not-render — the whole point of the level is to stop
1167
+ // paying for what is not the current project.
908
1168
  let globalEntities = [];
909
- if (colNames.has('namespace')) {
1169
+ if (briefingPolicy.global && colNames.has('namespace')) {
910
1170
  const globalQuery = buildScoringQuery('', `WHERE e.namespace = 'global' ${statusFilter}`);
911
1171
  globalEntities = db.prepare(globalQuery).all(CANDIDATE_CAP)
912
1172
  .filter(entity => isTrustedForAutoContext(entity.metadata))
@@ -917,15 +1177,18 @@ process.stdin.on('end', async () => {
917
1177
  // recentStatusFilter is "WHERE status = 'active'" or "" — the bare-column
918
1178
  // form is fine when there's no JOIN, but we now alias the table as `e`,
919
1179
  // so rewrite to e.status for consistency.
920
- const recentConditions = [
921
- hasStatus ? "e.status = 'active'" : '',
922
- colNames.has('namespace') ? "(e.namespace IS NULL OR e.namespace <> 'global')" : '',
923
- ].filter(Boolean);
924
- const recentWhere = recentConditions.length > 0 ? `WHERE ${recentConditions.join(' AND ')}` : '';
925
- const recentQuery = buildScoringQuery('', recentWhere);
926
- const recentEntities = db.prepare(recentQuery).all(CANDIDATE_CAP)
927
- .filter(entity => isTrustedForAutoContext(entity.metadata))
928
- .slice(0, 5);
1180
+ let recentEntities = [];
1181
+ if (briefingPolicy.foreign) {
1182
+ const recentConditions = [
1183
+ hasStatus ? "e.status = 'active'" : '',
1184
+ colNames.has('namespace') ? "(e.namespace IS NULL OR e.namespace <> 'global')" : '',
1185
+ ].filter(Boolean);
1186
+ const recentWhere = recentConditions.length > 0 ? `WHERE ${recentConditions.join(' AND ')}` : '';
1187
+ const recentQuery = buildScoringQuery('', recentWhere);
1188
+ recentEntities = db.prepare(recentQuery).all(CANDIDATE_CAP)
1189
+ .filter(entity => isTrustedForAutoContext(entity.metadata))
1190
+ .slice(0, 5);
1191
+ }
929
1192
 
930
1193
  // Lesson count (queried for summary, not listed individually).
931
1194
  // Status-column gate matches the project/recent queries above —
@@ -1005,6 +1268,7 @@ process.stdin.on('end', async () => {
1005
1268
  const topLessons = lessonEntities.slice(0, 5);
1006
1269
 
1007
1270
  const memoryLines = [];
1271
+ let memoryAssemblyFailed = false;
1008
1272
  try {
1009
1273
  const rankedIds = [
1010
1274
  ...topLessons.map(e => e.id),
@@ -1050,14 +1314,31 @@ process.stdin.on('end', async () => {
1050
1314
  const taskRow = db
1051
1315
  .prepare('SELECT metadata FROM entities WHERE name = ?')
1052
1316
  .get(taskStateName(projectName));
1053
- // SessionStart has no exact recipient identity. The shared leaf fails
1054
- // closed before querying, so hook and briefing cannot diverge here.
1317
+ // Who this session is comes from `MEMESH_RECIPIENT` (see
1318
+ // resolveMessageRecipient). Without it there is no exact recipient
1319
+ // and the shared leaf returns no lines, so a session never sees a
1320
+ // message addressed to anyone else.
1321
+ // #360: briefingTaskStateLines downgrades a stale record to one line
1322
+ // at EVERY level, and only consults `briefingPolicy.taskState` for a
1323
+ // fresh one — `minimal` omits a fresh state entirely, never a stale
1324
+ // flag. The unread-inbox line below is unconditional at every level:
1325
+ // a message waiting for this agent is not "another project's
1326
+ // memory", it is addressed to it. An inbox that cannot be read is
1327
+ // recorded as its own `error` (a label, like the two below), and is
1328
+ // not a failed memory assembly: the rest of the context still ships.
1055
1329
  const stateLines = [
1056
- ...taskStateLines(
1330
+ ...briefingTaskStateLines(
1057
1331
  parseTaskState(parseEntityMetadata(taskRow?.metadata)),
1058
1332
  projectName,
1333
+ new Date(),
1334
+ { includeFresh: briefingPolicy.taskState },
1059
1335
  ),
1060
- ...unreadInboxLines(unreadDeliveryCount(db, projectName), projectName),
1336
+ ...waitingMessageLines(db, resolveMessageRecipient(process.env), (err) =>
1337
+ recordHookOutcome(process.env, {
1338
+ hook: 'session-start',
1339
+ outcome: 'error',
1340
+ reason: `inbox: ${hookErrorReason(err)}`,
1341
+ })),
1061
1342
  ];
1062
1343
 
1063
1344
  // The pools overlap by construction (a lesson tagged to this project
@@ -1099,47 +1380,170 @@ process.stdin.on('end', async () => {
1099
1380
  // means memories stop reaching the model again (the exact v4.2.7
1100
1381
  // regression this block was written to fix).
1101
1382
  try { process.stderr.write(`[memesh session-start] memory-context: ${err?.message || err}\n`); } catch {}
1383
+ // Recorded like the index read's failure below (a label, never the
1384
+ // message), so a failed assembly is not later reported as an empty
1385
+ // project: at `minimal` the index read is skipped, and this is the
1386
+ // only place the fault can surface.
1387
+ memoryAssemblyFailed = true;
1388
+ recordHookOutcome(process.env, {
1389
+ hook: 'session-start',
1390
+ outcome: 'error',
1391
+ reason: `memory-context: ${hookErrorReason(err)}`,
1392
+ });
1393
+ }
1394
+
1395
+ // --- The durable-memory index (#323) -----------------------------
1396
+ // One line per decision / lesson / pattern / reference for this
1397
+ // project, newest first, hard-capped — what is known, visible without
1398
+ // a query. Same read as core's `readBriefingIndex` (this hook owns its
1399
+ // own SQL, with the legacy-schema guards the queries above use); every
1400
+ // decision about what to show lives in the generated leaf. A read that
1401
+ // fails renders as "could not be read", never as the empty-state line:
1402
+ // an empty index is a claim about the user's data.
1403
+ let indexLines;
1404
+ // id → name for the rows the index rendered, so the injected-set
1405
+ // record below can credit a citation of an index line the same way it
1406
+ // credits a ranked one — the line carries a `[mem:id]` handle, it was
1407
+ // shown, and a cite of it must not earn nothing.
1408
+ const indexEntities = [];
1409
+ // #360: `minimal` skips the index query outright — unlike the durable
1410
+ // index EXPOSED by the `briefing` tool/CLI (a separately-requestable
1411
+ // fact about the project), nothing else in this hook reads it, so
1412
+ // there is no reason to pay for a read whose only consumer is a
1413
+ // section this level does not render.
1414
+ if (!briefingPolicy.index) {
1415
+ indexLines = [];
1416
+ } else try {
1417
+ const excluded = INDEX_EXCLUDED_TYPES.map(() => '?').join(',');
1418
+ const indexRows = db.prepare(
1419
+ `SELECT e.id, e.name, e.type,${hasTitle ? ' e.title,' : ''} e.metadata,
1420
+ (SELECT substr(o.content, 1, ${INDEX_SNIPPET_FETCH_CHARS}) FROM observations o
1421
+ WHERE o.entity_id = e.id ORDER BY o.id ASC LIMIT 1) AS snippet,
1422
+ max(e.created_at, COALESCE((SELECT MAX(o2.created_at) FROM observations o2
1423
+ WHERE o2.entity_id = e.id), e.created_at)) AS last_activity
1424
+ FROM entities e
1425
+ WHERE e.id IN (SELECT entity_id FROM tags WHERE tag = ?)
1426
+ ${statusFilter} ${notGlobal}
1427
+ AND e.type NOT IN (${excluded})
1428
+ ORDER BY last_activity DESC, e.id DESC
1429
+ LIMIT ?`,
1430
+ ).all(projectTag, ...INDEX_EXCLUDED_TYPES, INDEX_CANDIDATE_CAP);
1431
+ const index = buildBriefingIndex(
1432
+ indexRows.map((row) => ({
1433
+ id: row.id,
1434
+ type: row.type,
1435
+ title: row.title ?? null,
1436
+ snippet: row.snippet,
1437
+ lastActivity: row.last_activity,
1438
+ // The RAW column: the index's gate must tell an absent metadata
1439
+ // column (allowed) from unparseable JSON (refused), exactly as
1440
+ // `isTrustedForAutoContext` does on the ranked path. Parsing here
1441
+ // would collapse both to null and fail OPEN.
1442
+ metadata: row.metadata,
1443
+ })),
1444
+ projectName,
1445
+ Date.now(),
1446
+ { truncated: indexRows.length >= INDEX_CANDIDATE_CAP },
1447
+ );
1448
+ indexLines = index.lines;
1449
+ const rendered = new Set(index.ids);
1450
+ for (const row of indexRows) if (rendered.has(row.id)) indexEntities.push(row);
1451
+ } catch (err) {
1452
+ const reason = String(err?.message || err);
1453
+ try { process.stderr.write(`[memesh session-start] briefing-index: ${reason}\n`); } catch {}
1454
+ recordHookOutcome(process.env, {
1455
+ hook: 'session-start',
1456
+ outcome: 'error',
1457
+ // The locus, plus a LABEL for the exception — never its message.
1458
+ // `hook-outcomes.jsonl` is permanent, exportable and meant to be
1459
+ // pasteable into an issue, and a message is a copy of whatever the
1460
+ // failure echoed: SQLite quotes the statement, execFileSync carries
1461
+ // absolute paths. `redactSecrets` runs on this field but
1462
+ // `redactUserPaths` does not. The full text is already on stderr
1463
+ // one line above, so nothing is lost.
1464
+ reason: `briefing-index: ${hookErrorReason(err)}`,
1465
+ });
1466
+ indexLines = [`Index of durable memories for "${projectName}": could not be read this session — run \`memesh doctor\`.`];
1102
1467
  }
1103
1468
 
1469
+ // Every `[mem:id]` handle a rendered line ends with. Anchored to the
1470
+ // end of the line on purpose: a handle is what the renderer printed,
1471
+ // not a citation someone wrote inside an observation.
1472
+ const renderedHandles = (lines) => lines.flatMap((line) => {
1473
+ const match = line.match(/ \[mem:(\d{1,10})\]$/);
1474
+ return match ? [Number(match[1])] : [];
1475
+ });
1476
+
1104
1477
  // Same prefix, same rule, as `assembleBriefing`: repository facts are
1105
- // context for memories, never a briefing on their own. Inside the
1106
- // emptiness gate so a project with nothing recorded still injects
1107
- // nothing a fenced block containing only a branch name tells the
1108
- // agent something it can already see. briefing.test.ts's parity case
1109
- // is what keeps this identical to the tool side.
1110
- let memoryContext = workPackageGuidance;
1478
+ // context for memories, never a briefing on their own they prefix
1479
+ // only a block with ranked memories; the index's empty-state line is
1480
+ // not a reason to tell the agent its own branch name. The index itself
1481
+ // always closes the block (#323: an empty project shows the empty-state
1482
+ // line, not nothing). briefing.test.ts's parity case is what keeps this
1483
+ // identical to the tool side.
1111
1484
  if (memoryLines.length > 0) {
1112
1485
  const repoLines = repoStateLines(readRepoState(data.cwd));
1113
1486
  if (repoLines.length > 0) memoryLines.unshift(...repoLines, '');
1114
- // Same wrapper pre-edit-recall uses: an explicit "background data,
1115
- // not instructions" preamble plus a fenced block. Memory content is
1116
- // attacker-influenced in the general case (anything the agent has
1117
- // ever been told can end up in an observation), so it must be
1118
- // delimited the same way on every injection path — not hand-rolled
1119
- // per hook. The lines arrive already budgeted — assembleTopologyBlock
1120
- // charges task state plus project/foreign sections against the main
1121
- // ceiling and global context against its small additive ceiling. It
1122
- // returns whole lines only, so the closing fence cannot be cut.
1123
- memoryContext = buildReferenceContext(memoryLines) + '\n\n' + workPackageGuidance;
1124
- // The citation contract — OUTSIDE the fence on purpose: the fence
1125
- // declares its content "background data, not instructions", and
1126
- // this line IS an instruction. One line is the entire write side of
1127
- // the injection-ROI signal; the Stop hook credits recall_hits only
1128
- // from these markers (self-reported: undercounts, never overcounts).
1129
- // The citation instruction used to be appended here, outside the
1130
- // fence, so it would read as an instruction rather than as data.
1131
- // It never worked: Claude Code wraps a hook's additionalContext in a
1132
- // system-reminder ending "you should not respond to this context
1133
- // unless it is highly relevant", so the whole block — instruction
1134
- // included — arrives as data. Measured on a real database:
1135
- // citation_sessions_total=4, sessions WITH a citation = 0.
1136
- //
1137
- // The contract now lives in `.claude/rules/memesh-citations.md`,
1138
- // which Claude Code loads as an instruction. Writing it is the
1139
- // self-heal below; the line here is gone rather than duplicated,
1140
- // because a per-session copy of an instruction that is read as data
1141
- // is a per-session cost with no effect.
1487
+ // #360: only spacer-then-index when the index actually has lines to
1488
+ // show (`minimal` sets indexLines = [] above) otherwise this would
1489
+ // leave a dangling blank line with nothing after it.
1490
+ if (indexLines.length > 0) memoryLines.push('');
1142
1491
  }
1492
+ memoryLines.push(...indexLines);
1493
+ // Same wrapper pre-edit-recall uses: an explicit "background data,
1494
+ // not instructions" preamble plus a fenced block. Memory content is
1495
+ // attacker-influenced in the general case (anything the agent has
1496
+ // ever been told can end up in an observation), so it must be
1497
+ // delimited the same way on every injection path — not hand-rolled
1498
+ // per hook. The lines arrive already budgeted — assembleTopologyBlock
1499
+ // charges task state plus project/foreign sections against the main
1500
+ // ceiling and global context against its small additive ceiling. It
1501
+ // returns whole lines only, so the closing fence cannot be cut.
1502
+ // #360: the work-package notice is `full`-only boilerplate; at every
1503
+ // other level workPackageNotice is undefined and this is just the
1504
+ // fenced block.
1505
+ //
1506
+ // When `memoryLines` is genuinely empty (no project content —
1507
+ // `minimal` with nothing durable yet, and no repository-state prefix
1508
+ // because that only prepends onto EXISTING topology lines) and there is
1509
+ // no work-package notice either, wrapping nothing in the preamble + an
1510
+ // empty ```text``` fence would spend 166+ characters informing the
1511
+ // agent of literally nothing. Inject NOTHING instead.
1512
+ // This can only happen at `minimal`: at `standard`/`full`,
1513
+ // `indexLines` always carries at least its own empty-state line
1514
+ // (#323 — "an index is a claim about the user's data", pinned by
1515
+ // tests/core/briefing.test.ts and unchanged here on purpose), so
1516
+ // `memoryLines` there is never actually empty.
1517
+ //
1518
+ // `hasBriefingContent` (work-topology.ts), not `memoryLines.length
1519
+ // === 0` inline: `assembleBriefing` (src/core/briefing.ts) makes the
1520
+ // IDENTICAL decision for the SAME reason on its own `block` array —
1521
+ // the hook cannot call that function directly (A1a: a hook cannot
1522
+ // import `../db.js`-dependent core modules), so this is the one place
1523
+ // the two sides CAN share the rule.
1524
+ const memoryContext = !hasBriefingContent(memoryLines) && !workPackageNotice
1525
+ ? undefined
1526
+ : workPackageNotice
1527
+ ? buildReferenceContext(memoryLines) + '\n\n' + workPackageNotice
1528
+ : buildReferenceContext(memoryLines);
1529
+ // The citation contract — OUTSIDE the fence on purpose: the fence
1530
+ // declares its content "background data, not instructions", and
1531
+ // this line IS an instruction. One line is the entire write side of
1532
+ // the injection-ROI signal; the Stop hook credits recall_hits only
1533
+ // from these markers (self-reported: undercounts, never overcounts).
1534
+ // The citation instruction used to be appended here, outside the
1535
+ // fence, so it would read as an instruction rather than as data.
1536
+ // It never worked: Claude Code wraps a hook's additionalContext in a
1537
+ // system-reminder ending "you should not respond to this context
1538
+ // unless it is highly relevant", so the whole block — instruction
1539
+ // included — arrives as data. Measured on a real database:
1540
+ // citation_sessions_total=4, sessions WITH a citation = 0.
1541
+ //
1542
+ // The contract now lives in `.claude/rules/memesh-citations.md`,
1543
+ // which Claude Code loads as an instruction. Writing it is the
1544
+ // self-heal below; the line here is gone rather than duplicated,
1545
+ // because a per-session copy of an instruction that is read as data
1546
+ // is a per-session cost with no effect.
1143
1547
 
1144
1548
  // --- Record injected entity IDs for recall effectiveness tracking ---
1145
1549
  // The Stop hook credits recall_hits from EXPLICIT `[mem:id]` citations
@@ -1151,16 +1555,16 @@ process.stdin.on('end', async () => {
1151
1555
  // kept as the record of what was shown.
1152
1556
  //
1153
1557
  // The set below is every pool the topology block draws from — the
1154
- // lessons pool included. It is derived from rendered citation handles,
1155
- // so clipped or budgeted-away candidates cannot be credited as shown.
1558
+ // lessons pool and the durable-memory index (#323) included. It is
1559
+ // derived from rendered citation handles, so clipped or budgeted-away
1560
+ // candidates cannot be credited as shown.
1156
1561
  try {
1157
- const renderedEntityIds = memoryLines.flatMap((line) => {
1158
- const match = line.match(/ \[mem:(\d{1,10})\]$/);
1159
- return match ? [Number(match[1])] : [];
1160
- });
1161
- const poolEntities = [...topLessons, ...projectEntities, ...globalEntities, ...recentEntities];
1562
+ const renderedEntityIds = renderedHandles(memoryLines);
1563
+ const poolEntities = [...topLessons, ...projectEntities, ...globalEntities, ...recentEntities, ...indexEntities];
1162
1564
  const entitiesById = new Map(poolEntities.map((entity) => [entity.id, entity]));
1163
- const allInjected = renderedEntityIds
1565
+ // A memory can appear in the ranked block AND the index; it was
1566
+ // injected once.
1567
+ const allInjected = [...new Set(renderedEntityIds)]
1164
1568
  .map((id) => entitiesById.get(id))
1165
1569
  .filter(Boolean);
1166
1570
 
@@ -1218,8 +1622,16 @@ process.stdin.on('end', async () => {
1218
1622
  let updateConsentContext = null;
1219
1623
  if (installedVersion) {
1220
1624
  const deprecation = buildDeprecationBanner(installedVersion, updateCache);
1625
+ const notice = updateNoticeBanner(installedVersion, updateCache);
1221
1626
  if (deprecation.length > 0) {
1222
1627
  bannerLines = deprecation;
1628
+ } else if (notice.lines.length > 0) {
1629
+ bannerLines = notice.lines;
1630
+ } else if (notice.kind !== 'UPGRADE_AVAILABLE') {
1631
+ // Snoozed ("Not now"), disabled ("Never ask again"), current, or
1632
+ // unknown-but-throttled: the resolver decided, so the routine
1633
+ // banner below must not undo it.
1634
+ bannerLines = [];
1223
1635
  } else {
1224
1636
  const channel = detectInstallChannelHook(resolvePluginRoot(import.meta.url));
1225
1637
  const consent = buildUpdateConsentPrompt(data.session_id, installedVersion, updateCache, channel);
@@ -1240,13 +1652,37 @@ process.stdin.on('end', async () => {
1240
1652
  }
1241
1653
  }
1242
1654
  }
1655
+ // The populated-database path builds its banner locally rather than
1656
+ // going through combineWithBanner() (the no-database path does). Keep
1657
+ // the capture-liveness notice in this path too: otherwise the warning
1658
+ // works only for a fresh graph and disappears precisely for users whose
1659
+ // existing memories make capture failure most costly.
1660
+ const captureLiveness = captureLivenessBannerLine(installedVersion);
1661
+ if (captureLiveness) bannerLines.push(captureLiveness);
1243
1662
  const finalMessage = bannerLines.length > 0
1244
1663
  ? [...bannerLines.filter(l => l.length > 0), '', summary].join('\n')
1245
1664
  : summary;
1246
1665
 
1247
- output(withCaptureWarning(finalMessage), updateConsentContext
1248
- ? `${updateConsentContext}\n\n${memoryContext}`
1249
- : memoryContext);
1666
+ // memoryContext can now be undefined (nothing to inject at this level)
1667
+ // — guard the concat so an empty injection does not become the LITERAL
1668
+ // string "undefined" glued onto a real consent prompt.
1669
+ output(
1670
+ withCaptureWarning(finalMessage),
1671
+ updateConsentContext
1672
+ ? (memoryContext ? `${updateConsentContext}\n\n${memoryContext}` : updateConsentContext)
1673
+ : memoryContext,
1674
+ // A specific, greppable reason for the "genuinely nothing to inject"
1675
+ // case, distinct from the generic 'session-start-banner' entity
1676
+ // marker output() would otherwise record for ANY falsy
1677
+ // memoryContext. The no-database and no-entities-table early exits
1678
+ // above record through the SAME `nothingToInjectReason` helper with
1679
+ // their own `detail`. Not recorded when the memory assembly threw:
1680
+ // that failure has its own error record, and an empty result it
1681
+ // caused must not also be reported as an empty project.
1682
+ !memoryContext && !memoryAssemblyFailed
1683
+ ? { outcome: 'notified', reason: nothingToInjectReason(briefingLevel, 'no project content, no repository state, index excluded') }
1684
+ : null,
1685
+ );
1250
1686
  if (updateConsentContext) {
1251
1687
  finalizeUpdatePromptClaim(data.session_id, installedVersion, updateCache?.latestVersion);
1252
1688
  }
@@ -1317,7 +1753,14 @@ process.stdin.on('end', async () => {
1317
1753
  // Hooks must never crash Claude Code — but report honestly.
1318
1754
  // Inner catch so the outer finally can still run the post-
1319
1755
  // banner update tasks even when the recall flow blew up.
1320
- console.log(JSON.stringify({ systemMessage: withCaptureWarning(`MeMesh: memories not loaded this session (${err?.message || 'unknown error'}) everything else works; run \`memesh doctor\` if this repeats.`) }));
1756
+ // Through output(), like every other emit: the gate proves "every
1757
+ // stdout write lies inside output()", and output() records exactly one
1758
+ // outcome — here an error, not the default `wrote`.
1759
+ output(
1760
+ withCaptureWarning(`MeMesh: memories not loaded this session (${err?.message || 'unknown error'}) — everything else works; run \`memesh doctor\` if this repeats.`),
1761
+ null,
1762
+ { outcome: 'error', reason: hookErrorReason(err) },
1763
+ );
1321
1764
  }
1322
1765
  } finally {
1323
1766
  // ── Auto-update + cache refresh ──────────────────────────────
@@ -1353,9 +1796,26 @@ process.stdin.on('end', async () => {
1353
1796
  *
1354
1797
  * The shape is asserted by tests/helpers/hook-output-contract.ts.
1355
1798
  */
1356
- const workPackageGuidance = 'Work packages: check work_package prepare for this project (digest or transcript). When available, offer a concise host-native interactive choice in the user’s conversation language: dispatch an agent task, later (defer not_now), or stop suggesting for this session. Never dispatch without the user choosing it. The Dashboard cannot dispatch agents, and no durable opt-out is implied.';
1799
+ // The notice's literal text is `_shared.js`'s exported `WORK_PACKAGE_NOTICE`
1800
+ // — single owner, shared with the test suite instead of a second hardcoded
1801
+ // copy there.
1357
1802
 
1358
- function output(text, memoryContext = workPackageGuidance) {
1803
+ // #360: `memoryContext` deliberately has no default. A default of
1804
+ // `WORK_PACKAGE_NOTICE` would give any call site that passed only `text` the
1805
+ // notice regardless of level — exactly the kind of default that silently
1806
+ // reintroduces what a level was supposed to drop. Every call site passes its
1807
+ // memoryContext (or `workPackageNotice`, already gated) explicitly;
1808
+ // `undefined` here means "this exit has nothing to add", not "fall back to
1809
+ // the notice".
1810
+ function output(text, memoryContext = undefined, recorded = null) {
1811
+ // session-start's only effect is the context it injects, so it records
1812
+ // `notified`, not `wrote`: doctor's `writes` answers "is memory capture
1813
+ // still alive", and injected context is something this hook READ, not
1814
+ // something it stored. Recorded here rather than at each of the handler's many
1815
+ // returns because output() is the single emit point they all funnel
1816
+ // through, so no path can add itself later and stay invisible (#327).
1817
+ // `recorded` overrides the outcome for the one path that is not a write
1818
+ // (the recall flow threw): still one emit, still exactly one record.
1359
1819
  const payload = { systemMessage: text };
1360
1820
  if (memoryContext) {
1361
1821
  payload.hookSpecificOutput = {
@@ -1364,4 +1824,11 @@ function output(text, memoryContext = workPackageGuidance) {
1364
1824
  };
1365
1825
  }
1366
1826
  console.log(JSON.stringify(payload));
1827
+ recordHookOutcome(process.env, recorded
1828
+ ? { hook: 'session-start', outcome: recorded.outcome, reason: recorded.reason }
1829
+ : {
1830
+ hook: 'session-start',
1831
+ outcome: 'notified',
1832
+ entity: memoryContext ? 'session-start-context' : 'session-start-banner',
1833
+ });
1367
1834
  }