stackmem 1.0.2 → 1.0.4

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 +253 -46
  3. package/package.json +1 -1
  4. package/src/cli.ts +283 -46
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
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import nodeFs from "node:fs";
3
+ import nodeOs from "node:os";
3
4
  import nodePath from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { createInterface } from "node:readline/promises";
@@ -14,6 +15,7 @@ const CLI_PATH = fileURLToPath(import.meta.url);
14
15
  // directly — tsx's esbuild transform fails when invoked from inside a git
15
16
  // hook's stripped-down shell environment.
16
17
  const DIST_CLI_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "cli.js");
18
+ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "index.js");
17
19
  const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"];
18
20
  const TYPE_LABELS = {
19
21
  decision: "Decisions",
@@ -23,63 +25,68 @@ const TYPE_LABELS = {
23
25
  };
24
26
  function usage() {
25
27
  console.error(`Usage:
26
- cm start <project>
27
- cm save <project> <type> <content> [--force] (type: decision|rejection|constraint|discovery)
28
- cm fix <project> <problem> <solution>
29
- cm analyze <project> <path>
30
- cm search <project> <query>
31
- cm resolve <project> <memory-id>
32
- cm delete <project> <memory-id>
33
- 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]
34
36
  cm init <project> <path>
35
- cm context <project> <task>
36
- 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.`);
37
42
  process.exit(1);
38
43
  }
39
44
  function cmdHelp() {
40
45
  console.log(`cm — coding memory CLI
41
46
 
42
- 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]
43
51
  Load all unresolved memories for a project, sorted by decay
44
52
  score and capped at a 2000 token context budget, grouped by
45
53
  type (decisions, constraints, discoveries, rejections), with
46
54
  linked memory ids shown under each entry.
47
55
 
48
- cm save <project> <type> <content> [--force]
56
+ cm save [project] <type> <content> [--force]
49
57
  Save a new memory. <type> is one of: decision, rejection,
50
58
  constraint, discovery. Automatically links to related existing
51
59
  memories. If the new memory contradicts an existing one, prompts
52
60
  to keep the old memory or replace it — pass --force to skip the
53
61
  prompt and always replace.
54
62
 
55
- cm fix <project> <problem> <solution>
63
+ cm fix [project] <problem> <solution>
56
64
  Record a problem and its solution for future reference.
57
65
 
58
- cm analyze <project> <path>
66
+ cm analyze [project] <path>
59
67
  Build an AST index (files, functions, classes) for the codebase
60
68
  at <path>.
61
69
 
62
- cm search <project> <query>
70
+ cm search [project] <query>
63
71
  Search saved memories and past fixes for a project matching
64
72
  <query>.
65
73
 
66
- cm resolve <project> <memory-id>
74
+ cm resolve [project] <memory-id>
67
75
  Mark a memory as resolved.
68
76
 
69
- cm delete <project> <memory-id>
77
+ cm delete [project] <memory-id>
70
78
  Delete a memory and any memory_links referencing it.
71
79
 
72
- cm compress <project>
80
+ cm compress [project]
73
81
  Cluster related unresolved memories (needs 20+) and collapse
74
82
  clusters of 3 or more into a single summary memory.
75
83
 
76
84
  cm init <project> <path>
77
- Install a git post-commit hook in the repo at <path> that
78
- records the commit message and any implicit memories (removed
79
- imports, new env vars, new auth-related files) after every
80
- 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.
81
88
 
82
- cm context <project> <task>
89
+ cm context [project] <task>
83
90
  Return only the top 5 memories most relevant to a specific
84
91
  task, ranked by a blend of task relevance and decay score,
85
92
  as a markdown block capped at a 2000 token budget.
@@ -91,6 +98,45 @@ function fail(message) {
91
98
  console.error(`Error: ${message}`);
92
99
  process.exit(1);
93
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
+ }
94
140
  // --- cm start ----------------------------------------------------------
95
141
  async function cmdStart(project) {
96
142
  const { data, error } = await supabase
@@ -387,11 +433,20 @@ async function cmdDelete(project, memoryId) {
387
433
  // .git/hooks/post-commit) so its module type is unambiguous — an
388
434
  // extensionless file run by git would otherwise have its CommonJS/ESM
389
435
  // interpretation depend on the target repo's own package.json.
390
- function buildHookLogicScript(project, cliPath) {
436
+ function buildHookLogicScript(cliPath) {
391
437
  return `#!/usr/bin/env node
392
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
+ }
393
449
 
394
- const PROJECT = ${JSON.stringify(project)};
395
450
  const CLI_PATH = ${JSON.stringify(cliPath)};
396
451
 
397
452
  function git(args) {
@@ -565,6 +620,113 @@ for (const item of savedItems) {
565
620
  }
566
621
  `;
567
622
  }
623
+ function registerMcpServer(distIndexPath) {
624
+ const claudeConfigPath = nodePath.join(nodeOs.homedir(), ".claude.json");
625
+ let config = {};
626
+ if (nodeFs.existsSync(claudeConfigPath)) {
627
+ try {
628
+ config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
629
+ }
630
+ catch {
631
+ config = {};
632
+ }
633
+ }
634
+ if (!config.mcpServers || typeof config.mcpServers !== "object") {
635
+ config.mcpServers = {};
636
+ }
637
+ if (config.mcpServers.stackmem) {
638
+ return;
639
+ }
640
+ config.mcpServers.stackmem = {
641
+ command: "node",
642
+ args: [distIndexPath],
643
+ };
644
+ nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
645
+ console.log("stackmem MCP server registered with Claude Code.");
646
+ }
647
+ function writeClaudeMd(project, resolvedPath) {
648
+ const claudeMdPath = nodePath.join(resolvedPath, "CLAUDE.md");
649
+ if (nodeFs.existsSync(claudeMdPath)) {
650
+ console.log("CLAUDE.md already exists — skipping.");
651
+ return;
652
+ }
653
+ const content = `# stackmem
654
+
655
+ At the start of every session, the stackmem MCP server will automatically load memories for this project. The project name is "${project}".
656
+
657
+ If the MCP server is not connected, run:
658
+ cm start ${project}
659
+ and paste the output here before starting work.
660
+
661
+ When you make a decision, discover a constraint, reject an approach, or make a discovery during this session, save it with:
662
+ cm save ${project} <type> "<content>"
663
+ `;
664
+ nodeFs.writeFileSync(claudeMdPath, content, "utf-8");
665
+ console.log("CLAUDE.md written.");
666
+ }
667
+ function writeProjectFile(project, resolvedPath) {
668
+ const stackmemPath = nodePath.join(resolvedPath, STACKMEM_FILE);
669
+ nodeFs.writeFileSync(stackmemPath, `${project}\n`, "utf-8");
670
+ }
671
+ // Scans the freshly-initialized project for a few cheap, high-signal facts
672
+ // and saves them as memories directly (bypassing cmdSave's duplicate/conflict
673
+ // prompt — there's nothing to conflict with yet, and init must stay
674
+ // non-interactive). Returns how many memories were actually saved.
675
+ async function seedMemories(project, resolvedPath) {
676
+ let seededCount = 0;
677
+ const packageJsonPath = nodePath.join(resolvedPath, "package.json");
678
+ if (nodeFs.existsSync(packageJsonPath)) {
679
+ try {
680
+ const pkg = JSON.parse(nodeFs.readFileSync(packageJsonPath, "utf-8"));
681
+ const depNames = [
682
+ ...Object.keys(pkg.dependencies ?? {}),
683
+ ...Object.keys(pkg.devDependencies ?? {}),
684
+ ];
685
+ if (depNames.length > 0) {
686
+ const { error } = await supabase.from("memories").insert({
687
+ project,
688
+ type: "discovery",
689
+ content: `tech stack: ${depNames.join(", ")}`,
690
+ device_id: getDeviceId(),
691
+ });
692
+ if (!error)
693
+ seededCount++;
694
+ }
695
+ }
696
+ catch {
697
+ // Malformed package.json — skip the tech-stack memory.
698
+ }
699
+ }
700
+ const envExamplePath = nodePath.join(resolvedPath, ".env.example");
701
+ if (nodeFs.existsSync(envExamplePath)) {
702
+ const varNames = nodeFs
703
+ .readFileSync(envExamplePath, "utf-8")
704
+ .split("\n")
705
+ .filter((line) => /^[A-Z_]+=/.test(line))
706
+ .map((line) => line.split("=")[0]);
707
+ if (varNames.length > 0) {
708
+ const { error } = await supabase.from("memories").insert({
709
+ project,
710
+ type: "constraint",
711
+ content: `required env vars: ${varNames.join(", ")}`,
712
+ device_id: getDeviceId(),
713
+ });
714
+ if (!error)
715
+ seededCount++;
716
+ }
717
+ }
718
+ analyzeCodebase(resolvedPath);
719
+ return seededCount;
720
+ }
721
+ // Smoke-tests the same anon-key + device-header path session_start/cmdStart
722
+ // use, without pulling in all of cmdStart's scoring/formatting logic.
723
+ async function verifyBackendConnection(project) {
724
+ const { error } = await supabase
725
+ .from("memories")
726
+ .select("id", { count: "exact", head: true })
727
+ .eq("project", project);
728
+ return !error;
729
+ }
568
730
  async function cmdInit(project, targetPath) {
569
731
  const resolvedPath = nodePath.resolve(targetPath);
570
732
  const gitDir = nodePath.join(resolvedPath, ".git");
@@ -575,13 +737,35 @@ async function cmdInit(project, targetPath) {
575
737
  const codingMemoryDir = nodePath.join(resolvedPath, ".coding-memory");
576
738
  nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
577
739
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
578
- nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(project, DIST_CLI_PATH), "utf-8");
740
+ nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
579
741
  const hooksDir = nodePath.join(gitDir, "hooks");
580
742
  nodeFs.mkdirSync(hooksDir, { recursive: true });
581
743
  const hookPath = nodePath.join(hooksDir, "post-commit");
582
744
  nodeFs.writeFileSync(hookPath, `#!/bin/sh\nexec node "${hookLogicPath}" "$@"\n`, "utf-8");
583
745
  nodeFs.chmodSync(hookPath, 0o755);
584
746
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
747
+ registerMcpServer(DIST_INDEX_PATH);
748
+ writeClaudeMd(project, resolvedPath);
749
+ const seededCount = await seedMemories(project, resolvedPath);
750
+ console.log(`Seeded ${seededCount} initial memories from project scan.`);
751
+ const backendOk = await verifyBackendConnection(project);
752
+ console.log("");
753
+ console.log("✓ Git hook installed");
754
+ console.log("✓ MCP server registered");
755
+ console.log("✓ CLAUDE.md written");
756
+ console.log(`✓ Seeded ${seededCount} memories from project scan`);
757
+ if (backendOk) {
758
+ console.log("✓ Backend connection verified");
759
+ console.log("");
760
+ console.log(`stackmem is live. Open Claude Code and ask "what do you know about this project?" to verify.`);
761
+ }
762
+ else {
763
+ console.log("✗ Backend connection failed — run 'cm doctor' (once we build it)");
764
+ }
765
+ // Written last and deliberately: if a commit (and hence the post-commit
766
+ // hook) fires anywhere during init, it must still see whatever project
767
+ // .stackmem pointed to before this run, not the one being initialized now.
768
+ writeProjectFile(project, resolvedPath);
585
769
  }
586
770
  // --- cm context ------------------------------------------------------------
587
771
  const CONTEXT_TYPE_ORDER = ["decision", "constraint", "rejection", "discovery"];
@@ -631,9 +815,10 @@ async function main() {
631
815
  const [, , command, ...args] = process.argv;
632
816
  switch (command) {
633
817
  case "start": {
634
- const [project] = args;
635
- if (!project)
818
+ const split = splitOptionalProject(args, 0);
819
+ if (!split)
636
820
  usage();
821
+ const project = requireProject(split.project);
637
822
  await cmdStart(project);
638
823
  break;
639
824
  }
@@ -643,51 +828,72 @@ async function main() {
643
828
  const positional = force
644
829
  ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
645
830
  : args;
646
- const [project, type, ...rest] = positional;
647
- if (!project || !type || rest.length === 0)
831
+ let explicitProject;
832
+ let type;
833
+ let rest;
834
+ if (MEMORY_TYPES.includes(positional[0])) {
835
+ explicitProject = null;
836
+ [type, ...rest] = positional;
837
+ }
838
+ else {
839
+ explicitProject = positional[0] ?? null;
840
+ [, type, ...rest] = positional;
841
+ }
842
+ if (!type || rest.length === 0)
648
843
  usage();
844
+ const project = requireProject(explicitProject);
649
845
  await cmdSave(project, type, rest.join(" "), force);
650
846
  break;
651
847
  }
652
848
  case "fix": {
653
- const [project, problem, solution] = args;
654
- if (!project || !problem || !solution)
849
+ const split = splitOptionalProject(args, 2);
850
+ if (!split)
655
851
  usage();
852
+ const project = requireProject(split.project);
853
+ const [problem, solution] = split.rest;
656
854
  await cmdFix(project, problem, solution);
657
855
  break;
658
856
  }
659
857
  case "analyze": {
660
- const [project, path] = args;
661
- if (!project || !path)
858
+ const split = splitOptionalProject(args, 1);
859
+ if (!split)
662
860
  usage();
861
+ const project = requireProject(split.project);
862
+ const [path] = split.rest;
663
863
  await cmdAnalyze(project, path);
664
864
  break;
665
865
  }
666
866
  case "search": {
667
- const [project, ...rest] = args;
668
- if (!project || rest.length === 0)
867
+ const split = splitProjectFromVariadic(args);
868
+ const project = requireProject(split.project);
869
+ if (split.rest.length === 0)
669
870
  usage();
670
- await cmdSearch(project, rest.join(" "));
871
+ await cmdSearch(project, split.rest.join(" "));
671
872
  break;
672
873
  }
673
874
  case "resolve": {
674
- const [project, memoryId] = args;
675
- if (!project || !memoryId)
875
+ const split = splitOptionalProject(args, 1);
876
+ if (!split)
676
877
  usage();
878
+ const project = requireProject(split.project);
879
+ const [memoryId] = split.rest;
677
880
  await cmdResolve(project, memoryId);
678
881
  break;
679
882
  }
680
883
  case "delete": {
681
- const [project, memoryId] = args;
682
- if (!project || !memoryId)
884
+ const split = splitOptionalProject(args, 1);
885
+ if (!split)
683
886
  usage();
887
+ const project = requireProject(split.project);
888
+ const [memoryId] = split.rest;
684
889
  await cmdDelete(project, memoryId);
685
890
  break;
686
891
  }
687
892
  case "compress": {
688
- const [project] = args;
689
- if (!project)
893
+ const split = splitOptionalProject(args, 0);
894
+ if (!split)
690
895
  usage();
896
+ const project = requireProject(split.project);
691
897
  await cmdCompress(project);
692
898
  break;
693
899
  }
@@ -699,10 +905,11 @@ async function main() {
699
905
  break;
700
906
  }
701
907
  case "context": {
702
- const [project, ...rest] = args;
703
- if (!project || rest.length === 0)
908
+ const split = splitProjectFromVariadic(args);
909
+ const project = requireProject(split.project);
910
+ if (split.rest.length === 0)
704
911
  usage();
705
- await cmdContext(project, rest.join(" "));
912
+ await cmdContext(project, split.rest.join(" "));
706
913
  break;
707
914
  }
708
915
  case "help": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackmem",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import nodeFs from "node:fs";
3
+ import nodeOs from "node:os";
3
4
  import nodePath from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { createInterface } from "node:readline/promises";
@@ -24,6 +25,7 @@ const CLI_PATH = fileURLToPath(import.meta.url);
24
25
  // directly — tsx's esbuild transform fails when invoked from inside a git
25
26
  // hook's stripped-down shell environment.
26
27
  const DIST_CLI_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "cli.js");
28
+ const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "index.js");
27
29
 
28
30
  const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
29
31
  type MemoryType = (typeof MEMORY_TYPES)[number];
@@ -37,64 +39,69 @@ const TYPE_LABELS: Record<MemoryType, string> = {
37
39
 
38
40
  function usage(): never {
39
41
  console.error(`Usage:
40
- cm start <project>
41
- cm save <project> <type> <content> [--force] (type: decision|rejection|constraint|discovery)
42
- cm fix <project> <problem> <solution>
43
- cm analyze <project> <path>
44
- cm search <project> <query>
45
- cm resolve <project> <memory-id>
46
- cm delete <project> <memory-id>
47
- 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]
48
50
  cm init <project> <path>
49
- cm context <project> <task>
50
- 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.`);
51
56
  process.exit(1);
52
57
  }
53
58
 
54
59
  function cmdHelp() {
55
60
  console.log(`cm — coding memory CLI
56
61
 
57
- 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]
58
66
  Load all unresolved memories for a project, sorted by decay
59
67
  score and capped at a 2000 token context budget, grouped by
60
68
  type (decisions, constraints, discoveries, rejections), with
61
69
  linked memory ids shown under each entry.
62
70
 
63
- cm save <project> <type> <content> [--force]
71
+ cm save [project] <type> <content> [--force]
64
72
  Save a new memory. <type> is one of: decision, rejection,
65
73
  constraint, discovery. Automatically links to related existing
66
74
  memories. If the new memory contradicts an existing one, prompts
67
75
  to keep the old memory or replace it — pass --force to skip the
68
76
  prompt and always replace.
69
77
 
70
- cm fix <project> <problem> <solution>
78
+ cm fix [project] <problem> <solution>
71
79
  Record a problem and its solution for future reference.
72
80
 
73
- cm analyze <project> <path>
81
+ cm analyze [project] <path>
74
82
  Build an AST index (files, functions, classes) for the codebase
75
83
  at <path>.
76
84
 
77
- cm search <project> <query>
85
+ cm search [project] <query>
78
86
  Search saved memories and past fixes for a project matching
79
87
  <query>.
80
88
 
81
- cm resolve <project> <memory-id>
89
+ cm resolve [project] <memory-id>
82
90
  Mark a memory as resolved.
83
91
 
84
- cm delete <project> <memory-id>
92
+ cm delete [project] <memory-id>
85
93
  Delete a memory and any memory_links referencing it.
86
94
 
87
- cm compress <project>
95
+ cm compress [project]
88
96
  Cluster related unresolved memories (needs 20+) and collapse
89
97
  clusters of 3 or more into a single summary memory.
90
98
 
91
99
  cm init <project> <path>
92
- Install a git post-commit hook in the repo at <path> that
93
- records the commit message and any implicit memories (removed
94
- imports, new env vars, new auth-related files) after every
95
- 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.
96
103
 
97
- cm context <project> <task>
104
+ cm context [project] <task>
98
105
  Return only the top 5 memories most relevant to a specific
99
106
  task, ranked by a blend of task relevance and decay score,
100
107
  as a markdown block capped at a 2000 token budget.
@@ -108,6 +115,53 @@ function fail(message: string): never {
108
115
  process.exit(1);
109
116
  }
110
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
+
111
165
  // --- cm start ----------------------------------------------------------
112
166
 
113
167
  async function cmdStart(project: string) {
@@ -469,11 +523,20 @@ async function cmdDelete(project: string, memoryId: string) {
469
523
  // .git/hooks/post-commit) so its module type is unambiguous — an
470
524
  // extensionless file run by git would otherwise have its CommonJS/ESM
471
525
  // interpretation depend on the target repo's own package.json.
472
- function buildHookLogicScript(project: string, cliPath: string): string {
526
+ function buildHookLogicScript(cliPath: string): string {
473
527
  return `#!/usr/bin/env node
474
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
+ }
475
539
 
476
- const PROJECT = ${JSON.stringify(project)};
477
540
  const CLI_PATH = ${JSON.stringify(cliPath)};
478
541
 
479
542
  function git(args) {
@@ -648,6 +711,128 @@ for (const item of savedItems) {
648
711
  `;
649
712
  }
650
713
 
714
+ function registerMcpServer(distIndexPath: string): void {
715
+ const claudeConfigPath = nodePath.join(nodeOs.homedir(), ".claude.json");
716
+
717
+ let config: { mcpServers?: Record<string, unknown> } = {};
718
+ if (nodeFs.existsSync(claudeConfigPath)) {
719
+ try {
720
+ config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
721
+ } catch {
722
+ config = {};
723
+ }
724
+ }
725
+
726
+ if (!config.mcpServers || typeof config.mcpServers !== "object") {
727
+ config.mcpServers = {};
728
+ }
729
+
730
+ if (config.mcpServers.stackmem) {
731
+ return;
732
+ }
733
+
734
+ config.mcpServers.stackmem = {
735
+ command: "node",
736
+ args: [distIndexPath],
737
+ };
738
+
739
+ nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
740
+ console.log("stackmem MCP server registered with Claude Code.");
741
+ }
742
+
743
+ function writeClaudeMd(project: string, resolvedPath: string): void {
744
+ const claudeMdPath = nodePath.join(resolvedPath, "CLAUDE.md");
745
+
746
+ if (nodeFs.existsSync(claudeMdPath)) {
747
+ console.log("CLAUDE.md already exists — skipping.");
748
+ return;
749
+ }
750
+
751
+ const content = `# stackmem
752
+
753
+ At the start of every session, the stackmem MCP server will automatically load memories for this project. The project name is "${project}".
754
+
755
+ If the MCP server is not connected, run:
756
+ cm start ${project}
757
+ and paste the output here before starting work.
758
+
759
+ When you make a decision, discover a constraint, reject an approach, or make a discovery during this session, save it with:
760
+ cm save ${project} <type> "<content>"
761
+ `;
762
+
763
+ nodeFs.writeFileSync(claudeMdPath, content, "utf-8");
764
+ console.log("CLAUDE.md written.");
765
+ }
766
+
767
+ function writeProjectFile(project: string, resolvedPath: string): void {
768
+ const stackmemPath = nodePath.join(resolvedPath, STACKMEM_FILE);
769
+ nodeFs.writeFileSync(stackmemPath, `${project}\n`, "utf-8");
770
+ }
771
+
772
+ // Scans the freshly-initialized project for a few cheap, high-signal facts
773
+ // and saves them as memories directly (bypassing cmdSave's duplicate/conflict
774
+ // prompt — there's nothing to conflict with yet, and init must stay
775
+ // non-interactive). Returns how many memories were actually saved.
776
+ async function seedMemories(project: string, resolvedPath: string): Promise<number> {
777
+ let seededCount = 0;
778
+
779
+ const packageJsonPath = nodePath.join(resolvedPath, "package.json");
780
+ if (nodeFs.existsSync(packageJsonPath)) {
781
+ try {
782
+ const pkg = JSON.parse(nodeFs.readFileSync(packageJsonPath, "utf-8"));
783
+ const depNames = [
784
+ ...Object.keys(pkg.dependencies ?? {}),
785
+ ...Object.keys(pkg.devDependencies ?? {}),
786
+ ];
787
+ if (depNames.length > 0) {
788
+ const { error } = await supabase.from("memories").insert({
789
+ project,
790
+ type: "discovery",
791
+ content: `tech stack: ${depNames.join(", ")}`,
792
+ device_id: getDeviceId(),
793
+ });
794
+ if (!error) seededCount++;
795
+ }
796
+ } catch {
797
+ // Malformed package.json — skip the tech-stack memory.
798
+ }
799
+ }
800
+
801
+ const envExamplePath = nodePath.join(resolvedPath, ".env.example");
802
+ if (nodeFs.existsSync(envExamplePath)) {
803
+ const varNames = nodeFs
804
+ .readFileSync(envExamplePath, "utf-8")
805
+ .split("\n")
806
+ .filter((line) => /^[A-Z_]+=/.test(line))
807
+ .map((line) => line.split("=")[0]);
808
+
809
+ if (varNames.length > 0) {
810
+ const { error } = await supabase.from("memories").insert({
811
+ project,
812
+ type: "constraint",
813
+ content: `required env vars: ${varNames.join(", ")}`,
814
+ device_id: getDeviceId(),
815
+ });
816
+ if (!error) seededCount++;
817
+ }
818
+ }
819
+
820
+ analyzeCodebase(resolvedPath);
821
+
822
+ return seededCount;
823
+ }
824
+
825
+ // Smoke-tests the same anon-key + device-header path session_start/cmdStart
826
+ // use, without pulling in all of cmdStart's scoring/formatting logic.
827
+ async function verifyBackendConnection(project: string): Promise<boolean> {
828
+ const { error } = await supabase
829
+ .from("memories")
830
+ .select("id", { count: "exact", head: true })
831
+ .eq("project", project);
832
+
833
+ return !error;
834
+ }
835
+
651
836
  async function cmdInit(project: string, targetPath: string) {
652
837
  const resolvedPath = nodePath.resolve(targetPath);
653
838
  const gitDir = nodePath.join(resolvedPath, ".git");
@@ -661,7 +846,7 @@ async function cmdInit(project: string, targetPath: string) {
661
846
  nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
662
847
 
663
848
  const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
664
- nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(project, DIST_CLI_PATH), "utf-8");
849
+ nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
665
850
 
666
851
  const hooksDir = nodePath.join(gitDir, "hooks");
667
852
  nodeFs.mkdirSync(hooksDir, { recursive: true });
@@ -671,6 +856,34 @@ async function cmdInit(project: string, targetPath: string) {
671
856
  nodeFs.chmodSync(hookPath, 0o755);
672
857
 
673
858
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
859
+
860
+ registerMcpServer(DIST_INDEX_PATH);
861
+ writeClaudeMd(project, resolvedPath);
862
+
863
+ const seededCount = await seedMemories(project, resolvedPath);
864
+ console.log(`Seeded ${seededCount} initial memories from project scan.`);
865
+
866
+ const backendOk = await verifyBackendConnection(project);
867
+
868
+ console.log("");
869
+ console.log("✓ Git hook installed");
870
+ console.log("✓ MCP server registered");
871
+ console.log("✓ CLAUDE.md written");
872
+ console.log(`✓ Seeded ${seededCount} memories from project scan`);
873
+ if (backendOk) {
874
+ console.log("✓ Backend connection verified");
875
+ console.log("");
876
+ console.log(
877
+ `stackmem is live. Open Claude Code and ask "what do you know about this project?" to verify.`
878
+ );
879
+ } else {
880
+ console.log("✗ Backend connection failed — run 'cm doctor' (once we build it)");
881
+ }
882
+
883
+ // Written last and deliberately: if a commit (and hence the post-commit
884
+ // hook) fires anywhere during init, it must still see whatever project
885
+ // .stackmem pointed to before this run, not the one being initialized now.
886
+ writeProjectFile(project, resolvedPath);
674
887
  }
675
888
 
676
889
  // --- cm context ------------------------------------------------------------
@@ -736,8 +949,9 @@ async function main() {
736
949
 
737
950
  switch (command) {
738
951
  case "start": {
739
- const [project] = args;
740
- if (!project) usage();
952
+ const split = splitOptionalProject(args, 0);
953
+ if (!split) usage();
954
+ const project = requireProject(split.project);
741
955
  await cmdStart(project);
742
956
  break;
743
957
  }
@@ -747,44 +961,66 @@ async function main() {
747
961
  const positional = force
748
962
  ? [...args.slice(0, forceIndex), ...args.slice(forceIndex + 1)]
749
963
  : args;
750
- const [project, type, ...rest] = positional;
751
- if (!project || !type || rest.length === 0) usage();
964
+
965
+ let explicitProject: string | null;
966
+ let type: string | undefined;
967
+ let rest: string[];
968
+ if (MEMORY_TYPES.includes(positional[0] as MemoryType)) {
969
+ explicitProject = null;
970
+ [type, ...rest] = positional;
971
+ } else {
972
+ explicitProject = positional[0] ?? null;
973
+ [, type, ...rest] = positional;
974
+ }
975
+
976
+ if (!type || rest.length === 0) usage();
977
+ const project = requireProject(explicitProject);
752
978
  await cmdSave(project, type, rest.join(" "), force);
753
979
  break;
754
980
  }
755
981
  case "fix": {
756
- const [project, problem, solution] = args;
757
- if (!project || !problem || !solution) usage();
982
+ const split = splitOptionalProject(args, 2);
983
+ if (!split) usage();
984
+ const project = requireProject(split.project);
985
+ const [problem, solution] = split.rest;
758
986
  await cmdFix(project, problem, solution);
759
987
  break;
760
988
  }
761
989
  case "analyze": {
762
- const [project, path] = args;
763
- if (!project || !path) usage();
990
+ const split = splitOptionalProject(args, 1);
991
+ if (!split) usage();
992
+ const project = requireProject(split.project);
993
+ const [path] = split.rest;
764
994
  await cmdAnalyze(project, path);
765
995
  break;
766
996
  }
767
997
  case "search": {
768
- const [project, ...rest] = args;
769
- if (!project || rest.length === 0) usage();
770
- await cmdSearch(project, rest.join(" "));
998
+ const split = splitProjectFromVariadic(args);
999
+ const project = requireProject(split.project);
1000
+ if (split.rest.length === 0) usage();
1001
+ await cmdSearch(project, split.rest.join(" "));
771
1002
  break;
772
1003
  }
773
1004
  case "resolve": {
774
- const [project, memoryId] = args;
775
- if (!project || !memoryId) usage();
1005
+ const split = splitOptionalProject(args, 1);
1006
+ if (!split) usage();
1007
+ const project = requireProject(split.project);
1008
+ const [memoryId] = split.rest;
776
1009
  await cmdResolve(project, memoryId);
777
1010
  break;
778
1011
  }
779
1012
  case "delete": {
780
- const [project, memoryId] = args;
781
- if (!project || !memoryId) usage();
1013
+ const split = splitOptionalProject(args, 1);
1014
+ if (!split) usage();
1015
+ const project = requireProject(split.project);
1016
+ const [memoryId] = split.rest;
782
1017
  await cmdDelete(project, memoryId);
783
1018
  break;
784
1019
  }
785
1020
  case "compress": {
786
- const [project] = args;
787
- if (!project) usage();
1021
+ const split = splitOptionalProject(args, 0);
1022
+ if (!split) usage();
1023
+ const project = requireProject(split.project);
788
1024
  await cmdCompress(project);
789
1025
  break;
790
1026
  }
@@ -795,9 +1031,10 @@ async function main() {
795
1031
  break;
796
1032
  }
797
1033
  case "context": {
798
- const [project, ...rest] = args;
799
- if (!project || rest.length === 0) usage();
800
- await cmdContext(project, rest.join(" "));
1034
+ const split = splitProjectFromVariadic(args);
1035
+ const project = requireProject(split.project);
1036
+ if (split.rest.length === 0) usage();
1037
+ await cmdContext(project, split.rest.join(" "));
801
1038
  break;
802
1039
  }
803
1040
  case "help": {