@pcircle/memesh 4.9.3 → 4.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) 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 +35 -4
  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 +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/briefing-index.d.ts +30 -0
  13. package/dist/core/briefing-index.d.ts.map +1 -0
  14. package/dist/core/briefing-index.js +140 -0
  15. package/dist/core/briefing-index.js.map +1 -0
  16. package/dist/core/briefing.d.ts +4 -0
  17. package/dist/core/briefing.d.ts.map +1 -1
  18. package/dist/core/briefing.js +42 -4
  19. package/dist/core/briefing.js.map +1 -1
  20. package/dist/core/capture-liveness.d.ts +136 -0
  21. package/dist/core/capture-liveness.d.ts.map +1 -0
  22. package/dist/core/capture-liveness.js +447 -0
  23. package/dist/core/capture-liveness.js.map +1 -0
  24. package/dist/core/config.d.ts +1 -0
  25. package/dist/core/config.d.ts.map +1 -1
  26. package/dist/core/config.js +3 -1
  27. package/dist/core/config.js.map +1 -1
  28. package/dist/core/delegation.d.ts +47 -0
  29. package/dist/core/delegation.d.ts.map +1 -0
  30. package/dist/core/delegation.js +174 -0
  31. package/dist/core/delegation.js.map +1 -0
  32. package/dist/core/doctor-fixes.d.ts +1 -0
  33. package/dist/core/doctor-fixes.d.ts.map +1 -1
  34. package/dist/core/doctor-fixes.js +21 -0
  35. package/dist/core/doctor-fixes.js.map +1 -1
  36. package/dist/core/doctor.d.ts +8 -0
  37. package/dist/core/doctor.d.ts.map +1 -1
  38. package/dist/core/doctor.js +148 -3
  39. package/dist/core/doctor.js.map +1 -1
  40. package/dist/core/note-derive.d.ts +14 -0
  41. package/dist/core/note-derive.d.ts.map +1 -0
  42. package/dist/core/note-derive.js +66 -0
  43. package/dist/core/note-derive.js.map +1 -0
  44. package/dist/core/note-ingest.d.ts +34 -0
  45. package/dist/core/note-ingest.d.ts.map +1 -0
  46. package/dist/core/note-ingest.js +445 -0
  47. package/dist/core/note-ingest.js.map +1 -0
  48. package/dist/core/operations.d.ts +10 -1
  49. package/dist/core/operations.d.ts.map +1 -1
  50. package/dist/core/operations.js +129 -14
  51. package/dist/core/operations.js.map +1 -1
  52. package/dist/core/paths.d.ts.map +1 -1
  53. package/dist/core/paths.js.map +1 -1
  54. package/dist/core/schema-export.d.ts.map +1 -1
  55. package/dist/core/schema-export.js +10 -4
  56. package/dist/core/schema-export.js.map +1 -1
  57. package/dist/core/serializer.d.ts.map +1 -1
  58. package/dist/core/serializer.js +3 -0
  59. package/dist/core/serializer.js.map +1 -1
  60. package/dist/core/session-insight.d.ts +44 -0
  61. package/dist/core/session-insight.d.ts.map +1 -0
  62. package/dist/core/session-insight.js +216 -0
  63. package/dist/core/session-insight.js.map +1 -0
  64. package/dist/core/task-state-store.d.ts +4 -0
  65. package/dist/core/task-state-store.d.ts.map +1 -1
  66. package/dist/core/task-state-store.js +18 -5
  67. package/dist/core/task-state-store.js.map +1 -1
  68. package/dist/core/turn-signal.d.ts +22 -0
  69. package/dist/core/turn-signal.d.ts.map +1 -0
  70. package/dist/core/turn-signal.js +89 -0
  71. package/dist/core/turn-signal.js.map +1 -0
  72. package/dist/core/types.d.ts +12 -3
  73. package/dist/core/types.d.ts.map +1 -1
  74. package/dist/core/update-entrypoint.d.ts +19 -0
  75. package/dist/core/update-entrypoint.d.ts.map +1 -0
  76. package/dist/core/update-entrypoint.js +222 -0
  77. package/dist/core/update-entrypoint.js.map +1 -0
  78. package/dist/core/update-notice.d.ts +69 -0
  79. package/dist/core/update-notice.d.ts.map +1 -0
  80. package/dist/core/update-notice.js +188 -0
  81. package/dist/core/update-notice.js.map +1 -0
  82. package/dist/host-runtime/codex-session.d.ts +10 -0
  83. package/dist/host-runtime/codex-session.d.ts.map +1 -1
  84. package/dist/host-runtime/codex-session.js +1 -1
  85. package/dist/host-runtime/codex-session.js.map +1 -1
  86. package/dist/knowledge-graph.d.ts.map +1 -1
  87. package/dist/knowledge-graph.js +24 -3
  88. package/dist/knowledge-graph.js.map +1 -1
  89. package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
  90. package/dist/mcp/server.js +1079 -90
  91. package/dist/mcp/server.js.map +3 -3
  92. package/dist/skills-manifest.json +49 -24
  93. package/dist/transports/cli/cli.d.ts.map +1 -1
  94. package/dist/transports/cli/cli.js +7702 -5764
  95. package/dist/transports/cli/cli.js.map +3 -3
  96. package/dist/transports/http/server.d.ts.map +1 -1
  97. package/dist/transports/http/server.js +38 -27
  98. package/dist/transports/http/server.js.map +1 -1
  99. package/dist/transports/mcp/handlers.d.ts +25 -5
  100. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  101. package/dist/transports/mcp/handlers.js +43 -5
  102. package/dist/transports/mcp/handlers.js.map +1 -1
  103. package/dist/transports/schemas.d.ts +4 -2
  104. package/dist/transports/schemas.d.ts.map +1 -1
  105. package/dist/transports/schemas.js +29 -3
  106. package/dist/transports/schemas.js.map +1 -1
  107. package/docs/platforms/README.md +1 -0
  108. package/docs/platforms/agent-messaging.md +22 -5
  109. package/package.json +17 -4
  110. package/scripts/check-plugin-hook-artifact.mjs +212 -0
  111. package/scripts/hooks/_generated/briefing-index.js +147 -0
  112. package/scripts/hooks/_generated/capture-liveness.js +454 -0
  113. package/scripts/hooks/_generated/update-notice.js +195 -0
  114. package/scripts/hooks/_shared.js +302 -14
  115. package/scripts/hooks/_stop-notes.js +504 -0
  116. package/scripts/hooks/auto-update-runner.mjs +22 -0
  117. package/scripts/hooks/decision-nudge.js +32 -5
  118. package/scripts/hooks/guard-check.js +30 -3
  119. package/scripts/hooks/post-commit.js +329 -133
  120. package/scripts/hooks/pre-compact.js +17 -2
  121. package/scripts/hooks/pre-edit-recall.js +32 -1
  122. package/scripts/hooks/session-start.js +361 -78
  123. package/scripts/hooks/session-summary.js +217 -41
  124. package/scripts/hooks/user-prompt-intent.js +89 -19
  125. package/scripts/lib/npm-bin.mjs +123 -0
  126. package/scripts/upgrade-plugin.sh +24 -0
  127. package/skills/memesh/SKILL.md +20 -8
  128. package/dist/cli/assets/d3.v7.min.js +0 -2
  129. package/dist/core/graph.d.ts +0 -26
  130. package/dist/core/graph.d.ts.map +0 -1
  131. package/dist/core/graph.js +0 -74
  132. package/dist/core/graph.js.map +0 -1
