@malindar/whyline 0.1.0
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/.claude/settings.local.json +33 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +37 -0
- package/.prettierrc.json +7 -0
- package/CLAUDE.md +74 -0
- package/LICENSE +21 -0
- package/README.md +359 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +125 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/delete.d.ts +3 -0
- package/dist/commands/delete.js +42 -0
- package/dist/commands/delete.js.map +1 -0
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +111 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/edit.d.ts +1 -0
- package/dist/commands/edit.js +78 -0
- package/dist/commands/edit.js.map +1 -0
- package/dist/commands/export.d.ts +8 -0
- package/dist/commands/export.js +90 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/import.d.ts +1 -0
- package/dist/commands/import.js +110 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +23 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install-claude.d.ts +3 -0
- package/dist/commands/install-claude.js +180 -0
- package/dist/commands/install-claude.js.map +1 -0
- package/dist/commands/list.d.ts +4 -0
- package/dist/commands/list.js +35 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/mcp.d.ts +1 -0
- package/dist/commands/mcp.js +10 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/save.d.ts +4 -0
- package/dist/commands/save.js +74 -0
- package/dist/commands/save.js.map +1 -0
- package/dist/commands/search.d.ts +7 -0
- package/dist/commands/search.js +46 -0
- package/dist/commands/search.js.map +1 -0
- package/dist/commands/show.d.ts +3 -0
- package/dist/commands/show.js +30 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/stats.d.ts +1 -0
- package/dist/commands/stats.js +27 -0
- package/dist/commands/stats.js.map +1 -0
- package/dist/commands/summarize.d.ts +3 -0
- package/dist/commands/summarize.js +140 -0
- package/dist/commands/summarize.js.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +17 -0
- package/dist/config.js.map +1 -0
- package/dist/db/connection.d.ts +2 -0
- package/dist/db/connection.js +8 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.js +19 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema.d.ts +5 -0
- package/dist/db/schema.js +64 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/git/diff.d.ts +2 -0
- package/dist/git/diff.js +45 -0
- package/dist/git/diff.js.map +1 -0
- package/dist/git/git.d.ts +3 -0
- package/dist/git/git.js +25 -0
- package/dist/git/git.js.map +1 -0
- package/dist/git/repoId.d.ts +3 -0
- package/dist/git/repoId.js +49 -0
- package/dist/git/repoId.js.map +1 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +296 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +119 -0
- package/dist/mcp/tools.js +43 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/memory/parseSummary.d.ts +14 -0
- package/dist/memory/parseSummary.js +53 -0
- package/dist/memory/parseSummary.js.map +1 -0
- package/dist/memory/qualityCheck.d.ts +13 -0
- package/dist/memory/qualityCheck.js +78 -0
- package/dist/memory/qualityCheck.js.map +1 -0
- package/dist/memory/redactSecrets.d.ts +7 -0
- package/dist/memory/redactSecrets.js +29 -0
- package/dist/memory/redactSecrets.js.map +1 -0
- package/dist/memory/repoContext.d.ts +2 -0
- package/dist/memory/repoContext.js +23 -0
- package/dist/memory/repoContext.js.map +1 -0
- package/dist/memory/saveMemory.d.ts +40 -0
- package/dist/memory/saveMemory.js +223 -0
- package/dist/memory/saveMemory.js.map +1 -0
- package/dist/memory/searchMemory.d.ts +17 -0
- package/dist/memory/searchMemory.js +122 -0
- package/dist/memory/searchMemory.js.map +1 -0
- package/dist/memory/types.d.ts +48 -0
- package/dist/memory/types.js +2 -0
- package/dist/memory/types.js.map +1 -0
- package/dist/output/format.d.ts +3 -0
- package/dist/output/format.js +43 -0
- package/dist/output/format.js.map +1 -0
- package/docs/architecture.md +387 -0
- package/docs/ec6ab3bf-60cf-4629-ad9e-3048e8e3c43a.png +0 -0
- package/docs/logo.png +0 -0
- package/eslint.config.js +16 -0
- package/how-to-run/01-install.md +69 -0
- package/how-to-run/02-wire-up-your-repo.md +80 -0
- package/how-to-run/03-test-it-manually.md +91 -0
- package/how-to-run/04-test-with-claude-code.md +70 -0
- package/how-to-run/CLAUDE.md.template +72 -0
- package/how-to-run/README.md +49 -0
- package/package.json +60 -0
- package/src/cli.ts +142 -0
- package/src/commands/delete.ts +47 -0
- package/src/commands/doctor.ts +128 -0
- package/src/commands/edit.ts +80 -0
- package/src/commands/export.ts +95 -0
- package/src/commands/import.ts +119 -0
- package/src/commands/init.ts +31 -0
- package/src/commands/install-claude.ts +203 -0
- package/src/commands/list.ts +41 -0
- package/src/commands/mcp.ts +12 -0
- package/src/commands/save.ts +85 -0
- package/src/commands/search.ts +56 -0
- package/src/commands/show.ts +37 -0
- package/src/commands/stats.ts +31 -0
- package/src/commands/summarize.ts +183 -0
- package/src/config.ts +26 -0
- package/src/db/connection.ts +8 -0
- package/src/db/migrations.ts +26 -0
- package/src/db/schema.ts +68 -0
- package/src/git/diff.ts +43 -0
- package/src/git/git.ts +25 -0
- package/src/git/repoId.ts +49 -0
- package/src/hooks/post-commit.sample.sh +9 -0
- package/src/mcp/server.ts +326 -0
- package/src/mcp/tools.ts +53 -0
- package/src/memory/parseSummary.ts +72 -0
- package/src/memory/qualityCheck.ts +102 -0
- package/src/memory/redactSecrets.ts +32 -0
- package/src/memory/repoContext.ts +25 -0
- package/src/memory/saveMemory.ts +369 -0
- package/src/memory/searchMemory.ts +153 -0
- package/src/memory/types.ts +57 -0
- package/src/output/format.ts +44 -0
- package/src/skill/SKILL.md +95 -0
- package/tests/cliV02.test.ts +213 -0
- package/tests/doctor.test.ts +253 -0
- package/tests/exportImport.test.ts +248 -0
- package/tests/fileRename.test.ts +156 -0
- package/tests/gitHelpers.test.ts +94 -0
- package/tests/init.test.ts +93 -0
- package/tests/installClaude.test.ts +157 -0
- package/tests/parseSummary.test.ts +111 -0
- package/tests/qualityCheck.test.ts +182 -0
- package/tests/redactSecrets.test.ts +75 -0
- package/tests/saveMemory.test.ts +196 -0
- package/tests/searchFilters.test.ts +139 -0
- package/tests/searchMemory.test.ts +273 -0
- package/tests/stale.test.ts +47 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Coding Memory Skill
|
|
2
|
+
|
|
3
|
+
Use this skill when assisting with software development in a git repository.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
Preserve and retrieve engineering reasoning across AI coding sessions.
|
|
8
|
+
|
|
9
|
+
## Before modifying code
|
|
10
|
+
|
|
11
|
+
**The very first action — before reading any file, before asking any question — is always a memory search. No exceptions.**
|
|
12
|
+
|
|
13
|
+
- If the task is clearly described: call `search_coding_memory` with:
|
|
14
|
+
- `repoPath` — the absolute path to the git repository
|
|
15
|
+
- `query` — the task description
|
|
16
|
+
- `files` — likely file paths to be modified
|
|
17
|
+
|
|
18
|
+
- If the task is vague or just starting out: call `get_recent_memories` with:
|
|
19
|
+
- `repoPath` — the absolute path to the git repository
|
|
20
|
+
- `limit` — 5
|
|
21
|
+
|
|
22
|
+
**If memories come back**, you MUST:
|
|
23
|
+
1. STOP. Do not read any file yet.
|
|
24
|
+
2. Quote the memory to the user verbatim: _"I found a previous memory about this: [decision + reason]. Before I proceed — what's the reason for changing it now?"_
|
|
25
|
+
3. If the memory has `isStale: true`, add: _"Note: this memory is over 90 days old — verify it still applies before treating it as current."_
|
|
26
|
+
4. Wait for the user to respond before doing anything else.
|
|
27
|
+
|
|
28
|
+
**If no memories come back**, say "No past memories found for this area" and then proceed normally.
|
|
29
|
+
|
|
30
|
+
Treat memories as historical context — they explain past decisions, not current truth.
|
|
31
|
+
|
|
32
|
+
## While coding
|
|
33
|
+
|
|
34
|
+
Preserve prior decisions unless the user explicitly wants to change them.
|
|
35
|
+
|
|
36
|
+
Pay attention to:
|
|
37
|
+
|
|
38
|
+
- rejected alternatives
|
|
39
|
+
- known risks
|
|
40
|
+
- follow-up tasks
|
|
41
|
+
- migration constraints
|
|
42
|
+
- compatibility concerns
|
|
43
|
+
- previous decisions around the same files
|
|
44
|
+
|
|
45
|
+
## Before committing
|
|
46
|
+
|
|
47
|
+
Summarize the coding session into a memory capsule:
|
|
48
|
+
|
|
49
|
+
- goal / intent
|
|
50
|
+
- files changed
|
|
51
|
+
- decision
|
|
52
|
+
- why
|
|
53
|
+
- alternatives rejected
|
|
54
|
+
- risks
|
|
55
|
+
- follow-ups
|
|
56
|
+
- tags
|
|
57
|
+
|
|
58
|
+
Show the summary to the user before saving:
|
|
59
|
+
_"Here's what I'm saving as a coding memory — let me know if you want to add or correct anything:"_
|
|
60
|
+
|
|
61
|
+
Wait for the user to respond. If they add or correct something, apply it. Then call `save_coding_memory`.
|
|
62
|
+
|
|
63
|
+
## Memory quality rules
|
|
64
|
+
|
|
65
|
+
Only save memories that would genuinely help a future session. Good memory:
|
|
66
|
+
- Explains a non-obvious decision
|
|
67
|
+
- Warns about a real risk
|
|
68
|
+
- Records a rejected alternative that someone will try again
|
|
69
|
+
|
|
70
|
+
Do not save:
|
|
71
|
+
|
|
72
|
+
- Routine refactors with no tradeoffs
|
|
73
|
+
- Things obvious from reading the code
|
|
74
|
+
- Secrets, access tokens, or private credentials
|
|
75
|
+
- Temporary debugging dead ends
|
|
76
|
+
|
|
77
|
+
Prefer this format:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"intent": "Add optimistic comment rendering",
|
|
82
|
+
"decision": "Render comments immediately and reconcile after server ack",
|
|
83
|
+
"why": "Server-confirmed rendering made the UI feel slow",
|
|
84
|
+
"alternativesRejected": [
|
|
85
|
+
"Wait for server confirmation before rendering"
|
|
86
|
+
],
|
|
87
|
+
"risks": [
|
|
88
|
+
"Duplicate comments during reconnect"
|
|
89
|
+
],
|
|
90
|
+
"followUps": [
|
|
91
|
+
"Add dedupe reconciliation tests"
|
|
92
|
+
],
|
|
93
|
+
"tags": ["comments", "sync", "optimistic-ui"]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { openDb } from "../src/db/connection.js";
|
|
3
|
+
import { runMigrations } from "../src/db/migrations.js";
|
|
4
|
+
import {
|
|
5
|
+
saveMemory,
|
|
6
|
+
generateMemoryId,
|
|
7
|
+
listMemories,
|
|
8
|
+
deleteMemory,
|
|
9
|
+
getMemoryById,
|
|
10
|
+
getStats,
|
|
11
|
+
updateMemory,
|
|
12
|
+
} from "../src/memory/saveMemory.js";
|
|
13
|
+
import type { CodingMemory } from "../src/memory/types.js";
|
|
14
|
+
import type Database from "better-sqlite3";
|
|
15
|
+
|
|
16
|
+
function makeTestDb(): Database.Database {
|
|
17
|
+
const db = openDb(":memory:");
|
|
18
|
+
runMigrations(db);
|
|
19
|
+
return db;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeMemory(overrides: Partial<CodingMemory> = {}): CodingMemory {
|
|
23
|
+
return {
|
|
24
|
+
id: generateMemoryId(),
|
|
25
|
+
createdAt: new Date().toISOString(),
|
|
26
|
+
updatedAt: new Date().toISOString(),
|
|
27
|
+
repoId: "repo-abc",
|
|
28
|
+
repoPath: "/home/user/my-app",
|
|
29
|
+
repoName: "my-app",
|
|
30
|
+
branch: "main",
|
|
31
|
+
commitSha: "abc123",
|
|
32
|
+
files: ["src/auth/session.ts"],
|
|
33
|
+
tags: ["auth"],
|
|
34
|
+
intent: "Add refresh token rotation",
|
|
35
|
+
summary: "Implemented refresh token rotation",
|
|
36
|
+
decision: "Rotate on every use",
|
|
37
|
+
why: "One-time-use tokens prevent replay attacks",
|
|
38
|
+
alternativesRejected: ["Long-lived tokens"],
|
|
39
|
+
risks: ["Token invalidation race condition"],
|
|
40
|
+
followUps: ["Add rotation tests"],
|
|
41
|
+
source: "cli",
|
|
42
|
+
embeddingText: "Add refresh token rotation",
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("listMemories", () => {
|
|
48
|
+
let db: Database.Database;
|
|
49
|
+
|
|
50
|
+
beforeEach(() => { db = makeTestDb(); });
|
|
51
|
+
|
|
52
|
+
it("returns all memories in reverse chronological order", () => {
|
|
53
|
+
const m1 = makeMemory({ createdAt: "2024-01-01T00:00:00.000Z" });
|
|
54
|
+
const m2 = makeMemory({ createdAt: "2024-06-01T00:00:00.000Z" });
|
|
55
|
+
saveMemory(db, m1);
|
|
56
|
+
saveMemory(db, m2);
|
|
57
|
+
const results = listMemories(db, { limit: 10 });
|
|
58
|
+
expect(results[0].id).toBe(m2.id);
|
|
59
|
+
expect(results[1].id).toBe(m1.id);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("respects limit", () => {
|
|
63
|
+
for (let i = 0; i < 5; i++) saveMemory(db, makeMemory());
|
|
64
|
+
const results = listMemories(db, { limit: 3 });
|
|
65
|
+
expect(results.length).toBe(3);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("filters by repoId when provided", () => {
|
|
69
|
+
const m1 = makeMemory({ repoId: "repo-abc" });
|
|
70
|
+
const m2 = makeMemory({ repoId: "repo-xyz" });
|
|
71
|
+
saveMemory(db, m1);
|
|
72
|
+
saveMemory(db, m2);
|
|
73
|
+
const results = listMemories(db, { repoId: "repo-abc", limit: 10 });
|
|
74
|
+
expect(results.length).toBe(1);
|
|
75
|
+
expect(results[0].id).toBe(m1.id);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns all repos when repoId is not provided", () => {
|
|
79
|
+
saveMemory(db, makeMemory({ repoId: "repo-abc" }));
|
|
80
|
+
saveMemory(db, makeMemory({ repoId: "repo-xyz" }));
|
|
81
|
+
const results = listMemories(db, { limit: 10 });
|
|
82
|
+
expect(results.length).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns empty array when no memories exist", () => {
|
|
86
|
+
expect(listMemories(db, { limit: 10 })).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("deleteMemory", () => {
|
|
91
|
+
let db: Database.Database;
|
|
92
|
+
|
|
93
|
+
beforeEach(() => { db = makeTestDb(); });
|
|
94
|
+
|
|
95
|
+
it("deletes a memory by ID", () => {
|
|
96
|
+
const m = makeMemory();
|
|
97
|
+
saveMemory(db, m);
|
|
98
|
+
deleteMemory(db, m.id);
|
|
99
|
+
expect(getMemoryById(db, m.id)).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("cascades to junction tables", () => {
|
|
103
|
+
const m = makeMemory();
|
|
104
|
+
saveMemory(db, m);
|
|
105
|
+
deleteMemory(db, m.id);
|
|
106
|
+
const files = db.prepare("SELECT * FROM memory_files WHERE memory_id = ?").all(m.id);
|
|
107
|
+
const tags = db.prepare("SELECT * FROM memory_tags WHERE memory_id = ?").all(m.id);
|
|
108
|
+
expect(files.length).toBe(0);
|
|
109
|
+
expect(tags.length).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("is a no-op for non-existent ID", () => {
|
|
113
|
+
expect(() => deleteMemory(db, "nonexistent")).not.toThrow();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("getStats", () => {
|
|
118
|
+
let db: Database.Database;
|
|
119
|
+
|
|
120
|
+
beforeEach(() => { db = makeTestDb(); });
|
|
121
|
+
|
|
122
|
+
it("returns zeros when no memories exist", () => {
|
|
123
|
+
const stats = getStats(db);
|
|
124
|
+
expect(stats.total).toBe(0);
|
|
125
|
+
expect(stats.repos).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("counts total memories and distinct repos", () => {
|
|
129
|
+
saveMemory(db, makeMemory({ repoId: "repo-a" }));
|
|
130
|
+
saveMemory(db, makeMemory({ repoId: "repo-a" }));
|
|
131
|
+
saveMemory(db, makeMemory({ repoId: "repo-b" }));
|
|
132
|
+
const stats = getStats(db);
|
|
133
|
+
expect(stats.total).toBe(3);
|
|
134
|
+
expect(stats.repos).toBe(2);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("returns top files sorted by count", () => {
|
|
138
|
+
const m1 = makeMemory({ files: ["src/auth.ts", "src/session.ts"] });
|
|
139
|
+
const m2 = makeMemory({ files: ["src/auth.ts"] });
|
|
140
|
+
saveMemory(db, m1);
|
|
141
|
+
saveMemory(db, m2);
|
|
142
|
+
const stats = getStats(db);
|
|
143
|
+
expect(stats.topFiles[0].filePath).toBe("src/auth.ts");
|
|
144
|
+
expect(stats.topFiles[0].count).toBe(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("includes oldest and newest dates", () => {
|
|
148
|
+
saveMemory(db, makeMemory({ createdAt: "2024-01-01T00:00:00.000Z" }));
|
|
149
|
+
saveMemory(db, makeMemory({ createdAt: "2024-12-31T00:00:00.000Z" }));
|
|
150
|
+
const stats = getStats(db);
|
|
151
|
+
expect(stats.oldest).toBeTruthy();
|
|
152
|
+
expect(stats.newest).toBeTruthy();
|
|
153
|
+
expect(stats.oldest).not.toBe(stats.newest);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("updateMemory", () => {
|
|
158
|
+
let db: Database.Database;
|
|
159
|
+
|
|
160
|
+
beforeEach(() => { db = makeTestDb(); });
|
|
161
|
+
|
|
162
|
+
it("updates text fields", () => {
|
|
163
|
+
const m = makeMemory();
|
|
164
|
+
saveMemory(db, m);
|
|
165
|
+
updateMemory(db, m.id, {
|
|
166
|
+
intent: "Updated intent",
|
|
167
|
+
summary: "Updated summary",
|
|
168
|
+
decision: "Updated decision",
|
|
169
|
+
why: "Updated why",
|
|
170
|
+
embeddingText: "Updated intent Updated summary",
|
|
171
|
+
});
|
|
172
|
+
const updated = getMemoryById(db, m.id);
|
|
173
|
+
expect(updated?.intent).toBe("Updated intent");
|
|
174
|
+
expect(updated?.decision).toBe("Updated decision");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("replaces tags on update", () => {
|
|
178
|
+
const m = makeMemory({ tags: ["old-tag"] });
|
|
179
|
+
saveMemory(db, m);
|
|
180
|
+
updateMemory(db, m.id, { tags: ["new-tag-1", "new-tag-2"] });
|
|
181
|
+
const updated = getMemoryById(db, m.id);
|
|
182
|
+
expect(updated?.tags).toContain("new-tag-1");
|
|
183
|
+
expect(updated?.tags).toContain("new-tag-2");
|
|
184
|
+
expect(updated?.tags).not.toContain("old-tag");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("replaces risks on update", () => {
|
|
188
|
+
const m = makeMemory({ risks: ["old risk"] });
|
|
189
|
+
saveMemory(db, m);
|
|
190
|
+
updateMemory(db, m.id, { risks: ["new risk"] });
|
|
191
|
+
const updated = getMemoryById(db, m.id);
|
|
192
|
+
expect(updated?.risks).toContain("new risk");
|
|
193
|
+
expect(updated?.risks).not.toContain("old risk");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("replaces followUps on update", () => {
|
|
197
|
+
const m = makeMemory({ followUps: ["old followup"] });
|
|
198
|
+
saveMemory(db, m);
|
|
199
|
+
updateMemory(db, m.id, { followUps: ["new followup"] });
|
|
200
|
+
const updated = getMemoryById(db, m.id);
|
|
201
|
+
expect(updated?.followUps).toContain("new followup");
|
|
202
|
+
expect(updated?.followUps).not.toContain("old followup");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("updates the updated_at timestamp", () => {
|
|
206
|
+
const m = makeMemory();
|
|
207
|
+
saveMemory(db, m);
|
|
208
|
+
const before = getMemoryById(db, m.id)!.updatedAt;
|
|
209
|
+
updateMemory(db, m.id, { intent: "changed" });
|
|
210
|
+
const after = getMemoryById(db, m.id)!.updatedAt;
|
|
211
|
+
expect(after >= before).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { openDb } from "../src/db/connection.js";
|
|
6
|
+
import { runMigrations } from "../src/db/migrations.js";
|
|
7
|
+
import { MIGRATIONS } from "../src/db/schema.js";
|
|
8
|
+
|
|
9
|
+
// Capture console output
|
|
10
|
+
async function captureOutput(fn: () => Promise<void>): Promise<{ stdout: string; exitCode: number }> {
|
|
11
|
+
const lines: string[] = [];
|
|
12
|
+
const orig = console.log;
|
|
13
|
+
console.log = (...args: unknown[]) => lines.push(args.join(" "));
|
|
14
|
+
|
|
15
|
+
let exitCode = 0;
|
|
16
|
+
vi.spyOn(process, "exit").mockImplementation((code?: number) => {
|
|
17
|
+
exitCode = code ?? 0;
|
|
18
|
+
throw new Error(`process.exit(${code})`);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await fn();
|
|
23
|
+
} catch (e: unknown) {
|
|
24
|
+
if (!(e instanceof Error) || !e.message.startsWith("process.exit")) throw e;
|
|
25
|
+
} finally {
|
|
26
|
+
console.log = orig;
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { stdout: lines.join("\n"), exitCode };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("doctor — DB checks", () => {
|
|
34
|
+
let tmpDir: string;
|
|
35
|
+
let dbPath: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "whyline-doctor-"));
|
|
39
|
+
dbPath = path.join(tmpDir, "memory.db");
|
|
40
|
+
vi.resetModules();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reports DB missing when not initialised", async () => {
|
|
44
|
+
vi.doMock("../src/config.js", () => ({
|
|
45
|
+
isInitialized: () => false,
|
|
46
|
+
resolveConfig: () => ({ storage: { dbPath } }),
|
|
47
|
+
}));
|
|
48
|
+
vi.doMock("../src/git/git.js", () => ({ getRepoRoot: () => null }));
|
|
49
|
+
vi.doMock("child_process", async (orig) => {
|
|
50
|
+
const actual = await orig();
|
|
51
|
+
return {
|
|
52
|
+
...(actual as object),
|
|
53
|
+
execSync: (cmd: string) => {
|
|
54
|
+
if (cmd === "which whyline") return "/usr/local/bin/whyline\n";
|
|
55
|
+
throw new Error("not called");
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const { runDoctor } = await import("../src/commands/doctor.js");
|
|
61
|
+
const { stdout } = await captureOutput(() => runDoctor());
|
|
62
|
+
expect(stdout).toMatch(/✗.*DB exists/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reports DB present and migrations current when initialised", async () => {
|
|
66
|
+
const db = openDb(dbPath);
|
|
67
|
+
runMigrations(db);
|
|
68
|
+
db.close();
|
|
69
|
+
|
|
70
|
+
vi.doMock("../src/config.js", () => ({
|
|
71
|
+
isInitialized: () => true,
|
|
72
|
+
resolveConfig: () => ({ storage: { dbPath } }),
|
|
73
|
+
}));
|
|
74
|
+
vi.doMock("../src/git/git.js", () => ({ getRepoRoot: () => null }));
|
|
75
|
+
vi.doMock("child_process", async (orig) => {
|
|
76
|
+
const actual = await orig();
|
|
77
|
+
return {
|
|
78
|
+
...(actual as object),
|
|
79
|
+
execSync: (cmd: string) => {
|
|
80
|
+
if (cmd === "which whyline") return "/usr/local/bin/whyline\n";
|
|
81
|
+
throw new Error("not called");
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const { runDoctor } = await import("../src/commands/doctor.js");
|
|
87
|
+
const { stdout } = await captureOutput(() => runDoctor());
|
|
88
|
+
expect(stdout).toMatch(/✓.*DB exists/);
|
|
89
|
+
expect(stdout).toMatch(/✓.*Migrations current/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reports migrations out of date when db has older version", async () => {
|
|
93
|
+
// Create DB but only apply migrations up to v0 (none)
|
|
94
|
+
const db = openDb(dbPath);
|
|
95
|
+
db.exec("CREATE TABLE IF NOT EXISTS migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL);");
|
|
96
|
+
db.close();
|
|
97
|
+
|
|
98
|
+
vi.doMock("../src/config.js", () => ({
|
|
99
|
+
isInitialized: () => true,
|
|
100
|
+
resolveConfig: () => ({ storage: { dbPath } }),
|
|
101
|
+
}));
|
|
102
|
+
vi.doMock("../src/git/git.js", () => ({ getRepoRoot: () => null }));
|
|
103
|
+
vi.doMock("child_process", async (orig) => {
|
|
104
|
+
const actual = await orig();
|
|
105
|
+
return {
|
|
106
|
+
...(actual as object),
|
|
107
|
+
execSync: (cmd: string) => {
|
|
108
|
+
if (cmd === "which whyline") return "/usr/local/bin/whyline\n";
|
|
109
|
+
throw new Error();
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const { runDoctor } = await import("../src/commands/doctor.js");
|
|
115
|
+
const { stdout } = await captureOutput(() => runDoctor());
|
|
116
|
+
expect(stdout).toMatch(/✗.*Migrations current/);
|
|
117
|
+
expect(stdout).toMatch(`v${MIGRATIONS[MIGRATIONS.length - 1].version}`);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("doctor — git repo checks", () => {
|
|
122
|
+
let tmpDir: string;
|
|
123
|
+
let dbPath: string;
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "whyline-doctor-"));
|
|
127
|
+
dbPath = path.join(tmpDir, "memory.db");
|
|
128
|
+
const db = openDb(dbPath);
|
|
129
|
+
runMigrations(db);
|
|
130
|
+
db.close();
|
|
131
|
+
vi.resetModules();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("reports not in git repo when getRepoRoot returns null", async () => {
|
|
135
|
+
vi.doMock("../src/config.js", () => ({
|
|
136
|
+
isInitialized: () => true,
|
|
137
|
+
resolveConfig: () => ({ storage: { dbPath } }),
|
|
138
|
+
}));
|
|
139
|
+
vi.doMock("../src/git/git.js", () => ({ getRepoRoot: () => null }));
|
|
140
|
+
vi.doMock("child_process", async (orig) => {
|
|
141
|
+
const actual = await orig();
|
|
142
|
+
return {
|
|
143
|
+
...(actual as object),
|
|
144
|
+
execSync: (cmd: string) => {
|
|
145
|
+
if (cmd === "which whyline") return "/usr/local/bin/whyline\n";
|
|
146
|
+
throw new Error();
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const { runDoctor } = await import("../src/commands/doctor.js");
|
|
152
|
+
const { stdout } = await captureOutput(() => runDoctor());
|
|
153
|
+
expect(stdout).toMatch(/✗.*Inside a git repo/);
|
|
154
|
+
expect(stdout).toMatch(/skipped.*not in a git repo/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("reports .mcp.json missing when inside a git repo without the file", async () => {
|
|
158
|
+
vi.doMock("../src/config.js", () => ({
|
|
159
|
+
isInitialized: () => true,
|
|
160
|
+
resolveConfig: () => ({ storage: { dbPath } }),
|
|
161
|
+
}));
|
|
162
|
+
vi.doMock("../src/git/git.js", () => ({ getRepoRoot: () => tmpDir }));
|
|
163
|
+
vi.doMock("child_process", async (orig) => {
|
|
164
|
+
const actual = await orig();
|
|
165
|
+
return {
|
|
166
|
+
...(actual as object),
|
|
167
|
+
execSync: (cmd: string) => {
|
|
168
|
+
if (cmd === "which whyline") return "/usr/local/bin/whyline\n";
|
|
169
|
+
throw new Error();
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const { runDoctor } = await import("../src/commands/doctor.js");
|
|
175
|
+
const { stdout } = await captureOutput(() => runDoctor());
|
|
176
|
+
expect(stdout).toMatch(/✗.*\.mcp\.json configured/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("detects valid .mcp.json with whyline server entry", async () => {
|
|
180
|
+
const mcpJson = { mcpServers: { whyline: { command: "whyline", args: ["mcp"] } } };
|
|
181
|
+
fs.writeFileSync(path.join(tmpDir, ".mcp.json"), JSON.stringify(mcpJson));
|
|
182
|
+
|
|
183
|
+
vi.doMock("../src/config.js", () => ({
|
|
184
|
+
isInitialized: () => true,
|
|
185
|
+
resolveConfig: () => ({ storage: { dbPath } }),
|
|
186
|
+
}));
|
|
187
|
+
vi.doMock("../src/git/git.js", () => ({ getRepoRoot: () => tmpDir }));
|
|
188
|
+
vi.doMock("child_process", async (orig) => {
|
|
189
|
+
const actual = await orig();
|
|
190
|
+
return {
|
|
191
|
+
...(actual as object),
|
|
192
|
+
execSync: (cmd: string) => {
|
|
193
|
+
if (cmd === "which whyline") return "/usr/local/bin/whyline\n";
|
|
194
|
+
throw new Error();
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const { runDoctor } = await import("../src/commands/doctor.js");
|
|
200
|
+
const { stdout } = await captureOutput(() => runDoctor());
|
|
201
|
+
expect(stdout).toMatch(/✓.*\.mcp\.json configured/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("detects CLAUDE.md that mentions Whyline", async () => {
|
|
205
|
+
fs.writeFileSync(path.join(tmpDir, ".mcp.json"), JSON.stringify({ mcpServers: { whyline: {} } }));
|
|
206
|
+
fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), "# My project\n\nUses Whyline for memory.\n");
|
|
207
|
+
|
|
208
|
+
vi.doMock("../src/config.js", () => ({
|
|
209
|
+
isInitialized: () => true,
|
|
210
|
+
resolveConfig: () => ({ storage: { dbPath } }),
|
|
211
|
+
}));
|
|
212
|
+
vi.doMock("../src/git/git.js", () => ({ getRepoRoot: () => tmpDir }));
|
|
213
|
+
vi.doMock("child_process", async (orig) => {
|
|
214
|
+
const actual = await orig();
|
|
215
|
+
return {
|
|
216
|
+
...(actual as object),
|
|
217
|
+
execSync: (cmd: string) => {
|
|
218
|
+
if (cmd === "which whyline") return "/usr/local/bin/whyline\n";
|
|
219
|
+
throw new Error();
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const { runDoctor } = await import("../src/commands/doctor.js");
|
|
225
|
+
const { stdout } = await captureOutput(() => runDoctor());
|
|
226
|
+
expect(stdout).toMatch(/✓.*CLAUDE\.md mentions Whyline/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("detects CLAUDE.md that does not mention Whyline", async () => {
|
|
230
|
+
fs.writeFileSync(path.join(tmpDir, ".mcp.json"), JSON.stringify({ mcpServers: { whyline: {} } }));
|
|
231
|
+
fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), "# My project\n\nNo memory tooling configured.\n");
|
|
232
|
+
|
|
233
|
+
vi.doMock("../src/config.js", () => ({
|
|
234
|
+
isInitialized: () => true,
|
|
235
|
+
resolveConfig: () => ({ storage: { dbPath } }),
|
|
236
|
+
}));
|
|
237
|
+
vi.doMock("../src/git/git.js", () => ({ getRepoRoot: () => tmpDir }));
|
|
238
|
+
vi.doMock("child_process", async (orig) => {
|
|
239
|
+
const actual = await orig();
|
|
240
|
+
return {
|
|
241
|
+
...(actual as object),
|
|
242
|
+
execSync: (cmd: string) => {
|
|
243
|
+
if (cmd === "which whyline") return "/usr/local/bin/whyline\n";
|
|
244
|
+
throw new Error();
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const { runDoctor } = await import("../src/commands/doctor.js");
|
|
250
|
+
const { stdout } = await captureOutput(() => runDoctor());
|
|
251
|
+
expect(stdout).toMatch(/✗.*CLAUDE\.md mentions Whyline/);
|
|
252
|
+
});
|
|
253
|
+
});
|