@lh8ppl/claude-memory-kit 0.1.0

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 (81) hide show
  1. package/bin/cmk-compress-lazy.mjs +59 -0
  2. package/bin/cmk-daily-distill.mjs +67 -0
  3. package/bin/cmk-weekly-curate.mjs +56 -0
  4. package/bin/cmk.mjs +12 -0
  5. package/package.json +50 -0
  6. package/src/audit-log.mjs +103 -0
  7. package/src/auto-extract.mjs +742 -0
  8. package/src/capture-prompt.mjs +61 -0
  9. package/src/capture-turn.mjs +273 -0
  10. package/src/claude-md.mjs +212 -0
  11. package/src/compress-session.mjs +349 -0
  12. package/src/compressor.mjs +376 -0
  13. package/src/conflict-queue.mjs +796 -0
  14. package/src/cooldown.mjs +61 -0
  15. package/src/daily-distill.mjs +252 -0
  16. package/src/doctor.mjs +528 -0
  17. package/src/forget.mjs +335 -0
  18. package/src/frontmatter.mjs +73 -0
  19. package/src/import-anthropic-memory.mjs +266 -0
  20. package/src/index-db.mjs +154 -0
  21. package/src/index-rebuild.mjs +597 -0
  22. package/src/index.mjs +90 -0
  23. package/src/inject-context.mjs +484 -0
  24. package/src/install.mjs +327 -0
  25. package/src/lazy-compress.mjs +326 -0
  26. package/src/lock-discipline.mjs +166 -0
  27. package/src/mcp-server.mjs +498 -0
  28. package/src/memory-write.mjs +565 -0
  29. package/src/merge-facts.mjs +213 -0
  30. package/src/observe-edit.mjs +87 -0
  31. package/src/platform-commands.mjs +138 -0
  32. package/src/poison-guard.mjs +245 -0
  33. package/src/privacy.mjs +21 -0
  34. package/src/provenance.mjs +217 -0
  35. package/src/register-crons.mjs +354 -0
  36. package/src/reindex.mjs +134 -0
  37. package/src/repair.mjs +316 -0
  38. package/src/result-shapes.mjs +155 -0
  39. package/src/review-queue.mjs +345 -0
  40. package/src/roll.mjs +115 -0
  41. package/src/scratchpad.mjs +335 -0
  42. package/src/search.mjs +311 -0
  43. package/src/subcommands.mjs +1252 -0
  44. package/src/tier-paths.mjs +74 -0
  45. package/src/transcripts.mjs +234 -0
  46. package/src/trust.mjs +226 -0
  47. package/src/weekly-curate.mjs +454 -0
  48. package/src/write-fact.mjs +205 -0
  49. package/template/.claude/hooks/pre-tool-memory.js +78 -0
  50. package/template/.claude/hooks/transcript-capture.js +69 -0
  51. package/template/.claude/settings.json +27 -0
  52. package/template/.claude/skills/memory-write/SKILL.md +117 -0
  53. package/template/.gitignore.fragment +12 -0
  54. package/template/CLAUDE.md.template +49 -0
  55. package/template/docs/journey/journey-log.md.template +292 -0
  56. package/template/local/machine-paths.md.template +37 -0
  57. package/template/local/overrides.md.template +36 -0
  58. package/template/project/.index/.gitkeep +0 -0
  59. package/template/project/MEMORY.md.template +47 -0
  60. package/template/project/SOUL.md.template +35 -0
  61. package/template/project/memory/INDEX.md.template +47 -0
  62. package/template/project/memory/archive/superseded/.gitkeep +0 -0
  63. package/template/project/memory/archive/tombstones/.gitkeep +0 -0
  64. package/template/project/queues/.gitkeep +0 -0
  65. package/template/project/sessions/.gitkeep +0 -0
  66. package/template/project/transcripts/.gitkeep +0 -0
  67. package/template/support/cron-jobs/daily-memory-distill.md +15 -0
  68. package/template/support/cron-jobs/nightly-memsearch-index.md +17 -0
  69. package/template/support/cron-jobs/weekly-memory-curator.md +15 -0
  70. package/template/support/milvus-deploy/README.md +57 -0
  71. package/template/support/milvus-deploy/docker-compose.yml +66 -0
  72. package/template/support/scripts/auto-extract-memory.sh +102 -0
  73. package/template/support/scripts/memsearch-index-with-flush.sh +59 -0
  74. package/template/support/scripts/refresh-distill-timestamp.py +35 -0
  75. package/template/support/scripts/register-crons.py +242 -0
  76. package/template/support/scripts/run-daily-distill.sh +67 -0
  77. package/template/support/scripts/run-weekly-curate.sh +58 -0
  78. package/template/user/HABITS.md.template +18 -0
  79. package/template/user/LESSONS.md.template +18 -0
  80. package/template/user/USER.md.template +18 -0
  81. package/template/user/fragments/INDEX.md.template +23 -0
