stackmem 1.0.24 → 1.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -2,9 +2,13 @@
2
2
  import nodeFs from "node:fs";
3
3
  import nodeOs from "node:os";
4
4
  import nodePath from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { randomInt } from "node:crypto";
5
7
  import { fileURLToPath, pathToFileURL } from "node:url";
6
8
  import { createInterface } from "node:readline/promises";
7
9
  import { analyzeCodebase } from "./ast-index.js";
10
+ import { VAULT_URL } from "./config.js";
11
+ import { defaultConfigFileContents, readStackmemConfig, STACKMEM_CONFIG_FILENAME } from "./config-file.js";
8
12
  import { getDeviceId } from "./device.js";
9
13
  import {
10
14
  applyContextBudget,
@@ -18,12 +22,26 @@ import {
18
22
  indicatesFailedApproach,
19
23
  isAuthRelatedFile,
20
24
  isCodeFile,
25
+ hasTokenOverlap,
21
26
  isQualityMessage,
22
27
  redactSecrets,
23
28
  scoreMemoryRelevance,
29
+ tokenize,
24
30
  } from "./scoring.js";
25
31
  import { supabase } from "./storage.js";
26
32
 
33
+ // Checked as early as ESM import hoisting allows — before any command
34
+ // dispatch, config loading, or network call — so an unsupported Node
35
+ // version fails with a clear message instead of an obscure syntax or
36
+ // API error from deeper in the dependency chain.
37
+ const NODE_MAJOR_VERSION = Number(process.versions.node.split(".")[0]);
38
+ if (NODE_MAJOR_VERSION < 18) {
39
+ console.error(
40
+ `Error: stackmem requires Node 18 or higher.\nYou are running Node ${process.versions.node}.\nPlease upgrade: https://nodejs.org`
41
+ );
42
+ process.exit(1);
43
+ }
44
+
27
45
  const CONTEXT_BUDGET_TOKENS = 2000;
28
46
  const COMPRESSION_THRESHOLD = 20;
29
47
  const CLI_PATH = fileURLToPath(import.meta.url);
@@ -39,7 +57,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
39
57
  const STACKMEM_HOOK_MARKER = "# stackmem-managed";
40
58
  const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
41
59
  const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
42
- const HOOK_BACKUP_FILENAME = "post-commit.pre-stackmem";
60
+ const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
43
61
 
44
62
  const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
45
63
  type MemoryType = (typeof MEMORY_TYPES)[number];
@@ -59,13 +77,17 @@ function usage(): never {
59
77
  cm analyze [project] <path>
60
78
  cm search [--project <name>] <query>
61
79
  cm resolve [project] <memory-id>
80
+ cm pin [project] <memory-id>
81
+ cm unpin [project] <memory-id>
62
82
  cm delete [project] <memory-id>
63
83
  cm inspect [project] <memory-id>
64
84
  cm compress [project]
65
- cm init <project> <path>
85
+ cm init <project> <path> [--config]
66
86
  cm uninstall [project] [path]
87
+ cm link
67
88
  cm sync
68
89
  cm context [--project <name>] <task>
90
+ cm why [--project <name>] <file>
69
91
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
70
92
  cm doctor [--fix]
71
93
  cm undo [project]
@@ -117,6 +139,14 @@ function cmdHelp() {
117
139
  cm resolve [project] <memory-id>
118
140
  Mark a memory as resolved.
119
141
 
142
+ cm pin [project] <memory-id>
143
+ Pin a memory: it scores 999 in decay ranking (always sorts
144
+ first, never decays) and shows in its own "Pinned" section at
145
+ the top of 'cm start', ahead of its normal type group.
146
+
147
+ cm unpin [project] <memory-id>
148
+ Un-pin a memory, returning it to normal decay-based ranking.
149
+
120
150
  cm delete [project] <memory-id>
121
151
  Delete a memory and any memory_links referencing it.
122
152
 
@@ -131,7 +161,7 @@ function cmdHelp() {
131
161
  Cluster related unresolved memories (needs 20+) and collapse
132
162
  clusters of 3 or more into a single summary memory.
133
163
 
134
- cm init <project> <path>
164
+ cm init <project> <path> [--config]
135
165
  Install a git post-commit hook in the repo at <path>, register
136
166
  the stackmem MCP server with Claude Code, write a CLAUDE.md,
137
167
  .cursorrules, .windsurfrules, and .stackmem file, and seed
@@ -139,7 +169,12 @@ function cmdHelp() {
139
169
  .windsurfrules are pre-filled with the project's current
140
170
  memories (for Cursor and Windsurf) and skipped if they already
141
171
  exist. The post-commit hook keeps them fresh afterward by
142
- running 'cm sync' on every commit.
172
+ running 'cm sync' on every commit — on disk only, never
173
+ committed, so they don't double your commit count or conflict
174
+ across branches. Both files are added to .gitignore. With
175
+ --config, also writes a default .stackmem.json (ignore patterns,
176
+ tokenBudget, decayLambda, captureTypes, minQualityScore — see
177
+ src/config-file.ts) if one doesn't already exist.
143
178
 
144
179
  cm uninstall [project] [path]
145
180
  Remove everything 'cm init' set up at <path> (defaults to the
@@ -152,11 +187,19 @@ function cmdHelp() {
152
187
  Also removes the stackmem entry from ~/.claude.json's mcpServers.
153
188
  [project] defaults to the .stackmem file at <path> if omitted.
154
189
 
190
+ cm link
191
+ Connect this device to your GitHub account in the hosted vault.
192
+ Prints a 6-digit code, then waits for you to enter it at
193
+ stackmem-vault.vercel.app/link while signed in. The code is
194
+ single-use and expires after 10 minutes; polling stops when it
195
+ does. Links are per-device, so run this once on each machine.
196
+
155
197
  cm sync
156
198
  Regenerate .cursorrules and .windsurfrules from the project's
157
199
  current unresolved memories (decay-ranked, capped at a 2000
158
200
  token budget) and its 5 most recent open execution_log fixes,
159
- overwriting both files. Project is read from .stackmem.
201
+ overwriting both files on disk only (never git add/commit).
202
+ Project is read from .stackmem.
160
203
 
161
204
  cm context [--project <name>] <task>
162
205
  Return only the top 5 memories most relevant to a specific
@@ -164,6 +207,14 @@ function cmdHelp() {
164
207
  as a markdown block capped at a 2000 token budget. Use
165
208
  --project to target a project other than the one in .stackmem.
166
209
 
210
+ cm why [--project <name>] <file>
211
+ Show memories tied to a file: matched by Item 6's file_paths
212
+ column (hook-captured commits that touched it) plus a plain
213
+ content search for the file's path or basename, ranked by decay
214
+ score and grouped by type. Prints a guidance message instead if
215
+ nothing matches. Use --project to target a project other than
216
+ the one in .stackmem.
217
+
167
218
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
168
219
  Capture what happened in a session and extract memories: each
169
220
  error becomes an unsolved execution_log entry, each approach
@@ -283,8 +334,85 @@ function splitProjectFromVariadic(args: string[]): { project: string | null; res
283
334
  return { project: null, rest: args };
284
335
  }
285
336
 
337
+ // Generic "--flag value" extractor for internal/provenance flags (not
338
+ // meant to be remembered by end users — only the hook and MCP server pass
339
+ // these). Unlike splitProjectFromVariadic's --project, these don't need
340
+ // special positional disambiguation, so one generic helper covers all of
341
+ // them: --commit-sha, --author, --source, --confidence, --files.
342
+ function extractValueFlag(args: string[], flag: string): { value: string | null; rest: string[] } {
343
+ const idx = args.indexOf(flag);
344
+ if (idx === -1 || args[idx + 1] === undefined) return { value: null, rest: args };
345
+ return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
346
+ }
347
+
286
348
  // --- cm start ----------------------------------------------------------
287
349
 
350
+ const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
351
+ const BRANCH_NAME_BOOST = 1.2;
352
+ const RECENT_FILES_BOOST = 1.3;
353
+ const PR_TITLE_BOOST = 1.2;
354
+
355
+ // null means "signal unavailable" (git/gh failed, detached HEAD, not
356
+ // enough history, gh not installed, no open PR, etc.) — every one of
357
+ // these is a soft skip, never an error, per the task spec.
358
+ function getBranchNameTokens(): Set<string> | null {
359
+ try {
360
+ const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
361
+ encoding: "utf-8",
362
+ stdio: ["ignore", "pipe", "ignore"],
363
+ }).trim();
364
+ if (!branch || branch === "HEAD") return null; // detached HEAD
365
+ const tokens = tokenize(branch);
366
+ return tokens.size > 0 ? tokens : null;
367
+ } catch {
368
+ return null;
369
+ }
370
+ }
371
+
372
+ function getRecentlyTouchedFiles(): Set<string> | null {
373
+ try {
374
+ const output = execFileSync(
375
+ "git",
376
+ ["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"],
377
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
378
+ );
379
+ const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
380
+ return files.length > 0 ? new Set(files) : null;
381
+ } catch {
382
+ // Fewer than RECENT_COMMITS_FOR_FILE_SIGNAL commits in history, or
383
+ // not a git repo at all — skip gracefully.
384
+ return null;
385
+ }
386
+ }
387
+
388
+ function getOpenPrTitleTokens(): Set<string> | null {
389
+ try {
390
+ const title = execFileSync("gh", ["pr", "view", "--json", "title", "-q", ".title"], {
391
+ encoding: "utf-8",
392
+ stdio: ["ignore", "pipe", "ignore"],
393
+ }).trim();
394
+ if (!title) return null;
395
+ const tokens = tokenize(title);
396
+ return tokens.size > 0 ? tokens : null;
397
+ } catch {
398
+ // gh not installed, not authenticated, or no open PR for this branch.
399
+ return null;
400
+ }
401
+ }
402
+
403
+ function fileListOverlaps(filePaths: unknown, recentFiles: Set<string>): boolean {
404
+ if (!Array.isArray(filePaths)) return false;
405
+ return filePaths.some((candidate) => {
406
+ if (typeof candidate !== "string") return false;
407
+ if (recentFiles.has(candidate)) return true;
408
+ const base = candidate.split("/").pop();
409
+ for (const recent of recentFiles) {
410
+ if (recent.split("/").pop() === base) return true;
411
+ }
412
+ return false;
413
+ });
414
+ }
415
+
288
416
  async function cmdStart(project: string) {
289
417
  const { data, error } = await supabase
290
418
  .from("memories")
@@ -302,14 +430,42 @@ async function cmdStart(project: string) {
302
430
  return;
303
431
  }
304
432
 
433
+ // Task-aware re-ranking: boost memories relevant to what's actively
434
+ // being worked on, multiplicatively on top of the base decay score.
435
+ // Every signal degrades to null (no boost, not an error) if the
436
+ // repo/branch/gh state it needs isn't available.
437
+ const branchTokens = getBranchNameTokens();
438
+ const recentFiles = getRecentlyTouchedFiles();
439
+ const prTitleTokens = getOpenPrTitleTokens();
440
+
441
+ const config = readStackmemConfig();
442
+ const tokenBudget = config.tokenBudget;
443
+
305
444
  const scoredMemories = memories
306
- .map((memory) => ({
307
- ...memory,
308
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
309
- }))
445
+ .map((memory) => {
446
+ let decayScore = computeDecayScore(
447
+ new Date(memory.created_at),
448
+ memory.access_count ?? 0,
449
+ memory.pinned ?? false,
450
+ config.decayLambda
451
+ );
452
+
453
+ const contentTokens = tokenize(memory.content);
454
+ if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
455
+ decayScore *= BRANCH_NAME_BOOST;
456
+ }
457
+ if (recentFiles && fileListOverlaps(memory.file_paths, recentFiles)) {
458
+ decayScore *= RECENT_FILES_BOOST;
459
+ }
460
+ if (prTitleTokens && hasTokenOverlap(contentTokens, prTitleTokens)) {
461
+ decayScore *= PR_TITLE_BOOST;
462
+ }
463
+
464
+ return { ...memory, decay_score: decayScore };
465
+ })
310
466
  .sort((a, b) => b.decay_score - a.decay_score);
311
467
 
312
- const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
468
+ const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
313
469
  const omittedCount = scoredMemories.length - budgetedMemories.length;
314
470
  let tokensUsed = budgetedMemories.reduce(
315
471
  (sum, memory) => sum + estimateTokens(memory.content),
@@ -326,12 +482,12 @@ async function cmdStart(project: string) {
326
482
 
327
483
  if (fixError) fail(`Failed to load execution log: ${fixError.message}`);
328
484
 
329
- // Fixes share the same 2000-token budget as memories, which already
330
- // consumed their portion above — fixes only get whatever room is left.
485
+ // Fixes share the same token budget as memories, which already consumed
486
+ // their portion above — fixes only get whatever room is left.
331
487
  const budgetedFixes: { problem: string; solution: string | null }[] = [];
332
488
  for (const fix of fixData ?? []) {
333
489
  const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
334
- if (tokensUsed + tokens > CONTEXT_BUDGET_TOKENS) break;
490
+ if (tokensUsed + tokens > tokenBudget) break;
335
491
  budgetedFixes.push(fix);
336
492
  tokensUsed += tokens;
337
493
  }
@@ -356,8 +512,24 @@ async function cmdStart(project: string) {
356
512
 
357
513
  console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
358
514
 
515
+ const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
516
+ const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
517
+
518
+ if (pinnedMemories.length > 0) {
519
+ console.log(`Pinned (${pinnedMemories.length})`);
520
+ console.log("-".repeat(11));
521
+ for (const memory of pinnedMemories) {
522
+ console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
523
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
524
+ if (linked.length > 0) {
525
+ console.log(` linked: ${linked.join(", ")}`);
526
+ }
527
+ }
528
+ console.log("");
529
+ }
530
+
359
531
  for (const type of MEMORY_TYPES) {
360
- const group = budgetedMemories.filter((m) => m.type === type);
532
+ const group = unpinnedMemories.filter((m) => m.type === type);
361
533
  if (group.length === 0) continue;
362
534
 
363
535
  console.log(`${TYPE_LABELS[type]} (${group.length})`);
@@ -382,7 +554,7 @@ async function cmdStart(project: string) {
382
554
  }
383
555
  }
384
556
 
385
- console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
557
+ console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
386
558
  if (omittedCount > 0) {
387
559
  console.log(`${omittedCount} memories omitted (budget exceeded)`);
388
560
  }
@@ -436,7 +608,26 @@ async function confirmSupersede(oldId: string, newContent: string): Promise<bool
436
608
  return false;
437
609
  }
438
610
 
439
- async function cmdSave(project: string, type: string, content: string, force = false) {
611
+ // Provenance metadata attached to a memory at save time. Manual `cm save`
612
+ // calls (a human at the keyboard) leave this at its defaults — source
613
+ // "manual", confidence 1.0, no commit/file/author info. The hook passes
614
+ // commitSha/filePaths/author and source "hook" with a lower confidence;
615
+ // MCP tool saves pass source "mcp".
616
+ interface SaveProvenance {
617
+ commitSha?: string;
618
+ filePaths?: string[];
619
+ author?: string;
620
+ source?: "hook" | "manual" | "mcp";
621
+ confidence?: number;
622
+ }
623
+
624
+ async function cmdSave(
625
+ project: string,
626
+ type: string,
627
+ content: string,
628
+ force = false,
629
+ provenance: SaveProvenance = {}
630
+ ) {
440
631
  if (!MEMORY_TYPES.includes(type as MemoryType)) {
441
632
  fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
442
633
  }
@@ -482,9 +673,22 @@ async function cmdSave(project: string, type: string, content: string, force = f
482
673
  if (resolveError) fail(`Failed to resolve superseded memory: ${resolveError.message}`);
483
674
  }
484
675
 
676
+ const { commitSha, filePaths, author, source = "manual", confidence = 1.0 } = provenance;
677
+ const insertPayload: Record<string, unknown> = {
678
+ project,
679
+ type,
680
+ content,
681
+ device_id: getDeviceId(),
682
+ source,
683
+ confidence,
684
+ };
685
+ if (commitSha) insertPayload.commit_sha = commitSha;
686
+ if (filePaths && filePaths.length > 0) insertPayload.file_paths = filePaths;
687
+ if (author) insertPayload.author = author;
688
+
485
689
  const { data: inserted, error } = await supabase
486
690
  .from("memories")
487
- .insert({ project, type, content, device_id: getDeviceId() })
691
+ .insert(insertPayload)
488
692
  .select("id")
489
693
  .single();
490
694
 
@@ -520,6 +724,40 @@ async function cmdSave(project: string, type: string, content: string, force = f
520
724
  }
521
725
  }
522
726
 
727
+ // --- cm check-commit (internal, used by the hook) --------------------------
728
+
729
+ // Not a user-facing command (no usage()/help entry) — the hook calls this
730
+ // once per invocation, before doing any diff analysis, to decide whether
731
+ // this commit_sha has already been captured. post-commit and post-rewrite
732
+ // both run the exact same capture logic against current HEAD, so without
733
+ // this guard, a commit that fires both hooks (e.g. `git commit --amend`,
734
+ // which triggers post-commit for the new commit AND post-rewrite for the
735
+ // rewrite) would be captured twice. Checked once per invocation — not
736
+ // inside each individual cmSave call — because a single commit normally
737
+ // produces several distinct memories; a per-save check would see the
738
+ // first save's own commit_sha and wrongly skip the rest.
739
+ async function cmdCheckCommit(project: string, commitSha: string): Promise<void> {
740
+ const { data, error } = await supabase
741
+ .from("memories")
742
+ .select("id")
743
+ .eq("project", project)
744
+ .eq("commit_sha", commitSha)
745
+ .limit(1);
746
+
747
+ if (error) {
748
+ // Fail open: an error here (including "column does not exist" if the
749
+ // commit_sha migration hasn't landed yet) must not look identical to
750
+ // "duplicate, skip" below — that would silently suppress every hook
751
+ // capture with no log trail. Exit 0 so the normal save path runs;
752
+ // if commit_sha genuinely isn't a column, the save itself fails
753
+ // there instead, where the hook's outer error handler logs it.
754
+ console.error(`check-commit: ${error.message}`);
755
+ process.exit(0);
756
+ }
757
+
758
+ process.exit(data && data.length > 0 ? 1 : 0);
759
+ }
760
+
523
761
  // --- memory compression ----------------------------------------------------
524
762
 
525
763
  async function compressMemories(
@@ -678,6 +916,34 @@ async function cmdResolve(project: string, memoryId: string) {
678
916
  console.log(`Resolved memory [${memoryId}]`);
679
917
  }
680
918
 
919
+ // --- cm pin / cm unpin -------------------------------------------------
920
+
921
+ async function setPinned(project: string, memoryId: string, pinned: boolean): Promise<void> {
922
+ const { data, error } = await supabase
923
+ .from("memories")
924
+ .update({ pinned })
925
+ .eq("project", project)
926
+ .eq("id", memoryId)
927
+ .select("id");
928
+
929
+ if (error) fail(`Failed to ${pinned ? "pin" : "unpin"} memory: ${error.message}`);
930
+
931
+ if (!data || data.length === 0) {
932
+ console.log(`No memory found with ID [${memoryId}]`);
933
+ return;
934
+ }
935
+
936
+ console.log(`${pinned ? "Pinned" : "Unpinned"} memory [${memoryId}]`);
937
+ }
938
+
939
+ async function cmdPin(project: string, memoryId: string): Promise<void> {
940
+ await setPinned(project, memoryId, true);
941
+ }
942
+
943
+ async function cmdUnpin(project: string, memoryId: string): Promise<void> {
944
+ await setPinned(project, memoryId, false);
945
+ }
946
+
681
947
  // --- cm delete -------------------------------------------------------------
682
948
 
683
949
  async function cmdDelete(project: string, memoryId: string) {
@@ -725,7 +991,13 @@ async function cmdInspect(project: string, memoryId: string) {
725
991
 
726
992
  if (error || !memory) fail(`Memory [${memoryId}] not found for "${project}".`);
727
993
 
728
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
994
+ const config = readStackmemConfig();
995
+ const decayScore = computeDecayScore(
996
+ new Date(memory.created_at),
997
+ memory.access_count ?? 0,
998
+ memory.pinned ?? false,
999
+ config.decayLambda
1000
+ );
729
1001
 
730
1002
  const { data: links, error: linksError } = await supabase
731
1003
  .from("memory_links")
@@ -833,6 +1105,65 @@ try {
833
1105
 
834
1106
  const CLI_PATH = ${JSON.stringify(cliPath)};
835
1107
 
1108
+ // Inlined copy of src/config-file.ts's defaults/merge logic — this script
1109
+ // runs standalone with no import access to that module.
1110
+ const DEFAULT_STACKMEM_CONFIG = {
1111
+ ignore: [],
1112
+ tokenBudget: 2000,
1113
+ decayLambda: 0.05,
1114
+ captureTypes: ["decision", "constraint", "discovery", "rejection"],
1115
+ minQualityScore: 0.3,
1116
+ };
1117
+
1118
+ function isStringArray(value) {
1119
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
1120
+ }
1121
+
1122
+ function readStackmemConfig() {
1123
+ const configPath = join(process.cwd(), ".stackmem.json");
1124
+ try {
1125
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
1126
+ return {
1127
+ ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
1128
+ tokenBudget:
1129
+ typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
1130
+ ? raw.tokenBudget
1131
+ : DEFAULT_STACKMEM_CONFIG.tokenBudget,
1132
+ decayLambda:
1133
+ typeof raw.decayLambda === "number" && raw.decayLambda > 0
1134
+ ? raw.decayLambda
1135
+ : DEFAULT_STACKMEM_CONFIG.decayLambda,
1136
+ captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
1137
+ minQualityScore:
1138
+ typeof raw.minQualityScore === "number"
1139
+ ? raw.minQualityScore
1140
+ : DEFAULT_STACKMEM_CONFIG.minQualityScore,
1141
+ };
1142
+ } catch {
1143
+ return { ...DEFAULT_STACKMEM_CONFIG };
1144
+ }
1145
+ }
1146
+
1147
+ function matchesIgnorePattern(filePath, pattern) {
1148
+ const base = filePath.split("/").pop() ?? filePath;
1149
+
1150
+ if (pattern.endsWith("/")) {
1151
+ const dirName = pattern.slice(0, -1);
1152
+ return filePath.split("/").includes(dirName);
1153
+ }
1154
+
1155
+ if (pattern.includes("*")) {
1156
+ const regex = new RegExp(
1157
+ "^" + pattern.split("*").map((part) => part.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join(".*") + "$"
1158
+ );
1159
+ return regex.test(base) || regex.test(filePath);
1160
+ }
1161
+
1162
+ return filePath === pattern || base === pattern;
1163
+ }
1164
+
1165
+ const STACKMEM_CONFIG = readStackmemConfig();
1166
+
836
1167
  const SECRET_PATTERNS = [
837
1168
  /AKIA[0-9A-Z]{16}/g,
838
1169
  /(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
@@ -889,10 +1220,43 @@ function git(args) {
889
1220
  return execFileSync("git", args, { encoding: "utf-8" }).trim();
890
1221
  }
891
1222
 
1223
+ const CURRENT_SHA = git(["rev-parse", "HEAD"]);
1224
+ const CURRENT_AUTHOR = (() => {
1225
+ try {
1226
+ return git(["log", "--format=%ae", "-1", "HEAD"]);
1227
+ } catch {
1228
+ return "";
1229
+ }
1230
+ })();
1231
+
1232
+ // post-commit and post-rewrite both run this exact script against current
1233
+ // HEAD, so a single operation that fires both (e.g. \`git commit --amend\`)
1234
+ // would otherwise capture the same commit twice. Checked once up front,
1235
+ // not per-save — see cmdCheckCommit in src/cli.ts for why.
1236
+ try {
1237
+ execFileSync("node", [CLI_PATH, "check-commit", PROJECT, CURRENT_SHA], { stdio: "pipe" });
1238
+ } catch {
1239
+ return;
1240
+ }
1241
+
1242
+ // file_paths is populated with every file the commit touched (not just
1243
+ // the filtered diffableFiles below) — "cm why <file>" should still find
1244
+ // a commit's decision/discovery even if that specific file's own diff
1245
+ // content was skipped as noise (a lockfile, say).
892
1246
  function cmSave(type, content) {
893
- execFileSync("node", [CLI_PATH, "save", PROJECT, type, content, "--force"], {
894
- stdio: "inherit",
895
- });
1247
+ if (!STACKMEM_CONFIG.captureTypes.includes(type)) return;
1248
+
1249
+ const args = [
1250
+ CLI_PATH, "save", PROJECT, type, content, "--force",
1251
+ "--commit-sha", CURRENT_SHA,
1252
+ "--source", "hook",
1253
+ "--confidence", "0.8",
1254
+ ];
1255
+ if (CURRENT_AUTHOR) args.push("--author", CURRENT_AUTHOR);
1256
+ if (changedFiles.length > 0) args.push("--files", changedFiles.join(","));
1257
+
1258
+ execFileSync("node", args, { stdio: "inherit" });
1259
+ savedItems.push(\`\${type}: \${content}\`);
896
1260
  }
897
1261
 
898
1262
  function basename(filePath) {
@@ -919,6 +1283,29 @@ function isSchemaFile(filePath) {
919
1283
  );
920
1284
  }
921
1285
 
1286
+ // Noise that's never worth diffing: lockfiles, build output, vendored/
1287
+ // generated code, and timestamp-prefixed SQL migrations (their content is
1288
+ // a mechanical dump, not a human decision). Checked against path segments
1289
+ // (not just a prefix) so "apps/web/dist/bundle.js" is skipped too, not
1290
+ // just a top-level "dist/...".
1291
+ const DIFF_SKIP_DIR_NAMES = new Set(["dist", "build", ".next", "coverage", "vendored", "node_modules"]);
1292
+ const DIFF_SKIP_LOCK_NAMES = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]);
1293
+ const MIGRATION_FILE_RE = /^\\d{14}_.*\\.sql$/;
1294
+ const DIFF_MAX_BYTES = 50 * 1024;
1295
+
1296
+ function shouldSkipDiffFile(filePath) {
1297
+ const base = basename(filePath);
1298
+ if (filePath.split("/").some((segment) => DIFF_SKIP_DIR_NAMES.has(segment))) return true;
1299
+ if (DIFF_SKIP_LOCK_NAMES.has(base) || base.endsWith(".lock")) return true;
1300
+ if (base.endsWith(".min.js") || base.endsWith(".min.css")) return true;
1301
+ if (base.endsWith(".map")) return true;
1302
+ if (base.endsWith(".pb.go")) return true;
1303
+ if (/\\.generated\\./.test(base)) return true;
1304
+ if (MIGRATION_FILE_RE.test(base)) return true;
1305
+ if (STACKMEM_CONFIG.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern))) return true;
1306
+ return false;
1307
+ }
1308
+
922
1309
  const LOW_QUALITY_PREFIXES = [
923
1310
  "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
924
1311
  "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
@@ -953,11 +1340,10 @@ const savedItems = [];
953
1340
 
954
1341
  const commitMessage = git(["log", "-1", "--pretty=%B"]);
955
1342
 
956
- // The sync commit below is itself a commit, so it fires this same
957
- // post-commit hook again — git's --no-verify only skips pre-commit and
958
- // commit-msg hooks, never post-commit. Without this guard, the hook would
959
- // save its own commit message as a new decision memory, which changes the
960
- // rules-file content and triggers another sync commit, indefinitely.
1343
+ // Kept as a general escape hatch for other automation (CI bots, scripts)
1344
+ // that tags its own commits this way and shouldn't be captured as a
1345
+ // memory — cm sync no longer self-commits, so this isn't load-bearing for
1346
+ // that anymore, but other tools' commits still deserve the same skip.
961
1347
  if (commitMessage.includes("[skip ci]")) {
962
1348
  return;
963
1349
  }
@@ -981,10 +1367,40 @@ const changedFiles = git(
981
1367
 
982
1368
  let diff = "";
983
1369
  if (!isFirstCommit) {
1370
+ // --numstat marks a binary file's added/deleted columns as "-" — that's
1371
+ // the cheapest way to identify them without fetching their (useless,
1372
+ // and potentially huge) diff content at all.
1373
+ let binaryFiles = new Set();
984
1374
  try {
985
- diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
1375
+ const numstat = execFileSync("git", ["diff", "--numstat", "HEAD~1", "HEAD"], {
1376
+ encoding: "utf-8",
1377
+ });
1378
+ for (const line of numstat.split("\\n")) {
1379
+ const parts = line.split("\\t");
1380
+ if (parts[0] === "-" && parts[1] === "-" && parts[2]) {
1381
+ binaryFiles.add(parts[2]);
1382
+ }
1383
+ }
986
1384
  } catch {
987
- diff = "";
1385
+ // Nothing to compare against — leave binaryFiles empty.
1386
+ }
1387
+
1388
+ const diffableFiles = changedFiles.filter(
1389
+ (file) => !binaryFiles.has(file) && !shouldSkipDiffFile(file)
1390
+ );
1391
+
1392
+ if (diffableFiles.length > 0) {
1393
+ try {
1394
+ diff = execFileSync("git", ["diff", "HEAD~1", "HEAD", "--", ...diffableFiles], {
1395
+ encoding: "utf-8",
1396
+ });
1397
+ } catch {
1398
+ diff = "";
1399
+ }
1400
+ }
1401
+
1402
+ if (diff.length > DIFF_MAX_BYTES) {
1403
+ diff = diff.slice(0, DIFF_MAX_BYTES) + "\\n\\n... [diff truncated: exceeded 50KB after filtering]\\n";
988
1404
  }
989
1405
  }
990
1406
 
@@ -1011,17 +1427,14 @@ for (const line of isFirstCommit ? [] : lines) {
1011
1427
  if (/(auth|login|session|token)/i.test(currentFile)) {
1012
1428
  const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
1013
1429
  cmSave("discovery", content);
1014
- savedItems.push(\`discovery: \${content}\`);
1015
1430
  }
1016
1431
  if (isConfigFile(currentFile)) {
1017
1432
  const content = redactSecrets(\`new config: \${currentFile}\`);
1018
1433
  cmSave("constraint", content);
1019
- savedItems.push(\`constraint: \${content}\`);
1020
1434
  }
1021
1435
  if (isSchemaFile(currentFile)) {
1022
1436
  const content = redactSecrets(\`schema change: \${currentFile}\`);
1023
1437
  cmSave("discovery", content);
1024
- savedItems.push(\`discovery: \${content}\`);
1025
1438
  }
1026
1439
  }
1027
1440
  continue;
@@ -1058,7 +1471,6 @@ for (const line of isFirstCommit ? [] : lines) {
1058
1471
  if (removedModulePath) {
1059
1472
  const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
1060
1473
  cmSave("rejection", content);
1061
- savedItems.push(\`rejection: \${content}\`);
1062
1474
  }
1063
1475
 
1064
1476
  if (isPackageJson && inDependenciesSection) {
@@ -1066,7 +1478,6 @@ for (const line of isFirstCommit ? [] : lines) {
1066
1478
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
1067
1479
  const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
1068
1480
  cmSave("rejection", content);
1069
- savedItems.push(\`rejection: \${content}\`);
1070
1481
  }
1071
1482
  }
1072
1483
  continue;
@@ -1082,7 +1493,6 @@ for (const line of isFirstCommit ? [] : lines) {
1082
1493
  if (envMatch) {
1083
1494
  const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
1084
1495
  cmSave("constraint", content);
1085
- savedItems.push(\`constraint: \${content}\`);
1086
1496
  }
1087
1497
  }
1088
1498
  }
@@ -1092,7 +1502,6 @@ for (const line of isFirstCommit ? [] : lines) {
1092
1502
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
1093
1503
  const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
1094
1504
  cmSave("decision", content);
1095
- savedItems.push(\`decision: \${content}\`);
1096
1505
  }
1097
1506
  }
1098
1507
 
@@ -1100,14 +1509,12 @@ for (const line of isFirstCommit ? [] : lines) {
1100
1509
  const truncated = codeLine.trim().slice(0, 80);
1101
1510
  const content = redactSecrets(\`database change: \${truncated}\`);
1102
1511
  cmSave("discovery", content);
1103
- savedItems.push(\`discovery: \${content}\`);
1104
1512
  }
1105
1513
 
1106
1514
  if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
1107
1515
  const truncated = codeLine.trim().slice(0, 100);
1108
1516
  const content = redactSecrets(\`open issue from commit: \${truncated}\`);
1109
1517
  cmSave("discovery", content);
1110
- savedItems.push(\`discovery: \${content}\`);
1111
1518
  }
1112
1519
  }
1113
1520
  }
@@ -1136,19 +1543,16 @@ if (breakingMatch) {
1136
1543
  const breakingContent = redactSecrets(breakingMatch[1].trim());
1137
1544
  const content = \`BREAKING CHANGE: \${breakingContent}\`;
1138
1545
  cmSave("constraint", content);
1139
- savedItems.push(\`constraint: \${content}\`);
1140
1546
  }
1141
1547
 
1142
1548
  if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
1143
1549
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1144
1550
  cmSave("decision", trimmedMessage);
1145
- savedItems.push(\`decision: \${trimmedMessage}\`);
1146
1551
  } else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
1147
1552
  // docs/style/test/ci/build/chore — skip entirely, nothing saved.
1148
1553
  } else if (isQualityCommitMessage(commitMessage)) {
1149
1554
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1150
1555
  cmSave("decision", trimmedMessage);
1151
- savedItems.push(\`decision: \${trimmedMessage}\`);
1152
1556
  }
1153
1557
 
1154
1558
  console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
@@ -1156,21 +1560,13 @@ for (const item of savedItems) {
1156
1560
  console.log(\` - \${item}\`);
1157
1561
  }
1158
1562
 
1563
+ // Updates .cursorrules/.windsurfrules on disk only — no git add/commit.
1564
+ // Editors read these files straight off disk, and they're gitignored by
1565
+ // default (see DEFAULT_GITIGNORE_CONTENT), so there's nothing to keep in
1566
+ // sync with git history; committing them here used to double the commit
1567
+ // count on every real commit and produced constant rules-file merge
1568
+ // conflicts on teams.
1159
1569
  execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
1160
-
1161
- // cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
1162
- // the working directory dirty (breaking things like \`npm version patch\`
1163
- // that require a clean tree). Commit the sync ourselves instead. --no-verify
1164
- // skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
1165
- // guard above, which is what actually stops this from recursing).
1166
- try {
1167
- execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
1168
- execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
1169
- stdio: "pipe",
1170
- });
1171
- } catch {
1172
- // Nothing to commit (rules files unchanged) — fine, ignore.
1173
- }
1174
1570
  }
1175
1571
 
1176
1572
  async function main() {
@@ -1196,15 +1592,27 @@ main().finally(() => process.exit(0));
1196
1592
  `;
1197
1593
  }
1198
1594
 
1199
- // Installs (or re-installs) the stackmem post-commit hook at `hooksDir`. If
1200
- // a post-commit hook already exists and wasn't written by stackmem, it's
1595
+ // Hooks stackmem manages. post-rewrite fires on `git commit --amend` and
1596
+ // `git rebase` (neither of which is a plain commit, so post-commit alone
1597
+ // misses them) — it runs the exact same capture script as post-commit,
1598
+ // against current HEAD, with the check-commit idempotency guard in the
1599
+ // script itself preventing a double-capture when a single operation
1600
+ // (e.g. --amend) fires both hooks for the same resulting commit.
1601
+ const MANAGED_HOOK_NAMES = ["post-commit", "post-rewrite"] as const;
1602
+
1603
+ function hookBackupFilename(hookName: string): string {
1604
+ return `${hookName}${HOOK_BACKUP_SUFFIX}`;
1605
+ }
1606
+
1607
+ // Installs (or re-installs) a stackmem-managed hook at `hooksDir`. If a
1608
+ // hook of this name already exists and wasn't written by stackmem, it's
1201
1609
  // preserved as a backup and chained to run before stackmem's own hook,
1202
1610
  // rather than being clobbered — see `uninstallHook` for how it's restored.
1203
- function installHook(hooksDir: string, hookLogicPath: string): void {
1611
+ function installHook(hooksDir: string, hookLogicPath: string, hookName: string): void {
1204
1612
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1205
1613
 
1206
- const hookPath = nodePath.join(hooksDir, "post-commit");
1207
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1614
+ const hookPath = nodePath.join(hooksDir, hookName);
1615
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1208
1616
 
1209
1617
  let chainedOriginal = false;
1210
1618
  if (nodeFs.existsSync(hookPath)) {
@@ -1221,7 +1629,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
1221
1629
  }
1222
1630
 
1223
1631
  const chainLine = chainedOriginal
1224
- ? `"$(dirname "$0")/${HOOK_BACKUP_FILENAME}" "$@"\n`
1632
+ ? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
1225
1633
  : "";
1226
1634
 
1227
1635
  nodeFs.writeFileSync(
@@ -1232,7 +1640,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
1232
1640
  nodeFs.chmodSync(hookPath, 0o755);
1233
1641
 
1234
1642
  if (chainedOriginal) {
1235
- console.log("Existing post-commit hook found — chained it to run before stackmem's hook.");
1643
+ console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
1236
1644
  }
1237
1645
  }
1238
1646
 
@@ -1240,9 +1648,9 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
1240
1648
  // stackmem chained it after an existing hook, restores that original hook
1241
1649
  // in its place. Returns a description of what happened, or null if there
1242
1650
  // was no stackmem-managed hook to remove (nothing done).
1243
- function uninstallHook(hooksDir: string): string | null {
1244
- const hookPath = nodePath.join(hooksDir, "post-commit");
1245
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1651
+ function uninstallHook(hooksDir: string, hookName: string): string | null {
1652
+ const hookPath = nodePath.join(hooksDir, hookName);
1653
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1246
1654
 
1247
1655
  if (!nodeFs.existsSync(hookPath)) return null;
1248
1656
 
@@ -1251,11 +1659,11 @@ function uninstallHook(hooksDir: string): string | null {
1251
1659
 
1252
1660
  if (nodeFs.existsSync(backupPath)) {
1253
1661
  nodeFs.renameSync(backupPath, hookPath);
1254
- return ".git/hooks/post-commit (restored original hook stackmem had chained)";
1662
+ return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
1255
1663
  }
1256
1664
 
1257
1665
  nodeFs.unlinkSync(hookPath);
1258
- return ".git/hooks/post-commit";
1666
+ return `.git/hooks/${hookName}`;
1259
1667
  }
1260
1668
 
1261
1669
  // Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
@@ -1318,6 +1726,8 @@ dist/
1318
1726
  coverage/
1319
1727
  *-memories.json
1320
1728
  *.db
1729
+ *cursorrules
1730
+ *windsurfrules
1321
1731
  `;
1322
1732
 
1323
1733
  // Returns true if the file was created, false if it already existed
@@ -1375,10 +1785,16 @@ async function generateRulesContent(project: string): Promise<string> {
1375
1785
 
1376
1786
  if (memoriesError) fail(`Failed to load memories: ${memoriesError.message}`);
1377
1787
 
1788
+ const config = readStackmemConfig();
1378
1789
  const scoredMemories = (memoryRows ?? [])
1379
1790
  .map((memory) => ({
1380
1791
  ...memory,
1381
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
1792
+ decay_score: computeDecayScore(
1793
+ new Date(memory.created_at),
1794
+ memory.access_count ?? 0,
1795
+ memory.pinned ?? false,
1796
+ config.decayLambda
1797
+ ),
1382
1798
  }))
1383
1799
  .sort((a, b) => b.decay_score - a.decay_score);
1384
1800
 
@@ -1497,6 +1913,10 @@ export async function trySeedMemory(
1497
1913
  type,
1498
1914
  content,
1499
1915
  device_id: getDeviceId(),
1916
+ // Seeded by `cm init` scanning project files — an explicit user-run
1917
+ // command, not an auto-captured commit, so it's "manual" like cm save,
1918
+ // not the memories.source column's default of "hook".
1919
+ source: "manual",
1500
1920
  });
1501
1921
  return !error;
1502
1922
  }
@@ -1611,7 +2031,20 @@ async function verifyBackendConnection(project: string): Promise<boolean> {
1611
2031
  return !error;
1612
2032
  }
1613
2033
 
1614
- async function cmdInit(project: string, targetPath: string) {
2034
+ // Returns true if the file was created, false if it already existed
2035
+ // (and was left untouched).
2036
+ function writeStackmemConfigFile(resolvedPath: string): boolean {
2037
+ const configPath = nodePath.join(resolvedPath, STACKMEM_CONFIG_FILENAME);
2038
+
2039
+ if (nodeFs.existsSync(configPath)) {
2040
+ return false;
2041
+ }
2042
+
2043
+ nodeFs.writeFileSync(configPath, defaultConfigFileContents(), "utf-8");
2044
+ return true;
2045
+ }
2046
+
2047
+ async function cmdInit(project: string, targetPath: string, writeConfig = false): Promise<void> {
1615
2048
  const resolvedPath = nodePath.resolve(targetPath);
1616
2049
  const gitDir = nodePath.join(resolvedPath, ".git");
1617
2050
 
@@ -1631,7 +2064,9 @@ async function cmdInit(project: string, targetPath: string) {
1631
2064
  const hooksDir = nodePath.join(gitDir, "hooks");
1632
2065
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1633
2066
 
1634
- installHook(hooksDir, hookLogicPath);
2067
+ for (const hookName of MANAGED_HOOK_NAMES) {
2068
+ installHook(hooksDir, hookLogicPath, hookName);
2069
+ }
1635
2070
 
1636
2071
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
1637
2072
 
@@ -1647,10 +2082,15 @@ async function cmdInit(project: string, targetPath: string) {
1647
2082
 
1648
2083
  const backendOk = await verifyBackendConnection(project);
1649
2084
 
2085
+ const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
2086
+
1650
2087
  console.log("");
1651
2088
  if (gitignoreCreated) {
1652
2089
  console.log("✓ .gitignore created");
1653
2090
  }
2091
+ if (writeConfig) {
2092
+ console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
2093
+ }
1654
2094
  console.log("✓ Git hook installed");
1655
2095
  console.log("✓ MCP server registered");
1656
2096
  console.log("✓ CLAUDE.md written");
@@ -1689,6 +2129,88 @@ async function cmdSync(project: string) {
1689
2129
  console.log("Synced memories to .cursorrules and .windsurfrules");
1690
2130
  }
1691
2131
 
2132
+ // --- cm link ---------------------------------------------------------------
2133
+
2134
+ // Mirrors the link_codes.expires_at default in the migration
2135
+ // (supabase/migrations/001_user_devices_and_link_codes.sql in the vault repo).
2136
+ // If that interval changes, change this too — polling past expires_at just
2137
+ // burns requests against a row the API will refuse.
2138
+ const LINK_CODE_TTL_MS = 10 * 60 * 1000;
2139
+ const LINK_POLL_INTERVAL_MS = 3_000;
2140
+ const LINK_CODE_INSERT_ATTEMPTS = 5;
2141
+ const PG_UNIQUE_VIOLATION = "23505";
2142
+
2143
+ function sleep(ms: number): Promise<void> {
2144
+ return new Promise((resolve) => setTimeout(resolve, ms));
2145
+ }
2146
+
2147
+ // randomInt is a CSPRNG and uniform over the range. Math.random() would do
2148
+ // neither, and a link code is a short-lived credential for claiming this
2149
+ // device's memories — a guessable one is a hijack.
2150
+ function generateLinkCode(): string {
2151
+ return String(randomInt(0, 1_000_000)).padStart(6, "0");
2152
+ }
2153
+
2154
+ async function cmdLink() {
2155
+ const deviceId = getDeviceId();
2156
+
2157
+ // A 6-digit space collides rarely, but only unclaimed codes occupy it and
2158
+ // the primary key makes a collision a hard error rather than a silent
2159
+ // overwrite — so retry with a fresh code instead of failing the command.
2160
+ let code: string | null = null;
2161
+ for (let attempt = 0; attempt < LINK_CODE_INSERT_ATTEMPTS; attempt++) {
2162
+ const candidate = generateLinkCode();
2163
+ const { error } = await supabase
2164
+ .from("link_codes")
2165
+ .insert({ code: candidate, device_id: deviceId });
2166
+
2167
+ if (!error) {
2168
+ code = candidate;
2169
+ break;
2170
+ }
2171
+ if (error.code !== PG_UNIQUE_VIOLATION) {
2172
+ fail(`Failed to create a link code: ${error.message}`);
2173
+ }
2174
+ }
2175
+
2176
+ if (!code) {
2177
+ fail("Could not get an unused link code. Try again in a minute.");
2178
+ }
2179
+
2180
+ console.log(`Go to ${VAULT_URL}/link and enter code: ${code}`);
2181
+
2182
+ const deadline = Date.now() + LINK_CODE_TTL_MS;
2183
+
2184
+ while (Date.now() < deadline) {
2185
+ await sleep(LINK_POLL_INTERVAL_MS);
2186
+
2187
+ const { data, error } = await supabase
2188
+ .from("link_codes")
2189
+ .select("claimed_at")
2190
+ .eq("code", code)
2191
+ .maybeSingle();
2192
+
2193
+ // A transient read failure shouldn't abandon a link the user may have
2194
+ // already completed in the browser — keep polling until the code expires.
2195
+ if (error) continue;
2196
+
2197
+ // The row is gone: either housekeeping swept it or it was never visible
2198
+ // to this device. Either way there's nothing left to claim.
2199
+ if (!data) break;
2200
+
2201
+ if (data.claimed_at) {
2202
+ console.log("Device linked to your GitHub account.");
2203
+
2204
+ // Best-effort cleanup — the code is spent either way, and the API
2205
+ // rejects claimed codes, so a failure here is harmless.
2206
+ await supabase.from("link_codes").delete().eq("code", code);
2207
+ return;
2208
+ }
2209
+ }
2210
+
2211
+ console.log("Link code expired. Run cm link again.");
2212
+ }
2213
+
1692
2214
  // --- cm context ------------------------------------------------------------
1693
2215
 
1694
2216
  const CONTEXT_TYPE_ORDER: MemoryType[] = ["decision", "constraint", "rejection", "discovery"];
@@ -1709,9 +2231,17 @@ async function cmdContext(project: string, task: string) {
1709
2231
  return;
1710
2232
  }
1711
2233
 
2234
+ const config = readStackmemConfig();
2235
+ const tokenBudget = config.tokenBudget;
2236
+
1712
2237
  const scoredMemories = memories
1713
2238
  .map((memory) => {
1714
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
2239
+ const decayScore = computeDecayScore(
2240
+ new Date(memory.created_at),
2241
+ memory.access_count ?? 0,
2242
+ memory.pinned ?? false,
2243
+ config.decayLambda
2244
+ );
1715
2245
  const relevanceScore = scoreMemoryRelevance(memory.content, task);
1716
2246
  return {
1717
2247
  ...memory,
@@ -1721,7 +2251,7 @@ async function cmdContext(project: string, task: string) {
1721
2251
  .sort((a, b) => b.combined_score - a.combined_score);
1722
2252
 
1723
2253
  const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
1724
- const budgetedMemories = applyContextBudget(topMemories, CONTEXT_BUDGET_TOKENS);
2254
+ const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
1725
2255
  const tokensUsed = budgetedMemories.reduce(
1726
2256
  (sum, memory) => sum + estimateTokens(memory.content),
1727
2257
  0
@@ -1740,11 +2270,82 @@ async function cmdContext(project: string, task: string) {
1740
2270
  lines.push("");
1741
2271
  }
1742
2272
 
1743
- lines.push(`*(${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used)*`);
2273
+ lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
1744
2274
 
1745
2275
  console.log(lines.join("\n"));
1746
2276
  }
1747
2277
 
2278
+ // --- cm why --------------------------------------------------------------
2279
+
2280
+ function fileMatchesPaths(filePaths: unknown, file: string, baseName: string): boolean {
2281
+ if (!Array.isArray(filePaths)) return false;
2282
+ return filePaths.some((p) => typeof p === "string" && (p === file || p.split("/").pop() === baseName));
2283
+ }
2284
+
2285
+ function fileMentionedInContent(content: string, file: string, baseName: string): boolean {
2286
+ const lower = content.toLowerCase();
2287
+ return lower.includes(file.toLowerCase()) || lower.includes(baseName.toLowerCase());
2288
+ }
2289
+
2290
+ async function cmdWhy(project: string, file: string) {
2291
+ const baseName = file.split("/").pop() ?? file;
2292
+
2293
+ const { data, error } = await supabase
2294
+ .from("memories")
2295
+ .select("*")
2296
+ .eq("project", project)
2297
+ .eq("resolved", false);
2298
+
2299
+ if (error) fail(`Failed to load memories: ${error.message}`);
2300
+
2301
+ const memories = data ?? [];
2302
+
2303
+ // file_paths (structured, from Item 6) and a content substring search
2304
+ // are combined into one match set rather than one gating the other as
2305
+ // a strict fallback — most memories predate file_paths existing at all
2306
+ // (manual saves and seeds never set it), so content search is the
2307
+ // primary signal for everything except recent hook-captured commits,
2308
+ // not a rare last resort.
2309
+ const matched = memories.filter(
2310
+ (memory) =>
2311
+ fileMatchesPaths(memory.file_paths, file, baseName) ||
2312
+ fileMentionedInContent(memory.content, file, baseName)
2313
+ );
2314
+
2315
+ if (matched.length === 0) {
2316
+ console.log(`No memories found for ${file}.`);
2317
+ console.log("Make some commits touching this file to build up context.");
2318
+ return;
2319
+ }
2320
+
2321
+ const config = readStackmemConfig();
2322
+ const scoredMemories = matched
2323
+ .map((memory) => ({
2324
+ ...memory,
2325
+ decay_score: computeDecayScore(
2326
+ new Date(memory.created_at),
2327
+ memory.access_count ?? 0,
2328
+ memory.pinned ?? false,
2329
+ config.decayLambda
2330
+ ),
2331
+ }))
2332
+ .sort((a, b) => b.decay_score - a.decay_score);
2333
+
2334
+ console.log(`Decisions and constraints tied to ${file}:\n`);
2335
+
2336
+ for (const type of MEMORY_TYPES) {
2337
+ const group = scoredMemories.filter((memory) => memory.type === type);
2338
+ if (group.length === 0) continue;
2339
+
2340
+ console.log(`${TYPE_LABELS[type]} (${group.length})`);
2341
+ console.log("-".repeat(TYPE_LABELS[type].length + 4));
2342
+ for (const memory of group) {
2343
+ console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
2344
+ }
2345
+ console.log("");
2346
+ }
2347
+ }
2348
+
1748
2349
  // --- cm session-end --------------------------------------------------------
1749
2350
 
1750
2351
  interface SessionEndInput {
@@ -1995,7 +2596,9 @@ async function cmdDoctor(fix: boolean) {
1995
2596
  nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
1996
2597
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
1997
2598
  nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
1998
- installHook(hooksDir, hookLogicPath);
2599
+ for (const name of MANAGED_HOOK_NAMES) {
2600
+ installHook(hooksDir, hookLogicPath, name);
2601
+ }
1999
2602
 
2000
2603
  if (isHookExecutable(hookPath)) {
2001
2604
  console.log("Fixed: git hook");
@@ -2192,8 +2795,10 @@ async function cmdUninstall(project: string, targetPath: string) {
2192
2795
  const gitDir = nodePath.join(targetPath, ".git");
2193
2796
  const hooksDir = nodePath.join(gitDir, "hooks");
2194
2797
 
2195
- const hookResult = uninstallHook(hooksDir);
2196
- if (hookResult) console.log(`Removed ${hookResult}`);
2798
+ for (const hookName of MANAGED_HOOK_NAMES) {
2799
+ const hookResult = uninstallHook(hooksDir, hookName);
2800
+ if (hookResult) console.log(`Removed ${hookResult}`);
2801
+ }
2197
2802
 
2198
2803
  const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
2199
2804
 
@@ -2247,10 +2852,21 @@ async function main() {
2247
2852
  case "save": {
2248
2853
  const forceIndex = args.indexOf("--force");
2249
2854
  const force = forceIndex !== -1;
2250
- const positional = force
2855
+ let positional = force
2251
2856
  ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
2252
2857
  : args;
2253
2858
 
2859
+ const commitShaFlag = extractValueFlag(positional, "--commit-sha");
2860
+ positional = commitShaFlag.rest;
2861
+ const authorFlag = extractValueFlag(positional, "--author");
2862
+ positional = authorFlag.rest;
2863
+ const sourceFlag = extractValueFlag(positional, "--source");
2864
+ positional = sourceFlag.rest;
2865
+ const confidenceFlag = extractValueFlag(positional, "--confidence");
2866
+ positional = confidenceFlag.rest;
2867
+ const filesFlag = extractValueFlag(positional, "--files");
2868
+ positional = filesFlag.rest;
2869
+
2254
2870
  let explicitProject: string | null;
2255
2871
  let type: string | undefined;
2256
2872
  let rest: string[];
@@ -2275,7 +2891,28 @@ async function main() {
2275
2891
 
2276
2892
  if (!type || rest.length === 0) usage();
2277
2893
  const project = requireProject(explicitProject);
2278
- await cmdSave(project, type, rest.join(" "), force);
2894
+
2895
+ const provenance: SaveProvenance = {};
2896
+ if (commitShaFlag.value) provenance.commitSha = commitShaFlag.value;
2897
+ if (authorFlag.value) provenance.author = authorFlag.value;
2898
+ if (sourceFlag.value === "hook" || sourceFlag.value === "manual" || sourceFlag.value === "mcp") {
2899
+ provenance.source = sourceFlag.value;
2900
+ }
2901
+ if (confidenceFlag.value) {
2902
+ const parsed = Number(confidenceFlag.value);
2903
+ if (!Number.isNaN(parsed)) provenance.confidence = parsed;
2904
+ }
2905
+ if (filesFlag.value) {
2906
+ provenance.filePaths = filesFlag.value.split(",").map((f) => f.trim()).filter(Boolean);
2907
+ }
2908
+
2909
+ await cmdSave(project, type, rest.join(" "), force, provenance);
2910
+ break;
2911
+ }
2912
+ case "check-commit": {
2913
+ const [project, commitSha] = args;
2914
+ if (!project || !commitSha) usage();
2915
+ await cmdCheckCommit(project, commitSha);
2279
2916
  break;
2280
2917
  }
2281
2918
  case "fix": {
@@ -2309,6 +2946,22 @@ async function main() {
2309
2946
  await cmdResolve(project, memoryId);
2310
2947
  break;
2311
2948
  }
2949
+ case "pin": {
2950
+ const split = splitOptionalProject(args, 1);
2951
+ if (!split) usage();
2952
+ const project = requireProject(split.project);
2953
+ const [memoryId] = split.rest;
2954
+ await cmdPin(project, memoryId);
2955
+ break;
2956
+ }
2957
+ case "unpin": {
2958
+ const split = splitOptionalProject(args, 1);
2959
+ if (!split) usage();
2960
+ const project = requireProject(split.project);
2961
+ const [memoryId] = split.rest;
2962
+ await cmdUnpin(project, memoryId);
2963
+ break;
2964
+ }
2312
2965
  case "delete": {
2313
2966
  const split = splitOptionalProject(args, 1);
2314
2967
  if (!split) usage();
@@ -2333,9 +2986,20 @@ async function main() {
2333
2986
  break;
2334
2987
  }
2335
2988
  case "init": {
2336
- const [project, targetPath] = args;
2989
+ const configIndex = args.indexOf("--config");
2990
+ const writeConfig = configIndex !== -1;
2991
+ const positional = writeConfig
2992
+ ? [...args.slice(0, configIndex), ...args.slice(configIndex + 1)]
2993
+ : args;
2994
+
2995
+ const [project, targetPath] = positional;
2337
2996
  if (!project || !targetPath) usage();
2338
- await cmdInit(project, targetPath);
2997
+ await cmdInit(project, targetPath, writeConfig);
2998
+ break;
2999
+ }
3000
+ case "link": {
3001
+ if (args.length > 0) usage();
3002
+ await cmdLink();
2339
3003
  break;
2340
3004
  }
2341
3005
  case "sync": {
@@ -2351,6 +3015,13 @@ async function main() {
2351
3015
  await cmdContext(project, split.rest.join(" "));
2352
3016
  break;
2353
3017
  }
3018
+ case "why": {
3019
+ const split = splitProjectFromVariadic(args);
3020
+ const project = requireProject(split.project);
3021
+ if (split.rest.length === 0) usage();
3022
+ await cmdWhy(project, split.rest.join(" "));
3023
+ break;
3024
+ }
2354
3025
  case "session-end": {
2355
3026
  await cmdSessionEndEntry(args);
2356
3027
  break;