@@ -28,20 +28,42 @@ import {
28
28
  matchingGuards,
29
29
  guardWarningLines,
30
30
  recordGuardFires,
31
+ hookErrorReason,
32
+ SKIP_REASONS,
33
+ recordHookOutcome,
31
34
  } from './_shared.js';
32
35
  import { MemeshDatabase } from './_generated/sqlite.js';
33
36
 
34
37
  const dbPath = getDbPath();
35
38
 
39
+ // See post-commit.js for why every exit path leaves a record (#327). This
40
+ // hook never writes a MEMORY. It bumps the guard-fire counter and injects a
41
+ // warning, so its outcome is `notified`, not `wrote`: doctor's `writes`
42
+ // answers "is memory capture still alive", and counting a printed warning
43
+ // there inflated the numerator of the one signal that question has. A run of
44
+ // skips here is normal, and the record exists to distinguish "no guard
45
+ // matched" from "never ran".
46
+ let payload = null;
47
+ function record(outcome, reason, entity) {
48
+ recordHookOutcome(process.env, { hook: 'guard-check', outcome, reason, entity, payload });
49
+ }
50
+
36
51
  let input = '';
37
52
  process.stdin.setEncoding('utf8');
38
53
  process.stdin.on('data', (chunk) => { input += chunk; });
