stackmem 1.0.25 → 1.0.27

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/src/cli.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  import nodeFs from "node:fs";
3
3
  import nodeOs from "node:os";
4
4
  import nodePath from "node:path";
5
+ import { execFileSync } from "node:child_process";
5
6
  import { randomInt } from "node:crypto";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  import { createInterface } from "node:readline/promises";
8
9
  import { analyzeCodebase } from "./ast-index.js";
9
10
  import { VAULT_URL } from "./config.js";
11
+ import { defaultConfigFileContents, readStackmemConfig, STACKMEM_CONFIG_FILENAME } from "./config-file.js";
10
12
  import { getDeviceId } from "./device.js";
11
13
  import {
12
14
  applyContextBudget,
@@ -20,12 +22,27 @@ import {
20
22
  indicatesFailedApproach,
21
23
  isAuthRelatedFile,
22
24
  isCodeFile,
25
+ hasTokenOverlap,
23
26
  isQualityMessage,
27
+ parseUtcDate,
24
28
  redactSecrets,
25
29
  scoreMemoryRelevance,
30
+ tokenize,
26
31
  } from "./scoring.js";
27
32
  import { supabase } from "./storage.js";
28
33
 
34
+ // Checked as early as ESM import hoisting allows — before any command
35
+ // dispatch, config loading, or network call — so an unsupported Node
36
+ // version fails with a clear message instead of an obscure syntax or
37
+ // API error from deeper in the dependency chain.
38
+ const NODE_MAJOR_VERSION = Number(process.versions.node.split(".")[0]);
39
+ if (NODE_MAJOR_VERSION < 18) {
40
+ console.error(
41
+ `Error: stackmem requires Node 18 or higher.\nYou are running Node ${process.versions.node}.\nPlease upgrade: https://nodejs.org`
42
+ );
43
+ process.exit(1);
44
+ }
45
+
29
46
  const CONTEXT_BUDGET_TOKENS = 2000;
30
47
  const COMPRESSION_THRESHOLD = 20;
31
48
  const CLI_PATH = fileURLToPath(import.meta.url);
@@ -41,7 +58,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
41
58
  const STACKMEM_HOOK_MARKER = "# stackmem-managed";
42
59
  const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
43
60
  const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
44
- const HOOK_BACKUP_FILENAME = "post-commit.pre-stackmem";
61
+ const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
45
62
 
46
63
  const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
47
64
  type MemoryType = (typeof MEMORY_TYPES)[number];
@@ -55,22 +72,30 @@ const TYPE_LABELS: Record<MemoryType, string> = {
55
72
 
56
73
  function usage(): never {
57
74
  console.error(`Usage:
58
- cm start [project]
75
+ cm start [project] [--hook]
59
76
  cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
60
77
  cm fix [project] <problem> <solution>
61
78
  cm analyze [project] <path>
62
79
  cm search [--project <name>] <query>
63
80
  cm resolve [project] <memory-id>
81
+ cm pin [project] <memory-id>
82
+ cm unpin [project] <memory-id>
83
+ cm review [--project <name>]
84
+ cm edit [project] <memory-id>
64
85
  cm delete [project] <memory-id>
65
86
  cm inspect [project] <memory-id>
66
87
  cm compress [project]
67
- cm init <project> <path>
88
+ cm init <project> <path> [--config]
68
89
  cm uninstall [project] [path]
69
90
  cm link
91
+ cm open [--project <name>]
92
+ cm purge [--project <name>] [--all]
70
93
  cm sync
71
- cm context [--project <name>] <task>
72
- cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
94
+ cm context [--project <name>] <task> [--hook]
95
+ cm why [--project <name>] <file>
96
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
73
97
  cm doctor [--fix]
98
+ cm status [--project <name>]
74
99
  cm undo [project]
75
100
  cm export [filename]
76
101
  cm import [project] <filename>
@@ -92,11 +117,13 @@ function cmdHelp() {
92
117
  [project] is optional everywhere below if a .stackmem file exists in
93
118
  the current directory (written by 'cm init'). Otherwise pass it explicitly.
94
119
 
95
- cm start [project]
120
+ cm start [project] [--hook]
96
121
  Load all unresolved memories for a project, sorted by decay
97
122
  score and capped at a 2000 token context budget, grouped by
98
123
  type (decisions, constraints, discoveries, rejections), with
99
- linked memory ids shown under each entry.
124
+ linked memory ids shown under each entry. --hook switches to
125
+ plain-text output (no ids, scores, or separator lines) for use
126
+ as a Claude Code SessionStart hook command — see cm init.
100
127
 
101
128
  cm save [project] <type> <content> [--force]
102
129
  Save a new memory. <type> is one of: decision, rejection,
@@ -120,6 +147,26 @@ function cmdHelp() {
120
147
  cm resolve [project] <memory-id>
121
148
  Mark a memory as resolved.
122
149
 
150
+ cm pin [project] <memory-id>
151
+ Pin a memory: it scores 999 in decay ranking (always sorts
152
+ first, never decays) and shows in its own "Pinned" section at
153
+ the top of 'cm start', ahead of its normal type group.
154
+
155
+ cm unpin [project] <memory-id>
156
+ Un-pin a memory, returning it to normal decay-based ranking.
157
+
158
+ cm review [--project <name>]
159
+ Walk through suggested memories one at a time (hook-captured
160
+ memories start as suggested — see cm save/cm init). For each:
161
+ (k)eep promotes it to permanent, (e)dit opens $EDITOR (or an
162
+ inline prompt) for new content then keeps it, (d)rop deletes
163
+ it, (s)kip leaves it suggested for next time, (q)uit stops and
164
+ reports progress so far. Prints a kept/dropped/skipped summary.
165
+
166
+ cm edit [project] <memory-id>
167
+ Open a memory's content in $EDITOR (or an inline prompt if
168
+ $EDITOR isn't set), save the result.
169
+
123
170
  cm delete [project] <memory-id>
124
171
  Delete a memory and any memory_links referencing it.
125
172
 
@@ -134,7 +181,7 @@ function cmdHelp() {
134
181
  Cluster related unresolved memories (needs 20+) and collapse
135
182
  clusters of 3 or more into a single summary memory.
136
183
 
137
- cm init <project> <path>
184
+ cm init <project> <path> [--config]
138
185
  Install a git post-commit hook in the repo at <path>, register
139
186
  the stackmem MCP server with Claude Code, write a CLAUDE.md,
140
187
  .cursorrules, .windsurfrules, and .stackmem file, and seed
@@ -142,7 +189,12 @@ function cmdHelp() {
142
189
  .windsurfrules are pre-filled with the project's current
143
190
  memories (for Cursor and Windsurf) and skipped if they already
144
191
  exist. The post-commit hook keeps them fresh afterward by
145
- running 'cm sync' on every commit.
192
+ running 'cm sync' on every commit — on disk only, never
193
+ committed, so they don't double your commit count or conflict
194
+ across branches. Both files are added to .gitignore. With
195
+ --config, also writes a default .stackmem.json (ignore patterns,
196
+ tokenBudget, decayLambda, captureTypes, minQualityScore — see
197
+ src/config-file.ts) if one doesn't already exist.
146
198
 
147
199
  cm uninstall [project] [path]
148
200
  Remove everything 'cm init' set up at <path> (defaults to the
@@ -162,19 +214,44 @@ function cmdHelp() {
162
214
  single-use and expires after 10 minutes; polling stops when it
163
215
  does. Links are per-device, so run this once on each machine.
164
216
 
217
+ cm open [--project <name>]
218
+ Open the hosted vault's page for this project in the default
219
+ browser (open on Mac, xdg-open on Linux, start on Windows).
220
+
221
+ cm purge [--project <name>] [--all]
222
+ Permanently delete memories, fixes, and memory_links — with a
223
+ typed confirmation first, never deletes on a flag alone.
224
+ --project (or the one in .stackmem): deletes that project's
225
+ data only, after you type the exact project name to confirm.
226
+ --all: deletes everything for this device's device_id across
227
+ every project, after you type DELETE to confirm. Prints the
228
+ count of deleted records, or "Confirmation did not match" if
229
+ you abort.
230
+
165
231
  cm sync
166
232
  Regenerate .cursorrules and .windsurfrules from the project's
167
233
  current unresolved memories (decay-ranked, capped at a 2000
168
234
  token budget) and its 5 most recent open execution_log fixes,
169
- overwriting both files. Project is read from .stackmem.
235
+ overwriting both files on disk only (never git add/commit).
236
+ Project is read from .stackmem.
170
237
 
171
- cm context [--project <name>] <task>
238
+ cm context [--project <name>] <task> [--hook]
172
239
  Return only the top 5 memories most relevant to a specific
173
240
  task, ranked by a blend of task relevance and decay score,
174
241
  as a markdown block capped at a 2000 token budget. Use
175
242
  --project to target a project other than the one in .stackmem.
176
-
177
- cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
243
+ <task> is optional with --hook (ranks by decay alone) — used
244
+ as a Claude Code PreCompact hook command; see cm init.
245
+
246
+ cm why [--project <name>] <file>
247
+ Show memories tied to a file: matched by Item 6's file_paths
248
+ column (hook-captured commits that touched it) plus a plain
249
+ content search for the file's path or basename, ranked by decay
250
+ score and grouped by type. Prints a guidance message instead if
251
+ nothing matches. Use --project to target a project other than
252
+ the one in .stackmem.
253
+
254
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
178
255
  Capture what happened in a session and extract memories: each
179
256
  error becomes an unsolved execution_log entry, each approach
180
257
  containing "failed", "didn't work", "reverted", or "switched
@@ -184,6 +261,11 @@ function cmdHelp() {
184
261
  Accepts the same fields as JSON on stdin instead of flags
185
262
  (project, summary, errors_encountered, files_touched,
186
263
  approaches_tried) when stdin is piped rather than a terminal.
264
+ --hook is accepted for use as a Claude Code Stop hook command
265
+ (see cm init) but is currently a no-op: Claude Code pipes its
266
+ own hook payload on stdin, not this command's expected shape,
267
+ so a Stop-triggered run captures 0 memories until a future
268
+ phase parses the session transcript directly.
187
269
 
188
270
  cm doctor [--fix]
189
271
  Check every integration point in the current directory — backend
@@ -196,6 +278,12 @@ function cmdHelp() {
196
278
  — those print guidance instead. Reports "Fixed: <item>" or
197
279
  "Could not auto-fix: <item>" for each failing check.
198
280
 
281
+ cm status [--project <name>]
282
+ One-screen project summary: memory/pinned/suggested counts,
283
+ recorded fixes, the active token budget (and whether it's the
284
+ default or from .stackmem.json), time since the last capture,
285
+ and hook/MCP/backend/editor registration status.
286
+
199
287
  cm undo [project]
200
288
  Delete the most recently saved unresolved memory (and any
201
289
  memory_links referencing it) for a project.
@@ -224,11 +312,35 @@ function fail(message: string): never {
224
312
 
225
313
  const STACKMEM_FILE = ".stackmem";
226
314
 
315
+ const MONOREPO_PARENT_LOOKUP_DEPTH = 3;
316
+
317
+ // Checks cwd, then up to 3 parent directories, for a .stackmem file — a
318
+ // monorepo subpackage (e.g. packages/api) won't have its own .stackmem,
319
+ // but the repo root will. When found in an ancestor, the project name
320
+ // gets the intervening path segments appended, e.g. "myapp/packages/api"
321
+ // — so a subpackage still reads/writes under its own namespace within
322
+ // the shared project, rather than silently resolving to just "myapp".
227
323
  function readProjectFromFile(): string | null {
228
- const stackmemPath = nodePath.join(process.cwd(), STACKMEM_FILE);
229
- if (!nodeFs.existsSync(stackmemPath)) return null;
230
- const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
231
- return content.length > 0 ? content : null;
324
+ let dir = process.cwd();
325
+ const subdirSegments: string[] = [];
326
+
327
+ for (let depth = 0; depth <= MONOREPO_PARENT_LOOKUP_DEPTH; depth++) {
328
+ const stackmemPath = nodePath.join(dir, STACKMEM_FILE);
329
+ if (nodeFs.existsSync(stackmemPath)) {
330
+ const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
331
+ if (content.length === 0) return null;
332
+ return subdirSegments.length > 0
333
+ ? `${content}/${subdirSegments.reverse().join("/")}`
334
+ : content;
335
+ }
336
+
337
+ const parent = nodePath.dirname(dir);
338
+ if (parent === dir) break; // reached the filesystem root
339
+ subdirSegments.push(nodePath.basename(dir));
340
+ dir = parent;
341
+ }
342
+
343
+ return null;
232
344
  }
233
345
 
234
346
  // Catches the common habit-slip of typing `cm fix . "problem" "solution"` by
@@ -293,33 +405,208 @@ function splitProjectFromVariadic(args: string[]): { project: string | null; res
293
405
  return { project: null, rest: args };
294
406
  }
295
407
 
408
+ // Generic "--flag value" extractor for internal/provenance flags (not
409
+ // meant to be remembered by end users — only the hook and MCP server pass
410
+ // these). Unlike splitProjectFromVariadic's --project, these don't need
411
+ // special positional disambiguation, so one generic helper covers all of
412
+ // them: --commit-sha, --author, --source, --confidence, --files.
413
+ function extractValueFlag(args: string[], flag: string): { value: string | null; rest: string[] } {
414
+ const idx = args.indexOf(flag);
415
+ if (idx === -1 || args[idx + 1] === undefined) return { value: null, rest: args };
416
+ return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
417
+ }
418
+
296
419
  // --- cm start ----------------------------------------------------------
297
420
 
298
- async function cmdStart(project: string) {
421
+ const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
422
+ const BRANCH_NAME_BOOST = 1.2;
423
+ const RECENT_FILES_BOOST = 1.3;
424
+ const PR_TITLE_BOOST = 1.2;
425
+
426
+ // null means "signal unavailable" (git/gh failed, not enough history, gh
427
+ // not installed, no open PR, etc.) — every one of these is a soft skip,
428
+ // never an error, per the task spec.
429
+ function getBranchNameTokens(): Set<string> | null {
430
+ let branch: string;
431
+ try {
432
+ // symbolic-ref (not rev-parse --abbrev-ref) specifically because it
433
+ // fails on a detached HEAD rather than returning the literal string
434
+ // "HEAD" — that failure is the signal this needs to distinguish a
435
+ // real detached-HEAD state from "not a git repo at all" below.
436
+ branch = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
437
+ encoding: "utf-8",
438
+ stdio: ["ignore", "pipe", "ignore"],
439
+ }).trim();
440
+ } catch {
441
+ try {
442
+ execFileSync("git", ["rev-parse", "HEAD"], { stdio: "pipe" });
443
+ // HEAD resolves to a real commit but symbolic-ref failed — genuinely
444
+ // detached, not a missing repo. Still a valid state to rank in, not
445
+ // a reason to skip the signal entirely.
446
+ branch = "detached";
447
+ } catch {
448
+ return null;
449
+ }
450
+ }
451
+
452
+ const tokens = tokenize(branch);
453
+ return tokens.size > 0 ? tokens : null;
454
+ }
455
+
456
+ function getRecentlyTouchedFiles(): Set<string> | null {
457
+ try {
458
+ const isShallow = execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
459
+ encoding: "utf-8",
460
+ stdio: ["ignore", "pipe", "ignore"],
461
+ }).trim();
462
+ // A shallow clone can't look back RECENT_COMMITS_FOR_FILE_SIGNAL
463
+ // commits — degrade gracefully rather than attempting (and either
464
+ // failing, or silently returning an incomplete picture of "recent").
465
+ if (isShallow === "true") return null;
466
+ } catch {
467
+ // --is-shallow-repository itself failing (very old git, or no repo at
468
+ // all) isn't a reason to skip on its own — the lookback below will
469
+ // fail (and get caught) or succeed on its own merits either way.
470
+ }
471
+
472
+ try {
473
+ const output = execFileSync(
474
+ "git",
475
+ ["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"],
476
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
477
+ );
478
+ const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
479
+ return files.length > 0 ? new Set(files) : null;
480
+ } catch {
481
+ // Fewer than RECENT_COMMITS_FOR_FILE_SIGNAL commits in history, or
482
+ // not a git repo at all — skip gracefully.
483
+ return null;
484
+ }
485
+ }
486
+
487
+ function getOpenPrTitleTokens(): Set<string> | null {
488
+ try {
489
+ const title = execFileSync("gh", ["pr", "view", "--json", "title", "-q", ".title"], {
490
+ encoding: "utf-8",
491
+ stdio: ["ignore", "pipe", "ignore"],
492
+ }).trim();
493
+ if (!title) return null;
494
+ const tokens = tokenize(title);
495
+ return tokens.size > 0 ? tokens : null;
496
+ } catch {
497
+ // gh not installed, not authenticated, or no open PR for this branch.
498
+ return null;
499
+ }
500
+ }
501
+
502
+ function fileListOverlaps(filePaths: unknown, recentFiles: Set<string>): boolean {
503
+ if (!Array.isArray(filePaths)) return false;
504
+ return filePaths.some((candidate) => {
505
+ if (typeof candidate !== "string") return false;
506
+ if (recentFiles.has(candidate)) return true;
507
+ const base = candidate.split("/").pop();
508
+ for (const recent of recentFiles) {
509
+ if (recent.split("/").pop() === base) return true;
510
+ }
511
+ return false;
512
+ });
513
+ }
514
+
515
+ // Plain-text, no ids/scores/box-drawing — meant to be injected directly
516
+ // into a Claude Code SessionStart hook's context, not read by a human at
517
+ // a terminal (that's the default, non---hook rendering below).
518
+ function printStartHookOutput(
519
+ project: string,
520
+ pinnedMemories: any[],
521
+ unpinnedMemories: any[],
522
+ budgetedFixes: { problem: string; solution: string | null }[]
523
+ ): void {
524
+ console.log(`# Project memory: ${project}`);
525
+
526
+ if (pinnedMemories.length > 0) {
527
+ console.log("");
528
+ console.log("## Pinned");
529
+ for (const memory of pinnedMemories) {
530
+ console.log(`- ${memory.content}`);
531
+ }
532
+ }
533
+
534
+ for (const type of MEMORY_TYPES) {
535
+ const group = unpinnedMemories.filter((m) => m.type === type);
536
+ if (group.length === 0) continue;
537
+
538
+ console.log("");
539
+ console.log(`## ${TYPE_LABELS[type]}`);
540
+ for (const memory of group) {
541
+ console.log(`- ${memory.content}`);
542
+ }
543
+ }
544
+
545
+ if (budgetedFixes.length > 0) {
546
+ console.log("");
547
+ console.log("## Past Fixes");
548
+ for (const fix of budgetedFixes) {
549
+ console.log(`- ${fix.problem} -> ${fix.solution ?? "(pending)"}`);
550
+ }
551
+ }
552
+ }
553
+
554
+ async function cmdStart(project: string, hookMode = false) {
299
555
  const { data, error } = await supabase
300
556
  .from("memories")
301
557
  .select("*")
302
558
  .eq("project", project)
303
- .eq("resolved", false);
559
+ .eq("resolved", false)
560
+ .eq("suggested", false);
304
561
 
305
562
  if (error) fail(`Failed to load memories: ${error.message}`);
306
563
 
307
564
  const memories = data ?? [];
308
565
  if (memories.length === 0) {
309
- console.log(`No memories found for "${project}".`);
310
- console.log("");
311
- console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
566
+ if (!hookMode) {
567
+ console.log(`No memories found for "${project}".`);
568
+ console.log("");
569
+ console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
570
+ }
312
571
  return;
313
572
  }
314
573
 
574
+ // Task-aware re-ranking: boost memories relevant to what's actively
575
+ // being worked on, multiplicatively on top of the base decay score.
576
+ // Every signal degrades to null (no boost, not an error) if the
577
+ // repo/branch/gh state it needs isn't available.
578
+ const branchTokens = getBranchNameTokens();
579
+ const recentFiles = getRecentlyTouchedFiles();
580
+ const prTitleTokens = getOpenPrTitleTokens();
581
+
582
+ const config = readStackmemConfig();
583
+ const tokenBudget = config.tokenBudget;
584
+
315
585
  const scoredMemories = memories
316
- .map((memory) => ({
317
- ...memory,
318
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
319
- }))
586
+ .map((memory) => {
587
+ let decayScore = computeDecayScore(
588
+ parseUtcDate(memory.created_at),
589
+ memory.access_count ?? 0,
590
+ memory.pinned ?? false,
591
+ config.decayLambda
592
+ );
593
+
594
+ const contentTokens = tokenize(memory.content);
595
+ if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
596
+ decayScore *= BRANCH_NAME_BOOST;
597
+ }
598
+ if (recentFiles && fileListOverlaps(memory.file_paths, recentFiles)) {
599
+ decayScore *= RECENT_FILES_BOOST;
600
+ }
601
+ if (prTitleTokens && hasTokenOverlap(contentTokens, prTitleTokens)) {
602
+ decayScore *= PR_TITLE_BOOST;
603
+ }
604
+
605
+ return { ...memory, decay_score: decayScore };
606
+ })
320
607
  .sort((a, b) => b.decay_score - a.decay_score);
321
608
 
322
- const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
609
+ const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
323
610
  const omittedCount = scoredMemories.length - budgetedMemories.length;
324
611
  let tokensUsed = budgetedMemories.reduce(
325
612
  (sum, memory) => sum + estimateTokens(memory.content),
@@ -336,12 +623,12 @@ async function cmdStart(project: string) {
336
623
 
337
624
  if (fixError) fail(`Failed to load execution log: ${fixError.message}`);
338
625
 
339
- // Fixes share the same 2000-token budget as memories, which already
340
- // consumed their portion above — fixes only get whatever room is left.
626
+ // Fixes share the same token budget as memories, which already consumed
627
+ // their portion above — fixes only get whatever room is left.
341
628
  const budgetedFixes: { problem: string; solution: string | null }[] = [];
342
629
  for (const fix of fixData ?? []) {
343
630
  const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
344
- if (tokensUsed + tokens > CONTEXT_BUDGET_TOKENS) break;
631
+ if (tokensUsed + tokens > tokenBudget) break;
345
632
  budgetedFixes.push(fix);
346
633
  tokensUsed += tokens;
347
634
  }
@@ -364,43 +651,63 @@ async function cmdStart(project: string) {
364
651
  linkedIdsByMemory.get(link.target_id)!.add(link.source_id);
365
652
  }
366
653
 
367
- console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
654
+ const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
655
+ const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
368
656
 
369
- for (const type of MEMORY_TYPES) {
370
- const group = budgetedMemories.filter((m) => m.type === type);
371
- if (group.length === 0) continue;
372
-
373
- console.log(`${TYPE_LABELS[type]} (${group.length})`);
374
- console.log("-".repeat(TYPE_LABELS[type].length + 4));
375
- for (const memory of group) {
376
- console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
377
- const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
378
- if (linked.length > 0) {
379
- console.log(` linked: ${linked.join(", ")}`);
657
+ if (hookMode) {
658
+ printStartHookOutput(project, pinnedMemories, unpinnedMemories, budgetedFixes);
659
+ } else {
660
+ console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
661
+
662
+ if (pinnedMemories.length > 0) {
663
+ console.log(`Pinned (${pinnedMemories.length})`);
664
+ console.log("-".repeat(11));
665
+ for (const memory of pinnedMemories) {
666
+ console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
667
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
668
+ if (linked.length > 0) {
669
+ console.log(` linked: ${linked.join(", ")}`);
670
+ }
380
671
  }
672
+ console.log("");
381
673
  }
382
- console.log("");
383
- }
384
674
 
385
- if (budgetedFixes.length > 0) {
386
- console.log(`Past Fixes (${budgetedFixes.length})`);
387
- console.log("-".repeat(14));
388
- for (const fix of budgetedFixes) {
389
- console.log(` Problem: ${fix.problem}`);
390
- console.log(` Solution: ${fix.solution ?? "(pending)"}`);
675
+ for (const type of MEMORY_TYPES) {
676
+ const group = unpinnedMemories.filter((m) => m.type === type);
677
+ if (group.length === 0) continue;
678
+
679
+ console.log(`${TYPE_LABELS[type]} (${group.length})`);
680
+ console.log("-".repeat(TYPE_LABELS[type].length + 4));
681
+ for (const memory of group) {
682
+ console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
683
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
684
+ if (linked.length > 0) {
685
+ console.log(` linked: ${linked.join(", ")}`);
686
+ }
687
+ }
391
688
  console.log("");
392
689
  }
393
- }
394
690
 
395
- console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
396
- if (omittedCount > 0) {
397
- console.log(`${omittedCount} memories omitted (budget exceeded)`);
398
- }
691
+ if (budgetedFixes.length > 0) {
692
+ console.log(`Past Fixes (${budgetedFixes.length})`);
693
+ console.log("-".repeat(14));
694
+ for (const fix of budgetedFixes) {
695
+ console.log(` Problem: ${fix.problem}`);
696
+ console.log(` Solution: ${fix.solution ?? "(pending)"}`);
697
+ console.log("");
698
+ }
699
+ }
399
700
 
400
- const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
401
- if (!hasTechStackMemory) {
402
- console.log("");
403
- console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
701
+ console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
702
+ if (omittedCount > 0) {
703
+ console.log(`${omittedCount} memories omitted (budget exceeded)`);
704
+ }
705
+
706
+ const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
707
+ if (!hasTechStackMemory) {
708
+ console.log("");
709
+ console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
710
+ }
404
711
  }
405
712
 
406
713
  const incrementResults = await Promise.all(
@@ -446,7 +753,26 @@ async function confirmSupersede(oldId: string, newContent: string): Promise<bool
446
753
  return false;
447
754
  }
448
755
 
449
- async function cmdSave(project: string, type: string, content: string, force = false) {
756
+ // Provenance metadata attached to a memory at save time. Manual `cm save`
757
+ // calls (a human at the keyboard) leave this at its defaults — source
758
+ // "manual", confidence 1.0, no commit/file/author info. The hook passes
759
+ // commitSha/filePaths/author and source "hook" with a lower confidence;
760
+ // MCP tool saves pass source "mcp".
761
+ interface SaveProvenance {
762
+ commitSha?: string;
763
+ filePaths?: string[];
764
+ author?: string;
765
+ source?: "hook" | "manual" | "mcp";
766
+ confidence?: number;
767
+ }
768
+
769
+ async function cmdSave(
770
+ project: string,
771
+ type: string,
772
+ content: string,
773
+ force = false,
774
+ provenance: SaveProvenance = {}
775
+ ) {
450
776
  if (!MEMORY_TYPES.includes(type as MemoryType)) {
451
777
  fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
452
778
  }
@@ -492,9 +818,27 @@ async function cmdSave(project: string, type: string, content: string, force = f
492
818
  if (resolveError) fail(`Failed to resolve superseded memory: ${resolveError.message}`);
493
819
  }
494
820
 
821
+ const { commitSha, filePaths, author, source = "manual", confidence = 1.0 } = provenance;
822
+ const insertPayload: Record<string, unknown> = {
823
+ project,
824
+ type,
825
+ content,
826
+ device_id: getDeviceId(),
827
+ source,
828
+ confidence,
829
+ // Hook-captured memories are heuristically detected and could be
830
+ // noisy or wrong — they start as suggested, pending `cm review`, and
831
+ // are excluded from cm start/context/why until promoted. Manual and
832
+ // mcp saves are already user-vetted, so they're permanent immediately.
833
+ suggested: source === "hook",
834
+ };
835
+ if (commitSha) insertPayload.commit_sha = commitSha;
836
+ if (filePaths && filePaths.length > 0) insertPayload.file_paths = filePaths;
837
+ if (author) insertPayload.author = author;
838
+
495
839
  const { data: inserted, error } = await supabase
496
840
  .from("memories")
497
- .insert({ project, type, content, device_id: getDeviceId() })
841
+ .insert(insertPayload)
498
842
  .select("id")
499
843
  .single();
500
844
 
@@ -530,6 +874,40 @@ async function cmdSave(project: string, type: string, content: string, force = f
530
874
  }
531
875
  }
532
876
 
877
+ // --- cm check-commit (internal, used by the hook) --------------------------
878
+
879
+ // Not a user-facing command (no usage()/help entry) — the hook calls this
880
+ // once per invocation, before doing any diff analysis, to decide whether
881
+ // this commit_sha has already been captured. post-commit and post-rewrite
882
+ // both run the exact same capture logic against current HEAD, so without
883
+ // this guard, a commit that fires both hooks (e.g. `git commit --amend`,
884
+ // which triggers post-commit for the new commit AND post-rewrite for the
885
+ // rewrite) would be captured twice. Checked once per invocation — not
886
+ // inside each individual cmSave call — because a single commit normally
887
+ // produces several distinct memories; a per-save check would see the
888
+ // first save's own commit_sha and wrongly skip the rest.
889
+ async function cmdCheckCommit(project: string, commitSha: string): Promise<void> {
890
+ const { data, error } = await supabase
891
+ .from("memories")
892
+ .select("id")
893
+ .eq("project", project)
894
+ .eq("commit_sha", commitSha)
895
+ .limit(1);
896
+
897
+ if (error) {
898
+ // Fail open: an error here (including "column does not exist" if the
899
+ // commit_sha migration hasn't landed yet) must not look identical to
900
+ // "duplicate, skip" below — that would silently suppress every hook
901
+ // capture with no log trail. Exit 0 so the normal save path runs;
902
+ // if commit_sha genuinely isn't a column, the save itself fails
903
+ // there instead, where the hook's outer error handler logs it.
904
+ console.error(`check-commit: ${error.message}`);
905
+ process.exit(0);
906
+ }
907
+
908
+ process.exit(data && data.length > 0 ? 1 : 0);
909
+ }
910
+
533
911
  // --- memory compression ----------------------------------------------------
534
912
 
535
913
  async function compressMemories(
@@ -539,7 +917,8 @@ async function compressMemories(
539
917
  .from("memories")
540
918
  .select("*")
541
919
  .eq("project", project)
542
- .eq("resolved", false);
920
+ .eq("resolved", false)
921
+ .eq("suggested", false);
543
922
 
544
923
  if (error) fail(`Failed to load memories for compression: ${error.message}`);
545
924
 
@@ -688,6 +1067,34 @@ async function cmdResolve(project: string, memoryId: string) {
688
1067
  console.log(`Resolved memory [${memoryId}]`);
689
1068
  }
690
1069
 
1070
+ // --- cm pin / cm unpin -------------------------------------------------
1071
+
1072
+ async function setPinned(project: string, memoryId: string, pinned: boolean): Promise<void> {
1073
+ const { data, error } = await supabase
1074
+ .from("memories")
1075
+ .update({ pinned })
1076
+ .eq("project", project)
1077
+ .eq("id", memoryId)
1078
+ .select("id");
1079
+
1080
+ if (error) fail(`Failed to ${pinned ? "pin" : "unpin"} memory: ${error.message}`);
1081
+
1082
+ if (!data || data.length === 0) {
1083
+ console.log(`No memory found with ID [${memoryId}]`);
1084
+ return;
1085
+ }
1086
+
1087
+ console.log(`${pinned ? "Pinned" : "Unpinned"} memory [${memoryId}]`);
1088
+ }
1089
+
1090
+ async function cmdPin(project: string, memoryId: string): Promise<void> {
1091
+ await setPinned(project, memoryId, true);
1092
+ }
1093
+
1094
+ async function cmdUnpin(project: string, memoryId: string): Promise<void> {
1095
+ await setPinned(project, memoryId, false);
1096
+ }
1097
+
691
1098
  // --- cm delete -------------------------------------------------------------
692
1099
 
693
1100
  async function cmdDelete(project: string, memoryId: string) {
@@ -715,10 +1122,173 @@ async function cmdDelete(project: string, memoryId: string) {
715
1122
  console.log(`Deleted memory [${memoryId}]`);
716
1123
  }
717
1124
 
1125
+ // --- shared: relative time + $EDITOR prompt (cm review, cm edit, cm status)
1126
+
1127
+ function formatRelativeTime(date: Date): string {
1128
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
1129
+ if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"} ago`;
1130
+ const minutes = Math.floor(seconds / 60);
1131
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
1132
+ const hours = Math.floor(minutes / 60);
1133
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
1134
+ const days = Math.floor(hours / 24);
1135
+ return `${days} day${days === 1 ? "" : "s"} ago`;
1136
+ }
1137
+
1138
+ // Opens $EDITOR on a scratch file pre-filled with the current content, or
1139
+ // falls back to an inline readline prompt when $EDITOR isn't set. Returns
1140
+ // the original content unchanged if the result is blank (an empty save
1141
+ // buffer almost certainly means "I didn't mean to clear this", not "make
1142
+ // this memory empty").
1143
+ async function editContentInteractively(currentContent: string): Promise<string> {
1144
+ const editor = process.env.EDITOR;
1145
+
1146
+ if (editor) {
1147
+ const tmpFile = nodePath.join(nodeOs.tmpdir(), `stackmem-edit-${randomInt(1_000_000_000)}.txt`);
1148
+ nodeFs.writeFileSync(tmpFile, currentContent, "utf-8");
1149
+ try {
1150
+ execFileSync(editor, [tmpFile], { stdio: "inherit" });
1151
+ const edited = nodeFs.readFileSync(tmpFile, "utf-8").trim();
1152
+ return edited.length > 0 ? edited : currentContent;
1153
+ } finally {
1154
+ try {
1155
+ nodeFs.unlinkSync(tmpFile);
1156
+ } catch {
1157
+ // Best-effort cleanup — a leftover temp file isn't worth failing on.
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ console.log(`Current: ${currentContent}`);
1163
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1164
+ const answer = (await rl.question("New content (leave blank to keep unchanged): ")).trim();
1165
+ rl.close();
1166
+ return answer.length > 0 ? answer : currentContent;
1167
+ }
1168
+
1169
+ // --- cm review -------------------------------------------------------------
1170
+
1171
+ async function cmdReview(project: string): Promise<void> {
1172
+ const { data, error } = await supabase
1173
+ .from("memories")
1174
+ .select("*")
1175
+ .eq("project", project)
1176
+ .eq("resolved", false)
1177
+ .eq("suggested", true)
1178
+ .order("created_at", { ascending: true });
1179
+
1180
+ if (error) fail(`Failed to load suggested memories: ${error.message}`);
1181
+
1182
+ const memories = data ?? [];
1183
+ if (memories.length === 0) {
1184
+ console.log(`No memories to review for "${project}".`);
1185
+ console.log("Your inbox is empty.");
1186
+ return;
1187
+ }
1188
+
1189
+ console.log(`Reviewing ${memories.length} suggested memories for "${project}"`);
1190
+ console.log("━".repeat(40));
1191
+
1192
+ // Node's readline can't reliably answer multiple sequential
1193
+ // rl.question() calls over piped (non-TTY) stdin — all buffered input
1194
+ // arrives before the second question is even issued, and it never
1195
+ // resolves. A real interactive terminal doesn't hit this (keystrokes
1196
+ // arrive one at a time), so only the non-TTY path needs the
1197
+ // read-everything-up-front workaround.
1198
+ const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
1199
+ const pipedAnswers: string[] = rl
1200
+ ? []
1201
+ : (
1202
+ await new Promise<string>((resolve) => {
1203
+ let buf = "";
1204
+ process.stdin.setEncoding("utf-8");
1205
+ process.stdin.on("data", (chunk) => (buf += chunk));
1206
+ process.stdin.on("end", () => resolve(buf));
1207
+ })
1208
+ ).split("\n");
1209
+
1210
+ async function nextAnswer(prompt: string): Promise<string> {
1211
+ if (rl) return (await rl.question(prompt)).trim().toLowerCase();
1212
+ console.log(prompt);
1213
+ return (pipedAnswers.shift() ?? "q").trim().toLowerCase();
1214
+ }
1215
+
1216
+ let kept = 0;
1217
+ let dropped = 0;
1218
+ let skipped = 0;
1219
+
1220
+ for (let i = 0; i < memories.length; i++) {
1221
+ const memory = memories[i];
1222
+ console.log(`[${i + 1}/${memories.length}] ${memory.type}: ${memory.content}`);
1223
+ console.log(` Captured: ${formatRelativeTime(parseUtcDate(memory.created_at))}`);
1224
+ console.log("");
1225
+
1226
+ const answer = await nextAnswer("(k)eep (e)dit (d)rop (s)kip (q)uit > ");
1227
+ console.log("");
1228
+
1229
+ if (answer === "k") {
1230
+ const { error: updateError } = await supabase
1231
+ .from("memories")
1232
+ .update({ suggested: false })
1233
+ .eq("id", memory.id);
1234
+ if (!updateError) kept++;
1235
+ } else if (answer === "e") {
1236
+ const newContent = redactSecrets(await editContentInteractively(memory.content));
1237
+ const { error: updateError } = await supabase
1238
+ .from("memories")
1239
+ .update({ content: newContent, suggested: false })
1240
+ .eq("id", memory.id);
1241
+ if (!updateError) kept++;
1242
+ } else if (answer === "d") {
1243
+ await supabase
1244
+ .from("memory_links")
1245
+ .delete()
1246
+ .or(`source_id.eq.${memory.id},target_id.eq.${memory.id}`);
1247
+ const { error: deleteError } = await supabase.from("memories").delete().eq("id", memory.id);
1248
+ if (!deleteError) dropped++;
1249
+ } else if (answer === "q") {
1250
+ break;
1251
+ } else {
1252
+ // "s", blank, or anything unrecognized — leave it suggested and
1253
+ // move on rather than erroring on a mistyped key.
1254
+ skipped++;
1255
+ }
1256
+ }
1257
+
1258
+ rl?.close();
1259
+ console.log(`Review complete. Kept ${kept}, dropped ${dropped}, skipped ${skipped}.`);
1260
+ }
1261
+
1262
+ // --- cm edit ---------------------------------------------------------------
1263
+
1264
+ async function cmdEdit(project: string, memoryId: string): Promise<void> {
1265
+ const { data: memory, error } = await supabase
1266
+ .from("memories")
1267
+ .select("id, content")
1268
+ .eq("project", project)
1269
+ .eq("id", memoryId)
1270
+ .single();
1271
+
1272
+ if (error || !memory) {
1273
+ console.log(`No memory found with ID [${memoryId}]`);
1274
+ return;
1275
+ }
1276
+
1277
+ const newContent = redactSecrets(await editContentInteractively(memory.content));
1278
+ const { error: updateError } = await supabase
1279
+ .from("memories")
1280
+ .update({ content: newContent })
1281
+ .eq("id", memoryId);
1282
+
1283
+ if (updateError) fail(`Failed to update memory: ${updateError.message}`);
1284
+
1285
+ console.log(`Updated memory [${memoryId}].`);
1286
+ }
1287
+
718
1288
  // --- cm inspect ------------------------------------------------------------
719
1289
 
720
1290
  function formatDate(dateString: string): string {
721
- return new Date(dateString).toLocaleDateString("en-US", {
1291
+ return parseUtcDate(dateString).toLocaleDateString("en-US", {
722
1292
  month: "short",
723
1293
  day: "numeric",
724
1294
  year: "numeric",
@@ -735,7 +1305,13 @@ async function cmdInspect(project: string, memoryId: string) {
735
1305
 
736
1306
  if (error || !memory) fail(`Memory [${memoryId}] not found for "${project}".`);
737
1307
 
738
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
1308
+ const config = readStackmemConfig();
1309
+ const decayScore = computeDecayScore(
1310
+ parseUtcDate(memory.created_at),
1311
+ memory.access_count ?? 0,
1312
+ memory.pinned ?? false,
1313
+ config.decayLambda
1314
+ );
739
1315
 
740
1316
  const { data: links, error: linksError } = await supabase
741
1317
  .from("memory_links")
@@ -843,6 +1419,65 @@ try {
843
1419
 
844
1420
  const CLI_PATH = ${JSON.stringify(cliPath)};
845
1421
 
1422
+ // Inlined copy of src/config-file.ts's defaults/merge logic — this script
1423
+ // runs standalone with no import access to that module.
1424
+ const DEFAULT_STACKMEM_CONFIG = {
1425
+ ignore: [],
1426
+ tokenBudget: 2000,
1427
+ decayLambda: 0.05,
1428
+ captureTypes: ["decision", "constraint", "discovery", "rejection"],
1429
+ minQualityScore: 0.3,
1430
+ };
1431
+
1432
+ function isStringArray(value) {
1433
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
1434
+ }
1435
+
1436
+ function readStackmemConfig() {
1437
+ const configPath = join(process.cwd(), ".stackmem.json");
1438
+ try {
1439
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
1440
+ return {
1441
+ ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
1442
+ tokenBudget:
1443
+ typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
1444
+ ? raw.tokenBudget
1445
+ : DEFAULT_STACKMEM_CONFIG.tokenBudget,
1446
+ decayLambda:
1447
+ typeof raw.decayLambda === "number" && raw.decayLambda > 0
1448
+ ? raw.decayLambda
1449
+ : DEFAULT_STACKMEM_CONFIG.decayLambda,
1450
+ captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
1451
+ minQualityScore:
1452
+ typeof raw.minQualityScore === "number"
1453
+ ? raw.minQualityScore
1454
+ : DEFAULT_STACKMEM_CONFIG.minQualityScore,
1455
+ };
1456
+ } catch {
1457
+ return { ...DEFAULT_STACKMEM_CONFIG };
1458
+ }
1459
+ }
1460
+
1461
+ function matchesIgnorePattern(filePath, pattern) {
1462
+ const base = filePath.split("/").pop() ?? filePath;
1463
+
1464
+ if (pattern.endsWith("/")) {
1465
+ const dirName = pattern.slice(0, -1);
1466
+ return filePath.split("/").includes(dirName);
1467
+ }
1468
+
1469
+ if (pattern.includes("*")) {
1470
+ const regex = new RegExp(
1471
+ "^" + pattern.split("*").map((part) => part.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join(".*") + "$"
1472
+ );
1473
+ return regex.test(base) || regex.test(filePath);
1474
+ }
1475
+
1476
+ return filePath === pattern || base === pattern;
1477
+ }
1478
+
1479
+ const STACKMEM_CONFIG = readStackmemConfig();
1480
+
846
1481
  const SECRET_PATTERNS = [
847
1482
  /AKIA[0-9A-Z]{16}/g,
848
1483
  /(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
@@ -899,10 +1534,43 @@ function git(args) {
899
1534
  return execFileSync("git", args, { encoding: "utf-8" }).trim();
900
1535
  }
901
1536
 
1537
+ const CURRENT_SHA = git(["rev-parse", "HEAD"]);
1538
+ const CURRENT_AUTHOR = (() => {
1539
+ try {
1540
+ return git(["log", "--format=%ae", "-1", "HEAD"]);
1541
+ } catch {
1542
+ return "";
1543
+ }
1544
+ })();
1545
+
1546
+ // post-commit and post-rewrite both run this exact script against current
1547
+ // HEAD, so a single operation that fires both (e.g. \`git commit --amend\`)
1548
+ // would otherwise capture the same commit twice. Checked once up front,
1549
+ // not per-save — see cmdCheckCommit in src/cli.ts for why.
1550
+ try {
1551
+ execFileSync("node", [CLI_PATH, "check-commit", PROJECT, CURRENT_SHA], { stdio: "pipe" });
1552
+ } catch {
1553
+ return;
1554
+ }
1555
+
1556
+ // file_paths is populated with every file the commit touched (not just
1557
+ // the filtered diffableFiles below) — "cm why <file>" should still find
1558
+ // a commit's decision/discovery even if that specific file's own diff
1559
+ // content was skipped as noise (a lockfile, say).
902
1560
  function cmSave(type, content) {
903
- execFileSync("node", [CLI_PATH, "save", PROJECT, type, content, "--force"], {
904
- stdio: "inherit",
905
- });
1561
+ if (!STACKMEM_CONFIG.captureTypes.includes(type)) return;
1562
+
1563
+ const args = [
1564
+ CLI_PATH, "save", PROJECT, type, content, "--force",
1565
+ "--commit-sha", CURRENT_SHA,
1566
+ "--source", "hook",
1567
+ "--confidence", "0.8",
1568
+ ];
1569
+ if (CURRENT_AUTHOR) args.push("--author", CURRENT_AUTHOR);
1570
+ if (changedFiles.length > 0) args.push("--files", changedFiles.join(","));
1571
+
1572
+ execFileSync("node", args, { stdio: "inherit" });
1573
+ savedItems.push(\`\${type}: \${content}\`);
906
1574
  }
907
1575
 
908
1576
  function basename(filePath) {
@@ -929,6 +1597,29 @@ function isSchemaFile(filePath) {
929
1597
  );
930
1598
  }
931
1599
 
1600
+ // Noise that's never worth diffing: lockfiles, build output, vendored/
1601
+ // generated code, and timestamp-prefixed SQL migrations (their content is
1602
+ // a mechanical dump, not a human decision). Checked against path segments
1603
+ // (not just a prefix) so "apps/web/dist/bundle.js" is skipped too, not
1604
+ // just a top-level "dist/...".
1605
+ const DIFF_SKIP_DIR_NAMES = new Set(["dist", "build", ".next", "coverage", "vendored", "node_modules"]);
1606
+ const DIFF_SKIP_LOCK_NAMES = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]);
1607
+ const MIGRATION_FILE_RE = /^\\d{14}_.*\\.sql$/;
1608
+ const DIFF_MAX_BYTES = 50 * 1024;
1609
+
1610
+ function shouldSkipDiffFile(filePath) {
1611
+ const base = basename(filePath);
1612
+ if (filePath.split("/").some((segment) => DIFF_SKIP_DIR_NAMES.has(segment))) return true;
1613
+ if (DIFF_SKIP_LOCK_NAMES.has(base) || base.endsWith(".lock")) return true;
1614
+ if (base.endsWith(".min.js") || base.endsWith(".min.css")) return true;
1615
+ if (base.endsWith(".map")) return true;
1616
+ if (base.endsWith(".pb.go")) return true;
1617
+ if (/\\.generated\\./.test(base)) return true;
1618
+ if (MIGRATION_FILE_RE.test(base)) return true;
1619
+ if (STACKMEM_CONFIG.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern))) return true;
1620
+ return false;
1621
+ }
1622
+
932
1623
  const LOW_QUALITY_PREFIXES = [
933
1624
  "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
934
1625
  "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
@@ -963,11 +1654,10 @@ const savedItems = [];
963
1654
 
964
1655
  const commitMessage = git(["log", "-1", "--pretty=%B"]);
965
1656
 
966
- // The sync commit below is itself a commit, so it fires this same
967
- // post-commit hook again — git's --no-verify only skips pre-commit and
968
- // commit-msg hooks, never post-commit. Without this guard, the hook would
969
- // save its own commit message as a new decision memory, which changes the
970
- // rules-file content and triggers another sync commit, indefinitely.
1657
+ // Kept as a general escape hatch for other automation (CI bots, scripts)
1658
+ // that tags its own commits this way and shouldn't be captured as a
1659
+ // memory — cm sync no longer self-commits, so this isn't load-bearing for
1660
+ // that anymore, but other tools' commits still deserve the same skip.
971
1661
  if (commitMessage.includes("[skip ci]")) {
972
1662
  return;
973
1663
  }
@@ -991,10 +1681,40 @@ const changedFiles = git(
991
1681
 
992
1682
  let diff = "";
993
1683
  if (!isFirstCommit) {
1684
+ // --numstat marks a binary file's added/deleted columns as "-" — that's
1685
+ // the cheapest way to identify them without fetching their (useless,
1686
+ // and potentially huge) diff content at all.
1687
+ let binaryFiles = new Set();
994
1688
  try {
995
- diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
1689
+ const numstat = execFileSync("git", ["diff", "--numstat", "HEAD~1", "HEAD"], {
1690
+ encoding: "utf-8",
1691
+ });
1692
+ for (const line of numstat.split("\\n")) {
1693
+ const parts = line.split("\\t");
1694
+ if (parts[0] === "-" && parts[1] === "-" && parts[2]) {
1695
+ binaryFiles.add(parts[2]);
1696
+ }
1697
+ }
996
1698
  } catch {
997
- diff = "";
1699
+ // Nothing to compare against — leave binaryFiles empty.
1700
+ }
1701
+
1702
+ const diffableFiles = changedFiles.filter(
1703
+ (file) => !binaryFiles.has(file) && !shouldSkipDiffFile(file)
1704
+ );
1705
+
1706
+ if (diffableFiles.length > 0) {
1707
+ try {
1708
+ diff = execFileSync("git", ["diff", "HEAD~1", "HEAD", "--", ...diffableFiles], {
1709
+ encoding: "utf-8",
1710
+ });
1711
+ } catch {
1712
+ diff = "";
1713
+ }
1714
+ }
1715
+
1716
+ if (diff.length > DIFF_MAX_BYTES) {
1717
+ diff = diff.slice(0, DIFF_MAX_BYTES) + "\\n\\n... [diff truncated: exceeded 50KB after filtering]\\n";
998
1718
  }
999
1719
  }
1000
1720
 
@@ -1021,17 +1741,14 @@ for (const line of isFirstCommit ? [] : lines) {
1021
1741
  if (/(auth|login|session|token)/i.test(currentFile)) {
1022
1742
  const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
1023
1743
  cmSave("discovery", content);
1024
- savedItems.push(\`discovery: \${content}\`);
1025
1744
  }
1026
1745
  if (isConfigFile(currentFile)) {
1027
1746
  const content = redactSecrets(\`new config: \${currentFile}\`);
1028
1747
  cmSave("constraint", content);
1029
- savedItems.push(\`constraint: \${content}\`);
1030
1748
  }
1031
1749
  if (isSchemaFile(currentFile)) {
1032
1750
  const content = redactSecrets(\`schema change: \${currentFile}\`);
1033
1751
  cmSave("discovery", content);
1034
- savedItems.push(\`discovery: \${content}\`);
1035
1752
  }
1036
1753
  }
1037
1754
  continue;
@@ -1068,7 +1785,6 @@ for (const line of isFirstCommit ? [] : lines) {
1068
1785
  if (removedModulePath) {
1069
1786
  const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
1070
1787
  cmSave("rejection", content);
1071
- savedItems.push(\`rejection: \${content}\`);
1072
1788
  }
1073
1789
 
1074
1790
  if (isPackageJson && inDependenciesSection) {
@@ -1076,7 +1792,6 @@ for (const line of isFirstCommit ? [] : lines) {
1076
1792
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
1077
1793
  const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
1078
1794
  cmSave("rejection", content);
1079
- savedItems.push(\`rejection: \${content}\`);
1080
1795
  }
1081
1796
  }
1082
1797
  continue;
@@ -1092,7 +1807,6 @@ for (const line of isFirstCommit ? [] : lines) {
1092
1807
  if (envMatch) {
1093
1808
  const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
1094
1809
  cmSave("constraint", content);
1095
- savedItems.push(\`constraint: \${content}\`);
1096
1810
  }
1097
1811
  }
1098
1812
  }
@@ -1102,7 +1816,6 @@ for (const line of isFirstCommit ? [] : lines) {
1102
1816
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
1103
1817
  const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
1104
1818
  cmSave("decision", content);
1105
- savedItems.push(\`decision: \${content}\`);
1106
1819
  }
1107
1820
  }
1108
1821
 
@@ -1110,14 +1823,12 @@ for (const line of isFirstCommit ? [] : lines) {
1110
1823
  const truncated = codeLine.trim().slice(0, 80);
1111
1824
  const content = redactSecrets(\`database change: \${truncated}\`);
1112
1825
  cmSave("discovery", content);
1113
- savedItems.push(\`discovery: \${content}\`);
1114
1826
  }
1115
1827
 
1116
1828
  if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
1117
1829
  const truncated = codeLine.trim().slice(0, 100);
1118
1830
  const content = redactSecrets(\`open issue from commit: \${truncated}\`);
1119
1831
  cmSave("discovery", content);
1120
- savedItems.push(\`discovery: \${content}\`);
1121
1832
  }
1122
1833
  }
1123
1834
  }
@@ -1146,19 +1857,16 @@ if (breakingMatch) {
1146
1857
  const breakingContent = redactSecrets(breakingMatch[1].trim());
1147
1858
  const content = \`BREAKING CHANGE: \${breakingContent}\`;
1148
1859
  cmSave("constraint", content);
1149
- savedItems.push(\`constraint: \${content}\`);
1150
1860
  }
1151
1861
 
1152
1862
  if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
1153
1863
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1154
1864
  cmSave("decision", trimmedMessage);
1155
- savedItems.push(\`decision: \${trimmedMessage}\`);
1156
1865
  } else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
1157
1866
  // docs/style/test/ci/build/chore — skip entirely, nothing saved.
1158
1867
  } else if (isQualityCommitMessage(commitMessage)) {
1159
1868
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1160
1869
  cmSave("decision", trimmedMessage);
1161
- savedItems.push(\`decision: \${trimmedMessage}\`);
1162
1870
  }
1163
1871
 
1164
1872
  console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
@@ -1166,21 +1874,13 @@ for (const item of savedItems) {
1166
1874
  console.log(\` - \${item}\`);
1167
1875
  }
1168
1876
 
1877
+ // Updates .cursorrules/.windsurfrules on disk only — no git add/commit.
1878
+ // Editors read these files straight off disk, and they're gitignored by
1879
+ // default (see DEFAULT_GITIGNORE_CONTENT), so there's nothing to keep in
1880
+ // sync with git history; committing them here used to double the commit
1881
+ // count on every real commit and produced constant rules-file merge
1882
+ // conflicts on teams.
1169
1883
  execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
1170
-
1171
- // cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
1172
- // the working directory dirty (breaking things like \`npm version patch\`
1173
- // that require a clean tree). Commit the sync ourselves instead. --no-verify
1174
- // skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
1175
- // guard above, which is what actually stops this from recursing).
1176
- try {
1177
- execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
1178
- execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
1179
- stdio: "pipe",
1180
- });
1181
- } catch {
1182
- // Nothing to commit (rules files unchanged) — fine, ignore.
1183
- }
1184
1884
  }
1185
1885
 
1186
1886
  async function main() {
@@ -1206,15 +1906,27 @@ main().finally(() => process.exit(0));
1206
1906
  `;
1207
1907
  }
1208
1908
 
1209
- // Installs (or re-installs) the stackmem post-commit hook at `hooksDir`. If
1210
- // a post-commit hook already exists and wasn't written by stackmem, it's
1909
+ // Hooks stackmem manages. post-rewrite fires on `git commit --amend` and
1910
+ // `git rebase` (neither of which is a plain commit, so post-commit alone
1911
+ // misses them) — it runs the exact same capture script as post-commit,
1912
+ // against current HEAD, with the check-commit idempotency guard in the
1913
+ // script itself preventing a double-capture when a single operation
1914
+ // (e.g. --amend) fires both hooks for the same resulting commit.
1915
+ const MANAGED_HOOK_NAMES = ["post-commit", "post-rewrite"] as const;
1916
+
1917
+ function hookBackupFilename(hookName: string): string {
1918
+ return `${hookName}${HOOK_BACKUP_SUFFIX}`;
1919
+ }
1920
+
1921
+ // Installs (or re-installs) a stackmem-managed hook at `hooksDir`. If a
1922
+ // hook of this name already exists and wasn't written by stackmem, it's
1211
1923
  // preserved as a backup and chained to run before stackmem's own hook,
1212
1924
  // rather than being clobbered — see `uninstallHook` for how it's restored.
1213
- function installHook(hooksDir: string, hookLogicPath: string): void {
1925
+ function installHook(hooksDir: string, hookLogicPath: string, hookName: string): void {
1214
1926
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1215
1927
 
1216
- const hookPath = nodePath.join(hooksDir, "post-commit");
1217
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1928
+ const hookPath = nodePath.join(hooksDir, hookName);
1929
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1218
1930
 
1219
1931
  let chainedOriginal = false;
1220
1932
  if (nodeFs.existsSync(hookPath)) {
@@ -1231,7 +1943,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
1231
1943
  }
1232
1944
 
1233
1945
  const chainLine = chainedOriginal
1234
- ? `"$(dirname "$0")/${HOOK_BACKUP_FILENAME}" "$@"\n`
1946
+ ? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
1235
1947
  : "";
1236
1948
 
1237
1949
  nodeFs.writeFileSync(
@@ -1242,7 +1954,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
1242
1954
  nodeFs.chmodSync(hookPath, 0o755);
1243
1955
 
1244
1956
  if (chainedOriginal) {
1245
- console.log("Existing post-commit hook found — chained it to run before stackmem's hook.");
1957
+ console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
1246
1958
  }
1247
1959
  }
1248
1960
 
@@ -1250,9 +1962,9 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
1250
1962
  // stackmem chained it after an existing hook, restores that original hook
1251
1963
  // in its place. Returns a description of what happened, or null if there
1252
1964
  // was no stackmem-managed hook to remove (nothing done).
1253
- function uninstallHook(hooksDir: string): string | null {
1254
- const hookPath = nodePath.join(hooksDir, "post-commit");
1255
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1965
+ function uninstallHook(hooksDir: string, hookName: string): string | null {
1966
+ const hookPath = nodePath.join(hooksDir, hookName);
1967
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1256
1968
 
1257
1969
  if (!nodeFs.existsSync(hookPath)) return null;
1258
1970
 
@@ -1261,11 +1973,11 @@ function uninstallHook(hooksDir: string): string | null {
1261
1973
 
1262
1974
  if (nodeFs.existsSync(backupPath)) {
1263
1975
  nodeFs.renameSync(backupPath, hookPath);
1264
- return ".git/hooks/post-commit (restored original hook stackmem had chained)";
1976
+ return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
1265
1977
  }
1266
1978
 
1267
1979
  nodeFs.unlinkSync(hookPath);
1268
- return ".git/hooks/post-commit";
1980
+ return `.git/hooks/${hookName}`;
1269
1981
  }
1270
1982
 
1271
1983
  // Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
@@ -1319,6 +2031,122 @@ function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()): boolean
1319
2031
  return true;
1320
2032
  }
1321
2033
 
2034
+ // --- MCP registration for other editors ---------------------------------
2035
+
2036
+ // Each entry detects its editor by an existing config directory (never
2037
+ // erroring if absent — "skip silently" per the task) and merges stackmem
2038
+ // into whatever's already there, same spirit as registerMcpServer above.
2039
+ // homeDir is a parameter (not a hardcoded nodeOs.homedir() call) so tests
2040
+ // can point at a scratch directory instead of the real one.
2041
+ interface EditorMcpTarget {
2042
+ name: string;
2043
+ configPath: (homeDir: string) => string | null;
2044
+ register: (configPath: string, distIndexPath: string) => void;
2045
+ }
2046
+
2047
+ function mergeMcpServersJson(configPath: string, distIndexPath: string): void {
2048
+ let config: { mcpServers?: Record<string, unknown> } = {};
2049
+ if (nodeFs.existsSync(configPath)) {
2050
+ try {
2051
+ config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
2052
+ } catch {
2053
+ config = {};
2054
+ }
2055
+ }
2056
+ if (!config.mcpServers || typeof config.mcpServers !== "object") {
2057
+ config.mcpServers = {};
2058
+ }
2059
+ config.mcpServers.stackmem = { command: "node", args: [distIndexPath] };
2060
+
2061
+ nodeFs.mkdirSync(nodePath.dirname(configPath), { recursive: true });
2062
+ nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
2063
+ }
2064
+
2065
+ // VS Code installs the Copilot Chat extension under a version-suffixed
2066
+ // directory name (e.g. "github.copilot-chat-0.26.4") — this resolves the
2067
+ // "github.copilot-chat*" glob the task describes against whatever's
2068
+ // actually installed, rather than assuming a fixed folder name.
2069
+ function findCopilotChatExtensionDir(homeDir: string): string | null {
2070
+ const extensionsDir = nodePath.join(homeDir, ".vscode", "extensions");
2071
+ if (!nodeFs.existsSync(extensionsDir)) return null;
2072
+
2073
+ try {
2074
+ const match = nodeFs
2075
+ .readdirSync(extensionsDir)
2076
+ .find((entry) => entry.startsWith("github.copilot-chat"));
2077
+ return match ? nodePath.join(extensionsDir, match) : null;
2078
+ } catch {
2079
+ return null;
2080
+ }
2081
+ }
2082
+
2083
+ const EDITOR_MCP_TARGETS: EditorMcpTarget[] = [
2084
+ {
2085
+ name: "Cursor",
2086
+ configPath: (homeDir) => {
2087
+ const dir = nodePath.join(homeDir, ".cursor");
2088
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp.json") : null;
2089
+ },
2090
+ register: mergeMcpServersJson,
2091
+ },
2092
+ {
2093
+ name: "Windsurf",
2094
+ configPath: (homeDir) => {
2095
+ const dir = nodePath.join(homeDir, ".codeium", "windsurf");
2096
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp_config.json") : null;
2097
+ },
2098
+ register: mergeMcpServersJson,
2099
+ },
2100
+ {
2101
+ name: "VS Code (Copilot)",
2102
+ configPath: (homeDir) => {
2103
+ const extensionDir = findCopilotChatExtensionDir(homeDir);
2104
+ return extensionDir ? nodePath.join(extensionDir, "mcp.json") : null;
2105
+ },
2106
+ register: mergeMcpServersJson,
2107
+ },
2108
+ {
2109
+ name: "Zed",
2110
+ configPath: (homeDir) => {
2111
+ const dir = nodePath.join(homeDir, ".config", "zed");
2112
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "settings.json") : null;
2113
+ },
2114
+ register: (configPath, distIndexPath) => {
2115
+ let config: { context_servers?: Record<string, unknown> } = {};
2116
+ if (nodeFs.existsSync(configPath)) {
2117
+ try {
2118
+ config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
2119
+ } catch {
2120
+ config = {};
2121
+ }
2122
+ }
2123
+ if (!config.context_servers || typeof config.context_servers !== "object") {
2124
+ config.context_servers = {};
2125
+ }
2126
+ config.context_servers.stackmem = { command: "node", args: [distIndexPath] };
2127
+ nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
2128
+ },
2129
+ },
2130
+ ];
2131
+
2132
+ // Registers stackmem's MCP server with every detected editor, printing a
2133
+ // line per success. An editor whose config directory doesn't exist is
2134
+ // skipped with no output and never an error.
2135
+ function registerEditorMcpServers(distIndexPath: string, homeDir = nodeOs.homedir()): void {
2136
+ for (const target of EDITOR_MCP_TARGETS) {
2137
+ const configPath = target.configPath(homeDir);
2138
+ if (!configPath) continue;
2139
+
2140
+ try {
2141
+ target.register(configPath, distIndexPath);
2142
+ console.log(`✓ MCP registered for ${target.name}`);
2143
+ } catch {
2144
+ // Best-effort — a permissions error or malformed existing config on
2145
+ // one editor shouldn't abort `cm init` or the other editors' writes.
2146
+ }
2147
+ }
2148
+ }
2149
+
1322
2150
  const DEFAULT_GITIGNORE_CONTENT = `node_modules/
1323
2151
  dist/
1324
2152
  .env
@@ -1328,6 +2156,9 @@ dist/
1328
2156
  coverage/
1329
2157
  *-memories.json
1330
2158
  *.db
2159
+ *cursorrules
2160
+ *windsurfrules
2161
+ .cursor/rules/stackmem.md
1331
2162
  `;
1332
2163
 
1333
2164
  // Returns true if the file was created, false if it already existed
@@ -1343,28 +2174,131 @@ function writeGitignore(resolvedPath: string): boolean {
1343
2174
  return true;
1344
2175
  }
1345
2176
 
1346
- function writeClaudeMd(project: string, resolvedPath: string): void {
1347
- const claudeMdPath = nodePath.join(resolvedPath, "CLAUDE.md");
2177
+ function writeClaudeMd(project: string, resolvedPath: string): void {
2178
+ const claudeMdPath = nodePath.join(resolvedPath, "CLAUDE.md");
2179
+
2180
+ if (nodeFs.existsSync(claudeMdPath)) {
2181
+ console.log("CLAUDE.md already exists — skipping.");
2182
+ return;
2183
+ }
2184
+
2185
+ const content = `# stackmem
2186
+
2187
+ At the start of every session, the stackmem MCP server will automatically load memories for this project. The project name is "${project}".
2188
+
2189
+ If the MCP server is not connected, run:
2190
+ cm start ${project}
2191
+ and paste the output here before starting work.
2192
+
2193
+ When you make a decision, discover a constraint, reject an approach, or make a discovery during this session, save it with:
2194
+ cm save ${project} <type> "<content>"
2195
+ `;
2196
+
2197
+ nodeFs.writeFileSync(claudeMdPath, content, "utf-8");
2198
+ console.log("CLAUDE.md written.");
2199
+ }
2200
+
2201
+ // Same instructional purpose as writeClaudeMd — point the agent at
2202
+ // `cm start` rather than embedding a memory snapshot directly, since a
2203
+ // baked-in snapshot goes stale the moment the next commit lands. AGENTS.md
2204
+ // is the convention used by OpenAI Codex CLI, Gemini CLI, and others;
2205
+ // GEMINI.md is Gemini CLI's own equivalent — both get identical content.
2206
+ function writeAgentsMemoryFile(project: string, resolvedPath: string, filename: string): void {
2207
+ const filePath = nodePath.join(resolvedPath, filename);
2208
+
2209
+ if (nodeFs.existsSync(filePath)) {
2210
+ console.log(`${filename} already exists — skipping.`);
2211
+ return;
2212
+ }
2213
+
2214
+ const content = `# ${project} — Agent Memory
2215
+
2216
+ This project uses stackmem for persistent agent memory.
2217
+ Run \`cm start ${project}\` at the start of every session
2218
+ to load relevant context.
2219
+
2220
+ ## Key decisions, constraints and discoveries
2221
+
2222
+ Decisions, constraints, discoveries, and rejections live in the stackmem
2223
+ backend, not duplicated in this file — run \`cm start ${project}\` to load
2224
+ the current list.
2225
+
2226
+ When you make a decision, discover a constraint, reject an approach, or
2227
+ make a discovery during this session, save it with:
2228
+ cm save ${project} <type> "<content>"
2229
+ `;
2230
+
2231
+ nodeFs.writeFileSync(filePath, content, "utf-8");
2232
+ console.log(`${filename} written.`);
2233
+ }
2234
+
2235
+ // --- Claude Code hooks (.claude/settings.json) ------------------------
2236
+
2237
+ interface ClaudeHookHandler {
2238
+ type: string;
2239
+ command: string;
2240
+ }
2241
+
2242
+ interface ClaudeHookGroup {
2243
+ matcher?: string;
2244
+ hooks: ClaudeHookHandler[];
2245
+ }
2246
+
2247
+ interface ClaudeSettingsFile {
2248
+ hooks?: Record<string, ClaudeHookGroup[]>;
2249
+ [key: string]: unknown;
2250
+ }
2251
+
2252
+ // --project (not positional) for the PreCompact command below: cmdContext's
2253
+ // CLI convention takes --project like cm search/cm context already do
2254
+ // everywhere else — cm start and cm session-end both take a positional
2255
+ // project, so those two keep the user's literal command shape.
2256
+ const STACKMEM_HOOK_EVENTS: { event: string; buildCommand: (project: string) => string }[] = [
2257
+ { event: "SessionStart", buildCommand: (project) => `cm start ${project} --hook` },
2258
+ { event: "Stop", buildCommand: (project) => `cm session-end ${project} --hook` },
2259
+ { event: "PreCompact", buildCommand: (project) => `cm context --project ${project} --hook` },
2260
+ ];
1348
2261
 
1349
- if (nodeFs.existsSync(claudeMdPath)) {
1350
- console.log("CLAUDE.md already exists — skipping.");
1351
- return;
2262
+ // Writes (or merges into) <resolvedPath>/.claude/settings.json. Merges
2263
+ // rather than overwrites — an existing settings.json may have unrelated
2264
+ // hooks or settings that must survive this — and is idempotent: re-running
2265
+ // `cm init` checks for the exact command string already present in an
2266
+ // event's hook groups before adding another one.
2267
+ function writeClaudeCodeHooks(project: string, resolvedPath: string): void {
2268
+ const settingsDir = nodePath.join(resolvedPath, ".claude");
2269
+ const settingsPath = nodePath.join(settingsDir, "settings.json");
2270
+
2271
+ let settings: ClaudeSettingsFile = {};
2272
+ if (nodeFs.existsSync(settingsPath)) {
2273
+ try {
2274
+ settings = JSON.parse(nodeFs.readFileSync(settingsPath, "utf-8"));
2275
+ } catch {
2276
+ settings = {};
2277
+ }
1352
2278
  }
1353
2279
 
1354
- const content = `# stackmem
2280
+ if (!settings.hooks || typeof settings.hooks !== "object") {
2281
+ settings.hooks = {};
2282
+ }
1355
2283
 
1356
- At the start of every session, the stackmem MCP server will automatically load memories for this project. The project name is "${project}".
2284
+ for (const { event, buildCommand } of STACKMEM_HOOK_EVENTS) {
2285
+ const command = buildCommand(project);
1357
2286
 
1358
- If the MCP server is not connected, run:
1359
- cm start ${project}
1360
- and paste the output here before starting work.
2287
+ if (!Array.isArray(settings.hooks[event])) {
2288
+ settings.hooks[event] = [];
2289
+ }
1361
2290
 
1362
- When you make a decision, discover a constraint, reject an approach, or make a discovery during this session, save it with:
1363
- cm save ${project} <type> "<content>"
1364
- `;
2291
+ const alreadyRegistered = settings.hooks[event].some(
2292
+ (group) => Array.isArray(group?.hooks) && group.hooks.some((h) => h?.command === command)
2293
+ );
1365
2294
 
1366
- nodeFs.writeFileSync(claudeMdPath, content, "utf-8");
1367
- console.log("CLAUDE.md written.");
2295
+ if (!alreadyRegistered) {
2296
+ settings.hooks[event].push({ hooks: [{ type: "command", command }] });
2297
+ }
2298
+ }
2299
+
2300
+ nodeFs.mkdirSync(settingsDir, { recursive: true });
2301
+ nodeFs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
1368
2302
  }
1369
2303
 
1370
2304
  // Section order for the generated rules-file markdown — matches the order
@@ -1381,14 +2315,21 @@ async function generateRulesContent(project: string): Promise<string> {
1381
2315
  .from("memories")
1382
2316
  .select("*")
1383
2317
  .eq("project", project)
1384
- .eq("resolved", false);
2318
+ .eq("resolved", false)
2319
+ .eq("suggested", false);
1385
2320
 
1386
2321
  if (memoriesError) fail(`Failed to load memories: ${memoriesError.message}`);
1387
2322
 
2323
+ const config = readStackmemConfig();
1388
2324
  const scoredMemories = (memoryRows ?? [])
1389
2325
  .map((memory) => ({
1390
2326
  ...memory,
1391
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
2327
+ decay_score: computeDecayScore(
2328
+ parseUtcDate(memory.created_at),
2329
+ memory.access_count ?? 0,
2330
+ memory.pinned ?? false,
2331
+ config.decayLambda
2332
+ ),
1392
2333
  }))
1393
2334
  .sort((a, b) => b.decay_score - a.decay_score);
1394
2335
 
@@ -1461,6 +2402,19 @@ function writeCursorRules(resolvedPath: string, content: string): boolean {
1461
2402
  return true;
1462
2403
  }
1463
2404
 
2405
+ // Cursor's newer rules format (.cursor/rules/*.md) — .cursorrules is
2406
+ // legacy but still read by older Cursor versions, so both are written
2407
+ // during the transition rather than one replacing the other. Unlike
2408
+ // writeCursorRules/writeWindsurfRules (write-once at init, skip if
2409
+ // present), this one unconditionally overwrites on every call — both at
2410
+ // init and on every `cm sync` — since it's gitignored content meant to
2411
+ // always reflect the current memory state, same as the legacy files.
2412
+ function writeCursorRulesMdFile(resolvedPath: string, content: string): void {
2413
+ const rulesDir = nodePath.join(resolvedPath, ".cursor", "rules");
2414
+ nodeFs.mkdirSync(rulesDir, { recursive: true });
2415
+ nodeFs.writeFileSync(nodePath.join(rulesDir, "stackmem.md"), content, "utf-8");
2416
+ }
2417
+
1464
2418
  // Returns true if the file was written, false if it already existed
1465
2419
  // (and was skipped).
1466
2420
  function writeWindsurfRules(resolvedPath: string, content: string): boolean {
@@ -1507,6 +2461,10 @@ export async function trySeedMemory(
1507
2461
  type,
1508
2462
  content,
1509
2463
  device_id: getDeviceId(),
2464
+ // Seeded by `cm init` scanning project files — an explicit user-run
2465
+ // command, not an auto-captured commit, so it's "manual" like cm save,
2466
+ // not the memories.source column's default of "hook".
2467
+ source: "manual",
1510
2468
  });
1511
2469
  return !error;
1512
2470
  }
@@ -1621,7 +2579,20 @@ async function verifyBackendConnection(project: string): Promise<boolean> {
1621
2579
  return !error;
1622
2580
  }
1623
2581
 
1624
- async function cmdInit(project: string, targetPath: string) {
2582
+ // Returns true if the file was created, false if it already existed
2583
+ // (and was left untouched).
2584
+ function writeStackmemConfigFile(resolvedPath: string): boolean {
2585
+ const configPath = nodePath.join(resolvedPath, STACKMEM_CONFIG_FILENAME);
2586
+
2587
+ if (nodeFs.existsSync(configPath)) {
2588
+ return false;
2589
+ }
2590
+
2591
+ nodeFs.writeFileSync(configPath, defaultConfigFileContents(), "utf-8");
2592
+ return true;
2593
+ }
2594
+
2595
+ async function cmdInit(project: string, targetPath: string, writeConfig = false): Promise<void> {
1625
2596
  const resolvedPath = nodePath.resolve(targetPath);
1626
2597
  const gitDir = nodePath.join(resolvedPath, ".git");
1627
2598
 
@@ -1641,12 +2612,18 @@ async function cmdInit(project: string, targetPath: string) {
1641
2612
  const hooksDir = nodePath.join(gitDir, "hooks");
1642
2613
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1643
2614
 
1644
- installHook(hooksDir, hookLogicPath);
2615
+ for (const hookName of MANAGED_HOOK_NAMES) {
2616
+ installHook(hooksDir, hookLogicPath, hookName);
2617
+ }
1645
2618
 
1646
2619
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
1647
2620
 
1648
2621
  registerMcpServer(DIST_INDEX_PATH);
2622
+ registerEditorMcpServers(DIST_INDEX_PATH);
1649
2623
  writeClaudeMd(project, resolvedPath);
2624
+ writeAgentsMemoryFile(project, resolvedPath, "AGENTS.md");
2625
+ writeAgentsMemoryFile(project, resolvedPath, "GEMINI.md");
2626
+ writeClaudeCodeHooks(project, resolvedPath);
1650
2627
 
1651
2628
  const seededCount = await seedMemories(project, resolvedPath);
1652
2629
  console.log(`Seeded ${seededCount} initial memories from project scan.`);
@@ -1654,22 +2631,32 @@ async function cmdInit(project: string, targetPath: string) {
1654
2631
  const rulesContent = await generateRulesContent(project);
1655
2632
  const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
1656
2633
  const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
2634
+ writeCursorRulesMdFile(resolvedPath, rulesContent);
1657
2635
 
1658
2636
  const backendOk = await verifyBackendConnection(project);
1659
2637
 
2638
+ const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
2639
+
1660
2640
  console.log("");
1661
2641
  if (gitignoreCreated) {
1662
2642
  console.log("✓ .gitignore created");
1663
2643
  }
2644
+ if (writeConfig) {
2645
+ console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
2646
+ }
1664
2647
  console.log("✓ Git hook installed");
1665
2648
  console.log("✓ MCP server registered");
2649
+ console.log("✓ Claude Code hooks registered (.claude/settings.json)");
1666
2650
  console.log("✓ CLAUDE.md written");
2651
+ console.log("✓ AGENTS.md written");
2652
+ console.log("✓ GEMINI.md written");
1667
2653
  console.log(
1668
2654
  `✓ .cursorrules ${cursorRulesWritten ? "written" : "already exists"}`
1669
2655
  );
1670
2656
  console.log(
1671
2657
  `✓ .windsurfrules ${windsurfRulesWritten ? "written" : "already exists"}`
1672
2658
  );
2659
+ console.log("✓ .cursor/rules/stackmem.md written");
1673
2660
  console.log(`✓ Seeded ${seededCount} memories from project scan`);
1674
2661
  if (backendOk) {
1675
2662
  console.log("✓ Backend connection verified");
@@ -1695,8 +2682,9 @@ async function cmdSync(project: string) {
1695
2682
 
1696
2683
  nodeFs.writeFileSync(nodePath.join(resolvedPath, ".cursorrules"), rulesContent, "utf-8");
1697
2684
  nodeFs.writeFileSync(nodePath.join(resolvedPath, ".windsurfrules"), rulesContent, "utf-8");
2685
+ writeCursorRulesMdFile(resolvedPath, rulesContent);
1698
2686
 
1699
- console.log("Synced memories to .cursorrules and .windsurfrules");
2687
+ console.log("Synced memories to .cursorrules, .windsurfrules, and .cursor/rules/stackmem.md");
1700
2688
  }
1701
2689
 
1702
2690
  // --- cm link ---------------------------------------------------------------
@@ -1781,45 +2769,178 @@ async function cmdLink() {
1781
2769
  console.log("Link code expired. Run cm link again.");
1782
2770
  }
1783
2771
 
2772
+ // --- cm open -----------------------------------------------------------
2773
+
2774
+ // Separated from cmdOpen so the platform-selection logic is unit-testable
2775
+ // without actually launching a browser — exercising this for real on
2776
+ // every platform isn't something a test (or a verification pass) should
2777
+ // do as a side effect.
2778
+ export function getOpenCommand(platform: string, url: string): { command: string; args: string[] } {
2779
+ if (platform === "darwin") return { command: "open", args: [url] };
2780
+ if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
2781
+ return { command: "xdg-open", args: [url] };
2782
+ }
2783
+
2784
+ async function cmdOpen(project: string): Promise<void> {
2785
+ const url = `${VAULT_URL}/project/${project}`;
2786
+ const { command, args } = getOpenCommand(process.platform, url);
2787
+
2788
+ try {
2789
+ execFileSync(command, args, { stdio: "ignore" });
2790
+ console.log(`Opened ${url}`);
2791
+ } catch (error) {
2792
+ fail(
2793
+ `Failed to open browser (${error instanceof Error ? error.message : String(error)}). Visit ${url} manually.`
2794
+ );
2795
+ }
2796
+ }
2797
+
2798
+ // --- cm purge ------------------------------------------------------------
2799
+
2800
+ async function cmdPurgeProject(project: string): Promise<void> {
2801
+ const { count: total, error: countError } = await supabase
2802
+ .from("memories")
2803
+ .select("id", { count: "exact", head: true })
2804
+ .eq("project", project);
2805
+ if (countError) fail(`Failed to count memories: ${countError.message}`);
2806
+
2807
+ console.log(`This will permanently delete all ${total ?? 0} memories for "${project}".`);
2808
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2809
+ const answer = (await rl.question("Type the project name to confirm: ")).trim();
2810
+ rl.close();
2811
+
2812
+ if (answer !== project) {
2813
+ console.log("Confirmation did not match — aborted. Nothing was deleted.");
2814
+ return;
2815
+ }
2816
+
2817
+ const { data: memRows, error: memFetchError } = await supabase
2818
+ .from("memories")
2819
+ .select("id")
2820
+ .eq("project", project);
2821
+ if (memFetchError) fail(`Failed to load memories: ${memFetchError.message}`);
2822
+
2823
+ const ids = (memRows ?? []).map((m) => m.id);
2824
+ let linksDeleted = 0;
2825
+ if (ids.length > 0) {
2826
+ const idList = ids.join(",");
2827
+ const { count, error: linksError } = await supabase
2828
+ .from("memory_links")
2829
+ .delete({ count: "exact" })
2830
+ .or(`source_id.in.(${idList}),target_id.in.(${idList})`);
2831
+ if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
2832
+ linksDeleted = count ?? 0;
2833
+ }
2834
+
2835
+ const { count: memoriesDeleted, error: memError } = await supabase
2836
+ .from("memories")
2837
+ .delete({ count: "exact" })
2838
+ .eq("project", project);
2839
+ if (memError) fail(`Failed to delete memories: ${memError.message}`);
2840
+
2841
+ const { count: fixesDeleted, error: fixError } = await supabase
2842
+ .from("execution_log")
2843
+ .delete({ count: "exact" })
2844
+ .eq("project", project);
2845
+ if (fixError) fail(`Failed to delete fixes: ${fixError.message}`);
2846
+
2847
+ console.log(
2848
+ `Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted} links for "${project}".`
2849
+ );
2850
+ }
2851
+
2852
+ // RLS already scopes every table read/write to this device's device_id
2853
+ // via the x-device-id header (see src/storage.ts) — a filter-less delete
2854
+ // here is already confined to this device, never another one. PostgREST
2855
+ // still requires an explicit filter clause to allow a DELETE at all, so
2856
+ // `.not("id", "is", null)` (every row has a non-null id) is used purely
2857
+ // to satisfy that, not to narrow the scope further.
2858
+ async function cmdPurgeAll(): Promise<void> {
2859
+ console.log("This will permanently delete ALL memories across ALL projects for this device.");
2860
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2861
+ const answer = (await rl.question("Type DELETE to confirm: ")).trim();
2862
+ rl.close();
2863
+
2864
+ if (answer !== "DELETE") {
2865
+ console.log("Confirmation did not match — aborted. Nothing was deleted.");
2866
+ return;
2867
+ }
2868
+
2869
+ const { count: linksDeleted, error: linksError } = await supabase
2870
+ .from("memory_links")
2871
+ .delete({ count: "exact" })
2872
+ .not("id", "is", null);
2873
+ if (linksError) fail(`Failed to delete memory links: ${linksError.message}`);
2874
+
2875
+ const { count: memoriesDeleted, error: memError } = await supabase
2876
+ .from("memories")
2877
+ .delete({ count: "exact" })
2878
+ .not("id", "is", null);
2879
+ if (memError) fail(`Failed to delete memories: ${memError.message}`);
2880
+
2881
+ const { count: fixesDeleted, error: fixError } = await supabase
2882
+ .from("execution_log")
2883
+ .delete({ count: "exact" })
2884
+ .not("id", "is", null);
2885
+ if (fixError) fail(`Failed to delete fixes: ${fixError.message}`);
2886
+
2887
+ console.log(
2888
+ `Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted ?? 0} links across all projects.`
2889
+ );
2890
+ }
2891
+
1784
2892
  // --- cm context ------------------------------------------------------------
1785
2893
 
1786
2894
  const CONTEXT_TYPE_ORDER: MemoryType[] = ["decision", "constraint", "rejection", "discovery"];
1787
2895
  const CONTEXT_TOP_N = 5;
1788
2896
 
1789
- async function cmdContext(project: string, task: string) {
2897
+ // task is null for the PreCompact hook — there's no user-typed task at a
2898
+ // compaction boundary, just "save the most relevant context" generically
2899
+ // — so ranking falls back to decay score alone instead of diluting it
2900
+ // with a relevance score computed against an empty string.
2901
+ async function cmdContext(project: string, task: string | null, hookMode = false) {
1790
2902
  const { data, error } = await supabase
1791
2903
  .from("memories")
1792
2904
  .select("*")
1793
2905
  .eq("project", project)
1794
- .eq("resolved", false);
2906
+ .eq("resolved", false)
2907
+ .eq("suggested", false);
1795
2908
 
1796
2909
  if (error) fail(`Failed to load memories: ${error.message}`);
1797
2910
 
1798
2911
  const memories = data ?? [];
1799
2912
  if (memories.length === 0) {
1800
- console.log(`No memories found for "${project}".`);
2913
+ if (!hookMode) console.log(`No memories found for "${project}".`);
1801
2914
  return;
1802
2915
  }
1803
2916
 
2917
+ const config = readStackmemConfig();
2918
+ const tokenBudget = config.tokenBudget;
2919
+
1804
2920
  const scoredMemories = memories
1805
2921
  .map((memory) => {
1806
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
1807
- const relevanceScore = scoreMemoryRelevance(memory.content, task);
1808
- return {
1809
- ...memory,
1810
- combined_score: relevanceScore * 0.6 + decayScore * 0.4,
1811
- };
2922
+ const decayScore = computeDecayScore(
2923
+ parseUtcDate(memory.created_at),
2924
+ memory.access_count ?? 0,
2925
+ memory.pinned ?? false,
2926
+ config.decayLambda
2927
+ );
2928
+ const combinedScore =
2929
+ task == null ? decayScore : scoreMemoryRelevance(memory.content, task) * 0.6 + decayScore * 0.4;
2930
+ return { ...memory, combined_score: combinedScore };
1812
2931
  })
1813
2932
  .sort((a, b) => b.combined_score - a.combined_score);
1814
2933
 
1815
2934
  const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
1816
- const budgetedMemories = applyContextBudget(topMemories, CONTEXT_BUDGET_TOKENS);
2935
+ const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
1817
2936
  const tokensUsed = budgetedMemories.reduce(
1818
2937
  (sum, memory) => sum + estimateTokens(memory.content),
1819
2938
  0
1820
2939
  );
1821
2940
 
1822
- const lines: string[] = [`## Project Context: ${project}`, `### Task: ${task}`, ""];
2941
+ const lines: string[] = [`## Project Context: ${project}`];
2942
+ if (task != null) lines.push(`### Task: ${task}`);
2943
+ lines.push("");
1823
2944
 
1824
2945
  for (const type of CONTEXT_TYPE_ORDER) {
1825
2946
  const group = budgetedMemories.filter((memory) => memory.type === type);
@@ -1832,11 +2953,83 @@ async function cmdContext(project: string, task: string) {
1832
2953
  lines.push("");
1833
2954
  }
1834
2955
 
1835
- lines.push(`*(${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used)*`);
2956
+ if (!hookMode) lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
1836
2957
 
1837
2958
  console.log(lines.join("\n"));
1838
2959
  }
1839
2960
 
2961
+ // --- cm why --------------------------------------------------------------
2962
+
2963
+ function fileMatchesPaths(filePaths: unknown, file: string, baseName: string): boolean {
2964
+ if (!Array.isArray(filePaths)) return false;
2965
+ return filePaths.some((p) => typeof p === "string" && (p === file || p.split("/").pop() === baseName));
2966
+ }
2967
+
2968
+ function fileMentionedInContent(content: string, file: string, baseName: string): boolean {
2969
+ const lower = content.toLowerCase();
2970
+ return lower.includes(file.toLowerCase()) || lower.includes(baseName.toLowerCase());
2971
+ }
2972
+
2973
+ async function cmdWhy(project: string, file: string) {
2974
+ const baseName = file.split("/").pop() ?? file;
2975
+
2976
+ const { data, error } = await supabase
2977
+ .from("memories")
2978
+ .select("*")
2979
+ .eq("project", project)
2980
+ .eq("resolved", false)
2981
+ .eq("suggested", false);
2982
+
2983
+ if (error) fail(`Failed to load memories: ${error.message}`);
2984
+
2985
+ const memories = data ?? [];
2986
+
2987
+ // file_paths (structured, from Item 6) and a content substring search
2988
+ // are combined into one match set rather than one gating the other as
2989
+ // a strict fallback — most memories predate file_paths existing at all
2990
+ // (manual saves and seeds never set it), so content search is the
2991
+ // primary signal for everything except recent hook-captured commits,
2992
+ // not a rare last resort.
2993
+ const matched = memories.filter(
2994
+ (memory) =>
2995
+ fileMatchesPaths(memory.file_paths, file, baseName) ||
2996
+ fileMentionedInContent(memory.content, file, baseName)
2997
+ );
2998
+
2999
+ if (matched.length === 0) {
3000
+ console.log(`No memories found for ${file}.`);
3001
+ console.log("Make some commits touching this file to build up context.");
3002
+ return;
3003
+ }
3004
+
3005
+ const config = readStackmemConfig();
3006
+ const scoredMemories = matched
3007
+ .map((memory) => ({
3008
+ ...memory,
3009
+ decay_score: computeDecayScore(
3010
+ parseUtcDate(memory.created_at),
3011
+ memory.access_count ?? 0,
3012
+ memory.pinned ?? false,
3013
+ config.decayLambda
3014
+ ),
3015
+ }))
3016
+ .sort((a, b) => b.decay_score - a.decay_score);
3017
+
3018
+ console.log(`Decisions and constraints tied to ${file}:\n`);
3019
+
3020
+ for (const type of MEMORY_TYPES) {
3021
+ const group = scoredMemories.filter((memory) => memory.type === type);
3022
+ if (group.length === 0) continue;
3023
+
3024
+ console.log(`${TYPE_LABELS[type]} (${group.length})`);
3025
+ console.log("-".repeat(TYPE_LABELS[type].length + 4));
3026
+ for (const memory of group) {
3027
+ console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
3028
+ }
3029
+ console.log("");
3030
+ }
3031
+ }
3032
+
1840
3033
  // --- cm session-end --------------------------------------------------------
1841
3034
 
1842
3035
  interface SessionEndInput {
@@ -1885,6 +3078,10 @@ function parseSessionEndFlags(args: string[]): SessionEndInput {
1885
3078
  } else if (flag === "--approaches") {
1886
3079
  approaches = splitCommaList(value);
1887
3080
  i++;
3081
+ } else if (flag === "--hook") {
3082
+ // Recognized explicitly (not just silently skipped as an unknown
3083
+ // flag) — see the comment on cmdSessionEnd for why it's currently
3084
+ // a no-op here specifically.
1888
3085
  }
1889
3086
  }
1890
3087
 
@@ -1907,6 +3104,16 @@ async function readStdinJson(): Promise<Record<string, unknown> | null> {
1907
3104
  }
1908
3105
  }
1909
3106
 
3107
+ // --hook is accepted (see parseSessionEndFlags) but currently a no-op:
3108
+ // Claude Code's Stop hook pipes ITS OWN JSON payload on stdin (session
3109
+ // id, transcript path, cwd — see the Claude Code hooks docs), not the
3110
+ // {summary, errors_encountered, files_touched, approaches_tried} shape
3111
+ // this command expects. readStdinJson() parses that payload successfully
3112
+ // (it's valid JSON) but finds none of the fields it looks for, so every
3113
+ // array below comes back empty and this reports 0 memories saved — a
3114
+ // real, known limitation, not a bug. Extracting genuine learnings from
3115
+ // the Stop hook would mean parsing the session transcript itself, which
3116
+ // is out of scope here (Phase 2, per the user).
1910
3117
  async function cmdSessionEnd(project: string, input: Omit<SessionEndInput, "project">) {
1911
3118
  let memoriesSaved = 0;
1912
3119
  let errorsLogged = 0;
@@ -2020,6 +3227,136 @@ function isHookExecutable(hookPath: string): boolean {
2020
3227
  }
2021
3228
  }
2022
3229
 
3230
+ // --- cm status ---------------------------------------------------------
3231
+
3232
+ function hasMcpServersStackmemKey(configPath: string): boolean {
3233
+ if (!nodeFs.existsSync(configPath)) return false;
3234
+ try {
3235
+ const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
3236
+ return Boolean(config?.mcpServers?.stackmem);
3237
+ } catch {
3238
+ return false;
3239
+ }
3240
+ }
3241
+
3242
+ function hasContextServersStackmemKey(configPath: string): boolean {
3243
+ if (!nodeFs.existsSync(configPath)) return false;
3244
+ try {
3245
+ const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
3246
+ return Boolean(config?.context_servers?.stackmem);
3247
+ } catch {
3248
+ return false;
3249
+ }
3250
+ }
3251
+
3252
+ const EDITOR_STATUS_CHECKS: { name: string; isRegistered: (homeDir: string) => boolean }[] = [
3253
+ { name: "Claude Code", isRegistered: (homeDir) => isMcpRegistered(nodePath.join(homeDir, ".claude.json")) },
3254
+ { name: "Cursor", isRegistered: (homeDir) => hasMcpServersStackmemKey(nodePath.join(homeDir, ".cursor", "mcp.json")) },
3255
+ {
3256
+ name: "Windsurf",
3257
+ isRegistered: (homeDir) =>
3258
+ hasMcpServersStackmemKey(nodePath.join(homeDir, ".codeium", "windsurf", "mcp_config.json")),
3259
+ },
3260
+ {
3261
+ name: "VS Code",
3262
+ isRegistered: (homeDir) => {
3263
+ const dir = findCopilotChatExtensionDir(homeDir);
3264
+ return dir ? hasMcpServersStackmemKey(nodePath.join(dir, "mcp.json")) : false;
3265
+ },
3266
+ },
3267
+ {
3268
+ name: "Zed",
3269
+ isRegistered: (homeDir) => hasContextServersStackmemKey(nodePath.join(homeDir, ".config", "zed", "settings.json")),
3270
+ },
3271
+ ];
3272
+
3273
+ async function cmdStatus(project: string): Promise<void> {
3274
+ const { count: totalCount, error: totalError } = await supabase
3275
+ .from("memories")
3276
+ .select("id", { count: "exact", head: true })
3277
+ .eq("project", project)
3278
+ .eq("resolved", false);
3279
+ if (totalError) fail(`Failed to load memory count: ${totalError.message}`);
3280
+
3281
+ const { count: pinnedCount, error: pinnedError } = await supabase
3282
+ .from("memories")
3283
+ .select("id", { count: "exact", head: true })
3284
+ .eq("project", project)
3285
+ .eq("resolved", false)
3286
+ .eq("pinned", true);
3287
+ if (pinnedError) fail(`Failed to load pinned count: ${pinnedError.message}`);
3288
+
3289
+ const { count: suggestedCount, error: suggestedError } = await supabase
3290
+ .from("memories")
3291
+ .select("id", { count: "exact", head: true })
3292
+ .eq("project", project)
3293
+ .eq("resolved", false)
3294
+ .eq("suggested", true);
3295
+ if (suggestedError) fail(`Failed to load suggested count: ${suggestedError.message}`);
3296
+
3297
+ const { count: fixesCount, error: fixesError } = await supabase
3298
+ .from("execution_log")
3299
+ .select("id", { count: "exact", head: true })
3300
+ .eq("project", project)
3301
+ .eq("resolved", false);
3302
+ if (fixesError) fail(`Failed to load fixes count: ${fixesError.message}`);
3303
+
3304
+ const { data: lastCaptureRows, error: lastCaptureError } = await supabase
3305
+ .from("memories")
3306
+ .select("content, created_at")
3307
+ .eq("project", project)
3308
+ .order("created_at", { ascending: false })
3309
+ .limit(1);
3310
+ if (lastCaptureError) fail(`Failed to load last capture: ${lastCaptureError.message}`);
3311
+
3312
+ const config = readStackmemConfig();
3313
+ const configPath = nodePath.join(process.cwd(), STACKMEM_CONFIG_FILENAME);
3314
+ let usesCustomBudget = false;
3315
+ if (nodeFs.existsSync(configPath)) {
3316
+ try {
3317
+ const raw = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
3318
+ usesCustomBudget = typeof raw.tokenBudget === "number";
3319
+ } catch {
3320
+ // Malformed config — readStackmemConfig already falls back to the
3321
+ // default in this case too, so "default" is the accurate label.
3322
+ }
3323
+ }
3324
+
3325
+ const hookOk = isHookExecutable(nodePath.join(process.cwd(), ".git", "hooks", "post-commit"));
3326
+ const mcpOk = isMcpRegistered(getClaudeConfigPath());
3327
+ const backendOk = await verifyBackendConnection(project);
3328
+
3329
+ const homeDir = nodeOs.homedir();
3330
+ const registeredEditors = EDITOR_STATUS_CHECKS.filter((e) => e.isRegistered(homeDir)).map((e) => e.name);
3331
+
3332
+ console.log(`stackmem status for "${project}"`);
3333
+ console.log("━".repeat(27));
3334
+ console.log(
3335
+ `Memories: ${totalCount ?? 0} total (${pinnedCount ?? 0} pinned, ${suggestedCount ?? 0} suggested)`
3336
+ );
3337
+ console.log(`Fixes: ${fixesCount ?? 0} recorded`);
3338
+ console.log(`Token budget: ${config.tokenBudget} (${usesCustomBudget ? "from .stackmem.json" : "default"})`);
3339
+
3340
+ const lastCapture = (lastCaptureRows ?? [])[0];
3341
+ if (lastCapture) {
3342
+ const ago = formatRelativeTime(parseUtcDate(lastCapture.created_at));
3343
+ const snippet =
3344
+ lastCapture.content.length > 50 ? `${lastCapture.content.slice(0, 50)}…` : lastCapture.content;
3345
+ console.log(`Last capture: ${ago} (${snippet})`);
3346
+ } else {
3347
+ console.log("Last capture: none yet");
3348
+ }
3349
+
3350
+ console.log(`Hook: ${hookOk ? "✓ installed" : "✗ not installed"}`);
3351
+ console.log(`MCP: ${mcpOk ? "✓ registered" : "✗ not registered"}`);
3352
+ console.log(`Backend: ${backendOk ? "✓ connected" : "✗ not connected"}`);
3353
+ console.log(
3354
+ `Editors: ${
3355
+ registeredEditors.length > 0 ? registeredEditors.map((name) => `${name} ✓`).join(" ") : "(none registered)"
3356
+ }`
3357
+ );
3358
+ }
3359
+
2023
3360
  async function cmdDoctor(fix: boolean) {
2024
3361
  let issues = 0;
2025
3362
  const project = readProjectFromFile();
@@ -2087,7 +3424,9 @@ async function cmdDoctor(fix: boolean) {
2087
3424
  nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
2088
3425
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
2089
3426
  nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
2090
- installHook(hooksDir, hookLogicPath);
3427
+ for (const name of MANAGED_HOOK_NAMES) {
3428
+ installHook(hooksDir, hookLogicPath, name);
3429
+ }
2091
3430
 
2092
3431
  if (isHookExecutable(hookPath)) {
2093
3432
  console.log("Fixed: git hook");
@@ -2284,8 +3623,10 @@ async function cmdUninstall(project: string, targetPath: string) {
2284
3623
  const gitDir = nodePath.join(targetPath, ".git");
2285
3624
  const hooksDir = nodePath.join(gitDir, "hooks");
2286
3625
 
2287
- const hookResult = uninstallHook(hooksDir);
2288
- if (hookResult) console.log(`Removed ${hookResult}`);
3626
+ for (const hookName of MANAGED_HOOK_NAMES) {
3627
+ const hookResult = uninstallHook(hooksDir, hookName);
3628
+ if (hookResult) console.log(`Removed ${hookResult}`);
3629
+ }
2289
3630
 
2290
3631
  const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
2291
3632
 
@@ -2330,19 +3671,34 @@ async function main() {
2330
3671
 
2331
3672
  switch (command) {
2332
3673
  case "start": {
2333
- const split = splitOptionalProject(args, 0);
3674
+ const hookIndex = args.indexOf("--hook");
3675
+ const hookMode = hookIndex !== -1;
3676
+ const positional = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
3677
+
3678
+ const split = splitOptionalProject(positional, 0);
2334
3679
  if (!split) usage();
2335
3680
  const project = requireProject(split.project);
2336
- await cmdStart(project);
3681
+ await cmdStart(project, hookMode);
2337
3682
  break;
2338
3683
  }
2339
3684
  case "save": {
2340
3685
  const forceIndex = args.indexOf("--force");
2341
3686
  const force = forceIndex !== -1;
2342
- const positional = force
3687
+ let positional = force
2343
3688
  ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
2344
3689
  : args;
2345
3690
 
3691
+ const commitShaFlag = extractValueFlag(positional, "--commit-sha");
3692
+ positional = commitShaFlag.rest;
3693
+ const authorFlag = extractValueFlag(positional, "--author");
3694
+ positional = authorFlag.rest;
3695
+ const sourceFlag = extractValueFlag(positional, "--source");
3696
+ positional = sourceFlag.rest;
3697
+ const confidenceFlag = extractValueFlag(positional, "--confidence");
3698
+ positional = confidenceFlag.rest;
3699
+ const filesFlag = extractValueFlag(positional, "--files");
3700
+ positional = filesFlag.rest;
3701
+
2346
3702
  let explicitProject: string | null;
2347
3703
  let type: string | undefined;
2348
3704
  let rest: string[];
@@ -2367,7 +3723,28 @@ async function main() {
2367
3723
 
2368
3724
  if (!type || rest.length === 0) usage();
2369
3725
  const project = requireProject(explicitProject);
2370
- await cmdSave(project, type, rest.join(" "), force);
3726
+
3727
+ const provenance: SaveProvenance = {};
3728
+ if (commitShaFlag.value) provenance.commitSha = commitShaFlag.value;
3729
+ if (authorFlag.value) provenance.author = authorFlag.value;
3730
+ if (sourceFlag.value === "hook" || sourceFlag.value === "manual" || sourceFlag.value === "mcp") {
3731
+ provenance.source = sourceFlag.value;
3732
+ }
3733
+ if (confidenceFlag.value) {
3734
+ const parsed = Number(confidenceFlag.value);
3735
+ if (!Number.isNaN(parsed)) provenance.confidence = parsed;
3736
+ }
3737
+ if (filesFlag.value) {
3738
+ provenance.filePaths = filesFlag.value.split(",").map((f) => f.trim()).filter(Boolean);
3739
+ }
3740
+
3741
+ await cmdSave(project, type, rest.join(" "), force, provenance);
3742
+ break;
3743
+ }
3744
+ case "check-commit": {
3745
+ const [project, commitSha] = args;
3746
+ if (!project || !commitSha) usage();
3747
+ await cmdCheckCommit(project, commitSha);
2371
3748
  break;
2372
3749
  }
2373
3750
  case "fix": {
@@ -2401,6 +3778,37 @@ async function main() {
2401
3778
  await cmdResolve(project, memoryId);
2402
3779
  break;
2403
3780
  }
3781
+ case "pin": {
3782
+ const split = splitOptionalProject(args, 1);
3783
+ if (!split) usage();
3784
+ const project = requireProject(split.project);
3785
+ const [memoryId] = split.rest;
3786
+ await cmdPin(project, memoryId);
3787
+ break;
3788
+ }
3789
+ case "unpin": {
3790
+ const split = splitOptionalProject(args, 1);
3791
+ if (!split) usage();
3792
+ const project = requireProject(split.project);
3793
+ const [memoryId] = split.rest;
3794
+ await cmdUnpin(project, memoryId);
3795
+ break;
3796
+ }
3797
+ case "review": {
3798
+ const split = splitProjectFromVariadic(args);
3799
+ if (split.rest.length > 0) usage();
3800
+ const project = requireProject(split.project);
3801
+ await cmdReview(project);
3802
+ break;
3803
+ }
3804
+ case "edit": {
3805
+ const split = splitOptionalProject(args, 1);
3806
+ if (!split) usage();
3807
+ const project = requireProject(split.project);
3808
+ const [memoryId] = split.rest;
3809
+ await cmdEdit(project, memoryId);
3810
+ break;
3811
+ }
2404
3812
  case "delete": {
2405
3813
  const split = splitOptionalProject(args, 1);
2406
3814
  if (!split) usage();
@@ -2425,9 +3833,15 @@ async function main() {
2425
3833
  break;
2426
3834
  }
2427
3835
  case "init": {
2428
- const [project, targetPath] = args;
3836
+ const configIndex = args.indexOf("--config");
3837
+ const writeConfig = configIndex !== -1;
3838
+ const positional = writeConfig
3839
+ ? [...args.slice(0, configIndex), ...args.slice(configIndex + 1)]
3840
+ : args;
3841
+
3842
+ const [project, targetPath] = positional;
2429
3843
  if (!project || !targetPath) usage();
2430
- await cmdInit(project, targetPath);
3844
+ await cmdInit(project, targetPath, writeConfig);
2431
3845
  break;
2432
3846
  }
2433
3847
  case "link": {
@@ -2435,6 +3849,29 @@ async function main() {
2435
3849
  await cmdLink();
2436
3850
  break;
2437
3851
  }
3852
+ case "open": {
3853
+ const split = splitProjectFromVariadic(args);
3854
+ if (split.rest.length > 0) usage();
3855
+ const project = requireProject(split.project);
3856
+ await cmdOpen(project);
3857
+ break;
3858
+ }
3859
+ case "purge": {
3860
+ const allIndex = args.indexOf("--all");
3861
+ const isAll = allIndex !== -1;
3862
+ const withoutAllFlag = isAll ? [...args.slice(0, allIndex), ...args.slice(allIndex + 1)] : args;
3863
+
3864
+ const split = splitProjectFromVariadic(withoutAllFlag);
3865
+ if (split.rest.length > 0) usage();
3866
+
3867
+ if (isAll) {
3868
+ await cmdPurgeAll();
3869
+ } else {
3870
+ const project = requireProject(split.project);
3871
+ await cmdPurgeProject(project);
3872
+ }
3873
+ break;
3874
+ }
2438
3875
  case "sync": {
2439
3876
  if (args.length > 0) usage();
2440
3877
  const project = requireProject(null);
@@ -2442,10 +3879,24 @@ async function main() {
2442
3879
  break;
2443
3880
  }
2444
3881
  case "context": {
3882
+ const hookIndex = args.indexOf("--hook");
3883
+ const hookMode = hookIndex !== -1;
3884
+ const withoutHookFlag = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
3885
+
3886
+ const split = splitProjectFromVariadic(withoutHookFlag);
3887
+ const project = requireProject(split.project);
3888
+ // task is optional only in --hook mode (the PreCompact hook has no
3889
+ // user-typed task to pass) — interactive use still requires one.
3890
+ if (split.rest.length === 0 && !hookMode) usage();
3891
+ const task = split.rest.length > 0 ? split.rest.join(" ") : null;
3892
+ await cmdContext(project, task, hookMode);
3893
+ break;
3894
+ }
3895
+ case "why": {
2445
3896
  const split = splitProjectFromVariadic(args);
2446
3897
  const project = requireProject(split.project);
2447
3898
  if (split.rest.length === 0) usage();
2448
- await cmdContext(project, split.rest.join(" "));
3899
+ await cmdWhy(project, split.rest.join(" "));
2449
3900
  break;
2450
3901
  }
2451
3902
  case "session-end": {
@@ -2457,6 +3908,13 @@ async function main() {
2457
3908
  await cmdDoctor(fix);
2458
3909
  break;
2459
3910
  }
3911
+ case "status": {
3912
+ const split = splitProjectFromVariadic(args);
3913
+ if (split.rest.length > 0) usage();
3914
+ const project = requireProject(split.project);
3915
+ await cmdStatus(project);
3916
+ break;
3917
+ }
2460
3918
  case "undo": {
2461
3919
  const split = splitOptionalProject(args, 0);
2462
3920
  if (!split) usage();