-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathhandoff-writer.mjs
More file actions
206 lines (170 loc) · 7.77 KB
/
handoff-writer.mjs
File metadata and controls
206 lines (170 loc) · 7.77 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
#!/usr/bin/env node
/**
* Handoff Writer — sync session-handoff between repo and Claude memory.
*
* Replaces the external .claude/scripts/sync-handoff.mjs dependency.
* The plugin now carries its own handoff sync logic for portability.
*
* Usage (from session-stop.mjs):
* import { syncHandoffToMemory } from "./handoff-writer.mjs";
* syncHandoffToMemory(repoRoot, handoffRelPath);
*
* Memory directory auto-discovery:
* 1. Compute expected slug from repo root path (case-preserving)
* 2. Verify directory exists at ~/.claude/projects/<slug>/memory/
* 3. Fallback: case-insensitive match
* 4. Fallback: multi-segment scan
*/
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
// ── Slug computation ─────────────────────────────────────
/**
* Compute the Claude Code project slug from an absolute path.
*
* Algorithm (verified against actual ~/.claude/projects/ entries):
* - Replace every non-alphanumeric-non-hyphen char with '-'
* - Preserve original case (Claude Code does NOT lowercase)
* - Strip leading/trailing hyphens
*
* Examples:
* "d:\\claude-tools\\.claude\\mcp-servers\\slack\\next"
* → "d--claude-tools--claude-mcp-servers-slack-next"
* "D:\\Trader"
* → "D--Trader"
* "/home/user/project"
* → "home-user-project" (leading hyphen stripped)
*/
export function projectSlug(absPath) {
return absPath.replace(/[^a-zA-Z0-9-]/g, "-").replace(/^-+|-+$/g, "");
}
// ── Memory directory resolution ──────────────────────────
/**
* Find the Claude Code memory directory for a given repo root.
* Returns the absolute path to the memory/ dir, or null if not found.
*/
export function findMemoryDir(repoRoot) {
const claudeProjectsDir = resolve(homedir(), ".claude", "projects");
if (!existsSync(claudeProjectsDir)) return null;
const slug = projectSlug(repoRoot);
// 1. Exact match (fast path)
const exactDir = resolve(claudeProjectsDir, slug, "memory");
if (existsSync(exactDir)) return exactDir;
// 2. Case-insensitive match (Windows drive letter D: vs d:)
let entries;
try { entries = readdirSync(claudeProjectsDir); } catch { return null; }
const slugLower = slug.toLowerCase();
for (const entry of entries) {
if (entry.toLowerCase() !== slugLower) continue;
const memDir = resolve(claudeProjectsDir, entry, "memory");
if (existsSync(memDir)) return memDir;
}
// 3. Multi-segment fallback — extract meaningful segments from the full path
// and require ALL to be present in the candidate directory name.
// Use full path segments (not just basename) to avoid false matches
// on generic names like "next" or "app".
const pathSegments = repoRoot
.replace(/[^a-zA-Z0-9-]/g, "-") // same transform as slug
.split(/-+/) // split on hyphens
.filter((s) => s.length >= 3); // skip short segments (d, C, etc.)
if (pathSegments.length < 2) return null; // not enough to disambiguate
for (const entry of entries) {
const memDir = resolve(claudeProjectsDir, entry, "memory");
if (!existsSync(memDir)) continue;
const entryLower = entry.toLowerCase();
const allMatch = pathSegments.every((seg) => entryLower.includes(seg.toLowerCase()));
if (allMatch) return memDir;
}
return null;
}
// ── Frontmatter helpers ──────────────────────────────────
const FRONTMATTER_TEMPLATES = {
ko: {
name: "세션 핸드오프",
description: "진행 중인 작업 목록 — 세션 시작 시 반드시 읽고 이어할 작업 확인",
},
en: {
name: "Session Handoff",
description: "Active task list — read at session start to resume pending work",
},
};
function buildFrontmatter(locale) {
const tmpl = FRONTMATTER_TEMPLATES[locale] ?? FRONTMATTER_TEMPLATES.en;
return `---\nname: ${tmpl.name}\ndescription: ${tmpl.description}\ntype: project\n---\n\n`;
}
// ── Sync operations ──────────────────────────────────────
/**
* Sync handoff from repo to Claude memory directory.
*
* @param {string} repoRoot - Absolute path to the repository root
* @param {string} handoffRelPath - Relative path to handoff file
* @param {{ locale?: string }} [opts]
* @returns {{ success: boolean, memoryDir?: string, error?: string }}
*/
export function syncHandoffToMemory(repoRoot, handoffRelPath, opts = {}) {
const repoHandoff = resolve(repoRoot, handoffRelPath);
if (!existsSync(repoHandoff)) {
return { success: false, error: "repo_handoff_not_found" };
}
const memoryDir = findMemoryDir(repoRoot);
if (!memoryDir) {
return { success: false, error: "memory_dir_not_found" };
}
const memoryFile = resolve(memoryDir, "session_handoff.md");
// "Newer wins" — 세션 중 메모리를 직접 수정했다면 repo 버전으로 덮어쓰지 않음
if (existsSync(memoryFile)) {
try {
const repoMtime = statSync(repoHandoff).mtimeMs;
const memMtime = statSync(memoryFile).mtimeMs;
if (memMtime > repoMtime) {
return { success: true, memoryDir, skipped: "memory_is_newer" };
}
} catch { /* stat 실패 시 기존 동작 유지 */ }
}
const content = readFileSync(repoHandoff, "utf8");
// Write as memory-format file with frontmatter (locale-aware)
const memoryContent = content.startsWith("---")
? content // Already has frontmatter
: buildFrontmatter(opts.locale ?? "en") + content;
if (!existsSync(memoryDir)) mkdirSync(memoryDir, { recursive: true });
writeFileSync(memoryFile, memoryContent, "utf8");
return { success: true, memoryDir };
}
/**
* Sync handoff from Claude memory to repo (reverse direction).
* Used at session start to ensure repo file is up to date.
*
* @param {string} repoRoot - Absolute path to the repository root
* @param {string} handoffRelPath - Relative path to handoff file
* @returns {{ success: boolean, updated: boolean }}
*/
export function syncHandoffFromMemory(repoRoot, handoffRelPath) {
const memoryDir = findMemoryDir(repoRoot);
if (!memoryDir) return { success: false, updated: false };
const memoryFile = resolve(memoryDir, "session_handoff.md");
if (!existsSync(memoryFile)) return { success: false, updated: false };
const memContent = readFileSync(memoryFile, "utf8");
const repoHandoff = resolve(repoRoot, handoffRelPath);
const repoContent = existsSync(repoHandoff) ? readFileSync(repoHandoff, "utf8") : "";
// Skip if content is identical
if (memContent === repoContent) return { success: true, updated: false };
// Write memory content to repo (preserving frontmatter if present)
const repoDir = resolve(repoRoot, handoffRelPath, "..");
if (!existsSync(repoDir)) mkdirSync(repoDir, { recursive: true });
writeFileSync(repoHandoff, memContent, "utf8");
return { success: true, updated: true };
}
// ── CLI entry point ──────────────────────────────────────
// Allows standalone execution: node handoff-writer.mjs [repo-root] [handoff-path]
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const { REPO_ROOT, cfg, safeLocale } = await import("./context.mjs");
const handoffFile = cfg.plugin?.handoff_file ?? ".claude/session-handoff.md";
const locale = safeLocale;
const result = syncHandoffToMemory(REPO_ROOT, handoffFile, { locale });
if (result.success) {
console.log(`[handoff] Synced to memory: ${result.memoryDir}`);
} else {
console.log(`[handoff] Skip: ${result.error}`);
}
}