@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,31 @@
1
+ import { isInitialized, resolveConfig } from "../config.js";
2
+ import { openDb } from "../db/connection.js";
3
+ import { getStats } from "../memory/saveMemory.js";
4
+
5
+ export async function runStats(): Promise<void> {
6
+ if (!isInitialized()) {
7
+ console.error("whyline is not initialized. Run `whyline init` first.");
8
+ process.exit(1);
9
+ }
10
+
11
+ const db = openDb(resolveConfig().storage.dbPath);
12
+ const stats = getStats(db);
13
+ db.close();
14
+
15
+ if (stats.total === 0) {
16
+ console.log("No memories stored yet.");
17
+ return;
18
+ }
19
+
20
+ console.log(`Total memories: ${stats.total}`);
21
+ console.log(`Repos tracked: ${stats.repos}`);
22
+ console.log(`Oldest memory: ${stats.oldest}`);
23
+ console.log(`Newest memory: ${stats.newest}`);
24
+
25
+ if (stats.topFiles.length > 0) {
26
+ console.log("\nMost referenced files:");
27
+ for (const { filePath, count } of stats.topFiles) {
28
+ console.log(` ${count}x ${filePath}`);
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,183 @@
1
+ import https from "https";
2
+ import readline from "readline";
3
+ import { isInitialized, resolveConfig } from "../config.js";
4
+ import { openDb } from "../db/connection.js";
5
+ import { getMemoryById, updateMemory, buildEmbeddingText } from "../memory/saveMemory.js";
6
+
7
+ type ImprovedMemory = {
8
+ intent: string;
9
+ summary: string;
10
+ decision: string;
11
+ why: string;
12
+ alternativesRejected: string[];
13
+ risks: string[];
14
+ followUps: string[];
15
+ tags: string[];
16
+ };
17
+
18
+ function buildPrompt(memory: {
19
+ intent: string;
20
+ summary: string;
21
+ decision: string;
22
+ why: string;
23
+ alternativesRejected: string[];
24
+ risks: string[];
25
+ followUps: string[];
26
+ tags: string[];
27
+ }): string {
28
+ return `You are improving a coding memory record. Rewrite each field to be clearer, more specific, and more useful for a future developer session. Do not invent details that aren't implied by the original. Keep each field concise. Return ONLY valid JSON with these exact keys: intent, summary, decision, why, alternativesRejected (array), risks (array), followUps (array), tags (array).
29
+
30
+ Original memory:
31
+ ${JSON.stringify(memory, null, 2)}`;
32
+ }
33
+
34
+ async function callClaudeApi(prompt: string, apiKey: string): Promise<ImprovedMemory> {
35
+ const body = JSON.stringify({
36
+ model: "claude-haiku-4-5-20251001",
37
+ max_tokens: 1024,
38
+ messages: [{ role: "user", content: prompt }],
39
+ });
40
+
41
+ return new Promise((resolve, reject) => {
42
+ const req = https.request(
43
+ {
44
+ hostname: "api.anthropic.com",
45
+ path: "/v1/messages",
46
+ method: "POST",
47
+ headers: {
48
+ "Content-Type": "application/json",
49
+ "x-api-key": apiKey,
50
+ "anthropic-version": "2023-06-01",
51
+ },
52
+ },
53
+ (res) => {
54
+ let data = "";
55
+ res.on("data", (chunk: Buffer) => { data += chunk.toString(); });
56
+ res.on("end", () => {
57
+ try {
58
+ const parsed = JSON.parse(data) as {
59
+ content?: Array<{ type: string; text: string }>;
60
+ error?: { message: string };
61
+ };
62
+ if (parsed.error) {
63
+ reject(new Error(`Claude API error: ${parsed.error.message}`));
64
+ return;
65
+ }
66
+ const text = parsed.content?.find((c) => c.type === "text")?.text ?? "";
67
+ // Extract JSON from the response (may be wrapped in markdown code block)
68
+ const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/) ?? [null, text];
69
+ const improved = JSON.parse(jsonMatch[1].trim()) as ImprovedMemory;
70
+ resolve(improved);
71
+ } catch (e) {
72
+ reject(new Error(`Failed to parse Claude response: ${String(e)}\nRaw: ${data}`));
73
+ }
74
+ });
75
+ }
76
+ );
77
+ req.on("error", reject);
78
+ req.write(body);
79
+ req.end();
80
+ });
81
+ }
82
+
83
+ function diffField(label: string, before: string | string[], after: string | string[]): string {
84
+ const b = Array.isArray(before) ? before.join(", ") : before;
85
+ const a = Array.isArray(after) ? after.join(", ") : after;
86
+ if (b === a) return "";
87
+ return ` ${label}:\n before: ${b}\n after: ${a}`;
88
+ }
89
+
90
+ function showDiff(original: ImprovedMemory, improved: ImprovedMemory): void {
91
+ const diffs = [
92
+ diffField("intent", original.intent, improved.intent),
93
+ diffField("summary", original.summary, improved.summary),
94
+ diffField("decision", original.decision, improved.decision),
95
+ diffField("why", original.why, improved.why),
96
+ diffField("alternativesRejected", original.alternativesRejected, improved.alternativesRejected),
97
+ diffField("risks", original.risks, improved.risks),
98
+ diffField("followUps", original.followUps, improved.followUps),
99
+ diffField("tags", original.tags, improved.tags),
100
+ ].filter(Boolean);
101
+
102
+ if (diffs.length === 0) {
103
+ console.log("No changes suggested — memory is already well-written.");
104
+ } else {
105
+ console.log("\nProposed improvements:\n");
106
+ console.log(diffs.join("\n\n"));
107
+ }
108
+ }
109
+
110
+ async function confirm(question: string): Promise<boolean> {
111
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
112
+ return new Promise((resolve) => {
113
+ rl.question(question, (answer) => {
114
+ rl.close();
115
+ resolve(answer.trim().toLowerCase() === "y");
116
+ });
117
+ });
118
+ }
119
+
120
+ export async function runSummarize(id: string, options: { force: boolean }): Promise<void> {
121
+ if (!isInitialized()) {
122
+ console.error("whyline is not initialized. Run `whyline init` first.");
123
+ process.exit(1);
124
+ }
125
+
126
+ const apiKey = process.env.ANTHROPIC_API_KEY;
127
+ if (!apiKey) {
128
+ console.error("ANTHROPIC_API_KEY environment variable is not set.");
129
+ console.error("Export it before running: export ANTHROPIC_API_KEY=your_key");
130
+ process.exit(1);
131
+ }
132
+
133
+ const db = openDb(resolveConfig().storage.dbPath);
134
+ const memory = getMemoryById(db, id);
135
+ db.close();
136
+
137
+ if (!memory) {
138
+ console.error(`Memory not found: ${id}`);
139
+ process.exit(1);
140
+ }
141
+
142
+ const original: ImprovedMemory = {
143
+ intent: memory.intent,
144
+ summary: memory.summary,
145
+ decision: memory.decision,
146
+ why: memory.why,
147
+ alternativesRejected: memory.alternativesRejected,
148
+ risks: memory.risks,
149
+ followUps: memory.followUps,
150
+ tags: memory.tags,
151
+ };
152
+
153
+ console.log(`Summarizing memory ${id}...`);
154
+ const improved = await callClaudeApi(buildPrompt(original), apiKey);
155
+
156
+ showDiff(original, improved);
157
+
158
+ const hasChanges =
159
+ JSON.stringify(original) !== JSON.stringify(improved);
160
+
161
+ if (!hasChanges) {
162
+ return;
163
+ }
164
+
165
+ const shouldSave = options.force || (await confirm("\nApply improvements? [y/N] "));
166
+
167
+ if (!shouldSave) {
168
+ console.log("Cancelled.");
169
+ return;
170
+ }
171
+
172
+ const db2 = openDb(resolveConfig().storage.dbPath);
173
+ const embeddingText = buildEmbeddingText({
174
+ ...memory,
175
+ ...improved,
176
+ commitSha: memory.commitSha,
177
+ files: memory.files,
178
+ });
179
+ updateMemory(db2, id, { ...improved, embeddingText });
180
+ db2.close();
181
+
182
+ console.log(`Updated memory ${id}.`);
183
+ }
package/src/config.ts ADDED
@@ -0,0 +1,26 @@
1
+ import os from "os";
2
+ import path from "path";
3
+ import fs from "fs";
4
+
5
+ export const DATA_DIR = path.join(os.homedir(), ".whyline");
6
+ export const DB_PATH = path.join(DATA_DIR, "memory.db");
7
+ export const CONFIG_PATH = path.join(DATA_DIR, "config.json");
8
+
9
+ export type AppConfig = {
10
+ version: number;
11
+ storage: {
12
+ dbPath: string;
13
+ };
14
+ };
15
+
16
+ export function resolveConfig(): AppConfig {
17
+ if (fs.existsSync(CONFIG_PATH)) {
18
+ const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
19
+ return JSON.parse(raw) as AppConfig;
20
+ }
21
+ return { version: 1, storage: { dbPath: DB_PATH } };
22
+ }
23
+
24
+ export function isInitialized(): boolean {
25
+ return fs.existsSync(DB_PATH);
26
+ }
@@ -0,0 +1,8 @@
1
+ import Database from "better-sqlite3";
2
+
3
+ export function openDb(dbPath: string): Database.Database {
4
+ const db = new Database(dbPath);
5
+ db.pragma("journal_mode = WAL");
6
+ db.pragma("foreign_keys = ON");
7
+ return db;
8
+ }
@@ -0,0 +1,26 @@
1
+ import type Database from "better-sqlite3";
2
+ import { MIGRATIONS } from "./schema.js";
3
+
4
+ export function runMigrations(db: Database.Database): void {
5
+ db.exec(`
6
+ CREATE TABLE IF NOT EXISTS migrations (
7
+ version INTEGER PRIMARY KEY,
8
+ applied_at TEXT NOT NULL
9
+ );
10
+ `);
11
+
12
+ const applied = db
13
+ .prepare<[], { version: number }>("SELECT version FROM migrations ORDER BY version")
14
+ .all()
15
+ .map((r) => r.version);
16
+
17
+ const pending = MIGRATIONS.filter((m) => !applied.includes(m.version));
18
+
19
+ for (const migration of pending) {
20
+ db.exec(migration.sql);
21
+ db.prepare("INSERT INTO migrations (version, applied_at) VALUES (?, ?)").run(
22
+ migration.version,
23
+ new Date().toISOString()
24
+ );
25
+ }
26
+ }
@@ -0,0 +1,68 @@
1
+ export type Migration = {
2
+ version: number;
3
+ sql: string;
4
+ };
5
+
6
+ export const MIGRATIONS: Migration[] = [
7
+ {
8
+ version: 1,
9
+ sql: `
10
+ CREATE TABLE IF NOT EXISTS memories (
11
+ id TEXT PRIMARY KEY,
12
+ created_at TEXT NOT NULL,
13
+ updated_at TEXT NOT NULL,
14
+ repo_id TEXT NOT NULL,
15
+ repo_path TEXT,
16
+ repo_name TEXT,
17
+ branch TEXT,
18
+ commit_sha TEXT,
19
+ task TEXT,
20
+ intent TEXT NOT NULL,
21
+ summary TEXT NOT NULL,
22
+ decision TEXT NOT NULL,
23
+ why TEXT NOT NULL,
24
+ source TEXT NOT NULL,
25
+ raw_transcript_path TEXT,
26
+ embedding_text TEXT NOT NULL
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS memory_files (
30
+ memory_id TEXT NOT NULL,
31
+ file_path TEXT NOT NULL,
32
+ PRIMARY KEY (memory_id, file_path),
33
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
34
+ );
35
+
36
+ CREATE TABLE IF NOT EXISTS memory_tags (
37
+ memory_id TEXT NOT NULL,
38
+ tag TEXT NOT NULL,
39
+ PRIMARY KEY (memory_id, tag),
40
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS memory_alternatives (
44
+ memory_id TEXT NOT NULL,
45
+ value TEXT NOT NULL,
46
+ UNIQUE(memory_id, value)
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS memory_risks (
50
+ memory_id TEXT NOT NULL,
51
+ value TEXT NOT NULL,
52
+ UNIQUE(memory_id, value)
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS memory_followups (
56
+ memory_id TEXT NOT NULL,
57
+ value TEXT NOT NULL,
58
+ UNIQUE(memory_id, value)
59
+ );
60
+
61
+ CREATE INDEX IF NOT EXISTS idx_memories_repo_id ON memories(repo_id);
62
+ CREATE INDEX IF NOT EXISTS idx_memories_repo_path ON memories(repo_path);
63
+ CREATE INDEX IF NOT EXISTS idx_memories_commit_sha ON memories(commit_sha);
64
+ CREATE INDEX IF NOT EXISTS idx_memory_files_file_path ON memory_files(file_path);
65
+ CREATE INDEX IF NOT EXISTS idx_memory_tags_tag ON memory_tags(tag);
66
+ `,
67
+ },
68
+ ];
@@ -0,0 +1,43 @@
1
+ import { execSync } from "child_process";
2
+
3
+ export function getChangedFilesForCommit(repoRoot: string, commitSha: string): string[] {
4
+ try {
5
+ // --name-status gives type + paths; we parse renames to include both old and new paths
6
+ const output = execSync(`git diff-tree --no-commit-id -r --name-status ${commitSha}`, {
7
+ cwd: repoRoot,
8
+ encoding: "utf-8",
9
+ });
10
+ const paths = new Set<string>();
11
+ for (const line of output.trim().split("\n").filter(Boolean)) {
12
+ const parts = line.split("\t");
13
+ const status = parts[0];
14
+ if (status.startsWith("R") || status.startsWith("C")) {
15
+ // Rename or copy: parts[1] = old path, parts[2] = new path
16
+ if (parts[1]) paths.add(parts[1]);
17
+ if (parts[2]) paths.add(parts[2]);
18
+ } else {
19
+ // Added, modified, deleted: parts[1] = path
20
+ if (parts[1]) paths.add(parts[1]);
21
+ }
22
+ }
23
+ return [...paths];
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ export function getFileRenameHistory(repoRoot: string, filePath: string): string[] {
30
+ try {
31
+ const output = execSync(
32
+ `git log --follow --name-only --format="" -- ${filePath}`,
33
+ { cwd: repoRoot, encoding: "utf-8" }
34
+ );
35
+ const paths = new Set<string>([filePath]);
36
+ for (const line of output.trim().split("\n").filter(Boolean)) {
37
+ paths.add(line.trim());
38
+ }
39
+ return [...paths];
40
+ } catch {
41
+ return [filePath];
42
+ }
43
+ }
package/src/git/git.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { execSync } from "child_process";
2
+
3
+ export function getRepoRoot(cwd: string): string | null {
4
+ try {
5
+ return execSync("git rev-parse --show-toplevel", { cwd, encoding: "utf-8" }).trim();
6
+ } catch {
7
+ return null;
8
+ }
9
+ }
10
+
11
+ export function getCurrentBranch(repoRoot: string): string | null {
12
+ try {
13
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
14
+ cwd: repoRoot,
15
+ encoding: "utf-8",
16
+ }).trim();
17
+ return branch === "HEAD" ? null : branch;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ export function resolveCommit(repoRoot: string, ref: string): string {
24
+ return execSync(`git rev-parse ${ref}`, { cwd: repoRoot, encoding: "utf-8" }).trim();
25
+ }
@@ -0,0 +1,49 @@
1
+ import { execSync } from "child_process";
2
+ import crypto from "crypto";
3
+ import path from "path";
4
+
5
+ export function normalizeRemoteUrl(url: string): string {
6
+ return url
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/\.git$/, "")
10
+ .replace(/^git@([^:]+):(.+)$/, "https://$1/$2");
11
+ }
12
+
13
+ export function getRepoId(repoRoot: string): string {
14
+ try {
15
+ const remote = execSync("git config --get remote.origin.url", {
16
+ cwd: repoRoot,
17
+ encoding: "utf-8",
18
+ }).trim();
19
+ if (remote) {
20
+ return crypto
21
+ .createHash("sha256")
22
+ .update(normalizeRemoteUrl(remote))
23
+ .digest("hex")
24
+ .slice(0, 32);
25
+ }
26
+ } catch {
27
+ // no remote — fall through
28
+ }
29
+ return crypto
30
+ .createHash("sha256")
31
+ .update(path.resolve(repoRoot))
32
+ .digest("hex")
33
+ .slice(0, 32);
34
+ }
35
+
36
+ export function getRepoName(repoRoot: string): string {
37
+ try {
38
+ const remote = execSync("git config --get remote.origin.url", {
39
+ cwd: repoRoot,
40
+ encoding: "utf-8",
41
+ }).trim();
42
+ if (remote) {
43
+ return path.basename(normalizeRemoteUrl(remote));
44
+ }
45
+ } catch {
46
+ // no remote
47
+ }
48
+ return path.basename(repoRoot);
49
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if command -v whyline >/dev/null 2>&1; then
5
+ echo ""
6
+ echo "You can save AI coding memory for this commit:"
7
+ echo " whyline save --commit HEAD --summary-file memory.md"
8
+ echo ""
9
+ fi