@@ -0,0 +1,134 @@
1
+ // Granular-archive pointer-index writer (Task 8, refactored in
2
+ // cleanup-layer-2-cross-module-drift). Single public boundary:
3
+ // reindex(opts) → result. See design §2.3.
4
+ //
5
+ // Uses shared modules: tier-paths (path resolution), frontmatter (js-yaml
6
+ // parse). See CLAUDE.md "Shared modules" rule.
7
+
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { VALID_TIERS, resolveTierRoot, resolveFactDir } from './tier-paths.mjs';
17
+ import { parse } from './frontmatter.mjs';
18
+
19
+ const INDEX_SIZE_WARN_BYTES = 25 * 1024;
20
+ const HOOK_MAX_LEN = 80;
21
+
22
+ const TIER_LABEL = {
23
+ P: 'project tier',
24
+ L: 'local tier',
25
+ U: 'user tier',
26
+ };
27
+
28
+ function extractHook(body) {
29
+ for (const raw of body.split('\n')) {
30
+ const line = raw.trim();
31
+ if (!line) continue;
32
+ if (line.startsWith('#')) continue;
33
+ if (line.length > HOOK_MAX_LEN) {
34
+ return line.slice(0, HOOK_MAX_LEN).trimEnd() + '...';
35
+ }
36
+ return line;
37
+ }
38
+ return '';
39
+ }
40
+
41
+ function formatIndexLine({ id, type, title, filename, hook }) {
42
+ const head = `- (${id}) [${type}] [${title}](${filename})`;
43
+ return hook ? `${head} — ${hook}` : head;
44
+ }
45
+
46
+ function listFactFiles(factDir) {
47
+ if (!existsSync(factDir)) return [];
48
+ const out = [];
49
+ for (const entry of readdirSync(factDir, { withFileTypes: true })) {
50
+ if (!entry.isFile()) continue;
51
+ if (!entry.name.endsWith('.md')) continue;
52
+ if (entry.name === 'INDEX.md') continue;
53
+ out.push(entry.name);
54
+ }
55
+ return out.sort();
56
+ }
57
+
58
+ export function reindex(opts = {}) {
59
+ const { tier, projectRoot, userDir, warn } = opts;
60
+ if (!tier || !VALID_TIERS.has(tier)) {
61
+ throw new Error(
62
+ `reindex: invalid tier ${JSON.stringify(tier)}. Must be 'U', 'P', or 'L'.`,
63
+ );
64
+ }
65
+ const emit = warn ?? ((msg) => process.stderr.write(msg + '\n'));
66
+ const warnings = [];
67
+ function pushWarning(msg) {
68
+ warnings.push(msg);
69
+ emit(msg);
70
+ }
71
+
72
+ const tierRoot = resolveTierRoot({ tier, projectRoot, userDir });
73
+ const factDir = resolveFactDir(tier, tierRoot);
74
+ mkdirSync(factDir, { recursive: true });
75
+
76
+ const entries = [];
77
+ for (const filename of listFactFiles(factDir)) {
78
+ const path = join(factDir, filename);
79
+ let text;
80
+ try {
81
+ text = readFileSync(path, 'utf8');
82
+ } catch (e) {
83
+ pushWarning(`reindex: failed to read ${filename}: ${e.message}`);
84
+ continue;
85
+ }
86
+ const { frontmatter, body, parseError } = parse(text);
87
+ if (!frontmatter) {
88
+ pushWarning(
89
+ `reindex: ${filename} skipped — ${parseError ?? 'no YAML frontmatter'}`,
90
+ );
91
+ continue;
92
+ }
93
+ if (!frontmatter.id || !frontmatter.type || !frontmatter.title) {
94
+ pushWarning(
95
+ `reindex: ${filename} skipped — missing required frontmatter field(s) (id/type/title)`,
96
+ );
97
+ continue;
98
+ }
99
+ if (frontmatter.deleted_at) continue;
100
+ entries.push({
101
+ id: frontmatter.id,
102
+ type: frontmatter.type,
103
+ title: frontmatter.title,
104
+ filename,
105
+ hook: extractHook(body),
106
+ });
107
+ }
108
+
109
+ entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
110
+
111
+ const header = `# Granular memory index — ${TIER_LABEL[tier]}\n\n## Files\n`;
112
+ const bodyLines = entries.map(formatIndexLine).join('\n');
113
+ const content = entries.length
114
+ ? `${header}\n${bodyLines}\n`
115
+ : `${header}\n`;
116
+
117
+ const indexPath = join(factDir, 'INDEX.md');
118
+ writeFileSync(indexPath, content, 'utf8');
119
+
120
+ const bytes = Buffer.byteLength(content, 'utf8');
121
+ if (bytes > INDEX_SIZE_WARN_BYTES) {
122
+ pushWarning(
123
+ `reindex: ${indexPath} is ${(bytes / 1024).toFixed(1)} KB (>25 KB); consider consolidation`,
124
+ );
125
+ }
126
+
127
+ return {
128
+ tier,
129
+ indexPath,
130
+ factCount: entries.length,
131
+ bytes,
132
+ warnings,
133
+ };
134
+ }
package/src/repair.mjs ADDED
@@ -0,0 +1,316 @@
1
+ // `cmk repair` (Task 39, T-033, parts 39.1–39.3).
2
+ //
3
+ // Public boundary:
4
+ // async runRepair({projectRoot, scope: 'hooks'|'locks'|'index'|'all', staleLockMs?, reindexer?})
5
+ // → {action, scope, repairs: [...], errors, duration_ms}
6
+ //
7
+ // Three repair scopes:
8
+ // - 'hooks' : deep-merge the kit's canonical hooks block from plugin/hooks/hooks.json
9
+ // into <projectRoot>/.claude/settings.json. Idempotent. Closes HC-2 failures.
10
+ // - 'locks' : remove stale lock files (default >1h old; configurable via staleLockMs).
11
+ // Live locks (holderAlive: true) are preserved. Closes HC-9 failures.
12
+ // - 'index' : invoke cmk reindex --full (Task 29's reindexFull boundary). Closes HC-5 failures.
13
+ // - 'all' : run all three in order. Default scope when --all flag is set OR no scope provided
14
+ // (v0.1.0 defaults to NO-OP if no scope flag — user must opt in to repairs).
15
+ //
16
+ // Per design §14 + tasks.md 39 (39.1–39.3).
17
+
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ statSync,
23
+ unlinkSync,
24
+ writeFileSync,
25
+ } from 'node:fs';
26
+ import { dirname, join } from 'node:path';
27
+ import {
28
+ appendAuditEntry,
29
+ nowIso,
30
+ REASON_CODES,
31
+ } from './audit-log.mjs';
32
+ import { ERROR_CATEGORIES, errorResult } from './result-shapes.mjs';
33
+ import { detectStaleLocks } from './lock-discipline.mjs';
34
+
35
+ const DEFAULT_STALE_LOCK_MS = 60 * 60 * 1000; // 1 hour
36
+ const SETTINGS_REL = ['.claude', 'settings.json'];
37
+
38
+ // I1 fix (Task 39 skill-review 2026-05-28): the canonical hooks block
39
+ // is embedded INLINE as a JS constant instead of being read from
40
+ // plugin/hooks/hooks.json. Rationale: packages/cli/package.json `files`
41
+ // lists ['bin/', 'src/', 'README.md'] — the plugin/ tree is OUTSIDE the
42
+ // published @lh8ppl/claude-memory-kit tarball. Reading from plugin/ works
43
+ // in-repo (where __dirname resolves up to the repo root) but breaks
44
+ // post-`npm install -g` where plugin/ doesn't exist.
45
+ //
46
+ // Same Task-33-B1 class of bug: cron-emission paths were also broken
47
+ // post-npm-install-g until they embedded absolute paths at registration
48
+ // time. The embed-the-canonical-constant pattern is the durable fix.
49
+ //
50
+ // Source of truth: keep this in sync with plugin/hooks/hooks.json. A
51
+ // future validator (scripts/validate-hooks-block-sync.mjs) would catch
52
+ // drift automatically; v0.1.x candidate.
53
+ const KIT_HOOKS_BLOCK = Object.freeze({
54
+ Setup: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-version-check"', timeout: 30 }] }],
55
+ SessionStart: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-inject-context"', timeout: 30 }] }],
56
+ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-capture-prompt"', timeout: 10 }] }],
57
+ PostToolUse: [{ matcher: 'Write|Edit|MultiEdit', hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-observe-edit"', async: true, timeout: 120 }] }],
58
+ Stop: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-capture-turn"', timeout: 30 }] }],
59
+ SessionEnd: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PLUGIN_ROOT}/bin/cmk-compress-session"', timeout: 60 }] }],
60
+ });
61
+
62
+ /**
63
+ * Repair `<projectRoot>/.claude/settings.json` by merging in the kit's
64
+ * canonical hooks block. Preserves any other top-level keys + non-kit
65
+ * hook entries (e.g., the user's own PreToolUse hooks under different
66
+ * matchers).
67
+ */
68
+ function repairHooks({ projectRoot, ts }) {
69
+ // I1 fix: use the inlined KIT_HOOKS_BLOCK constant; no file read,
70
+ // no npm-install-g brittleness.
71
+ const kitHooks = KIT_HOOKS_BLOCK;
72
+
73
+ const settingsPath = join(projectRoot, ...SETTINGS_REL);
74
+ let settings = {};
75
+ if (existsSync(settingsPath)) {
76
+ try {
77
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
78
+ } catch (err) {
79
+ return {
80
+ kind: 'hooks',
81
+ changed: false,
82
+ error: `${settingsPath} parse error: ${err?.message ?? err}`,
83
+ };
84
+ }
85
+ }
86
+
87
+ const before = JSON.stringify(settings);
88
+ if (!settings.hooks || typeof settings.hooks !== 'object') {
89
+ settings.hooks = {};
90
+ }
91
+
92
+ // Merge each event array: replace the kit's hook entries (matched by
93
+ // command substring) with the canonical version; keep any user-added
94
+ // entries that don't reference the kit.
95
+ const KIT_COMMAND_TOKENS = [
96
+ 'cmk-version-check',
97
+ 'cmk-inject-context',
98
+ 'cmk-capture-prompt',
99
+ 'cmk-observe-edit',
100
+ 'cmk-capture-turn',
101
+ 'cmk-compress-session',
102
+ ];
103
+ const isKitEntry = (entry) => {
104
+ if (!entry || typeof entry !== 'object') return false;
105
+ // Entry shape varies: {command} or {hooks: [{command}]}.
106
+ const collectCommands = (e) => {
107
+ const cmds = [];
108
+ if (typeof e.command === 'string') cmds.push(e.command);
109
+ if (Array.isArray(e.hooks)) {
110
+ for (const h of e.hooks) if (typeof h.command === 'string') cmds.push(h.command);
111
+ }
112
+ return cmds;
113
+ };
114
+ const cmds = collectCommands(entry);
115
+ return cmds.some((c) => KIT_COMMAND_TOKENS.some((t) => c.includes(t)));
116
+ };
117
+
118
+ for (const [eventName, kitEntries] of Object.entries(kitHooks)) {
119
+ const existing = Array.isArray(settings.hooks[eventName])
120
+ ? settings.hooks[eventName]
121
+ : [];
122
+ const userEntries = existing.filter((e) => !isKitEntry(e));
123
+ settings.hooks[eventName] = [...userEntries, ...kitEntries];
124
+ }
125
+
126
+ const after = JSON.stringify(settings);
127
+ const changed = before !== after;
128
+
129
+ if (changed) {
130
+ mkdirSync(dirname(settingsPath), { recursive: true });
131
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
132
+ }
133
+
134
+ // I3 fix (Task 39 skill-review 2026-05-28): emit a Door-4 audit
135
+ // entry per outcome. cmk repair mutates user-visible state; without
136
+ // this, a "cmk repair broke my settings.json" report two weeks from
137
+ // now has no audit trail.
138
+ try {
139
+ appendAuditEntry(join(projectRoot, 'context'), {
140
+ ts,
141
+ action: 'repair',
142
+ tier: 'P',
143
+ id: 'P-RPHKAPLD', // synthetic stable id for hooks-repair events (base32 alphabet)
144
+ reasonCode: changed ? REASON_CODES.REPAIR_HOOKS_APPLIED : REASON_CODES.REPAIR_HOOKS_NOOP,
145
+ extra: { settingsPath, events: Object.keys(kitHooks) },
146
+ });
147
+ } catch {
148
+ // best-effort — never block repair on audit-log failure
149
+ }
150
+
151
+ return {
152
+ kind: 'hooks',
153
+ changed,
154
+ settingsPath,
155
+ events: Object.keys(kitHooks),
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Repair stale locks under <projectRoot>/context/.locks/ +
161
+ * <userDir>/.locks/. Removes locks older than staleLockMs whose holder
162
+ * process is no longer alive. Live locks are preserved.
163
+ */
164
+ function repairLocks({ projectRoot, userDir, staleLockMs, now, ts }) {
165
+ const stale = detectStaleLocks(projectRoot, { userDir }).filter((r) => r.stale);
166
+ const removed = [];
167
+ const preserved = [];
168
+ // M1 fix (skill-review 2026-05-28): nowMs is anchored once and used
169
+ // for BOTH cutoff comparison AND preserved-entry ageMs reporting. Old
170
+ // code used injected `now` for cutoff but Date.now() for ageMs, which
171
+ // produced confusing test fixtures if anyone asserted on age values.
172
+ const nowMs = now ? new Date(now).getTime() : Date.now();
173
+ const cutoffMs = nowMs - staleLockMs;
174
+ for (const lock of stale) {
175
+ let mtimeMs;
176
+ try {
177
+ mtimeMs = statSync(lock.path).mtimeMs;
178
+ } catch {
179
+ continue;
180
+ }
181
+ if (mtimeMs > cutoffMs) {
182
+ // Stale but recent (within the staleLockMs window) — keep for now
183
+ preserved.push({ path: lock.path, ageMs: nowMs - mtimeMs, reason: 'within-cutoff' });
184
+ continue;
185
+ }
186
+ try {
187
+ unlinkSync(lock.path);
188
+ removed.push({ path: lock.path, reason: lock.reason });
189
+ } catch (err) {
190
+ preserved.push({
191
+ path: lock.path,
192
+ ageMs: nowMs - mtimeMs,
193
+ reason: `unlink-failed: ${err?.message ?? err}`,
194
+ });
195
+ }
196
+ }
197
+ // I3 fix (skill-review 2026-05-28): emit a Door-4 audit entry per
198
+ // removed lock so the audit trail records every file deletion the
199
+ // repair pipeline performed. Without this, a "cmk repair deleted my
200
+ // lock" post-mortem has nothing to read.
201
+ for (const r of removed) {
202
+ try {
203
+ appendAuditEntry(join(projectRoot, 'context'), {
204
+ ts,
205
+ action: 'repair',
206
+ tier: 'P',
207
+ id: 'P-RPLKRMVD', // synthetic stable id for repair-lock-removed events (base32 alphabet)
208
+ reasonCode: REASON_CODES.REPAIR_LOCK_REMOVED,
209
+ extra: { path: r.path, reason: r.reason },
210
+ });
211
+ } catch {
212
+ // best-effort
213
+ }
214
+ }
215
+ return {
216
+ kind: 'locks',
217
+ changed: removed.length > 0,
218
+ removed,
219
+ preserved,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Repair the SQLite + FTS5 index by invoking the reindex full pipeline
225
+ * (Task 29's reindexFull). Lazy-loaded so test imports don't pull in
226
+ * the better-sqlite3 binary unnecessarily.
227
+ *
228
+ * @param {object} opts
229
+ * @param {Function} [opts.reindexer] test-injected reindex function; defaults to import('./index-rebuild.mjs').reindexFull
230
+ */
231
+ async function repairIndex({ projectRoot, userDir, reindexer }) {
232
+ let reindexFn = reindexer;
233
+ if (!reindexFn) {
234
+ const mod = await import('./index-rebuild.mjs');
235
+ reindexFn = mod.reindexFull;
236
+ }
237
+ if (typeof reindexFn !== 'function') {
238
+ return {
239
+ kind: 'index',
240
+ changed: false,
241
+ error: 'reindexFull is not a function',
242
+ };
243
+ }
244
+ try {
245
+ const r = await reindexFn({ projectRoot, userDir });
246
+ return {
247
+ kind: 'index',
248
+ changed: true,
249
+ result: r,
250
+ };
251
+ } catch (err) {
252
+ return {
253
+ kind: 'index',
254
+ changed: false,
255
+ error: `reindex failed: ${err?.message ?? err}`,
256
+ };
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Public boundary: run the repair pipeline.
262
+ *
263
+ * @returns {Promise<object>}
264
+ */
265
+ export async function runRepair({
266
+ projectRoot,
267
+ userDir,
268
+ scope = 'all',
269
+ staleLockMs = DEFAULT_STALE_LOCK_MS,
270
+ reindexer,
271
+ now,
272
+ } = {}) {
273
+ const ts = now ?? nowIso();
274
+ const t0 = Date.now();
275
+ if (!projectRoot) {
276
+ return errorResult({
277
+ category: ERROR_CATEGORIES.MISSING_PROJECT_ROOT,
278
+ errors: ['projectRoot is required'],
279
+ duration_ms: Date.now() - t0,
280
+ });
281
+ }
282
+ if (!['hooks', 'locks', 'index', 'all'].includes(scope)) {
283
+ return errorResult({
284
+ category: ERROR_CATEGORIES.SCHEMA,
285
+ errors: [`invalid scope: ${scope}; expected 'hooks' | 'locks' | 'index' | 'all'`],
286
+ duration_ms: Date.now() - t0,
287
+ });
288
+ }
289
+
290
+ const scopes = scope === 'all' ? ['hooks', 'locks', 'index'] : [scope];
291
+ const repairs = [];
292
+ let errors = 0;
293
+ for (const s of scopes) {
294
+ if (s === 'hooks') {
295
+ const r = repairHooks({ projectRoot, ts });
296
+ if (r.error) errors += 1;
297
+ repairs.push(r);
298
+ } else if (s === 'locks') {
299
+ const r = repairLocks({ projectRoot, userDir, staleLockMs, now: ts, ts });
300
+ if (r.error) errors += 1;
301
+ repairs.push(r);
302
+ } else if (s === 'index') {
303
+ // eslint-disable-next-line no-await-in-loop
304
+ const r = await repairIndex({ projectRoot, userDir, reindexer });
305
+ if (r.error) errors += 1;
306
+ repairs.push(r);
307
+ }
308
+ }
309
+ return {
310
+ action: 'completed',
311
+ scope,
312
+ repairs,
313
+ errors,
314
+ duration_ms: Date.now() - t0,
315
+ };
316
+ }
@@ -0,0 +1,155 @@
1
+ // Canonical result-shape conventions for cmk public boundaries.
2
+ //
3
+ // Per the Layer-2 review's I3 finding, `errorCategory` was tagged inconsistently
4
+ // across the four modules: writeFact set it; forget never did; mergeFacts set it
5
+ // only for input validation. A consumer doing `if (r.errorCategory === 'schema')`
6
+ // branched correctly for some failures and missed others. This module pins down
7
+ // the enum + provides helpers so error returns are uniform.
8
+ //
9
+ // Public surface:
10
+ // ERROR_CATEGORIES — frozen enum of errorCategory values
11
+ // ACTION_TYPES — frozen enum of action values
12
+ // errorResult({category, errors, ...rest}) → canonical error result object
13
+ // notFoundResult({errors, ...rest}) → canonical not-found result object
14
+ //
15
+ // The canonical result shape for write-side boundaries is:
16
+ // { action: enum, id?, path?, errorCategory?, errors?, ...extras }
17
+ // Where action is the discriminator. errorCategory + errors only appear when
18
+ // action === 'error'. See CLAUDE.md "Shared modules" + design §1.3.
19
+
20
+ export const ERROR_CATEGORIES = Object.freeze({
21
+ // Input shape wrong — a validation rule was violated. Caller passed bad
22
+ // arguments. Most validateOptions failures use this.
23
+ SCHEMA: 'schema',
24
+
25
+ // Runtime constraint violated. The arguments looked valid but the operation
26
+ // can't proceed without corrupting existing state. Examples:
27
+ // - mergeFacts: merged body would dedup against an unrelated existing fact
28
+ // - writeFact: same path exists with a different id (would overwrite)
29
+ COLLISION: 'collision',
30
+
31
+ // Lookup failed at runtime. Used by callers that distinguish "I couldn't
32
+ // find what you asked for" from "your input was wrong" — typically pairs
33
+ // with action: 'not-found' for the cleanest UX, but errorCategory: 'not-found'
34
+ // is available for write-side boundaries that need to report missing
35
+ // referenced ids without a discriminator change.
36
+ NOT_FOUND: 'not-found',
37
+
38
+ // Another writer holds a lock; retry later. Used by the auto-extract
39
+ // subagent (Task 23) when a prior invocation still holds the
40
+ // context/.locks/auto-extract.lock file.
41
+ CONCURRENT_RUN: 'concurrent_run',
42
+
43
+ // A scratchpad write would push the file past its configured cap even
44
+ // after consolidation (Task 12, design §2.1). Caller chose not to
45
+ // forcibly truncate; the write is rejected so no silent data loss.
46
+ CAP_EXCEEDED: 'cap_exceeded',
47
+
48
+ // --- Auto-extract / hook entrypoint validation (Task 23) -----------
49
+ // These pair with handlers that ALWAYS exit 0 (a crashed hook is
50
+ // worse than a missing capture). The category surfaces in
51
+ // sessions/{date}.extract.log so analytics can track failure modes.
52
+
53
+ // The caller did not pass `projectRoot`. Programmer error in the
54
+ // bin wrapper; ships as a guard against misuse.
55
+ MISSING_PROJECT_ROOT: 'missing_project_root',
56
+
57
+ // No CompressorBackend implementation was passed. Same shape as
58
+ // above — guards a programmer error.
59
+ MISSING_BACKEND: 'missing_backend',
60
+
61
+ // The expected turn buffer file (Task 21 wrote it under
62
+ // transcripts/.extract-*.tmp) doesn't exist by the time auto-extract
63
+ // gets scheduled. Could be a race with Task 21's writeFileSync, or
64
+ // a manual cleanup of stale temp files.
65
+ MISSING_TURN: 'missing_turn',
66
+
67
+ // The CompressorBackend's compress() rejected. For
68
+ // HaikuViaAnthropicApi this means the `claude --print` subprocess
69
+ // exited non-zero or the spawn itself failed. HAIKU_TIMEOUT
70
+ // (below) is the disambiguated case for "took too long" vs.
71
+ // "exited unhealthy"; analytics treat them differently.
72
+ HAIKU_FAILED: 'haiku_failed',
73
+
74
+ // CompressorBackend.compress() exceeded the caller-supplied
75
+ // timeoutMs (design §8.5). The subprocess was killed by the
76
+ // SIGTERM → grace → SIGKILL escalation in terminateSubprocess.
77
+ // Auto-extract passes timeoutMs=25_000 (under 30s Stop hook
78
+ // ceiling); compress-session passes 50_000 (under 60s SessionEnd
79
+ // ceiling). The inner timeout exists so the catch + finally +
80
+ // log-write all run BEFORE the outer hook ceiling kills the
81
+ // parent — without it, a hung Haiku call would leak the
82
+ // auto-extract.lock file and skip the NDJSON log entry.
83
+ // Distinct from HAIKU_FAILED so analytics can separate "the API
84
+ // is slow today" from "the API rejected our call".
85
+ HAIKU_TIMEOUT: 'haiku_timeout',
86
+
87
+ // SessionEnd compression (Task 22) — the CompressorBackend's
88
+ // compress() rejected when called with the §8.4 compression
89
+ // prompt against sessions/now.md. Disambiguates from
90
+ // HAIKU_FAILED in extract.log so analytics can separate
91
+ // extraction failures from compression failures (same root cause
92
+ // — the `claude` subprocess — but the call sites have different
93
+ // recovery semantics: extract is best-effort, compression
94
+ // leaves now.md intact for the next attempt).
95
+ COMPRESS_FAILED: 'compress_failed',
96
+
97
+ // Poison_Guard rejection (Task 24, design §6.7) — the pre-write
98
+ // regex filter matched a secret/injection pattern. memoryWrite()
99
+ // returns this category and the matched pattern_id surfaces in
100
+ // .locks/poison-guard.log (NDJSON, redacted) so audits can track
101
+ // frequency without exposing the cleartext that triggered the
102
+ // rejection. Pairs with POISON_GUARD_CATEGORIES from
103
+ // poison-guard.mjs for routing analytics.
104
+ POISON_GUARD: 'poison_guard',
105
+
106
+ // `cmk search` requested --mode=semantic or --mode=hybrid but the
107
+ // Layer 5b memsearch+Milvus install isn't present (Task 30, design
108
+ // §9.3). Pairs with `process.exitCode = 2` in subcommands.mjs per
109
+ // tasks.md 30.2's explicit "exit 2 when not installed" contract.
110
+ // NO silent fallback to keyword — the user asked for semantic,
111
+ // and the surface should fail-loud so they know what's missing.
112
+ SEMANTIC_UNAVAILABLE: 'semantic_unavailable',
113
+ });
114
+
115
+ export const ACTION_TYPES = Object.freeze({
116
+ CREATED: 'created',
117
+ SKIPPED: 'skipped',
118
+ TOMBSTONED: 'tombstoned',
119
+ MERGED: 'merged',
120
+ ERROR: 'error',
121
+ CANCELLED: 'cancelled',
122
+ NOT_FOUND: 'not-found',
123
+ });
124
+
125
+ const VALID_CATEGORIES = new Set(Object.values(ERROR_CATEGORIES));
126
+
127
+ export function errorResult({ category, errors, ...rest }) {
128
+ if (!VALID_CATEGORIES.has(category)) {
129
+ throw new Error(
130
+ `errorResult: invalid category ${JSON.stringify(category)}. Must be one of: ${[
131
+ ...VALID_CATEGORIES,
132
+ ].join(', ')}`,
133
+ );
134
+ }
135
+ if (!Array.isArray(errors) || errors.length === 0) {
136
+ throw new Error('errorResult: errors must be a non-empty array');
137
+ }
138
+ return {
139
+ action: 'error',
140
+ errorCategory: category,
141
+ errors,
142
+ ...rest,
143
+ };
144
+ }
145
+
146
+ export function notFoundResult({ errors, ...rest }) {
147
+ if (!Array.isArray(errors) || errors.length === 0) {
148
+ throw new Error('notFoundResult: errors must be a non-empty array');
149
+ }
150
+ return {
151
+ action: 'not-found',
152
+ errors,
153
+ ...rest,
154
+ };
155
+ }