@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.
Files changed (164) hide show
  1. package/.claude/settings.local.json +33 -0
  2. package/.github/workflows/ci.yml +35 -0
  3. package/.github/workflows/publish.yml +37 -0
  4. package/.prettierrc.json +7 -0
  5. package/CLAUDE.md +74 -0
  6. package/LICENSE +21 -0
  7. package/README.md +359 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +125 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/commands/delete.d.ts +3 -0
  12. package/dist/commands/delete.js +42 -0
  13. package/dist/commands/delete.js.map +1 -0
  14. package/dist/commands/doctor.d.ts +1 -0
  15. package/dist/commands/doctor.js +111 -0
  16. package/dist/commands/doctor.js.map +1 -0
  17. package/dist/commands/edit.d.ts +1 -0
  18. package/dist/commands/edit.js +78 -0
  19. package/dist/commands/edit.js.map +1 -0
  20. package/dist/commands/export.d.ts +8 -0
  21. package/dist/commands/export.js +90 -0
  22. package/dist/commands/export.js.map +1 -0
  23. package/dist/commands/import.d.ts +1 -0
  24. package/dist/commands/import.js +110 -0
  25. package/dist/commands/import.js.map +1 -0
  26. package/dist/commands/init.d.ts +5 -0
  27. package/dist/commands/init.js +23 -0
  28. package/dist/commands/init.js.map +1 -0
  29. package/dist/commands/install-claude.d.ts +3 -0
  30. package/dist/commands/install-claude.js +180 -0
  31. package/dist/commands/install-claude.js.map +1 -0
  32. package/dist/commands/list.d.ts +4 -0
  33. package/dist/commands/list.js +35 -0
  34. package/dist/commands/list.js.map +1 -0
  35. package/dist/commands/mcp.d.ts +1 -0
  36. package/dist/commands/mcp.js +10 -0
  37. package/dist/commands/mcp.js.map +1 -0
  38. package/dist/commands/save.d.ts +4 -0
  39. package/dist/commands/save.js +74 -0
  40. package/dist/commands/save.js.map +1 -0
  41. package/dist/commands/search.d.ts +7 -0
  42. package/dist/commands/search.js +46 -0
  43. package/dist/commands/search.js.map +1 -0
  44. package/dist/commands/show.d.ts +3 -0
  45. package/dist/commands/show.js +30 -0
  46. package/dist/commands/show.js.map +1 -0
  47. package/dist/commands/stats.d.ts +1 -0
  48. package/dist/commands/stats.js +27 -0
  49. package/dist/commands/stats.js.map +1 -0
  50. package/dist/commands/summarize.d.ts +3 -0
  51. package/dist/commands/summarize.js +140 -0
  52. package/dist/commands/summarize.js.map +1 -0
  53. package/dist/config.d.ts +11 -0
  54. package/dist/config.js +17 -0
  55. package/dist/config.js.map +1 -0
  56. package/dist/db/connection.d.ts +2 -0
  57. package/dist/db/connection.js +8 -0
  58. package/dist/db/connection.js.map +1 -0
  59. package/dist/db/migrations.d.ts +2 -0
  60. package/dist/db/migrations.js +19 -0
  61. package/dist/db/migrations.js.map +1 -0
  62. package/dist/db/schema.d.ts +5 -0
  63. package/dist/db/schema.js +64 -0
  64. package/dist/db/schema.js.map +1 -0
  65. package/dist/git/diff.d.ts +2 -0
  66. package/dist/git/diff.js +45 -0
  67. package/dist/git/diff.js.map +1 -0
  68. package/dist/git/git.d.ts +3 -0
  69. package/dist/git/git.js +25 -0
  70. package/dist/git/git.js.map +1 -0
  71. package/dist/git/repoId.d.ts +3 -0
  72. package/dist/git/repoId.js +49 -0
  73. package/dist/git/repoId.js.map +1 -0
  74. package/dist/mcp/server.d.ts +1 -0
  75. package/dist/mcp/server.js +296 -0
  76. package/dist/mcp/server.js.map +1 -0
  77. package/dist/mcp/tools.d.ts +119 -0
  78. package/dist/mcp/tools.js +43 -0
  79. package/dist/mcp/tools.js.map +1 -0
  80. package/dist/memory/parseSummary.d.ts +14 -0
  81. package/dist/memory/parseSummary.js +53 -0
  82. package/dist/memory/parseSummary.js.map +1 -0
  83. package/dist/memory/qualityCheck.d.ts +13 -0
  84. package/dist/memory/qualityCheck.js +78 -0
  85. package/dist/memory/qualityCheck.js.map +1 -0
  86. package/dist/memory/redactSecrets.d.ts +7 -0
  87. package/dist/memory/redactSecrets.js +29 -0
  88. package/dist/memory/redactSecrets.js.map +1 -0
  89. package/dist/memory/repoContext.d.ts +2 -0
  90. package/dist/memory/repoContext.js +23 -0
  91. package/dist/memory/repoContext.js.map +1 -0
  92. package/dist/memory/saveMemory.d.ts +40 -0
  93. package/dist/memory/saveMemory.js +223 -0
  94. package/dist/memory/saveMemory.js.map +1 -0
  95. package/dist/memory/searchMemory.d.ts +17 -0
  96. package/dist/memory/searchMemory.js +122 -0
  97. package/dist/memory/searchMemory.js.map +1 -0
  98. package/dist/memory/types.d.ts +48 -0
  99. package/dist/memory/types.js +2 -0
  100. package/dist/memory/types.js.map +1 -0
  101. package/dist/output/format.d.ts +3 -0
  102. package/dist/output/format.js +43 -0
  103. package/dist/output/format.js.map +1 -0
  104. package/docs/architecture.md +387 -0
  105. package/docs/ec6ab3bf-60cf-4629-ad9e-3048e8e3c43a.png +0 -0
  106. package/docs/logo.png +0 -0
  107. package/eslint.config.js +16 -0
  108. package/how-to-run/01-install.md +69 -0
  109. package/how-to-run/02-wire-up-your-repo.md +80 -0
  110. package/how-to-run/03-test-it-manually.md +91 -0
  111. package/how-to-run/04-test-with-claude-code.md +70 -0
  112. package/how-to-run/CLAUDE.md.template +72 -0
  113. package/how-to-run/README.md +49 -0
  114. package/package.json +60 -0
  115. package/src/cli.ts +142 -0
  116. package/src/commands/delete.ts +47 -0
  117. package/src/commands/doctor.ts +128 -0
  118. package/src/commands/edit.ts +80 -0
  119. package/src/commands/export.ts +95 -0
  120. package/src/commands/import.ts +119 -0
  121. package/src/commands/init.ts +31 -0
  122. package/src/commands/install-claude.ts +203 -0
  123. package/src/commands/list.ts +41 -0
  124. package/src/commands/mcp.ts +12 -0
  125. package/src/commands/save.ts +85 -0
  126. package/src/commands/search.ts +56 -0
  127. package/src/commands/show.ts +37 -0
  128. package/src/commands/stats.ts +31 -0
  129. package/src/commands/summarize.ts +183 -0
  130. package/src/config.ts +26 -0
  131. package/src/db/connection.ts +8 -0
  132. package/src/db/migrations.ts +26 -0
  133. package/src/db/schema.ts +68 -0
  134. package/src/git/diff.ts +43 -0
  135. package/src/git/git.ts +25 -0
  136. package/src/git/repoId.ts +49 -0
  137. package/src/hooks/post-commit.sample.sh +9 -0
  138. package/src/mcp/server.ts +326 -0
  139. package/src/mcp/tools.ts +53 -0
  140. package/src/memory/parseSummary.ts +72 -0
  141. package/src/memory/qualityCheck.ts +102 -0
  142. package/src/memory/redactSecrets.ts +32 -0
  143. package/src/memory/repoContext.ts +25 -0
  144. package/src/memory/saveMemory.ts +369 -0
  145. package/src/memory/searchMemory.ts +153 -0
  146. package/src/memory/types.ts +57 -0
  147. package/src/output/format.ts +44 -0
  148. package/src/skill/SKILL.md +95 -0
  149. package/tests/cliV02.test.ts +213 -0
  150. package/tests/doctor.test.ts +253 -0
  151. package/tests/exportImport.test.ts +248 -0
  152. package/tests/fileRename.test.ts +156 -0
  153. package/tests/gitHelpers.test.ts +94 -0
  154. package/tests/init.test.ts +93 -0
  155. package/tests/installClaude.test.ts +157 -0
  156. package/tests/parseSummary.test.ts +111 -0
  157. package/tests/qualityCheck.test.ts +182 -0
  158. package/tests/redactSecrets.test.ts +75 -0
  159. package/tests/saveMemory.test.ts +196 -0
  160. package/tests/searchFilters.test.ts +139 -0
  161. package/tests/searchMemory.test.ts +273 -0
  162. package/tests/stale.test.ts +47 -0
  163. package/tsconfig.json +18 -0
  164. 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
+ });