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/dist/cli.js CHANGED
@@ -2,14 +2,25 @@
2
2
  import nodeFs from "node:fs";
3
3
  import nodeOs from "node:os";
4
4
  import nodePath from "node:path";
5
+ import { execFileSync } from "node:child_process";
5
6
  import { randomInt } from "node:crypto";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  import { createInterface } from "node:readline/promises";
8
9
  import { analyzeCodebase } from "./ast-index.js";
9
10
  import { VAULT_URL } from "./config.js";
11
+ import { defaultConfigFileContents, readStackmemConfig, STACKMEM_CONFIG_FILENAME } from "./config-file.js";
10
12
  import { getDeviceId } from "./device.js";
11
- import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, redactSecrets, scoreMemoryRelevance, } from "./scoring.js";
13
+ import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, hasTokenOverlap, isQualityMessage, redactSecrets, scoreMemoryRelevance, tokenize, } from "./scoring.js";
12
14
  import { supabase } from "./storage.js";
15
+ // Checked as early as ESM import hoisting allows — before any command
16
+ // dispatch, config loading, or network call — so an unsupported Node
17
+ // version fails with a clear message instead of an obscure syntax or
18
+ // API error from deeper in the dependency chain.
19
+ const NODE_MAJOR_VERSION = Number(process.versions.node.split(".")[0]);
20
+ if (NODE_MAJOR_VERSION < 18) {
21
+ console.error(`Error: stackmem requires Node 18 or higher.\nYou are running Node ${process.versions.node}.\nPlease upgrade: https://nodejs.org`);
22
+ process.exit(1);
23
+ }
13
24
  const CONTEXT_BUDGET_TOKENS = 2000;
14
25
  const COMPRESSION_THRESHOLD = 20;
15
26
  const CLI_PATH = fileURLToPath(import.meta.url);
@@ -24,7 +35,7 @@ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist",
24
35
  const STACKMEM_HOOK_MARKER = "# stackmem-managed";
25
36
  const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
26
37
  const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
27
- const HOOK_BACKUP_FILENAME = "post-commit.pre-stackmem";
38
+ const HOOK_BACKUP_SUFFIX = ".pre-stackmem";
28
39
  const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"];
