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/dist/cli.js CHANGED
@@ -2,14 +2,25 @@
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
- import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, redactSecrets, scoreMemoryRelevance, } from "./scoring.js";
13
+ import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, hasTokenOverlap, isQualityMessage, parseUtcDate, redactSecrets, scoreMemoryRelevance, tokenize, } from "./scoring.js";
12
14
  import { supabase } from "./storage.js";
15
+ // Checked as early as ESM import hoisting allows — before any command
16
+ // dispatch, config loading, or network call — so an unsupported Node
17
+ // version fails with a clear message instead of an obscure syntax or
18
+ // API error from deeper in the dependency chain.
19
+ const NODE_MAJOR_VERSION = Number(process.versions.node.split(".")[0]);
20
+ if (NODE_MAJOR_VERSION < 18) {
21
+ console.error(`Error: stackmem requires Node 18 or higher.\nYou are running Node ${process.versions.node}.\nPlease upgrade: https://nodejs.org`);
22
+ process.exit(1);
23
+ }
13
24
  const CONTEXT_BUDGET_TOKENS = 2000;
14
25
  const COMPRESSION_THRESHOLD = 20;
15
26
  const CLI_PATH = fileURLToPath(import.meta.url);
@@ -24,7 +35,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
24
35
  const STACKMEM_HOOK_MARKER = "# stackmem-managed";
25
36
  const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
26
37
  const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
27
- const HOOK_BACKUP_FILENAME = "post-commit.pre-stackmem";
38
+ const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
28
39
  const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"];
