stackmem 1.0.25 → 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,11 +2,13 @@
2
2
  import nodeFs from "node:fs";
3
3
  import nodeOs from "node:os";
4
4
  import nodePath from "node:path";
5
+ import { execFileSync } from "node:child_process";
5
6
  import { randomInt } from "node:crypto";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  import { createInterface } from "node:readline/promises";
8
9
  import { analyzeCodebase } from "./ast-index.js";
9
10
  import { VAULT_URL } from "./config.js";
11
+ import { defaultConfigFileContents, readStackmemConfig, STACKMEM_CONFIG_FILENAME } from "./config-file.js";
10
12
  import { getDeviceId } from "./device.js";
11
13
  import {
12
14
  applyContextBudget,
@@ -20,12 +22,26 @@ import {
20
22
  indicatesFailedApproach,
21
23
  isAuthRelatedFile,
22
24
  isCodeFile,
25
+ hasTokenOverlap,
23
26
  isQualityMessage,
24
27
  redactSecrets,
25
28
  scoreMemoryRelevance,
29
+ tokenize,
26
30
  } from "./scoring.js";
27
31
  import { supabase } from "./storage.js";
28
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
+
29
45
  const CONTEXT_BUDGET_TOKENS = 2000;
30
46
  const COMPRESSION_THRESHOLD = 20;
31
47
  const CLI_PATH = fileURLToPath(import.meta.url);
@@ -41,7 +57,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
41
57
  const STACKMEM_HOOK_MARKER = "# stackmem-managed";
42
58
  const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
43
59
  const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
44
- const HOOK_BACKUP_FILENAME = "post-commit.pre-stackmem";
60
+ const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
45
61
 
46
62
  const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
47
63
  type MemoryType = (typeof MEMORY_TYPES)[number];
@@ -61,14 +77,17 @@ function usage(): never {
61
77
  cm analyze [project] <path>
62
78
  cm search [--project <name>] <query>
63
79
  cm resolve [project] <memory-id>
80
+ cm pin [project] <memory-id>
81
+ cm unpin [project] <memory-id>
64
82
  cm delete [project] <memory-id>
65
83
  cm inspect [project] <memory-id>
66
84
  cm compress [project]
67
- cm init <project> <path>
85
+ cm init <project> <path> [--config]
68
86
  cm uninstall [project] [path]
69
87
  cm link
70
88
  cm sync
71
89
  cm context [--project <name>] <task>
90
+ cm why [--project <name>] <file>
72
91
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
73
92
  cm doctor [--fix]
74
93
  cm undo [project]
@@ -120,6 +139,14 @@ function cmdHelp() {
120
139
  cm resolve [project] <memory-id>
121
140
  Mark a memory as resolved.
122
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
+
123
150
  cm delete [project] <memory-id>
124
151
  Delete a memory and any memory_links referencing it.
125
152
 
@@ -134,7 +161,7 @@ function cmdHelp() {
134
161
  Cluster related unresolved memories (needs 20+) and collapse
135
162
  clusters of 3 or more into a single summary memory.
136
163
 
137
- cm init <project> <path>
164
+ cm init <project> <path> [--config]
138
165
  Install a git post-commit hook in the repo at <path>, register
139
166
  the stackmem MCP server with Claude Code, write a CLAUDE.md,
140
167
  .cursorrules, .windsurfrules, and .stackmem file, and seed
@@ -142,7 +169,12 @@ function cmdHelp() {
142
169
  .windsurfrules are pre-filled with the project's current
143
170
  memories (for Cursor and Windsurf) and skipped if they already
144
171
  exist. The post-commit hook keeps them fresh afterward by
145
- 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.
146
178
 
147
179
  cm uninstall [project] [path]
148
180
  Remove everything 'cm init' set up at <path> (defaults to the
@@ -166,7 +198,8 @@ function cmdHelp() {
166
198
  Regenerate .cursorrules and .windsurfrules from the project's
167
199
  current unresolved memories (decay-ranked, capped at a 2000
168
200
  token budget) and its 5 most recent open execution_log fixes,
169
- 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.
170
203
 
171
204
  cm context [--project <name>] <task>
172
205
  Return only the top 5 memories most relevant to a specific
@@ -174,6 +207,14 @@ function cmdHelp() {
174
207
  as a markdown block capped at a 2000 token budget. Use
175
208
  --project to target a project other than the one in .stackmem.
176
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
+
177
218
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
178
219
  Capture what happened in a session and extract memories: each
179
220
  error becomes an unsolved execution_log entry, each approach
@@ -293,8 +334,85 @@ function splitProjectFromVariadic(args: string[]): { project: string | null; res
293
334
  return { project: null, rest: args };
294
335
  }
295
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
+
296
348
  // --- cm start ----------------------------------------------------------
297
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
+
298
416
  async function cmdStart(project: string) {
299
417
  const { data, error } = await supabase
300
418
  .from("memories")
@@ -312,14 +430,42 @@ async function cmdStart(project: string) {
312
430
  return;
313
431
  }
314
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
+
315
444
  const scoredMemories = memories
316
- .map((memory) => ({
317
- ...memory,
318
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
319
- }))
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
+ })
320
466
  .sort((a, b) => b.decay_score - a.decay_score);
321
467
 
322
- const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
468
+ const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
323
469
  const omittedCount = scoredMemories.length - budgetedMemories.length;
324
470
  let tokensUsed = budgetedMemories.reduce(
325
471
  (sum, memory) => sum + estimateTokens(memory.content),
@@ -336,12 +482,12 @@ async function cmdStart(project: string) {
336
482
 
337
483
  if (fixError) fail(`Failed to load execution log: ${fixError.message}`);
338
484
 
339
- // Fixes share the same 2000-token budget as memories, which already
340
- // 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.
341
487
  const budgetedFixes: { problem: string; solution: string | null }[] = [];
342
488
  for (const fix of fixData ?? []) {
343
489
  const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
344
- if (tokensUsed + tokens > CONTEXT_BUDGET_TOKENS) break;
490
+ if (tokensUsed + tokens > tokenBudget) break;
345
491
  budgetedFixes.push(fix);
346
492
  tokensUsed += tokens;
347
493
  }
@@ -366,8 +512,24 @@ async function cmdStart(project: string) {
366
512
 
367
513
  console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
368
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
+
369
531
  for (const type of MEMORY_TYPES) {
370
- const group = budgetedMemories.filter((m) => m.type === type);
532
+ const group = unpinnedMemories.filter((m) => m.type === type);
371
533
  if (group.length === 0) continue;
372
534
 
373
535
  console.log(`${TYPE_LABELS[type]} (${group.length})`);
@@ -392,7 +554,7 @@ async function cmdStart(project: string) {
392
554
  }
393
555
  }
394
556
 
395
- console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
557
+ console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
396
558
  if (omittedCount > 0) {
397
559
  console.log(`${omittedCount} memories omitted (budget exceeded)`);
398
560
  }
@@ -446,7 +608,26 @@ async function confirmSupersede(oldId: string, newContent: string): Promise<bool
446
608
  return false;
447
609
  }
448
610
 
449
- 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
+ ) {
450
631
  if (!MEMORY_TYPES.includes(type as MemoryType)) {
451
632
  fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
452
633
  }
@@ -492,9 +673,22 @@ async function cmdSave(project: string, type: string, content: string, force = f
492
673
  if (resolveError) fail(`Failed to resolve superseded memory: ${resolveError.message}`);
493
674
  }
494
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
+
495
689
  const { data: inserted, error } = await supabase
496
690
  .from("memories")
497
- .insert({ project, type, content, device_id: getDeviceId() })
691
+ .insert(insertPayload)
498
692
  .select("id")
499
693
  .single();
500
694
 
@@ -530,6 +724,40 @@ async function cmdSave(project: string, type: string, content: string, force = f
530
724
  }
531
725
  }
532
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
+
533
761
  // --- memory compression ----------------------------------------------------
534
762
 
535
763
  async function compressMemories(
@@ -688,6 +916,34 @@ async function cmdResolve(project: string, memoryId: string) {
688
916
  console.log(`Resolved memory [${memoryId}]`);
689
917
  }
690
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
+
691
947
  // --- cm delete -------------------------------------------------------------
692
948
 
693
949
  async function cmdDelete(project: string, memoryId: string) {
@@ -735,7 +991,13 @@ async function cmdInspect(project: string, memoryId: string) {
735
991
 
736
992
  if (error || !memory) fail(`Memory [${memoryId}] not found for "${project}".`);
737
993
 
738
- 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
+ );
739
1001
 
740
1002
  const { data: links, error: linksError } = await supabase
741
1003
  .from("memory_links")
@@ -843,6 +1105,65 @@ try {
843
1105
 
844
1106
  const CLI_PATH = ${JSON.stringify(cliPath)};
845
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
+
846
1167
  const SECRET_PATTERNS = [
847
1168
  /AKIA[0-9A-Z]{16}/g,
848
1169
  /(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
@@ -899,10 +1220,43 @@ function git(args) {
899
1220
  return execFileSync("git", args, { encoding: "utf-8" }).trim();
900
1221
  }
901
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).
902
1246
  function cmSave(type, content) {
903
- execFileSync("node", [CLI_PATH, "save", PROJECT, type, content, "--force"], {
904
- stdio: "inherit",
905
- });
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}\`);
906
1260
  }
907
1261
 
908
1262
  function basename(filePath) {
@@ -929,6 +1283,29 @@ function isSchemaFile(filePath) {
929
1283
  );
930
1284
  }
931
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
+
932
1309
  const LOW_QUALITY_PREFIXES = [
933
1310
  "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
934
1311
  "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
@@ -963,11 +1340,10 @@ const savedItems = [];
963
1340
 
964
1341
  const commitMessage = git(["log", "-1", "--pretty=%B"]);
965
1342
 
966
- // The sync commit below is itself a commit, so it fires this same
967
- // post-commit hook again — git's --no-verify only skips pre-commit and
968
- // commit-msg hooks, never post-commit. Without this guard, the hook would
969
- // save its own commit message as a new decision memory, which changes the
970
- // rules-file content and triggers another sync commit, indefinitely.
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.
971
1347
  if (commitMessage.includes("[skip ci]")) {
972
1348
  return;
973
1349
  }
@@ -991,10 +1367,40 @@ const changedFiles = git(
991
1367
 
992
1368
  let diff = "";
993
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();
994
1374
  try {
995
- 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
+ }
996
1384
  } catch {
997
- 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";
998
1404
  }
999
1405
  }
1000
1406
 
@@ -1021,17 +1427,14 @@ for (const line of isFirstCommit ? [] : lines) {
1021
1427
  if (/(auth|login|session|token)/i.test(currentFile)) {
1022
1428
  const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
1023
1429
  cmSave("discovery", content);
1024
- savedItems.push(\`discovery: \${content}\`);
1025
1430
  }
1026
1431
  if (isConfigFile(currentFile)) {
1027
1432
  const content = redactSecrets(\`new config: \${currentFile}\`);
1028
1433
  cmSave("constraint", content);
1029
- savedItems.push(\`constraint: \${content}\`);
1030
1434
  }
1031
1435
  if (isSchemaFile(currentFile)) {
1032
1436
  const content = redactSecrets(\`schema change: \${currentFile}\`);
1033
1437
  cmSave("discovery", content);
1034
- savedItems.push(\`discovery: \${content}\`);
1035
1438
  }
1036
1439
  }
1037
1440
  continue;
@@ -1068,7 +1471,6 @@ for (const line of isFirstCommit ? [] : lines) {
1068
1471
  if (removedModulePath) {
1069
1472
  const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
1070
1473
  cmSave("rejection", content);
1071
- savedItems.push(\`rejection: \${content}\`);
1072
1474
  }
1073
1475
 
1074
1476
  if (isPackageJson && inDependenciesSection) {
@@ -1076,7 +1478,6 @@ for (const line of isFirstCommit ? [] : lines) {
1076
1478
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
1077
1479
  const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
1078
1480
  cmSave("rejection", content);
1079
- savedItems.push(\`rejection: \${content}\`);
1080
1481
  }
1081
1482
  }
1082
1483
  continue;
@@ -1092,7 +1493,6 @@ for (const line of isFirstCommit ? [] : lines) {
1092
1493
  if (envMatch) {
1093
1494
  const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
1094
1495
  cmSave("constraint", content);
1095
- savedItems.push(\`constraint: \${content}\`);
1096
1496
  }
1097
1497
  }
1098
1498
  }
@@ -1102,7 +1502,6 @@ for (const line of isFirstCommit ? [] : lines) {
1102
1502
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
1103
1503
  const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
1104
1504
  cmSave("decision", content);
1105
- savedItems.push(\`decision: \${content}\`);
1106
1505
  }
1107
1506
  }
1108
1507
 
@@ -1110,14 +1509,12 @@ for (const line of isFirstCommit ? [] : lines) {
1110
1509
  const truncated = codeLine.trim().slice(0, 80);
1111
1510
  const content = redactSecrets(\`database change: \${truncated}\`);
1112
1511
  cmSave("discovery", content);
1113
- savedItems.push(\`discovery: \${content}\`);
1114
1512
  }
1115
1513
 
1116
1514
  if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
1117
1515
  const truncated = codeLine.trim().slice(0, 100);
1118
1516
  const content = redactSecrets(\`open issue from commit: \${truncated}\`);
1119
1517
  cmSave("discovery", content);
1120
- savedItems.push(\`discovery: \${content}\`);
1121
1518
  }
1122
1519
  }
1123
1520
  }
@@ -1146,19 +1543,16 @@ if (breakingMatch) {
1146
1543
  const breakingContent = redactSecrets(breakingMatch[1].trim());
1147
1544
  const content = \`BREAKING CHANGE: \${breakingContent}\`;
1148
1545
  cmSave("constraint", content);
1149
- savedItems.push(\`constraint: \${content}\`);
1150
1546
  }
1151
1547
 
1152
1548
  if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
1153
1549
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1154
1550
  cmSave("decision", trimmedMessage);
1155
- savedItems.push(\`decision: \${trimmedMessage}\`);
1156
1551
  } else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
1157
1552
  // docs/style/test/ci/build/chore — skip entirely, nothing saved.
1158
1553
  } else if (isQualityCommitMessage(commitMessage)) {
1159
1554
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1160
1555
  cmSave("decision", trimmedMessage);
1161
- savedItems.push(\`decision: \${trimmedMessage}\`);
1162
1556
  }
1163
1557
 
1164
1558
  console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
@@ -1166,21 +1560,13 @@ for (const item of savedItems) {
1166
1560
  console.log(\` - \${item}\`);
1167
1561
  }
1168
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.
1169
1569
  execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
1170
-
1171
- // cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
1172
- // the working directory dirty (breaking things like \`npm version patch\`
1173
- // that require a clean tree). Commit the sync ourselves instead. --no-verify
1174
- // skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
1175
- // guard above, which is what actually stops this from recursing).
1176
- try {
1177
- execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
1178
- execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
1179
- stdio: "pipe",
1180
- });
1181
- } catch {
1182
- // Nothing to commit (rules files unchanged) — fine, ignore.
1183
- }
1184
1570
  }
1185
1571
 
1186
1572
  async function main() {
@@ -1206,15 +1592,27 @@ main().finally(() => process.exit(0));
1206
1592
  `;
1207
1593
  }
1208
1594
 
1209
- // Installs (or re-installs) the stackmem post-commit hook at `hooksDir`. If
1210
- // a post-commit hook already exists and wasn't written by stackmem, it's
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
1211
1609
  // preserved as a backup and chained to run before stackmem's own hook,
1212
1610
  // rather than being clobbered — see `uninstallHook` for how it's restored.
1213
- function installHook(hooksDir: string, hookLogicPath: string): void {
1611
+ function installHook(hooksDir: string, hookLogicPath: string, hookName: string): void {
1214
1612
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1215
1613
 
1216
- const hookPath = nodePath.join(hooksDir, "post-commit");
1217
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1614
+ const hookPath = nodePath.join(hooksDir, hookName);
1615
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1218
1616
 
1219
1617
  let chainedOriginal = false;
1220
1618
  if (nodeFs.existsSync(hookPath)) {
@@ -1231,7 +1629,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
1231
1629
  }
1232
1630
 
1233
1631
  const chainLine = chainedOriginal
1234
- ? `"$(dirname "$0")/${HOOK_BACKUP_FILENAME}" "$@"\n`
1632
+ ? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
1235
1633
  : "";
1236
1634
 
1237
1635
  nodeFs.writeFileSync(
@@ -1242,7 +1640,7 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
1242
1640
  nodeFs.chmodSync(hookPath, 0o755);
1243
1641
 
1244
1642
  if (chainedOriginal) {
1245
- 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.`);
1246
1644
  }
1247
1645
  }
1248
1646
 
@@ -1250,9 +1648,9 @@ function installHook(hooksDir: string, hookLogicPath: string): void {
1250
1648
  // stackmem chained it after an existing hook, restores that original hook
1251
1649
  // in its place. Returns a description of what happened, or null if there
1252
1650
  // was no stackmem-managed hook to remove (nothing done).
1253
- function uninstallHook(hooksDir: string): string | null {
1254
- const hookPath = nodePath.join(hooksDir, "post-commit");
1255
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1651
+ function uninstallHook(hooksDir: string, hookName: string): string | null {
1652
+ const hookPath = nodePath.join(hooksDir, hookName);
1653
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1256
1654
 
1257
1655
  if (!nodeFs.existsSync(hookPath)) return null;
1258
1656
 
@@ -1261,11 +1659,11 @@ function uninstallHook(hooksDir: string): string | null {
1261
1659
 
1262
1660
  if (nodeFs.existsSync(backupPath)) {
1263
1661
  nodeFs.renameSync(backupPath, hookPath);
1264
- return ".git/hooks/post-commit (restored original hook stackmem had chained)";
1662
+ return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
1265
1663
  }
1266
1664
 
1267
1665
  nodeFs.unlinkSync(hookPath);
1268
- return ".git/hooks/post-commit";
1666
+ return `.git/hooks/${hookName}`;
1269
1667
  }
1270
1668
 
1271
1669
  // Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
@@ -1328,6 +1726,8 @@ dist/
1328
1726
  coverage/
1329
1727
  *-memories.json
1330
1728
  *.db
1729
+ *cursorrules
1730
+ *windsurfrules
1331
1731
  `;
1332
1732
 
1333
1733
  // Returns true if the file was created, false if it already existed
@@ -1385,10 +1785,16 @@ async function generateRulesContent(project: string): Promise<string> {
1385
1785
 
1386
1786
  if (memoriesError) fail(`Failed to load memories: ${memoriesError.message}`);
1387
1787
 
1788
+ const config = readStackmemConfig();
1388
1789
  const scoredMemories = (memoryRows ?? [])
1389
1790
  .map((memory) => ({
1390
1791
  ...memory,
1391
- 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
+ ),
1392
1798
  }))
1393
1799
  .sort((a, b) => b.decay_score - a.decay_score);
1394
1800
 
@@ -1507,6 +1913,10 @@ export async function trySeedMemory(
1507
1913
  type,
1508
1914
  content,
1509
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",
1510
1920
  });
1511
1921
  return !error;
1512
1922
  }
@@ -1621,7 +2031,20 @@ async function verifyBackendConnection(project: string): Promise<boolean> {
1621
2031
  return !error;
1622
2032
  }
1623
2033
 
1624
- 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> {
1625
2048
  const resolvedPath = nodePath.resolve(targetPath);
1626
2049
  const gitDir = nodePath.join(resolvedPath, ".git");
1627
2050
 
@@ -1641,7 +2064,9 @@ async function cmdInit(project: string, targetPath: string) {
1641
2064
  const hooksDir = nodePath.join(gitDir, "hooks");
1642
2065
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1643
2066
 
1644
- installHook(hooksDir, hookLogicPath);
2067
+ for (const hookName of MANAGED_HOOK_NAMES) {
2068
+ installHook(hooksDir, hookLogicPath, hookName);
2069
+ }
1645
2070
 
1646
2071
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
1647
2072
 
@@ -1657,10 +2082,15 @@ async function cmdInit(project: string, targetPath: string) {
1657
2082
 
1658
2083
  const backendOk = await verifyBackendConnection(project);
1659
2084
 
2085
+ const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
2086
+
1660
2087
  console.log("");
1661
2088
  if (gitignoreCreated) {
1662
2089
  console.log("✓ .gitignore created");
1663
2090
  }
2091
+ if (writeConfig) {
2092
+ console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
2093
+ }
1664
2094
  console.log("✓ Git hook installed");
1665
2095
  console.log("✓ MCP server registered");
1666
2096
  console.log("✓ CLAUDE.md written");
@@ -1801,9 +2231,17 @@ async function cmdContext(project: string, task: string) {
1801
2231
  return;
1802
2232
  }
1803
2233
 
2234
+ const config = readStackmemConfig();
2235
+ const tokenBudget = config.tokenBudget;
2236
+
1804
2237
  const scoredMemories = memories
1805
2238
  .map((memory) => {
1806
- 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
+ );
1807
2245
  const relevanceScore = scoreMemoryRelevance(memory.content, task);
1808
2246
  return {
1809
2247
  ...memory,
@@ -1813,7 +2251,7 @@ async function cmdContext(project: string, task: string) {
1813
2251
  .sort((a, b) => b.combined_score - a.combined_score);
1814
2252
 
1815
2253
  const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
1816
- const budgetedMemories = applyContextBudget(topMemories, CONTEXT_BUDGET_TOKENS);
2254
+ const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
1817
2255
  const tokensUsed = budgetedMemories.reduce(
1818
2256
  (sum, memory) => sum + estimateTokens(memory.content),
1819
2257
  0
@@ -1832,11 +2270,82 @@ async function cmdContext(project: string, task: string) {
1832
2270
  lines.push("");
1833
2271
  }
1834
2272
 
1835
- lines.push(`*(${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used)*`);
2273
+ lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
1836
2274
 
1837
2275
  console.log(lines.join("\n"));
1838
2276
  }
1839
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
+
1840
2349
  // --- cm session-end --------------------------------------------------------
1841
2350
 
1842
2351
  interface SessionEndInput {
@@ -2087,7 +2596,9 @@ async function cmdDoctor(fix: boolean) {
2087
2596
  nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
2088
2597
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
2089
2598
  nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
2090
- installHook(hooksDir, hookLogicPath);
2599
+ for (const name of MANAGED_HOOK_NAMES) {
2600
+ installHook(hooksDir, hookLogicPath, name);
2601
+ }
2091
2602
 
2092
2603
  if (isHookExecutable(hookPath)) {
2093
2604
  console.log("Fixed: git hook");
@@ -2284,8 +2795,10 @@ async function cmdUninstall(project: string, targetPath: string) {
2284
2795
  const gitDir = nodePath.join(targetPath, ".git");
2285
2796
  const hooksDir = nodePath.join(gitDir, "hooks");
2286
2797
 
2287
- const hookResult = uninstallHook(hooksDir);
2288
- 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
+ }
2289
2802
 
2290
2803
  const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
2291
2804
 
@@ -2339,10 +2852,21 @@ async function main() {
2339
2852
  case "save": {
2340
2853
  const forceIndex = args.indexOf("--force");
2341
2854
  const force = forceIndex !== -1;
2342
- const positional = force
2855
+ let positional = force
2343
2856
  ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
2344
2857
  : args;
2345
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
+
2346
2870
  let explicitProject: string | null;
2347
2871
  let type: string | undefined;
2348
2872
  let rest: string[];
@@ -2367,7 +2891,28 @@ async function main() {
2367
2891
 
2368
2892
  if (!type || rest.length === 0) usage();
2369
2893
  const project = requireProject(explicitProject);
2370
- 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);
2371
2916
  break;
2372
2917
  }
2373
2918
  case "fix": {
@@ -2401,6 +2946,22 @@ async function main() {
2401
2946
  await cmdResolve(project, memoryId);
2402
2947
  break;
2403
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
+ }
2404
2965
  case "delete": {
2405
2966
  const split = splitOptionalProject(args, 1);
2406
2967
  if (!split) usage();
@@ -2425,9 +2986,15 @@ async function main() {
2425
2986
  break;
2426
2987
  }
2427
2988
  case "init": {
2428
- 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;
2429
2996
  if (!project || !targetPath) usage();
2430
- await cmdInit(project, targetPath);
2997
+ await cmdInit(project, targetPath, writeConfig);
2431
2998
  break;
2432
2999
  }
2433
3000
  case "link": {
@@ -2448,6 +3015,13 @@ async function main() {
2448
3015
  await cmdContext(project, split.rest.join(" "));
2449
3016
  break;
2450
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
+ }
2451
3025
  case "session-end": {
2452
3026
  await cmdSessionEndEntry(args);
2453
3027
  break;