@pcircle/memesh 4.10.1 → 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 (86) 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 +44 -13
  5. package/README.de.md +3 -3
  6. package/README.md +3 -3
  7. package/README.zh-TW.md +3 -3
  8. package/dashboard/dist/index.html +1 -1
  9. package/dist/core/agent-message-inbox.d.ts +6 -0
  10. package/dist/core/agent-message-inbox.d.ts.map +1 -1
  11. package/dist/core/agent-message-inbox.js +28 -1
  12. package/dist/core/agent-message-inbox.js.map +1 -1
  13. package/dist/core/briefing-level.d.ts +22 -0
  14. package/dist/core/briefing-level.d.ts.map +1 -0
  15. package/dist/core/briefing-level.js +75 -0
  16. package/dist/core/briefing-level.js.map +1 -0
  17. package/dist/core/briefing.d.ts +3 -0
  18. package/dist/core/briefing.d.ts.map +1 -1
  19. package/dist/core/briefing.js +33 -11
  20. package/dist/core/briefing.js.map +1 -1
  21. package/dist/core/capture-liveness.d.ts +1 -0
  22. package/dist/core/capture-liveness.d.ts.map +1 -1
  23. package/dist/core/capture-liveness.js +1 -0
  24. package/dist/core/capture-liveness.js.map +1 -1
  25. package/dist/core/config.d.ts +1 -0
  26. package/dist/core/config.d.ts.map +1 -1
  27. package/dist/core/config.js +3 -1
  28. package/dist/core/config.js.map +1 -1
  29. package/dist/core/paths.d.ts +1 -0
  30. package/dist/core/paths.d.ts.map +1 -1
  31. package/dist/core/paths.js +4 -0
  32. package/dist/core/paths.js.map +1 -1
  33. package/dist/core/schema-export.d.ts.map +1 -1
  34. package/dist/core/schema-export.js +5 -1
  35. package/dist/core/schema-export.js.map +1 -1
  36. package/dist/core/serializer.d.ts +2 -0
  37. package/dist/core/serializer.d.ts.map +1 -1
  38. package/dist/core/serializer.js +119 -6
  39. package/dist/core/serializer.js.map +1 -1
  40. package/dist/core/task-state.d.ts +5 -0
  41. package/dist/core/task-state.d.ts.map +1 -1
  42. package/dist/core/task-state.js +70 -0
  43. package/dist/core/task-state.js.map +1 -1
  44. package/dist/core/types.d.ts +2 -0
  45. package/dist/core/types.d.ts.map +1 -1
  46. package/dist/core/work-topology.d.ts +1 -0
  47. package/dist/core/work-topology.d.ts.map +1 -1
  48. package/dist/core/work-topology.js +3 -0
  49. package/dist/core/work-topology.js.map +1 -1
  50. package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
  51. package/dist/mcp/server.js +448 -121
  52. package/dist/mcp/server.js.map +3 -3
  53. package/dist/skills-manifest.json +29 -24
  54. package/dist/storage/fts-index.d.ts +1 -0
  55. package/dist/storage/fts-index.d.ts.map +1 -1
  56. package/dist/storage/fts-index.js +5 -0
  57. package/dist/storage/fts-index.js.map +1 -1
  58. package/dist/transports/cli/cli.d.ts.map +1 -1
  59. package/dist/transports/cli/cli.js +348 -53
  60. package/dist/transports/cli/cli.js.map +3 -3
  61. package/dist/transports/http/server.d.ts.map +1 -1
  62. package/dist/transports/http/server.js +10 -1
  63. package/dist/transports/http/server.js.map +1 -1
  64. package/dist/transports/mcp/handlers.d.ts +7 -3
  65. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  66. package/dist/transports/mcp/handlers.js +7 -3
  67. package/dist/transports/mcp/handlers.js.map +1 -1
  68. package/dist/transports/schemas.d.ts +1 -0
  69. package/dist/transports/schemas.d.ts.map +1 -1
  70. package/dist/transports/schemas.js +1 -0
  71. package/dist/transports/schemas.js.map +1 -1
  72. package/docs/platforms/agent-messaging.md +17 -3
  73. package/package.json +4 -9
  74. package/scripts/hooks/_generated/agent-message-inbox.js +28 -1
  75. package/scripts/hooks/_generated/briefing-level.js +82 -0
  76. package/scripts/hooks/_generated/capture-liveness.js +1 -0
  77. package/scripts/hooks/_generated/core-paths.js +4 -0
  78. package/scripts/hooks/_generated/fts-index.js +5 -0
  79. package/scripts/hooks/_generated/task-state.js +70 -0
  80. package/scripts/hooks/_generated/work-topology.js +3 -0
  81. package/scripts/hooks/_shared.js +623 -15
  82. package/scripts/hooks/pre-edit-recall.js +386 -65
  83. package/scripts/hooks/session-start.js +261 -77
  84. package/scripts/hooks/user-prompt-intent.js +38 -6
  85. package/scripts/upgrade-plugin.sh +3 -4
  86. package/skills/memesh/SKILL.md +26 -16