29
40
  const TYPE_LABELS = {
30
41
  decision: "Decisions",
@@ -40,14 +51,17 @@ function usage() {
40
51
  cm analyze [project] <path>
41
52
  cm search [--project <name>] <query>
42
53
  cm resolve [project] <memory-id>
54
+ cm pin [project] <memory-id>
55
+ cm unpin [project] <memory-id>
43
56
  cm delete [project] <memory-id>
44
57
  cm inspect [project] <memory-id>
45
58
  cm compress [project]
46
- cm init <project> <path>
59
+ cm init <project> <path> [--config]
47
60
  cm uninstall [project] [path]
48
61
  cm link
49
62
  cm sync
50
63
  cm context [--project <name>] <task>
64
+ cm why [--project <name>] <file>
51
65
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
52
66
  cm doctor [--fix]
53
67
  cm undo [project]
@@ -98,6 +112,14 @@ function cmdHelp() {
98
112
  cm resolve [project] <memory-id>
99
113
  Mark a memory as resolved.
100
114
 
115
+ cm pin [project] <memory-id>
116
+ Pin a memory: it scores 999 in decay ranking (always sorts
117
+ first, never decays) and shows in its own "Pinned" section at
118
+ the top of 'cm start', ahead of its normal type group.
119
+
120
+ cm unpin [project] <memory-id>
121
+ Un-pin a memory, returning it to normal decay-based ranking.
122
+
101
123
  cm delete [project] <memory-id>
102
124
  Delete a memory and any memory_links referencing it.
103
125
 
@@ -112,7 +134,7 @@ function cmdHelp() {
112
134
  Cluster related unresolved memories (needs 20+) and collapse
113
135
  clusters of 3 or more into a single summary memory.
114
136
 
115
- cm init <project> <path>
137
+ cm init <project> <path> [--config]
116
138
  Install a git post-commit hook in the repo at <path>, register
117
139
  the stackmem MCP server with Claude Code, write a CLAUDE.md,
118
140
  .cursorrules, .windsurfrules, and .stackmem file, and seed
@@ -120,7 +142,12 @@ function cmdHelp() {
120
142
  .windsurfrules are pre-filled with the project's current
121
143
  memories (for Cursor and Windsurf) and skipped if they already
122
144
  exist. The post-commit hook keeps them fresh afterward by
123
- running 'cm sync' on every commit.
145
+ running 'cm sync' on every commit — on disk only, never
146
+ committed, so they don't double your commit count or conflict
147
+ across branches. Both files are added to .gitignore. With
148
+ --config, also writes a default .stackmem.json (ignore patterns,
149
+ tokenBudget, decayLambda, captureTypes, minQualityScore — see
150
+ src/config-file.ts) if one doesn't already exist.
124
151
 
125
152
  cm uninstall [project] [path]
126
153
  Remove everything 'cm init' set up at <path> (defaults to the
@@ -144,7 +171,8 @@ function cmdHelp() {
144
171
  Regenerate .cursorrules and .windsurfrules from the project's
145
172
  current unresolved memories (decay-ranked, capped at a 2000
146
173
  token budget) and its 5 most recent open execution_log fixes,
147
- overwriting both files. Project is read from .stackmem.
174
+ overwriting both files on disk only (never git add/commit).
175
+ Project is read from .stackmem.
148
176
 
149
177
  cm context [--project <name>] <task>
150
178
  Return only the top 5 memories most relevant to a specific
@@ -152,6 +180,14 @@ function cmdHelp() {
152
180
  as a markdown block capped at a 2000 token budget. Use
153
181
  --project to target a project other than the one in .stackmem.
154
182
 
183
+ cm why [--project <name>] <file>
184
+ Show memories tied to a file: matched by Item 6's file_paths
185
+ column (hook-captured commits that touched it) plus a plain
186
+ content search for the file's path or basename, ranked by decay
187
+ score and grouped by type. Prints a guidance message instead if
188
+ nothing matches. Use --project to target a project other than
189
+ the one in .stackmem.
190
+
155
191
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
156
192
  Capture what happened in a session and extract memories: each
157
193
  error becomes an unsolved execution_log entry, each approach
@@ -257,7 +293,84 @@ function splitProjectFromVariadic(args) {
257
293
  }
258
294
  return { project: null, rest: args };
259
295
  }
296
+ // Generic "--flag value" extractor for internal/provenance flags (not
297
+ // meant to be remembered by end users — only the hook and MCP server pass
298
+ // these). Unlike splitProjectFromVariadic's --project, these don't need
299
+ // special positional disambiguation, so one generic helper covers all of
300
+ // them: --commit-sha, --author, --source, --confidence, --files.
301
+ function extractValueFlag(args, flag) {
302
+ const idx = args.indexOf(flag);
303
+ if (idx === -1 || args[idx + 1] === undefined)
304
+ return { value: null, rest: args };
305
+ return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
306
+ }
260
307
  // --- cm start ----------------------------------------------------------
308
+ const RECENT_COMMITS_FOR_FILE_SIGNAL = 5;
309
+ const BRANCH_NAME_BOOST = 1.2;
310
+ const RECENT_FILES_BOOST = 1.3;
311
+ const PR_TITLE_BOOST = 1.2;
312
+ // null means "signal unavailable" (git/gh failed, detached HEAD, not
313
+ // enough history, gh not installed, no open PR, etc.) — every one of
314
+ // these is a soft skip, never an error, per the task spec.
315
+ function getBranchNameTokens() {
316
+ try {
317
+ const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
318
+ encoding: "utf-8",
319
+ stdio: ["ignore", "pipe", "ignore"],
320
+ }).trim();
321
+ if (!branch || branch === "HEAD")
322
+ return null; // detached HEAD
323
+ const tokens = tokenize(branch);
324
+ return tokens.size > 0 ? tokens : null;
325
+ }
326
+ catch {
327
+ return null;
328
+ }
329
+ }
330
+ function getRecentlyTouchedFiles() {
331
+ try {
332
+ const output = execFileSync("git", ["diff", "--name-only", `HEAD~${RECENT_COMMITS_FOR_FILE_SIGNAL}`, "HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
333
+ const files = output.split("\n").map((f) => f.trim()).filter(Boolean);
334
+ return files.length > 0 ? new Set(files) : null;
335
+ }
336
+ catch {
337
+ // Fewer than RECENT_COMMITS_FOR_FILE_SIGNAL commits in history, or
338
+ // not a git repo at all — skip gracefully.
339
+ return null;
340
+ }
341
+ }
342
+ function getOpenPrTitleTokens() {
343
+ try {
344
+ const title = execFileSync("gh", ["pr", "view", "--json", "title", "-q", ".title"], {
345
+ encoding: "utf-8",
346
+ stdio: ["ignore", "pipe", "ignore"],
347
+ }).trim();
348
+ if (!title)
349
+ return null;
350
+ const tokens = tokenize(title);
351
+ return tokens.size > 0 ? tokens : null;
352
+ }
353
+ catch {
354
+ // gh not installed, not authenticated, or no open PR for this branch.
355
+ return null;
356
+ }
357
+ }
358
+ function fileListOverlaps(filePaths, recentFiles) {
359
+ if (!Array.isArray(filePaths))
360
+ return false;
361
+ return filePaths.some((candidate) => {
362
+ if (typeof candidate !== "string")
363
+ return false;
364
+ if (recentFiles.has(candidate))
365
+ return true;
366
+ const base = candidate.split("/").pop();
367
+ for (const recent of recentFiles) {
368
+ if (recent.split("/").pop() === base)
369
+ return true;
370
+ }
371
+ return false;
372
+ });
373
+ }
261
374
  async function cmdStart(project) {
262
375
  const { data, error } = await supabase
263
376
  .from("memories")
@@ -273,13 +386,32 @@ async function cmdStart(project) {
273
386
  console.log("Tip: run 'cm init' to seed tech stack memories from your project files.");
274
387
  return;
275
388
  }
389
+ // Task-aware re-ranking: boost memories relevant to what's actively
390
+ // being worked on, multiplicatively on top of the base decay score.
391
+ // Every signal degrades to null (no boost, not an error) if the
392
+ // repo/branch/gh state it needs isn't available.
393
+ const branchTokens = getBranchNameTokens();
394
+ const recentFiles = getRecentlyTouchedFiles();
395
+ const prTitleTokens = getOpenPrTitleTokens();
396
+ const config = readStackmemConfig();
397
+ const tokenBudget = config.tokenBudget;
276
398
  const scoredMemories = memories
277
- .map((memory) => ({
278
- ...memory,
279
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
280
- }))
399
+ .map((memory) => {
400
+ let decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
401
+ const contentTokens = tokenize(memory.content);
402
+ if (branchTokens && hasTokenOverlap(contentTokens, branchTokens)) {
403
+ decayScore *= BRANCH_NAME_BOOST;
404
+ }
405
+ if (recentFiles && fileListOverlaps(memory.file_paths, recentFiles)) {
406
+ decayScore *= RECENT_FILES_BOOST;
407
+ }
408
+ if (prTitleTokens && hasTokenOverlap(contentTokens, prTitleTokens)) {
409
+ decayScore *= PR_TITLE_BOOST;
410
+ }
411
+ return { ...memory, decay_score: decayScore };
412
+ })
281
413
  .sort((a, b) => b.decay_score - a.decay_score);
282
- const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
414
+ const budgetedMemories = applyContextBudget(scoredMemories, tokenBudget);
283
415
  const omittedCount = scoredMemories.length - budgetedMemories.length;
284
416
  let tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
285
417
  const { data: fixData, error: fixError } = await supabase
@@ -291,12 +423,12 @@ async function cmdStart(project) {
291
423
  .limit(5);
292
424
  if (fixError)
293
425
  fail(`Failed to load execution log: ${fixError.message}`);
294
- // Fixes share the same 2000-token budget as memories, which already
295
- // consumed their portion above — fixes only get whatever room is left.
426
+ // Fixes share the same token budget as memories, which already consumed
427
+ // their portion above — fixes only get whatever room is left.
296
428
  const budgetedFixes = [];
297
429
  for (const fix of fixData ?? []) {
298
430
  const tokens = estimateTokens(`${fix.problem} ${fix.solution ?? ""}`);
299
- if (tokensUsed + tokens > CONTEXT_BUDGET_TOKENS)
431
+ if (tokensUsed + tokens > tokenBudget)
300
432
  break;
301
433
  budgetedFixes.push(fix);
302
434
  tokensUsed += tokens;
@@ -319,8 +451,22 @@ async function cmdStart(project) {
319
451
  linkedIdsByMemory.get(link.target_id).add(link.source_id);
320
452
  }
321
453
  console.log(`Loaded ${scoredMemories.length} memory(ies) for "${project}".\n`);
454
+ const pinnedMemories = budgetedMemories.filter((m) => m.pinned);
455
+ const unpinnedMemories = budgetedMemories.filter((m) => !m.pinned);
456
+ if (pinnedMemories.length > 0) {
457
+ console.log(`Pinned (${pinnedMemories.length})`);
458
+ console.log("-".repeat(11));
459
+ for (const memory of pinnedMemories) {
460
+ console.log(` [${memory.id}] (${memory.type}) ${memory.content}`);
461
+ const linked = Array.from(linkedIdsByMemory.get(memory.id) ?? []);
462
+ if (linked.length > 0) {
463
+ console.log(` linked: ${linked.join(", ")}`);
464
+ }
465
+ }
466
+ console.log("");
467
+ }
322
468
  for (const type of MEMORY_TYPES) {
323
- const group = budgetedMemories.filter((m) => m.type === type);
469
+ const group = unpinnedMemories.filter((m) => m.type === type);
324
470
  if (group.length === 0)
325
471
  continue;
326
472
  console.log(`${TYPE_LABELS[type]} (${group.length})`);
@@ -343,7 +489,7 @@ async function cmdStart(project) {
343
489
  console.log("");
344
490
  }
345
491
  }
346
- console.log(`Context budget: ${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used`);
492
+ console.log(`Context budget: ${tokensUsed}/${tokenBudget} tokens used`);
347
493
  if (omittedCount > 0) {
348
494
  console.log(`${omittedCount} memories omitted (budget exceeded)`);
349
495
  }
@@ -386,7 +532,7 @@ async function confirmSupersede(oldId, newContent) {
386
532
  console.log("Invalid input — kept original memory.");
387
533
  return false;
388
534
  }
389
- async function cmdSave(project, type, content, force = false) {
535
+ async function cmdSave(project, type, content, force = false, provenance = {}) {
390
536
  if (!MEMORY_TYPES.includes(type)) {
391
537
  fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
392
538
  }
@@ -423,9 +569,24 @@ async function cmdSave(project, type, content, force = false) {
423
569
  if (resolveError)
424
570
  fail(`Failed to resolve superseded memory: ${resolveError.message}`);
425
571
  }
572
+ const { commitSha, filePaths, author, source = "manual", confidence = 1.0 } = provenance;
573
+ const insertPayload = {
574
+ project,
575
+ type,
576
+ content,
577
+ device_id: getDeviceId(),
578
+ source,
579
+ confidence,
580
+ };
581
+ if (commitSha)
582
+ insertPayload.commit_sha = commitSha;
583
+ if (filePaths && filePaths.length > 0)
584
+ insertPayload.file_paths = filePaths;
585
+ if (author)
586
+ insertPayload.author = author;
426
587
  const { data: inserted, error } = await supabase
427
588
  .from("memories")
428
- .insert({ project, type, content, device_id: getDeviceId() })
589
+ .insert(insertPayload)
429
590
  .select("id")
430
591
  .single();
431
592
  if (error || !inserted)
@@ -455,6 +616,36 @@ async function cmdSave(project, type, content, force = false) {
455
616
  await compressMemories(project);
456
617
  }
457
618
  }
619
+ // --- cm check-commit (internal, used by the hook) --------------------------
620
+ // Not a user-facing command (no usage()/help entry) — the hook calls this
621
+ // once per invocation, before doing any diff analysis, to decide whether
622
+ // this commit_sha has already been captured. post-commit and post-rewrite
623
+ // both run the exact same capture logic against current HEAD, so without
624
+ // this guard, a commit that fires both hooks (e.g. `git commit --amend`,
625
+ // which triggers post-commit for the new commit AND post-rewrite for the
626
+ // rewrite) would be captured twice. Checked once per invocation — not
627
+ // inside each individual cmSave call — because a single commit normally
628
+ // produces several distinct memories; a per-save check would see the
629
+ // first save's own commit_sha and wrongly skip the rest.
630
+ async function cmdCheckCommit(project, commitSha) {
631
+ const { data, error } = await supabase
632
+ .from("memories")
633
+ .select("id")
634
+ .eq("project", project)
635
+ .eq("commit_sha", commitSha)
636
+ .limit(1);
637
+ if (error) {
638
+ // Fail open: an error here (including "column does not exist" if the
639
+ // commit_sha migration hasn't landed yet) must not look identical to
640
+ // "duplicate, skip" below — that would silently suppress every hook
641
+ // capture with no log trail. Exit 0 so the normal save path runs;
642
+ // if commit_sha genuinely isn't a column, the save itself fails
643
+ // there instead, where the hook's outer error handler logs it.
644
+ console.error(`check-commit: ${error.message}`);
645
+ process.exit(0);
646
+ }
647
+ process.exit(data && data.length > 0 ? 1 : 0);
648
+ }
458
649
  // --- memory compression ----------------------------------------------------
459
650
  async function compressMemories(project) {
460
651
  const { data, error } = await supabase
@@ -583,6 +774,28 @@ async function cmdResolve(project, memoryId) {
583
774
  }
584
775
  console.log(`Resolved memory [${memoryId}]`);
585
776
  }
777
+ // --- cm pin / cm unpin -------------------------------------------------
778
+ async function setPinned(project, memoryId, pinned) {
779
+ const { data, error } = await supabase
780
+ .from("memories")
781
+ .update({ pinned })
782
+ .eq("project", project)
783
+ .eq("id", memoryId)
784
+ .select("id");
785
+ if (error)
786
+ fail(`Failed to ${pinned ? "pin" : "unpin"} memory: ${error.message}`);
787
+ if (!data || data.length === 0) {
788
+ console.log(`No memory found with ID [${memoryId}]`);
789
+ return;
790
+ }
791
+ console.log(`${pinned ? "Pinned" : "Unpinned"} memory [${memoryId}]`);
792
+ }
793
+ async function cmdPin(project, memoryId) {
794
+ await setPinned(project, memoryId, true);
795
+ }
796
+ async function cmdUnpin(project, memoryId) {
797
+ await setPinned(project, memoryId, false);
798
+ }
586
799
  // --- cm delete -------------------------------------------------------------
587
800
  async function cmdDelete(project, memoryId) {
588
801
  const { error: linksError } = await supabase
@@ -622,7 +835,8 @@ async function cmdInspect(project, memoryId) {
622
835
  .single();
623
836
  if (error || !memory)
624
837
  fail(`Memory [${memoryId}] not found for "${project}".`);
625
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
838
+ const config = readStackmemConfig();
839
+ const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
626
840
  const { data: links, error: linksError } = await supabase
627
841
  .from("memory_links")
628
842
  .select("source_id, target_id, score")
@@ -711,6 +925,65 @@ try {
711
925
 
712
926
  const CLI_PATH = ${JSON.stringify(cliPath)};
713
927
 
928
+ // Inlined copy of src/config-file.ts's defaults/merge logic — this script
929
+ // runs standalone with no import access to that module.
930
+ const DEFAULT_STACKMEM_CONFIG = {
931
+ ignore: [],
932
+ tokenBudget: 2000,
933
+ decayLambda: 0.05,
934
+ captureTypes: ["decision", "constraint", "discovery", "rejection"],
935
+ minQualityScore: 0.3,
936
+ };
937
+
938
+ function isStringArray(value) {
939
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
940
+ }
941
+
942
+ function readStackmemConfig() {
943
+ const configPath = join(process.cwd(), ".stackmem.json");
944
+ try {
945
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
946
+ return {
947
+ ignore: isStringArray(raw.ignore) ? raw.ignore : DEFAULT_STACKMEM_CONFIG.ignore,
948
+ tokenBudget:
949
+ typeof raw.tokenBudget === "number" && raw.tokenBudget > 0
950
+ ? raw.tokenBudget
951
+ : DEFAULT_STACKMEM_CONFIG.tokenBudget,
952
+ decayLambda:
953
+ typeof raw.decayLambda === "number" && raw.decayLambda > 0
954
+ ? raw.decayLambda
955
+ : DEFAULT_STACKMEM_CONFIG.decayLambda,
956
+ captureTypes: isStringArray(raw.captureTypes) ? raw.captureTypes : DEFAULT_STACKMEM_CONFIG.captureTypes,
957
+ minQualityScore:
958
+ typeof raw.minQualityScore === "number"
959
+ ? raw.minQualityScore
960
+ : DEFAULT_STACKMEM_CONFIG.minQualityScore,
961
+ };
962
+ } catch {
963
+ return { ...DEFAULT_STACKMEM_CONFIG };
964
+ }
965
+ }
966
+
967
+ function matchesIgnorePattern(filePath, pattern) {
968
+ const base = filePath.split("/").pop() ?? filePath;
969
+
970
+ if (pattern.endsWith("/")) {
971
+ const dirName = pattern.slice(0, -1);
972
+ return filePath.split("/").includes(dirName);
973
+ }
974
+
975
+ if (pattern.includes("*")) {
976
+ const regex = new RegExp(
977
+ "^" + pattern.split("*").map((part) => part.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join(".*") + "$"
978
+ );
979
+ return regex.test(base) || regex.test(filePath);
980
+ }
981
+
982
+ return filePath === pattern || base === pattern;
983
+ }
984
+
985
+ const STACKMEM_CONFIG = readStackmemConfig();
986
+
714
987
  const SECRET_PATTERNS = [
715
988
  /AKIA[0-9A-Z]{16}/g,
716
989
  /(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
@@ -767,10 +1040,43 @@ function git(args) {
767
1040
  return execFileSync("git", args, { encoding: "utf-8" }).trim();
768
1041
  }
769
1042
 
1043
+ const CURRENT_SHA = git(["rev-parse", "HEAD"]);
1044
+ const CURRENT_AUTHOR = (() => {
1045
+ try {
1046
+ return git(["log", "--format=%ae", "-1", "HEAD"]);
1047
+ } catch {
1048
+ return "";
1049
+ }
1050
+ })();
1051
+
1052
+ // post-commit and post-rewrite both run this exact script against current
1053
+ // HEAD, so a single operation that fires both (e.g. \`git commit --amend\`)
1054
+ // would otherwise capture the same commit twice. Checked once up front,
1055
+ // not per-save — see cmdCheckCommit in src/cli.ts for why.
1056
+ try {
1057
+ execFileSync("node", [CLI_PATH, "check-commit", PROJECT, CURRENT_SHA], { stdio: "pipe" });
1058
+ } catch {
1059
+ return;
1060
+ }
1061
+
1062
+ // file_paths is populated with every file the commit touched (not just
1063
+ // the filtered diffableFiles below) — "cm why <file>" should still find
1064
+ // a commit's decision/discovery even if that specific file's own diff
1065
+ // content was skipped as noise (a lockfile, say).
770
1066
  function cmSave(type, content) {
771
- execFileSync("node", [CLI_PATH, "save", PROJECT, type, content, "--force"], {
772
- stdio: "inherit",
773
- });
1067
+ if (!STACKMEM_CONFIG.captureTypes.includes(type)) return;
1068
+
1069
+ const args = [
1070
+ CLI_PATH, "save", PROJECT, type, content, "--force",
1071
+ "--commit-sha", CURRENT_SHA,
1072
+ "--source", "hook",
1073
+ "--confidence", "0.8",
1074
+ ];
1075
+ if (CURRENT_AUTHOR) args.push("--author", CURRENT_AUTHOR);
1076
+ if (changedFiles.length > 0) args.push("--files", changedFiles.join(","));
1077
+
1078
+ execFileSync("node", args, { stdio: "inherit" });
1079
+ savedItems.push(\`\${type}: \${content}\`);
774
1080
  }
775
1081
 
776
1082
  function basename(filePath) {
@@ -797,6 +1103,29 @@ function isSchemaFile(filePath) {
797
1103
  );
798
1104
  }
799
1105
 
1106
+ // Noise that's never worth diffing: lockfiles, build output, vendored/
1107
+ // generated code, and timestamp-prefixed SQL migrations (their content is
1108
+ // a mechanical dump, not a human decision). Checked against path segments
1109
+ // (not just a prefix) so "apps/web/dist/bundle.js" is skipped too, not
1110
+ // just a top-level "dist/...".
1111
+ const DIFF_SKIP_DIR_NAMES = new Set(["dist", "build", ".next", "coverage", "vendored", "node_modules"]);
1112
+ const DIFF_SKIP_LOCK_NAMES = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]);
1113
+ const MIGRATION_FILE_RE = /^\\d{14}_.*\\.sql$/;
1114
+ const DIFF_MAX_BYTES = 50 * 1024;
1115
+
1116
+ function shouldSkipDiffFile(filePath) {
1117
+ const base = basename(filePath);
1118
+ if (filePath.split("/").some((segment) => DIFF_SKIP_DIR_NAMES.has(segment))) return true;
1119
+ if (DIFF_SKIP_LOCK_NAMES.has(base) || base.endsWith(".lock")) return true;
1120
+ if (base.endsWith(".min.js") || base.endsWith(".min.css")) return true;
1121
+ if (base.endsWith(".map")) return true;
1122
+ if (base.endsWith(".pb.go")) return true;
1123
+ if (/\\.generated\\./.test(base)) return true;
1124
+ if (MIGRATION_FILE_RE.test(base)) return true;
1125
+ if (STACKMEM_CONFIG.ignore.some((pattern) => matchesIgnorePattern(filePath, pattern))) return true;
1126
+ return false;
1127
+ }
1128
+
800
1129
  const LOW_QUALITY_PREFIXES = [
801
1130
  "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
802
1131
  "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
@@ -831,11 +1160,10 @@ const savedItems = [];
831
1160
 
832
1161
  const commitMessage = git(["log", "-1", "--pretty=%B"]);
833
1162
 
834
- // The sync commit below is itself a commit, so it fires this same
835
- // post-commit hook again — git's --no-verify only skips pre-commit and
836
- // commit-msg hooks, never post-commit. Without this guard, the hook would
837
- // save its own commit message as a new decision memory, which changes the
838
- // rules-file content and triggers another sync commit, indefinitely.
1163
+ // Kept as a general escape hatch for other automation (CI bots, scripts)
1164
+ // that tags its own commits this way and shouldn't be captured as a
1165
+ // memory — cm sync no longer self-commits, so this isn't load-bearing for
1166
+ // that anymore, but other tools' commits still deserve the same skip.
839
1167
  if (commitMessage.includes("[skip ci]")) {
840
1168
  return;
841
1169
  }
@@ -859,10 +1187,40 @@ const changedFiles = git(
859
1187
 
860
1188
  let diff = "";
861
1189
  if (!isFirstCommit) {
1190
+ // --numstat marks a binary file's added/deleted columns as "-" — that's
1191
+ // the cheapest way to identify them without fetching their (useless,
1192
+ // and potentially huge) diff content at all.
1193
+ let binaryFiles = new Set();
862
1194
  try {
863
- diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
1195
+ const numstat = execFileSync("git", ["diff", "--numstat", "HEAD~1", "HEAD"], {
1196
+ encoding: "utf-8",
1197
+ });
1198
+ for (const line of numstat.split("\\n")) {
1199
+ const parts = line.split("\\t");
1200
+ if (parts[0] === "-" && parts[1] === "-" && parts[2]) {
1201
+ binaryFiles.add(parts[2]);
1202
+ }
1203
+ }
864
1204
  } catch {
865
- diff = "";
1205
+ // Nothing to compare against — leave binaryFiles empty.
1206
+ }
1207
+
1208
+ const diffableFiles = changedFiles.filter(
1209
+ (file) => !binaryFiles.has(file) && !shouldSkipDiffFile(file)
1210
+ );
1211
+
1212
+ if (diffableFiles.length > 0) {
1213
+ try {
1214
+ diff = execFileSync("git", ["diff", "HEAD~1", "HEAD", "--", ...diffableFiles], {
1215
+ encoding: "utf-8",
1216
+ });
1217
+ } catch {
1218
+ diff = "";
1219
+ }
1220
+ }
1221
+
1222
+ if (diff.length > DIFF_MAX_BYTES) {
1223
+ diff = diff.slice(0, DIFF_MAX_BYTES) + "\\n\\n... [diff truncated: exceeded 50KB after filtering]\\n";
866
1224
  }
867
1225
  }
868
1226
 
@@ -889,17 +1247,14 @@ for (const line of isFirstCommit ? [] : lines) {
889
1247
  if (/(auth|login|session|token)/i.test(currentFile)) {
890
1248
  const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
891
1249
  cmSave("discovery", content);
892
- savedItems.push(\`discovery: \${content}\`);
893
1250
  }
894
1251
  if (isConfigFile(currentFile)) {
895
1252
  const content = redactSecrets(\`new config: \${currentFile}\`);
896
1253
  cmSave("constraint", content);
897
- savedItems.push(\`constraint: \${content}\`);
898
1254
  }
899
1255
  if (isSchemaFile(currentFile)) {
900
1256
  const content = redactSecrets(\`schema change: \${currentFile}\`);
901
1257
  cmSave("discovery", content);
902
- savedItems.push(\`discovery: \${content}\`);
903
1258
  }
904
1259
  }
905
1260
  continue;
@@ -936,7 +1291,6 @@ for (const line of isFirstCommit ? [] : lines) {
936
1291
  if (removedModulePath) {
937
1292
  const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
938
1293
  cmSave("rejection", content);
939
- savedItems.push(\`rejection: \${content}\`);
940
1294
  }
941
1295
 
942
1296
  if (isPackageJson && inDependenciesSection) {
@@ -944,7 +1298,6 @@ for (const line of isFirstCommit ? [] : lines) {
944
1298
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
945
1299
  const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
946
1300
  cmSave("rejection", content);
947
- savedItems.push(\`rejection: \${content}\`);
948
1301
  }
949
1302
  }
950
1303
  continue;
@@ -960,7 +1313,6 @@ for (const line of isFirstCommit ? [] : lines) {
960
1313
  if (envMatch) {
961
1314
  const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
962
1315
  cmSave("constraint", content);
963
- savedItems.push(\`constraint: \${content}\`);
964
1316
  }
965
1317
  }
966
1318
  }
@@ -970,7 +1322,6 @@ for (const line of isFirstCommit ? [] : lines) {
970
1322
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
971
1323
  const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
972
1324
  cmSave("decision", content);
973
- savedItems.push(\`decision: \${content}\`);
974
1325
  }
975
1326
  }
976
1327
 
@@ -978,14 +1329,12 @@ for (const line of isFirstCommit ? [] : lines) {
978
1329
  const truncated = codeLine.trim().slice(0, 80);
979
1330
  const content = redactSecrets(\`database change: \${truncated}\`);
980
1331
  cmSave("discovery", content);
981
- savedItems.push(\`discovery: \${content}\`);
982
1332
  }
983
1333
 
984
1334
  if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
985
1335
  const truncated = codeLine.trim().slice(0, 100);
986
1336
  const content = redactSecrets(\`open issue from commit: \${truncated}\`);
987
1337
  cmSave("discovery", content);
988
- savedItems.push(\`discovery: \${content}\`);
989
1338
  }
990
1339
  }
991
1340
  }
@@ -1014,19 +1363,16 @@ if (breakingMatch) {
1014
1363
  const breakingContent = redactSecrets(breakingMatch[1].trim());
1015
1364
  const content = \`BREAKING CHANGE: \${breakingContent}\`;
1016
1365
  cmSave("constraint", content);
1017
- savedItems.push(\`constraint: \${content}\`);
1018
1366
  }
1019
1367
 
1020
1368
  if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
1021
1369
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1022
1370
  cmSave("decision", trimmedMessage);
1023
- savedItems.push(\`decision: \${trimmedMessage}\`);
1024
1371
  } else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
1025
1372
  // docs/style/test/ci/build/chore — skip entirely, nothing saved.
1026
1373
  } else if (isQualityCommitMessage(commitMessage)) {
1027
1374
  const trimmedMessage = redactSecrets(trimmedCommitMessage);
1028
1375
  cmSave("decision", trimmedMessage);
1029
- savedItems.push(\`decision: \${trimmedMessage}\`);
1030
1376
  }
1031
1377
 
1032
1378
  console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
@@ -1034,21 +1380,13 @@ for (const item of savedItems) {
1034
1380
  console.log(\` - \${item}\`);
1035
1381
  }
1036
1382
 
1383
+ // Updates .cursorrules/.windsurfrules on disk only — no git add/commit.
1384
+ // Editors read these files straight off disk, and they're gitignored by
1385
+ // default (see DEFAULT_GITIGNORE_CONTENT), so there's nothing to keep in
1386
+ // sync with git history; committing them here used to double the commit
1387
+ // count on every real commit and produced constant rules-file merge
1388
+ // conflicts on teams.
1037
1389
  execFileSync("node", [CLI_PATH, "sync"], { stdio: "inherit" });
1038
-
1039
- // cm sync rewrites .cursorrules/.windsurfrules, which would otherwise leave
1040
- // the working directory dirty (breaking things like \`npm version patch\`
1041
- // that require a clean tree). Commit the sync ourselves instead. --no-verify
1042
- // skips pre-commit/commit-msg hooks (not post-commit — see the [skip ci]
1043
- // guard above, which is what actually stops this from recursing).
1044
- try {
1045
- execFileSync("git", ["add", ".cursorrules", ".windsurfrules"], { stdio: "pipe" });
1046
- execFileSync("git", ["commit", "--no-verify", "-m", "chore: sync memory to rules files [skip ci]"], {
1047
- stdio: "pipe",
1048
- });
1049
- } catch {
1050
- // Nothing to commit (rules files unchanged) — fine, ignore.
1051
- }
1052
1390
  }
1053
1391
 
1054
1392
  async function main() {
@@ -1073,14 +1411,24 @@ async function main() {
1073
1411
  main().finally(() => process.exit(0));
1074
1412
  `;
1075
1413
  }
1076
- // Installs (or re-installs) the stackmem post-commit hook at `hooksDir`. If
1077
- // a post-commit hook already exists and wasn't written by stackmem, it's
1414
+ // Hooks stackmem manages. post-rewrite fires on `git commit --amend` and
1415
+ // `git rebase` (neither of which is a plain commit, so post-commit alone
1416
+ // misses them) — it runs the exact same capture script as post-commit,
1417
+ // against current HEAD, with the check-commit idempotency guard in the
1418
+ // script itself preventing a double-capture when a single operation
1419
+ // (e.g. --amend) fires both hooks for the same resulting commit.
1420
+ const MANAGED_HOOK_NAMES = ["post-commit", "post-rewrite"];
1421
+ function hookBackupFilename(hookName) {
1422
+ return `${hookName}${HOOK_BACKUP_SUFFIX}`;
1423
+ }
1424
+ // Installs (or re-installs) a stackmem-managed hook at `hooksDir`. If a
1425
+ // hook of this name already exists and wasn't written by stackmem, it's
1078
1426
  // preserved as a backup and chained to run before stackmem's own hook,
1079
1427
  // rather than being clobbered — see `uninstallHook` for how it's restored.
1080
- function installHook(hooksDir, hookLogicPath) {
1428
+ function installHook(hooksDir, hookLogicPath, hookName) {
1081
1429
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1082
- const hookPath = nodePath.join(hooksDir, "post-commit");
1083
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1430
+ const hookPath = nodePath.join(hooksDir, hookName);
1431
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1084
1432
  let chainedOriginal = false;
1085
1433
  if (nodeFs.existsSync(hookPath)) {
1086
1434
  const existingContent = nodeFs.readFileSync(hookPath, "utf-8");
@@ -1096,21 +1444,21 @@ function installHook(hooksDir, hookLogicPath) {
1096
1444
  }
1097
1445
  }
1098
1446
  const chainLine = chainedOriginal
1099
- ? `"$(dirname "$0")/${HOOK_BACKUP_FILENAME}" "$@"\n`
1447
+ ? `"$(dirname "$0")/${hookBackupFilename(hookName)}" "$@"\n`
1100
1448
  : "";
1101
1449
  nodeFs.writeFileSync(hookPath, `#!/bin/sh\n${STACKMEM_HOOK_MARKER}\n${chainLine}exec node "${hookLogicPath}" "$@"\n`, "utf-8");
1102
1450
  nodeFs.chmodSync(hookPath, 0o755);
1103
1451
  if (chainedOriginal) {
1104
- console.log("Existing post-commit hook found — chained it to run before stackmem's hook.");
1452
+ console.log(`Existing ${hookName} hook found — chained it to run before stackmem's hook.`);
1105
1453
  }
1106
1454
  }
1107
1455
  // Reverses `installHook`: if stackmem wrote the hook fresh, removes it; if
1108
1456
  // stackmem chained it after an existing hook, restores that original hook
1109
1457
  // in its place. Returns a description of what happened, or null if there
1110
1458
  // was no stackmem-managed hook to remove (nothing done).
1111
- function uninstallHook(hooksDir) {
1112
- const hookPath = nodePath.join(hooksDir, "post-commit");
1113
- const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1459
+ function uninstallHook(hooksDir, hookName) {
1460
+ const hookPath = nodePath.join(hooksDir, hookName);
1461
+ const backupPath = nodePath.join(hooksDir, hookBackupFilename(hookName));
1114
1462
  if (!nodeFs.existsSync(hookPath))
1115
1463
  return null;
1116
1464
  const content = nodeFs.readFileSync(hookPath, "utf-8");
@@ -1118,10 +1466,10 @@ function uninstallHook(hooksDir) {
1118
1466
  return null;
1119
1467
  if (nodeFs.existsSync(backupPath)) {
1120
1468
  nodeFs.renameSync(backupPath, hookPath);
1121
- return ".git/hooks/post-commit (restored original hook stackmem had chained)";
1469
+ return `.git/hooks/${hookName} (restored original hook stackmem had chained)`;
1122
1470
  }
1123
1471
  nodeFs.unlinkSync(hookPath);
1124
- return ".git/hooks/post-commit";
1472
+ return `.git/hooks/${hookName}`;
1125
1473
  }
1126
1474
  // Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
1127
1475
  // at a scratch file instead of the real ~/.claude.json.
@@ -1177,6 +1525,8 @@ dist/
1177
1525
  coverage/
1178
1526
  *-memories.json
1179
1527
  *.db
1528
+ *cursorrules
1529
+ *windsurfrules
1180
1530
  `;
1181
1531
  // Returns true if the file was created, false if it already existed
1182
1532
  // (and was left untouched).
@@ -1224,10 +1574,11 @@ async function generateRulesContent(project) {
1224
1574
  .eq("resolved", false);
1225
1575
  if (memoriesError)
1226
1576
  fail(`Failed to load memories: ${memoriesError.message}`);
1577
+ const config = readStackmemConfig();
1227
1578
  const scoredMemories = (memoryRows ?? [])
1228
1579
  .map((memory) => ({
1229
1580
  ...memory,
1230
- decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0),
1581
+ decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
1231
1582
  }))
1232
1583
  .sort((a, b) => b.decay_score - a.decay_score);
1233
1584
  const budgetedMemories = applyContextBudget(scoredMemories, CONTEXT_BUDGET_TOKENS);
@@ -1319,6 +1670,10 @@ export async function trySeedMemory(project, existingMemories, type, content, cl
1319
1670
  type,
1320
1671
  content,
1321
1672
  device_id: getDeviceId(),
1673
+ // Seeded by `cm init` scanning project files — an explicit user-run
1674
+ // command, not an auto-captured commit, so it's "manual" like cm save,
1675
+ // not the memories.source column's default of "hook".
1676
+ source: "manual",
1322
1677
  });
1323
1678
  return !error;
1324
1679
  }
@@ -1427,7 +1782,17 @@ async function verifyBackendConnection(project) {
1427
1782
  .eq("project", project);
1428
1783
  return !error;
1429
1784
  }
1430
- async function cmdInit(project, targetPath) {
1785
+ // Returns true if the file was created, false if it already existed
1786
+ // (and was left untouched).
1787
+ function writeStackmemConfigFile(resolvedPath) {
1788
+ const configPath = nodePath.join(resolvedPath, STACKMEM_CONFIG_FILENAME);
1789
+ if (nodeFs.existsSync(configPath)) {
1790
+ return false;
1791
+ }
1792
+ nodeFs.writeFileSync(configPath, defaultConfigFileContents(), "utf-8");
1793
+ return true;
1794
+ }
1795
+ async function cmdInit(project, targetPath, writeConfig = false) {
1431
1796
  const resolvedPath = nodePath.resolve(targetPath);
1432
1797
  const gitDir = nodePath.join(resolvedPath, ".git");
1433
1798
  if (!nodeFs.existsSync(gitDir)) {
@@ -1441,7 +1806,9 @@ async function cmdInit(project, targetPath) {
1441
1806
  nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
1442
1807
  const hooksDir = nodePath.join(gitDir, "hooks");
1443
1808
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1444
- installHook(hooksDir, hookLogicPath);
1809
+ for (const hookName of MANAGED_HOOK_NAMES) {
1810
+ installHook(hooksDir, hookLogicPath, hookName);
1811
+ }
1445
1812
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
1446
1813
  registerMcpServer(DIST_INDEX_PATH);
1447
1814
  writeClaudeMd(project, resolvedPath);
@@ -1451,10 +1818,14 @@ async function cmdInit(project, targetPath) {
1451
1818
  const cursorRulesWritten = writeCursorRules(resolvedPath, rulesContent);
1452
1819
  const windsurfRulesWritten = writeWindsurfRules(resolvedPath, rulesContent);
1453
1820
  const backendOk = await verifyBackendConnection(project);
1821
+ const configFileWritten = writeConfig ? writeStackmemConfigFile(resolvedPath) : false;
1454
1822
  console.log("");
1455
1823
  if (gitignoreCreated) {
1456
1824
  console.log("✓ .gitignore created");
1457
1825
  }
1826
+ if (writeConfig) {
1827
+ console.log(`✓ .stackmem.json ${configFileWritten ? "written" : "already exists"}`);
1828
+ }
1458
1829
  console.log("✓ Git hook installed");
1459
1830
  console.log("✓ MCP server registered");
1460
1831
  console.log("✓ CLAUDE.md written");
@@ -1565,9 +1936,11 @@ async function cmdContext(project, task) {
1565
1936
  console.log(`No memories found for "${project}".`);
1566
1937
  return;
1567
1938
  }
1939
+ const config = readStackmemConfig();
1940
+ const tokenBudget = config.tokenBudget;
1568
1941
  const scoredMemories = memories
1569
1942
  .map((memory) => {
1570
- const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0);
1943
+ const decayScore = computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda);
1571
1944
  const relevanceScore = scoreMemoryRelevance(memory.content, task);
1572
1945
  return {
1573
1946
  ...memory,
@@ -1576,7 +1949,7 @@ async function cmdContext(project, task) {
1576
1949
  })
1577
1950
  .sort((a, b) => b.combined_score - a.combined_score);
1578
1951
  const topMemories = scoredMemories.slice(0, CONTEXT_TOP_N);
1579
- const budgetedMemories = applyContextBudget(topMemories, CONTEXT_BUDGET_TOKENS);
1952
+ const budgetedMemories = applyContextBudget(topMemories, tokenBudget);
1580
1953
  const tokensUsed = budgetedMemories.reduce((sum, memory) => sum + estimateTokens(memory.content), 0);
1581
1954
  const lines = [`## Project Context: ${project}`, `### Task: ${task}`, ""];
1582
1955
  for (const type of CONTEXT_TYPE_ORDER) {
@@ -1589,9 +1962,62 @@ async function cmdContext(project, task) {
1589
1962
  }
1590
1963
  lines.push("");
1591
1964
  }
1592
- lines.push(`*(${tokensUsed}/${CONTEXT_BUDGET_TOKENS} tokens used)*`);
1965
+ lines.push(`*(${tokensUsed}/${tokenBudget} tokens used)*`);
1593
1966
  console.log(lines.join("\n"));
1594
1967
  }
1968
+ // --- cm why --------------------------------------------------------------
1969
+ function fileMatchesPaths(filePaths, file, baseName) {
1970
+ if (!Array.isArray(filePaths))
1971
+ return false;
1972
+ return filePaths.some((p) => typeof p === "string" && (p === file || p.split("/").pop() === baseName));
1973
+ }
1974
+ function fileMentionedInContent(content, file, baseName) {
1975
+ const lower = content.toLowerCase();
1976
+ return lower.includes(file.toLowerCase()) || lower.includes(baseName.toLowerCase());
1977
+ }
1978
+ async function cmdWhy(project, file) {
1979
+ const baseName = file.split("/").pop() ?? file;
1980
+ const { data, error } = await supabase
1981
+ .from("memories")
1982
+ .select("*")
1983
+ .eq("project", project)
1984
+ .eq("resolved", false);
1985
+ if (error)
1986
+ fail(`Failed to load memories: ${error.message}`);
1987
+ const memories = data ?? [];
1988
+ // file_paths (structured, from Item 6) and a content substring search
1989
+ // are combined into one match set rather than one gating the other as
1990
+ // a strict fallback — most memories predate file_paths existing at all
1991
+ // (manual saves and seeds never set it), so content search is the
1992
+ // primary signal for everything except recent hook-captured commits,
1993
+ // not a rare last resort.
1994
+ const matched = memories.filter((memory) => fileMatchesPaths(memory.file_paths, file, baseName) ||
1995
+ fileMentionedInContent(memory.content, file, baseName));
1996
+ if (matched.length === 0) {
1997
+ console.log(`No memories found for ${file}.`);
1998
+ console.log("Make some commits touching this file to build up context.");
1999
+ return;
2000
+ }
2001
+ const config = readStackmemConfig();
2002
+ const scoredMemories = matched
2003
+ .map((memory) => ({
2004
+ ...memory,
2005
+ decay_score: computeDecayScore(new Date(memory.created_at), memory.access_count ?? 0, memory.pinned ?? false, config.decayLambda),
2006
+ }))
2007
+ .sort((a, b) => b.decay_score - a.decay_score);
2008
+ console.log(`Decisions and constraints tied to ${file}:\n`);
2009
+ for (const type of MEMORY_TYPES) {
2010
+ const group = scoredMemories.filter((memory) => memory.type === type);
2011
+ if (group.length === 0)
2012
+ continue;
2013
+ console.log(`${TYPE_LABELS[type]} (${group.length})`);
2014
+ console.log("-".repeat(TYPE_LABELS[type].length + 4));
2015
+ for (const memory of group) {
2016
+ console.log(` [${memory.id}] (score: ${memory.decay_score.toFixed(2)}) ${memory.content}`);
2017
+ }
2018
+ console.log("");
2019
+ }
2020
+ }
1595
2021
  function splitCommaList(value) {
1596
2022
  if (!value)
1597
2023
  return [];
@@ -1828,7 +2254,9 @@ async function cmdDoctor(fix) {
1828
2254
  nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
1829
2255
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
1830
2256
  nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
1831
- installHook(hooksDir, hookLogicPath);
2257
+ for (const name of MANAGED_HOOK_NAMES) {
2258
+ installHook(hooksDir, hookLogicPath, name);
2259
+ }
1832
2260
  if (isHookExecutable(hookPath)) {
1833
2261
  console.log("Fixed: git hook");
1834
2262
  issues--;
@@ -1998,9 +2426,11 @@ function removeIfMarked(filePath, marker, label) {
1998
2426
  async function cmdUninstall(project, targetPath) {
1999
2427
  const gitDir = nodePath.join(targetPath, ".git");
2000
2428
  const hooksDir = nodePath.join(gitDir, "hooks");
2001
- const hookResult = uninstallHook(hooksDir);
2002
- if (hookResult)
2003
- console.log(`Removed ${hookResult}`);
2429
+ for (const hookName of MANAGED_HOOK_NAMES) {
2430
+ const hookResult = uninstallHook(hooksDir, hookName);
2431
+ if (hookResult)
2432
+ console.log(`Removed ${hookResult}`);
2433
+ }
2004
2434
  const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
2005
2435
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
2006
2436
  if (nodeFs.existsSync(hookLogicPath)) {
@@ -2044,9 +2474,19 @@ async function main() {
2044
2474
  case "save": {
2045
2475
  const forceIndex = args.indexOf("--force");
2046
2476
  const force = forceIndex !== -1;
2047
- const positional = force
2477
+ let positional = force
2048
2478
  ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
2049
2479
  : args;
2480
+ const commitShaFlag = extractValueFlag(positional, "--commit-sha");
2481
+ positional = commitShaFlag.rest;
2482
+ const authorFlag = extractValueFlag(positional, "--author");
2483
+ positional = authorFlag.rest;
2484
+ const sourceFlag = extractValueFlag(positional, "--source");
2485
+ positional = sourceFlag.rest;
2486
+ const confidenceFlag = extractValueFlag(positional, "--confidence");
2487
+ positional = confidenceFlag.rest;
2488
+ const filesFlag = extractValueFlag(positional, "--files");
2489
+ positional = filesFlag.rest;
2050
2490
  let explicitProject;
2051
2491
  let type;
2052
2492
  let rest;
@@ -2071,7 +2511,30 @@ async function main() {
2071
2511
  if (!type || rest.length === 0)
2072
2512
  usage();
2073
2513
  const project = requireProject(explicitProject);
2074
- await cmdSave(project, type, rest.join(" "), force);
2514
+ const provenance = {};
2515
+ if (commitShaFlag.value)
2516
+ provenance.commitSha = commitShaFlag.value;
2517
+ if (authorFlag.value)
2518
+ provenance.author = authorFlag.value;
2519
+ if (sourceFlag.value === "hook" || sourceFlag.value === "manual" || sourceFlag.value === "mcp") {
2520
+ provenance.source = sourceFlag.value;
2521
+ }
2522
+ if (confidenceFlag.value) {
2523
+ const parsed = Number(confidenceFlag.value);
2524
+ if (!Number.isNaN(parsed))
2525
+ provenance.confidence = parsed;
2526
+ }
2527
+ if (filesFlag.value) {
2528
+ provenance.filePaths = filesFlag.value.split(",").map((f) => f.trim()).filter(Boolean);
2529
+ }
2530
+ await cmdSave(project, type, rest.join(" "), force, provenance);
2531
+ break;
2532
+ }
2533
+ case "check-commit": {
2534
+ const [project, commitSha] = args;
2535
+ if (!project || !commitSha)
2536
+ usage();
2537
+ await cmdCheckCommit(project, commitSha);
2075
2538
  break;
2076
2539
  }
2077
2540
  case "fix": {
@@ -2109,6 +2572,24 @@ async function main() {
2109
2572
  await cmdResolve(project, memoryId);
2110
2573
  break;
2111
2574
  }
2575
+ case "pin": {
2576
+ const split = splitOptionalProject(args, 1);
2577
+ if (!split)
2578
+ usage();
2579
+ const project = requireProject(split.project);
2580
+ const [memoryId] = split.rest;
2581
+ await cmdPin(project, memoryId);
2582
+ break;
2583
+ }
2584
+ case "unpin": {
2585
+ const split = splitOptionalProject(args, 1);
2586
+ if (!split)
2587
+ usage();
2588
+ const project = requireProject(split.project);
2589
+ const [memoryId] = split.rest;
2590
+ await cmdUnpin(project, memoryId);
2591
+ break;
2592
+ }
2112
2593
  case "delete": {
2113
2594
  const split = splitOptionalProject(args, 1);
2114
2595
  if (!split)
@@ -2136,10 +2617,15 @@ async function main() {
2136
2617
  break;
2137
2618
  }
2138
2619
  case "init": {
2139
- const [project, targetPath] = args;
2620
+ const configIndex = args.indexOf("--config");
2621
+ const writeConfig = configIndex !== -1;
2622
+ const positional = writeConfig
2623
+ ? [...args.slice(0, configIndex), ...args.slice(configIndex + 1)]
2624
+ : args;
2625
+ const [project, targetPath] = positional;
2140
2626
  if (!project || !targetPath)
2141
2627
  usage();
2142
- await cmdInit(project, targetPath);
2628
+ await cmdInit(project, targetPath, writeConfig);
2143
2629
  break;
2144
2630
  }
2145
2631
  case "link": {
@@ -2163,6 +2649,14 @@ async function main() {
2163
2649
  await cmdContext(project, split.rest.join(" "));
2164
2650
  break;
2165
2651
  }
2652
+ case "why": {
2653
+ const split = splitProjectFromVariadic(args);
2654
+ const project = requireProject(split.project);
2655
+ if (split.rest.length === 0)
2656
+ usage();
2657
+ await cmdWhy(project, split.rest.join(" "));
2658
+ break;
2659
+ }
2166
2660
  case "session-end": {
2167
2661
  await cmdSessionEndEntry(args);
2168
2662
  break;