@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +75 -13
- package/README.de.md +5 -5
- package/README.md +5 -5
- package/README.zh-TW.md +5 -5
- package/dashboard/dist/index.html +8 -8
- package/dist/cli/view-live.d.ts.map +1 -1
- package/dist/cli/view-live.js +0 -427
- package/dist/cli/view-live.js.map +1 -1
- package/dist/core/agent-message-inbox.d.ts +6 -0
- package/dist/core/agent-message-inbox.d.ts.map +1 -1
- package/dist/core/agent-message-inbox.js +28 -1
- package/dist/core/agent-message-inbox.js.map +1 -1
- package/dist/core/briefing-index.d.ts +30 -0
- package/dist/core/briefing-index.d.ts.map +1 -0
- package/dist/core/briefing-index.js +140 -0
- package/dist/core/briefing-index.js.map +1 -0
- package/dist/core/briefing-level.d.ts +22 -0
- package/dist/core/briefing-level.d.ts.map +1 -0
- package/dist/core/briefing-level.js +75 -0
- package/dist/core/briefing-level.js.map +1 -0
- package/dist/core/briefing.d.ts +7 -0
- package/dist/core/briefing.d.ts.map +1 -1
- package/dist/core/briefing.js +72 -12
- package/dist/core/briefing.js.map +1 -1
- package/dist/core/capture-liveness.d.ts +137 -0
- package/dist/core/capture-liveness.d.ts.map +1 -0
- package/dist/core/capture-liveness.js +448 -0
- package/dist/core/capture-liveness.js.map +1 -0
- package/dist/core/config.d.ts +2 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +5 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/delegation.d.ts +47 -0
- package/dist/core/delegation.d.ts.map +1 -0
- package/dist/core/delegation.js +174 -0
- package/dist/core/delegation.js.map +1 -0
- package/dist/core/doctor-fixes.d.ts +1 -0
- package/dist/core/doctor-fixes.d.ts.map +1 -1
- package/dist/core/doctor-fixes.js +21 -0
- package/dist/core/doctor-fixes.js.map +1 -1
- package/dist/core/doctor.d.ts +8 -0
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +148 -3
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/note-derive.d.ts +14 -0
- package/dist/core/note-derive.d.ts.map +1 -0
- package/dist/core/note-derive.js +66 -0
- package/dist/core/note-derive.js.map +1 -0
- package/dist/core/note-ingest.d.ts +34 -0
- package/dist/core/note-ingest.d.ts.map +1 -0
- package/dist/core/note-ingest.js +445 -0
- package/dist/core/note-ingest.js.map +1 -0
- package/dist/core/operations.d.ts +10 -1
- package/dist/core/operations.d.ts.map +1 -1
- package/dist/core/operations.js +129 -14
- package/dist/core/operations.js.map +1 -1
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js +4 -0
- package/dist/core/paths.js.map +1 -1
- package/dist/core/schema-export.d.ts.map +1 -1
- package/dist/core/schema-export.js +15 -5
- package/dist/core/schema-export.js.map +1 -1
- package/dist/core/serializer.d.ts +2 -0
- package/dist/core/serializer.d.ts.map +1 -1
- package/dist/core/serializer.js +119 -3
- package/dist/core/serializer.js.map +1 -1
- package/dist/core/session-insight.d.ts +44 -0
- package/dist/core/session-insight.d.ts.map +1 -0
- package/dist/core/session-insight.js +216 -0
- package/dist/core/session-insight.js.map +1 -0
- package/dist/core/task-state-store.d.ts +4 -0
- package/dist/core/task-state-store.d.ts.map +1 -1
- package/dist/core/task-state-store.js +18 -5
- package/dist/core/task-state-store.js.map +1 -1
- package/dist/core/task-state.d.ts +5 -0
- package/dist/core/task-state.d.ts.map +1 -1
- package/dist/core/task-state.js +70 -0
- package/dist/core/task-state.js.map +1 -1
- package/dist/core/turn-signal.d.ts +22 -0
- package/dist/core/turn-signal.d.ts.map +1 -0
- package/dist/core/turn-signal.js +89 -0
- package/dist/core/turn-signal.js.map +1 -0
- package/dist/core/types.d.ts +14 -3
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/update-entrypoint.d.ts +19 -0
- package/dist/core/update-entrypoint.d.ts.map +1 -0
- package/dist/core/update-entrypoint.js +222 -0
- package/dist/core/update-entrypoint.js.map +1 -0
- package/dist/core/update-notice.d.ts +69 -0
- package/dist/core/update-notice.d.ts.map +1 -0
- package/dist/core/update-notice.js +188 -0
- package/dist/core/update-notice.js.map +1 -0
- package/dist/core/work-topology.d.ts +1 -0
- package/dist/core/work-topology.d.ts.map +1 -1
- package/dist/core/work-topology.js +3 -0
- package/dist/core/work-topology.js.map +1 -1
- package/dist/host-runtime/codex-session.d.ts +10 -0
- package/dist/host-runtime/codex-session.d.ts.map +1 -1
- package/dist/host-runtime/codex-session.js +1 -1
- package/dist/host-runtime/codex-session.js.map +1 -1
- package/dist/knowledge-graph.d.ts.map +1 -1
- package/dist/knowledge-graph.js +24 -3
- package/dist/knowledge-graph.js.map +1 -1
- package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
- package/dist/mcp/server.js +1438 -122
- package/dist/mcp/server.js.map +3 -3
- package/dist/skills-manifest.json +61 -36
- package/dist/storage/fts-index.d.ts +1 -0
- package/dist/storage/fts-index.d.ts.map +1 -1
- package/dist/storage/fts-index.js +5 -0
- package/dist/storage/fts-index.js.map +1 -1
- package/dist/transports/cli/cli.d.ts.map +1 -1
- package/dist/transports/cli/cli.js +7846 -5613
- package/dist/transports/cli/cli.js.map +3 -3
- package/dist/transports/http/server.d.ts.map +1 -1
- package/dist/transports/http/server.js +48 -28
- package/dist/transports/http/server.js.map +1 -1
- package/dist/transports/mcp/handlers.d.ts +31 -7
- package/dist/transports/mcp/handlers.d.ts.map +1 -1
- package/dist/transports/mcp/handlers.js +49 -7
- package/dist/transports/mcp/handlers.js.map +1 -1
- package/dist/transports/schemas.d.ts +5 -2
- package/dist/transports/schemas.d.ts.map +1 -1
- package/dist/transports/schemas.js +30 -3
- package/dist/transports/schemas.js.map +1 -1
- package/docs/platforms/README.md +1 -0
- package/docs/platforms/agent-messaging.md +39 -8
- package/package.json +11 -6
- package/scripts/hooks/_generated/agent-message-inbox.js +28 -1
- package/scripts/hooks/_generated/briefing-index.js +147 -0
- package/scripts/hooks/_generated/briefing-level.js +82 -0
- package/scripts/hooks/_generated/capture-liveness.js +455 -0
- package/scripts/hooks/_generated/core-paths.js +4 -0
- package/scripts/hooks/_generated/fts-index.js +5 -0
- package/scripts/hooks/_generated/task-state.js +70 -0
- package/scripts/hooks/_generated/update-notice.js +195 -0
- package/scripts/hooks/_generated/work-topology.js +3 -0
- package/scripts/hooks/_shared.js +923 -27
- package/scripts/hooks/_stop-notes.js +504 -0
- package/scripts/hooks/auto-update-runner.mjs +22 -0
- package/scripts/hooks/decision-nudge.js +32 -5
- package/scripts/hooks/guard-check.js +30 -3
- package/scripts/hooks/post-commit.js +329 -133
- package/scripts/hooks/pre-compact.js +17 -2
- package/scripts/hooks/pre-edit-recall.js +416 -64
- package/scripts/hooks/session-start.js +617 -150
- package/scripts/hooks/session-summary.js +217 -41
- package/scripts/hooks/user-prompt-intent.js +124 -22
- package/scripts/upgrade-plugin.sh +3 -4
- package/skills/memesh/SKILL.md +45 -23
- package/dist/cli/assets/d3.v7.min.js +0 -2
- package/dist/core/graph.d.ts +0 -26
- package/dist/core/graph.d.ts.map +0 -1
- package/dist/core/graph.js +0 -74
- 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')
|
|
44
|
-
|
|
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)
|
|
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 {
|
|
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))
|
|
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')
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// command
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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 (
|
|
69
|
-
|
|
250
|
+
if (!isGitCommitCommand(issuedCommand)) {
|
|
251
|
+
record('skipped', SKIP_REASONS.notGitCommit);
|
|
70
252
|
return exit0();
|
|
71
253
|
}
|
|
72
254
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
133
|
-
// git show failed — no diff stats recorded, existing behavior unchanged
|
|
373
|
+
captured.push(result.entityName);
|
|
134
374
|
}
|
|
135
375
|
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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)
|
|
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
|
});
|