stackmem 1.0.20 → 1.0.22
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 +404 -63
- package/dist/scoring.js +74 -0
- package/dist/tests/regression.test.js +248 -0
- package/package.json +3 -2
- package/src/cli.ts +443 -60
- package/src/scoring.ts +88 -0
- package/src/tests/regression.test.ts +304 -0
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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";
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
isAuthRelatedFile,
|
|
20
20
|
isCodeFile,
|
|
21
21
|
isQualityMessage,
|
|
22
|
+
redactSecrets,
|
|
22
23
|
scoreMemoryRelevance,
|
|
23
24
|
} from "./scoring.js";
|
|
24
25
|
import { supabase } from "./storage.js";
|
|
@@ -32,6 +33,14 @@ const CLI_PATH = fileURLToPath(import.meta.url);
|
|
|
32
33
|
const DIST_CLI_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "cli.js");
|
|
33
34
|
const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "index.js");
|
|
34
35
|
|
|
36
|
+
// Written into every file/hook stackmem manages, so `cm uninstall` (and the
|
|
37
|
+
// hook installer's chaining check) can tell "ours, safe to remove/replace"
|
|
38
|
+
// apart from a user's own pre-existing file/hook with the same name.
|
|
39
|
+
const STACKMEM_HOOK_MARKER = "# stackmem-managed";
|
|
40
|
+
const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
|
|
41
|
+
const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
|
|
42
|
+
const HOOK_BACKUP_FILENAME = "post-commit.pre-stackmem";
|
|
43
|
+
|
|
35
44
|
const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
|
|
36
45
|
type MemoryType = (typeof MEMORY_TYPES)[number];
|
|
37
46
|
|
|
@@ -54,10 +63,11 @@ function usage(): never {
|
|
|
54
63
|
cm inspect [project] <memory-id>
|
|
55
64
|
cm compress [project]
|
|
56
65
|
cm init <project> <path>
|
|
66
|
+
cm uninstall [project] [path]
|
|
57
67
|
cm sync
|
|
58
68
|
cm context [--project <name>] <task>
|
|
59
69
|
cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
|
|
60
|
-
cm doctor
|
|
70
|
+
cm doctor [--fix]
|
|
61
71
|
cm undo [project]
|
|
62
72
|
cm export [filename]
|
|
63
73
|
cm import [project] <filename>
|
|
@@ -131,6 +141,17 @@ function cmdHelp() {
|
|
|
131
141
|
exist. The post-commit hook keeps them fresh afterward by
|
|
132
142
|
running 'cm sync' on every commit.
|
|
133
143
|
|
|
144
|
+
cm uninstall [project] [path]
|
|
145
|
+
Remove everything 'cm init' set up at <path> (defaults to the
|
|
146
|
+
current directory): the git post-commit hook (restoring whatever
|
|
147
|
+
hook it chained after, if any), .coding-memory/post-commit-hook.mjs,
|
|
148
|
+
.coding-memory/ast-index.db and the .coding-memory/ directory
|
|
149
|
+
itself if left empty, the .stackmem file, and CLAUDE.md /
|
|
150
|
+
.cursorrules / .windsurfrules — the last three only if they still
|
|
151
|
+
carry the stackmem marker, so hand-edited files are left alone.
|
|
152
|
+
Also removes the stackmem entry from ~/.claude.json's mcpServers.
|
|
153
|
+
[project] defaults to the .stackmem file at <path> if omitted.
|
|
154
|
+
|
|
134
155
|
cm sync
|
|
135
156
|
Regenerate .cursorrules and .windsurfrules from the project's
|
|
136
157
|
current unresolved memories (decay-ranked, capped at a 2000
|
|
@@ -154,10 +175,16 @@ function cmdHelp() {
|
|
|
154
175
|
(project, summary, errors_encountered, files_touched,
|
|
155
176
|
approaches_tried) when stdin is piped rather than a terminal.
|
|
156
177
|
|
|
157
|
-
cm doctor
|
|
178
|
+
cm doctor [--fix]
|
|
158
179
|
Check every integration point in the current directory — backend
|
|
159
180
|
connection, MCP registration, .stackmem, git hook, and memory
|
|
160
|
-
count — and report what's working and what's broken.
|
|
181
|
+
count — and report what's working and what's broken. With --fix,
|
|
182
|
+
attempt a repair after each failing check: re-register the MCP
|
|
183
|
+
server, or re-install the git hook. A failed backend connection,
|
|
184
|
+
a missing .stackmem file, or no memories yet can't be auto-fixed
|
|
185
|
+
(they need a working network, 'cm init', or a commit respectively)
|
|
186
|
+
— those print guidance instead. Reports "Fixed: <item>" or
|
|
187
|
+
"Could not auto-fix: <item>" for each failing check.
|
|
161
188
|
|
|
162
189
|
cm undo [project]
|
|
163
190
|
Delete the most recently saved unresolved memory (and any
|
|
@@ -414,6 +441,16 @@ async function cmdSave(project: string, type: string, content: string, force = f
|
|
|
414
441
|
fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
|
|
415
442
|
}
|
|
416
443
|
|
|
444
|
+
if (content.length > 500) {
|
|
445
|
+
fail(
|
|
446
|
+
`Memory content too long (${content.length} chars). Keep memories under 500 characters — they should be concise facts, not paragraphs.`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Redacted before any further use — comparisons, prompts, and storage all
|
|
451
|
+
// see the scrubbed text, so a secret never round-trips through a console
|
|
452
|
+
// prompt (confirmSupersede) or ends up persisted.
|
|
453
|
+
content = redactSecrets(content);
|
|
417
454
|
|
|
418
455
|
const { data: existingRows, error: existingError } = await supabase
|
|
419
456
|
.from("memories")
|
|
@@ -548,6 +585,9 @@ async function cmdFix(project: string, problem: string, solution: string) {
|
|
|
548
585
|
fail("Problem and solution cannot be empty.");
|
|
549
586
|
}
|
|
550
587
|
|
|
588
|
+
problem = redactSecrets(problem);
|
|
589
|
+
solution = redactSecrets(solution);
|
|
590
|
+
|
|
551
591
|
const { error } = await supabase
|
|
552
592
|
.from("execution_log")
|
|
553
593
|
.insert({ project, problem, solution, resolved: false, device_id: getDeviceId() });
|
|
@@ -775,8 +815,13 @@ async function cmdInspect(project: string, memoryId: string) {
|
|
|
775
815
|
function buildHookLogicScript(cliPath: string): string {
|
|
776
816
|
return `#!/usr/bin/env node
|
|
777
817
|
import { execFileSync } from "node:child_process";
|
|
778
|
-
import { readFileSync } from "node:fs";
|
|
779
|
-
import { join } from "node:path";
|
|
818
|
+
import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
819
|
+
import { join, dirname } from "node:path";
|
|
820
|
+
|
|
821
|
+
// The hook must never block or break a commit: a slow or throwing hook body
|
|
822
|
+
// is worse than a skipped one. runHook() does the real work; main() races
|
|
823
|
+
// it against a 2s timeout and logs (rather than surfaces) any failure.
|
|
824
|
+
const HOOK_TIMEOUT_MS = 2000;
|
|
780
825
|
|
|
781
826
|
const stackmemPath = join(process.cwd(), ".stackmem");
|
|
782
827
|
let PROJECT;
|
|
@@ -788,6 +833,58 @@ try {
|
|
|
788
833
|
|
|
789
834
|
const CLI_PATH = ${JSON.stringify(cliPath)};
|
|
790
835
|
|
|
836
|
+
const SECRET_PATTERNS = [
|
|
837
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
838
|
+
/(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
|
|
839
|
+
/bearer\\s+[a-zA-Z0-9\\-._~+/]+=*/gi,
|
|
840
|
+
/-----BEGIN [A-Z ]+PRIVATE KEY-----/g,
|
|
841
|
+
/^[A-Z_]+=.{8,}/gm,
|
|
842
|
+
];
|
|
843
|
+
|
|
844
|
+
const HIGH_ENTROPY_RE = /[a-zA-Z0-9+/]{40,}={0,2}/g;
|
|
845
|
+
const HIGH_ENTROPY_MIN_BITS_PER_CHAR = 4.5;
|
|
846
|
+
|
|
847
|
+
function shannonEntropy(text) {
|
|
848
|
+
if (text.length === 0) return 0;
|
|
849
|
+
const counts = new Map();
|
|
850
|
+
for (const char of text) {
|
|
851
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
852
|
+
}
|
|
853
|
+
let entropy = 0;
|
|
854
|
+
for (const count of counts.values()) {
|
|
855
|
+
const p = count / text.length;
|
|
856
|
+
entropy -= p * Math.log2(p);
|
|
857
|
+
}
|
|
858
|
+
return entropy;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Inlined copy of src/scoring.ts's redactSecrets — this script runs
|
|
862
|
+
// standalone via git's post-commit, with no import access to that module.
|
|
863
|
+
function redactSecrets(content) {
|
|
864
|
+
let result = content;
|
|
865
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
866
|
+
result = result.replace(pattern, "[REDACTED]");
|
|
867
|
+
}
|
|
868
|
+
result = result.replace(HIGH_ENTROPY_RE, (match) =>
|
|
869
|
+
shannonEntropy(match) > HIGH_ENTROPY_MIN_BITS_PER_CHAR ? "[REDACTED]" : match
|
|
870
|
+
);
|
|
871
|
+
return result;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function logHookError(error) {
|
|
875
|
+
try {
|
|
876
|
+
const logPath = join(process.cwd(), ".coding-memory", "hook.log");
|
|
877
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
878
|
+
const message = error instanceof Error ? (error.stack || error.message) : String(error);
|
|
879
|
+
appendFileSync(logPath, \`[\${new Date().toISOString()}] \${message}\\n\`);
|
|
880
|
+
} catch {
|
|
881
|
+
// Logging itself failed — nothing more we can safely do without
|
|
882
|
+
// risking the commit, so give up silently.
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async function runHook() {
|
|
887
|
+
|
|
791
888
|
function git(args) {
|
|
792
889
|
return execFileSync("git", args, { encoding: "utf-8" }).trim();
|
|
793
890
|
}
|
|
@@ -862,7 +959,7 @@ const commitMessage = git(["log", "-1", "--pretty=%B"]);
|
|
|
862
959
|
// save its own commit message as a new decision memory, which changes the
|
|
863
960
|
// rules-file content and triggers another sync commit, indefinitely.
|
|
864
961
|
if (commitMessage.includes("[skip ci]")) {
|
|
865
|
-
|
|
962
|
+
return;
|
|
866
963
|
}
|
|
867
964
|
|
|
868
965
|
const isFirstCommit = (() => {
|
|
@@ -912,17 +1009,17 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
912
1009
|
|
|
913
1010
|
if (isNewFile) {
|
|
914
1011
|
if (/(auth|login|session|token)/i.test(currentFile)) {
|
|
915
|
-
const content = \`new auth-related file: \${currentFile}
|
|
1012
|
+
const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
|
|
916
1013
|
cmSave("discovery", content);
|
|
917
1014
|
savedItems.push(\`discovery: \${content}\`);
|
|
918
1015
|
}
|
|
919
1016
|
if (isConfigFile(currentFile)) {
|
|
920
|
-
const content = \`new config: \${currentFile}
|
|
1017
|
+
const content = redactSecrets(\`new config: \${currentFile}\`);
|
|
921
1018
|
cmSave("constraint", content);
|
|
922
1019
|
savedItems.push(\`constraint: \${content}\`);
|
|
923
1020
|
}
|
|
924
1021
|
if (isSchemaFile(currentFile)) {
|
|
925
|
-
const content = \`schema change: \${currentFile}
|
|
1022
|
+
const content = redactSecrets(\`schema change: \${currentFile}\`);
|
|
926
1023
|
cmSave("discovery", content);
|
|
927
1024
|
savedItems.push(\`discovery: \${content}\`);
|
|
928
1025
|
}
|
|
@@ -959,7 +1056,7 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
959
1056
|
: null;
|
|
960
1057
|
|
|
961
1058
|
if (removedModulePath) {
|
|
962
|
-
const content = \`removed dependency: \${removedModulePath}
|
|
1059
|
+
const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
|
|
963
1060
|
cmSave("rejection", content);
|
|
964
1061
|
savedItems.push(\`rejection: \${content}\`);
|
|
965
1062
|
}
|
|
@@ -967,7 +1064,7 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
967
1064
|
if (isPackageJson && inDependenciesSection) {
|
|
968
1065
|
const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
|
|
969
1066
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
970
|
-
const content = \`removed dependency: \${pkgMatch[1]}
|
|
1067
|
+
const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
|
|
971
1068
|
cmSave("rejection", content);
|
|
972
1069
|
savedItems.push(\`rejection: \${content}\`);
|
|
973
1070
|
}
|
|
@@ -983,7 +1080,7 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
983
1080
|
if (base === ".env" || base === ".env.example") {
|
|
984
1081
|
const envMatch = codeLine.match(/([A-Z_]{3,})=/);
|
|
985
1082
|
if (envMatch) {
|
|
986
|
-
const content = \`new env var required: \${envMatch[1]}
|
|
1083
|
+
const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
|
|
987
1084
|
cmSave("constraint", content);
|
|
988
1085
|
savedItems.push(\`constraint: \${content}\`);
|
|
989
1086
|
}
|
|
@@ -993,7 +1090,7 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
993
1090
|
if (isPackageJson && inDependenciesSection) {
|
|
994
1091
|
const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
|
|
995
1092
|
if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
|
|
996
|
-
const content = \`added dependency: \${pkgMatch[1]}
|
|
1093
|
+
const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
|
|
997
1094
|
cmSave("decision", content);
|
|
998
1095
|
savedItems.push(\`decision: \${content}\`);
|
|
999
1096
|
}
|
|
@@ -1001,24 +1098,55 @@ for (const line of isFirstCommit ? [] : lines) {
|
|
|
1001
1098
|
|
|
1002
1099
|
if (currentFile && isSchemaFile(currentFile) && /CREATE TABLE|ALTER TABLE|DROP TABLE/i.test(codeLine)) {
|
|
1003
1100
|
const truncated = codeLine.trim().slice(0, 80);
|
|
1004
|
-
const content = \`database change: \${truncated}
|
|
1101
|
+
const content = redactSecrets(\`database change: \${truncated}\`);
|
|
1005
1102
|
cmSave("discovery", content);
|
|
1006
1103
|
savedItems.push(\`discovery: \${content}\`);
|
|
1007
1104
|
}
|
|
1008
1105
|
|
|
1009
1106
|
if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
|
|
1010
1107
|
const truncated = codeLine.trim().slice(0, 100);
|
|
1011
|
-
const content = \`open issue from commit: \${truncated}
|
|
1108
|
+
const content = redactSecrets(\`open issue from commit: \${truncated}\`);
|
|
1012
1109
|
cmSave("discovery", content);
|
|
1013
1110
|
savedItems.push(\`discovery: \${content}\`);
|
|
1014
1111
|
}
|
|
1015
1112
|
}
|
|
1016
1113
|
}
|
|
1017
1114
|
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
|
|
1021
|
-
|
|
1115
|
+
// Conventional-commit routing (handled last, after every diff-detected
|
|
1116
|
+
// memory — diff analysis always runs regardless of commit message):
|
|
1117
|
+
// feat/fix/perf/refactor save as a decision outright (the "type:" prefix is
|
|
1118
|
+
// itself the quality signal, so the generic filter below is bypassed);
|
|
1119
|
+
// docs/style/test/ci/build/chore are skipped entirely; a BREAKING CHANGE
|
|
1120
|
+
// footer always saves as a constraint, independent of (and in addition to)
|
|
1121
|
+
// the type-based routing above; anything that isn't a recognized
|
|
1122
|
+
// conventional-commit type at all falls back to the pre-existing
|
|
1123
|
+
// isQualityCommitMessage filter.
|
|
1124
|
+
const CONVENTIONAL_COMMIT_RE = /^([a-zA-Z]+)(\\([^)]*\\))?!?:\\s*(.+)/;
|
|
1125
|
+
const CONVENTIONAL_DECISION_TYPES = new Set(["feat", "fix", "perf", "refactor"]);
|
|
1126
|
+
const CONVENTIONAL_SKIP_TYPES = new Set(["docs", "style", "test", "ci", "build", "chore"]);
|
|
1127
|
+
const BREAKING_CHANGE_RE = /BREAKING CHANGE:\\s*([\\s\\S]+?)(?:\\n\\n|$)/;
|
|
1128
|
+
|
|
1129
|
+
const trimmedCommitMessage = commitMessage.trim();
|
|
1130
|
+
const commitFirstLine = trimmedCommitMessage.split("\\n")[0];
|
|
1131
|
+
const conventionalMatch = commitFirstLine.match(CONVENTIONAL_COMMIT_RE);
|
|
1132
|
+
const conventionalType = conventionalMatch ? conventionalMatch[1].toLowerCase() : null;
|
|
1133
|
+
|
|
1134
|
+
const breakingMatch = trimmedCommitMessage.match(BREAKING_CHANGE_RE);
|
|
1135
|
+
if (breakingMatch) {
|
|
1136
|
+
const breakingContent = redactSecrets(breakingMatch[1].trim());
|
|
1137
|
+
const content = \`BREAKING CHANGE: \${breakingContent}\`;
|
|
1138
|
+
cmSave("constraint", content);
|
|
1139
|
+
savedItems.push(\`constraint: \${content}\`);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
|
|
1143
|
+
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1144
|
+
cmSave("decision", trimmedMessage);
|
|
1145
|
+
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1146
|
+
} else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
|
|
1147
|
+
// docs/style/test/ci/build/chore — skip entirely, nothing saved.
|
|
1148
|
+
} else if (isQualityCommitMessage(commitMessage)) {
|
|
1149
|
+
const trimmedMessage = redactSecrets(trimmedCommitMessage);
|
|
1022
1150
|
cmSave("decision", trimmedMessage);
|
|
1023
1151
|
savedItems.push(\`decision: \${trimmedMessage}\`);
|
|
1024
1152
|
}
|
|
@@ -1043,12 +1171,100 @@ try {
|
|
|
1043
1171
|
} catch {
|
|
1044
1172
|
// Nothing to commit (rules files unchanged) — fine, ignore.
|
|
1045
1173
|
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function main() {
|
|
1177
|
+
let timeoutHandle;
|
|
1178
|
+
try {
|
|
1179
|
+
await Promise.race([
|
|
1180
|
+
runHook(),
|
|
1181
|
+
new Promise((_resolve, reject) => {
|
|
1182
|
+
timeoutHandle = setTimeout(
|
|
1183
|
+
() => reject(new Error("stackmem post-commit hook exceeded " + HOOK_TIMEOUT_MS + "ms timeout")),
|
|
1184
|
+
HOOK_TIMEOUT_MS
|
|
1185
|
+
);
|
|
1186
|
+
}),
|
|
1187
|
+
]);
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
logHookError(error);
|
|
1190
|
+
} finally {
|
|
1191
|
+
clearTimeout(timeoutHandle);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
main().finally(() => process.exit(0));
|
|
1046
1196
|
`;
|
|
1047
1197
|
}
|
|
1048
1198
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1199
|
+
// Installs (or re-installs) the stackmem post-commit hook at `hooksDir`. If
|
|
1200
|
+
// a post-commit hook already exists and wasn't written by stackmem, it's
|
|
1201
|
+
// preserved as a backup and chained to run before stackmem's own hook,
|
|
1202
|
+
// rather than being clobbered — see `uninstallHook` for how it's restored.
|
|
1203
|
+
function installHook(hooksDir: string, hookLogicPath: string): void {
|
|
1204
|
+
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1205
|
+
|
|
1206
|
+
const hookPath = nodePath.join(hooksDir, "post-commit");
|
|
1207
|
+
const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
|
|
1208
|
+
|
|
1209
|
+
let chainedOriginal = false;
|
|
1210
|
+
if (nodeFs.existsSync(hookPath)) {
|
|
1211
|
+
const existingContent = nodeFs.readFileSync(hookPath, "utf-8");
|
|
1212
|
+
if (!existingContent.includes(STACKMEM_HOOK_MARKER)) {
|
|
1213
|
+
nodeFs.copyFileSync(hookPath, backupPath);
|
|
1214
|
+
try {
|
|
1215
|
+
nodeFs.chmodSync(backupPath, 0o755);
|
|
1216
|
+
} catch {
|
|
1217
|
+
// Best-effort — if the original wasn't executable either, leave it.
|
|
1218
|
+
}
|
|
1219
|
+
chainedOriginal = true;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const chainLine = chainedOriginal
|
|
1224
|
+
? `"$(dirname "$0")/${HOOK_BACKUP_FILENAME}" "$@"\n`
|
|
1225
|
+
: "";
|
|
1226
|
+
|
|
1227
|
+
nodeFs.writeFileSync(
|
|
1228
|
+
hookPath,
|
|
1229
|
+
`#!/bin/sh\n${STACKMEM_HOOK_MARKER}\n${chainLine}exec node "${hookLogicPath}" "$@"\n`,
|
|
1230
|
+
"utf-8"
|
|
1231
|
+
);
|
|
1232
|
+
nodeFs.chmodSync(hookPath, 0o755);
|
|
1233
|
+
|
|
1234
|
+
if (chainedOriginal) {
|
|
1235
|
+
console.log("Existing post-commit hook found — chained it to run before stackmem's hook.");
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// Reverses `installHook`: if stackmem wrote the hook fresh, removes it; if
|
|
1240
|
+
// stackmem chained it after an existing hook, restores that original hook
|
|
1241
|
+
// in its place. Returns a description of what happened, or null if there
|
|
1242
|
+
// was no stackmem-managed hook to remove (nothing done).
|
|
1243
|
+
function uninstallHook(hooksDir: string): string | null {
|
|
1244
|
+
const hookPath = nodePath.join(hooksDir, "post-commit");
|
|
1245
|
+
const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
|
|
1246
|
+
|
|
1247
|
+
if (!nodeFs.existsSync(hookPath)) return null;
|
|
1248
|
+
|
|
1249
|
+
const content = nodeFs.readFileSync(hookPath, "utf-8");
|
|
1250
|
+
if (!content.includes(STACKMEM_HOOK_MARKER)) return null;
|
|
1251
|
+
|
|
1252
|
+
if (nodeFs.existsSync(backupPath)) {
|
|
1253
|
+
nodeFs.renameSync(backupPath, hookPath);
|
|
1254
|
+
return ".git/hooks/post-commit (restored original hook stackmem had chained)";
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
nodeFs.unlinkSync(hookPath);
|
|
1258
|
+
return ".git/hooks/post-commit";
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
|
|
1262
|
+
// at a scratch file instead of the real ~/.claude.json.
|
|
1263
|
+
function getClaudeConfigPath(): string {
|
|
1264
|
+
return nodePath.join(nodeOs.homedir(), ".claude.json");
|
|
1265
|
+
}
|
|
1051
1266
|
|
|
1267
|
+
function registerMcpServer(distIndexPath: string, claudeConfigPath = getClaudeConfigPath()): void {
|
|
1052
1268
|
let config: { mcpServers?: Record<string, unknown> } = {};
|
|
1053
1269
|
if (nodeFs.existsSync(claudeConfigPath)) {
|
|
1054
1270
|
try {
|
|
@@ -1075,6 +1291,24 @@ function registerMcpServer(distIndexPath: string): void {
|
|
|
1075
1291
|
console.log("stackmem MCP server registered with Claude Code.");
|
|
1076
1292
|
}
|
|
1077
1293
|
|
|
1294
|
+
// Returns true if an entry was actually removed.
|
|
1295
|
+
function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()): boolean {
|
|
1296
|
+
if (!nodeFs.existsSync(claudeConfigPath)) return false;
|
|
1297
|
+
|
|
1298
|
+
let config: { mcpServers?: Record<string, unknown> } = {};
|
|
1299
|
+
try {
|
|
1300
|
+
config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
|
|
1301
|
+
} catch {
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (!config.mcpServers || !("stackmem" in config.mcpServers)) return false;
|
|
1306
|
+
|
|
1307
|
+
delete config.mcpServers.stackmem;
|
|
1308
|
+
nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
|
1309
|
+
return true;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1078
1312
|
const DEFAULT_GITIGNORE_CONTENT = `node_modules/
|
|
1079
1313
|
dist/
|
|
1080
1314
|
.env
|
|
@@ -1237,19 +1471,28 @@ function writeProjectFile(project: string, resolvedPath: string): void {
|
|
|
1237
1471
|
nodeFs.writeFileSync(stackmemPath, `${project}\n`, "utf-8");
|
|
1238
1472
|
}
|
|
1239
1473
|
|
|
1474
|
+
// Minimal shape trySeedMemory needs from a Supabase client — narrow enough
|
|
1475
|
+
// that tests can pass a mock without satisfying the full SupabaseClient type.
|
|
1476
|
+
interface MemoryInsertClient {
|
|
1477
|
+
from(table: string): { insert(row: unknown): PromiseLike<{ error: unknown }> };
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1240
1480
|
// Shared by every seedMemories check below: skip the insert if this exact
|
|
1241
1481
|
// content already exists (decideSaveAction's duplicate check) so re-running
|
|
1242
|
-
// `cm init` on an already-seeded project doesn't pile up repeats.
|
|
1243
|
-
|
|
1482
|
+
// `cm init` on an already-seeded project doesn't pile up repeats. Exported,
|
|
1483
|
+
// with an injectable client, so this duplicate-prevention guarantee is
|
|
1484
|
+
// unit-testable without a live Supabase connection.
|
|
1485
|
+
export async function trySeedMemory(
|
|
1244
1486
|
project: string,
|
|
1245
1487
|
existingMemories: { id: string; content: string }[],
|
|
1246
1488
|
type: MemoryType,
|
|
1247
|
-
content: string
|
|
1489
|
+
content: string,
|
|
1490
|
+
client: MemoryInsertClient = supabase
|
|
1248
1491
|
): Promise<boolean> {
|
|
1249
1492
|
const decision = decideSaveAction(content, existingMemories);
|
|
1250
1493
|
if (decision.action === "duplicate") return false;
|
|
1251
1494
|
|
|
1252
|
-
const { error } = await
|
|
1495
|
+
const { error } = await client.from("memories").insert({
|
|
1253
1496
|
project,
|
|
1254
1497
|
type,
|
|
1255
1498
|
content,
|
|
@@ -1388,9 +1631,7 @@ async function cmdInit(project: string, targetPath: string) {
|
|
|
1388
1631
|
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1389
1632
|
nodeFs.mkdirSync(hooksDir, { recursive: true });
|
|
1390
1633
|
|
|
1391
|
-
|
|
1392
|
-
nodeFs.writeFileSync(hookPath, `#!/bin/sh\nexec node "${hookLogicPath}" "$@"\n`, "utf-8");
|
|
1393
|
-
nodeFs.chmodSync(hookPath, 0o755);
|
|
1634
|
+
installHook(hooksDir, hookLogicPath);
|
|
1394
1635
|
|
|
1395
1636
|
console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
|
|
1396
1637
|
|
|
@@ -1665,7 +1906,29 @@ async function cmdSessionEndEntry(args: string[]) {
|
|
|
1665
1906
|
|
|
1666
1907
|
// --- cm doctor ---------------------------------------------------------
|
|
1667
1908
|
|
|
1668
|
-
|
|
1909
|
+
// Re-reads the freshly (re)written config to confirm the MCP entry is
|
|
1910
|
+
// actually there now, rather than trusting registerMcpServer didn't throw.
|
|
1911
|
+
function isMcpRegistered(claudeConfigPath: string): boolean {
|
|
1912
|
+
if (!nodeFs.existsSync(claudeConfigPath)) return false;
|
|
1913
|
+
try {
|
|
1914
|
+
const config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
|
|
1915
|
+
return Boolean(config?.mcpServers?.stackmem);
|
|
1916
|
+
} catch {
|
|
1917
|
+
return false;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function isHookExecutable(hookPath: string): boolean {
|
|
1922
|
+
if (!nodeFs.existsSync(hookPath)) return false;
|
|
1923
|
+
try {
|
|
1924
|
+
nodeFs.accessSync(hookPath, nodeFs.constants.X_OK);
|
|
1925
|
+
return true;
|
|
1926
|
+
} catch {
|
|
1927
|
+
return false;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
async function cmdDoctor(fix: boolean) {
|
|
1669
1932
|
let issues = 0;
|
|
1670
1933
|
const project = readProjectFromFile();
|
|
1671
1934
|
|
|
@@ -1676,24 +1939,30 @@ async function cmdDoctor() {
|
|
|
1676
1939
|
} else {
|
|
1677
1940
|
console.log("✗ Backend connection failed — check your network");
|
|
1678
1941
|
issues++;
|
|
1942
|
+
if (fix) {
|
|
1943
|
+
console.log(
|
|
1944
|
+
"Could not auto-fix: backend connection — check your network and Supabase status, then re-run 'cm doctor'."
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1679
1947
|
}
|
|
1680
1948
|
|
|
1681
1949
|
// 2. MCP server registration
|
|
1682
|
-
const claudeConfigPath =
|
|
1683
|
-
|
|
1684
|
-
if (nodeFs.existsSync(claudeConfigPath)) {
|
|
1685
|
-
try {
|
|
1686
|
-
const config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
|
|
1687
|
-
mcpRegistered = Boolean(config?.mcpServers?.stackmem);
|
|
1688
|
-
} catch {
|
|
1689
|
-
mcpRegistered = false;
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1950
|
+
const claudeConfigPath = getClaudeConfigPath();
|
|
1951
|
+
const mcpRegistered = isMcpRegistered(claudeConfigPath);
|
|
1692
1952
|
if (mcpRegistered) {
|
|
1693
1953
|
console.log("✓ MCP server registered with Claude Code");
|
|
1694
1954
|
} else {
|
|
1695
1955
|
console.log("✗ MCP server not registered — run 'cm init <project> .'");
|
|
1696
1956
|
issues++;
|
|
1957
|
+
if (fix) {
|
|
1958
|
+
registerMcpServer(DIST_INDEX_PATH, claudeConfigPath);
|
|
1959
|
+
if (isMcpRegistered(claudeConfigPath)) {
|
|
1960
|
+
console.log("Fixed: MCP server registration");
|
|
1961
|
+
issues--;
|
|
1962
|
+
} else {
|
|
1963
|
+
console.log("Could not auto-fix: MCP server registration");
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1697
1966
|
}
|
|
1698
1967
|
|
|
1699
1968
|
// 3. Project file
|
|
@@ -1702,6 +1971,9 @@ async function cmdDoctor() {
|
|
|
1702
1971
|
} else {
|
|
1703
1972
|
console.log("✗ No .stackmem found — run 'cm init <project> .'");
|
|
1704
1973
|
issues++;
|
|
1974
|
+
if (fix) {
|
|
1975
|
+
console.log("Could not auto-fix: .stackmem file — run 'cm init <project> .'");
|
|
1976
|
+
}
|
|
1705
1977
|
}
|
|
1706
1978
|
|
|
1707
1979
|
// 4. Git hook
|
|
@@ -1710,25 +1982,33 @@ async function cmdDoctor() {
|
|
|
1710
1982
|
console.log("✗ No git repo found in current directory");
|
|
1711
1983
|
issues++;
|
|
1712
1984
|
} else {
|
|
1713
|
-
const
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
nodeFs.accessSync(hookPath, nodeFs.constants.X_OK);
|
|
1718
|
-
hookExecutable = true;
|
|
1719
|
-
} catch {
|
|
1720
|
-
hookExecutable = false;
|
|
1721
|
-
}
|
|
1722
|
-
}
|
|
1723
|
-
if (hookExecutable) {
|
|
1985
|
+
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
1986
|
+
const hookPath = nodePath.join(hooksDir, "post-commit");
|
|
1987
|
+
|
|
1988
|
+
if (isHookExecutable(hookPath)) {
|
|
1724
1989
|
console.log("✓ Git hook installed");
|
|
1725
1990
|
} else {
|
|
1726
1991
|
console.log("✗ Git hook not installed — run 'cm init <project> .'");
|
|
1727
1992
|
issues++;
|
|
1993
|
+
if (fix) {
|
|
1994
|
+
const codingMemoryDir = nodePath.join(process.cwd(), ".coding-memory");
|
|
1995
|
+
nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
|
|
1996
|
+
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
1997
|
+
nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
|
|
1998
|
+
installHook(hooksDir, hookLogicPath);
|
|
1999
|
+
|
|
2000
|
+
if (isHookExecutable(hookPath)) {
|
|
2001
|
+
console.log("Fixed: git hook");
|
|
2002
|
+
issues--;
|
|
2003
|
+
} else {
|
|
2004
|
+
console.log("Could not auto-fix: git hook");
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
1728
2007
|
}
|
|
1729
2008
|
}
|
|
1730
2009
|
|
|
1731
2010
|
// 5. Memory count
|
|
2011
|
+
let hasMemories = false;
|
|
1732
2012
|
if (project) {
|
|
1733
2013
|
const { count, error } = await supabase
|
|
1734
2014
|
.from("memories")
|
|
@@ -1736,15 +2016,17 @@ async function cmdDoctor() {
|
|
|
1736
2016
|
.eq("project", project)
|
|
1737
2017
|
.eq("resolved", false);
|
|
1738
2018
|
|
|
1739
|
-
|
|
2019
|
+
hasMemories = !error && (count ?? 0) > 0;
|
|
2020
|
+
if (hasMemories) {
|
|
1740
2021
|
console.log(`✓ ${count} memories stored for "${project}"`);
|
|
1741
|
-
} else {
|
|
1742
|
-
console.log(`✗ No memories yet — make a commit to start capturing`);
|
|
1743
|
-
issues++;
|
|
1744
2022
|
}
|
|
1745
|
-
}
|
|
2023
|
+
}
|
|
2024
|
+
if (!hasMemories) {
|
|
1746
2025
|
console.log(`✗ No memories yet — make a commit to start capturing`);
|
|
1747
2026
|
issues++;
|
|
2027
|
+
if (fix) {
|
|
2028
|
+
console.log("Could not auto-fix: memories — make a commit to start capturing");
|
|
2029
|
+
}
|
|
1748
2030
|
}
|
|
1749
2031
|
|
|
1750
2032
|
console.log("");
|
|
@@ -1880,6 +2162,75 @@ async function cmdImport(project: string, filename: string) {
|
|
|
1880
2162
|
console.log(`Imported ${importedCount} memories from ${filename}`);
|
|
1881
2163
|
}
|
|
1882
2164
|
|
|
2165
|
+
// --- cm uninstall ------------------------------------------------------
|
|
2166
|
+
|
|
2167
|
+
function readProjectFromPath(dirPath: string): string | null {
|
|
2168
|
+
const stackmemPath = nodePath.join(dirPath, STACKMEM_FILE);
|
|
2169
|
+
if (!nodeFs.existsSync(stackmemPath)) return null;
|
|
2170
|
+
const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
|
|
2171
|
+
return content.length > 0 ? content : null;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// Removes a marker-gated file (CLAUDE.md / .cursorrules / .windsurfrules)
|
|
2175
|
+
// only if it still carries the stackmem marker — a file the user has since
|
|
2176
|
+
// rewritten by hand, or never had stackmem's content to begin with, is left
|
|
2177
|
+
// alone rather than silently deleted.
|
|
2178
|
+
function removeIfMarked(filePath: string, marker: string, label: string): void {
|
|
2179
|
+
if (!nodeFs.existsSync(filePath)) return;
|
|
2180
|
+
|
|
2181
|
+
const content = nodeFs.readFileSync(filePath, "utf-8");
|
|
2182
|
+
if (!content.includes(marker)) {
|
|
2183
|
+
console.log(`Skipped ${label} (no stackmem marker found — left as-is).`);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
nodeFs.unlinkSync(filePath);
|
|
2188
|
+
console.log(`Removed ${label}`);
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
async function cmdUninstall(project: string, targetPath: string) {
|
|
2192
|
+
const gitDir = nodePath.join(targetPath, ".git");
|
|
2193
|
+
const hooksDir = nodePath.join(gitDir, "hooks");
|
|
2194
|
+
|
|
2195
|
+
const hookResult = uninstallHook(hooksDir);
|
|
2196
|
+
if (hookResult) console.log(`Removed ${hookResult}`);
|
|
2197
|
+
|
|
2198
|
+
const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
|
|
2199
|
+
|
|
2200
|
+
const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
|
|
2201
|
+
if (nodeFs.existsSync(hookLogicPath)) {
|
|
2202
|
+
nodeFs.unlinkSync(hookLogicPath);
|
|
2203
|
+
console.log("Removed .coding-memory/post-commit-hook.mjs");
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
const astDbPath = nodePath.join(codingMemoryDir, "ast-index.db");
|
|
2207
|
+
if (nodeFs.existsSync(astDbPath)) {
|
|
2208
|
+
nodeFs.unlinkSync(astDbPath);
|
|
2209
|
+
console.log("Removed .coding-memory/ast-index.db");
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
if (nodeFs.existsSync(codingMemoryDir) && nodeFs.readdirSync(codingMemoryDir).length === 0) {
|
|
2213
|
+
nodeFs.rmdirSync(codingMemoryDir);
|
|
2214
|
+
console.log("Removed .coding-memory/ directory");
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
const stackmemPath = nodePath.join(targetPath, STACKMEM_FILE);
|
|
2218
|
+
if (nodeFs.existsSync(stackmemPath)) {
|
|
2219
|
+
nodeFs.unlinkSync(stackmemPath);
|
|
2220
|
+
console.log("Removed .stackmem");
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
removeIfMarked(nodePath.join(targetPath, "CLAUDE.md"), CLAUDE_MD_MARKER, "CLAUDE.md");
|
|
2224
|
+
removeIfMarked(nodePath.join(targetPath, ".cursorrules"), RULES_FILE_MARKER, ".cursorrules");
|
|
2225
|
+
removeIfMarked(nodePath.join(targetPath, ".windsurfrules"), RULES_FILE_MARKER, ".windsurfrules");
|
|
2226
|
+
|
|
2227
|
+
if (unregisterMcpServer()) {
|
|
2228
|
+
console.log("Removed stackmem entry from ~/.claude.json mcpServers");
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
console.log(`stackmem uninstalled from ${project}.`);
|
|
2232
|
+
}
|
|
2233
|
+
|
|
1883
2234
|
// --- main --------------------------------------------------------------
|
|
1884
2235
|
|
|
1885
2236
|
async function main() {
|
|
@@ -2005,7 +2356,8 @@ async function main() {
|
|
|
2005
2356
|
break;
|
|
2006
2357
|
}
|
|
2007
2358
|
case "doctor": {
|
|
2008
|
-
|
|
2359
|
+
const fix = args.includes("--fix");
|
|
2360
|
+
await cmdDoctor(fix);
|
|
2009
2361
|
break;
|
|
2010
2362
|
}
|
|
2011
2363
|
case "undo": {
|
|
@@ -2030,6 +2382,31 @@ async function main() {
|
|
|
2030
2382
|
await cmdImport(project, filename);
|
|
2031
2383
|
break;
|
|
2032
2384
|
}
|
|
2385
|
+
case "uninstall": {
|
|
2386
|
+
if (args.length > 2) usage();
|
|
2387
|
+
|
|
2388
|
+
let explicitProject: string | null = null;
|
|
2389
|
+
let targetPath = process.cwd();
|
|
2390
|
+
if (args.length === 1) {
|
|
2391
|
+
if (looksLikeAPath(args[0])) {
|
|
2392
|
+
targetPath = args[0];
|
|
2393
|
+
} else {
|
|
2394
|
+
explicitProject = args[0];
|
|
2395
|
+
}
|
|
2396
|
+
} else if (args.length === 2) {
|
|
2397
|
+
[explicitProject, targetPath] = args;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
const resolvedPath = nodePath.resolve(targetPath);
|
|
2401
|
+
const project = explicitProject ?? readProjectFromPath(resolvedPath);
|
|
2402
|
+
if (!project) {
|
|
2403
|
+
console.error(`No project specified and no .stackmem file found at ${resolvedPath}.`);
|
|
2404
|
+
process.exit(1);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
await cmdUninstall(project, resolvedPath);
|
|
2408
|
+
break;
|
|
2409
|
+
}
|
|
2033
2410
|
case "help": {
|
|
2034
2411
|
cmdHelp();
|
|
2035
2412
|
break;
|
|
@@ -2039,6 +2416,12 @@ async function main() {
|
|
|
2039
2416
|
}
|
|
2040
2417
|
}
|
|
2041
2418
|
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2419
|
+
// Guards the auto-run so importing this module (e.g. from a test file, to
|
|
2420
|
+
// reach exported helpers like trySeedMemory) doesn't also execute main()
|
|
2421
|
+
// against the test runner's own argv.
|
|
2422
|
+
const isMainModule = process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
2423
|
+
if (isMainModule) {
|
|
2424
|
+
main().catch((error) => {
|
|
2425
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
2426
|
+
});
|
|
2427
|
+
}
|