@@ -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,
@@ -50,7 +51,15 @@ import {
50
51
  repoStateLines,
51
52
  resolvePluginRoot,
52
53
  resolveSessionLimit,
53
- taskStateLines,
54
+ briefingTaskStateLines,
55
+ resolveBriefingLevel,
56
+ resolveMessageRecipient,
57
+ waitingMessageLines,
58
+ briefingLevelPolicy,
59
+ sessionStartAppendsWorkPackageNotice,
60
+ WORK_PACKAGE_NOTICE,
61
+ readHookConfigResult,
62
+ HOOK_CONFIG_UNREADABLE_REASON,
54
63
  homeDir,
55
64
  taskStateName,
56
65
  writeCitationRule,
@@ -58,7 +67,6 @@ import {
58
67
  writePrivateJson,
59
68
  } from './_shared.js';
60
69
  import { MemeshDatabase } from './_generated/sqlite.js';
61
- import { unreadDeliveryCount, unreadInboxLines } from './_generated/agent-message-inbox.js';
62
70
  import {
63
71
  buildBriefingIndex,
64
72
  INDEX_CANDIDATE_CAP,
@@ -68,13 +76,12 @@ import {
68
76
 
69
77
  const require = createRequire(import.meta.url);
70
78
 
71
- // Codex round 37: dist/core/install-channel.js is emitted as ESM
72
- // (the project's tsconfig produces NodeNext modules). On Node 20.x
73
- // `require()` against an ESM file throws ERR_REQUIRE_ESM, which
74
- // silently downgraded all install-channel detection to 'unknown' on
75
- // the supported floor. Pre-load the module via dynamic `import()`
76
- // at hook startup using a top-level await once at process init,
77
- // 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
78
85
  // missing (source checkout pre-build) or fails to load.
79
86
  let _installChannelMod = null;
80
87
  try {
@@ -124,32 +131,26 @@ function buildDeprecationBanner(currentVersion, cache) {
124
131
  `⚠️ MeMesh ${currentVersion} is DEPRECATED by maintainers.`,
125
132
  ` ${msg}`,
126
133
  ];
127
- // Codex round 36: emit a remediation line for EVERY deprecation
128
- // banner including the cases where the cached `latestVersion`
129
- // is null, equal to current, or stale. The previous gate omitted
130
- // the action line whenever the cache didn't yet show a strictly-
131
- // newer version, leaving users with a security warning and no
132
- // follow-up step. doctor / CLI status / dashboard already point
133
- // at `memesh update` (or channel equivalents) in those uncertain
134
- // cases, and the session-start banner should match — `npm`
135
- // resolves @latest at install time, so the command works even
136
- // 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.
137
141
  const knownUpgradeTarget = Boolean(
138
142
  cache.latestVersion && cache.latestVersion !== currentVersion,
139
143
  );
140
- // Codex round 39: the SessionStart hook reads ONLY cached cache
141
- // data there's no fresh lookup happening on this code path.
142
- // That means `freshness === 'fresh'` (the strict rule the
143
- // dashboard / `memesh status` use to authoritatively say
144
- // "no upgrade target yet") can never apply here. Round 38 used a
145
- // 24h-window heuristic to fire the no-target message anyway, but
146
- // codex correctly flagged that as suppressing the upgrade hint
147
- // exactly when a security-advisory fix could ship within the
148
- // window. Conservative remediation: always recommend
149
- // `memesh update` (which is a harmless no-op when there's truly
150
- // no target, and immediately applies a freshly-published fix
151
- // when there is one). The "no target yet" message remains
152
- // 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
153
154
  // (after a Check now click).
154
155
  // Tailor the remediation hint to the install channel. `memesh
155
156
  // update` and `autoUpdate` only work for npm-global installs;
@@ -177,9 +178,8 @@ function buildDeprecationBanner(currentVersion, cache) {
177
178
  } else if (channel === 'source-checkout') {
178
179
  lines.push(` Source checkout: pull and rebuild (\`git pull && npm install && npm run build\`).`);
179
180
  } else if (channel === 'npm-local') {
180
- // Codex round 30: the cached `latestVersion` may itself be
181
- // stale (cache TTL is 24h and we're already showing a stale
182
- // 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
183
183
  // already-superseded build that's part of the same security
184
184
  // advisory. `@latest` always resolves to the registry's
185
185
  // current dist-tag at install time, which is the right
@@ -497,14 +497,13 @@ function spawnFreshUpdateCheck(installedVersion) {
497
497
  // the banner marker above: marker and cache must share a directory.
498
498
  const dir = memeshHomeDir();
499
499
  try { ensurePrivateDir(dir); } catch { /* best-effort */ }
500
- // Codex round 37: scope the throttle marker to the installed
501
- // version. The marker was machine-global, so a refresh started
502
- // by a global 4.1.3 install would suppress refreshes for a
503
- // sibling project-local 4.1.1 for the next 5 minutes and the
504
- // shared cache it wrote would carry version 4.1.3, so the
505
- // 4.1.1 session would skip its banner because
506
- // `cache.currentVersion !== currentVersion`. Per-version
507
- // 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.
508
507
  // Sanitize version for filesystem (semver chars only, no path
509
508
  // separators); fall back to 'unknown' if missing.
510
509
  const versionTag = typeof installedVersion === 'string'
@@ -512,13 +511,12 @@ function spawnFreshUpdateCheck(installedVersion) {
512
511
  ? installedVersion
513
512
  : 'unknown';
514
513
  const markerPath = join(dir, `last-fresh-refresh.${versionTag}.lock`);
515
- // Single-owner claim: O_EXCL atomic create. Codex round 27
516
- // caught that the previous temp+rename+readback pattern was
517
- // racy both peers' renames are destructive, so each could
518
- // read its own token back and both would spawn a refresh.
519
- // O_EXCL is the standard POSIX/libuv primitive that lets at
520
- // most one process succeed. The updater runner uses the same O_EXCL
521
- // 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.
522
520
  const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
523
521
  const claim = () => {
524
522
  try {
@@ -799,6 +797,18 @@ function combineWithBanner(baseMessage, { skipUpdateBanner = false } = {}) {
799
797
  return [...lines.filter((l) => l.length > 0), '', baseMessage].join('\n');
800
798
  }
801
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
+
802
812
  let input = '';
803
813
  process.stdin.setEncoding('utf8');
804
814
  process.stdin.on('data', (chunk) => { input += chunk; });
@@ -809,13 +819,70 @@ process.stdin.on('end', async () => {
809
819
  let captureWarning = null;
810
820
  const withCaptureWarning = (msg) => {
811
821
  if (!captureWarning) return msg;
812
- 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 · ')}`;
813
831
  };
814
832
  try {
815
833
  try {
816
834
  const data = JSON.parse(input);
817
835
  const projectName = getProjectName(data.cwd);
818
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
+
819
886
  // Self-heal the citation contract.
820
887
  //
821
888
  // `install-hooks` writes it too, but a PLUGIN install never runs that
@@ -911,8 +978,22 @@ process.stdin.on('end', async () => {
911
978
  captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work',
912
979
  { skipUpdateBanner: alreadyNoticed },
913
980
  );
914
- output(consent ? `${consent.system}\n${emptySummary}` : emptySummary,
915
- consent ? `${consent.context}\n\n${workPackageGuidance}` : workPackageGuidance);
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
+ );
916
997
  if (consent) finalizeUpdatePromptClaim(data.session_id, consentVersion, consentCache?.latestVersion);
917
998
  return;
918
999
  }
@@ -939,7 +1020,14 @@ process.stdin.on('end', async () => {
939
1020
  "SELECT name FROM sqlite_master WHERE type='table' AND name='entities'"
940
1021
  ).get();
941
1022
  if (!tableCheck) {
942
- 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
+ );
943
1031
  return;
944
1032
  }
945
1033
 
@@ -1074,8 +1162,11 @@ process.stdin.on('end', async () => {
1074
1162
  // gives them a separate render budget. The project keeps `sessionLimit`
1075
1163
  // slots and its full character budget. The column is absent on
1076
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.
1077
1168
  let globalEntities = [];
1078
- if (colNames.has('namespace')) {
1169
+ if (briefingPolicy.global && colNames.has('namespace')) {
1079
1170
  const globalQuery = buildScoringQuery('', `WHERE e.namespace = 'global' ${statusFilter}`);
1080
1171
  globalEntities = db.prepare(globalQuery).all(CANDIDATE_CAP)
1081
1172
  .filter(entity => isTrustedForAutoContext(entity.metadata))
@@ -1086,15 +1177,18 @@ process.stdin.on('end', async () => {
1086
1177
  // recentStatusFilter is "WHERE status = 'active'" or "" — the bare-column
1087
1178
  // form is fine when there's no JOIN, but we now alias the table as `e`,
1088
1179
  // so rewrite to e.status for consistency.
1089
- const recentConditions = [
1090
- hasStatus ? "e.status = 'active'" : '',
1091
- colNames.has('namespace') ? "(e.namespace IS NULL OR e.namespace <> 'global')" : '',
1092
- ].filter(Boolean);
1093
- const recentWhere = recentConditions.length > 0 ? `WHERE ${recentConditions.join(' AND ')}` : '';
1094
- const recentQuery = buildScoringQuery('', recentWhere);
1095
- const recentEntities = db.prepare(recentQuery).all(CANDIDATE_CAP)
1096
- .filter(entity => isTrustedForAutoContext(entity.metadata))
1097
- .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
+ }
1098
1192
 
1099
1193
  // Lesson count (queried for summary, not listed individually).
1100
1194
  // Status-column gate matches the project/recent queries above —
@@ -1174,6 +1268,7 @@ process.stdin.on('end', async () => {
1174
1268
  const topLessons = lessonEntities.slice(0, 5);
1175
1269
 
1176
1270
  const memoryLines = [];
1271
+ let memoryAssemblyFailed = false;
1177
1272
  try {
1178
1273
  const rankedIds = [
1179
1274
  ...topLessons.map(e => e.id),
@@ -1219,14 +1314,31 @@ process.stdin.on('end', async () => {
1219
1314
  const taskRow = db
1220
1315
  .prepare('SELECT metadata FROM entities WHERE name = ?')
1221
1316
  .get(taskStateName(projectName));
1222
- // SessionStart has no exact recipient identity. The shared leaf fails
1223
- // 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.
1224
1329
  const stateLines = [
1225
- ...taskStateLines(
1330
+ ...briefingTaskStateLines(
1226
1331
  parseTaskState(parseEntityMetadata(taskRow?.metadata)),
1227
1332
  projectName,
1333
+ new Date(),
1334
+ { includeFresh: briefingPolicy.taskState },
1228
1335
  ),
1229
- ...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
+ })),
1230
1342
  ];
1231
1343
 
1232
1344
  // The pools overlap by construction (a lesson tagged to this project
@@ -1268,6 +1380,16 @@ process.stdin.on('end', async () => {
1268
1380
  // means memories stop reaching the model again (the exact v4.2.7
1269
1381
  // regression this block was written to fix).
1270
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
+ });
1271
1393
  }
1272
1394
 
1273
1395
  // --- The durable-memory index (#323) -----------------------------
@@ -1284,7 +1406,14 @@ process.stdin.on('end', async () => {
1284
1406
  // credits a ranked one — the line carries a `[mem:id]` handle, it was
1285
1407
  // shown, and a cite of it must not earn nothing.
1286
1408
  const indexEntities = [];
1287
- try {
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 {
1288
1417
  const excluded = INDEX_EXCLUDED_TYPES.map(() => '?').join(',');
1289
1418
  const indexRows = db.prepare(
1290
1419
  `SELECT e.id, e.name, e.type,${hasTitle ? ' e.title,' : ''} e.metadata,
@@ -1355,7 +1484,10 @@ process.stdin.on('end', async () => {
1355
1484
  if (memoryLines.length > 0) {
1356
1485
  const repoLines = repoStateLines(readRepoState(data.cwd));
1357
1486
  if (repoLines.length > 0) memoryLines.unshift(...repoLines, '');
1358
- memoryLines.push('');
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('');
1359
1491
  }
1360
1492
  memoryLines.push(...indexLines);
1361
1493
  // Same wrapper pre-edit-recall uses: an explicit "background data,
@@ -1367,7 +1499,33 @@ process.stdin.on('end', async () => {
1367
1499
  // charges task state plus project/foreign sections against the main
1368
1500
  // ceiling and global context against its small additive ceiling. It
1369
1501
  // returns whole lines only, so the closing fence cannot be cut.
1370
- const memoryContext = buildReferenceContext(memoryLines) + '\n\n' + workPackageGuidance;
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);
1371
1529
  // The citation contract — OUTSIDE the fence on purpose: the fence
1372
1530
  // declares its content "background data, not instructions", and
1373
1531
  // this line IS an instruction. One line is the entire write side of
@@ -1505,9 +1663,26 @@ process.stdin.on('end', async () => {
1505
1663
  ? [...bannerLines.filter(l => l.length > 0), '', summary].join('\n')
1506
1664
  : summary;
1507
1665
 
1508
- output(withCaptureWarning(finalMessage), updateConsentContext
1509
- ? `${updateConsentContext}\n\n${memoryContext}`
1510
- : 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
+ );
1511
1686
  if (updateConsentContext) {
1512
1687
  finalizeUpdatePromptClaim(data.session_id, installedVersion, updateCache?.latestVersion);
1513
1688
  }
@@ -1621,9 +1796,18 @@ process.stdin.on('end', async () => {
1621
1796
  *
1622
1797
  * The shape is asserted by tests/helpers/hook-output-contract.ts.
1623
1798
  */
1624
- 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.';
1625
-
1626
- function output(text, memoryContext = workPackageGuidance, recorded = null) {
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.
1802
+
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) {
1627
1811
  // session-start's only effect is the context it injects, so it records
1628
1812
  // `notified`, not `wrote`: doctor's `writes` answers "is memory capture
1629
1813
  // still alive", and injected context is something this hook READ, not
@@ -32,11 +32,12 @@ import {
32
32
  readUpdateCheckCache,
33
33
  readUpdatePromptClaim,
34
34
  resolvePluginRoot,
35
+ unreadMessageLines,
35
36
  writeAutoUpdateConsent,
36
37
  writeSnooze,
37
38
  } from './_shared.js';
38
39
  import { join } from 'path';
39
- import { existsSync, readFileSync } from 'fs';
40
+ import { existsSync, readFileSync, realpathSync } from 'fs';
40
41
 
41
42
  let installChannelMod = null;
42
43
  try {
@@ -217,7 +218,29 @@ function logError(scope, msg) {
217
218
  // `file://${process.argv[1]}` produces an invalid URL because the path uses
218
219
  // backslashes; pathToFileURL() correctly normalizes to a file:// URL on
219
220
  // every platform, so the comparison is portable.
220
- const isMainModule = import.meta.url === pathToFileURL(process.argv[1]).href;
221
+ //
222
+ // `import.meta.url` is the REAL path of this file; `process.argv[1]` is the
223
+ // path as the host typed it. Through a symlink (plugin cache, global npm
224
+ // prefix) the two differ, so compare the typed path first — under
225
+ // `--preserve-symlinks-main` Node leaves `import.meta.url` unresolved too, and
226
+ // resolving the typed side unconditionally would make them diverge — and only
227
+ // then the resolved path. A realpath failure on that fallback is left to
228
+ // throw: a crash is visible, "not the entry point" is not.
229
+ //
230
+ // Also reads "not main" for `node -` (`argv[1]` is the literal `-`;
231
+ // `realpathSync('-')` would throw) and `node -e`/`-p` (the eval flag is in
232
+ // `process.execArgv`; `argv[1]` is just the caller's first argument). That
233
+ // is `isMain()`'s (scripts/lib/verify-core.mjs) `-`/eval handling and its
234
+ // typed-path-then-realpath order, without its directory case: `node <dir>`
235
+ // reads as "not main" here, where `isMain()` throws. The hook cannot import
236
+ // `isMain()`: it ships standalone (package.json `files`).
237
+ const EVAL_FLAG = /^(-e|-p|-pe|--eval|--print)(=|$)/;
238
+ const entryPath = process.argv[1];
239
+ const isMainModule = Boolean(entryPath)
240
+ && entryPath !== '-'
241
+ && !process.execArgv.some((arg) => EVAL_FLAG.test(arg))
242
+ && (import.meta.url === pathToFileURL(entryPath).href
243
+ || import.meta.url === pathToFileURL(realpathSync(entryPath)).href);
221
244
  if (isMainModule) {
222
245
  // See post-commit.js for why every exit path leaves a record (#327). This
223
246
  // hook's only effect is the additionalContext it injects, so its outcome is
@@ -257,13 +280,20 @@ if (isMainModule) {
257
280
  const prompt = data.prompt ?? data.user_prompt ?? '';
258
281
  const updateDecision = await recordUpdateConsent(data.session_id, prompt);
259
282
  const rememberIntent = detectRememberIntent(prompt);
260
- if (!rememberIntent && !updateDecision) {
283
+ // Messages waiting for the recipient this session declared in
284
+ // MEMESH_RECIPIENT. Read-only and not memory capture, so it is not
285
+ // gated by autoCapture. Empty when the variable is unset. An inbox that
286
+ // cannot be read is recorded as an `error` of its own (a label, never
287
+ // the message), next to whatever this prompt's outcome turns out to be,
288
+ // so a skip below cannot be read as "nothing was waiting".
289
+ const inboxLines = unreadMessageLines(process.env, (err) => record('error', `inbox: ${hookErrorReason(err)}`));
290
+ if (!rememberIntent && !updateDecision && inboxLines.length === 0) {
261
291
  record('skipped', SKIP_REASONS.noPromptIntent);
262
292
  return process.exit(0);
263
293
  }
264
294
  // Update consent is a user-authorized control decision, not memory
265
295
  // capture; it must still be recorded when auto-capture is disabled.
266
- if (!isAutoCaptureEnabled(process.env) && !updateDecision) {
296
+ if (!isAutoCaptureEnabled(process.env) && !updateDecision && inboxLines.length === 0) {
267
297
  record('skipped', SKIP_REASONS.autoCaptureOff);
268
298
  return process.exit(0);
269
299
  }
@@ -276,10 +306,12 @@ if (isMainModule) {
276
306
  } else if (updateDecision === 'never') {
277
307
  contexts.push('The user asked never to be asked about MeMesh updates again. updateCheck is now off; do not mention updates. `memesh config set updateCheck true` turns checks back on.');
278
308
  }
279
- if (rememberIntent) contexts.push(buildHint());
309
+ const hint = rememberIntent && isAutoCaptureEnabled(process.env);
310
+ if (hint) contexts.push(buildHint());
311
+ if (inboxLines.length > 0) contexts.push(inboxLines.join('\n'));
280
312
  const out = { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: contexts.join('\n\n') } };
281
313
  process.stdout.write(JSON.stringify(out));
282
- record('notified', undefined, `hint:${updateDecision ?? 'remember-intent'}`);
314
+ record('notified', undefined, `hint:${updateDecision ?? (hint ? 'remember-intent' : 'inbox')}`);
283
315
  process.exit(0);
284
316
  } catch (err) {
285
317
  logError('user-prompt-intent', err?.message || err);
@@ -497,14 +497,13 @@ fi
497
497
  # that never contained them. `git archive` extracts exactly the tree at
498
498
  # $MARKETPLACE_SHA; nothing untracked or uncommitted can reach the cache.
499
499
  # node_modules and .git are never tracked, so they never appear in the
500
- # archive; tests/benchmarks/docs/plans are tracked (real source) and are
501
- # removed after extraction to keep the shipped cache the same shape as
502
- # before.
500
+ # archive; tests/benchmarks are tracked (real source) and are removed after
501
+ # extraction to keep the shipped cache the same shape as before.
503
502
  git -C "$MARKETPLACE_DIR" archive "$MARKETPLACE_SHA" | tar -x -C "$STAGE_PATH" || {
504
503
  echo "ERROR: git archive failed — the live cache at $NEW_INSTALL_PATH was not touched" >&2
505
504
  exit 1
506
505
  }
507
- if ! rm -rf "$STAGE_PATH/tests" "$STAGE_PATH/benchmarks" "$STAGE_PATH/docs/plans"; then
506
+ if ! rm -rf "$STAGE_PATH/tests" "$STAGE_PATH/benchmarks"; then
508
507
  echo "ERROR: could not remove development-only files from the staging copy — the live cache was not touched." >&2
509
508
  exit 1
510
509
  fi