39
54
  process.stdin.on('end', () => {
40
55
  try {
41
56
  const data = JSON.parse(input);
57
+ payload = data;
42
58
  const command = data?.tool_input?.command;
43
- if (!command || typeof command !== 'string') return pass();
44
- if (!existsSync(dbPath)) return pass();
59
+ if (!command || typeof command !== 'string') {
60
+ record('skipped', SKIP_REASONS.noBashCommand);
61
+ return pass();
62
+ }
63
+ if (!existsSync(dbPath)) {
64
+ record('skipped', SKIP_REASONS.noDatabaseForGuards);
65
+ return pass();
66
+ }
45
67
 
46
68
  // `readOnly`, not `readonly`: node:sqlite ignores the lowercase
47
69
  // spelling and hands back a WRITABLE handle. This hook only reads;
@@ -57,9 +79,13 @@ process.stdin.on('end', () => {
57
79
  } finally {
58
80
  db.close();
59
81
  }
60
- if (!matches || matches.length === 0) return pass();
82
+ if (!matches || matches.length === 0) {
83
+ record('skipped', SKIP_REASONS.noGuardMatched);
84
+ return pass();
85
+ }
61
86
 
62
87
  recordGuardFires(dbPath, matches.map((g) => g.lessonId));
88
+ record('notified', undefined, `guard-fires:${matches.length}`);
63
89
 
64
90
  console.log(JSON.stringify({
65
91
  hookSpecificOutput: {
@@ -72,6 +98,7 @@ process.stdin.on('end', () => {
72
98
  // Never crash Claude Code, but trace — a silent break here means
73
99
  // every accepted guard stops firing and nothing reports it.
74
100
  try { process.stderr.write(`[memesh guard-check] ${err?.message || err}\n`); } catch {}
101
+ record('error', hookErrorReason(err));
75
102
  pass();
76
103
  }
77
104
  });
@@ -1,7 +1,203 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync } from 'child_process';
4
- import { AUTO_CAPTURE_TAG, captureEntity, getProjectName, isAutoCaptureEnabled, openHookDb, recordHookRun, truncateTitle } from './_shared.js';
4
+ import { createHash, randomBytes } from 'crypto';
5
+ import { chmodSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'fs';
6
+ import { MemeshDatabase } from './_generated/sqlite.js';
7
+ import { isAbsolute, join, resolve } from 'path';
8
+ import { AUTO_CAPTURE_TAG, SKIP_REASONS, captureEntity, ensurePrivateDir, getMemeshDirFromDbPath, getProjectName, isAutoCaptureEnabled, isGitCommitCommand, openHookDb, hookErrorReason, recordHookOutcome, recordHookRun, truncateTitle } from './_shared.js';
9
+
10
+ const HEAD_MARKER_DIR = 'post-commit-heads';
11
+ const FULL_SHA = /^[a-f0-9]{40,64}$/;
12
+ const MAX_COMMITS_PER_RUN = 20;
13
+ // Leave time to record an error before the host's five-second hook deadline.
14
+ const MARKER_LOCK_TIMEOUT_MS = 1000;
15
+
16
+ function gitText(cwd, args) {
17
+ return execFileSync('git', ['-C', cwd, ...args], {
18
+ encoding: 'utf8',
19
+ timeout: 5000,
20
+ stdio: ['ignore', 'pipe', 'pipe'],
21
+ }).trim();
22
+ }
23
+
24
+ function validStoredHeads(parsed) {
25
+ const heads = {};
26
+ if (parsed?.heads && typeof parsed.heads === 'object') {
27
+ for (const [key, head] of Object.entries(parsed.heads)) {
28
+ if (/^[a-f0-9]{64}$/.test(key) && typeof head === 'string' && FULL_SHA.test(head)) heads[key] = head;
29
+ }
30
+ }
31
+ return heads;
32
+ }
33
+
34
+ function closestKnownAncestor(cwd, head, candidates) {
35
+ let closest = null;
36
+ let distance = Number.POSITIVE_INFINITY;
37
+ for (const candidate of new Set(candidates)) {
38
+ if (!FULL_SHA.test(candidate)) continue;
39
+ try {
40
+ execFileSync('git', ['-C', cwd, 'merge-base', '--is-ancestor', candidate, head], {
41
+ timeout: 5000,
42
+ stdio: ['ignore', 'ignore', 'ignore'],
43
+ });
44
+ const count = Number.parseInt(gitText(cwd, ['rev-list', '--count', `${candidate}..${head}`]), 10);
45
+ if (Number.isFinite(count) && count < distance) {
46
+ closest = candidate;
47
+ distance = count;
48
+ }
49
+ } catch {}
50
+ }
51
+ return closest;
52
+ }
53
+
54
+ /** Resolve one repository's current HEAD and its privacy-preserving per-worktree state. */
55
+ function resolveHeadLocation(cwd) {
56
+ const rawCommonDir = gitText(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
57
+ const commonDir = realpathSync(isAbsolute(rawCommonDir) ? rawCommonDir : resolve(cwd, rawCommonDir));
58
+ const rawGitDir = gitText(cwd, ['rev-parse', '--path-format=absolute', '--git-dir']);
59
+ const gitDir = realpathSync(isAbsolute(rawGitDir) ? rawGitDir : resolve(cwd, rawGitDir));
60
+ const key = createHash('sha256').update(commonDir).digest('hex');
61
+ const worktreeKey = createHash('sha256').update(gitDir).digest('hex');
62
+ const markerDir = join(getMemeshDirFromDbPath(), HEAD_MARKER_DIR);
63
+ const markerPath = join(markerDir, `${key}.json`);
64
+ return { markerDir, markerPath, worktreeKey };
65
+ }
66
+
67
+ function resolveHeadState(cwd, location) {
68
+ const head = gitText(cwd, ['rev-parse', '--verify', 'HEAD']);
69
+ if (!FULL_SHA.test(head)) throw new Error('git returned an invalid HEAD');
70
+ let parsed = null;
71
+ try {
72
+ parsed = JSON.parse(readFileSync(location.markerPath, 'utf8'));
73
+ } catch {
74
+ // Missing or malformed state is the same safe boundary: establish a
75
+ // baseline and never guess which existing commits belonged to this run.
76
+ }
77
+ const heads = validStoredHeads(parsed);
78
+ const legacyHead = typeof parsed?.head === 'string' && FULL_SHA.test(parsed.head) ? parsed.head : null;
79
+ // A stored worktree position can stop being an ancestor after reset or
80
+ // rebase. Re-prove ancestry every time, then fall back only to another
81
+ // known position Git proves is on the current history.
82
+ const previous = closestKnownAncestor(cwd, head, [
83
+ ...(heads[location.worktreeKey] ? [heads[location.worktreeKey]] : []),
84
+ ...Object.values(heads),
85
+ ...(legacyHead ? [legacyHead] : []),
86
+ ]);
87
+ const short = gitText(cwd, ['rev-parse', '--short', head]);
88
+ const subject = gitText(cwd, ['show', '-s', '--format=%s', head]);
89
+ let branch = 'unknown';
90
+ try { branch = gitText(cwd, ['branch', '--show-current']) || 'detached'; } catch {}
91
+ return { head, previous, short, subject, branch, ...location };
92
+ }
93
+
94
+ function acquireMarkerLock(markerPath) {
95
+ // Keep one stable file: SQLite releases its OS lock on close or process death.
96
+ // Never unlink/rename it; pathname-based stale recovery can steal a new owner.
97
+ // This is separate from the graph so a baseline need not create user memories.
98
+ const db = new MemeshDatabase(`${markerPath}.lock.sqlite`);
99
+ try {
100
+ db.pragma(`busy_timeout = ${MARKER_LOCK_TIMEOUT_MS}`);
101
+ db.exec('BEGIN IMMEDIATE');
102
+ } catch (err) {
103
+ db.close();
104
+ if (err?.errcode === 5 || err?.code === 'SQLITE_BUSY') {
105
+ throw new Error('timed out waiting for post-commit state lock', { cause: err });
106
+ }
107
+ throw err;
108
+ }
109
+ return () => db.close();
110
+ }
111
+
112
+ /** Advance only this worktree's position. The caller holds the repository marker lock. */
113
+ function writeHeadState(state) {
114
+ ensurePrivateDir(state.markerDir);
115
+ const tmp = `${state.markerPath}.${randomBytes(8).toString('hex')}.tmp`;
116
+ try {
117
+ let heads = {};
118
+ try { heads = validStoredHeads(JSON.parse(readFileSync(state.markerPath, 'utf8'))); } catch {}
119
+ heads[state.worktreeKey] = state.head;
120
+ writeFileSync(tmp, JSON.stringify({ version: 2, heads }), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
121
+ renameSync(tmp, state.markerPath);
122
+ try { chmodSync(state.markerPath, 0o600); } catch {}
123
+ } catch (err) {
124
+ try { unlinkSync(tmp); } catch {}
125
+ throw err;
126
+ }
127
+ }
128
+
129
+ function commitsFromState(cwd, state) {
130
+ const raw = gitText(cwd, [
131
+ 'rev-list', '--reverse', `--max-count=${MAX_COMMITS_PER_RUN}`, `${state.previous}..${state.head}`,
132
+ ]);
133
+ const hashes = raw.split('\n').map((line) => line.trim()).filter((line) => FULL_SHA.test(line));
134
+ const total = Number.parseInt(gitText(cwd, ['rev-list', '--count', `${state.previous}..${state.head}`]), 10);
135
+ return {
136
+ commits: hashes.map((hash) => ({
137
+ hash: gitText(cwd, ['rev-parse', '--short', hash]),
138
+ message: gitText(cwd, ['show', '-s', '--format=%s', hash]),
139
+ branch: state.branch,
140
+ batch: hashes.length > 1 || total > 1,
141
+ })),
142
+ skipped: Number.isFinite(total) ? Math.max(0, total - hashes.length) : 0,
143
+ };
144
+ }
145
+
146
+ function captureCommit(db, data, projectName, commit) {
147
+ const entityName = `commit-${commit.hash}`;
148
+ const observations = [commit.message, `Branch: ${commit.branch}`];
149
+ try {
150
+ const stat = gitText(data.cwd, ['show', '--stat', '--format=', commit.hash]);
151
+ if (stat) {
152
+ const statLines = stat.split('\n').filter((line) => line.trim());
153
+ const summary = statLines[statLines.length - 1]?.trim() || '';
154
+ if (summary) observations.push(`Diff stats: ${summary}`);
155
+ }
156
+ } catch {
157
+ // Diff stats are best-effort and never decide whether the commit exists.
158
+ }
159
+
160
+ const whyMetadata = {};
161
+ if (typeof data.session_id === 'string' && data.session_id) whyMetadata.session_id = data.session_id;
162
+ try {
163
+ const nameOnly = gitText(data.cwd, ['show', '--name-only', '--format=', commit.hash]);
164
+ if (nameOnly) {
165
+ const files = nameOnly.split('\n').map((line) => line.trim()).filter(Boolean);
166
+ if (files.length > 0) whyMetadata.files = files.slice(0, 50);
167
+ }
168
+ } catch {
169
+ // File names are best-effort metadata, like diff stats.
170
+ }
171
+
172
+ const written = captureEntity(db, {
173
+ name: entityName,
174
+ type: 'commit',
175
+ observations,
176
+ tags: [AUTO_CAPTURE_TAG, `project:${projectName}`, ...(commit.batch ? ['origin:batch'] : [])],
177
+ title: truncateTitle(commit.message),
178
+ metadata: whyMetadata,
179
+ });
180
+ return { entityName, written };
181
+ }
182
+
183
+ // The parsed payload, hoisted so the outcome recorder below can read
184
+ // session_id and the host signal from ANY exit path — including the ones
185
+ // that fire before or instead of a capture.
186
+ let payload = null;
187
+ let releaseStateLock = null;
188
+
189
+ /**
190
+ * Leave a record on this exit path (issue #327).
191
+ *
192
+ * Every `return exit0()` below is a decision this hook made about a real
193
+ * event, and until now every one of them was indistinguishable from the hook
194
+ * not running at all. That is exactly how #321 hid: `git commit -q` prints no
195
+ * line, so `no commit line in output` fired on every commit for two days and
196
+ * the graph looked identical to a hook broken by an upgrade.
197
+ */
198
+ function record(outcome, reason, entity) {
199
+ recordHookOutcome(process.env, { hook: 'post-commit', outcome, reason, entity, payload });
200
+ }
5
201
 
6
202
  let input = '';
7
203
  process.stdin.setEncoding('utf8');
@@ -12,9 +208,13 @@ process.stdin.on('end', () => {
12
208
  // years while its two siblings honoured it — with capture disabled it
13
209
  // kept writing commit entities AND stamping the heartbeat, which made
14
210
  // doctor's "capture is off, hook silence is expected" message false.
15
- if (!isAutoCaptureEnabled(process.env)) return exit0();
211
+ if (!isAutoCaptureEnabled(process.env)) {
212
+ record('skipped', SKIP_REASONS.autoCaptureOff);
213
+ return exit0();
214
+ }
16
215
 
17
216
  const data = JSON.parse(input);
217
+ payload = data;
18
218
 
19
219
  // tool_name absent is a schema-flip signal (Claude Code has done
20
220
  // tool_name renames historically — e.g. tool_use/tool_result
@@ -23,67 +223,117 @@ process.stdin.on('end', () => {
23
223
  // `was_in_agentic_loop`).
24
224
  if (data.tool_name === undefined) {
25
225
  try { process.stderr.write(`[memesh post-commit] tool_name absent in payload (keys: ${Object.keys(data).join(',')}); skipping\n`); } catch {}
226
+ record('skipped', SKIP_REASONS.toolNameAbsent);
26
227
  return exit0();
27
228
  }
28
- if (data.tool_name !== 'Bash') return exit0();
29
-
30
- // Claude Code's PostToolUse hook payload has had two field-name shapes:
31
- // legacy `tool_output: <string>` and current
32
- // `tool_response: { stdout, stderr, interrupted, isError }`. Prefer the
33
- // current shape; fall back to legacy so unit-test fixtures (which use
34
- // tool_output) keep working. This block existed only as the legacy
35
- // branch — once Claude Code unified on tool_response the hook silently
36
- // stopped seeing any output and never wrote commit entities again.
37
- const tr = data.tool_response;
38
- let toolOutput = '';
39
- if (typeof tr === 'string') {
40
- toolOutput = tr;
41
- } else if (tr && typeof tr === 'object') {
42
- const stdout = typeof tr.stdout === 'string' ? tr.stdout : '';
43
- const stderr = typeof tr.stderr === 'string' ? tr.stderr : '';
44
- toolOutput = stdout + (stderr ? '\n' + stderr : '');
45
- } else if (typeof data.tool_output === 'string') {
46
- toolOutput = data.tool_output;
47
- } else if (data.tool_output != null) {
48
- toolOutput = JSON.stringify(data.tool_output);
229
+ if (data.tool_name !== 'Bash') {
230
+ record('skipped', SKIP_REASONS.notBash);
231
+ return exit0();
49
232
  }
50
233
 
51
- // Detect git commit in output
52
- // Pattern: [branch hash] commit message with an optional parenthesised
53
- // note between branch and hash: git prints `[master (root-commit) 32e98b8]`
54
- // for a repo's FIRST commit, and the old pattern silently skipped exactly
55
- // that one, so no repository's first commit was ever remembered.
56
- const commitMatch = toolOutput.match(/\[[\w/.-]+(?: \([\w -]+\))? ([a-f0-9]{7,})\] (.+)/);
57
- if (!commitMatch) return exit0();
58
-
59
- // The OUTPUT looking like a commit is not evidence that a commit happened.
60
- // This hook stopped at the regex above, so any Bash output containing a
61
- // commit-shaped line produced a permanent memory. Measured: a payload whose
62
- // command was `cat docs/release-notes.md` wrote entity `commit-9f3c2a1`
63
- // for a hash `git cat-file -t` rejects as "Not a valid object name".
64
- // Reading a changelog, tailing a build log, or quoting a commit line was
65
- // enough — and the fake then surfaced through session-start and
66
- // pre-edit-recall as if it had happened.
234
+ // The COMMAND decides whether this run is about a commit at all, and it
235
+ // is checked BEFORE the output for two reasons.
236
+ //
237
+ // First, the output looking like a commit is not evidence that a commit
238
+ // happened. This hook once stopped at the output regex below, so any Bash
239
+ // output containing a commit-shaped line produced a permanent memory.
240
+ // Measured: a payload whose command was `cat docs/release-notes.md` wrote
241
+ // entity `commit-9f3c2a1` for a hash `git cat-file -t` rejects as "Not a
242
+ // valid object name".
243
+ //
244
+ // Second, the two skips mean opposite things to `memesh doctor` (#327):
245
+ // "not a git commit command" is this hook correctly ignoring `ls`, on
246
+ // almost every Bash call, and must not count as silence; "a git commit
247
+ // ran but printed no commit line" is the #321 shape — a commit happened
248
+ // and nothing was saved — and is exactly the silence worth a sentence.
67
249
  const issuedCommand = typeof data.tool_input?.command === 'string' ? data.tool_input.command : '';
68
- if (!/\bgit\b[^|;&]*\bcommit\b/.test(issuedCommand)) {
69
- try { process.stderr.write(`[memesh post-commit] output looks like a commit but the command was not a git commit; skipping ${commitMatch[1]}\n`); } catch {}
250
+ if (!isGitCommitCommand(issuedCommand)) {
251
+ record('skipped', SKIP_REASONS.notGitCommit);
70
252
  return exit0();
71
253
  }
72
254
 
73
- const branchMatch = commitMatch[0].match(/^\[([^\s]+)\s/);
74
- const branch = branchMatch ? branchMatch[1] : 'unknown';
255
+ if (!data.cwd) {
256
+ try { process.stderr.write('[memesh post-commit] data.cwd absent cannot resolve project / repo; skipping\n'); } catch {}
257
+ record('skipped', SKIP_REASONS.commitCwdAbsent);
258
+ return exit0();
259
+ }
75
260
 
76
- const commitHash = commitMatch[1];
77
- const commitMsg = commitMatch[2];
261
+ // Current PostToolUse payloads explicitly say whether the command failed.
262
+ // Legacy payloads expose only terminal text. That text may corroborate a
263
+ // successfully resolved HEAD, but it is never commit authority by itself:
264
+ // wrappers can replay buffered output, while `-q` can hide a real commit.
265
+ const tr = data.tool_response;
266
+ let toolOutput = '';
267
+ if (typeof tr === 'string') toolOutput = tr;
268
+ else if (tr && typeof tr === 'object') {
269
+ const stdout = typeof tr.stdout === 'string' ? tr.stdout : '';
270
+ const stderr = typeof tr.stderr === 'string' ? tr.stderr : '';
271
+ toolOutput = stdout + (stderr ? `\n${stderr}` : '');
272
+ } else if (typeof data.tool_output === 'string') toolOutput = data.tool_output;
273
+ else if (data.tool_output != null) toolOutput = JSON.stringify(data.tool_output);
274
+ const commitMatch = toolOutput.match(/\[[\w/.-]+(?: \([\w -]+\))? ([a-f0-9]{7,})\] (.+)/);
275
+ let state = null;
276
+ let location = null;
277
+ try { location = resolveHeadLocation(data.cwd); } catch {}
278
+ if (location) {
279
+ ensurePrivateDir(location.markerDir);
280
+ releaseStateLock = acquireMarkerLock(location.markerPath);
281
+ try { state = resolveHeadState(data.cwd, location); } catch {}
282
+ }
78
283
 
79
- // `data.cwd` MUST be present for project-tag and `git show` below
80
- // to be correct. If absent, falling through to process.cwd() (the
81
- // hook process's launch dir, unspecified for PostToolUse Bash)
82
- // would either run git show against a different repo (silent
83
- // corruption) or write the wrong project tag. Skip + trace
84
- // instead better to miss one commit than to tag it wrong.
85
- if (!data.cwd) {
86
- try { process.stderr.write(`[memesh post-commit] data.cwd absent — cannot resolve project / repo; skipping commit ${commitHash}\n`); } catch {}
284
+ let branch;
285
+ let commitHash;
286
+ let commitMsg;
287
+ let stateBatch = [];
288
+ let batchSkipped = 0;
289
+ if (state?.previous && state.previous !== state.head) {
290
+ const resolved = commitsFromState(data.cwd, state);
291
+ stateBatch = resolved.commits;
292
+ batchSkipped = resolved.skipped;
293
+ const newest = stateBatch.at(-1);
294
+ if (!newest) {
295
+ try { process.stderr.write('[memesh post-commit] repository HEAD changed, but no reachable commit could be enumerated\n'); } catch {}
296
+ record('skipped', SKIP_REASONS.commitHeadUnresolvable);
297
+ return exit0();
298
+ }
299
+ branch = newest.branch;
300
+ commitHash = newest.hash;
301
+ commitMsg = newest.message;
302
+ } else if (state && state.previous === state.head) {
303
+ // When Git state is available it is stronger evidence than terminal
304
+ // text. A failed command can return buffered or wrapper-produced text
305
+ // that looks like an earlier commit; unchanged HEAD must never turn
306
+ // that stale text into a write or a false liveness heartbeat.
307
+ try { process.stderr.write('[memesh post-commit] commit-like command completed, but repository HEAD did not change\n'); } catch {}
308
+ record('skipped', SKIP_REASONS.commitHeadUnchanged);
309
+ return exit0();
310
+ } else if (!state) {
311
+ try { process.stderr.write('[memesh post-commit] commit-like command ran, but repository HEAD could not be resolved\n'); } catch {}
312
+ record('skipped', SKIP_REASONS.commitHeadUnresolvable);
313
+ return exit0();
314
+ } else if (state.previous === null
315
+ && tr?.isError !== true && tr?.interrupted !== true
316
+ && commitMatch
317
+ && state.head.startsWith(commitMatch[1])) {
318
+ // Success flags are optional across hosts. Accept the commit line only
319
+ // after Git independently proves that exact hash is HEAD, and never
320
+ // when the host explicitly reports failure or interruption.
321
+ branch = state.branch;
322
+ commitHash = state.short;
323
+ commitMsg = state.subject;
324
+ } else if (state.previous === null && tr && typeof tr === 'object' && tr.isError === false && tr.interrupted !== true) {
325
+ // A current Claude Code PostToolUse payload tells us the command
326
+ // completed successfully. In that one case HEAD itself is the newly
327
+ // created commit, even when `-q` or redirection removed every output
328
+ // line. Capture only HEAD: older repository history remains outside
329
+ // this event and is never backfilled.
330
+ branch = state.branch;
331
+ commitHash = state.short;
332
+ commitMsg = state.subject;
333
+ } else if (state.previous === null) {
334
+ writeHeadState(state);
335
+ try { process.stderr.write('[memesh post-commit] recorded repository HEAD baseline; existing history was not backfilled\n'); } catch {}
336
+ record('skipped', SKIP_REASONS.commitHeadBaseline);
87
337
  return exit0();
88
338
  }
89
339
  // And the commit has to actually be in THIS repository.
@@ -100,6 +350,7 @@ process.stdin.on('end', () => {
100
350
  });
101
351
  } catch {
102
352
  try { process.stderr.write(`[memesh post-commit] ${commitHash} is not a commit in ${data.cwd}; nothing written\n`); } catch {}
353
+ record('skipped', SKIP_REASONS.hashNotACommit);
103
354
  return exit0();
104
355
  }
105
356
 
@@ -109,95 +360,35 @@ process.stdin.on('end', () => {
109
360
  // Pass fts:true so the FTS5 entity-search index is also available.
110
361
  const { db } = openHookDb(process.env, { fts: true });
111
362
  try {
112
- const entityName = `commit-${commitHash}`;
113
-
114
- // Build the observation set: commit message, branch, and best-effort —
115
- // richer diff stats (git failures are silently ignored, unchanged behavior).
116
- const observations = [commitMsg, `Branch: ${branch}`];
117
- try {
118
- const stat = execFileSync('git', ['show', '--stat', '--format=', commitHash], {
119
- cwd: data.cwd || process.cwd(),
120
- encoding: 'utf8',
121
- timeout: 5000,
122
- // stderr captured, not inherited: outside a repo this used to leak
123
- // a raw `fatal: not a git repository` into the hook's own stderr.
124
- stdio: ['ignore', 'pipe', 'pipe'],
125
- }).trim();
126
- if (stat) {
127
- // Last non-empty line is the summary, e.g. "3 files changed, 45 insertions(+), 12 deletions(-)"
128
- const statLines = stat.split('\n').filter(l => l.trim());
129
- const summary = statLines[statLines.length - 1]?.trim() || '';
130
- if (summary) observations.push(`Diff stats: ${summary}`);
363
+ const commits = stateBatch.length > 0
364
+ ? stateBatch
365
+ : [{ hash: commitHash, message: commitMsg, branch, batch: false }];
366
+ const captured = [];
367
+ for (const commit of commits) {
368
+ const result = captureCommit(db, data, projectName, commit);
369
+ if (!result.written) {
370
+ record('error', 'captureEntity did not land the write', result.entityName);
371
+ return exit0();
131
372
  }
132
- } catch {
133
- // git show failed — no diff stats recorded, existing behavior unchanged
373
+ captured.push(result.entityName);
134
374
  }
135
375
 
136
- // Commit session linkage, the hop `memesh why` walks. The payload has
137
- // carried session_id all along; this hook just never recorded it, so
138
- // every commit entity was an island (no file list, no session, no
139
- // relations). Recorded as METADATA, not tags, on purpose: a `file:*`
140
- // tag here would make pre-edit-recall inject commit noise into every
141
- // edit of a touched file (Strategy 1 joins on exactly that tag).
142
- const whyMetadata = {};
143
- if (typeof data.session_id === 'string' && data.session_id) {
144
- whyMetadata.session_id = data.session_id;
145
- }
146
- try {
147
- const nameOnly = execFileSync('git', ['-C', data.cwd, 'show', '--name-only', '--format=', commitHash], {
148
- encoding: 'utf8',
149
- timeout: 5000,
150
- stdio: ['ignore', 'pipe', 'pipe'],
151
- }).trim();
152
- if (nameOnly) {
153
- // Repo-relative paths, capped: a lockfile-churn commit can touch
154
- // thousands of files and metadata rides every entity read.
155
- const files = nameOnly.split('\n').map(l => l.trim()).filter(Boolean);
156
- if (files.length > 0) whyMetadata.files = files.slice(0, 50);
157
- }
158
- } catch {
159
- // Best-effort like the diff stats above — no file list recorded.
160
- }
161
-
162
- // Shared write dance — upsert entity + observations + tags AND reindex FTS.
163
- //
164
- // `source:auto-capture` is the provenance marker every capture hook
165
- // writes (session-summary, pre-compact and the extractor already did;
166
- // this one did not). `memesh doctor` counts it to answer "is the
167
- // auto-capture loop alive" — a question it used to answer from entity
168
- // TYPE, which a hand-typed `memesh learn` satisfied all by itself.
169
- // The commit subject IS the title — git authors already wrote a
170
- // one-line human summary; nothing to synthesize.
171
- //
172
- // Re-capture of an already-stored commit is expected and harmless: this
173
- // hook runs on PostToolUse, so any later Bash call while HEAD is
174
- // unchanged rebuilds the same `commit-<sha>` payload. It used to APPEND
175
- // it — three commit entities on the maintainer's graph reached 6
176
- // observations / 3 distinct, one triple re-written 107 seconds after the
177
- // first. captureEntity now refuses to store an observation whose exact
178
- // content is already on the entity (#240, widened), which is the right
179
- // guard here rather than "skip if the entity exists": a sha is immutable,
180
- // so any line that DOES differ on a later run (diff stats that failed the
181
- // first time and succeeded now) is new information and must still land.
182
- const written = captureEntity(db, {
183
- name: entityName,
184
- type: 'commit',
185
- observations,
186
- tags: [AUTO_CAPTURE_TAG, `project:${projectName}`],
187
- title: truncateTitle(commitMsg),
188
- metadata: whyMetadata,
189
- });
190
-
191
- // Heartbeat AFTER capture, so the stamp certifies "the capture loop
192
- // completed", not "a database handle existed". A throw above skips it,
193
- // and so does a null return (the write did not land).
194
- if (written) recordHookRun(db, 'post-commit');
376
+ // One hook invocation is one heartbeat even when a merge exposes a
377
+ // bounded batch. Advance the state only after every selected commit
378
+ // landed, so a partial database failure is retried instead of forgotten.
379
+ recordHookRun(db, 'post-commit');
380
+ if (state) writeHeadState(state);
381
+ const reason = captured.length > 1 || batchSkipped > 0
382
+ ? `captured ${captured.length} commits as a batch; skipped ${batchSkipped} older commits`
383
+ : undefined;
384
+ record('wrote', reason, captured.at(-1));
195
385
  } finally {
196
386
  db.close();
197
387
  }
198
388
  } catch (err) {
199
389
  // Never crash Claude Code — but leave a trace for debugging
200
390
  try { process.stderr.write(`[memesh post-commit] ${err?.message || err}\n`); } catch {}
391
+ record('error', hookErrorReason(err));
201
392
  }
202
393
  // Emit NOTHING on success — not `{"suppressOutput": true}`.
203
394
  //
@@ -216,5 +407,10 @@ process.stdin.on('end', () => {
216
407
  });
217
408
 
218
409
  function exit0() {
410
+ if (releaseStateLock) {
411
+ const release = releaseStateLock;
412
+ releaseStateLock = null;
413
+ try { release(); } catch {}
414
+ }
219
415
  process.exit(0);
220
416
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { basename } from 'path';
4
4
  import { existsSync, readFileSync } from 'fs';
5
- import { AUTO_CAPTURE_TAG, captureEntity, getProjectName, isAutoCaptureEnabled, openHookDb, recordHookRun, truncateTitle } from './_shared.js';
5
+ import { AUTO_CAPTURE_TAG, captureEntity, getProjectName, isAutoCaptureEnabled, openHookDb, hookErrorReason, SKIP_REASONS, recordHookOutcome, recordHookRun, truncateTitle } from './_shared.js';
6
6
 
7
7
  // There is no in-process timeout guard, and its absence is deliberate.
8
8
  //
@@ -19,6 +19,12 @@ import { AUTO_CAPTURE_TAG, captureEntity, getProjectName, isAutoCaptureEnabled,
19
19
  // process. `openHookDb` additionally caps the SQLite lock wait at 2s so
20
20
  // contention ends in a skipped capture rather than in that kill.
21
21
 
22
+ // See post-commit.js for why every exit path leaves a record (#327).
23
+ let payload = null;
24
+ function record(outcome, reason, entity) {
25
+ recordHookOutcome(process.env, { hook: 'pre-compact', outcome, reason, entity, payload });
26
+ }
27
+
22
28
  let input = '';
23
29
  process.stdin.setEncoding('utf8');
24
30
  process.stdin.on('data', (chunk) => { input += chunk; });
@@ -26,10 +32,12 @@ process.stdin.on('end', () => {
26
32
  try {
27
33
  // Opt-out check (env > config > default-on)
28
34
  if (!isAutoCaptureEnabled(process.env)) {
35
+ record('skipped', SKIP_REASONS.autoCaptureOff);
29
36
  return exit0();
30
37
  }
31
38
 
32
39
  const data = JSON.parse(input);
40
+ payload = data;
33
41
  const sessionId = data.session_id || 'unknown';
34
42
  const transcriptPath = data.transcript_path || '';
35
43
  // A payload with NEITHER a session id NOR a transcript is not a
@@ -39,6 +47,7 @@ process.stdin.on('end', () => {
39
47
  // context as a recent memory. A real session_id without a transcript
40
48
  // still records (that contract is pinned by the basic-scenario test).
41
49
  if (!data.session_id && !transcriptPath) {
50
+ record('skipped', SKIP_REASONS.noSessionOrTranscript);
42
51
  process.exit(0);
43
52
  }
44
53
  const cwd = data.cwd || process.cwd();
@@ -137,7 +146,12 @@ process.stdin.on('end', () => {
137
146
  // and so does a null return — captureEntity's null means the write did
138
147
  // not land, and this very hook tells the user "could not save" below;
139
148
  // stamping would say "alive" to doctor about the same failed run.
140
- if (written) recordHookRun(db, 'pre-compact');
149
+ if (written) {
150
+ recordHookRun(db, 'pre-compact');
151
+ record('wrote', undefined, entityName);
152
+ } else {
153
+ record('error', 'captureEntity did not land the write', entityName);
154
+ }
141
155
  } finally {
142
156
  db.close();
143
157
  }
@@ -179,6 +193,7 @@ process.stdin.on('end', () => {
179
193
  } catch (err) {
180
194
  // Hooks must never crash Claude Code — exit cleanly
181
195
  try { process.stderr.write(`[memesh pre-compact] ${err?.message || err}\n`); } catch {}
196
+ record('error', hookErrorReason(err));
182
197
  }
183
198
  exit0();
184
199
  });