stackmem 1.0.3 → 1.0.5

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.
Files changed (4) hide show
  1. package/README.md +7 -1
  2. package/dist/cli.js +274 -58
  3. package/package.json +1 -1
  4. package/src/cli.ts +294 -58
package/README.md CHANGED
@@ -16,7 +16,13 @@ Then in any git repo:
16
16
  cm init <project> .
17
17
  ```
18
18
 
19
- That's it. No config, no accounts, no env file.
19
+ `cm init <project> .` does three things automatically:
20
+
21
+ 1. Installs a git post-commit hook
22
+ 2. Registers the stackmem MCP server with Claude Code
23
+ 3. Writes a CLAUDE.md to the project
24
+
25
+ After that, memories load automatically when you open Claude Code and save automatically on every commit. No further setup needed.
20
26
 
21
27
  ## Usage
22
28
 
package/dist/cli.js CHANGED
@@ -25,63 +25,68 @@ const TYPE_LABELS = {
25
25
  };
26
26
  function usage() {
27
27
  console.error(`Usage:
28
- cm start <project>
29
- cm save <project> <type> <content> [--force] (type: decision|rejection|constraint|discovery)
30
- cm fix <project> <problem> <solution>
31
- cm analyze <project> <path>
32
- cm search <project> <query>
33
- cm resolve <project> <memory-id>
34
- cm delete <project> <memory-id>
35
- cm compress <project>
28
+ cm start [project]
29
+ cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
30
+ cm fix [project] <problem> <solution>
31
+ cm analyze [project] <path>
32
+ cm search [project] <query>
33
+ cm resolve [project] <memory-id>
34
+ cm delete [project] <memory-id>
35
+ cm compress [project]
36
36
  cm init <project> <path>
37
- cm context <project> <task>
38
- cm help`);
37
+ cm context [project] <task>
38
+ cm help
39
+
40
+ [project] is optional if a .stackmem file exists in the current
41
+ directory (written by 'cm init'). Otherwise it must be given explicitly.`);
39
42
  process.exit(1);
40
43
  }
