stackmem 1.0.21 → 1.0.24
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/README.md +81 -155
- package/dist/cli.js +413 -63
- package/dist/scoring.js +74 -0
- package/dist/tests/regression.test.js +248 -0
- package/package.json +3 -2
- package/src/cli.ts +451 -60
- package/src/scoring.ts +88 -0
- package/src/tests/regression.test.ts +304 -0
package/dist/cli.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import nodeFs from "node:fs";
|
|
3
3
|
import nodeOs from "node:os";
|
|
4
4
|
import nodePath from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
7
|
import { analyzeCodebase } from "./ast-index.js";
|
|
8
8
|
import { getDeviceId } from "./device.js";
|
|
9
|
-
import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, scoreMemoryRelevance, } from "./scoring.js";
|
|
9
|
+
import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, redactSecrets, scoreMemoryRelevance, } from "./scoring.js";
|
|
10
10
|
import { supabase } from "./storage.js";
|
|
11
11
|
const CONTEXT_BUDGET_TOKENS = 2000;
|
|
12
12
|
const COMPRESSION_THRESHOLD = 20;
|
|
@@ -16,6 +16,13 @@ const CLI_PATH = fileURLToPath(import.meta.url);
|
|
|
16
16
|
// hook's stripped-down shell environment.
|
|
17
17
|
const DIST_CLI_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "cli.js");
|
|
18
18
|
const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "index.js");
|
|
19
|
+
// Written into every file/hook stackmem manages, so `cm uninstall` (and the
|
|
20
|
+
// hook installer's chaining check) can tell "ours, safe to remove/replace"
|
|
21
|
+
// apart from a user's own pre-existing file/hook with the same name.
|
|
22
|
+
const STACKMEM_HOOK_MARKER = "# stackmem-managed";
|
|
23
|
+
const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
|
|
24
|
+
const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
|
|
25
|
+
const HOOK_BACKUP_FILENAME = "post-commit.pre-stackmem";
|
|
19
26
|
const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"];
|
|
20
27
|
const TYPE_LABELS = {
|
|
21
28
|
decision: "Decisions",
|
|
@@ -35,10 +42,11 @@ function usage() {
|
|
|
35
42
|
cm inspect [project] <memory-id>
|
|
36
43
|
cm compress [project]
|
|
37
44
|
cm init <project> <path>
|
|
45
|
+
cm uninstall [project] [path]
|
|
38
46
|
cm sync
|
|
39
47
|
cm context [--project <name>] <task>
|
|
40
48
|
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
41
|
-
cm doctor
|
|
49
|
+
cm doctor [--fix]
|
|
42
50
|
cm undo [project]
|
|
43
51
|
cm export [filename]
|
|
44
52
|
cm import [project] <filename>
|
|
@@ -111,6 +119,17 @@ function cmdHelp() {
|
|
|
111
119
|
exist. The post-commit hook keeps them fresh afterward by
|
|
112
120
|
running 'cm sync' on every commit.
|
|
113
121
|
|
|
122
|
+
cm uninstall [project] [path]
|
|
123
|
+
Remove everything 'cm init' set up at <path> (defaults to the
|
|
124
|
+
current directory): the git post-commit hook (restoring whatever
|
|
125
|
+
hook it chained after, if any), .coding-memory/post-commit-hook.mjs,
|
|
126
|
+
.coding-memory/ast-index.db and the .coding-memory/ directory
|
|
127
|
+
itself if left empty, the .stackmem file, and CLAUDE.md /
|
|
128
|
+
.cursorrules / .windsurfrules — the last three only if they still
|
|
129
|
+
carry the stackmem marker, so hand-edited files are left alone.
|
|
130
|
+
Also removes the stackmem entry from ~/.claude.json's mcpServers.
|
|
131
|
+
[project] defaults to the .stackmem file at <path> if omitted.
|
|
132
|
+
|
|
114
133
|
cm sync
|
|
115
134
|
Regenerate .cursorrules and .windsurfrules from the project's
|
|
116
135
|
current unresolved memories (decay-ranked, capped at a 2000
|
|
@@ -134,10 +153,16 @@ function cmdHelp() {
|
|
|
134
153
|
(project, summary, errors_encountered, files_touched,
|
|
135
154
|
approaches_tried) when stdin is piped rather than a terminal.
|
|
136
155
|
|
|
137
|
-
cm doctor
|
|
156
|
+
cm doctor [--fix]
|
|
138
157
|
Check every integration point in the current directory — backend
|
|
139
158
|
connection, MCP registration, .stackmem, git hook, and memory
|
|
140
|
-
count — and report what's working and what's broken.
|
|
159
|
+
count — and report what's working and what's broken. With --fix,
|
|
160
|
+
attempt a repair after each failing check: re-register the MCP
|
|
161
|
+
server, or re-install the git hook. A failed backend connection,
|
|
162
|
+
a missing .stackmem file, or no memories yet can't be auto-fixed
|
|
163
|
+
(they need a working network, 'cm init', or a commit respectively)
|
|
164
|
+
— those print guidance instead. Reports "Fixed: <item>" or
|
|
165
|
+
"Could not auto-fix: <item>" for each failing check.
|
|
141
166
|
|
|
142
167
|
cm undo [project]
|
|
143
168
|
Delete the most recently saved unresolved memory (and any
|
|
@@ -358,6 +383,10 @@ async function cmdSave(project, type, content, force = false) {
|
|
|
358
383
|
if (content.length > 500) {
|
|
359
384
|
fail(`Memory content too long (${content.length} chars). Keep memories under 500 characters — they should be concise facts, not paragraphs.`);
|
|
360
385
|
}
|
|
386
|
+
// Redacted before any further use — comparisons, prompts, and storage all
|
|
387
|
+
// see the scrubbed text, so a secret never round-trips through a console
|
|
388
|
+
// prompt (confirmSupersede) or ends up persisted.
|
|
389
|
+
content = redactSecrets(content);
|
|
361
390
|
const { data: existingRows, error: existingError } = await supabase
|
|
362
391
|
.from("memories")
|
|
363
392
|
.select("id, content")
|
|
@@ -465,6 +494,8 @@ async function cmdFix(project, problem, solution) {
|
|
|
465
494
|
if (problem.trim().length === 0 || solution.trim().length === 0) {
|
|
466
495
|
fail("Problem and solution cannot be empty.");
|
|
467
496
|
}
|
|
497
|
+
problem = redactSecrets(problem);
|
|
498
|
+
solution = redactSecrets(solution);
|
|
468
499
|
const { error } = await supabase
|
|
469
500
|
.from("execution_log")
|
|
470
501
|
.insert({ project, problem, solution, resolved: false, device_id: getDeviceId() });
|
|
@@ -652,8 +683,13 @@ async function cmdInspect(project, memoryId) {
|
|
|
652
683
|
function buildHookLogicScript(cliPath) {
|
|
653
684
|
return `#!/usr/bin/env node
|
|
654
685
|
import { execFileSync } from "node:child_process";
|
|
655
|
-
import { readFileSync } from "node:fs";
|
|
656
|
-
import { join } from "node:path";
|
|
686
|
+
import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
687
|
+
import { join, dirname } from "node:path";
|
|
688
|
+
|
|
689
|
+
// The hook must never block or break a commit: a slow or throwing hook body
|
|
690
|
+
// is worse than a skipped one. runHook() does the real work; main() races
|
|
691
|
+
// it against a 2s timeout and logs (rather than surfaces) any failure.
|
|
692
|
+
const HOOK_TIMEOUT_MS = 2000;
|
|
657
693
|
|
|
658
694
|
const stackmemPath = join(process.cwd(), ".stackmem");
|
|
659
695
|
let PROJECT;
|
|
@@ -665,6 +701,58 @@ try {
|
|
|
665
701
|
|
|
666
702
|
const CLI_PATH = ${JSON.stringify(cliPath)};
|
|
667
703
|
|
|
704
|
+
const SECRET_PATTERNS = [
|
|
705
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
706
|
+
/(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
|
|
707
|
+
/bearer\\s+[a-zA-Z0-9\\-._~+/]+=*/gi,
|
|
708
|
+
/-----BEGIN [A-Z ]+PRIVATE KEY-----/g,
|
|
709
|
+
/^[A-Z_]+=.{8,}/gm,
|
|
710
|
+
];
|
|
711
|
+
|
|
712
|
+
const HIGH_ENTROPY_RE = /[a-zA-Z0-9+/]{40,}={0,2}/g;
|
|
713
|
+
const HIGH_ENTROPY_MIN_BITS_PER_CHAR = 4.5;
|
|
714
|
+
|
|
715
|
+
function shannonEntropy(text) {
|
|
716
|
+
if (text.length === 0) return 0;
|
|
717
|
+
const counts = new Map();
|
|
718
|
+
for (const char of text) {
|
|
719
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
720
|
+
}
|
|
721
|
+
let entropy = 0;
|
|
722
|
+
for (const count of counts.values()) {
|
|
723
|
+
const p = count / text.length;
|
|
724
|
+
entropy -= p * Math.log2(p);
|
|
725
|
+
}
|
|
726
|
+
return entropy;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Inlined copy of src/scoring.ts's redactSecrets — this script runs
|
|
730
|
+
// standalone via git's post-commit, with no import access to that module.
|
|
731
|
+
function redactSecrets(content) {
|
|
732
|
+
let result = content;
|
|
733
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
734
|
+
result = result.replace(pattern, "[REDACTED]");
|
|
735
|
+
}
|
|
736
|
+
result = result.replace(HIGH_ENTROPY_RE, (match) =>
|
|
737
|
+
shannonEntropy(match) > HIGH_ENTROPY_MIN_BITS_PER_CHAR ? "[REDACTED]" : match
|
|
738
|
+
);
|
|
739
|
+
return result;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function logHookError(error) {
|
|
743
|
+
try {
|
|
744
|
+
const logPath = join(process.cwd(), ".coding-memory", "hook.log");
|
|
745
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
746
|
+
const message = error instanceof Error ? (error.stack || error.message) : String(error);
|
|
747
|
+
appendFileSync(logPath, \`[\${new Date().toISOString()}] \${message}\\n\`);
|
|
748
|
+
} catch {
|
|
749
|
+
// Logging itself failed — nothing more we can safely do without
|
|
750
|
+
// risking the commit, so give up silently.
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function runHook() {
|
|
755
|
+
|
|
668
756
|
function git(args) {
|
|
669
757
|
return execFileSync("git", args, { encoding: "utf-8" }).trim();
|
|
670
758
|
}
|
|
@@ -739,7 +827,7 @@ const commitMessage = git(["log", "-1", "--pretty=%B"]);
|
|
|
739
827
|
// save its own commit message as a new decision memory, which changes the
|
|
740
828
|
// rules-file content and triggers another sync commit, indefinitely.
|
|
741
829
|
if (commitMessage.includes("[skip ci]")) {
|
|
742
|
-
|
|
830
|
+
return;
|
|
743
831
|
}
|
|
744
832
|
|
|
745
833
|
const isFirstCommit = (() => {
|
|
@@ -789,17 +877,17 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
789
877
|
|
|
790
878
|
if (isNewFile) {
|
|
791
879
|
if (/(auth|login|session|token)/i.test(currentFile)) {
|
|
792
|
-
const content = \`new auth-related file: \${currentFile}
|
|
880
|
+
const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
|
|
793
881
|
cmSave("discovery", content);
|
|
794
882
|
savedItems.push(\`discovery: \${content}\`);
|
|
795
883
|
}
|
|
796
884
|
if (isConfigFile(currentFile)) {
|
|
797
|
-
const content = \`new config: \${currentFile}
|
|
885
|
+
const content = redactSecrets(\`new config: \${currentFile}\`);
|
|
798
886
|
cmSave("constraint", content);
|
|
799
887
|
savedItems.push(\`constraint: \${content}\`);
|
|
800
888
|
}
|
|
801
889
|
if (isSchemaFile(currentFile)) {
|
|
802
|
-
const content = \`schema change: \${currentFile}
|
|
890
|
+
const content = redactSecrets(\`schema change: \${currentFile}\`);
|
|
803
891
|
cmSave("discovery", content);
|
|
804
892
|
savedItems.push(\`discovery: \${content}\`);
|
|
805
893
|
}
|
|
@@ -836,7 +924,7 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
836
924
|
: null;
|
|
837
925
|
|
|
838
926
|
if (removedModulePath) {
|
|
839
|
-
const content = \`removed dependency: \${removedModulePath}
|
|
927
|
+
const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
|
|
840
928
|
cmSave("rejection", content);
|
|
841
929
|
savedItems.push(\`rejection: \${content}\`);
|
|
842
930
|
}
|
|
@@ -844,7 +932,7 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
844
932
|
if (isPackageJson && inDependenciesSection) {
|
|
845
933
|
const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
|
|
846
934
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
847
|
-
const content = \`removed dependency: \${pkgMatch[1]}
|
|
935
|
+
const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
|
|
848
936
|
cmSave("rejection", content);
|
|
849
937
|
savedItems.push(\`rejection: \${content}\`);
|
|
850
938
|
}
|
|
@@ -860,7 +948,7 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
860
948
|
if (base === ".env" || base === ".env.example") {
|
|
861
949
|
const envMatch = codeLine.match(/([A-Z_]{3,})=/);
|
|
862
950
|
if (envMatch) {
|
|
863
|
-
const content = \`new env var required: \${envMatch[1]}
|
|
951
|
+
const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
|
|
864
952
|
cmSave("constraint", content);
|
|
865
953
|
savedItems.push(\`constraint: \${content}\`);
|
|
866
954
|
}
|
|
@@ -870,7 +958,7 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
870
958
|
if (isPackageJson && inDependenciesSection) {
|
|
871
959
|
const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
|
|
872
960
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
873
|
-
const content = \`added dependency: \${pkgMatch[1]}
|
|
961
|
+
const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
|
|
874
962
|
cmSave("decision", content);
|
|
875
963
|
savedItems.push(\`decision: \${content}\`);
|
|
876
964
|
}
|
|
@@ -878,24 +966,55 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
878
966
|
|
|
879
967
|
if (currentFile && isSchemaFile(currentFile) && /CREATE TABLE|ALTER TABLE|DROP TABLE/i.test(codeLine)) {
|
|
880
968
|
const truncated = codeLine.trim().slice(0, 80);
|
|
881
|
-
const content = \`database change: \${truncated}
|
|
969
|
+
const content = redactSecrets(\`database change: \${truncated}\`);
|
|
882
970
|
cmSave("discovery", content);
|
|
883
971
|
savedItems.push(\`discovery: \${content}\`);
|
|
884
972
|
}
|
|
885
973
|
|
|
886
974
|
if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
|
|
887
975
|
const truncated = codeLine.trim().slice(0, 100);
|
|
888
|
-
const content = \`open issue from commit: \${truncated}
|
|
976
|
+
const content = redactSecrets(\`open issue from commit: \${truncated}\`);
|
|
889
977
|
cmSave("discovery", content);
|
|
890
978
|
savedItems.push(\`discovery: \${content}\`);
|
|
891
979
|
}
|
|
892
980
|
}
|
|
893
981
|
}
|
|
894
982
|
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
|
|
898
|
-
|
|
983
|
+
// Conventional-commit routing (handled last, after every diff-detected
|
|
984
|
+
// memory — diff analysis always runs regardless of commit message):
|
|
985
|
+
// feat/fix/perf/refactor save as a decision outright (the "type:" prefix is
|
|
986
|
+
// itself the quality signal, so the generic filter below is bypassed);
|
|
987
|
+
// docs/style/test/ci/build/chore are skipped entirely; a BREAKING CHANGE
|
|
988
|
+
// footer always saves as a constraint, independent of (and in addition to)
|
|
989
|
+
// the type-based routing above; anything that isn't a recognized
|
|
990
|
+
// conventional-commit type at all falls back to the pre-existing
|
|
991
|
+
// isQualityCommitMessage filter.
|
|
992
|
+
const CONVENTIONAL_COMMIT_RE = /^([a-zA-Z]+)(\\([^)]*\\))?!?:\\s*(.+)/;
|
|
993
|
+
const CONVENTIONAL_DECISION_TYPES = new Set(["feat", "fix", "perf", "refactor"]);
|
|
994
|
+
const CONVENTIONAL_SKIP_TYPES = new Set(["docs", "style", "test", "ci", "build", "chore"]);
|
|
995
|
+
const BREAKING_CHANGE_RE = /BREAKING CHANGE:\\s*([\\s\\S]+?)(?:\\n\\n|$)/;
|
|
996
|
+
|
|
997
|
+
const trimmedCommitMessage = commitMessage.trim();
|
|
998
|
+
const commitFirstLine = trimmedCommitMessage.split("\\n")[0];
|
|
999
|
+
const conventionalMatch = commitFirstLine.match(CONVENTIONAL_COMMIT_RE);
|
|
1000
|
+
const conventionalType = conventionalMatch ? conventionalMatch[1].toLowerCase() : null;
|
|
1001
|
+
|
|
1002
|
+
const breakingMatch = trimmedCommitMessage.match(BREAKING_CHANGE_RE);
|
|
1003
|
+
if (breakingMatch) {
|
|
1004
|
+
const breakingContent = redactSecrets(breakingMatch[1].trim());
|
|
1005
|
+
const content = \`BREAKING CHANGE: \${breakingContent}\`;
|
|
1006
|
+
cmSave("constraint", content);
|
|
1007
|
+
savedItems.push(\`constraint: \${content}\`);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
|
|
1011
|
+
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1012
|
+
cmSave("decision", trimmedMessage);
|
|
1013
|
+
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1014
|
+
} else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
|
|
1015
|
+
// docs/style/test/ci/build/chore — skip entirely, nothing saved.
|
|
1016
|
+
} else if (isQualityCommitMessage(commitMessage)) {
|
|
1017
|
+
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
899
1018
|
cmSave("decision", trimmedMessage);
|
|
900
1019
|
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
901
1020
|
}
|
|
@@ -920,10 +1039,86 @@ try {
|
|
|
920
1039
|
} catch {
|
|
921
1040
|
// Nothing to commit (rules files unchanged) — fine, ignore.
|
|
922
1041
|
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async function main() {
|
|
1045
|
+
let timeoutHandle;
|
|
1046
|
+
try {
|
|
1047
|
+
await Promise.race([
|
|
1048
|
+
runHook(),
|
|
1049
|
+
new Promise((_resolve, reject) => {
|
|
1050
|
+
timeoutHandle = setTimeout(
|
|
1051
|
+
() => reject(new Error("stackmem post-commit hook exceeded " + HOOK_TIMEOUT_MS + "ms timeout")),
|
|
1052
|
+
HOOK_TIMEOUT_MS
|
|
1053
|
+
);
|
|
1054
|
+
}),
|
|
1055
|
+
]);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
logHookError(error);
|
|
1058
|
+
} finally {
|
|
1059
|
+
clearTimeout(timeoutHandle);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
main().finally(() => process.exit(0));
|
|
923
1064
|
`;
|
|
924
1065
|
}
|
|
925
|
-
|
|
926
|
-
|
|
1066
|
+
// Installs (or re-installs) the stackmem post-commit hook at `hooksDir`. If
|
|
1067
|
+
// a post-commit hook already exists and wasn't written by stackmem, it's
|
|
1068
|
+
// preserved as a backup and chained to run before stackmem's own hook,
|
|
1069
|
+
// rather than being clobbered — see `uninstallHook` for how it's restored.
|
|
1070
|
+
function installHook(hooksDir, hookLogicPath) {
|
|
1071
|
+
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1072
|
+
const hookPath = nodePath.join(hooksDir, "post-commit");
|
|
1073
|
+
const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
|
|
1074
|
+
let chainedOriginal = false;
|
|
1075
|
+
if (nodeFs.existsSync(hookPath)) {
|
|
1076
|
+
const existingContent = nodeFs.readFileSync(hookPath, "utf-8");
|
|
1077
|
+
if (!existingContent.includes(STACKMEM_HOOK_MARKER)) {
|
|
1078
|
+
nodeFs.copyFileSync(hookPath, backupPath);
|
|
1079
|
+
try {
|
|
1080
|
+
nodeFs.chmodSync(backupPath, 0o755);
|
|
1081
|
+
}
|
|
1082
|
+
catch {
|
|
1083
|
+
// Best-effort — if the original wasn't executable either, leave it.
|
|
1084
|
+
}
|
|
1085
|
+
chainedOriginal = true;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
const chainLine = chainedOriginal
|
|
1089
|
+
? `"$(dirname "$0")/${HOOK_BACKUP_FILENAME}" "$@"\n`
|
|
1090
|
+
: "";
|
|
1091
|
+
nodeFs.writeFileSync(hookPath, `#!/bin/sh\n${STACKMEM_HOOK_MARKER}\n${chainLine}exec node "${hookLogicPath}" "$@"\n`, "utf-8");
|
|
1092
|
+
nodeFs.chmodSync(hookPath, 0o755);
|
|
1093
|
+
if (chainedOriginal) {
|
|
1094
|
+
console.log("Existing post-commit hook found — chained it to run before stackmem's hook.");
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
// Reverses `installHook`: if stackmem wrote the hook fresh, removes it; if
|
|
1098
|
+
// stackmem chained it after an existing hook, restores that original hook
|
|
1099
|
+
// in its place. Returns a description of what happened, or null if there
|
|
1100
|
+
// was no stackmem-managed hook to remove (nothing done).
|
|
1101
|
+
function uninstallHook(hooksDir) {
|
|
1102
|
+
const hookPath = nodePath.join(hooksDir, "post-commit");
|
|
1103
|
+
const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
|
|
1104
|
+
if (!nodeFs.existsSync(hookPath))
|
|
1105
|
+
return null;
|
|
1106
|
+
const content = nodeFs.readFileSync(hookPath, "utf-8");
|
|
1107
|
+
if (!content.includes(STACKMEM_HOOK_MARKER))
|
|
1108
|
+
return null;
|
|
1109
|
+
if (nodeFs.existsSync(backupPath)) {
|
|
1110
|
+
nodeFs.renameSync(backupPath, hookPath);
|
|
1111
|
+
return ".git/hooks/post-commit (restored original hook stackmem had chained)";
|
|
1112
|
+
}
|
|
1113
|
+
nodeFs.unlinkSync(hookPath);
|
|
1114
|
+
return ".git/hooks/post-commit";
|
|
1115
|
+
}
|
|
1116
|
+
// Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
|
|
1117
|
+
// at a scratch file instead of the real ~/.claude.json.
|
|
1118
|
+
function getClaudeConfigPath() {
|
|
1119
|
+
return nodePath.join(nodeOs.homedir(), ".claude.json");
|
|
1120
|
+
}
|
|
1121
|
+
function registerMcpServer(distIndexPath, claudeConfigPath = getClaudeConfigPath()) {
|
|
927
1122
|
let config = {};
|
|
928
1123
|
if (nodeFs.existsSync(claudeConfigPath)) {
|
|
929
1124
|
try {
|
|
@@ -946,6 +1141,23 @@ function registerMcpServer(distIndexPath) {
|
|
|
946
1141
|
nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
|
947
1142
|
console.log("stackmem MCP server registered with Claude Code.");
|
|
948
1143
|
}
|
|
1144
|
+
// Returns true if an entry was actually removed.
|
|
1145
|
+
function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()) {
|
|
1146
|
+
if (!nodeFs.existsSync(claudeConfigPath))
|
|
1147
|
+
return false;
|
|
1148
|
+
let config = {};
|
|
1149
|
+
try {
|
|
1150
|
+
config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
return false;
|
|
1154
|
+
}
|
|
1155
|
+
if (!config.mcpServers || !("stackmem" in config.mcpServers))
|
|
1156
|
+
return false;
|
|
1157
|
+
delete config.mcpServers.stackmem;
|
|
1158
|
+
nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
949
1161
|
const DEFAULT_GITIGNORE_CONTENT = `node_modules/
|
|
950
1162
|
dist/
|
|
951
1163
|
.env
|
|
@@ -1085,12 +1297,14 @@ function writeProjectFile(project, resolvedPath) {
|
|
|
1085
1297
|
}
|
|
1086
1298
|
// Shared by every seedMemories check below: skip the insert if this exact
|
|
1087
1299
|
// content already exists (decideSaveAction's duplicate check) so re-running
|
|
1088
|
-
// `cm init` on an already-seeded project doesn't pile up repeats.
|
|
1089
|
-
|
|
1300
|
+
// `cm init` on an already-seeded project doesn't pile up repeats. Exported,
|
|
1301
|
+
// with an injectable client, so this duplicate-prevention guarantee is
|
|
1302
|
+
// unit-testable without a live Supabase connection.
|
|
1303
|
+
export async function trySeedMemory(project, existingMemories, type, content, client = supabase) {
|
|
1090
1304
|
const decision = decideSaveAction(content, existingMemories);
|
|
1091
1305
|
if (decision.action === "duplicate")
|
|
1092
1306
|
return false;
|
|
1093
|
-
const { error } = await
|
|
1307
|
+
const { error } = await client.from("memories").insert({
|
|
1094
1308
|
project,
|
|
1095
1309
|
type,
|
|
1096
1310
|
content,
|
|
@@ -1217,9 +1431,7 @@ async function cmdInit(project, targetPath) {
|
|
|
1217
1431
|
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
1218
1432
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1219
1433
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1220
|
-
|
|
1221
|
-
nodeFs.writeFileSync(hookPath, `#!/bin/sh\nexec node "${hookLogicPath}" "$@"\n`, "utf-8");
|
|
1222
|
-
nodeFs.chmodSync(hookPath, 0o755);
|
|
1434
|
+
installHook(hooksDir, hookLogicPath);
|
|
1223
1435
|
console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
|
|
1224
1436
|
registerMcpServer(DIST_INDEX_PATH);
|
|
1225
1437
|
writeClaudeMd(project, resolvedPath);
|
|
@@ -1449,7 +1661,31 @@ async function cmdSessionEndEntry(args) {
|
|
|
1449
1661
|
});
|
|
1450
1662
|
}
|
|
1451
1663
|
// --- cm doctor ---------------------------------------------------------
|
|
1452
|
-
|
|
1664
|
+
// Re-reads the freshly (re)written config to confirm the MCP entry is
|
|
1665
|
+
// actually there now, rather than trusting registerMcpServer didn't throw.
|
|
1666
|
+
function isMcpRegistered(claudeConfigPath) {
|
|
1667
|
+
if (!nodeFs.existsSync(claudeConfigPath))
|
|
1668
|
+
return false;
|
|
1669
|
+
try {
|
|
1670
|
+
const config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
|
|
1671
|
+
return Boolean(config?.mcpServers?.stackmem);
|
|
1672
|
+
}
|
|
1673
|
+
catch {
|
|
1674
|
+
return false;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
function isHookExecutable(hookPath) {
|
|
1678
|
+
if (!nodeFs.existsSync(hookPath))
|
|
1679
|
+
return false;
|
|
1680
|
+
try {
|
|
1681
|
+
nodeFs.accessSync(hookPath, nodeFs.constants.X_OK);
|
|
1682
|
+
return true;
|
|
1683
|
+
}
|
|
1684
|
+
catch {
|
|
1685
|
+
return false;
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
async function cmdDoctor(fix) {
|
|
1453
1689
|
let issues = 0;
|
|
1454
1690
|
const project = readProjectFromFile();
|
|
1455
1691
|
// 1. Backend connection
|
|
@@ -1460,25 +1696,29 @@ async function cmdDoctor() {
|
|
|
1460
1696
|
else {
|
|
1461
1697
|
console.log("✗ Backend connection failed — check your network");
|
|
1462
1698
|
issues++;
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
const claudeConfigPath = nodePath.join(nodeOs.homedir(), ".claude.json");
|
|
1466
|
-
let mcpRegistered = false;
|
|
1467
|
-
if (nodeFs.existsSync(claudeConfigPath)) {
|
|
1468
|
-
try {
|
|
1469
|
-
const config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
|
|
1470
|
-
mcpRegistered = Boolean(config?.mcpServers?.stackmem);
|
|
1471
|
-
}
|
|
1472
|
-
catch {
|
|
1473
|
-
mcpRegistered = false;
|
|
1699
|
+
if (fix) {
|
|
1700
|
+
console.log("Could not auto-fix: backend connection — check your network and Supabase status, then re-run 'cm doctor'.");
|
|
1474
1701
|
}
|
|
1475
1702
|
}
|
|
1703
|
+
// 2. MCP server registration
|
|
1704
|
+
const claudeConfigPath = getClaudeConfigPath();
|
|
1705
|
+
const mcpRegistered = isMcpRegistered(claudeConfigPath);
|
|
1476
1706
|
if (mcpRegistered) {
|
|
1477
1707
|
console.log("✓ MCP server registered with Claude Code");
|
|
1478
1708
|
}
|
|
1479
1709
|
else {
|
|
1480
1710
|
console.log("✗ MCP server not registered — run 'cm init <project> .'");
|
|
1481
1711
|
issues++;
|
|
1712
|
+
if (fix) {
|
|
1713
|
+
registerMcpServer(DIST_INDEX_PATH, claudeConfigPath);
|
|
1714
|
+
if (isMcpRegistered(claudeConfigPath)) {
|
|
1715
|
+
console.log("Fixed: MCP server registration");
|
|
1716
|
+
issues--;
|
|
1717
|
+
}
|
|
1718
|
+
else {
|
|
1719
|
+
console.log("Could not auto-fix: MCP server registration");
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1482
1722
|
}
|
|
1483
1723
|
// 3. Project file
|
|
1484
1724
|
if (project) {
|
|
@@ -1487,6 +1727,9 @@ async function cmdDoctor() {
|
|
|
1487
1727
|
else {
|
|
1488
1728
|
console.log("✗ No .stackmem found — run 'cm init <project> .'");
|
|
1489
1729
|
issues++;
|
|
1730
|
+
if (fix) {
|
|
1731
|
+
console.log("Could not auto-fix: .stackmem file — run 'cm init <project> .'");
|
|
1732
|
+
}
|
|
1490
1733
|
}
|
|
1491
1734
|
// 4. Git hook
|
|
1492
1735
|
const gitDir = nodePath.join(process.cwd(), ".git");
|
|
@@ -1495,43 +1738,49 @@ async function cmdDoctor() {
|
|
|
1495
1738
|
issues++;
|
|
1496
1739
|
}
|
|
1497
1740
|
else {
|
|
1498
|
-
const
|
|
1499
|
-
|
|
1500
|
-
if (
|
|
1501
|
-
try {
|
|
1502
|
-
nodeFs.accessSync(hookPath, nodeFs.constants.X_OK);
|
|
1503
|
-
hookExecutable = true;
|
|
1504
|
-
}
|
|
1505
|
-
catch {
|
|
1506
|
-
hookExecutable = false;
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
if (hookExecutable) {
|
|
1741
|
+
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1742
|
+
const hookPath = nodePath.join(hooksDir, "post-commit");
|
|
1743
|
+
if (isHookExecutable(hookPath)) {
|
|
1510
1744
|
console.log("✓ Git hook installed");
|
|
1511
1745
|
}
|
|
1512
1746
|
else {
|
|
1513
1747
|
console.log("✗ Git hook not installed — run 'cm init <project> .'");
|
|
1514
1748
|
issues++;
|
|
1749
|
+
if (fix) {
|
|
1750
|
+
const codingMemoryDir = nodePath.join(process.cwd(), ".coding-memory");
|
|
1751
|
+
nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
|
|
1752
|
+
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
1753
|
+
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
1754
|
+
installHook(hooksDir, hookLogicPath);
|
|
1755
|
+
if (isHookExecutable(hookPath)) {
|
|
1756
|
+
console.log("Fixed: git hook");
|
|
1757
|
+
issues--;
|
|
1758
|
+
}
|
|
1759
|
+
else {
|
|
1760
|
+
console.log("Could not auto-fix: git hook");
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1515
1763
|
}
|
|
1516
1764
|
}
|
|
1517
1765
|
// 5. Memory count
|
|
1766
|
+
let hasMemories = false;
|
|
1518
1767
|
if (project) {
|
|
1519
1768
|
const { count, error } = await supabase
|
|
1520
1769
|
.from("memories")
|
|
1521
1770
|
.select("id", { count: "exact", head: true })
|
|
1522
1771
|
.eq("project", project)
|
|
1523
1772
|
.eq("resolved", false);
|
|
1524
|
-
|
|
1773
|
+
hasMemories = !error && (count ?? 0) > 0;
|
|
1774
|
+
if (hasMemories) {
|
|
1525
1775
|
console.log(`✓ ${count} memories stored for "${project}"`);
|
|
1526
1776
|
}
|
|
1527
|
-
else {
|
|
1528
|
-
console.log(`✗ No memories yet — make a commit to start capturing`);
|
|
1529
|
-
issues++;
|
|
1530
|
-
}
|
|
1531
1777
|
}
|
|
1532
|
-
|
|
1778
|
+
if (!hasMemories) {
|
|
1533
1779
|
console.log(`✗ No memories yet — make a commit to start capturing`);
|
|
1534
1780
|
issues++;
|
|
1781
|
+
if (fix) {
|
|
1782
|
+
console.log("Could not auto-fix: memories — make a commit to start capturing");
|
|
1783
|
+
}
|
|
1535
1784
|
}
|
|
1536
1785
|
console.log("");
|
|
1537
1786
|
if (issues === 0) {
|
|
@@ -1646,6 +1895,63 @@ async function cmdImport(project, filename) {
|
|
|
1646
1895
|
}
|
|
1647
1896
|
console.log(`Imported ${importedCount} memories from ${filename}`);
|
|
1648
1897
|
}
|
|
1898
|
+
// --- cm uninstall ------------------------------------------------------
|
|
1899
|
+
function readProjectFromPath(dirPath) {
|
|
1900
|
+
const stackmemPath = nodePath.join(dirPath, STACKMEM_FILE);
|
|
1901
|
+
if (!nodeFs.existsSync(stackmemPath))
|
|
1902
|
+
return null;
|
|
1903
|
+
const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
|
|
1904
|
+
return content.length > 0 ? content : null;
|
|
1905
|
+
}
|
|
1906
|
+
// Removes a marker-gated file (CLAUDE.md / .cursorrules / .windsurfrules)
|
|
1907
|
+
// only if it still carries the stackmem marker — a file the user has since
|
|
1908
|
+
// rewritten by hand, or never had stackmem's content to begin with, is left
|
|
1909
|
+
// alone rather than silently deleted.
|
|
1910
|
+
function removeIfMarked(filePath, marker, label) {
|
|
1911
|
+
if (!nodeFs.existsSync(filePath))
|
|
1912
|
+
return;
|
|
1913
|
+
const content = nodeFs.readFileSync(filePath, "utf-8");
|
|
1914
|
+
if (!content.includes(marker)) {
|
|
1915
|
+
console.log(`Skipped ${label} (no stackmem marker found — left as-is).`);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
nodeFs.unlinkSync(filePath);
|
|
1919
|
+
console.log(`Removed ${label}`);
|
|
1920
|
+
}
|
|
1921
|
+
async function cmdUninstall(project, targetPath) {
|
|
1922
|
+
const gitDir = nodePath.join(targetPath, ".git");
|
|
1923
|
+
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1924
|
+
const hookResult = uninstallHook(hooksDir);
|
|
1925
|
+
if (hookResult)
|
|
1926
|
+
console.log(`Removed ${hookResult}`);
|
|
1927
|
+
const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
|
|
1928
|
+
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
1929
|
+
if (nodeFs.existsSync(hookLogicPath)) {
|
|
1930
|
+
nodeFs.unlinkSync(hookLogicPath);
|
|
1931
|
+
console.log("Removed .coding-memory/post-commit-hook.mjs");
|
|
1932
|
+
}
|
|
1933
|
+
const astDbPath = nodePath.join(codingMemoryDir, "ast-index.db");
|
|
1934
|
+
if (nodeFs.existsSync(astDbPath)) {
|
|
1935
|
+
nodeFs.unlinkSync(astDbPath);
|
|
1936
|
+
console.log("Removed .coding-memory/ast-index.db");
|
|
1937
|
+
}
|
|
1938
|
+
if (nodeFs.existsSync(codingMemoryDir) && nodeFs.readdirSync(codingMemoryDir).length === 0) {
|
|
1939
|
+
nodeFs.rmdirSync(codingMemoryDir);
|
|
1940
|
+
console.log("Removed .coding-memory/ directory");
|
|
1941
|
+
}
|
|
1942
|
+
const stackmemPath = nodePath.join(targetPath, STACKMEM_FILE);
|
|
1943
|
+
if (nodeFs.existsSync(stackmemPath)) {
|
|
1944
|
+
nodeFs.unlinkSync(stackmemPath);
|
|
1945
|
+
console.log("Removed .stackmem");
|
|
1946
|
+
}
|
|
1947
|
+
removeIfMarked(nodePath.join(targetPath, "CLAUDE.md"), CLAUDE_MD_MARKER, "CLAUDE.md");
|
|
1948
|
+
removeIfMarked(nodePath.join(targetPath, ".cursorrules"), RULES_FILE_MARKER, ".cursorrules");
|
|
1949
|
+
removeIfMarked(nodePath.join(targetPath, ".windsurfrules"), RULES_FILE_MARKER, ".windsurfrules");
|
|
1950
|
+
if (unregisterMcpServer()) {
|
|
1951
|
+
console.log("Removed stackmem entry from ~/.claude.json mcpServers");
|
|
1952
|
+
}
|
|
1953
|
+
console.log(`stackmem uninstalled from ${project}.`);
|
|
1954
|
+
}
|
|
1649
1955
|
// --- main --------------------------------------------------------------
|
|
1650
1956
|
async function main() {
|
|
1651
1957
|
const [, , command, ...args] = process.argv;
|
|
@@ -1779,7 +2085,8 @@ async function main() {
|
|
|
1779
2085
|
break;
|
|
1780
2086
|
}
|
|
1781
2087
|
case "doctor": {
|
|
1782
|
-
|
|
2088
|
+
const fix = args.includes("--fix");
|
|
2089
|
+
await cmdDoctor(fix);
|
|
1783
2090
|
break;
|
|
1784
2091
|
}
|
|
1785
2092
|
case "undo": {
|
|
@@ -1807,6 +2114,31 @@ async function main() {
|
|
|
1807
2114
|
await cmdImport(project, filename);
|
|
1808
2115
|
break;
|
|
1809
2116
|
}
|
|
2117
|
+
case "uninstall": {
|
|
2118
|
+
if (args.length > 2)
|
|
2119
|
+
usage();
|
|
2120
|
+
let explicitProject = null;
|
|
2121
|
+
let targetPath = process.cwd();
|
|
2122
|
+
if (args.length === 1) {
|
|
2123
|
+
if (looksLikeAPath(args[0])) {
|
|
2124
|
+
targetPath = args[0];
|
|
2125
|
+
}
|
|
2126
|
+
else {
|
|
2127
|
+
explicitProject = args[0];
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
else if (args.length === 2) {
|
|
2131
|
+
[explicitProject, targetPath] = args;
|
|
2132
|
+
}
|
|
2133
|
+
const resolvedPath = nodePath.resolve(targetPath);
|
|
2134
|
+
const project = explicitProject ?? readProjectFromPath(resolvedPath);
|
|
2135
|
+
if (!project) {
|
|
2136
|
+
console.error(`No project specified and no .stackmem file found at ${resolvedPath}.`);
|
|
2137
|
+
process.exit(1);
|
|
2138
|
+
}
|
|
2139
|
+
await cmdUninstall(project, resolvedPath);
|
|
2140
|
+
break;
|
|
2141
|
+
}
|
|
1810
2142
|
case "help": {
|
|
1811
2143
|
cmdHelp();
|
|
1812
2144
|
break;
|
|
@@ -1815,6 +2147,24 @@ async function main() {
|
|
|
1815
2147
|
usage();
|
|
1816
2148
|
}
|
|
1817
2149
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
2150
|
+
// Guards the auto-run so importing this module (e.g. from a test file, to
|
|
2151
|
+
// reach exported helpers like trySeedMemory) doesn't also execute main()
|
|
2152
|
+
// against the test runner's own argv. realpathSync matters here: the `cm`
|
|
2153
|
+
// bin is typically a symlink (npm link, or the global install this project
|
|
2154
|
+
// itself uses), and process.argv[1] preserves the symlink path as invoked
|
|
2155
|
+
// while import.meta.url resolves to the link's real target — comparing
|
|
2156
|
+
// them raw would never match, silently skipping main() entirely.
|
|
2157
|
+
function resolveMainModulePath(argvPath) {
|
|
2158
|
+
try {
|
|
2159
|
+
return pathToFileURL(nodeFs.realpathSync(argvPath)).href;
|
|
2160
|
+
}
|
|
2161
|
+
catch {
|
|
2162
|
+
return null;
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
const isMainModule = process.argv[1] != null && import.meta.url === resolveMainModulePath(process.argv[1]);
|
|
2166
|
+
if (isMainModule) {
|
|
2167
|
+
main().catch((error) => {
|
|
2168
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
2169
|
+
});
|
|
2170
|
+
}
|