@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,1252 @@
1
+ // Subcommand registry for cmk.
2
+ //
3
+ // Single source of truth for every verb the CLI accepts. Each entry
4
+ // describes one verb + (optionally) its sub-verbs. v0.1.0 implements
5
+ // verbs incrementally — each task in tasks.md replaces one stub with
6
+ // a real action. Verbs still on stub print a "not yet implemented in
7
+ // v0.1.0 milestone N" notice (N = the tasks.md task that lights it up).
8
+ //
9
+ // The `milestone` field references the tasks.md parent task that will
10
+ // implement the subcommand. Use "v0.1.x" for verbs deferred past v0.1
11
+ // (per design §12 but not in the v0.1.0 critical path).
12
+ //
13
+ // Adding a new verb? Append to `subcommands` below — the test suite
14
+ // asserts exactly what's exported here, so coverage stays automatic.
15
+
16
+ import { install as installAction, initUserTier as initUserTierAction } from './install.mjs';
17
+ import { removeClaudeMdBlock } from './claude-md.mjs';
18
+ import { reindex as reindexAction } from './reindex.mjs';
19
+ import { openIndexDb } from './index-db.mjs';
20
+ import { reindexBoot, reindexFull } from './index-rebuild.mjs';
21
+ import { search as searchAction, SEARCH_MODES } from './search.mjs';
22
+ import { runMcpServer } from './mcp-server.mjs';
23
+ import { dailyDistill } from './daily-distill.mjs';
24
+ import { weeklyCurate } from './weekly-curate.mjs';
25
+ import { runLazyCompress } from './lazy-compress.mjs';
26
+ import { runDoctor } from './doctor.mjs';
27
+ import { importAnthropicMemory } from './import-anthropic-memory.mjs';
28
+ import { extractTranscript, discoverSessions } from './transcripts.mjs';
29
+ import { runRepair } from './repair.mjs';
30
+ import { runRoll, ROLL_SCOPES } from './roll.mjs';
31
+ import {
32
+ markCronRegistered,
33
+ unmarkCronRegistered,
34
+ } from './lazy-compress.mjs';
35
+ import {
36
+ registerCron,
37
+ unregisterCron,
38
+ CRON_ENTRY_NAME,
39
+ WEEKLY_ENTRY_NAME,
40
+ DEFAULT_WEEKLY_SCHEDULE,
41
+ } from './register-crons.mjs';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { dirname } from 'node:path';
44
+
45
+ const __filename_subcommands = fileURLToPath(import.meta.url);
46
+ const __dirname_subcommands = dirname(__filename_subcommands);
47
+ import { homedir } from 'node:os';
48
+ import { forget as forgetAction } from './forget.mjs';
49
+ import { overrideTrust as overrideTrustAction } from './trust.mjs';
50
+ import { resolveConflictQueue, mergeScratchpadBullets } from './conflict-queue.mjs';
51
+ import { resolveReviewQueue } from './review-queue.mjs';
52
+ import { createInterface } from 'node:readline';
53
+ import { resolve as resolvePath, join } from 'node:path';
54
+
55
+ const NOTICE_PREFIX = 'not yet implemented in v0.1.0';
56
+
57
+ /**
58
+ * Real `cmk install` action — wired in Task 3, extended in Task 4 with
59
+ * --force passed through to the CLAUDE.md downgrade guard. Reads CLI
60
+ * options/flags, dispatches to the install module, prints a one-line
61
+ * summary, and reports the CLAUDE.md action (created / appended /
62
+ * replaced / upgraded / downgrade-blocked / forced-downgrade / unchanged).
63
+ */
64
+ async function runInstall(options /* , command */) {
65
+ const result = await installAction({ force: !!(options && options.force) });
66
+ const parts = [
67
+ `scaffolded ${result.created.length} file(s)`,
68
+ result.skipped.length ? `skipped ${result.skipped.length} existing` : null,
69
+ `.gitignore=${result.gitignore.action}`,
70
+ `CLAUDE.md=${result.claudeMd.action}`,
71
+ ].filter(Boolean);
72
+ console.log('cmk install: ' + parts.join(', '));
73
+
74
+ if (result.claudeMd.action === 'downgrade-blocked') {
75
+ console.error(
76
+ ` warning: CLAUDE.md already has a newer kit block (v${result.claudeMd.oldVersion}). ` +
77
+ `Re-run with --force to downgrade.`
78
+ );
79
+ }
80
+
81
+ if (result.errors.length > 0) {
82
+ for (const e of result.errors) console.error(` error: ${e.path}: ${e.error}`);
83
+ process.exitCode = 1;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * `cmk uninstall` — wired in Task 4. Strips the kit-managed block from
89
+ * the project's CLAUDE.md (if present). Everything outside the markers
90
+ * is byte-preserved. Does NOT touch context/, context.local/, the user
91
+ * tier, or .gitignore — `cmk uninstall` is conservative; users delete
92
+ * those by hand if they really want to.
93
+ */
94
+ function runUninstall(/* options, command */) {
95
+ const projectRoot = resolvePath(process.cwd());
96
+ const result = removeClaudeMdBlock({ projectRoot });
97
+ console.log(`cmk uninstall: CLAUDE.md=${result.action} (${result.path})`);
98
+ if (result.action === 'not-found') {
99
+ console.log(' (no kit-managed block found; CLAUDE.md left unchanged)');
100
+ } else if (result.action === 'no-file') {
101
+ console.log(' (no CLAUDE.md to uninstall from)');
102
+ }
103
+ }
104
+
105
+ /**
106
+ * `cmk init-user-tier` — wired in Task 14. User-tier-only install.
107
+ * Scaffolds USER.md, HABITS.md, LESSONS.md, fragments/ at the
108
+ * resolved user-tier path. Does NOT touch project/local tier files
109
+ * or .gitignore or CLAUDE.md (call `cmk install` for that).
110
+ */
111
+ function runInitUserTier(/* options, command */) {
112
+ const result = initUserTierAction({});
113
+ console.log(
114
+ `cmk init-user-tier: scaffolded ${result.created.length} file(s)` +
115
+ (result.skipped.length ? `, skipped ${result.skipped.length} existing` : '') +
116
+ ` at ${result.userTier}`,
117
+ );
118
+ if (result.errors.length > 0) {
119
+ for (const e of result.errors) console.error(` error: ${e.path}: ${e.error}`);
120
+ process.exitCode = 1;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * `cmk trust <id> <level>` — wired in Task 15. Updates the `trust:`
126
+ * field in BOTH the matched fact file (YAML frontmatter) AND any
127
+ * scratchpad bullet with the matching id (HTML-comment provenance).
128
+ * Writes a canonical audit-log entry per design §6.1 + spec 15.3.
129
+ */
130
+ function runTrust(id, level /* , options, command */) {
131
+ const projectRoot = resolvePath(process.cwd());
132
+ const result = overrideTrustAction({ id, level, projectRoot });
133
+ if (result.action === 'trust-updated') {
134
+ console.log(
135
+ `cmk trust: ${result.id} (${result.tier}) → ${result.level} — updated ${result.updatedLocations.length} location(s)`,
136
+ );
137
+ for (const loc of result.updatedLocations) {
138
+ console.log(` ${loc.type}: ${loc.path} (was ${loc.priorTrust})`);
139
+ }
140
+ return;
141
+ }
142
+ if (result.action === 'not-found') {
143
+ console.error(`cmk trust: ${result.errors[0]}`);
144
+ process.exitCode = 2;
145
+ return;
146
+ }
147
+ if (result.action === 'error') {
148
+ for (const e of result.errors) console.error(`cmk trust: ${e}`);
149
+ process.exitCode = 2;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * `cmk search` — Task 30. Hybrid keyword + optional semantic.
155
+ *
156
+ * v0.1.0 ships the keyword backend (FTS5 BM25 over the observations
157
+ * index). Semantic + hybrid modes require the Layer 5b memsearch+Milvus
158
+ * install which isn't bundled in v0.1.0; both error with exit code 2
159
+ * and a clear "memsearch not installed" hint per tasks.md 30.2.
160
+ *
161
+ * Filter flags (per tasks.md 30.4):
162
+ * --mode <keyword|semantic|hybrid> (default keyword)
163
+ * --min-trust <low|medium|high>
164
+ * --tier <U|P|L>
165
+ * --since <ISO date>
166
+ * --limit <N> (default 20)
167
+ * --include-tombstoned (default false)
168
+ */
169
+ function runSearch(queryParts, options) {
170
+ const projectRoot = resolvePath(process.cwd());
171
+ const query = Array.isArray(queryParts) ? queryParts.join(' ') : queryParts;
172
+ const db = openIndexDb({ projectRoot });
173
+ try {
174
+ const r = searchAction({
175
+ db,
176
+ query,
177
+ mode: options?.mode ?? SEARCH_MODES.KEYWORD,
178
+ minTrust: options?.minTrust,
179
+ tier: options?.tier,
180
+ since: options?.since,
181
+ limit: options?.limit !== undefined ? Number(options.limit) : undefined,
182
+ includeTombstoned: options?.includeTombstoned === true,
183
+ });
184
+ if (r.action === 'error') {
185
+ for (const e of r.errors) console.error(`cmk search: ${e}`);
186
+ // Exit 2 per tasks.md 30.2 contract for semantic-unavailable; schema
187
+ // errors are exit 2 by general kit convention too.
188
+ process.exitCode = 2;
189
+ return;
190
+ }
191
+ if (r.results.length === 0) {
192
+ console.log('cmk search: no results');
193
+ return;
194
+ }
195
+ for (const hit of r.results) {
196
+ // Plain-text output suitable for terminal piping. Snippet uses
197
+ // FTS5's <b>...</b> markers; preserved as-is so callers can pipe
198
+ // to a TUI that renders them OR strip via sed.
199
+ console.log(
200
+ `${hit.id}\t${hit.tier}/${hit.trust}\t${hit.source_file}:${hit.source_line}\t${hit.snippet}`,
201
+ );
202
+ }
203
+ console.log(
204
+ `\ncmk search: ${r.results.length} result(s) (mode=${r.mode})`,
205
+ );
206
+ } finally {
207
+ db.close();
208
+ }
209
+ }
210
+
211
+ /**
212
+ * `cmk reindex` — three modes.
213
+ *
214
+ * no flag Markdown INDEX.md rebuild only (Task 8 behavior). Backward-
215
+ * compat for callers that haven't adopted the SQLite layer.
216
+ * --boot Same as no-flag PLUS SQLite boot diff (Task 29). Reindexes
217
+ * only the source files whose sha1 differs from the `files`
218
+ * checkpoint table. Fast on a warm cache.
219
+ * --full Same as no-flag PLUS SQLite full rebuild (Task 29). DROPs
220
+ * observations / observations_fts / files; walks every source
221
+ * and rebuilds. Recovery path for a corrupted index.
222
+ *
223
+ * Flag semantics per tasks.md 29.1 + 29.3. Markdown INDEX runs in every
224
+ * mode because (a) it's cheap (milliseconds to milliseconds-low-thousands),
225
+ * (b) keeping it always-current avoids users having to think about which
226
+ * index to rebuild when.
227
+ */
228
+ function runReindex(options /* , command */) {
229
+ const projectRoot = resolvePath(process.cwd());
230
+ const userDir = join(homedir(), '.claude-memory-kit');
231
+ const result = reindexAction({ tier: 'P', projectRoot });
232
+ console.log(
233
+ `cmk reindex: tier=${result.tier} facts=${result.factCount} bytes=${result.bytes} (${result.indexPath})`,
234
+ );
235
+ const useBoot = options?.boot === true;
236
+ const useFull = options?.full === true;
237
+ if (!useBoot && !useFull) return;
238
+ if (useBoot && useFull) {
239
+ console.error('cmk reindex: --boot and --full are mutually exclusive');
240
+ process.exitCode = 2;
241
+ return;
242
+ }
243
+ const db = openIndexDb({ projectRoot });
244
+ try {
245
+ const r = useFull
246
+ ? reindexFull({ projectRoot, userDir, db })
247
+ : reindexBoot({ projectRoot, userDir, db });
248
+ if (useFull) {
249
+ console.log(
250
+ `cmk reindex --full: scanned=${r.filesScanned} observations=${r.observationsAffected} duration=${r.durationMs}ms`,
251
+ );
252
+ } else {
253
+ console.log(
254
+ `cmk reindex --boot: scanned=${r.filesScanned} reindexed=${r.filesReindexed} observations=${r.observationsAffected} duration=${r.durationMs}ms`,
255
+ );
256
+ }
257
+ if (r.skipped && r.skipped.length > 0) {
258
+ for (const s of r.skipped) {
259
+ console.error(` skipped ${s.path}: ${s.reason}`);
260
+ }
261
+ }
262
+ } finally {
263
+ db.close();
264
+ }
265
+ }
266
+
267
+ /**
268
+ * `cmk forget <id-or-query>` — wired in Task 9. Tombstones the matching
269
+ * fact (moves it to <tier>/<memory|fragments>/archive/tombstones/<id>.md
270
+ * with deleted_at/deleted_reason/deleted_by frontmatter) and strips any
271
+ * citing bullets from same-tier scratchpads.
272
+ *
273
+ * v0.1 requires --yes — the interactive confirmation prompt is a v0.1.x
274
+ * follow-up (the boundary's `confirm()` callback path is still tested by
275
+ * cli-forget.test.js; the CLI just doesn't wire stdin readline yet).
276
+ */
277
+ function runForget(idOrQuery, options /* , command */) {
278
+ if (!options.yes) {
279
+ console.error(
280
+ 'cmk forget: --yes is required in v0.1.0 (interactive confirmation prompt is a v0.1.x follow-up). Re-run with --yes to confirm tombstoning.',
281
+ );
282
+ process.exitCode = 2;
283
+ return;
284
+ }
285
+ const projectRoot = resolvePath(process.cwd());
286
+ const result = forgetAction({
287
+ idOrQuery,
288
+ projectRoot,
289
+ reason: options.reason,
290
+ deletedBy: options.deletedBy,
291
+ yes: true,
292
+ });
293
+
294
+ if (result.action === 'tombstoned') {
295
+ console.log(
296
+ `cmk forget: tombstoned ${result.id} (${result.tier}) → ${result.tombstonePath}`,
297
+ );
298
+ if (result.scratchpadEdits.length > 0) {
299
+ const total = result.scratchpadEdits.reduce((n, e) => n + e.removed, 0);
300
+ console.log(
301
+ ` scrubbed ${total} bullet(s) across ${result.scratchpadEdits.length} scratchpad(s)`,
302
+ );
303
+ }
304
+ return;
305
+ }
306
+ if (result.action === 'not-found') {
307
+ console.error(`cmk forget: ${result.errors[0]}`);
308
+ process.exitCode = 2;
309
+ return;
310
+ }
311
+ if (result.action === 'error') {
312
+ for (const e of result.errors) console.error(`cmk forget: ${e}`);
313
+ process.exitCode = 2;
314
+ return;
315
+ }
316
+ // cancelled (won't fire here since we pass yes:true above, but defensive)
317
+ console.log('cmk forget: cancelled');
318
+ }
319
+
320
+ /**
321
+ * Real `cmk queue` dispatcher — Task 25. Routes by sub-verb:
322
+ * - 'conflicts' → wire to resolveConflictQueue with a readline-based
323
+ * interactive prompter. merge-both decisions dispatch to mergeFacts.
324
+ * - 'review' → still stubbed (Task 26 / v0.1.x); print the standard
325
+ * notice.
326
+ */
327
+ /**
328
+ * `cmk mcp <child>` dispatcher (Task 31). Currently one child:
329
+ * - 'serve' → start the stdio MCP server. Invoked by Claude Code as
330
+ * a subprocess; runs until stdin closes.
331
+ */
332
+ /**
333
+ * `cmk daily-distill` (Task 33) — runs the daily-distill pipeline once.
334
+ * Designed to be invoked by the host scheduler (cron / launchd /
335
+ * schtasks) registered via `cmk register-crons`. Humans normally don't
336
+ * call this directly; they run register-crons once at install time.
337
+ *
338
+ * Always exits 0 — same posture as cmk-compress-session per design §8.6.1.
339
+ */
340
+ async function runDailyDistill(/* options */) {
341
+ const projectRoot = resolvePath(process.cwd());
342
+ // Lazy-load HaikuViaAnthropicApi (avoids the dep when running unit tests).
343
+ const { HaikuViaAnthropicApi } = await import('./compressor.mjs');
344
+ try {
345
+ const backend = new HaikuViaAnthropicApi();
346
+ const r = await dailyDistill({ projectRoot, backend });
347
+ if (r.action === 'error') {
348
+ console.error(
349
+ `cmk daily-distill: error (${r.error_category ?? 'unknown'})${r.errorMessage ? `: ${r.errorMessage}` : ''}`,
350
+ );
351
+ } else {
352
+ console.log(
353
+ `cmk daily-distill: ${r.action}${r.reason ? ` (${r.reason})` : ''}${r.bytesIn ? ` (in: ${r.bytesIn}b, out: ${r.bytesOut}b, days: ${r.sourceDays})` : ''}`,
354
+ );
355
+ }
356
+ } catch (err) {
357
+ console.error(`cmk daily-distill: unexpected error: ${err?.message ?? err}`);
358
+ }
359
+ }
360
+
361
+ /**
362
+ * `cmk weekly-curate` (Task 34) — runs the weekly-curate pipeline once.
363
+ * Designed to be invoked by the host scheduler registered via
364
+ * `cmk register-crons` (which registers both daily + weekly entries
365
+ * by default). Humans normally don't invoke this directly.
366
+ */
367
+ async function runWeeklyCurate(/* options */) {
368
+ const projectRoot = resolvePath(process.cwd());
369
+ const { HaikuViaAnthropicApi } = await import('./compressor.mjs');
370
+ try {
371
+ const backend = new HaikuViaAnthropicApi();
372
+ const r = await weeklyCurate({ projectRoot, backend });
373
+ if (r.action === 'error') {
374
+ console.error(
375
+ `cmk weekly-curate: error (${r.errorCategory ?? 'unknown'})${(r.errors && r.errors.length) ? `: ${r.errors.join('; ')}` : ''}`,
376
+ );
377
+ } else {
378
+ console.log(
379
+ `cmk weekly-curate: ${r.action}${r.reason ? ` (${r.reason})` : ''}${r.archivedDays ? ` (archived: ${r.archivedDays}d, current: ${r.currentDays}d, in: ${r.bytesIn}b, out: ${r.bytesOut}b)` : ''}`,
380
+ );
381
+ }
382
+ } catch (err) {
383
+ console.error(`cmk weekly-curate: unexpected error: ${err?.message ?? err}`);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * `cmk register-crons [--dry-run] [--unregister]` (Task 33) — register
389
+ * the daily-distill cron entry on the current platform.
390
+ *
391
+ * Per design §8.6.2 cross-platform mapping. `--dry-run` prints the
392
+ * command without executing — recommended first run so the user
393
+ * sees what host-config will change before granting permissions.
394
+ */
395
+ function runRegisterCrons(options /* , command */) {
396
+ const dryRun = options?.dryRun === true;
397
+ const unregister = options?.unregister === true;
398
+ // Task 36 B1+B2 fix: emit the FULL cron command as
399
+ // "<absolute-node-path>" "<absolute-bin-script-path>" "<absolute-project-root>"
400
+ // Rationale (from the layer-wide review):
401
+ // B1 — Cron / launchd / schtasks have non-kit default cwd ($HOME, /,
402
+ // C:\Windows\System32). The bin needs projectRoot resolved AT
403
+ // registration time, not via cwd at fire time.
404
+ // B2 — Bare bin names ('cmk-daily-distill') don't PATH-resolve under
405
+ // the scheduler's restricted PATH (/usr/bin:/bin for launchd; varies
406
+ // for cron). Emitting absolute paths sidesteps PATH entirely.
407
+ // This also bypasses the npm-installed bin shim (.cmd on Windows;
408
+ // symlink on POSIX) — `node <abs-script>` works directly on every
409
+ // platform regardless of how the kit was installed (npm global,
410
+ // npm link, vendored).
411
+ const nodePath = process.execPath;
412
+ const binDir = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'bin');
413
+ const projectRoot = resolvePath(process.cwd());
414
+
415
+ // Helper: quote a path for the platform's cron-line shell.
416
+ // Linux + macOS: double-quote (the cron line is single-quoted around the
417
+ // whole `echo '...'`; double-quotes inside are safe).
418
+ // Windows: the schtasks /TR value is already double-quoted by registerCron,
419
+ // with `\"` escaping for inner quotes — registerCron's existing
420
+ // escapedCommand handles this.
421
+ const quote = (s) => `"${s}"`;
422
+
423
+ const jobs = [
424
+ {
425
+ label: 'daily-distill',
426
+ command: `${quote(nodePath)} ${quote(join(binDir, 'cmk-daily-distill.mjs'))} ${quote(projectRoot)}`,
427
+ entryName: CRON_ENTRY_NAME,
428
+ schedule: undefined, // registerCron default = daily 23:00
429
+ },
430
+ {
431
+ label: 'weekly-curate',
432
+ command: `${quote(nodePath)} ${quote(join(binDir, 'cmk-weekly-curate.mjs'))} ${quote(projectRoot)}`,
433
+ entryName: WEEKLY_ENTRY_NAME,
434
+ schedule: DEFAULT_WEEKLY_SCHEDULE,
435
+ },
436
+ ];
437
+ let anyError = false;
438
+ let anySuccess = false;
439
+ for (const job of jobs) {
440
+ const r = unregister
441
+ ? unregisterCron({ entryName: job.entryName, dryRun })
442
+ : registerCron({
443
+ command: job.command,
444
+ entryName: job.entryName,
445
+ schedule: job.schedule,
446
+ dryRun,
447
+ });
448
+ if (r.action === 'error') {
449
+ anyError = true;
450
+ console.error(
451
+ `cmk register-crons (${job.label}): error — ${(r.errors ?? []).join('; ')}`,
452
+ );
453
+ if (r.error) console.error(` ${r.error}`);
454
+ if (r.output) console.error(r.output);
455
+ continue;
456
+ }
457
+ anySuccess = true;
458
+ console.log(`cmk register-crons (${job.label}): ${r.action} on ${r.platform}`);
459
+ console.log(` command: ${r.command}`);
460
+ if (r.output) console.log(` output: ${r.output.trim()}`);
461
+ }
462
+ // Task 35.3: maintain the cron-registered sentinel so lazy-compress
463
+ // can short-circuit when cron is active. Skip on --dry-run (no
464
+ // host-scheduler state changed, so kit state shouldn't either).
465
+ //
466
+ // M3 fix (skill-review 2026-05-28): anySuccess gates the sentinel
467
+ // write even on PARTIAL failure (one job registered, the other
468
+ // errored). Correct: at least one cron entry is now active, so
469
+ // detectStaleness SHOULD short-circuit to 'cron-active'. The
470
+ // partial failure surfaces to the user via process.exitCode=2 below
471
+ // — kit state (sentinel) and host-scheduler state (the registered
472
+ // job) stay coherent.
473
+ if (!dryRun) {
474
+ const projectRoot = resolvePath(process.cwd());
475
+ if (unregister) {
476
+ unmarkCronRegistered({ projectRoot });
477
+ } else if (anySuccess) {
478
+ markCronRegistered({ projectRoot });
479
+ }
480
+ }
481
+ if (anyError) process.exitCode = 2;
482
+ }
483
+
484
+ /**
485
+ * `cmk compress --lazy` (Task 35) — runs the lazy-compress pipeline once.
486
+ * Designed to be invoked as a detached subprocess from inject-context.mjs
487
+ * (SessionStart hook) when staleness is detected and cron is NOT active.
488
+ * Humans normally don't invoke this directly.
489
+ */
490
+ /**
491
+ * `cmk doctor` (Task 37) — runs the 9 health checks and prints a
492
+ * structured report with repair commands. Per design §14 + tasks.md 37.3.
493
+ *
494
+ * Per NFR-9 + tasks.md 37.5: any recoveryCommand whose underlying
495
+ * action requires a system-level install (pip install / npm install /
496
+ * docker compose up etc.) must NOT be auto-invoked. v0.1.0 surfaces
497
+ * the command to stdout — the user runs it themselves. Auto-repair
498
+ * with --yes is a v0.1.x candidate (design §16).
499
+ */
500
+ async function runDoctorCli(/* options */) {
501
+ const projectRoot = resolvePath(process.cwd());
502
+ const userDir = join(homedir(), '.claude-memory-kit');
503
+ try {
504
+ const r = await runDoctor({ projectRoot, userDir });
505
+ if (r.action === 'error') {
506
+ console.error(`cmk doctor: error — ${(r.errors ?? []).join('; ')}`);
507
+ process.exitCode = 2;
508
+ return;
509
+ }
510
+ // Structured report: one line per check
511
+ const counts = { pass: 0, fail: 0, skip: 0 };
512
+ for (const c of r.checks) {
513
+ counts[c.status] += 1;
514
+ const statusLabel = c.status.toUpperCase().padEnd(4);
515
+ console.log(`[${statusLabel}] ${c.id}: ${c.name}`);
516
+ console.log(` ${c.message}`);
517
+ if (c.status === 'fail' && c.recoveryCommand) {
518
+ // Repair-command surfaced for the user. Per 37.5 + NFR-9, we
519
+ // do NOT auto-invoke install-requiring repairs — the user
520
+ // copies the command.
521
+ const installNote = c.requiresInstall
522
+ ? ' (REQUIRES INSTALL — review before running)'
523
+ : '';
524
+ console.log(` → repair: ${c.recoveryCommand}${installNote}`);
525
+ }
526
+ }
527
+ console.log('');
528
+ console.log(
529
+ `Summary: ${counts.pass} pass · ${counts.fail} fail · ${counts.skip} skip (${r.duration_ms}ms)`,
530
+ );
531
+ if (counts.fail > 0) process.exitCode = 1;
532
+ } catch (err) {
533
+ console.error(`cmk doctor: unexpected error: ${err?.message ?? err}`);
534
+ process.exitCode = 2;
535
+ }
536
+ }
537
+
538
+ async function runRepairCli(options /* , command */) {
539
+ const projectRoot = resolvePath(process.cwd());
540
+ const userDir = join(homedir(), '.claude-memory-kit');
541
+ // Scope flags: --hooks / --locks / --index → run that one only.
542
+ // --all OR no flag → run all three.
543
+ let scope;
544
+ if (options?.hooks && !options?.locks && !options?.index) scope = 'hooks';
545
+ else if (options?.locks && !options?.hooks && !options?.index) scope = 'locks';
546
+ else if (options?.index && !options?.hooks && !options?.locks) scope = 'index';
547
+ else scope = 'all';
548
+
549
+ try {
550
+ const r = await runRepair({ projectRoot, userDir, scope });
551
+ if (r.action === 'error') {
552
+ console.error(`cmk repair: error — ${(r.errors ?? []).join('; ')}`);
553
+ process.exitCode = 2;
554
+ return;
555
+ }
556
+ for (const repair of r.repairs) {
557
+ if (repair.error) {
558
+ console.error(`cmk repair (${repair.kind}): error — ${repair.error}`);
559
+ continue;
560
+ }
561
+ const status = repair.changed ? 'fixed' : 'no-op';
562
+ console.log(`cmk repair (${repair.kind}): ${status}`);
563
+ if (repair.kind === 'hooks' && repair.changed) {
564
+ console.log(` → updated ${repair.settingsPath}`);
565
+ console.log(` events: ${repair.events.join(', ')}`);
566
+ }
567
+ if (repair.kind === 'locks') {
568
+ if (repair.removed && repair.removed.length > 0) {
569
+ for (const l of repair.removed) console.log(` removed: ${l.path} (${l.reason})`);
570
+ }
571
+ if (repair.preserved && repair.preserved.length > 0) {
572
+ for (const l of repair.preserved) console.log(` preserved: ${l.path} (${l.reason})`);
573
+ }
574
+ }
575
+ if (repair.kind === 'index' && repair.changed) {
576
+ console.log(` → reindex completed`);
577
+ }
578
+ }
579
+ if (r.errors > 0) process.exitCode = 1;
580
+ } catch (err) {
581
+ console.error(`cmk repair: unexpected error: ${err?.message ?? err}`);
582
+ process.exitCode = 2;
583
+ }
584
+ }
585
+
586
+ async function runRollCli(options /* , command */) {
587
+ const projectRoot = resolvePath(process.cwd());
588
+ const scope = options?.scope ?? ROLL_SCOPES.NOW;
589
+ // I2 fix (Task 39 skill-review 2026-05-28): dropped unused userDir
590
+ // computation. runRoll's underlying pipelines (compress-session,
591
+ // daily-distill, weekly-curate) all operate purely on projectRoot —
592
+ // none take userDir. Same forward-compat-rot anti-pattern Task 37 M3
593
+ // + Task 38 I1 already removed.
594
+ const { HaikuViaAnthropicApi } = await import('./compressor.mjs');
595
+ try {
596
+ const backend = new HaikuViaAnthropicApi();
597
+ const r = await runRoll({ projectRoot, scope, backend });
598
+ if (r.action === 'error') {
599
+ console.error(`cmk roll: error — ${(r.errors ?? []).join('; ')}`);
600
+ process.exitCode = 2;
601
+ return;
602
+ }
603
+ const inner = r.result;
604
+ console.log(`cmk roll --scope ${scope} → ${r.delegatedTo}: ${inner?.action ?? 'unknown'}${inner?.reason ? ` (${inner.reason})` : ''}`);
605
+ } catch (err) {
606
+ console.error(`cmk roll: unexpected error: ${err?.message ?? err}`);
607
+ process.exitCode = 2;
608
+ }
609
+ }
610
+
611
+ async function runImportAnthropicMemory(options /* , command */) {
612
+ const projectRoot = resolvePath(process.cwd());
613
+ const dryRun = options?.dryRun === true;
614
+ const acceptAll = options?.yes === true;
615
+ try {
616
+ // I1 fix (skill-review 2026-05-28): userDir was unused, dropped.
617
+ const r = await importAnthropicMemory({ projectRoot, dryRun, acceptAll });
618
+ if (r.action === 'error') {
619
+ console.error(`cmk import-anthropic-memory: error — ${(r.errors ?? []).join('; ')}`);
620
+ process.exitCode = 2;
621
+ return;
622
+ }
623
+ if (r.reason === 'no-source') {
624
+ console.log(`cmk import-anthropic-memory: no Anthropic auto-memory found at ${r.sourcePath}`);
625
+ return;
626
+ }
627
+ if (r.mode === 'dry-run') {
628
+ console.log(`cmk import-anthropic-memory: dry-run — ${r.proposals.length} proposal(s), ${r.skipped} duplicate(s) skipped`);
629
+ for (const p of r.proposals) {
630
+ console.log(` + ${p.id}: ${p.text}`);
631
+ }
632
+ return;
633
+ }
634
+ if (r.mode === 'requires-confirmation') {
635
+ console.log(`cmk import-anthropic-memory: ${r.proposals.length} proposal(s) ready to apply.`);
636
+ console.log(' Re-run with --yes to apply, or --dry-run to inspect.');
637
+ for (const p of r.proposals) {
638
+ console.log(` + ${p.id}: ${p.text}`);
639
+ }
640
+ return;
641
+ }
642
+ console.log(`cmk import-anthropic-memory: applied ${r.accepted} proposal(s), skipped ${r.skipped} duplicate(s)`);
643
+ } catch (err) {
644
+ console.error(`cmk import-anthropic-memory: unexpected error: ${err?.message ?? err}`);
645
+ process.exitCode = 2;
646
+ }
647
+ }
648
+
649
+ async function runTranscriptsDispatch(childName, options) {
650
+ if (childName === 'extract') {
651
+ return runTranscriptsExtract(options);
652
+ }
653
+ console.error(`cmk transcripts: ${NOTICE_PREFIX} (unknown sub-verb '${childName}')`);
654
+ process.exitCode = 2;
655
+ }
656
+
657
+ async function runTranscriptsExtract(options) {
658
+ // Discover sessions per the flags + extract each into the output dir.
659
+ const projectRoot = resolvePath(process.cwd());
660
+ const outputDir = options?.output
661
+ ? resolvePath(options.output)
662
+ : join(projectRoot, 'transcripts-extracted');
663
+ const includeThinking = options?.includeThinking === true;
664
+ let sessions;
665
+ try {
666
+ sessions = discoverSessions({
667
+ slug: options?.slug,
668
+ sessionUuidSuffix: options?.session,
669
+ sinceIso: options?.since,
670
+ });
671
+ } catch (err) {
672
+ console.error(`cmk transcripts extract: discovery error: ${err?.message ?? err}`);
673
+ process.exitCode = 2;
674
+ return;
675
+ }
676
+ if (sessions.length === 0) {
677
+ // S1 fix (Task 38 skill-review 2026-05-28): specialize the message
678
+ // for --session-not-found so the user sees the filter that failed.
679
+ if (options?.session) {
680
+ console.error(
681
+ `cmk transcripts extract: no session matching --session ${options.session}`,
682
+ );
683
+ process.exitCode = 2;
684
+ return;
685
+ }
686
+ console.log('cmk transcripts extract: no sessions found matching filter');
687
+ return;
688
+ }
689
+ if (options?.session && sessions.length > 1) {
690
+ console.error(`cmk transcripts extract: ambiguous --session match (${sessions.length} candidates):`);
691
+ for (const s of sessions.slice(0, 10)) {
692
+ console.error(` ${s.slug}/${s.sessionId}.jsonl`);
693
+ }
694
+ process.exitCode = 2;
695
+ return;
696
+ }
697
+ let totalTurns = 0;
698
+ let totalBytes = 0;
699
+ for (const s of sessions) {
700
+ const outputPath = join(outputDir, s.slug, `${s.sessionId}.md`);
701
+ try {
702
+ const r = extractTranscript({
703
+ inputPath: s.jsonlPath,
704
+ outputPath,
705
+ includeThinking,
706
+ });
707
+ if (r.action === 'error') {
708
+ console.error(` ${s.sessionId}: error — ${(r.errors ?? []).join('; ')}`);
709
+ continue;
710
+ }
711
+ totalTurns += r.turnsKept;
712
+ totalBytes += r.outputSize;
713
+ console.log(` ${s.slug}/${s.sessionId}: ${r.turnsKept} turn(s) → ${outputPath}`);
714
+ } catch (err) {
715
+ console.error(` ${s.sessionId}: unexpected error: ${err?.message ?? err}`);
716
+ }
717
+ }
718
+ console.log(`cmk transcripts extract: processed ${sessions.length} session(s); ${totalTurns} total turns; ${(totalBytes / 1024 / 1024).toFixed(2)} MB written`);
719
+ }
720
+
721
+ async function runCompress(options /* , command */) {
722
+ const lazy = options?.lazy === true;
723
+ if (!lazy) {
724
+ // S1 fix (skill-review 2026-05-28): exit 2 on missing --lazy so
725
+ // scripts can distinguish "command ran" from "command rejected its
726
+ // input". Matches NOTICE_PREFIX convention elsewhere in v0.1.0.
727
+ console.error(
728
+ `cmk compress: ${NOTICE_PREFIX} (the --lazy flag is required for v0.1.0; bare \`cmk compress\` is a v0.1.x candidate — see design §16)`,
729
+ );
730
+ process.exitCode = 2;
731
+ return;
732
+ }
733
+ const projectRoot = resolvePath(process.cwd());
734
+ const { HaikuViaAnthropicApi } = await import('./compressor.mjs');
735
+ try {
736
+ const backend = new HaikuViaAnthropicApi();
737
+ const r = await runLazyCompress({ projectRoot, backend });
738
+ if (r.action === 'error') {
739
+ console.error(
740
+ `cmk compress --lazy: error (${r.errorCategory ?? 'unknown'})${(r.errors && r.errors.length) ? `: ${r.errors.join('; ')}` : ''}`,
741
+ );
742
+ } else {
743
+ console.log(
744
+ `cmk compress --lazy: ${r.action}${r.reason ? ` (${r.reason})` : ''}${r.delegatedTo ? ` → ${r.delegatedTo}` : ''}`,
745
+ );
746
+ }
747
+ } catch (err) {
748
+ console.error(`cmk compress --lazy: unexpected error: ${err?.message ?? err}`);
749
+ }
750
+ }
751
+
752
+ async function runMcpDispatch(childName) {
753
+ if (childName === 'serve') {
754
+ const projectRoot = resolvePath(process.cwd());
755
+ const userDir = join(homedir(), '.claude-memory-kit');
756
+ // ALL logs to stderr per design §10.1; stdout is reserved for
757
+ // JSON-RPC messages handled by the SDK's StdioServerTransport.
758
+ // Don't console.log() anything before/during the server's run.
759
+ try {
760
+ await runMcpServer({ projectRoot, userDir });
761
+ } catch (err) {
762
+ console.error(`cmk mcp serve: fatal — ${err?.message ?? err}`);
763
+ process.exitCode = 2;
764
+ }
765
+ return;
766
+ }
767
+ console.error(`cmk mcp: ${NOTICE_PREFIX} (unknown sub-verb '${childName}')`);
768
+ process.exitCode = 2;
769
+ }
770
+
771
+ async function runQueueDispatch(childName) {
772
+ if (childName === 'conflicts') {
773
+ return runQueueConflicts();
774
+ }
775
+ if (childName === 'review') {
776
+ return runQueueReview();
777
+ }
778
+ console.log(`cmk queue: ${NOTICE_PREFIX} (unknown sub-verb '${childName}')`);
779
+ process.exitCode = 2;
780
+ }
781
+
782
+ /**
783
+ * Interactive resolver for `cmk queue conflicts`. Walks pending
784
+ * entries one-at-a-time, prints existing + proposed text, asks for
785
+ * one of `keep-old` / `keep-new` / `merge-both` / `skip`. Loops
786
+ * until the queue is empty or the user signals end-of-input.
787
+ *
788
+ * For v0.1.0 this resolves the PROJECT tier's conflicts queue (the
789
+ * canonical kit usage). User-tier / language-tier conflicts queues
790
+ * can be added when the kit's CLI gains explicit `--tier` selection.
791
+ */
792
+ async function runQueueConflicts() {
793
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
794
+ const askOnce = (q) =>
795
+ new Promise((resolve) => {
796
+ rl.question(q, (answer) => resolve(answer));
797
+ });
798
+
799
+ const VALID_DECISIONS = new Set(['keep-old', 'keep-new', 'merge-both', 'skip']);
800
+
801
+ const prompter = async ({
802
+ proposedId,
803
+ proposedText,
804
+ proposedTrust,
805
+ existingId,
806
+ existingText,
807
+ existingTrust,
808
+ similarity,
809
+ }) => {
810
+ console.log('');
811
+ console.log('─── pending conflict ──────────────────────────────────────');
812
+ console.log(`existing (${existingId}, trust=${existingTrust}): ${existingText}`);
813
+ console.log(`proposed (${proposedId}, trust=${proposedTrust}): ${proposedText}`);
814
+ console.log(`similarity: ${Number(similarity).toFixed(4)}`);
815
+ let decision = '';
816
+ while (!VALID_DECISIONS.has(decision)) {
817
+ const answer = await askOnce(
818
+ ` [keep-old / keep-new / merge-both / skip]: `,
819
+ );
820
+ decision = String(answer).trim();
821
+ if (!VALID_DECISIONS.has(decision)) {
822
+ console.log(
823
+ ` unknown answer "${decision}" — please type one of: keep-old, keep-new, merge-both, skip`,
824
+ );
825
+ }
826
+ }
827
+ return decision;
828
+ };
829
+
830
+ // merge-both wiring (Task 25b — closes Task 25's cross-layer
831
+ // composition gap). The proposed bullet from the conflict queue
832
+ // wasn't materialized as a Layer-2 per-fact file (it was routed to
833
+ // `queues/conflicts.md` instead of MEMORY.md). So we DON'T call
834
+ // `mergeFacts` (Layer 2); we call `mergeScratchpadBullets` (Layer 3)
835
+ // which operates directly on the scratchpad: combines the two
836
+ // bullet texts, writes a new merged bullet with a fresh canonical
837
+ // ID + provenance citing both sources, and mutates both originals'
838
+ // provenance to inject `superseded_by: <newId>`.
839
+ //
840
+ // For Task 25b's v0.1.0 ship, the merger assumes the kit's default
841
+ // scratchpad (MEMORY.md under `context/`). Section discovery: the
842
+ // queue entry written by `writeConflictEntry` does NOT capture the
843
+ // existing bullet's section heading — the merger receives `section`
844
+ // here as undefined from `resolveConflictQueue` (it doesn't pass
845
+ // through), and `mergeScratchpadBullets` falls back to
846
+ // `discoverSectionAt(lines, matchA.bulletIdx)` to find the heading
847
+ // by walking back from the existing bullet's position. That fallback
848
+ // is the documented contract for v0.1.0. Per-candidate section
849
+ // capture in `writeConflictEntry`'s queue entry is a v0.1.x
850
+ // candidate — see design §6.8 + §16.x notes for the trade-off.
851
+ const mergeFn = async ({
852
+ tier,
853
+ projectRoot,
854
+ userDir,
855
+ proposedId,
856
+ proposedText,
857
+ existingId,
858
+ existingText,
859
+ section,
860
+ }) => {
861
+ // Default scratchpad is MEMORY.md at the project tier. Section
862
+ // comes from the queue entry (which captured it at detect time).
863
+ const scratchpadPath = resolvePath(projectRoot, 'context', 'MEMORY.md');
864
+ const result = mergeScratchpadBullets({
865
+ tier,
866
+ projectRoot,
867
+ userDir,
868
+ scratchpadPath,
869
+ section,
870
+ idA: existingId,
871
+ idB: proposedId,
872
+ });
873
+ if (result.action === 'error') {
874
+ console.error(
875
+ `cmk queue conflicts: merge-both for ${existingId} + ${proposedId} failed: ${result.errors.join('; ')}`,
876
+ );
877
+ } else {
878
+ console.log(
879
+ ` merge-both → ${existingId} + ${proposedId} merged into ${result.id}`,
880
+ );
881
+ }
882
+ };
883
+
884
+ try {
885
+ const result = await resolveConflictQueue({
886
+ tier: 'P',
887
+ projectRoot: process.cwd(),
888
+ prompter,
889
+ mergeFn,
890
+ });
891
+ if (result.action === 'error') {
892
+ for (const e of result.errors) console.error(`cmk queue conflicts: ${e}`);
893
+ process.exitCode = 2;
894
+ return;
895
+ }
896
+ console.log('');
897
+ console.log(
898
+ `cmk queue conflicts: ${result.resolved} resolved (${result.kept_old} kept-old, ${result.kept_new} kept-new, ${result.merged} merged), ${result.skipped} skipped`,
899
+ );
900
+ } finally {
901
+ rl.close();
902
+ }
903
+ }
904
+
905
+ /**
906
+ * Interactive resolver for `cmk queue review` (Task 26). Walks
907
+ * pending medium-trust auto-extract candidates one-at-a-time, prints
908
+ * the candidate text + provenance, asks for one of `promote` /
909
+ * `discard` / `skip`. Loops until the queue is empty or user signals
910
+ * end-of-input.
911
+ *
912
+ * Resolves the PROJECT tier's review queue (the canonical kit usage).
913
+ */
914
+ async function runQueueReview() {
915
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
916
+ const askOnce = (q) =>
917
+ new Promise((resolve) => {
918
+ rl.question(q, (answer) => resolve(answer));
919
+ });
920
+
921
+ const VALID_DECISIONS = new Set(['promote', 'discard', 'skip']);
922
+
923
+ const prompter = async ({ id, text, ts, provenance }) => {
924
+ console.log('');
925
+ console.log('─── pending review ────────────────────────────────────────');
926
+ console.log(`id: ${id}`);
927
+ console.log(`ts: ${ts}`);
928
+ console.log(`text: ${text}`);
929
+ if (provenance) console.log(`prov: ${provenance.trim()}`);
930
+ let decision = '';
931
+ while (!VALID_DECISIONS.has(decision)) {
932
+ const answer = await askOnce(` [promote / discard / skip]: `);
933
+ decision = String(answer).trim();
934
+ if (!VALID_DECISIONS.has(decision)) {
935
+ console.log(
936
+ ` unknown answer "${decision}" — please type one of: promote, discard, skip`,
937
+ );
938
+ }
939
+ }
940
+ return decision;
941
+ };
942
+
943
+ try {
944
+ const result = await resolveReviewQueue({
945
+ tier: 'P',
946
+ projectRoot: process.cwd(),
947
+ prompter,
948
+ });
949
+ if (result.action === 'error') {
950
+ for (const e of result.errors) console.error(`cmk queue review: ${e}`);
951
+ process.exitCode = 2;
952
+ return;
953
+ }
954
+ console.log('');
955
+ console.log(
956
+ `cmk queue review: ${result.promoted} promoted, ${result.discarded} discarded, ${result.skipped} skipped${result.errors && result.errors.length ? `, ${result.errors.length} errored` : ''}`,
957
+ );
958
+ if (result.errors && result.errors.length) {
959
+ for (const err of result.errors) {
960
+ console.error(
961
+ ` error on ${err.id} (${err.decision}): ${err.errors.join('; ')}`,
962
+ );
963
+ }
964
+ }
965
+ } finally {
966
+ rl.close();
967
+ }
968
+ }
969
+
970
+ /** Helper: build a stub action that prints the standard notice + exits 0. */
971
+ function stub(name, milestone, extra) {
972
+ return function action(/* args, options */) {
973
+ const tail = milestone === 'v0.1.x' ? `${milestone}` : `milestone ${milestone}`;
974
+ const detail = extra ? ` (${extra})` : '';
975
+ console.log(`cmk ${name}: ${NOTICE_PREFIX} (${tail})${detail}`);
976
+ // commander already returns to its caller; explicit exit not needed for
977
+ // stubs and would prevent the test harness from running multiple cases.
978
+ };
979
+ }
980
+
981
+ /**
982
+ * @typedef {Object} ArgSpec
983
+ * @property {string} flags - commander argument string, e.g. "<id>" or "[query...]"
984
+ * @property {string} description
985
+ *
986
+ * @typedef {Object} OptionSpec
987
+ * @property {string} flags - commander option flags, e.g. "--dry-run"
988
+ * @property {string} description
989
+ *
990
+ * @typedef {Object} SubcommandChild
991
+ * @property {string} name
992
+ * @property {string} description
993
+ * @property {ArgSpec[]=} argSpec
994
+ * @property {OptionSpec[]=} optionSpec
995
+ *
996
+ * @typedef {Object} Subcommand
997
+ * @property {string} name
998
+ * @property {string} description
999
+ * @property {string|number} milestone - tasks.md task number or "v0.1.x"
1000
+ * @property {ArgSpec[]=} argSpec
1001
+ * @property {OptionSpec[]=} optionSpec
1002
+ * @property {SubcommandChild[]=} children
1003
+ * @property {(name?: string, ...rest: any[]) => void} action
1004
+ */
1005
+
1006
+ /** @type {Subcommand[]} */
1007
+ export const subcommands = [
1008
+ {
1009
+ name: 'install',
1010
+ description: 'cross-OS one-shot install — scaffold 3-tier dirs + inject .gitignore + drop kit CLAUDE.md block',
1011
+ milestone: 3,
1012
+ optionSpec: [
1013
+ { flags: '--force', description: 'allow downgrade of an existing newer-version CLAUDE.md block' },
1014
+ ],
1015
+ action: runInstall,
1016
+ },
1017
+ {
1018
+ name: 'uninstall',
1019
+ description: 'remove the CLAUDE.md kit block (preserves everything else byte-for-byte)',
1020
+ milestone: 4,
1021
+ action: runUninstall,
1022
+ },
1023
+ {
1024
+ name: 'init-user-tier',
1025
+ description: 'scaffold ~/.claude-memory-kit/ (honors $MEMORY_KIT_USER_DIR override)',
1026
+ milestone: 14,
1027
+ action: runInitUserTier,
1028
+ },
1029
+ {
1030
+ name: 'search',
1031
+ description: 'search memory — hybrid keyword + optional semantic',
1032
+ milestone: 30,
1033
+ argSpec: [{ flags: '<query...>', description: 'query terms' }],
1034
+ optionSpec: [
1035
+ { flags: '--mode <mode>', description: 'keyword | semantic | hybrid (default: keyword; semantic+hybrid need memsearch — Layer 5b install, not in v0.1.0)' },
1036
+ { flags: '--min-trust <level>', description: 'low | medium | high' },
1037
+ { flags: '--tier <tier>', description: 'U | P | L (filter to a single tier)' },
1038
+ { flags: '--since <date>', description: 'ISO date — exclude observations older than this' },
1039
+ { flags: '--limit <n>', description: 'max results (default: 20)' },
1040
+ { flags: '--include-tombstoned', description: 'include deleted observations in results' },
1041
+ ],
1042
+ action: runSearch,
1043
+ },
1044
+ {
1045
+ name: 'reindex',
1046
+ description: 'rebuild the markdown INDEX.md pointer index for the project tier',
1047
+ milestone: 8,
1048
+ optionSpec: [
1049
+ { flags: '--boot', description: 'incremental — re-index only changed files' },
1050
+ { flags: '--full', description: 'drop the cache and rebuild from scratch' },
1051
+ ],
1052
+ action: runReindex,
1053
+ },
1054
+ {
1055
+ name: 'doctor',
1056
+ description: 'run health checks HC-1..HC-9; print structured report with self-repair commands',
1057
+ milestone: 37,
1058
+ action: runDoctorCli,
1059
+ },
1060
+ {
1061
+ name: 'config',
1062
+ description: 'settings access (per design §7.2)',
1063
+ milestone: 'v0.1.x',
1064
+ optionSpec: [
1065
+ { flags: '--show-origin <key>', description: 'print where each value comes from (project / user / local tier)' },
1066
+ ],
1067
+ children: [
1068
+ {
1069
+ name: 'get',
1070
+ description: 'print the resolved value of a setting',
1071
+ argSpec: [{ flags: '<key>', description: 'setting key (dotted path)' }],
1072
+ },
1073
+ {
1074
+ name: 'set',
1075
+ description: 'set a setting in the current tier',
1076
+ argSpec: [
1077
+ { flags: '<key>', description: 'setting key (dotted path)' },
1078
+ { flags: '<value>', description: 'new value' },
1079
+ ],
1080
+ },
1081
+ ],
1082
+ action: stub('config', 'v0.1.x'),
1083
+ },
1084
+ {
1085
+ name: 'view',
1086
+ description: 'open a local markdown viewer at 127.0.0.1:37778',
1087
+ milestone: 'v0.1.x',
1088
+ optionSpec: [{ flags: '--port <n>', description: 'override default port 37778' }],
1089
+ action: stub('view', 'v0.1.x'),
1090
+ },
1091
+ {
1092
+ name: 'import-anthropic-memory',
1093
+ description: "merge useful bullets from Anthropic's auto-memory into this project's MEMORY.md",
1094
+ milestone: 38,
1095
+ optionSpec: [
1096
+ { flags: '--dry-run', description: 'print proposed additions without modifying files' },
1097
+ { flags: '--yes', description: 'apply every proposal without prompting (v0.1.0 requires explicit --yes; interactive y/N is v0.1.x)' },
1098
+ ],
1099
+ action: runImportAnthropicMemory,
1100
+ },
1101
+ {
1102
+ name: 'transcripts',
1103
+ description: "extract clean markdown transcripts from Claude Code session jsonls under ~/.claude/projects/",
1104
+ milestone: 38,
1105
+ children: [
1106
+ {
1107
+ name: 'extract',
1108
+ description: 'extract one or more session jsonls into clean markdown',
1109
+ optionSpec: [
1110
+ { flags: '--session <uuid-suffix>', description: 'extract a specific session by uuid suffix (substring match across all slugs)' },
1111
+ { flags: '--slug <slug>', description: 'extract all sessions under a specific Anthropic slug' },
1112
+ { flags: '--since <YYYY-MM-DD>', description: 'extract only sessions with mtime >= this date' },
1113
+ { flags: '--output <dir>', description: 'output directory (default: <cwd>/transcripts-extracted/)' },
1114
+ { flags: '--include-thinking', description: 'retain the agent\'s [thinking] blocks (omitted by default)' },
1115
+ ],
1116
+ action: (options) => runTranscriptsDispatch('extract', options),
1117
+ },
1118
+ ],
1119
+ },
1120
+ {
1121
+ name: 'trust',
1122
+ description: 'manually override the trust level of an observation (fact file or scratchpad bullet)',
1123
+ milestone: 15,
1124
+ argSpec: [
1125
+ { flags: '<id>', description: 'citation ID (e.g. P-S79MJHFN)' },
1126
+ { flags: '<level>', description: 'low | medium | high' },
1127
+ ],
1128
+ action: runTrust,
1129
+ },
1130
+ {
1131
+ name: 'lessons',
1132
+ description: 'promote project-tier observations to the user-tier LESSONS.md',
1133
+ milestone: 'v0.1.x',
1134
+ children: [
1135
+ {
1136
+ name: 'promote',
1137
+ description: 'move a project observation to ~/.claude-memory-kit/LESSONS.md',
1138
+ argSpec: [{ flags: '<id>', description: 'citation ID' }],
1139
+ },
1140
+ ],
1141
+ action: stub('lessons', 'v0.1.x'),
1142
+ },
1143
+ {
1144
+ name: 'queue',
1145
+ description: 'review medium-trust auto-extracts and resolve conflicting observations',
1146
+ milestone: 25,
1147
+ children: [
1148
+ { name: 'review', description: 'walk pending medium-trust auto-extracts; promote / discard / skip' },
1149
+ { name: 'conflicts', description: 'walk pending conflicts; keep-old / keep-new / merge-both / skip' },
1150
+ ],
1151
+ action: runQueueDispatch,
1152
+ },
1153
+ {
1154
+ name: 'forget',
1155
+ description: 'tombstone an observation (preserves audit trail; never silent delete)',
1156
+ milestone: 9,
1157
+ argSpec: [{ flags: '<id-or-query>', description: 'citation ID or substring query against canonical text' }],
1158
+ optionSpec: [
1159
+ { flags: '--yes', description: 'skip the interactive confirmation prompt (required in v0.1.0; interactive prompt is a v0.1.x follow-up)' },
1160
+ { flags: '--reason <text>', description: 'deletion reason recorded in the tombstone frontmatter' },
1161
+ { flags: '--deleted-by <enum>', description: 'who initiated the deletion (default: user-explicit)' },
1162
+ ],
1163
+ action: runForget,
1164
+ },
1165
+ {
1166
+ name: 'purge',
1167
+ description: 'permanent deletion of an observation — rare; bypasses the tombstone audit trail',
1168
+ milestone: 'v0.1.x',
1169
+ argSpec: [{ flags: '<id>', description: 'citation ID' }],
1170
+ optionSpec: [{ flags: '--hard', description: 'required confirmation flag' }],
1171
+ action: stub('purge', 'v0.1.x', 'use `cmk forget` for normal deletion; this is for emergencies only'),
1172
+ },
1173
+ {
1174
+ name: 'roll',
1175
+ description: 'force-roll the rolling-window pipeline (same internals as SessionEnd / cron)',
1176
+ milestone: 39,
1177
+ optionSpec: [{ flags: '--scope <scope>', description: 'now (default — task 22 compress-session) | today (task 33 daily-distill) | recent (task 34 weekly-curate)' }],
1178
+ action: runRollCli,
1179
+ },
1180
+ {
1181
+ name: 'repair',
1182
+ description: 'idempotent self-repair — re-register hooks, reset stale locks, rebuild index',
1183
+ milestone: 39,
1184
+ optionSpec: [
1185
+ { flags: '--hooks', description: 're-register hooks from template (merges kit hooks into .claude/settings.json)' },
1186
+ { flags: '--locks', description: 'clear stale locks (>1h old by default)' },
1187
+ { flags: '--index', description: 'invoke `cmk reindex --full`' },
1188
+ { flags: '--all', description: 'run all three repairs in order (default if no scope flag given)' },
1189
+ ],
1190
+ action: runRepairCli,
1191
+ },
1192
+ {
1193
+ name: 'daily-distill',
1194
+ description: 'run the daily-distill pipeline once (invoked by host scheduler; humans normally use `cmk register-crons`)',
1195
+ milestone: 33,
1196
+ action: runDailyDistill,
1197
+ },
1198
+ {
1199
+ name: 'weekly-curate',
1200
+ description: 'run the weekly-curate pipeline once: archive today-*.md older than 7 days, dedup bullets, rebuild recent.md (invoked by host scheduler)',
1201
+ milestone: 34,
1202
+ action: runWeeklyCurate,
1203
+ },
1204
+ {
1205
+ name: 'compress',
1206
+ description: 'lazy-on-read compression fallback for no-cron environments. Use `--lazy` to delegate to daily-distill or weekly-curate based on staleness (typically invoked detached from the SessionStart hook).',
1207
+ milestone: 35,
1208
+ optionSpec: [
1209
+ { flags: '--lazy', description: 'run the lazy-compress cycle (the v0.1.0 supported invocation)' },
1210
+ ],
1211
+ action: runCompress,
1212
+ },
1213
+ {
1214
+ name: 'register-crons',
1215
+ description: 'register both daily-distill (23:00) and weekly-curate (Sun 09:00) cron jobs with the host scheduler',
1216
+ milestone: 33,
1217
+ optionSpec: [
1218
+ { flags: '--dry-run', description: 'print the platform-detected command without executing' },
1219
+ { flags: '--unregister', description: 'remove both daily-distill and weekly-curate entries instead of adding them' },
1220
+ ],
1221
+ action: runRegisterCrons,
1222
+ },
1223
+ {
1224
+ name: 'mcp',
1225
+ description: 'run the MCP server over stdio (invoked by Claude Code, not by humans)',
1226
+ milestone: 31,
1227
+ children: [{ name: 'serve', description: 'start the stdio MCP server; JSON-RPC on stdin/stdout' }],
1228
+ action: runMcpDispatch,
1229
+ },
1230
+ {
1231
+ name: 'version',
1232
+ description: 'print the cmk version (alias for --version)',
1233
+ milestone: 'always',
1234
+ action: function action() {
1235
+ // version is special — never a stub. Print and continue.
1236
+ // The shared --version flag is wired in index.mjs; this verb
1237
+ // exists for parity with design §12 and prints the same string.
1238
+ // Implementation note: we resolve the version from package.json
1239
+ // already done at program-build time, so this can simply call into
1240
+ // the main flag handler via process.exit after printing.
1241
+ // For v0.1.0 stub-only milestone, defer the actual print to the
1242
+ // top-level --version handler.
1243
+ console.log(`cmk version: see \`cmk --version\``);
1244
+ },
1245
+ },
1246
+ ];
1247
+
1248
+ /** Names list — handy for tests + help-output assertions. */
1249
+ export const subcommandNames = subcommands.map((s) => s.name);
1250
+
1251
+ /** Notice string — exposed so tests can assert it appears in every stub output. */
1252
+ export const STUB_NOTICE_PREFIX = NOTICE_PREFIX;