41
44
  function cmdHelp() {
42
45
  console.log(`cm — coding memory CLI
43
46
 
44
- cm start <project>
47
+ [project] is optional everywhere below if a .stackmem file exists in
48
+ the current directory (written by 'cm init'). Otherwise pass it explicitly.
49
+
50
+ cm start [project]
45
51
  Load all unresolved memories for a project, sorted by decay
46
52
  score and capped at a 2000 token context budget, grouped by
47
53
  type (decisions, constraints, discoveries, rejections), with
48
54
  linked memory ids shown under each entry.
49
55
 
50
- cm save <project> <type> <content> [--force]
56
+ cm save [project] <type> <content> [--force]
51
57
  Save a new memory. <type> is one of: decision, rejection,
52
58
  constraint, discovery. Automatically links to related existing
53
59
  memories. If the new memory contradicts an existing one, prompts
54
60
  to keep the old memory or replace it — pass --force to skip the
55
61
  prompt and always replace.
56
62
 
57
- cm fix <project> <problem> <solution>
63
+ cm fix [project] <problem> <solution>
58
64
  Record a problem and its solution for future reference.
59
65
 
60
- cm analyze <project> <path>
66
+ cm analyze [project] <path>
61
67
  Build an AST index (files, functions, classes) for the codebase
62
68
  at <path>.
63
69
 
64
- cm search <project> <query>
70
+ cm search [project] <query>
65
71
  Search saved memories and past fixes for a project matching
66
72
  <query>.
67
73
 
68
- cm resolve <project> <memory-id>
74
+ cm resolve [project] <memory-id>
69
75
  Mark a memory as resolved.
70
76
 
71
- cm delete <project> <memory-id>
77
+ cm delete [project] <memory-id>
72
78
  Delete a memory and any memory_links referencing it.
73
79
 
74
- cm compress <project>
80
+ cm compress [project]
75
81
  Cluster related unresolved memories (needs 20+) and collapse
76
82
  clusters of 3 or more into a single summary memory.
77
83
 
78
84
  cm init <project> <path>
79
- Install a git post-commit hook in the repo at <path> that
80
- records the commit message and any implicit memories (removed
81
- imports, new env vars, new auth-related files) after every
82
- commit.
85
+ Install a git post-commit hook in the repo at <path>, register
86
+ the stackmem MCP server with Claude Code, write a CLAUDE.md and
87
+ .stackmem file, and seed initial memories from the project scan.
83
88
 
84
- cm context <project> <task>
89
+ cm context [project] <task>
85
90
  Return only the top 5 memories most relevant to a specific
86
91
  task, ranked by a blend of task relevance and decay score,
87
92
  as a markdown block capped at a 2000 token budget.
@@ -93,6 +98,45 @@ function fail(message) {
93
98
  console.error(`Error: ${message}`);
94
99
  process.exit(1);
95
100
  }
101
+ // --- optional project resolution ------------------------------------------
102
+ const STACKMEM_FILE = ".stackmem";
103
+ function readProjectFromFile() {
104
+ const stackmemPath = nodePath.join(process.cwd(), STACKMEM_FILE);
105
+ if (!nodeFs.existsSync(stackmemPath))
106
+ return null;
107
+ const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
108
+ return content.length > 0 ? content : null;
109
+ }
110
+ function requireProject(explicit) {
111
+ if (explicit)
112
+ return explicit;
113
+ const fromFile = readProjectFromFile();
114
+ if (fromFile)
115
+ return fromFile;
116
+ console.error("No project specified and no .stackmem file found. Run 'cm init <project> .' first or pass a project name.");
117
+ process.exit(1);
118
+ }
119
+ // For commands whose non-project args have a fixed count: if exactly that
120
+ // many args are given, project was omitted; if one more, the first is the
121
+ // project. Anything else is a usage error.
122
+ function splitOptionalProject(args, fixedArgCount) {
123
+ if (args.length === fixedArgCount)
124
+ return { project: null, rest: args };
125
+ if (args.length === fixedArgCount + 1)
126
+ return { project: args[0], rest: args.slice(1) };
127
+ return null;
128
+ }
129
+ // For commands whose remaining arg is free-form text (a query or task),
130
+ // arg count can't disambiguate an omitted project from a multi-word query —
131
+ // "cm search bug fix" is ambiguous by count alone. Use .stackmem's presence
132
+ // instead: if it exists, nothing is positionally a project.
133
+ function splitProjectFromVariadic(args) {
134
+ if (readProjectFromFile() !== null) {
135
+ return { project: null, rest: args };
136
+ }
137
+ const [project = null, ...rest] = args;
138
+ return { project, rest };
139
+ }
96
140
  // --- cm start ----------------------------------------------------------
97
141
  async function cmdStart(project) {
98
142
  const { data, error } = await supabase
@@ -389,11 +433,20 @@ async function cmdDelete(project, memoryId) {
389
433
  // .git/hooks/post-commit) so its module type is unambiguous — an
390
434
  // extensionless file run by git would otherwise have its CommonJS/ESM
391
435
  // interpretation depend on the target repo's own package.json.
392
- function buildHookLogicScript(project, cliPath) {
436
+ function buildHookLogicScript(cliPath) {
393
437
  return `#!/usr/bin/env node
394
438
  import { execFileSync } from "node:child_process";
439
+ import { readFileSync } from "node:fs";
440
+ import { join } from "node:path";
441
+
442
+ const stackmemPath = join(process.cwd(), ".stackmem");
443
+ let PROJECT;
444
+ try {
445
+ PROJECT = readFileSync(stackmemPath, "utf8").trim();
446
+ } catch {
447
+ process.exit(0); // no .stackmem, skip silently
448
+ }
395
449
 
396
- const PROJECT = ${JSON.stringify(project)};
397
450
  const CLI_PATH = ${JSON.stringify(cliPath)};
398
451
 
399
452
  function git(args) {
@@ -430,21 +483,60 @@ function isSchemaFile(filePath) {
430
483
  );
431
484
  }
432
485
 
486
+ const LOW_QUALITY_PREFIXES = [
487
+ "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
488
+ "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
489
+ ];
490
+
491
+ // Filters out lazy/placeholder commit messages ("wip", "fix", "m", "...")
492
+ // so only messages worth remembering become decisions. Checked against the
493
+ // first word, not a raw prefix match, so a real message like "Migrate auth
494
+ // to JWT" isn't rejected just for starting with the letter "m".
495
+ function isQualityCommitMessage(message) {
496
+ const trimmed = message.trim();
497
+ if (trimmed.length <= 20) return false;
498
+ if (!trimmed.includes(" ")) return false;
499
+
500
+ const stripped = trimmed.replace(/\\s/g, "");
501
+ if (stripped.length > 0 && new Set(stripped).size === 1) return false;
502
+
503
+ const lowerTrimmed = trimmed.toLowerCase();
504
+ if (LOW_QUALITY_PREFIXES.includes(lowerTrimmed)) return false;
505
+
506
+ const firstWord = (lowerTrimmed.match(/^[a-z0-9]+/) || [""])[0];
507
+ if (LOW_QUALITY_PREFIXES.includes(firstWord)) return false;
508
+
509
+ return true;
510
+ }
511
+
433
512
  const savedItems = [];
434
513
 
435
514
  const commitMessage = git(["log", "-1", "--pretty=%B"]);
436
- cmSave("decision", commitMessage);
437
- savedItems.push(\`decision: \${commitMessage}\`);
438
515
 
439
- const changedFiles = git(["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"])
516
+ const isFirstCommit = (() => {
517
+ try {
518
+ execFileSync("git", ["rev-parse", "HEAD~1"], { stdio: "pipe" });
519
+ return false;
520
+ } catch {
521
+ return true;
522
+ }
523
+ })();
524
+
525
+ const changedFiles = git(
526
+ isFirstCommit
527
+ ? ["diff-tree", "--root", "--no-commit-id", "-r", "--name-only", "HEAD"]
528
+ : ["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"]
529
+ )
440
530
  .split("\\n")
441
531
  .filter(Boolean);
442
532
 
443
533
  let diff = "";
444
- try {
445
- diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
446
- } catch {
447
- diff = "";
534
+ if (!isFirstCommit) {
535
+ try {
536
+ diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
537
+ } catch {
538
+ diff = "";
539
+ }
448
540
  }
449
541
 
450
542
  const lines = diff.split("\\n");
@@ -452,7 +544,7 @@ let currentFile = null;
452
544
  let isNewFile = false;
453
545
  let inDependenciesSection = false;
454
546
 
455
- for (const line of lines) {
547
+ for (const line of isFirstCommit ? [] : lines) {
456
548
  if (line.startsWith("diff --git")) {
457
549
  isNewFile = false;
458
550
  inDependenciesSection = false;
@@ -502,10 +594,20 @@ for (const line of lines) {
502
594
  if (line.startsWith("-") && !line.startsWith("---")) {
503
595
  const codeLine = line.slice(1).trim();
504
596
 
505
- if (codeLine.startsWith("import") || codeLine.startsWith("require")) {
506
- const pathMatch = codeLine.match(/['"]([^'"]+)['"]/);
507
- const importPath = pathMatch ? pathMatch[1] : codeLine;
508
- const content = \`removed dependency: \${importPath}\`;
597
+ const importMatch = line.match(
598
+ /^-\\s*(import\\s+.*\\s+from\\s+['"]([^'"]+)['"]|import\\s+['"]([^'"]+)['"])/
599
+ );
600
+ const requireMatch = line.match(
601
+ /^-\\s*(?:const|let|var)\\s+\\S+\\s*=\\s*require\\(['"]([^'"]+)['"]\\)/
602
+ );
603
+ const removedModulePath = importMatch
604
+ ? importMatch[2] || importMatch[3]
605
+ : requireMatch
606
+ ? requireMatch[1]
607
+ : null;
608
+
609
+ if (removedModulePath) {
610
+ const content = \`removed dependency: \${removedModulePath}\`;
509
611
  cmSave("rejection", content);
510
612
  savedItems.push(\`rejection: \${content}\`);
511
613
  }
@@ -561,6 +663,14 @@ for (const line of lines) {
561
663
  }
562
664
  }
563
665
 
666
+ // Saved last, after every diff-detected memory, and only if it clears the
667
+ // quality filter — diff analysis always runs regardless of commit message.
668
+ if (isQualityCommitMessage(commitMessage)) {
669
+ const trimmedMessage = commitMessage.trim();
670
+ cmSave("decision", trimmedMessage);
671
+ savedItems.push(\`decision: \${trimmedMessage}\`);
672
+ }
673
+
564
674
  console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
565
675
  for (const item of savedItems) {
566
676
  console.log(\` - \${item}\`);
@@ -611,6 +721,69 @@ cm save ${project} <type> "<content>"
611
721
  nodeFs.writeFileSync(claudeMdPath, content, "utf-8");
612
722
  console.log("CLAUDE.md written.");
613
723
  }
724
+ function writeProjectFile(project, resolvedPath) {
725
+ const stackmemPath = nodePath.join(resolvedPath, STACKMEM_FILE);
726
+ nodeFs.writeFileSync(stackmemPath, `${project}\n`, "utf-8");
727
+ }
728
+ // Scans the freshly-initialized project for a few cheap, high-signal facts
729
+ // and saves them as memories directly (bypassing cmdSave's duplicate/conflict
730
+ // prompt — there's nothing to conflict with yet, and init must stay
731
+ // non-interactive). Returns how many memories were actually saved.
732
+ async function seedMemories(project, resolvedPath) {
733
+ let seededCount = 0;
734
+ const packageJsonPath = nodePath.join(resolvedPath, "package.json");
735
+ if (nodeFs.existsSync(packageJsonPath)) {
736
+ try {
737
+ const pkg = JSON.parse(nodeFs.readFileSync(packageJsonPath, "utf-8"));
738
+ const depNames = [
739
+ ...Object.keys(pkg.dependencies ?? {}),
740
+ ...Object.keys(pkg.devDependencies ?? {}),
741
+ ];
742
+ if (depNames.length > 0) {
743
+ const { error } = await supabase.from("memories").insert({
744
+ project,
745
+ type: "discovery",
746
+ content: `tech stack: ${depNames.join(", ")}`,
747
+ device_id: getDeviceId(),
748
+ });
749
+ if (!error)
750
+ seededCount++;
751
+ }
752
+ }
753
+ catch {
754
+ // Malformed package.json — skip the tech-stack memory.
755
+ }
756
+ }
757
+ const envExamplePath = nodePath.join(resolvedPath, ".env.example");
758
+ if (nodeFs.existsSync(envExamplePath)) {
759
+ const varNames = nodeFs
760
+ .readFileSync(envExamplePath, "utf-8")
761
+ .split("\n")
762
+ .filter((line) => /^[A-Z_]+=/.test(line))
763
+ .map((line) => line.split("=")[0]);
764
+ if (varNames.length > 0) {
765
+ const { error } = await supabase.from("memories").insert({
766
+ project,
767
+ type: "constraint",
768
+ content: `required env vars: ${varNames.join(", ")}`,
769
+ device_id: getDeviceId(),
770
+ });
771
+ if (!error)
772
+ seededCount++;
773
+ }
774
+ }
775
+ analyzeCodebase(resolvedPath);
776
+ return seededCount;
777
+ }
778
+ // Smoke-tests the same anon-key + device-header path session_start/cmdStart
779
+ // use, without pulling in all of cmdStart's scoring/formatting logic.
780
+ async function verifyBackendConnection(project) {
781
+ const { error } = await supabase
782
+ .from("memories")
783
+ .select("id", { count: "exact", head: true })
784
+ .eq("project", project);
785
+ return !error;
786
+ }
614
787
  async function cmdInit(project, targetPath) {
615
788
  const resolvedPath = nodePath.resolve(targetPath);
616
789
  const gitDir = nodePath.join(resolvedPath, ".git");
@@ -621,7 +794,7 @@ async function cmdInit(project, targetPath) {
621
794
  const codingMemoryDir = nodePath.join(resolvedPath, ".coding-memory");
622
795
  nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
623
796
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
624
- nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(project, DIST_CLI_PATH), "utf-8");
797
+ nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
625
798
  const hooksDir = nodePath.join(gitDir, "hooks");
626
799
  nodeFs.mkdirSync(hooksDir, { recursive: true });
627
800
  const hookPath = nodePath.join(hooksDir, "post-commit");
@@ -630,6 +803,26 @@ async function cmdInit(project, targetPath) {
630
803
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
631
804
  registerMcpServer(DIST_INDEX_PATH);
632
805
  writeClaudeMd(project, resolvedPath);
806
+ const seededCount = await seedMemories(project, resolvedPath);
807
+ console.log(`Seeded ${seededCount} initial memories from project scan.`);
808
+ const backendOk = await verifyBackendConnection(project);
809
+ console.log("");
810
+ console.log("✓ Git hook installed");
811
+ console.log("✓ MCP server registered");
812
+ console.log("✓ CLAUDE.md written");
813
+ console.log(`✓ Seeded ${seededCount} memories from project scan`);
814
+ if (backendOk) {
815
+ console.log("✓ Backend connection verified");
816
+ console.log("");
817
+ console.log(`stackmem is live. Open Claude Code and ask "what do you know about this project?" to verify.`);
818
+ }
819
+ else {
820
+ console.log("✗ Backend connection failed — run 'cm doctor' (once we build it)");
821
+ }
822
+ // Written last and deliberately: if a commit (and hence the post-commit
823
+ // hook) fires anywhere during init, it must still see whatever project
824
+ // .stackmem pointed to before this run, not the one being initialized now.
825
+ writeProjectFile(project, resolvedPath);
633
826
  }
634
827
  // --- cm context ------------------------------------------------------------
635
828
  const CONTEXT_TYPE_ORDER = ["decision", "constraint", "rejection", "discovery"];
@@ -679,9 +872,10 @@ async function main() {
679
872
  const [, , command, ...args] = process.argv;
680
873
  switch (command) {
681
874
  case "start": {
682
- const [project] = args;
683
- if (!project)
875
+ const split = splitOptionalProject(args, 0);
876
+ if (!split)
684
877
  usage();
878
+ const project = requireProject(split.project);
685
879
  await cmdStart(project);
686
880
  break;
687
881
  }
@@ -691,51 +885,72 @@ async function main() {
691
885
  const positional = force
692
886
  ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
693
887
  : args;
694
- const [project, type, ...rest] = positional;
695
- if (!project || !type || rest.length === 0)
888
+ let explicitProject;
889
+ let type;
890
+ let rest;
891
+ if (MEMORY_TYPES.includes(positional[0])) {
892
+ explicitProject = null;
893
+ [type, ...rest] = positional;
894
+ }
895
+ else {
896
+ explicitProject = positional[0] ?? null;
897
+ [, type, ...rest] = positional;
898
+ }
899
+ if (!type || rest.length === 0)
696
900
  usage();
901
+ const project = requireProject(explicitProject);
697
902
  await cmdSave(project, type, rest.join(" "), force);
698
903
  break;
699
904
  }
700
905
  case "fix": {
701
- const [project, problem, solution] = args;
702
- if (!project || !problem || !solution)
906
+ const split = splitOptionalProject(args, 2);
907
+ if (!split)
703
908
  usage();
909
+ const project = requireProject(split.project);
910
+ const [problem, solution] = split.rest;
704
911
  await cmdFix(project, problem, solution);
705
912
  break;
706
913
  }
707
914
  case "analyze": {
708
- const [project, path] = args;
709
- if (!project || !path)
915
+ const split = splitOptionalProject(args, 1);
916
+ if (!split)
710
917
  usage();
918
+ const project = requireProject(split.project);
919
+ const [path] = split.rest;
711
920
  await cmdAnalyze(project, path);
712
921
  break;
713
922
  }
714
923
  case "search": {
715
- const [project, ...rest] = args;
716
- if (!project || rest.length === 0)
924
+ const split = splitProjectFromVariadic(args);
925
+ const project = requireProject(split.project);
926
+ if (split.rest.length === 0)
717
927
  usage();
718
- await cmdSearch(project, rest.join(" "));
928
+ await cmdSearch(project, split.rest.join(" "));
719
929
  break;
720
930
  }
721
931
  case "resolve": {
722
- const [project, memoryId] = args;
723
- if (!project || !memoryId)
932
+ const split = splitOptionalProject(args, 1);
933
+ if (!split)
724
934
  usage();
935
+ const project = requireProject(split.project);
936
+ const [memoryId] = split.rest;
725
937
  await cmdResolve(project, memoryId);
726
938
  break;
727
939
  }
728
940
  case "delete": {
729
- const [project, memoryId] = args;
730
- if (!project || !memoryId)
941
+ const split = splitOptionalProject(args, 1);
942
+ if (!split)
731
943
  usage();
944
+ const project = requireProject(split.project);
945
+ const [memoryId] = split.rest;
732
946
  await cmdDelete(project, memoryId);
733
947
  break;
734
948
  }
735
949
  case "compress": {
736
- const [project] = args;
737
- if (!project)
950
+ const split = splitOptionalProject(args, 0);
951
+ if (!split)
738
952
  usage();
953
+ const project = requireProject(split.project);
739
954
  await cmdCompress(project);
740
955
  break;
741
956
  }
@@ -747,10 +962,11 @@ async function main() {
747
962
  break;
748
963
  }
749
964
  case "context": {
750
- const [project, ...rest] = args;
751
- if (!project || rest.length === 0)
965
+ const split = splitProjectFromVariadic(args);
966
+ const project = requireProject(split.project);
967
+ if (split.rest.length === 0)
752
968
  usage();
753
- await cmdContext(project, rest.join(" "));
969
+ await cmdContext(project, split.rest.join(" "));
754
970
  break;
755
971
  }
756
972
  case "help": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackmem",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -39,64 +39,69 @@ const TYPE_LABELS: Record<MemoryType, string> = {
39
39
 
40
40
  function usage(): never {
41
41
  console.error(`Usage:
42
- cm start <project>
43
- cm save <project> <type> <content> [--force] (type: decision|rejection|constraint|discovery)
44
- cm fix <project> <problem> <solution>
45
- cm analyze <project> <path>
46
- cm search <project> <query>
47
- cm resolve <project> <memory-id>
48
- cm delete <project> <memory-id>
49
- cm compress <project>
42
+ cm start [project]
43
+ cm save [project] <type> <content> [--force] (type: decision|rejection|constraint|discovery)
44
+ cm fix [project] <problem> <solution>
45
+ cm analyze [project] <path>
46
+ cm search [project] <query>
47
+ cm resolve [project] <memory-id>
48
+ cm delete [project] <memory-id>
49
+ cm compress [project]
50
50
  cm init <project> <path>
51
- cm context <project> <task>
52
- cm help`);
51
+ cm context [project] <task>
52
+ cm help
53
+
54
+ [project] is optional if a .stackmem file exists in the current
55
+ directory (written by 'cm init'). Otherwise it must be given explicitly.`);
53
56
  process.exit(1);
54
57
  }
55
58
 
56
59
  function cmdHelp() {
57
60
  console.log(`cm — coding memory CLI
58
61
 
59
- cm start <project>
62
+ [project] is optional everywhere below if a .stackmem file exists in
63
+ the current directory (written by 'cm init'). Otherwise pass it explicitly.
64
+
65
+ cm start [project]
60
66
  Load all unresolved memories for a project, sorted by decay
61
67
  score and capped at a 2000 token context budget, grouped by
62
68
  type (decisions, constraints, discoveries, rejections), with
63
69
  linked memory ids shown under each entry.
64
70
 
65
- cm save <project> <type> <content> [--force]
71
+ cm save [project] <type> <content> [--force]
66
72
  Save a new memory. <type> is one of: decision, rejection,
67
73
  constraint, discovery. Automatically links to related existing
68
74
  memories. If the new memory contradicts an existing one, prompts
69
75
  to keep the old memory or replace it — pass --force to skip the
70
76
  prompt and always replace.
71
77
 
72
- cm fix <project> <problem> <solution>
78
+ cm fix [project] <problem> <solution>
73
79
  Record a problem and its solution for future reference.
74
80
 
75
- cm analyze <project> <path>
81
+ cm analyze [project] <path>
76
82
  Build an AST index (files, functions, classes) for the codebase
77
83
  at <path>.
78
84
 
79
- cm search <project> <query>
85
+ cm search [project] <query>
80
86
  Search saved memories and past fixes for a project matching
81
87
  <query>.
82
88
 
83
- cm resolve <project> <memory-id>
89
+ cm resolve [project] <memory-id>
84
90
  Mark a memory as resolved.
85
91
 
86
- cm delete <project> <memory-id>
92
+ cm delete [project] <memory-id>
87
93
  Delete a memory and any memory_links referencing it.
88
94
 
89
- cm compress <project>
95
+ cm compress [project]
90
96
  Cluster related unresolved memories (needs 20+) and collapse
91
97
  clusters of 3 or more into a single summary memory.
92
98
 
93
99
  cm init <project> <path>
94
- Install a git post-commit hook in the repo at <path> that
95
- records the commit message and any implicit memories (removed
96
- imports, new env vars, new auth-related files) after every
97
- commit.
100
+ Install a git post-commit hook in the repo at <path>, register
101
+ the stackmem MCP server with Claude Code, write a CLAUDE.md and
102
+ .stackmem file, and seed initial memories from the project scan.
98
103
 
99
- cm context <project> <task>
104
+ cm context [project] <task>
100
105
  Return only the top 5 memories most relevant to a specific
101
106
  task, ranked by a blend of task relevance and decay score,
102
107
  as a markdown block capped at a 2000 token budget.
@@ -110,6 +115,53 @@ function fail(message: string): never {
110
115
  process.exit(1);
111
116
  }
112
117
 
118
+ // --- optional project resolution ------------------------------------------
119
+
120
+ const STACKMEM_FILE = ".stackmem";
121
+
122
+ function readProjectFromFile(): string | null {
123
+ const stackmemPath = nodePath.join(process.cwd(), STACKMEM_FILE);
124
+ if (!nodeFs.existsSync(stackmemPath)) return null;
125
+ const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
126
+ return content.length > 0 ? content : null;
127
+ }
128
+
129
+ function requireProject(explicit: string | null): string {
130
+ if (explicit) return explicit;
131
+
132
+ const fromFile = readProjectFromFile();
133
+ if (fromFile) return fromFile;
134
+
135
+ console.error(
136
+ "No project specified and no .stackmem file found. Run 'cm init <project> .' first or pass a project name."
137
+ );
138
+ process.exit(1);
139
+ }
140
+
141
+ // For commands whose non-project args have a fixed count: if exactly that
142
+ // many args are given, project was omitted; if one more, the first is the
143
+ // project. Anything else is a usage error.
144
+ function splitOptionalProject(
145
+ args: string[],
146
+ fixedArgCount: number
147
+ ): { project: string | null; rest: string[] } | null {
148
+ if (args.length === fixedArgCount) return { project: null, rest: args };
149
+ if (args.length === fixedArgCount + 1) return { project: args[0], rest: args.slice(1) };
150
+ return null;
151
+ }
152
+
153
+ // For commands whose remaining arg is free-form text (a query or task),
154
+ // arg count can't disambiguate an omitted project from a multi-word query —
155
+ // "cm search bug fix" is ambiguous by count alone. Use .stackmem's presence
156
+ // instead: if it exists, nothing is positionally a project.
157
+ function splitProjectFromVariadic(args: string[]): { project: string | null; rest: string[] } {
158
+ if (readProjectFromFile() !== null) {
159
+ return { project: null, rest: args };
160
+ }
161
+ const [project = null, ...rest] = args;
162
+ return { project, rest };
163
+ }
164
+
113
165
  // --- cm start ----------------------------------------------------------
114
166
 
115
167
  async function cmdStart(project: string) {
@@ -471,11 +523,20 @@ async function cmdDelete(project: string, memoryId: string) {
471
523
  // .git/hooks/post-commit) so its module type is unambiguous — an
472
524
  // extensionless file run by git would otherwise have its CommonJS/ESM
473
525
  // interpretation depend on the target repo's own package.json.
474
- function buildHookLogicScript(project: string, cliPath: string): string {
526
+ function buildHookLogicScript(cliPath: string): string {
475
527
  return `#!/usr/bin/env node
476
528
  import { execFileSync } from "node:child_process";
529
+ import { readFileSync } from "node:fs";
530
+ import { join } from "node:path";
531
+
532
+ const stackmemPath = join(process.cwd(), ".stackmem");
533
+ let PROJECT;
534
+ try {
535
+ PROJECT = readFileSync(stackmemPath, "utf8").trim();
536
+ } catch {
537
+ process.exit(0); // no .stackmem, skip silently
538
+ }
477
539
 
478
- const PROJECT = ${JSON.stringify(project)};
479
540
  const CLI_PATH = ${JSON.stringify(cliPath)};
480
541
 
481
542
  function git(args) {
@@ -512,21 +573,60 @@ function isSchemaFile(filePath) {
512
573
  );
513
574
  }
514
575
 
576
+ const LOW_QUALITY_PREFIXES = [
577
+ "wip", "fix", "update", "misc", "temp", "test", "patch", "minor",
578
+ "tweak", "change", "stuff", "done", "commit", "save", "ok", "m", "x",
579
+ ];
580
+
581
+ // Filters out lazy/placeholder commit messages ("wip", "fix", "m", "...")
582
+ // so only messages worth remembering become decisions. Checked against the
583
+ // first word, not a raw prefix match, so a real message like "Migrate auth
584
+ // to JWT" isn't rejected just for starting with the letter "m".
585
+ function isQualityCommitMessage(message) {
586
+ const trimmed = message.trim();
587
+ if (trimmed.length <= 20) return false;
588
+ if (!trimmed.includes(" ")) return false;
589
+
590
+ const stripped = trimmed.replace(/\\s/g, "");
591
+ if (stripped.length > 0 && new Set(stripped).size === 1) return false;
592
+
593
+ const lowerTrimmed = trimmed.toLowerCase();
594
+ if (LOW_QUALITY_PREFIXES.includes(lowerTrimmed)) return false;
595
+
596
+ const firstWord = (lowerTrimmed.match(/^[a-z0-9]+/) || [""])[0];
597
+ if (LOW_QUALITY_PREFIXES.includes(firstWord)) return false;
598
+
599
+ return true;
600
+ }
601
+
515
602
  const savedItems = [];
516
603
 
517
604
  const commitMessage = git(["log", "-1", "--pretty=%B"]);
518
- cmSave("decision", commitMessage);
519
- savedItems.push(\`decision: \${commitMessage}\`);
520
605
 
521
- const changedFiles = git(["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"])
606
+ const isFirstCommit = (() => {
607
+ try {
608
+ execFileSync("git", ["rev-parse", "HEAD~1"], { stdio: "pipe" });
609
+ return false;
610
+ } catch {
611
+ return true;
612
+ }
613
+ })();
614
+
615
+ const changedFiles = git(
616
+ isFirstCommit
617
+ ? ["diff-tree", "--root", "--no-commit-id", "-r", "--name-only", "HEAD"]
618
+ : ["diff-tree", "--no-commit-id", "-r", "--name-only", "HEAD"]
619
+ )
522
620
  .split("\\n")
523
621
  .filter(Boolean);
524
622
 
525
623
  let diff = "";
526
- try {
527
- diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
528
- } catch {
529
- diff = "";
624
+ if (!isFirstCommit) {
625
+ try {
626
+ diff = execFileSync("git", ["diff", "HEAD~1", "HEAD"], { encoding: "utf-8" });
627
+ } catch {
628
+ diff = "";
629
+ }
530
630
  }
531
631
 
532
632
  const lines = diff.split("\\n");
@@ -534,7 +634,7 @@ let currentFile = null;
534
634
  let isNewFile = false;
535
635
  let inDependenciesSection = false;
536
636
 
537
- for (const line of lines) {
637
+ for (const line of isFirstCommit ? [] : lines) {
538
638
  if (line.startsWith("diff --git")) {
539
639
  isNewFile = false;
540
640
  inDependenciesSection = false;
@@ -584,10 +684,20 @@ for (const line of lines) {
584
684
  if (line.startsWith("-") && !line.startsWith("---")) {
585
685
  const codeLine = line.slice(1).trim();
586
686
 
587
- if (codeLine.startsWith("import") || codeLine.startsWith("require")) {
588
- const pathMatch = codeLine.match(/['"]([^'"]+)['"]/);
589
- const importPath = pathMatch ? pathMatch[1] : codeLine;
590
- const content = \`removed dependency: \${importPath}\`;
687
+ const importMatch = line.match(
688
+ /^-\\s*(import\\s+.*\\s+from\\s+['"]([^'"]+)['"]|import\\s+['"]([^'"]+)['"])/
689
+ );
690
+ const requireMatch = line.match(
691
+ /^-\\s*(?:const|let|var)\\s+\\S+\\s*=\\s*require\\(['"]([^'"]+)['"]\\)/
692
+ );
693
+ const removedModulePath = importMatch
694
+ ? importMatch[2] || importMatch[3]
695
+ : requireMatch
696
+ ? requireMatch[1]
697
+ : null;
698
+
699
+ if (removedModulePath) {
700
+ const content = \`removed dependency: \${removedModulePath}\`;
591
701
  cmSave("rejection", content);
592
702
  savedItems.push(\`rejection: \${content}\`);
593
703
  }
@@ -643,6 +753,14 @@ for (const line of lines) {
643
753
  }
644
754
  }
645
755
 
756
+ // Saved last, after every diff-detected memory, and only if it clears the
757
+ // quality filter — diff analysis always runs regardless of commit message.
758
+ if (isQualityCommitMessage(commitMessage)) {
759
+ const trimmedMessage = commitMessage.trim();
760
+ cmSave("decision", trimmedMessage);
761
+ savedItems.push(\`decision: \${trimmedMessage}\`);
762
+ }
763
+
646
764
  console.log(\`coding-memory post-commit summary (\${changedFiles.length} file(s) changed):\`);
647
765
  for (const item of savedItems) {
648
766
  console.log(\` - \${item}\`);
@@ -703,6 +821,75 @@ cm save ${project} <type> "<content>"
703
821
  console.log("CLAUDE.md written.");
704
822
  }
705
823
 
824
+ function writeProjectFile(project: string, resolvedPath: string): void {
825
+ const stackmemPath = nodePath.join(resolvedPath, STACKMEM_FILE);
826
+ nodeFs.writeFileSync(stackmemPath, `${project}\n`, "utf-8");
827
+ }
828
+
829
+ // Scans the freshly-initialized project for a few cheap, high-signal facts
830
+ // and saves them as memories directly (bypassing cmdSave's duplicate/conflict
831
+ // prompt — there's nothing to conflict with yet, and init must stay
832
+ // non-interactive). Returns how many memories were actually saved.
833
+ async function seedMemories(project: string, resolvedPath: string): Promise<number> {
834
+ let seededCount = 0;
835
+
836
+ const packageJsonPath = nodePath.join(resolvedPath, "package.json");
837
+ if (nodeFs.existsSync(packageJsonPath)) {
838
+ try {
839
+ const pkg = JSON.parse(nodeFs.readFileSync(packageJsonPath, "utf-8"));
840
+ const depNames = [
841
+ ...Object.keys(pkg.dependencies ?? {}),
842
+ ...Object.keys(pkg.devDependencies ?? {}),
843
+ ];
844
+ if (depNames.length > 0) {
845
+ const { error } = await supabase.from("memories").insert({
846
+ project,
847
+ type: "discovery",
848
+ content: `tech stack: ${depNames.join(", ")}`,
849
+ device_id: getDeviceId(),
850
+ });
851
+ if (!error) seededCount++;
852
+ }
853
+ } catch {
854
+ // Malformed package.json — skip the tech-stack memory.
855
+ }
856
+ }
857
+
858
+ const envExamplePath = nodePath.join(resolvedPath, ".env.example");
859
+ if (nodeFs.existsSync(envExamplePath)) {
860
+ const varNames = nodeFs
861
+ .readFileSync(envExamplePath, "utf-8")
862
+ .split("\n")
863
+ .filter((line) => /^[A-Z_]+=/.test(line))
864
+ .map((line) => line.split("=")[0]);
865
+
866
+ if (varNames.length > 0) {
867
+ const { error } = await supabase.from("memories").insert({
868
+ project,
869
+ type: "constraint",
870
+ content: `required env vars: ${varNames.join(", ")}`,
871
+ device_id: getDeviceId(),
872
+ });
873
+ if (!error) seededCount++;
874
+ }
875
+ }
876
+
877
+ analyzeCodebase(resolvedPath);
878
+
879
+ return seededCount;
880
+ }
881
+
882
+ // Smoke-tests the same anon-key + device-header path session_start/cmdStart
883
+ // use, without pulling in all of cmdStart's scoring/formatting logic.
884
+ async function verifyBackendConnection(project: string): Promise<boolean> {
885
+ const { error } = await supabase
886
+ .from("memories")
887
+ .select("id", { count: "exact", head: true })
888
+ .eq("project", project);
889
+
890
+ return !error;
891
+ }
892
+
706
893
  async function cmdInit(project: string, targetPath: string) {
707
894
  const resolvedPath = nodePath.resolve(targetPath);
708
895
  const gitDir = nodePath.join(resolvedPath, ".git");
@@ -716,7 +903,7 @@ async function cmdInit(project: string, targetPath: string) {
716
903
  nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
717
904
 
718
905
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
719
- nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(project, DIST_CLI_PATH), "utf-8");
906
+ nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
720
907
 
721
908
  const hooksDir = nodePath.join(gitDir, "hooks");
722
909
  nodeFs.mkdirSync(hooksDir, { recursive: true });
@@ -729,6 +916,31 @@ async function cmdInit(project: string, targetPath: string) {
729
916
 
730
917
  registerMcpServer(DIST_INDEX_PATH);
731
918
  writeClaudeMd(project, resolvedPath);
919
+
920
+ const seededCount = await seedMemories(project, resolvedPath);
921
+ console.log(`Seeded ${seededCount} initial memories from project scan.`);
922
+
923
+ const backendOk = await verifyBackendConnection(project);
924
+
925
+ console.log("");
926
+ console.log("✓ Git hook installed");
927
+ console.log("✓ MCP server registered");
928
+ console.log("✓ CLAUDE.md written");
929
+ console.log(`✓ Seeded ${seededCount} memories from project scan`);
930
+ if (backendOk) {
931
+ console.log("✓ Backend connection verified");
932
+ console.log("");
933
+ console.log(
934
+ `stackmem is live. Open Claude Code and ask "what do you know about this project?" to verify.`
935
+ );
936
+ } else {
937
+ console.log("✗ Backend connection failed — run 'cm doctor' (once we build it)");
938
+ }
939
+
940
+ // Written last and deliberately: if a commit (and hence the post-commit
941
+ // hook) fires anywhere during init, it must still see whatever project
942
+ // .stackmem pointed to before this run, not the one being initialized now.
943
+ writeProjectFile(project, resolvedPath);
732
944
  }
733
945
 
734
946
  // --- cm context ------------------------------------------------------------
@@ -794,8 +1006,9 @@ async function main() {
794
1006
 
795
1007
  switch (command) {
796
1008
  case "start": {
797
- const [project] = args;
798
- if (!project) usage();
1009
+ const split = splitOptionalProject(args, 0);
1010
+ if (!split) usage();
1011
+ const project = requireProject(split.project);
799
1012
  await cmdStart(project);
800
1013
  break;
801
1014
  }
@@ -805,44 +1018,66 @@ async function main() {
805
1018
  const positional = force
806
1019
  ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
807
1020
  : args;
808
- const [project, type, ...rest] = positional;
809
- if (!project || !type || rest.length === 0) usage();
1021
+
1022
+ let explicitProject: string | null;
1023
+ let type: string | undefined;
1024
+ let rest: string[];
1025
+ if (MEMORY_TYPES.includes(positional[0] as MemoryType)) {
1026
+ explicitProject = null;
1027
+ [type, ...rest] = positional;
1028
+ } else {
1029
+ explicitProject = positional[0] ?? null;
1030
+ [, type, ...rest] = positional;
1031
+ }
1032
+
1033
+ if (!type || rest.length === 0) usage();
1034
+ const project = requireProject(explicitProject);
810
1035
  await cmdSave(project, type, rest.join(" "), force);
811
1036
  break;
812
1037
  }
813
1038
  case "fix": {
814
- const [project, problem, solution] = args;
815
- if (!project || !problem || !solution) usage();
1039
+ const split = splitOptionalProject(args, 2);
1040
+ if (!split) usage();
1041
+ const project = requireProject(split.project);
1042
+ const [problem, solution] = split.rest;
816
1043
  await cmdFix(project, problem, solution);
817
1044
  break;
818
1045
  }
819
1046
  case "analyze": {
820
- const [project, path] = args;
821
- if (!project || !path) usage();
1047
+ const split = splitOptionalProject(args, 1);
1048
+ if (!split) usage();
1049
+ const project = requireProject(split.project);
1050
+ const [path] = split.rest;
822
1051
  await cmdAnalyze(project, path);
823
1052
  break;
824
1053
  }
825
1054
  case "search": {
826
- const [project, ...rest] = args;
827
- if (!project || rest.length === 0) usage();
828
- await cmdSearch(project, rest.join(" "));
1055
+ const split = splitProjectFromVariadic(args);
1056
+ const project = requireProject(split.project);
1057
+ if (split.rest.length === 0) usage();
1058
+ await cmdSearch(project, split.rest.join(" "));
829
1059
  break;
830
1060
  }
831
1061
  case "resolve": {
832
- const [project, memoryId] = args;
833
- if (!project || !memoryId) usage();
1062
+ const split = splitOptionalProject(args, 1);
1063
+ if (!split) usage();
1064
+ const project = requireProject(split.project);
1065
+ const [memoryId] = split.rest;
834
1066
  await cmdResolve(project, memoryId);
835
1067
  break;
836
1068
  }
837
1069
  case "delete": {
838
- const [project, memoryId] = args;
839
- if (!project || !memoryId) usage();
1070
+ const split = splitOptionalProject(args, 1);
1071
+ if (!split) usage();
1072
+ const project = requireProject(split.project);
1073
+ const [memoryId] = split.rest;
840
1074
  await cmdDelete(project, memoryId);
841
1075
  break;
842
1076
  }
843
1077
  case "compress": {
844
- const [project] = args;
845
- if (!project) usage();
1078
+ const split = splitOptionalProject(args, 0);
1079
+ if (!split) usage();
1080
+ const project = requireProject(split.project);
846
1081
  await cmdCompress(project);
847
1082
  break;
848
1083
  }
@@ -853,9 +1088,10 @@ async function main() {
853
1088
  break;
854
1089
  }
855
1090
  case "context": {
856
- const [project, ...rest] = args;
857
- if (!project || rest.length === 0) usage();
858
- await cmdContext(project, rest.join(" "));
1091
+ const split = splitProjectFromVariadic(args);
1092
+ const project = requireProject(split.project);
1093
+ if (split.rest.length === 0) usage();
1094
+ await cmdContext(project, split.rest.join(" "));
859
1095
  break;
860
1096
  }
861
1097
  case "help": {