29
40
  const TYPE_LABELS = {
30
41
  decision: "Decisions",
@@ -34,22 +45,30 @@ const TYPE_LABELS = {
34
45
  };
35
46
  function usage() {
36
47
  console.error(`Usage:
37
- cm start [project]
48
+ cm start [project] [--hook]
38
49
  cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
39
50
  cm fix [project] <problem> <solution>
40
51
  cm analyze [project] <path>
41
52
  cm search [--project <name>] <query>
42
53
  cm resolve [project] <memory-id>
54
+ cm pin [project] <memory-id>
55
+ cm unpin [project] <memory-id>
56
+ cm review [--project <name>]
57
+ cm edit [project] <memory-id>
43
58
  cm delete [project] <memory-id>
44
59
  cm inspect [project] <memory-id>
45
60
  cm compress [project]
46
- cm init <project> <path>
61
+ cm init <project> <path> [--config]
47
62
  cm uninstall [project] [path]
48
63
  cm link
64
+ cm open [--project <name>]
65
+ cm purge [--project <name>] [--all]
49
66
  cm sync
50
- cm context [--project <name>] <task>
51
- cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
67
+ cm context [--project <name>] <task> [--hook]
68
+ cm why [--project <name>] <file>
69
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
52
70
  cm doctor [--fix]
71
+ cm status [--project <name>]
53
72
  cm undo [project]
54
73
  cm export [filename]
55
74
  cm import [project] <filename>
@@ -70,11 +89,13 @@ function cmdHelp() {
70
89
  [project] is optional everywhere below if a .stackmem file exists in
71
90
  the current directory (written by 'cm init'). Otherwise pass it explicitly.
72
91
 
73
- cm start [project]
92
+ cm start [project] [--hook]
74
93
  Load all unresolved memories for a project, sorted by decay
75
94
  score and capped at a 2000 token context budget, grouped by
76
95
  type (decisions, constraints, discoveries, rejections), with
77
- linked memory ids shown under each entry.
96
+ linked memory ids shown under each entry. --hook switches to
97
+ plain-text output (no ids, scores, or separator lines) for use
98
+ as a Claude Code SessionStart hook command — see cm init.
78
99
 
79
100
  cm save [project] <type> <content> [--force]
80
101
  Save a new memory. <type> is one of: decision, rejection,
@@ -98,6 +119,26 @@ function cmdHelp() {
98
119
  cm resolve [project] <memory-id>
99
120
  Mark a memory as resolved.
100
121
 
122
+ cm pin [project] <memory-id>
123
+ Pin a memory: it scores 999 in decay ranking (always sorts
124
+ first, never decays) and shows in its own "Pinned" section at
125
+ the top of 'cm start', ahead of its normal type group.
126
+
127
+ cm unpin [project] <memory-id>
128
+ Un-pin a memory, returning it to normal decay-based ranking.
129
+
130
+ cm review [--project <name>]
131
+ Walk through suggested memories one at a time (hook-captured
132
+ memories start as suggested — see cm save/cm init). For each:
133
+ (k)eep promotes it to permanent, (e)dit opens $EDITOR (or an
134
+ inline prompt) for new content then keeps it, (d)rop deletes
135
+ it, (s)kip leaves it suggested for next time, (q)uit stops and
136
+ reports progress so far. Prints a kept/dropped/skipped summary.
137
+
138
+ cm edit [project] <memory-id>
139
+ Open a memory's content in $EDITOR (or an inline prompt if
140
+ $EDITOR isn't set), save the result.
141
+
101
142
  cm delete [project] <memory-id>
102
143
  Delete a memory and any memory_links referencing it.
103
144
 
@@ -112,7 +153,7 @@ function cmdHelp() {
112
153
  Cluster related unresolved memories (needs 20+) and collapse
113
154
  clusters of 3 or more into a single summary memory.
114
155
 
115
- cm init <project> <path>
156
+ cm init <project> <path> [--config]
116
157
  Install a git post-commit hook in the repo at <path>, register
117
158
  the stackmem MCP server with Claude Code, write a CLAUDE.md,
118
159
  .cursorrules, .windsurfrules, and .stackmem file, and seed
@@ -120,7 +161,12 @@ function cmdHelp() {
120
161
  .windsurfrules are pre-filled with the project's current
121
162
  memories (for Cursor and Windsurf) and skipped if they already
122
163
  exist. The post-commit hook keeps them fresh afterward by
123
- running 'cm sync' on every commit.
164
+ running 'cm sync' on every commit — on disk only, never
165
+ committed, so they don't double your commit count or conflict
166
+ across branches. Both files are added to .gitignore. With
167
+ --config, also writes a default .stackmem.json (ignore patterns,
168
+ tokenBudget, decayLambda, captureTypes, minQualityScore — see
169
+ src/config-file.ts) if one doesn't already exist.
124
170
 
125
171
  cm uninstall [project] [path]
126
172
  Remove everything 'cm init' set up at <path> (defaults to the
@@ -140,19 +186,44 @@ function cmdHelp() {
140
186
  single-use and expires after 10 minutes; polling stops when it
141
187
  does. Links are per-device, so run this once on each machine.
142
188
 
189
+ cm open [--project <name>]
190
+ Open the hosted vault's page for this project in the default
191
+ browser (open on Mac, xdg-open on Linux, start on Windows).
192
+
193
+ cm purge [--project <name>] [--all]
194
+ Permanently delete memories, fixes, and memory_links — with a
195
+ typed confirmation first, never deletes on a flag alone.
196
+ --project (or the one in .stackmem): deletes that project's
197
+ data only, after you type the exact project name to confirm.
198
+ --all: deletes everything for this device's device_id across
199
+ every project, after you type DELETE to confirm. Prints the
200
+ count of deleted records, or "Confirmation did not match" if
201
+ you abort.
202
+
143
203
  cm sync
144
204
  Regenerate .cursorrules and .windsurfrules from the project's
145
205
  current unresolved memories (decay-ranked, capped at a 2000
146
206
  token budget) and its 5 most recent open execution_log fixes,
147
- overwriting both files. Project is read from .stackmem.
207
+ overwriting both files on disk only (never git add/commit).
208
+ Project is read from .stackmem.
148
209
 
149
- cm context [--project <name>] <task>
210
+ cm context [--project <name>] <task> [--hook]
150
211
  Return only the top 5 memories most relevant to a specific
151
212
  task, ranked by a blend of task relevance and decay score,
152
213
  as a markdown block capped at a 2000 token budget. Use
153
214
  --project to target a project other than the one in .stackmem.
154
-
155
- cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
215
+ <task> is optional with --hook (ranks by decay alone) — used
216
+ as a Claude Code PreCompact hook command; see cm init.
217
+
218
+ cm why [--project <name>] <file>
219
+ Show memories tied to a file: matched by Item 6's file_paths
220
+ column (hook-captured commits that touched it) plus a plain
221
+ content search for the file's path or basename, ranked by decay
222
+ score and grouped by type. Prints a guidance message instead if
223
+ nothing matches. Use --project to target a project other than
224
+ the one in .stackmem.
225
+
226
+ cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>] [--hook]
156
227
  Capture what happened in a session and extract memories: each
157
228
  error becomes an unsolved execution_log entry, each approach
158
229
  containing "failed", "didn't work", "reverted", or "switched
@@ -162,6 +233,11 @@ function cmdHelp() {
162
233
  Accepts the same fields as JSON on stdin instead of flags
163
234
  (project, summary, errors_encountered, files_touched,
164
235
  approaches_tried) when stdin is piped rather than a terminal.
236
+ --hook is accepted for use as a Claude Code Stop hook command
237
+ (see cm init) but is currently a no-op: Claude Code pipes its
238
+ own hook payload on stdin, not this command's expected shape,
239
+ so a Stop-triggered run captures 0 memories until a future
240
+ phase parses the session transcript directly.
165
241
 
166
242
  cm doctor [--fix]
167
243
  Check every integration point in the current directory — backend
@@ -174,6 +250,12 @@ function cmdHelp() {
174
250
  — those print guidance instead. Reports "Fixed: <item>" or
175
251
  "Could not auto-fix: <item>" for each failing check.
176
252
 
253
+ cm status [--project <name>]
254
+ One-screen project summary: memory/pinned/suggested counts,
255
+ recorded fixes, the active token budget (and whether it's the
256
+ default or from .stackmem.json), time since the last capture,
257
+ and hook/MCP/backend/editor registration status.
258
+
177
259
  cm undo [project]
178
260
  Delete the most recently saved unresolved memory (and any
179
261
  memory_links referencing it) for a project.
@@ -198,12 +280,33 @@ function fail(message) {
198
280
  }
199
281
  // --- optional project resolution ------------------------------------------
200
282
  const STACKMEM_FILE = ".stackmem";
283
+ const MONOREPO_PARENT_LOOKUP_DEPTH = 3;
284
+ // Checks cwd, then up to 3 parent directories, for a .stackmem file — a
285
+ // monorepo subpackage (e.g. packages/api) won't have its own .stackmem,
286
+ // but the repo root will. When found in an ancestor, the project name
287
+ // gets the intervening path segments appended, e.g. "myapp/packages/api"
288
+ // — so a subpackage still reads/writes under its own namespace within
289
+ // the shared project, rather than silently resolving to just "myapp".
201
290
  function readProjectFromFile() {
202
- const stackmemPath = nodePath.join(process.cwd(), STACKMEM_FILE);
203
- if (!nodeFs.existsSync(stackmemPath))
204
- return null;
205
- const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
206
- return content.length > 0 ? content : null;
291
+ let dir = process.cwd();
292
+ const subdirSegments = [];
293
+ for (let depth = 0; depth <= MONOREPO_PARENT_LOOKUP_DEPTH; depth++) {
294
+ const stackmemPath = nodePath.join(dir, STACKMEM_FILE);
295
+ if (nodeFs.existsSync(stackmemPath)) {
296
+ const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
297
+ if (content.length === 0)
298
+ return null;
299
+ return subdirSegments.length > 0
300
+ ? `${content}/${subdirSegments.reverse().join("/")}`
301
+ : content;
302
+ }
303
+ const parent = nodePath.dirname(dir);
304
+ if (parent === dir)
305
+ break; // reached the filesystem root
306
+ subdirSegments.push(nodePath.basename(dir));
307
+ dir = parent;
308
+ }
309
+ return null;
207
310
  }
208
311
  // Catches the common habit-slip of typing `cm fix . "problem" "solution"` by
209
312
  // muscle memory from `cm init <project> .` — "." (or "..", or anything
@@ -257,29 +360,186 @@ function splitProjectFromVariadic(args) {
257
360
  }
258
361
  return { project: null, rest: args };
259
362
  }
363
+ // Generic "--flag value" extractor for internal/provenance flags (not
364
+ // meant to be remembered by end users — only the hook and MCP server pass
365
+ // these). Unlike splitProjectFromVariadic's --project, these don't need
366
+ // special positional disambiguation, so one generic helper covers all of
367
+ // them: --commit-sha, --author, --source, --confidence, --files.
368
+ function extractValueFlag(args, flag) {
369
+ const idx = args.indexOf(flag);
370
+ if (idx === -1 || args[idx + 1] === undefined)
371
+ return { value: null, rest: args };
372
+ return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
373
+ }
260
374
  // --- cm start ----------------------------------------------------------
261
- async function cmdStart(project) {
375
+ const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
376
+ const BRANCH_NAME_BOOST = 1.2;
377
+ const RECENT_FILES_BOOST = 1.3;
378
+ const PR_TITLE_BOOST = 1.2;
379
+ // null means "signal unavailable" (git/gh failed, not enough history, gh
380
+ // not installed, no open PR, etc.) — every one of these is a soft skip,
381
+ // never an error, per the task spec.
382
+ function getBranchNameTokens() {
383
+ let branch;
384
+ try {
385
+ // symbolic-ref (not rev-parse --abbrev-ref) specifically because it
386
+ // fails on a detached HEAD rather than returning the literal string
387
+ // "HEAD" — that failure is the signal this needs to distinguish a
388
+ // real detached-HEAD state from "not a git repo at all" below.
389
+ branch = execFileSync("git", ["symbolic-ref", "--short", "HEAD"], {
390
+ encoding: "utf-8",
391
+ stdio: ["ignore", "pipe", "ignore"],
392
+ }).trim();
393
+ }
394
+ catch {
395
+ try {
396
+ execFileSync("git", ["rev-parse", "HEAD"], { stdio: "pipe" });
397
+ // HEAD resolves to a real commit but symbolic-ref failed — genuinely
398
+ // detached, not a missing repo. Still a valid state to rank in, not
399
+ // a reason to skip the signal entirely.
400
+ branch = "detached";
401
+ }
402
+ catch {
403
+ return null;
404
+ }
405
+ }
406
+ const tokens = tokenize(branch);
407
+ return tokens.size > 0 ? tokens : null;
408
+ }
409
+ function getRecentlyTouchedFiles() {
410
+ try {
411
+ const isShallow = execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
412
+ encoding: "utf-8",
413
+ stdio: ["ignore", "pipe", "ignore"],
414
+ }).trim();
415
+ // A shallow clone can't look back RECENT_COMMITS_FOR_FILE_SIGNAL
416
+ // commits — degrade gracefully rather than attempting (and either
417
+ // failing, or silently returning an incomplete picture of "recent").
418
+ if (isShallow === "true")
419
+ return null;
420
+ }
421
+ catch {
422
+ // --is-shallow-repository itself failing (very old git, or no repo at
423
+ // all) isn't a reason to skip on its own — the lookback below will
424
+ // fail (and get caught) or succeed on its own merits either way.
425
+ }
426
+ try {
427
+ const output = execFileSync("git", ["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
428
+ const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
429
+ return files.length > 0 ? new Set(files) : null;
430
+ }
431
+ catch {
432
+ // Fewer than RECENT_COMMITS_FOR_FILE_SIGNAL commits in history, or
433
+ // not a git repo at all — skip gracefully.
434
+ return null;
435
+ }
436
+ }
437
+ function getOpenPrTitleTokens() {
438
+ try {
439
+ const title = execFileSync("gh", ["pr", "view", "--json", "title", "-q", ".title"], {
440
+ encoding: "utf-8",
441
+ stdio: ["ignore", "pipe", "ignore"],
442
+ }).trim();
443
+ if (!title)
444
+ return null;
445
+ const tokens = tokenize(title);
446
+ return tokens.size > 0 ? tokens : null;
447
+ }
448
+ catch {
449
+ // gh not installed, not authenticated, or no open PR for this branch.
450
+ return null;
451
+ }
452
+ }
453
+ function fileListOverlaps(filePaths, recentFiles) {
454
+ if (!Array.isArray(filePaths))
455
+ return false;
456
+ return filePaths.some((candidate) => {
457
+ if (typeof candidate !== "string")
458
+ return false;
459
+ if (recentFiles.has(candidate))
460
+ return true;
461
+ const base = candidate.split("/").pop();
462
+ for (const recent of recentFiles) {
463
+ if (recent.split("/").pop() === base)
464
+ return true;
465
+ }
466
+ return false;
467
+ });
468
+ }
469
+ // Plain-text, no ids/scores/box-drawing — meant to be injected directly
470
+ // into a Claude Code SessionStart hook's context, not read by a human at
471
+ // a terminal (that's the default, non---hook rendering below).
472
+ function printStartHookOutput(project, pinnedMemories, unpinnedMemories, budgetedFixes) {
473
+ console.log(`# Project memory: ${project}`);
474
+ if (pinnedMemories.length > 0) {
475
+ console.log("");
476
+ console.log("## Pinned");
477
+ for (const memory of pinnedMemories) {
478
+ console.log(`- ${memory.content}`);
479
+ }
480
+ }
481
+ for (const type of MEMORY_TYPES) {
482
+ const group = unpinnedMemories.filter((m) => m.type === type);
483
+ if (group.length === 0)
484
+ continue;
485
+ console.log("");
486
+ console.log(`## ${TYPE_LABELS[type]}`);
487
+ for (const memory of group) {
488
+ console.log(`- ${memory.content}`);
489
+ }
490
+ }
491
+ if (budgetedFixes.length > 0) {
492
+ console.log("");
493
+ console.log("## Past Fixes");
494
+ for (const fix of budgetedFixes) {
495
+ console.log(`- ${fix.problem} -> ${fix.solution ?? "(pending)"}`);
496
+ }
497
+ }
498
+ }
499
+ async function cmdStart(project, hookMode = false) {
262
500
  const { data, error } = await supabase
263
501
  .from("memories")
264
502
  .select("*")
265
503
  .eq("project", project)
266
- .eq("resolved", false);
504
+ .eq("resolved", false)
505
+ .eq("suggested", false);
267
506
  if (error)
268
507
  fail(`Failed to load memories: ${error.message}`);
269
508
  const memories = data ?? [];
270
509
  if (memories.length === 0) {
271
- console.log(`No memories found for "${project}".`);
272
- console.log("");
273
- console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
510
+ if (!hookMode) {
511
+ console.log(`No memories found for "${project}".`);
512
+ console.log("");
513
+ console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
514
+ }
274
515
  return;
275
516
  }
517
+ // Task-aware re-ranking: boost memories relevant to what's actively
518
+ // being worked on, multiplicatively on top of the base decay score.
519
+ // Every signal degrades to null (no boost, not an error) if the
520
+ // repo/branch/gh state it needs isn't available.
521
+ const branchTokens = getBranchNameTokens();
522
+ const recentFiles = getRecentlyTouchedFiles();
523
+ const prTitleTokens = getOpenPrTitleTokens();
524
+ const config = readStackmemConfig();
525
+ const tokenBudget = config.tokenBudget;
276
526
  const scoredMemories = memories
277
- .map((memory) => ({
278
- ...memory,
279
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
280
- }))
527
+ .map((memory) => {
528
+ let decayScore = computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
529
+ const contentTokens = tokenize(memory.content);
530
+ if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
531
+ decayScore *= BRANCH_NAME_BOOST;
532
+ }
533
+ if (recentFiles && fileListOverlaps(memory.file_paths, recentFiles)) {
534
+ decayScore *= RECENT_FILES_BOOST;
535
+ }
536
+ if (prTitleTokens && hasTokenOverlap(contentTokens, prTitleTokens)) {
537
+ decayScore *= PR_TITLE_BOOST;
538
+ }
539
+ return { ...memory, decay_score: decayScore };
540
+ })
281
541
  .sort((a, b) => b.decay_score - a.decay_score);
282
- const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
542
+ const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
283
543
  const omittedCount = scoredMemories.length - budgetedMemories.length;
284
544
  let tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
285
545
  const { data: fixData, error: fixError } = await supabase
@@ -291,12 +551,12 @@ async function cmdStart(project) {
291
551
  .limit(5);
292
552
  if (fixError)
293
553
  fail(`Failed to load execution log: ${fixError.message}`);
294
- // Fixes share the same 2000-token budget as memories, which already
295
- // consumed their portion above — fixes only get whatever room is left.
554
+ // Fixes share the same token budget as memories, which already consumed
555
+ // their portion above — fixes only get whatever room is left.
296
556
  const budgetedFixes = [];
297
557
  for (const fix of fixData ?? []) {
298
558
  const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
299
- if (tokensUsed + tokens > CONTEXT_BUDGET_TOKENS)
559
+ if (tokensUsed + tokens > tokenBudget)
300
560
  break;
301
561
  budgetedFixes.push(fix);
302
562
  tokensUsed += tokens;
@@ -318,39 +578,58 @@ async function cmdStart(project) {
318
578
  linkedIdsByMemory.get(link.source_id).add(link.target_id);
319
579
  linkedIdsByMemory.get(link.target_id).add(link.source_id);
320
580
  }
321
- console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
322
- for (const type of MEMORY_TYPES) {
323
- const group = budgetedMemories.filter((m) => m.type === type);
324
- if (group.length === 0)
325
- continue;
326
- console.log(`${TYPE_LABELS[type]} (${group.length})`);
327
- console.log("-".repeat(TYPE_LABELS[type].length + 4));
328
- for (const memory of group) {
329
- console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
330
- const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
331
- if (linked.length > 0) {
332
- console.log(` linked: ${linked.join(", ")}`);
581
+ const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
582
+ const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
583
+ if (hookMode) {
584
+ printStartHookOutput(project, pinnedMemories, unpinnedMemories, budgetedFixes);
585
+ }
586
+ else {
587
+ console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
588
+ if (pinnedMemories.length > 0) {
589
+ console.log(`Pinned (${pinnedMemories.length})`);
590
+ console.log("-".repeat(11));
591
+ for (const memory of pinnedMemories) {
592
+ console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
593
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
594
+ if (linked.length > 0) {
595
+ console.log(` linked: ${linked.join(", ")}`);
596
+ }
333
597
  }
598
+ console.log("");
334
599
  }
335
- console.log("");
336
- }
337
- if (budgetedFixes.length > 0) {
338
- console.log(`Past Fixes (${budgetedFixes.length})`);
339
- console.log("-".repeat(14));
340
- for (const fix of budgetedFixes) {
341
- console.log(` Problem: ${fix.problem}`);
342
- console.log(` Solution: ${fix.solution ?? "(pending)"}`);
600
+ for (const type of MEMORY_TYPES) {
601
+ const group = unpinnedMemories.filter((m) => m.type === type);
602
+ if (group.length === 0)
603
+ continue;
604
+ console.log(`${TYPE_LABELS[type]} (${group.length})`);
605
+ console.log("-".repeat(TYPE_LABELS[type].length + 4));
606
+ for (const memory of group) {
607
+ console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
608
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
609
+ if (linked.length > 0) {
610
+ console.log(` linked: ${linked.join(", ")}`);
611
+ }
612
+ }
343
613
  console.log("");
344
614
  }
345
- }
346
- console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
347
- if (omittedCount > 0) {
348
- console.log(`${omittedCount} memories omitted (budget exceeded)`);
349
- }
350
- const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
351
- if (!hasTechStackMemory) {
352
- console.log("");
353
- console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
615
+ if (budgetedFixes.length > 0) {
616
+ console.log(`Past Fixes (${budgetedFixes.length})`);
617
+ console.log("-".repeat(14));
618
+ for (const fix of budgetedFixes) {
619
+ console.log(` Problem: ${fix.problem}`);
620
+ console.log(` Solution: ${fix.solution ?? "(pending)"}`);
621
+ console.log("");
622
+ }
623
+ }
624
+ console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
625
+ if (omittedCount > 0) {
626
+ console.log(`${omittedCount} memories omitted (budget exceeded)`);
627
+ }
628
+ const hasTechStackMemory = memories.some((m) => /^tech stack:/i.test(m.content));
629
+ if (!hasTechStackMemory) {
630
+ console.log("");
631
+ console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
632
+ }
354
633
  }
355
634
  const incrementResults = await Promise.all(budgetedMemories.map((memory) => supabase
356
635
  .from("memories")
@@ -386,7 +665,7 @@ async function confirmSupersede(oldId, newContent) {
386
665
  console.log("Invalid input — kept original memory.");
387
666
  return false;
388
667
  }
389
- async function cmdSave(project, type, content, force = false) {
668
+ async function cmdSave(project, type, content, force = false, provenance = {}) {
390
669
  if (!MEMORY_TYPES.includes(type)) {
391
670
  fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
392
671
  }
@@ -423,9 +702,29 @@ async function cmdSave(project, type, content, force = false) {
423
702
  if (resolveError)
424
703
  fail(`Failed to resolve superseded memory: ${resolveError.message}`);
425
704
  }
705
+ const { commitSha, filePaths, author, source = "manual", confidence = 1.0 } = provenance;
706
+ const insertPayload = {
707
+ project,
708
+ type,
709
+ content,
710
+ device_id: getDeviceId(),
711
+ source,
712
+ confidence,
713
+ // Hook-captured memories are heuristically detected and could be
714
+ // noisy or wrong — they start as suggested, pending `cm review`, and
715
+ // are excluded from cm start/context/why until promoted. Manual and
716
+ // mcp saves are already user-vetted, so they're permanent immediately.
717
+ suggested: source === "hook",
718
+ };
719
+ if (commitSha)
720
+ insertPayload.commit_sha = commitSha;
721
+ if (filePaths && filePaths.length > 0)
722
+ insertPayload.file_paths = filePaths;
723
+ if (author)
724
+ insertPayload.author = author;
426
725
  const { data: inserted, error } = await supabase
427
726
  .from("memories")
428
- .insert({ project, type, content, device_id: getDeviceId() })
727
+ .insert(insertPayload)
429
728
  .select("id")
430
729
  .single();
431
730
  if (error || !inserted)
@@ -455,13 +754,44 @@ async function cmdSave(project, type, content, force = false) {
455
754
  await compressMemories(project);
456
755
  }
457
756
  }
757
+ // --- cm check-commit (internal, used by the hook) --------------------------
758
+ // Not a user-facing command (no usage()/help entry) — the hook calls this
759
+ // once per invocation, before doing any diff analysis, to decide whether
760
+ // this commit_sha has already been captured. post-commit and post-rewrite
761
+ // both run the exact same capture logic against current HEAD, so without
762
+ // this guard, a commit that fires both hooks (e.g. `git commit --amend`,
763
+ // which triggers post-commit for the new commit AND post-rewrite for the
764
+ // rewrite) would be captured twice. Checked once per invocation — not
765
+ // inside each individual cmSave call — because a single commit normally
766
+ // produces several distinct memories; a per-save check would see the
767
+ // first save's own commit_sha and wrongly skip the rest.
768
+ async function cmdCheckCommit(project, commitSha) {
769
+ const { data, error } = await supabase
770
+ .from("memories")
771
+ .select("id")
772
+ .eq("project", project)
773
+ .eq("commit_sha", commitSha)
774
+ .limit(1);
775
+ if (error) {
776
+ // Fail open: an error here (including "column does not exist" if the
777
+ // commit_sha migration hasn't landed yet) must not look identical to
778
+ // "duplicate, skip" below — that would silently suppress every hook
779
+ // capture with no log trail. Exit 0 so the normal save path runs;
780
+ // if commit_sha genuinely isn't a column, the save itself fails
781
+ // there instead, where the hook's outer error handler logs it.
782
+ console.error(`check-commit: ${error.message}`);
783
+ process.exit(0);
784
+ }
785
+ process.exit(data && data.length > 0 ? 1 : 0);
786
+ }
458
787
  // --- memory compression ----------------------------------------------------
459
788
  async function compressMemories(project) {
460
789
  const { data, error } = await supabase
461
790
  .from("memories")
462
791
  .select("*")
463
792
  .eq("project", project)
464
- .eq("resolved", false);
793
+ .eq("resolved", false)
794
+ .eq("suggested", false);
465
795
  if (error)
466
796
  fail(`Failed to load memories for compression: ${error.message}`);
467
797
  const memories = data ?? [];
@@ -583,6 +913,28 @@ async function cmdResolve(project, memoryId) {
583
913
  }
584
914
  console.log(`Resolved memory [${memoryId}]`);
585
915
  }
916
+ // --- cm pin / cm unpin -------------------------------------------------
917
+ async function setPinned(project, memoryId, pinned) {
918
+ const { data, error } = await supabase
919
+ .from("memories")
920
+ .update({ pinned })
921
+ .eq("project", project)
922
+ .eq("id", memoryId)
923
+ .select("id");
924
+ if (error)
925
+ fail(`Failed to ${pinned ? "pin" : "unpin"} memory: ${error.message}`);
926
+ if (!data || data.length === 0) {
927
+ console.log(`No memory found with ID [${memoryId}]`);
928
+ return;
929
+ }
930
+ console.log(`${pinned ? "Pinned" : "Unpinned"} memory [${memoryId}]`);
931
+ }
932
+ async function cmdPin(project, memoryId) {
933
+ await setPinned(project, memoryId, true);
934
+ }
935
+ async function cmdUnpin(project, memoryId) {
936
+ await setPinned(project, memoryId, false);
937
+ }
586
938
  // --- cm delete -------------------------------------------------------------
587
939
  async function cmdDelete(project, memoryId) {
588
940
  const { error: linksError } = await supabase
@@ -605,9 +957,162 @@ async function cmdDelete(project, memoryId) {
605
957
  }
606
958
  console.log(`Deleted memory [${memoryId}]`);
607
959
  }
960
+ // --- shared: relative time + $EDITOR prompt (cm review, cm edit, cm status)
961
+ function formatRelativeTime(date) {
962
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
963
+ if (seconds < 60)
964
+ return `${seconds} second${seconds === 1 ? "" : "s"} ago`;
965
+ const minutes = Math.floor(seconds / 60);
966
+ if (minutes < 60)
967
+ return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
968
+ const hours = Math.floor(minutes / 60);
969
+ if (hours < 24)
970
+ return `${hours} hour${hours === 1 ? "" : "s"} ago`;
971
+ const days = Math.floor(hours / 24);
972
+ return `${days} day${days === 1 ? "" : "s"} ago`;
973
+ }
974
+ // Opens $EDITOR on a scratch file pre-filled with the current content, or
975
+ // falls back to an inline readline prompt when $EDITOR isn't set. Returns
976
+ // the original content unchanged if the result is blank (an empty save
977
+ // buffer almost certainly means "I didn't mean to clear this", not "make
978
+ // this memory empty").
979
+ async function editContentInteractively(currentContent) {
980
+ const editor = process.env.EDITOR;
981
+ if (editor) {
982
+ const tmpFile = nodePath.join(nodeOs.tmpdir(), `stackmem-edit-${randomInt(1_000_000_000)}.txt`);
983
+ nodeFs.writeFileSync(tmpFile, currentContent, "utf-8");
984
+ try {
985
+ execFileSync(editor, [tmpFile], { stdio: "inherit" });
986
+ const edited = nodeFs.readFileSync(tmpFile, "utf-8").trim();
987
+ return edited.length > 0 ? edited : currentContent;
988
+ }
989
+ finally {
990
+ try {
991
+ nodeFs.unlinkSync(tmpFile);
992
+ }
993
+ catch {
994
+ // Best-effort cleanup — a leftover temp file isn't worth failing on.
995
+ }
996
+ }
997
+ }
998
+ console.log(`Current: ${currentContent}`);
999
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1000
+ const answer = (await rl.question("New content (leave blank to keep unchanged): ")).trim();
1001
+ rl.close();
1002
+ return answer.length > 0 ? answer : currentContent;
1003
+ }
1004
+ // --- cm review -------------------------------------------------------------
1005
+ async function cmdReview(project) {
1006
+ const { data, error } = await supabase
1007
+ .from("memories")
1008
+ .select("*")
1009
+ .eq("project", project)
1010
+ .eq("resolved", false)
1011
+ .eq("suggested", true)
1012
+ .order("created_at", { ascending: true });
1013
+ if (error)
1014
+ fail(`Failed to load suggested memories: ${error.message}`);
1015
+ const memories = data ?? [];
1016
+ if (memories.length === 0) {
1017
+ console.log(`No memories to review for "${project}".`);
1018
+ console.log("Your inbox is empty.");
1019
+ return;
1020
+ }
1021
+ console.log(`Reviewing ${memories.length} suggested memories for "${project}"`);
1022
+ console.log("━".repeat(40));
1023
+ // Node's readline can't reliably answer multiple sequential
1024
+ // rl.question() calls over piped (non-TTY) stdin — all buffered input
1025
+ // arrives before the second question is even issued, and it never
1026
+ // resolves. A real interactive terminal doesn't hit this (keystrokes
1027
+ // arrive one at a time), so only the non-TTY path needs the
1028
+ // read-everything-up-front workaround.
1029
+ const rl = process.stdin.isTTY ? createInterface({ input: process.stdin, output: process.stdout }) : null;
1030
+ const pipedAnswers = rl
1031
+ ? []
1032
+ : (await new Promise((resolve) => {
1033
+ let buf = "";
1034
+ process.stdin.setEncoding("utf-8");
1035
+ process.stdin.on("data", (chunk) => (buf += chunk));
1036
+ process.stdin.on("end", () => resolve(buf));
1037
+ })).split("\n");
1038
+ async function nextAnswer(prompt) {
1039
+ if (rl)
1040
+ return (await rl.question(prompt)).trim().toLowerCase();
1041
+ console.log(prompt);
1042
+ return (pipedAnswers.shift() ?? "q").trim().toLowerCase();
1043
+ }
1044
+ let kept = 0;
1045
+ let dropped = 0;
1046
+ let skipped = 0;
1047
+ for (let i = 0; i < memories.length; i++) {
1048
+ const memory = memories[i];
1049
+ console.log(`[${i + 1}/${memories.length}] ${memory.type}: ${memory.content}`);
1050
+ console.log(` Captured: ${formatRelativeTime(parseUtcDate(memory.created_at))}`);
1051
+ console.log("");
1052
+ const answer = await nextAnswer("(k)eep (e)dit (d)rop (s)kip (q)uit > ");
1053
+ console.log("");
1054
+ if (answer === "k") {
1055
+ const { error: updateError } = await supabase
1056
+ .from("memories")
1057
+ .update({ suggested: false })
1058
+ .eq("id", memory.id);
1059
+ if (!updateError)
1060
+ kept++;
1061
+ }
1062
+ else if (answer === "e") {
1063
+ const newContent = redactSecrets(await editContentInteractively(memory.content));
1064
+ const { error: updateError } = await supabase
1065
+ .from("memories")
1066
+ .update({ content: newContent, suggested: false })
1067
+ .eq("id", memory.id);
1068
+ if (!updateError)
1069
+ kept++;
1070
+ }
1071
+ else if (answer === "d") {
1072
+ await supabase
1073
+ .from("memory_links")
1074
+ .delete()
1075
+ .or(`source_id.eq.${memory.id},target_id.eq.${memory.id}`);
1076
+ const { error: deleteError } = await supabase.from("memories").delete().eq("id", memory.id);
1077
+ if (!deleteError)
1078
+ dropped++;
1079
+ }
1080
+ else if (answer === "q") {
1081
+ break;
1082
+ }
1083
+ else {
1084
+ // "s", blank, or anything unrecognized — leave it suggested and
1085
+ // move on rather than erroring on a mistyped key.
1086
+ skipped++;
1087
+ }
1088
+ }
1089
+ rl?.close();
1090
+ console.log(`Review complete. Kept ${kept}, dropped ${dropped}, skipped ${skipped}.`);
1091
+ }
1092
+ // --- cm edit ---------------------------------------------------------------
1093
+ async function cmdEdit(project, memoryId) {
1094
+ const { data: memory, error } = await supabase
1095
+ .from("memories")
1096
+ .select("id, content")
1097
+ .eq("project", project)
1098
+ .eq("id", memoryId)
1099
+ .single();
1100
+ if (error || !memory) {
1101
+ console.log(`No memory found with ID [${memoryId}]`);
1102
+ return;
1103
+ }
1104
+ const newContent = redactSecrets(await editContentInteractively(memory.content));
1105
+ const { error: updateError } = await supabase
1106
+ .from("memories")
1107
+ .update({ content: newContent })
1108
+ .eq("id", memoryId);
1109
+ if (updateError)
1110
+ fail(`Failed to update memory: ${updateError.message}`);
1111
+ console.log(`Updated memory [${memoryId}].`);
1112
+ }
608
1113
  // --- cm inspect ------------------------------------------------------------
609
1114
  function formatDate(dateString) {
610
- return new Date(dateString).toLocaleDateString("en-US", {
1115
+ return parseUtcDate(dateString).toLocaleDateString("en-US", {
611
1116
  month: "short",
612
1117
  day: "numeric",
613
1118
  year: "numeric",
@@ -622,7 +1127,8 @@ async function cmdInspect(project, memoryId) {
622
1127
  .single();
623
1128
  if (error || !memory)
624
1129
  fail(`Memory [${memoryId}] not found for "${project}".`);
625
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
1130
+ const config = readStackmemConfig();
1131
+ const decayScore = computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
626
1132
  const { data: links, error: linksError } = await supabase
627
1133
  .from("memory_links")
628
1134
  .select("source_id, target_id, score")
@@ -711,6 +1217,65 @@ try {
711
1217
 
712
1218
  const CLI_PATH = ${JSON.stringify(cliPath)};
713
1219
 
1220
+ // Inlined copy of src/config-file.ts's defaults/merge logic — this script
1221
+ // runs standalone with no import access to that module.
1222
+ const DEFAULT_STACKMEM_CONFIG = {
1223
+ ignore: [],
1224
+ tokenBudget: 2000,
1225
+ decayLambda: 0.05,
1226
+ captureTypes: ["decision", "constraint", "discovery", "rejection"],
1227
+ minQualityScore: 0.3,
1228
+ };
1229
+
1230
+ function isStringArray(value) {
1231
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
1232
+ }
1233
+
1234
+ function readStackmemConfig() {
1235
+ const configPath = join(process.cwd(), ".stackmem.json");
1236
+ try {
1237
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
1238
+ return {
1239
+ ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
1240
+ tokenBudget:
1241
+ typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
1242
+ ? raw.tokenBudget
1243
+ : DEFAULT_STACKMEM_CONFIG.tokenBudget,
1244
+ decayLambda:
1245
+ typeof raw.decayLambda === "number" && raw.decayLambda > 0
1246
+ ? raw.decayLambda
1247
+ : DEFAULT_STACKMEM_CONFIG.decayLambda,
1248
+ captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
1249
+ minQualityScore:
1250
+ typeof raw.minQualityScore === "number"
1251
+ ? raw.minQualityScore
1252
+ : DEFAULT_STACKMEM_CONFIG.minQualityScore,
1253
+ };
1254
+ } catch {
1255
+ return { ...DEFAULT_STACKMEM_CONFIG };
1256
+ }
1257
+ }
1258
+
1259
+ function matchesIgnorePattern(filePath, pattern) {
1260
+ const base = filePath.split("/").pop() ?? filePath;
1261
+
1262
+ if (pattern.endsWith("/")) {
1263
+ const dirName = pattern.slice(0, -1);
1264
+ return filePath.split("/").includes(dirName);
1265
+ }
1266
+
1267
+ if (pattern.includes("*")) {
1268
+ const regex = new RegExp(
1269
+ "^" + pattern.split("*").map((part) => part.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join(".*") + "$"
1270
+ );
1271
+ return regex.test(base) || regex.test(filePath);
1272
+ }
1273
+
1274
+ return filePath === pattern || base === pattern;
1275
+ }
1276
+
1277
+ const STACKMEM_CONFIG = readStackmemConfig();
1278
+
714
1279
  const SECRET_PATTERNS = [
715
1280
  /AKIA[0-9A-Z]{16}/g,
716
1281
  /(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
@@ -767,10 +1332,43 @@ function git(args) {
767
1332
  return execFileSync("git", args, { encoding: "utf-8" }).trim();
768
1333
  }
769
1334
 
1335
+ const CURRENT_SHA = git(["rev-parse", "HEAD"]);
1336
+ const CURRENT_AUTHOR = (() => {
1337
+ try {
1338
+ return git(["log", "--format=%ae", "-1", "HEAD"]);
1339
+ } catch {
1340
+ return "";
1341
+ }
1342
+ })();
1343
+
1344
+ // post-commit and post-rewrite both run this exact script against current
1345
+ // HEAD, so a single operation that fires both (e.g. \`git commit --amend\`)
1346
+ // would otherwise capture the same commit twice. Checked once up front,
1347
+ // not per-save — see cmdCheckCommit in src/cli.ts for why.
1348
+ try {
1349
+ execFileSync("node", [CLI_PATH, "check-commit", PROJECT, CURRENT_SHA], { stdio: "pipe" });
1350
+ } catch {
1351
+ return;
1352
+ }
1353
+
1354
+ // file_paths is populated with every file the commit touched (not just
1355
+ // the filtered diffableFiles below) — "cm why <file>" should still find
1356
+ // a commit's decision/discovery even if that specific file's own diff
1357
+ // content was skipped as noise (a lockfile, say).
770
1358
  function cmSave(type, content) {
771
- execFileSync("node", [CLI_PATH, "save", PROJECT, type, content, "--force"], {
772
- stdio: "inherit",
773
- });
1359
+ if (!STACKMEM_CONFIG.captureTypes.includes(type)) return;
1360
+
1361
+ const args = [
1362
+ CLI_PATH, "save", PROJECT, type, content, "--force",
1363
+ "--commit-sha", CURRENT_SHA,
1364
+ "--source", "hook",
1365
+ "--confidence", "0.8",
1366
+ ];
1367
+ if (CURRENT_AUTHOR) args.push("--author", CURRENT_AUTHOR);
1368
+ if (changedFiles.length > 0) args.push("--files", changedFiles.join(","));
1369
+
1370
+ execFileSync("node", args, { stdio: "inherit" });
1371
+ savedItems.push(\`\${type}: \${content}\`);
774
1372
  }
775
1373
 
776
1374
  function basename(filePath) {
@@ -797,6 +1395,29 @@ function isSchemaFile(filePath) {
797
1395
  );
798
1396
  }
799
1397
 
1398
+ // Noise that's never worth diffing: lockfiles, build output, vendored/
1399
+ // generated code, and timestamp-prefixed SQL migrations (their content is
1400
+ // a mechanical dump, not a human decision). Checked against path segments
1401
+ // (not just a prefix) so "apps/web/dist/bundle.js" is skipped too, not
1402
+ // just a top-level "dist/...".
1403
+ const DIFF_SKIP_DIR_NAMES = new Set(["dist", "build", ".next", "coverage", "vendored", "node_modules"]);
1404
+ const DIFF_SKIP_LOCK_NAMES = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]);
1405
+ const MIGRATION_FILE_RE = /^\\d{14}_.*\\.sql$/;
1406
+ const DIFF_MAX_BYTES = 50 * 1024;
1407
+
1408
+ function shouldSkipDiffFile(filePath) {
1409
+ const base = basename(filePath);
1410
+ if (filePath.split("/").some((segment) => DIFF_SKIP_DIR_NAMES.has(segment))) return true;
1411
+ if (DIFF_SKIP_LOCK_NAMES.has(base) || base.endsWith(".lock")) return true;
1412
+ if (base.endsWith(".min.js") || base.endsWith(".min.css")) return true;
1413
+ if (base.endsWith(".map")) return true;
1414
+ if (base.endsWith(".pb.go")) return true;
1415
+ if (/\\.generated\\./.test(base)) return true;
1416
+ if (MIGRATION_FILE_RE.test(base)) return true;
1417
+ if (STACKMEM_CONFIG.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern))) return true;
1418
+ return false;
1419
+ }
1420
+
800
1421
  const LOW_QUALITY_PREFIXES = [
801
1422
  "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
802
1423
  "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
@@ -831,11 +1452,10 @@ const savedItems = [];
831
1452
 
832
1453
  const commitMessage = git(["log", "-1", "--pretty=%B"]);
833
1454
 
834
- // The sync commit below is itself a commit, so it fires this same
835
- // post-commit hook again — git's --no-verify only skips pre-commit and
836
- // commit-msg hooks, never post-commit. Without this guard, the hook would
837
- // save its own commit message as a new decision memory, which changes the
838
- // rules-file content and triggers another sync commit, indefinitely.
1455
+ // Kept as a general escape hatch for other automation (CI bots, scripts)
1456
+ // that tags its own commits this way and shouldn't be captured as a
1457
+ // memory — cm sync no longer self-commits, so this isn't load-bearing for
1458
+ // that anymore, but other tools' commits still deserve the same skip.
839
1459
  if (commitMessage.includes("[skip ci]")) {
840
1460
  return;
841
1461
  }
@@ -859,10 +1479,40 @@ const changedFiles = git(
859
1479
 
860
1480
  let diff = "";
861
1481
  if (!isFirstCommit) {
1482
+ // --numstat marks a binary file's added/deleted columns as "-" — that's
1483
+ // the cheapest way to identify them without fetching their (useless,
1484
+ // and potentially huge) diff content at all.
1485
+ let binaryFiles = new Set();
862
1486
  try {
863
- diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
1487
+ const numstat = execFileSync("git", ["diff", "--numstat", "HEAD~1", "HEAD"], {
1488
+ encoding: "utf-8",
1489
+ });
1490
+ for (const line of numstat.split("\\n")) {
1491
+ const parts = line.split("\\t");
1492
+ if (parts[0] === "-" && parts[1] === "-" && parts[2]) {
1493
+ binaryFiles.add(parts[2]);
1494
+ }
1495
+ }
864
1496
  } catch {
865
- diff = "";
1497
+ // Nothing to compare against — leave binaryFiles empty.
1498
+ }
1499
+
1500
+ const diffableFiles = changedFiles.filter(
1501
+ (file) => !binaryFiles.has(file) && !shouldSkipDiffFile(file)
1502
+ );
1503
+
1504
+ if (diffableFiles.length > 0) {
1505
+ try {
1506
+ diff = execFileSync("git", ["diff", "HEAD~1", "HEAD", "--", ...diffableFiles], {
1507
+ encoding: "utf-8",
1508
+ });
1509
+ } catch {
1510
+ diff = "";
1511
+ }
1512
+ }
1513
+
1514
+ if (diff.length > DIFF_MAX_BYTES) {
1515
+ diff = diff.slice(0, DIFF_MAX_BYTES) + "\\n\\n... [diff truncated: exceeded 50KB after filtering]\\n";
866
1516
  }
867
1517
  }
868
1518
 
@@ -889,17 +1539,14 @@ for (const line of isFirstCommit ? [] : lines) {
889
1539
  if (/(auth|login|session|token)/i.test(currentFile)) {
890
1540
  const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
891
1541
  cmSave("discovery", content);
892
- savedItems.push(\`discovery: \${content}\`);
893
1542
  }
894
1543
  if (isConfigFile(currentFile)) {
895
1544
  const content = redactSecrets(\`new config: \${currentFile}\`);
896
1545
  cmSave("constraint", content);
897
- savedItems.push(\`constraint: \${content}\`);
898
1546
  }
899
1547
  if (isSchemaFile(currentFile)) {
900
1548
  const content = redactSecrets(\`schema change: \${currentFile}\`);
901
1549
  cmSave("discovery", content);
902
- savedItems.push(\`discovery: \${content}\`);
903
1550
  }
904
1551
  }
905
1552
  continue;
@@ -936,7 +1583,6 @@ for (const line of isFirstCommit ? [] : lines) {
936
1583
  if (removedModulePath) {
937
1584
  const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
938
1585
  cmSave("rejection", content);
939
- savedItems.push(\`rejection: \${content}\`);
940
1586
  }
941
1587
 
942
1588
  if (isPackageJson && inDependenciesSection) {
@@ -944,7 +1590,6 @@ for (const line of isFirstCommit ? [] : lines) {
944
1590
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
945
1591
  const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
946
1592
  cmSave("rejection", content);
947
- savedItems.push(\`rejection: \${content}\`);
948
1593
  }
949
1594
  }
950
1595
  continue;
@@ -960,7 +1605,6 @@ for (const line of isFirstCommit ? [] : lines) {
960
1605
  if (envMatch) {
961
1606
  const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
962
1607
  cmSave("constraint", content);
963
- savedItems.push(\`constraint: \${content}\`);
964
1608
  }
965
1609
  }
966
1610
  }
@@ -970,7 +1614,6 @@ for (const line of isFirstCommit ? [] : lines) {
970
1614
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
971
1615
  const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
972
1616
  cmSave("decision", content);
973
- savedItems.push(\`decision: \${content}\`);
974
1617
  }
975
1618
  }
976
1619
 
@@ -978,14 +1621,12 @@ for (const line of isFirstCommit ? [] : lines) {
978
1621
  const truncated = codeLine.trim().slice(0, 80);
979
1622
  const content = redactSecrets(\`database change: \${truncated}\`);
980
1623
  cmSave("discovery", content);
981
- savedItems.push(\`discovery: \${content}\`);
982
1624
  }
983
1625
 
984
1626
  if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
985
1627
  const truncated = codeLine.trim().slice(0, 100);
986
1628
  const content = redactSecrets(\`open issue from commit: \${truncated}\`);
987
1629
  cmSave("discovery", content);
988
- savedItems.push(\`discovery: \${content}\`);
989
1630
  }
990
1631
  }
991
1632
  }
@@ -1014,19 +1655,16 @@ if (breakingMatch) {
1014
1655
  const breakingContent = redactSecrets(breakingMatch[1].trim());
1015
1656
  const content = \`BREAKING CHANGE: \${breakingContent}\`;
1016
1657
  cmSave("constraint", content);
1017
- savedItems.push(\`constraint: \${content}\`);
1018
1658
  }
1019
1659
 
1020
1660
  if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
1021
1661
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1022
1662
  cmSave("decision", trimmedMessage);
1023
- savedItems.push(\`decision: \${trimmedMessage}\`);
1024
1663
  } else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
1025
1664
  // docs/style/test/ci/build/chore — skip entirely, nothing saved.
1026
1665
  } else if (isQualityCommitMessage(commitMessage)) {
1027
1666
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1028
1667
  cmSave("decision", trimmedMessage);
1029
- savedItems.push(\`decision: \${trimmedMessage}\`);
1030
1668
  }
1031
1669
 
1032
1670
  console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
@@ -1034,21 +1672,13 @@ for (const item of savedItems) {
1034
1672
  console.log(\` - \${item}\`);
1035
1673
  }
1036
1674
 
1675
+ // Updates .cursorrules/.windsurfrules on disk only — no git add/commit.
1676
+ // Editors read these files straight off disk, and they're gitignored by
1677
+ // default (see DEFAULT_GITIGNORE_CONTENT), so there's nothing to keep in
1678
+ // sync with git history; committing them here used to double the commit
1679
+ // count on every real commit and produced constant rules-file merge
1680
+ // conflicts on teams.
1037
1681
  execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
1038
-
1039
- // cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
1040
- // the working directory dirty (breaking things like \`npm version patch\`
1041
- // that require a clean tree). Commit the sync ourselves instead. --no-verify
1042
- // skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
1043
- // guard above, which is what actually stops this from recursing).
1044
- try {
1045
- execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
1046
- execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
1047
- stdio: "pipe",
1048
- });
1049
- } catch {
1050
- // Nothing to commit (rules files unchanged) — fine, ignore.
1051
- }
1052
1682
  }
1053
1683
 
1054
1684
  async function main() {
@@ -1073,14 +1703,24 @@ async function main() {
1073
1703
  main().finally(() => process.exit(0));
1074
1704
  `;
1075
1705
  }
1076
- // Installs (or re-installs) the stackmem post-commit hook at `hooksDir`. If
1077
- // a post-commit hook already exists and wasn't written by stackmem, it's
1706
+ // Hooks stackmem manages. post-rewrite fires on `git commit --amend` and
1707
+ // `git rebase` (neither of which is a plain commit, so post-commit alone
1708
+ // misses them) — it runs the exact same capture script as post-commit,
1709
+ // against current HEAD, with the check-commit idempotency guard in the
1710
+ // script itself preventing a double-capture when a single operation
1711
+ // (e.g. --amend) fires both hooks for the same resulting commit.
1712
+ const MANAGED_HOOK_NAMES = ["post-commit", "post-rewrite"];
1713
+ function hookBackupFilename(hookName) {
1714
+ return `${hookName}${HOOK_BACKUP_SUFFIX}`;
1715
+ }
1716
+ // Installs (or re-installs) a stackmem-managed hook at `hooksDir`. If a
1717
+ // hook of this name already exists and wasn't written by stackmem, it's
1078
1718
  // preserved as a backup and chained to run before stackmem's own hook,
1079
1719
  // rather than being clobbered — see `uninstallHook` for how it's restored.
1080
- function installHook(hooksDir, hookLogicPath) {
1720
+ function installHook(hooksDir, hookLogicPath, hookName) {
1081
1721
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1082
- const hookPath = nodePath.join(hooksDir, "post-commit");
1083
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1722
+ const hookPath = nodePath.join(hooksDir, hookName);
1723
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1084
1724
  let chainedOriginal = false;
1085
1725
  if (nodeFs.existsSync(hookPath)) {
1086
1726
  const existingContent = nodeFs.readFileSync(hookPath, "utf-8");
@@ -1096,21 +1736,21 @@ function installHook(hooksDir, hookLogicPath) {
1096
1736
  }
1097
1737
  }
1098
1738
  const chainLine = chainedOriginal
1099
- ? `"$(dirname "$0")/${HOOK_BACKUP_FILENAME}" "$@"\n`
1739
+ ? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
1100
1740
  : "";
1101
1741
  nodeFs.writeFileSync(hookPath, `#!/bin/sh\n${STACKMEM_HOOK_MARKER}\n${chainLine}exec node "${hookLogicPath}" "$@"\n`, "utf-8");
1102
1742
  nodeFs.chmodSync(hookPath, 0o755);
1103
1743
  if (chainedOriginal) {
1104
- console.log("Existing post-commit hook found — chained it to run before stackmem's hook.");
1744
+ console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
1105
1745
  }
1106
1746
  }
1107
1747
  // Reverses `installHook`: if stackmem wrote the hook fresh, removes it; if
1108
1748
  // stackmem chained it after an existing hook, restores that original hook
1109
1749
  // in its place. Returns a description of what happened, or null if there
1110
1750
  // was no stackmem-managed hook to remove (nothing done).
1111
- function uninstallHook(hooksDir) {
1112
- const hookPath = nodePath.join(hooksDir, "post-commit");
1113
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1751
+ function uninstallHook(hooksDir, hookName) {
1752
+ const hookPath = nodePath.join(hooksDir, hookName);
1753
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1114
1754
  if (!nodeFs.existsSync(hookPath))
1115
1755
  return null;
1116
1756
  const content = nodeFs.readFileSync(hookPath, "utf-8");
@@ -1118,10 +1758,10 @@ function uninstallHook(hooksDir) {
1118
1758
  return null;
1119
1759
  if (nodeFs.existsSync(backupPath)) {
1120
1760
  nodeFs.renameSync(backupPath, hookPath);
1121
- return ".git/hooks/post-commit (restored original hook stackmem had chained)";
1761
+ return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
1122
1762
  }
1123
1763
  nodeFs.unlinkSync(hookPath);
1124
- return ".git/hooks/post-commit";
1764
+ return `.git/hooks/${hookName}`;
1125
1765
  }
1126
1766
  // Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
1127
1767
  // at a scratch file instead of the real ~/.claude.json.
@@ -1168,6 +1808,108 @@ function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()) {
1168
1808
  nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
1169
1809
  return true;
1170
1810
  }
1811
+ function mergeMcpServersJson(configPath, distIndexPath) {
1812
+ let config = {};
1813
+ if (nodeFs.existsSync(configPath)) {
1814
+ try {
1815
+ config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
1816
+ }
1817
+ catch {
1818
+ config = {};
1819
+ }
1820
+ }
1821
+ if (!config.mcpServers || typeof config.mcpServers !== "object") {
1822
+ config.mcpServers = {};
1823
+ }
1824
+ config.mcpServers.stackmem = { command: "node", args: [distIndexPath] };
1825
+ nodeFs.mkdirSync(nodePath.dirname(configPath), { recursive: true });
1826
+ nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
1827
+ }
1828
+ // VS Code installs the Copilot Chat extension under a version-suffixed
1829
+ // directory name (e.g. "github.copilot-chat-0.26.4") — this resolves the
1830
+ // "github.copilot-chat*" glob the task describes against whatever's
1831
+ // actually installed, rather than assuming a fixed folder name.
1832
+ function findCopilotChatExtensionDir(homeDir) {
1833
+ const extensionsDir = nodePath.join(homeDir, ".vscode", "extensions");
1834
+ if (!nodeFs.existsSync(extensionsDir))
1835
+ return null;
1836
+ try {
1837
+ const match = nodeFs
1838
+ .readdirSync(extensionsDir)
1839
+ .find((entry) => entry.startsWith("github.copilot-chat"));
1840
+ return match ? nodePath.join(extensionsDir, match) : null;
1841
+ }
1842
+ catch {
1843
+ return null;
1844
+ }
1845
+ }
1846
+ const EDITOR_MCP_TARGETS = [
1847
+ {
1848
+ name: "Cursor",
1849
+ configPath: (homeDir) => {
1850
+ const dir = nodePath.join(homeDir, ".cursor");
1851
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp.json") : null;
1852
+ },
1853
+ register: mergeMcpServersJson,
1854
+ },
1855
+ {
1856
+ name: "Windsurf",
1857
+ configPath: (homeDir) => {
1858
+ const dir = nodePath.join(homeDir, ".codeium", "windsurf");
1859
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "mcp_config.json") : null;
1860
+ },
1861
+ register: mergeMcpServersJson,
1862
+ },
1863
+ {
1864
+ name: "VS Code (Copilot)",
1865
+ configPath: (homeDir) => {
1866
+ const extensionDir = findCopilotChatExtensionDir(homeDir);
1867
+ return extensionDir ? nodePath.join(extensionDir, "mcp.json") : null;
1868
+ },
1869
+ register: mergeMcpServersJson,
1870
+ },
1871
+ {
1872
+ name: "Zed",
1873
+ configPath: (homeDir) => {
1874
+ const dir = nodePath.join(homeDir, ".config", "zed");
1875
+ return nodeFs.existsSync(dir) ? nodePath.join(dir, "settings.json") : null;
1876
+ },
1877
+ register: (configPath, distIndexPath) => {
1878
+ let config = {};
1879
+ if (nodeFs.existsSync(configPath)) {
1880
+ try {
1881
+ config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
1882
+ }
1883
+ catch {
1884
+ config = {};
1885
+ }
1886
+ }
1887
+ if (!config.context_servers || typeof config.context_servers !== "object") {
1888
+ config.context_servers = {};
1889
+ }
1890
+ config.context_servers.stackmem = { command: "node", args: [distIndexPath] };
1891
+ nodeFs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
1892
+ },
1893
+ },
1894
+ ];
1895
+ // Registers stackmem's MCP server with every detected editor, printing a
1896
+ // line per success. An editor whose config directory doesn't exist is
1897
+ // skipped with no output and never an error.
1898
+ function registerEditorMcpServers(distIndexPath, homeDir = nodeOs.homedir()) {
1899
+ for (const target of EDITOR_MCP_TARGETS) {
1900
+ const configPath = target.configPath(homeDir);
1901
+ if (!configPath)
1902
+ continue;
1903
+ try {
1904
+ target.register(configPath, distIndexPath);
1905
+ console.log(`✓ MCP registered for ${target.name}`);
1906
+ }
1907
+ catch {
1908
+ // Best-effort — a permissions error or malformed existing config on
1909
+ // one editor shouldn't abort `cm init` or the other editors' writes.
1910
+ }
1911
+ }
1912
+ }
1171
1913
  const DEFAULT_GITIGNORE_CONTENT = `node_modules/
1172
1914
  dist/
1173
1915
  .env
@@ -1177,6 +1919,9 @@ dist/
1177
1919
  coverage/
1178
1920
  *-memories.json
1179
1921
  *.db
1922
+ *cursorrules
1923
+ *windsurfrules
1924
+ .cursor/rules/stackmem.md
1180
1925
  `;
1181
1926
  // Returns true if the file was created, false if it already existed
1182
1927
  // (and was left untouched).
@@ -1208,6 +1953,78 @@ cm save ${project} <type> "<content>"
1208
1953
  nodeFs.writeFileSync(claudeMdPath, content, "utf-8");
1209
1954
  console.log("CLAUDE.md written.");
1210
1955
  }
1956
+ // Same instructional purpose as writeClaudeMd — point the agent at
1957
+ // `cm start` rather than embedding a memory snapshot directly, since a
1958
+ // baked-in snapshot goes stale the moment the next commit lands. AGENTS.md
1959
+ // is the convention used by OpenAI Codex CLI, Gemini CLI, and others;
1960
+ // GEMINI.md is Gemini CLI's own equivalent — both get identical content.
1961
+ function writeAgentsMemoryFile(project, resolvedPath, filename) {
1962
+ const filePath = nodePath.join(resolvedPath, filename);
1963
+ if (nodeFs.existsSync(filePath)) {
1964
+ console.log(`${filename} already exists — skipping.`);
1965
+ return;
1966
+ }
1967
+ const content = `# ${project} — Agent Memory
1968
+
1969
+ This project uses stackmem for persistent agent memory.
1970
+ Run \`cm start ${project}\` at the start of every session
1971
+ to load relevant context.
1972
+
1973
+ ## Key decisions, constraints and discoveries
1974
+
1975
+ Decisions, constraints, discoveries, and rejections live in the stackmem
1976
+ backend, not duplicated in this file — run \`cm start ${project}\` to load
1977
+ the current list.
1978
+
1979
+ When you make a decision, discover a constraint, reject an approach, or
1980
+ make a discovery during this session, save it with:
1981
+ cm save ${project} <type> "<content>"
1982
+ `;
1983
+ nodeFs.writeFileSync(filePath, content, "utf-8");
1984
+ console.log(`${filename} written.`);
1985
+ }
1986
+ // --project (not positional) for the PreCompact command below: cmdContext's
1987
+ // CLI convention takes --project like cm search/cm context already do
1988
+ // everywhere else — cm start and cm session-end both take a positional
1989
+ // project, so those two keep the user's literal command shape.
1990
+ const STACKMEM_HOOK_EVENTS = [
1991
+ { event: "SessionStart", buildCommand: (project) => `cm start ${project} --hook` },
1992
+ { event: "Stop", buildCommand: (project) => `cm session-end ${project} --hook` },
1993
+ { event: "PreCompact", buildCommand: (project) => `cm context --project ${project} --hook` },
1994
+ ];
1995
+ // Writes (or merges into) <resolvedPath>/.claude/settings.json. Merges
1996
+ // rather than overwrites — an existing settings.json may have unrelated
1997
+ // hooks or settings that must survive this — and is idempotent: re-running
1998
+ // `cm init` checks for the exact command string already present in an
1999
+ // event's hook groups before adding another one.
2000
+ function writeClaudeCodeHooks(project, resolvedPath) {
2001
+ const settingsDir = nodePath.join(resolvedPath, ".claude");
2002
+ const settingsPath = nodePath.join(settingsDir, "settings.json");
2003
+ let settings = {};
2004
+ if (nodeFs.existsSync(settingsPath)) {
2005
+ try {
2006
+ settings = JSON.parse(nodeFs.readFileSync(settingsPath, "utf-8"));
2007
+ }
2008
+ catch {
2009
+ settings = {};
2010
+ }
2011
+ }
2012
+ if (!settings.hooks || typeof settings.hooks !== "object") {
2013
+ settings.hooks = {};
2014
+ }
2015
+ for (const { event, buildCommand } of STACKMEM_HOOK_EVENTS) {
2016
+ const command = buildCommand(project);
2017
+ if (!Array.isArray(settings.hooks[event])) {
2018
+ settings.hooks[event] = [];
2019
+ }
2020
+ const alreadyRegistered = settings.hooks[event].some((group) => Array.isArray(group?.hooks) && group.hooks.some((h) => h?.command === command));
2021
+ if (!alreadyRegistered) {
2022
+ settings.hooks[event].push({ hooks: [{ type: "command", command }] });
2023
+ }
2024
+ }
2025
+ nodeFs.mkdirSync(settingsDir, { recursive: true });
2026
+ nodeFs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
2027
+ }
1211
2028
  // Section order for the generated rules-file markdown — matches the order
1212
2029
  // memories are presented in everywhere else (cm context's CONTEXT_TYPE_ORDER),
1213
2030
  // not MEMORY_TYPES' declaration order.
@@ -1221,13 +2038,15 @@ async function generateRulesContent(project) {
1221
2038
  .from("memories")
1222
2039
  .select("*")
1223
2040
  .eq("project", project)
1224
- .eq("resolved", false);
2041
+ .eq("resolved", false)
2042
+ .eq("suggested", false);
1225
2043
  if (memoriesError)
1226
2044
  fail(`Failed to load memories: ${memoriesError.message}`);
2045
+ const config = readStackmemConfig();
1227
2046
  const scoredMemories = (memoryRows ?? [])
1228
2047
  .map((memory) => ({
1229
2048
  ...memory,
1230
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
2049
+ decay_score: computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
1231
2050
  }))
1232
2051
  .sort((a, b) => b.decay_score - a.decay_score);
1233
2052
  const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
@@ -1289,6 +2108,18 @@ function writeCursorRules(resolvedPath, content) {
1289
2108
  console.log(".cursorrules written.");
1290
2109
  return true;
1291
2110
  }
2111
+ // Cursor's newer rules format (.cursor/rules/*.md) — .cursorrules is
2112
+ // legacy but still read by older Cursor versions, so both are written
2113
+ // during the transition rather than one replacing the other. Unlike
2114
+ // writeCursorRules/writeWindsurfRules (write-once at init, skip if
2115
+ // present), this one unconditionally overwrites on every call — both at
2116
+ // init and on every `cm sync` — since it's gitignored content meant to
2117
+ // always reflect the current memory state, same as the legacy files.
2118
+ function writeCursorRulesMdFile(resolvedPath, content) {
2119
+ const rulesDir = nodePath.join(resolvedPath, ".cursor", "rules");
2120
+ nodeFs.mkdirSync(rulesDir, { recursive: true });
2121
+ nodeFs.writeFileSync(nodePath.join(rulesDir, "stackmem.md"), content, "utf-8");
2122
+ }
1292
2123
  // Returns true if the file was written, false if it already existed
1293
2124
  // (and was skipped).
1294
2125
  function writeWindsurfRules(resolvedPath, content) {
@@ -1319,6 +2150,10 @@ export async function trySeedMemory(project, existingMemories, type, content, cl
1319
2150
  type,
1320
2151
  content,
1321
2152
  device_id: getDeviceId(),
2153
+ // Seeded by `cm init` scanning project files — an explicit user-run
2154
+ // command, not an auto-captured commit, so it's "manual" like cm save,
2155
+ // not the memories.source column's default of "hook".
2156
+ source: "manual",
1322
2157
  });
1323
2158
  return !error;
1324
2159
  }
@@ -1427,7 +2262,17 @@ async function verifyBackendConnection(project) {
1427
2262
  .eq("project", project);
1428
2263
  return !error;
1429
2264
  }
1430
- async function cmdInit(project, targetPath) {
2265
+ // Returns true if the file was created, false if it already existed
2266
+ // (and was left untouched).
2267
+ function writeStackmemConfigFile(resolvedPath) {
2268
+ const configPath = nodePath.join(resolvedPath, STACKMEM_CONFIG_FILENAME);
2269
+ if (nodeFs.existsSync(configPath)) {
2270
+ return false;
2271
+ }
2272
+ nodeFs.writeFileSync(configPath, defaultConfigFileContents(), "utf-8");
2273
+ return true;
2274
+ }
2275
+ async function cmdInit(project, targetPath, writeConfig = false) {
1431
2276
  const resolvedPath = nodePath.resolve(targetPath);
1432
2277
  const gitDir = nodePath.join(resolvedPath, ".git");
1433
2278
  if (!nodeFs.existsSync(gitDir)) {
@@ -1441,25 +2286,40 @@ async function cmdInit(project, targetPath) {
1441
2286
  nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
1442
2287
  const hooksDir = nodePath.join(gitDir, "hooks");
1443
2288
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1444
- installHook(hooksDir, hookLogicPath);
2289
+ for (const hookName of MANAGED_HOOK_NAMES) {
2290
+ installHook(hooksDir, hookLogicPath, hookName);
2291
+ }
1445
2292
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
1446
2293
  registerMcpServer(DIST_INDEX_PATH);
2294
+ registerEditorMcpServers(DIST_INDEX_PATH);
1447
2295
  writeClaudeMd(project, resolvedPath);
2296
+ writeAgentsMemoryFile(project, resolvedPath, "AGENTS.md");
2297
+ writeAgentsMemoryFile(project, resolvedPath, "GEMINI.md");
2298
+ writeClaudeCodeHooks(project, resolvedPath);
1448
2299
  const seededCount = await seedMemories(project, resolvedPath);
1449
2300
  console.log(`Seeded ${seededCount} initial memories from project scan.`);
1450
2301
  const rulesContent = await generateRulesContent(project);
1451
2302
  const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
1452
2303
  const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
2304
+ writeCursorRulesMdFile(resolvedPath, rulesContent);
1453
2305
  const backendOk = await verifyBackendConnection(project);
2306
+ const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
1454
2307
  console.log("");
1455
2308
  if (gitignoreCreated) {
1456
2309
  console.log("✓ .gitignore created");
1457
2310
  }
2311
+ if (writeConfig) {
2312
+ console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
2313
+ }
1458
2314
  console.log("✓ Git hook installed");
1459
2315
  console.log("✓ MCP server registered");
2316
+ console.log("✓ Claude Code hooks registered (.claude/settings.json)");
1460
2317
  console.log("✓ CLAUDE.md written");
2318
+ console.log("✓ AGENTS.md written");
2319
+ console.log("✓ GEMINI.md written");
1461
2320
  console.log(`✓ .cursorrules ${cursorRulesWritten ? "written" : "already exists"}`);
1462
2321
  console.log(`✓ .windsurfrules ${windsurfRulesWritten ? "written" : "already exists"}`);
2322
+ console.log("✓ .cursor/rules/stackmem.md written");
1463
2323
  console.log(`✓ Seeded ${seededCount} memories from project scan`);
1464
2324
  if (backendOk) {
1465
2325
  console.log("✓ Backend connection verified");
@@ -1480,7 +2340,8 @@ async function cmdSync(project) {
1480
2340
  const rulesContent = await generateRulesContent(project);
1481
2341
  nodeFs.writeFileSync(nodePath.join(resolvedPath, ".cursorrules"), rulesContent, "utf-8");
1482
2342
  nodeFs.writeFileSync(nodePath.join(resolvedPath, ".windsurfrules"), rulesContent, "utf-8");
1483
- console.log("Synced memories to .cursorrules and .windsurfrules");
2343
+ writeCursorRulesMdFile(resolvedPath, rulesContent);
2344
+ console.log("Synced memories to .cursorrules, .windsurfrules, and .cursor/rules/stackmem.md");
1484
2345
  }
1485
2346
  // --- cm link ---------------------------------------------------------------
1486
2347
  // Mirrors the link_codes.expires_at default in the migration
@@ -1549,36 +2410,150 @@ async function cmdLink() {
1549
2410
  }
1550
2411
  console.log("Link code expired. Run cm link again.");
1551
2412
  }
2413
+ // --- cm open -----------------------------------------------------------
2414
+ // Separated from cmdOpen so the platform-selection logic is unit-testable
2415
+ // without actually launching a browser — exercising this for real on
2416
+ // every platform isn't something a test (or a verification pass) should
2417
+ // do as a side effect.
2418
+ export function getOpenCommand(platform, url) {
2419
+ if (platform === "darwin")
2420
+ return { command: "open", args: [url] };
2421
+ if (platform === "win32")
2422
+ return { command: "cmd", args: ["/c", "start", "", url] };
2423
+ return { command: "xdg-open", args: [url] };
2424
+ }
2425
+ async function cmdOpen(project) {
2426
+ const url = `${VAULT_URL}/project/${project}`;
2427
+ const { command, args } = getOpenCommand(process.platform, url);
2428
+ try {
2429
+ execFileSync(command, args, { stdio: "ignore" });
2430
+ console.log(`Opened ${url}`);
2431
+ }
2432
+ catch (error) {
2433
+ fail(`Failed to open browser (${error instanceof Error ? error.message : String(error)}). Visit ${url} manually.`);
2434
+ }
2435
+ }
2436
+ // --- cm purge ------------------------------------------------------------
2437
+ async function cmdPurgeProject(project) {
2438
+ const { count: total, error: countError } = await supabase
2439
+ .from("memories")
2440
+ .select("id", { count: "exact", head: true })
2441
+ .eq("project", project);
2442
+ if (countError)
2443
+ fail(`Failed to count memories: ${countError.message}`);
2444
+ console.log(`This will permanently delete all ${total ?? 0} memories for "${project}".`);
2445
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2446
+ const answer = (await rl.question("Type the project name to confirm: ")).trim();
2447
+ rl.close();
2448
+ if (answer !== project) {
2449
+ console.log("Confirmation did not match — aborted. Nothing was deleted.");
2450
+ return;
2451
+ }
2452
+ const { data: memRows, error: memFetchError } = await supabase
2453
+ .from("memories")
2454
+ .select("id")
2455
+ .eq("project", project);
2456
+ if (memFetchError)
2457
+ fail(`Failed to load memories: ${memFetchError.message}`);
2458
+ const ids = (memRows ?? []).map((m) => m.id);
2459
+ let linksDeleted = 0;
2460
+ if (ids.length > 0) {
2461
+ const idList = ids.join(",");
2462
+ const { count, error: linksError } = await supabase
2463
+ .from("memory_links")
2464
+ .delete({ count: "exact" })
2465
+ .or(`source_id.in.(${idList}),target_id.in.(${idList})`);
2466
+ if (linksError)
2467
+ fail(`Failed to delete memory links: ${linksError.message}`);
2468
+ linksDeleted = count ?? 0;
2469
+ }
2470
+ const { count: memoriesDeleted, error: memError } = await supabase
2471
+ .from("memories")
2472
+ .delete({ count: "exact" })
2473
+ .eq("project", project);
2474
+ if (memError)
2475
+ fail(`Failed to delete memories: ${memError.message}`);
2476
+ const { count: fixesDeleted, error: fixError } = await supabase
2477
+ .from("execution_log")
2478
+ .delete({ count: "exact" })
2479
+ .eq("project", project);
2480
+ if (fixError)
2481
+ fail(`Failed to delete fixes: ${fixError.message}`);
2482
+ console.log(`Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted} links for "${project}".`);
2483
+ }
2484
+ // RLS already scopes every table read/write to this device's device_id
2485
+ // via the x-device-id header (see src/storage.ts) — a filter-less delete
2486
+ // here is already confined to this device, never another one. PostgREST
2487
+ // still requires an explicit filter clause to allow a DELETE at all, so
2488
+ // `.not("id", "is", null)` (every row has a non-null id) is used purely
2489
+ // to satisfy that, not to narrow the scope further.
2490
+ async function cmdPurgeAll() {
2491
+ console.log("This will permanently delete ALL memories across ALL projects for this device.");
2492
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2493
+ const answer = (await rl.question("Type DELETE to confirm: ")).trim();
2494
+ rl.close();
2495
+ if (answer !== "DELETE") {
2496
+ console.log("Confirmation did not match — aborted. Nothing was deleted.");
2497
+ return;
2498
+ }
2499
+ const { count: linksDeleted, error: linksError } = await supabase
2500
+ .from("memory_links")
2501
+ .delete({ count: "exact" })
2502
+ .not("id", "is", null);
2503
+ if (linksError)
2504
+ fail(`Failed to delete memory links: ${linksError.message}`);
2505
+ const { count: memoriesDeleted, error: memError } = await supabase
2506
+ .from("memories")
2507
+ .delete({ count: "exact" })
2508
+ .not("id", "is", null);
2509
+ if (memError)
2510
+ fail(`Failed to delete memories: ${memError.message}`);
2511
+ const { count: fixesDeleted, error: fixError } = await supabase
2512
+ .from("execution_log")
2513
+ .delete({ count: "exact" })
2514
+ .not("id", "is", null);
2515
+ if (fixError)
2516
+ fail(`Failed to delete fixes: ${fixError.message}`);
2517
+ console.log(`Deleted ${memoriesDeleted ?? 0} memories, ${fixesDeleted ?? 0} fixes, and ${linksDeleted ?? 0} links across all projects.`);
2518
+ }
1552
2519
  // --- cm context ------------------------------------------------------------
1553
2520
  const CONTEXT_TYPE_ORDER = ["decision", "constraint", "rejection", "discovery"];
1554
2521
  const CONTEXT_TOP_N = 5;
1555
- async function cmdContext(project, task) {
2522
+ // task is null for the PreCompact hook — there's no user-typed task at a
2523
+ // compaction boundary, just "save the most relevant context" generically
2524
+ // — so ranking falls back to decay score alone instead of diluting it
2525
+ // with a relevance score computed against an empty string.
2526
+ async function cmdContext(project, task, hookMode = false) {
1556
2527
  const { data, error } = await supabase
1557
2528
  .from("memories")
1558
2529
  .select("*")
1559
2530
  .eq("project", project)
1560
- .eq("resolved", false);
2531
+ .eq("resolved", false)
2532
+ .eq("suggested", false);
1561
2533
  if (error)
1562
2534
  fail(`Failed to load memories: ${error.message}`);
1563
2535
  const memories = data ?? [];
1564
2536
  if (memories.length === 0) {
1565
- console.log(`No memories found for "${project}".`);
2537
+ if (!hookMode)
2538
+ console.log(`No memories found for "${project}".`);
1566
2539
  return;
1567
2540
  }
2541
+ const config = readStackmemConfig();
2542
+ const tokenBudget = config.tokenBudget;
1568
2543
  const scoredMemories = memories
1569
2544
  .map((memory) => {
1570
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
1571
- const relevanceScore = scoreMemoryRelevance(memory.content, task);
1572
- return {
1573
- ...memory,
1574
- combined_score: relevanceScore * 0.6 + decayScore * 0.4,
1575
- };
2545
+ const decayScore = computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
2546
+ const combinedScore = task == null ? decayScore : scoreMemoryRelevance(memory.content, task) * 0.6 + decayScore * 0.4;
2547
+ return { ...memory, combined_score: combinedScore };
1576
2548
  })
1577
2549
  .sort((a, b) => b.combined_score - a.combined_score);
1578
2550
  const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
1579
- const budgetedMemories = applyContextBudget(topMemories, CONTEXT_BUDGET_TOKENS);
2551
+ const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
1580
2552
  const tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
1581
- const lines = [`## Project Context: ${project}`, `### Task: ${task}`, ""];
2553
+ const lines = [`## Project Context: ${project}`];
2554
+ if (task != null)
2555
+ lines.push(`### Task: ${task}`);
2556
+ lines.push("");
1582
2557
  for (const type of CONTEXT_TYPE_ORDER) {
1583
2558
  const group = budgetedMemories.filter((memory) => memory.type === type);
1584
2559
  if (group.length === 0)
@@ -1589,9 +2564,64 @@ async function cmdContext(project, task) {
1589
2564
  }
1590
2565
  lines.push("");
1591
2566
  }
1592
- lines.push(`*(${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used)*`);
2567
+ if (!hookMode)
2568
+ lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
1593
2569
  console.log(lines.join("\n"));
1594
2570
  }
2571
+ // --- cm why --------------------------------------------------------------
2572
+ function fileMatchesPaths(filePaths, file, baseName) {
2573
+ if (!Array.isArray(filePaths))
2574
+ return false;
2575
+ return filePaths.some((p) => typeof p === "string" && (p === file || p.split("/").pop() === baseName));
2576
+ }
2577
+ function fileMentionedInContent(content, file, baseName) {
2578
+ const lower = content.toLowerCase();
2579
+ return lower.includes(file.toLowerCase()) || lower.includes(baseName.toLowerCase());
2580
+ }
2581
+ async function cmdWhy(project, file) {
2582
+ const baseName = file.split("/").pop() ?? file;
2583
+ const { data, error } = await supabase
2584
+ .from("memories")
2585
+ .select("*")
2586
+ .eq("project", project)
2587
+ .eq("resolved", false)
2588
+ .eq("suggested", false);
2589
+ if (error)
2590
+ fail(`Failed to load memories: ${error.message}`);
2591
+ const memories = data ?? [];
2592
+ // file_paths (structured, from Item 6) and a content substring search
2593
+ // are combined into one match set rather than one gating the other as
2594
+ // a strict fallback — most memories predate file_paths existing at all
2595
+ // (manual saves and seeds never set it), so content search is the
2596
+ // primary signal for everything except recent hook-captured commits,
2597
+ // not a rare last resort.
2598
+ const matched = memories.filter((memory) => fileMatchesPaths(memory.file_paths, file, baseName) ||
2599
+ fileMentionedInContent(memory.content, file, baseName));
2600
+ if (matched.length === 0) {
2601
+ console.log(`No memories found for ${file}.`);
2602
+ console.log("Make some commits touching this file to build up context.");
2603
+ return;
2604
+ }
2605
+ const config = readStackmemConfig();
2606
+ const scoredMemories = matched
2607
+ .map((memory) => ({
2608
+ ...memory,
2609
+ decay_score: computeDecayScore(parseUtcDate(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
2610
+ }))
2611
+ .sort((a, b) => b.decay_score - a.decay_score);
2612
+ console.log(`Decisions and constraints tied to ${file}:\n`);
2613
+ for (const type of MEMORY_TYPES) {
2614
+ const group = scoredMemories.filter((memory) => memory.type === type);
2615
+ if (group.length === 0)
2616
+ continue;
2617
+ console.log(`${TYPE_LABELS[type]} (${group.length})`);
2618
+ console.log("-".repeat(TYPE_LABELS[type].length + 4));
2619
+ for (const memory of group) {
2620
+ console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
2621
+ }
2622
+ console.log("");
2623
+ }
2624
+ }
1595
2625
  function splitCommaList(value) {
1596
2626
  if (!value)
1597
2627
  return [];
@@ -1632,6 +2662,11 @@ function parseSessionEndFlags(args) {
1632
2662
  approaches = splitCommaList(value);
1633
2663
  i++;
1634
2664
  }
2665
+ else if (flag === "--hook") {
2666
+ // Recognized explicitly (not just silently skipped as an unknown
2667
+ // flag) — see the comment on cmdSessionEnd for why it's currently
2668
+ // a no-op here specifically.
2669
+ }
1635
2670
  }
1636
2671
  return { project, summary, errors, files, approaches };
1637
2672
  }
@@ -1651,6 +2686,16 @@ async function readStdinJson() {
1651
2686
  return null;
1652
2687
  }
1653
2688
  }
2689
+ // --hook is accepted (see parseSessionEndFlags) but currently a no-op:
2690
+ // Claude Code's Stop hook pipes ITS OWN JSON payload on stdin (session
2691
+ // id, transcript path, cwd — see the Claude Code hooks docs), not the
2692
+ // {summary, errors_encountered, files_touched, approaches_tried} shape
2693
+ // this command expects. readStdinJson() parses that payload successfully
2694
+ // (it's valid JSON) but finds none of the fields it looks for, so every
2695
+ // array below comes back empty and this reports 0 memories saved — a
2696
+ // real, known limitation, not a bug. Extracting genuine learnings from
2697
+ // the Stop hook would mean parsing the session transcript itself, which
2698
+ // is out of scope here (Phase 2, per the user).
1654
2699
  async function cmdSessionEnd(project, input) {
1655
2700
  let memoriesSaved = 0;
1656
2701
  let errorsLogged = 0;
@@ -1762,6 +2807,124 @@ function isHookExecutable(hookPath) {
1762
2807
  return false;
1763
2808
  }
1764
2809
  }
2810
+ // --- cm status ---------------------------------------------------------
2811
+ function hasMcpServersStackmemKey(configPath) {
2812
+ if (!nodeFs.existsSync(configPath))
2813
+ return false;
2814
+ try {
2815
+ const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
2816
+ return Boolean(config?.mcpServers?.stackmem);
2817
+ }
2818
+ catch {
2819
+ return false;
2820
+ }
2821
+ }
2822
+ function hasContextServersStackmemKey(configPath) {
2823
+ if (!nodeFs.existsSync(configPath))
2824
+ return false;
2825
+ try {
2826
+ const config = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
2827
+ return Boolean(config?.context_servers?.stackmem);
2828
+ }
2829
+ catch {
2830
+ return false;
2831
+ }
2832
+ }
2833
+ const EDITOR_STATUS_CHECKS = [
2834
+ { name: "Claude Code", isRegistered: (homeDir) => isMcpRegistered(nodePath.join(homeDir, ".claude.json")) },
2835
+ { name: "Cursor", isRegistered: (homeDir) => hasMcpServersStackmemKey(nodePath.join(homeDir, ".cursor", "mcp.json")) },
2836
+ {
2837
+ name: "Windsurf",
2838
+ isRegistered: (homeDir) => hasMcpServersStackmemKey(nodePath.join(homeDir, ".codeium", "windsurf", "mcp_config.json")),
2839
+ },
2840
+ {
2841
+ name: "VS Code",
2842
+ isRegistered: (homeDir) => {
2843
+ const dir = findCopilotChatExtensionDir(homeDir);
2844
+ return dir ? hasMcpServersStackmemKey(nodePath.join(dir, "mcp.json")) : false;
2845
+ },
2846
+ },
2847
+ {
2848
+ name: "Zed",
2849
+ isRegistered: (homeDir) => hasContextServersStackmemKey(nodePath.join(homeDir, ".config", "zed", "settings.json")),
2850
+ },
2851
+ ];
2852
+ async function cmdStatus(project) {
2853
+ const { count: totalCount, error: totalError } = await supabase
2854
+ .from("memories")
2855
+ .select("id", { count: "exact", head: true })
2856
+ .eq("project", project)
2857
+ .eq("resolved", false);
2858
+ if (totalError)
2859
+ fail(`Failed to load memory count: ${totalError.message}`);
2860
+ const { count: pinnedCount, error: pinnedError } = await supabase
2861
+ .from("memories")
2862
+ .select("id", { count: "exact", head: true })
2863
+ .eq("project", project)
2864
+ .eq("resolved", false)
2865
+ .eq("pinned", true);
2866
+ if (pinnedError)
2867
+ fail(`Failed to load pinned count: ${pinnedError.message}`);
2868
+ const { count: suggestedCount, error: suggestedError } = await supabase
2869
+ .from("memories")
2870
+ .select("id", { count: "exact", head: true })
2871
+ .eq("project", project)
2872
+ .eq("resolved", false)
2873
+ .eq("suggested", true);
2874
+ if (suggestedError)
2875
+ fail(`Failed to load suggested count: ${suggestedError.message}`);
2876
+ const { count: fixesCount, error: fixesError } = await supabase
2877
+ .from("execution_log")
2878
+ .select("id", { count: "exact", head: true })
2879
+ .eq("project", project)
2880
+ .eq("resolved", false);
2881
+ if (fixesError)
2882
+ fail(`Failed to load fixes count: ${fixesError.message}`);
2883
+ const { data: lastCaptureRows, error: lastCaptureError } = await supabase
2884
+ .from("memories")
2885
+ .select("content, created_at")
2886
+ .eq("project", project)
2887
+ .order("created_at", { ascending: false })
2888
+ .limit(1);
2889
+ if (lastCaptureError)
2890
+ fail(`Failed to load last capture: ${lastCaptureError.message}`);
2891
+ const config = readStackmemConfig();
2892
+ const configPath = nodePath.join(process.cwd(), STACKMEM_CONFIG_FILENAME);
2893
+ let usesCustomBudget = false;
2894
+ if (nodeFs.existsSync(configPath)) {
2895
+ try {
2896
+ const raw = JSON.parse(nodeFs.readFileSync(configPath, "utf-8"));
2897
+ usesCustomBudget = typeof raw.tokenBudget === "number";
2898
+ }
2899
+ catch {
2900
+ // Malformed config — readStackmemConfig already falls back to the
2901
+ // default in this case too, so "default" is the accurate label.
2902
+ }
2903
+ }
2904
+ const hookOk = isHookExecutable(nodePath.join(process.cwd(), ".git", "hooks", "post-commit"));
2905
+ const mcpOk = isMcpRegistered(getClaudeConfigPath());
2906
+ const backendOk = await verifyBackendConnection(project);
2907
+ const homeDir = nodeOs.homedir();
2908
+ const registeredEditors = EDITOR_STATUS_CHECKS.filter((e) => e.isRegistered(homeDir)).map((e) => e.name);
2909
+ console.log(`stackmem status for "${project}"`);
2910
+ console.log("━".repeat(27));
2911
+ console.log(`Memories: ${totalCount ?? 0} total (${pinnedCount ?? 0} pinned, ${suggestedCount ?? 0} suggested)`);
2912
+ console.log(`Fixes: ${fixesCount ?? 0} recorded`);
2913
+ console.log(`Token budget: ${config.tokenBudget} (${usesCustomBudget ? "from .stackmem.json" : "default"})`);
2914
+ const lastCapture = (lastCaptureRows ?? [])[0];
2915
+ if (lastCapture) {
2916
+ const ago = formatRelativeTime(parseUtcDate(lastCapture.created_at));
2917
+ const snippet = lastCapture.content.length > 50 ? `${lastCapture.content.slice(0, 50)}…` : lastCapture.content;
2918
+ console.log(`Last capture: ${ago} (${snippet})`);
2919
+ }
2920
+ else {
2921
+ console.log("Last capture: none yet");
2922
+ }
2923
+ console.log(`Hook: ${hookOk ? "✓ installed" : "✗ not installed"}`);
2924
+ console.log(`MCP: ${mcpOk ? "✓ registered" : "✗ not registered"}`);
2925
+ console.log(`Backend: ${backendOk ? "✓ connected" : "✗ not connected"}`);
2926
+ console.log(`Editors: ${registeredEditors.length > 0 ? registeredEditors.map((name) => `${name} ✓`).join(" ") : "(none registered)"}`);
2927
+ }
1765
2928
  async function cmdDoctor(fix) {
1766
2929
  let issues = 0;
1767
2930
  const project = readProjectFromFile();
@@ -1828,7 +2991,9 @@ async function cmdDoctor(fix) {
1828
2991
  nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
1829
2992
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
1830
2993
  nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
1831
- installHook(hooksDir, hookLogicPath);
2994
+ for (const name of MANAGED_HOOK_NAMES) {
2995
+ installHook(hooksDir, hookLogicPath, name);
2996
+ }
1832
2997
  if (isHookExecutable(hookPath)) {
1833
2998
  console.log("Fixed: git hook");
1834
2999
  issues--;
@@ -1998,9 +3163,11 @@ function removeIfMarked(filePath, marker, label) {
1998
3163
  async function cmdUninstall(project, targetPath) {
1999
3164
  const gitDir = nodePath.join(targetPath, ".git");
2000
3165
  const hooksDir = nodePath.join(gitDir, "hooks");
2001
- const hookResult = uninstallHook(hooksDir);
2002
- if (hookResult)
2003
- console.log(`Removed ${hookResult}`);
3166
+ for (const hookName of MANAGED_HOOK_NAMES) {
3167
+ const hookResult = uninstallHook(hooksDir, hookName);
3168
+ if (hookResult)
3169
+ console.log(`Removed ${hookResult}`);
3170
+ }
2004
3171
  const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
2005
3172
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
2006
3173
  if (nodeFs.existsSync(hookLogicPath)) {
@@ -2034,19 +3201,32 @@ async function main() {
2034
3201
  const [, , command, ...args] = process.argv;
2035
3202
  switch (command) {
2036
3203
  case "start": {
2037
- const split = splitOptionalProject(args, 0);
3204
+ const hookIndex = args.indexOf("--hook");
3205
+ const hookMode = hookIndex !== -1;
3206
+ const positional = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
3207
+ const split = splitOptionalProject(positional, 0);
2038
3208
  if (!split)
2039
3209
  usage();
2040
3210
  const project = requireProject(split.project);
2041
- await cmdStart(project);
3211
+ await cmdStart(project, hookMode);
2042
3212
  break;
2043
3213
  }
2044
3214
  case "save": {
2045
3215
  const forceIndex = args.indexOf("--force");
2046
3216
  const force = forceIndex !== -1;
2047
- const positional = force
3217
+ let positional = force
2048
3218
  ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
2049
3219
  : args;
3220
+ const commitShaFlag = extractValueFlag(positional, "--commit-sha");
3221
+ positional = commitShaFlag.rest;
3222
+ const authorFlag = extractValueFlag(positional, "--author");
3223
+ positional = authorFlag.rest;
3224
+ const sourceFlag = extractValueFlag(positional, "--source");
3225
+ positional = sourceFlag.rest;
3226
+ const confidenceFlag = extractValueFlag(positional, "--confidence");
3227
+ positional = confidenceFlag.rest;
3228
+ const filesFlag = extractValueFlag(positional, "--files");
3229
+ positional = filesFlag.rest;
2050
3230
  let explicitProject;
2051
3231
  let type;
2052
3232
  let rest;
@@ -2071,7 +3251,30 @@ async function main() {
2071
3251
  if (!type || rest.length === 0)
2072
3252
  usage();
2073
3253
  const project = requireProject(explicitProject);
2074
- await cmdSave(project, type, rest.join(" "), force);
3254
+ const provenance = {};
3255
+ if (commitShaFlag.value)
3256
+ provenance.commitSha = commitShaFlag.value;
3257
+ if (authorFlag.value)
3258
+ provenance.author = authorFlag.value;
3259
+ if (sourceFlag.value === "hook" || sourceFlag.value === "manual" || sourceFlag.value === "mcp") {
3260
+ provenance.source = sourceFlag.value;
3261
+ }
3262
+ if (confidenceFlag.value) {
3263
+ const parsed = Number(confidenceFlag.value);
3264
+ if (!Number.isNaN(parsed))
3265
+ provenance.confidence = parsed;
3266
+ }
3267
+ if (filesFlag.value) {
3268
+ provenance.filePaths = filesFlag.value.split(",").map((f) => f.trim()).filter(Boolean);
3269
+ }
3270
+ await cmdSave(project, type, rest.join(" "), force, provenance);
3271
+ break;
3272
+ }
3273
+ case "check-commit": {
3274
+ const [project, commitSha] = args;
3275
+ if (!project || !commitSha)
3276
+ usage();
3277
+ await cmdCheckCommit(project, commitSha);
2075
3278
  break;
2076
3279
  }
2077
3280
  case "fix": {
@@ -2109,6 +3312,41 @@ async function main() {
2109
3312
  await cmdResolve(project, memoryId);
2110
3313
  break;
2111
3314
  }
3315
+ case "pin": {
3316
+ const split = splitOptionalProject(args, 1);
3317
+ if (!split)
3318
+ usage();
3319
+ const project = requireProject(split.project);
3320
+ const [memoryId] = split.rest;
3321
+ await cmdPin(project, memoryId);
3322
+ break;
3323
+ }
3324
+ case "unpin": {
3325
+ const split = splitOptionalProject(args, 1);
3326
+ if (!split)
3327
+ usage();
3328
+ const project = requireProject(split.project);
3329
+ const [memoryId] = split.rest;
3330
+ await cmdUnpin(project, memoryId);
3331
+ break;
3332
+ }
3333
+ case "review": {
3334
+ const split = splitProjectFromVariadic(args);
3335
+ if (split.rest.length > 0)
3336
+ usage();
3337
+ const project = requireProject(split.project);
3338
+ await cmdReview(project);
3339
+ break;
3340
+ }
3341
+ case "edit": {
3342
+ const split = splitOptionalProject(args, 1);
3343
+ if (!split)
3344
+ usage();
3345
+ const project = requireProject(split.project);
3346
+ const [memoryId] = split.rest;
3347
+ await cmdEdit(project, memoryId);
3348
+ break;
3349
+ }
2112
3350
  case "delete": {
2113
3351
  const split = splitOptionalProject(args, 1);
2114
3352
  if (!split)
@@ -2136,10 +3374,15 @@ async function main() {
2136
3374
  break;
2137
3375
  }
2138
3376
  case "init": {
2139
- const [project, targetPath] = args;
3377
+ const configIndex = args.indexOf("--config");
3378
+ const writeConfig = configIndex !== -1;
3379
+ const positional = writeConfig
3380
+ ? [...args.slice(0, configIndex), ...args.slice(configIndex + 1)]
3381
+ : args;
3382
+ const [project, targetPath] = positional;
2140
3383
  if (!project || !targetPath)
2141
3384
  usage();
2142
- await cmdInit(project, targetPath);
3385
+ await cmdInit(project, targetPath, writeConfig);
2143
3386
  break;
2144
3387
  }
2145
3388
  case "link": {
@@ -2148,6 +3391,30 @@ async function main() {
2148
3391
  await cmdLink();
2149
3392
  break;
2150
3393
  }
3394
+ case "open": {
3395
+ const split = splitProjectFromVariadic(args);
3396
+ if (split.rest.length > 0)
3397
+ usage();
3398
+ const project = requireProject(split.project);
3399
+ await cmdOpen(project);
3400
+ break;
3401
+ }
3402
+ case "purge": {
3403
+ const allIndex = args.indexOf("--all");
3404
+ const isAll = allIndex !== -1;
3405
+ const withoutAllFlag = isAll ? [...args.slice(0, allIndex), ...args.slice(allIndex + 1)] : args;
3406
+ const split = splitProjectFromVariadic(withoutAllFlag);
3407
+ if (split.rest.length > 0)
3408
+ usage();
3409
+ if (isAll) {
3410
+ await cmdPurgeAll();
3411
+ }
3412
+ else {
3413
+ const project = requireProject(split.project);
3414
+ await cmdPurgeProject(project);
3415
+ }
3416
+ break;
3417
+ }
2151
3418
  case "sync": {
2152
3419
  if (args.length > 0)
2153
3420
  usage();
@@ -2156,11 +3423,25 @@ async function main() {
2156
3423
  break;
2157
3424
  }
2158
3425
  case "context": {
3426
+ const hookIndex = args.indexOf("--hook");
3427
+ const hookMode = hookIndex !== -1;
3428
+ const withoutHookFlag = hookMode ? [...args.slice(0, hookIndex), ...args.slice(hookIndex + 1)] : args;
3429
+ const split = splitProjectFromVariadic(withoutHookFlag);
3430
+ const project = requireProject(split.project);
3431
+ // task is optional only in --hook mode (the PreCompact hook has no
3432
+ // user-typed task to pass) — interactive use still requires one.
3433
+ if (split.rest.length === 0 && !hookMode)
3434
+ usage();
3435
+ const task = split.rest.length > 0 ? split.rest.join(" ") : null;
3436
+ await cmdContext(project, task, hookMode);
3437
+ break;
3438
+ }
3439
+ case "why": {
2159
3440
  const split = splitProjectFromVariadic(args);
2160
3441
  const project = requireProject(split.project);
2161
3442
  if (split.rest.length === 0)
2162
3443
  usage();
2163
- await cmdContext(project, split.rest.join(" "));
3444
+ await cmdWhy(project, split.rest.join(" "));
2164
3445
  break;
2165
3446
  }
2166
3447
  case "session-end": {
@@ -2172,6 +3453,14 @@ async function main() {
2172
3453
  await cmdDoctor(fix);
2173
3454
  break;
2174
3455
  }
3456
+ case "status": {
3457
+ const split = splitProjectFromVariadic(args);
3458
+ if (split.rest.length > 0)
3459
+ usage();
3460
+ const project = requireProject(split.project);
3461
+ await cmdStatus(project);
3462
+ break;
3463
+ }
2175
3464
  case "undo": {
2176
3465
  const split = splitOptionalProject(args, 0);
2177
3466
  if (!split)