dev-memory-cli 0.18.2
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/LICENSE +21 -0
- package/README.md +362 -0
- package/bin/dev-memory.js +590 -0
- package/hooks/README.md +84 -0
- package/hooks/codex-hooks.json +32 -0
- package/hooks/hooks.json +60 -0
- package/lib/assets/tidy_review.html +889 -0
- package/lib/dev_memory_branch.py +853 -0
- package/lib/dev_memory_capture.py +1343 -0
- package/lib/dev_memory_common.py +1934 -0
- package/lib/dev_memory_context.py +129 -0
- package/lib/dev_memory_graduate.py +282 -0
- package/lib/dev_memory_setup.py +256 -0
- package/lib/dev_memory_tidy.py +1622 -0
- package/lib/ui-app.html +1052 -0
- package/lib/ui-server.js +330 -0
- package/package.json +52 -0
- package/scripts/hooks/_common.py +456 -0
- package/scripts/hooks/pre_compact.py +21 -0
- package/scripts/hooks/session_end.py +35 -0
- package/scripts/hooks/session_start.py +51 -0
- package/scripts/hooks/stop.py +35 -0
- package/suite-manifest.json +26 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
|
|
8
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
9
|
+
const DEFAULT_STORAGE_ROOT = path.join(os.homedir(), ".dev-memory", "repos");
|
|
10
|
+
|
|
11
|
+
function fail(message) {
|
|
12
|
+
process.stderr.write(`ERROR: ${message}\n`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const positional = [];
|
|
18
|
+
const options = {};
|
|
19
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
20
|
+
const arg = argv[i];
|
|
21
|
+
if (!arg.startsWith("--")) {
|
|
22
|
+
positional.push(arg);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const key = arg.slice(2);
|
|
26
|
+
const next = argv[i + 1];
|
|
27
|
+
if (next && !next.startsWith("--")) {
|
|
28
|
+
options[key] = next;
|
|
29
|
+
i += 1;
|
|
30
|
+
} else {
|
|
31
|
+
options[key] = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { positional, options };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findPython() {
|
|
38
|
+
for (const name of ["python3", "python"]) {
|
|
39
|
+
const probe = spawnSync(name, ["--version"], { encoding: "utf8" });
|
|
40
|
+
if (probe.status === 0) {
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
fail("python3 is required");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function packageScript(...parts) {
|
|
48
|
+
return path.join(PACKAGE_ROOT, ...parts);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runPython(scriptPath, args, cwd = process.cwd(), extraEnv = {}) {
|
|
52
|
+
const python = findPython();
|
|
53
|
+
// Only forward explicit storage-root env vars from the parent shell. Don't
|
|
54
|
+
// inject DEFAULT_STORAGE_ROOT here — that would short-circuit Python's
|
|
55
|
+
// fallback chain (which prefers legacy ~/.dev-assets if it has data and
|
|
56
|
+
// ~/.dev-memory does not).
|
|
57
|
+
const env = { ...process.env, ...extraEnv };
|
|
58
|
+
const result = spawnSync(python, [scriptPath, ...args], {
|
|
59
|
+
cwd,
|
|
60
|
+
env,
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
63
|
+
});
|
|
64
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
65
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
66
|
+
if (result.status !== 0) {
|
|
67
|
+
process.exit(result.status || 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runPythonCapture(scriptPath, args, cwd = process.cwd(), extraEnv = {}) {
|
|
72
|
+
const python = findPython();
|
|
73
|
+
const env = { ...process.env, ...extraEnv };
|
|
74
|
+
const result = spawnSync(python, [scriptPath, ...args], {
|
|
75
|
+
cwd,
|
|
76
|
+
env,
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
79
|
+
});
|
|
80
|
+
return { status: result.status, stdout: result.stdout || "", stderr: result.stderr || "" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildSessionStartContext(repoRoot) {
|
|
84
|
+
const script = packageScript("scripts", "hooks", "session_start.py");
|
|
85
|
+
const env = {
|
|
86
|
+
DEV_MEMORY_HOOK_REPO_ROOT: repoRoot,
|
|
87
|
+
DEV_ASSETS_HOOK_REPO_ROOT: repoRoot,
|
|
88
|
+
};
|
|
89
|
+
runPython(script, [], repoRoot, env);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function runHookAction(action, repoRoot) {
|
|
93
|
+
const scriptMap = {
|
|
94
|
+
"session-start": packageScript("scripts", "hooks", "session_start.py"),
|
|
95
|
+
"pre-compact": packageScript("scripts", "hooks", "pre_compact.py"),
|
|
96
|
+
stop: packageScript("scripts", "hooks", "stop.py"),
|
|
97
|
+
"session-end": packageScript("scripts", "hooks", "session_end.py"),
|
|
98
|
+
};
|
|
99
|
+
const script = scriptMap[action];
|
|
100
|
+
if (!script) {
|
|
101
|
+
fail(`unknown hook action: ${action}`);
|
|
102
|
+
}
|
|
103
|
+
const env = {
|
|
104
|
+
DEV_MEMORY_HOOK_REPO_ROOT: repoRoot,
|
|
105
|
+
DEV_ASSETS_HOOK_REPO_ROOT: repoRoot,
|
|
106
|
+
};
|
|
107
|
+
runPython(script, [], repoRoot, env);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function loadJson(filePath) {
|
|
111
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function writeJson(filePath, data) {
|
|
115
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
116
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function templatePathForAgent(agent) {
|
|
120
|
+
if (agent === "codex") return packageScript("hooks", "codex-hooks.json");
|
|
121
|
+
if (agent === "claude") return packageScript("hooks", "hooks.json");
|
|
122
|
+
fail(`unsupported agent: ${agent}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function targetPathForAgent(agent, repoRoot) {
|
|
126
|
+
if (agent === "codex") return path.join(repoRoot, ".codex", "hooks.json");
|
|
127
|
+
if (agent === "claude") return path.join(repoRoot, ".claude", "settings.local.json");
|
|
128
|
+
fail(`unsupported agent: ${agent}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function globalTargetPathForAgent(agent) {
|
|
132
|
+
if (agent === "codex") return path.join(os.homedir(), ".codex", "hooks.json");
|
|
133
|
+
if (agent === "claude") return path.join(os.homedir(), ".claude", "settings.json");
|
|
134
|
+
fail(`unsupported agent: ${agent}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function mergeHookLists(existingItems, incomingItems) {
|
|
138
|
+
const merged = existingItems.map((item) => ({ ...item }));
|
|
139
|
+
const index = new Map();
|
|
140
|
+
merged.forEach((item, i) => index.set(`${item.id || ""}\u0000${item.matcher || ""}`, i));
|
|
141
|
+
for (const item of incomingItems) {
|
|
142
|
+
const copied = { ...item };
|
|
143
|
+
const key = `${copied.id || ""}\u0000${copied.matcher || ""}`;
|
|
144
|
+
if (index.has(key)) {
|
|
145
|
+
merged[index.get(key)] = copied;
|
|
146
|
+
} else {
|
|
147
|
+
index.set(key, merged.length);
|
|
148
|
+
merged.push(copied);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return merged;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function mergeConfig(existingConfig, templateConfig) {
|
|
155
|
+
const result = { ...existingConfig };
|
|
156
|
+
const existingHooks = existingConfig.hooks || {};
|
|
157
|
+
const templateHooks = templateConfig.hooks || {};
|
|
158
|
+
const mergedHooks = {};
|
|
159
|
+
for (const eventName of [...new Set([...Object.keys(existingHooks), ...Object.keys(templateHooks)])].sort()) {
|
|
160
|
+
mergedHooks[eventName] = mergeHookLists(existingHooks[eventName] || [], templateHooks[eventName] || []);
|
|
161
|
+
}
|
|
162
|
+
result.hooks = mergedHooks;
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function commandHook(positional, options) {
|
|
167
|
+
const action = positional[0];
|
|
168
|
+
const repoRoot = path.resolve(options.repo || process.cwd());
|
|
169
|
+
runHookAction(action, repoRoot);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function installHooksForAgent(agent, options) {
|
|
173
|
+
const isGlobal = Boolean(options.global);
|
|
174
|
+
const template = loadJson(templatePathForAgent(agent));
|
|
175
|
+
let targetPath;
|
|
176
|
+
let scope;
|
|
177
|
+
let repoRoot = null;
|
|
178
|
+
if (isGlobal) {
|
|
179
|
+
targetPath = globalTargetPathForAgent(agent);
|
|
180
|
+
scope = "global";
|
|
181
|
+
} else {
|
|
182
|
+
repoRoot = path.resolve(options.repo || process.cwd());
|
|
183
|
+
targetPath = targetPathForAgent(agent, repoRoot);
|
|
184
|
+
scope = "repo";
|
|
185
|
+
}
|
|
186
|
+
const existing = fs.existsSync(targetPath) ? loadJson(targetPath) : {};
|
|
187
|
+
const merged = mergeConfig(existing, template);
|
|
188
|
+
writeJson(targetPath, merged);
|
|
189
|
+
const report = { agent, scope, target: targetPath, events: Object.keys(template.hooks || {}) };
|
|
190
|
+
if (repoRoot) report.repo_root = repoRoot;
|
|
191
|
+
return report;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function commandInstallHooks(positional, options) {
|
|
195
|
+
const isAll = Boolean(options.all);
|
|
196
|
+
const agents = isAll ? ["codex", "claude"] : [positional[0] || options.agent || "codex"];
|
|
197
|
+
const reports = agents.map((agent) => installHooksForAgent(agent, options));
|
|
198
|
+
process.stdout.write(`${JSON.stringify(isAll ? reports : reports[0], null, 2)}\n`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function commandUi(_positional, options) {
|
|
202
|
+
const { start } = require(path.join(PACKAGE_ROOT, "lib", "ui-server.js"));
|
|
203
|
+
const host = options.host || "127.0.0.1";
|
|
204
|
+
const port = options.port != null && options.port !== true ? Number(options.port) : 0;
|
|
205
|
+
const openBrowserFlag = !options["no-open"];
|
|
206
|
+
const readOnly = !!options["read-only"];
|
|
207
|
+
start({ host, port, openBrowserFlag, readOnly });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Subcommands that delegate straight to the Python lib/ scripts.
|
|
211
|
+
// Their CLIs (subcommands, flags) are owned by argparse on the Python
|
|
212
|
+
// side, so we deliberately bypass parseArgs and forward raw argv.
|
|
213
|
+
const PY_SUBCOMMAND_SCRIPTS = {
|
|
214
|
+
context: "dev_memory_context.py",
|
|
215
|
+
capture: "dev_memory_capture.py",
|
|
216
|
+
setup: "dev_memory_setup.py",
|
|
217
|
+
graduate: "dev_memory_graduate.py",
|
|
218
|
+
tidy: "dev_memory_tidy.py",
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
function commandPySubcommand(name, rawArgs) {
|
|
222
|
+
const scriptPath = packageScript("lib", PY_SUBCOMMAND_SCRIPTS[name]);
|
|
223
|
+
if (!fs.existsSync(scriptPath)) {
|
|
224
|
+
fail(`missing lib script: ${scriptPath}`);
|
|
225
|
+
}
|
|
226
|
+
runPython(scriptPath, rawArgs);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function branchScript() {
|
|
230
|
+
return packageScript("lib", "dev_memory_branch.py");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function callBranchPython(args) {
|
|
234
|
+
const result = runPythonCapture(branchScript(), args);
|
|
235
|
+
if (result.status !== 0) {
|
|
236
|
+
let message = result.stderr.trim() || `branch op failed (exit ${result.status})`;
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(result.stderr.trim());
|
|
239
|
+
if (parsed && parsed.error) message = parsed.error;
|
|
240
|
+
} catch (_) {
|
|
241
|
+
// not json — keep raw stderr
|
|
242
|
+
}
|
|
243
|
+
return { ok: false, error: message };
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
return { ok: true, data: JSON.parse(result.stdout.trim()) };
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return { ok: false, error: `unable to parse branch output: ${err.message}` };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function loadClack() {
|
|
253
|
+
try {
|
|
254
|
+
return require("@clack/prompts");
|
|
255
|
+
} catch (_) {
|
|
256
|
+
fail(
|
|
257
|
+
"@clack/prompts is not installed. Run `npm install` inside the dev-memory-cli "
|
|
258
|
+
+ "package, or use the non-interactive forms: dev-memory-cli branch rename|fork ...",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function relativeAge(iso) {
|
|
264
|
+
if (!iso) return "未初始化";
|
|
265
|
+
const t = Date.parse(iso);
|
|
266
|
+
if (Number.isNaN(t)) return iso;
|
|
267
|
+
const diff = Date.now() - t;
|
|
268
|
+
const min = Math.round(diff / 60000);
|
|
269
|
+
if (min < 1) return "刚刚";
|
|
270
|
+
if (min < 60) return `${min} 分钟前`;
|
|
271
|
+
const hr = Math.round(min / 60);
|
|
272
|
+
if (hr < 24) return `${hr} 小时前`;
|
|
273
|
+
const day = Math.round(hr / 24);
|
|
274
|
+
if (day < 30) return `${day} 天前`;
|
|
275
|
+
const mon = Math.round(day / 30);
|
|
276
|
+
return `${mon} 个月前`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function describeBranchRow(row) {
|
|
280
|
+
const tags = [];
|
|
281
|
+
if (row.git_exists) tags.push("git");
|
|
282
|
+
if (!row.memory_exists) tags.push("无记忆");
|
|
283
|
+
else if (row.is_skeleton) tags.push("空骨架");
|
|
284
|
+
else tags.push(`${row.entry_count} 条记忆`);
|
|
285
|
+
if (row.last_updated) tags.push(relativeAge(row.last_updated));
|
|
286
|
+
return tags.join(" · ");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function runConflictPrompt(p, target) {
|
|
290
|
+
const used = target.memory_exists && !target.is_skeleton;
|
|
291
|
+
if (!used) return { mode: null };
|
|
292
|
+
const choice = await p.select({
|
|
293
|
+
message: `目标分支 ${target.name} 已有 ${target.entry_count} 条记忆,怎么处理?`,
|
|
294
|
+
options: [
|
|
295
|
+
{ value: "backup", label: "备份后覆盖", hint: "移到 _archived/ 后再迁移(推荐)" },
|
|
296
|
+
{ value: "force", label: "强制覆盖", hint: "直接删除,无法恢复" },
|
|
297
|
+
{ value: "cancel", label: "返回上一步" },
|
|
298
|
+
],
|
|
299
|
+
initialValue: "backup",
|
|
300
|
+
});
|
|
301
|
+
if (p.isCancel(choice) || choice === "cancel") return { cancelled: true };
|
|
302
|
+
return { mode: choice };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function buildPlanArgs(plan) {
|
|
306
|
+
const args = [plan.op];
|
|
307
|
+
if (plan.op === "rename" || plan.op === "fork") {
|
|
308
|
+
args.push("--source", plan.source, "--target", plan.target);
|
|
309
|
+
} else if (plan.op === "delete" || plan.op === "init") {
|
|
310
|
+
args.push("--branch", plan.branch);
|
|
311
|
+
}
|
|
312
|
+
if (plan.mode === "force") args.push("--force");
|
|
313
|
+
if (plan.mode === "backup") args.push("--backup");
|
|
314
|
+
return args;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function summarizePlan(plan) {
|
|
318
|
+
if (plan.op === "rename" || plan.op === "fork") {
|
|
319
|
+
return `${plan.op}: ${plan.source} → ${plan.target}` + (plan.mode ? ` · 冲突处理: ${plan.mode}` : "");
|
|
320
|
+
}
|
|
321
|
+
return `${plan.op}: ${plan.branch} · 模式: ${plan.mode}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function describePlanResult(plan, data) {
|
|
325
|
+
if (plan.op === "rename" || plan.op === "fork") {
|
|
326
|
+
return `已完成 ${plan.op}:${data.source} → ${data.target}`;
|
|
327
|
+
}
|
|
328
|
+
if (plan.op === "delete") {
|
|
329
|
+
return `已删除 ${data.branch} 的记忆(${data.mode})`;
|
|
330
|
+
}
|
|
331
|
+
return `已重置 ${data.branch}(${data.mode})`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function pickBranchAutocomplete(p, message, options, { allowManual = false } = {}) {
|
|
335
|
+
const finalOptions = options.slice();
|
|
336
|
+
if (allowManual) {
|
|
337
|
+
finalOptions.push({
|
|
338
|
+
value: "__manual__",
|
|
339
|
+
label: "手动输入分支名…",
|
|
340
|
+
hint: "新分支或不在列表里",
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const picked = await p.autocomplete({
|
|
344
|
+
message,
|
|
345
|
+
options: finalOptions,
|
|
346
|
+
placeholder: "输入关键字过滤,↑↓ 选择,回车确认",
|
|
347
|
+
maxItems: 8,
|
|
348
|
+
});
|
|
349
|
+
if (p.isCancel(picked)) return null;
|
|
350
|
+
if (picked === "__manual__") {
|
|
351
|
+
const typed = await p.text({
|
|
352
|
+
message: "输入分支名",
|
|
353
|
+
validate: (v) => (!v || !v.trim() ? "不能为空" : undefined),
|
|
354
|
+
});
|
|
355
|
+
if (p.isCancel(typed)) return null;
|
|
356
|
+
return typed.trim();
|
|
357
|
+
}
|
|
358
|
+
return picked;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function pickDestructiveMode(p, message) {
|
|
362
|
+
const choice = await p.select({
|
|
363
|
+
message,
|
|
364
|
+
options: [
|
|
365
|
+
{ value: "backup", label: "备份后执行", hint: "移到 _archived/,可恢复(推荐)" },
|
|
366
|
+
{ value: "force", label: "强制执行", hint: "直接销毁,无法恢复" },
|
|
367
|
+
{ value: "cancel", label: "返回上一步" },
|
|
368
|
+
],
|
|
369
|
+
initialValue: "backup",
|
|
370
|
+
});
|
|
371
|
+
if (p.isCancel(choice) || choice === "cancel") return null;
|
|
372
|
+
return choice;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function interactiveFromUsedBranch(p, snapshot) {
|
|
376
|
+
const action = await p.select({
|
|
377
|
+
message: `当前分支 ${snapshot.current_branch} 已有 ${snapshot.current.entry_count} 条记忆,要做什么?`,
|
|
378
|
+
options: [
|
|
379
|
+
{ value: "rename", label: "迁移到另一个分支", hint: "rename — 当前分支记忆消失" },
|
|
380
|
+
{ value: "fork", label: "复制到另一个分支", hint: "fork — 当前分支保留" },
|
|
381
|
+
{ value: "init", label: "重置当前分支", hint: "回到空骨架" },
|
|
382
|
+
{ value: "delete", label: "删除当前分支的记忆", hint: "整个目录移走/销毁" },
|
|
383
|
+
{ value: "cancel", label: "取消" },
|
|
384
|
+
],
|
|
385
|
+
initialValue: "fork",
|
|
386
|
+
});
|
|
387
|
+
if (p.isCancel(action) || action === "cancel") return null;
|
|
388
|
+
|
|
389
|
+
if (action === "delete" || action === "init") {
|
|
390
|
+
const mode = await pickDestructiveMode(
|
|
391
|
+
p,
|
|
392
|
+
action === "delete"
|
|
393
|
+
? `确认删除 ${snapshot.current_branch} 的 ${snapshot.current.entry_count} 条记忆?`
|
|
394
|
+
: `确认重置 ${snapshot.current_branch}(${snapshot.current.entry_count} 条记忆将丢失)?`,
|
|
395
|
+
);
|
|
396
|
+
if (!mode) return null;
|
|
397
|
+
return { op: action, branch: snapshot.current_branch, mode };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// rename / fork
|
|
401
|
+
const candidates = snapshot.branches
|
|
402
|
+
.filter((b) => b.name !== snapshot.current_branch)
|
|
403
|
+
.map((b) => ({ value: b.name, label: b.name, hint: describeBranchRow(b) }));
|
|
404
|
+
|
|
405
|
+
const target = await pickBranchAutocomplete(p, "目标分支:", candidates, { allowManual: true });
|
|
406
|
+
if (!target) return null;
|
|
407
|
+
|
|
408
|
+
const targetRow = snapshot.branches.find((b) => b.name === target) || {
|
|
409
|
+
name: target,
|
|
410
|
+
memory_exists: false,
|
|
411
|
+
is_skeleton: true,
|
|
412
|
+
deviations: [],
|
|
413
|
+
entry_count: 0,
|
|
414
|
+
};
|
|
415
|
+
const conflict = await runConflictPrompt(p, targetRow);
|
|
416
|
+
if (conflict.cancelled) return null;
|
|
417
|
+
|
|
418
|
+
return { op: action, source: snapshot.current_branch, target, mode: conflict.mode };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function interactiveFromEmptyBranch(p, snapshot) {
|
|
422
|
+
const candidates = snapshot.branches
|
|
423
|
+
.filter((b) => b.name !== snapshot.current_branch && b.memory_exists && !b.is_skeleton)
|
|
424
|
+
.map((b) => ({ value: b.name, label: b.name, hint: describeBranchRow(b) }));
|
|
425
|
+
if (candidates.length === 0) {
|
|
426
|
+
p.log.warn("没找到其他分支有可迁移的记忆,无事可做。");
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
const source = await pickBranchAutocomplete(
|
|
430
|
+
p,
|
|
431
|
+
`当前分支 ${snapshot.current_branch} 还没用过,从哪个分支接力?`,
|
|
432
|
+
candidates,
|
|
433
|
+
);
|
|
434
|
+
if (!source) return null;
|
|
435
|
+
|
|
436
|
+
const op = await p.select({
|
|
437
|
+
message: "方式:",
|
|
438
|
+
options: [
|
|
439
|
+
{ value: "fork", label: "fork — 复制过来", hint: `${source} 保留` },
|
|
440
|
+
{ value: "rename", label: "rename — 搬过来", hint: `${source} 消失` },
|
|
441
|
+
],
|
|
442
|
+
initialValue: "fork",
|
|
443
|
+
});
|
|
444
|
+
if (p.isCancel(op)) return null;
|
|
445
|
+
|
|
446
|
+
// Current branch is, by construction, an empty skeleton, so no conflict prompt
|
|
447
|
+
// is needed — python's _resolve_conflict will silently overwrite it.
|
|
448
|
+
return { op, source, target: snapshot.current_branch, mode: null };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function commandBranchInteractive() {
|
|
452
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
453
|
+
fail(
|
|
454
|
+
"interactive branch flow requires a TTY. Use the non-interactive form instead:\n"
|
|
455
|
+
+ " dev-memory-cli branch list\n"
|
|
456
|
+
+ " dev-memory-cli branch rename --source A --target B [--force | --backup]\n"
|
|
457
|
+
+ " dev-memory-cli branch fork --source A --target B [--force | --backup]",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
const p = loadClack();
|
|
461
|
+
p.intro("dev-memory · 分支记忆迁移");
|
|
462
|
+
|
|
463
|
+
const listed = callBranchPython(["list"]);
|
|
464
|
+
if (!listed.ok) {
|
|
465
|
+
p.cancel(listed.error);
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
const snapshot = listed.data;
|
|
469
|
+
if (!snapshot.current_branch) {
|
|
470
|
+
p.cancel("当前 HEAD 处于游离状态,请先 checkout 一个分支再试。");
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
const current = snapshot.branches.find((b) => b.name === snapshot.current_branch);
|
|
474
|
+
if (!current) {
|
|
475
|
+
p.cancel(`未在分支列表中找到当前分支 ${snapshot.current_branch}。`);
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
snapshot.current = current;
|
|
479
|
+
|
|
480
|
+
let plan;
|
|
481
|
+
if (current.memory_exists && !current.is_skeleton) {
|
|
482
|
+
plan = await interactiveFromUsedBranch(p, snapshot);
|
|
483
|
+
} else {
|
|
484
|
+
plan = await interactiveFromEmptyBranch(p, snapshot);
|
|
485
|
+
}
|
|
486
|
+
if (!plan) {
|
|
487
|
+
p.cancel("已取消。");
|
|
488
|
+
process.exit(0);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const confirmed = await p.confirm({
|
|
492
|
+
message: `确认执行 ${summarizePlan(plan)}?`,
|
|
493
|
+
initialValue: true,
|
|
494
|
+
});
|
|
495
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
496
|
+
p.cancel("已取消。");
|
|
497
|
+
process.exit(0);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const result = callBranchPython(buildPlanArgs(plan));
|
|
501
|
+
if (!result.ok) {
|
|
502
|
+
p.log.error(result.error);
|
|
503
|
+
p.outro("失败");
|
|
504
|
+
process.exit(1);
|
|
505
|
+
}
|
|
506
|
+
p.log.success(describePlanResult(plan, result.data));
|
|
507
|
+
const dirField = result.data.target_dir || result.data.branch_dir;
|
|
508
|
+
if (dirField) p.log.info(`目录:${dirField}`);
|
|
509
|
+
p.outro("done");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function commandBranch(rawArgs) {
|
|
513
|
+
const sub = rawArgs[0];
|
|
514
|
+
// No subcommand → interactive mode.
|
|
515
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
516
|
+
if (sub === "--help" || sub === "-h") {
|
|
517
|
+
process.stdout.write(`Usage:
|
|
518
|
+
dev-memory-cli branch # interactive flow
|
|
519
|
+
dev-memory-cli branch list # JSON snapshot of all branches
|
|
520
|
+
dev-memory-cli branch inspect [--branch NAME] # JSON snapshot of one branch
|
|
521
|
+
dev-memory-cli branch rename --source A --target B [--force | --backup]
|
|
522
|
+
dev-memory-cli branch fork --source A --target B [--force | --backup]
|
|
523
|
+
dev-memory-cli branch delete [--branch NAME] [--force | --backup]
|
|
524
|
+
dev-memory-cli branch init [--branch NAME] [--force | --backup]
|
|
525
|
+
dev-memory-cli branch inherit-worktree-base [--source NAME] [--branch NAME] [--force | --backup]
|
|
526
|
+
# explicit worktree-base inheritance (auto-fires on first lazy-init)
|
|
527
|
+
`);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
commandBranchInteractive();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// Any other subcommand is forwarded to python verbatim — the python side
|
|
534
|
+
// owns the flag parsing.
|
|
535
|
+
runPython(branchScript(), rawArgs);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function printHelp() {
|
|
539
|
+
process.stdout.write(`Usage:
|
|
540
|
+
dev-memory-cli hook <session-start|pre-compact|stop|session-end> [--repo PATH]
|
|
541
|
+
dev-memory-cli install-hooks <codex|claude> [--repo PATH] [--global]
|
|
542
|
+
dev-memory-cli install-hooks --all [--repo PATH] [--global]
|
|
543
|
+
dev-memory-cli ui [--port N] [--host HOST] [--no-open] [--read-only]
|
|
544
|
+
dev-memory-cli context <show|...> [...]
|
|
545
|
+
dev-memory-cli capture <record|show|sync-working-tree|record-head|suggest-kind|classify> [...]
|
|
546
|
+
dev-memory-cli setup <init|merge-unsorted|mark-completed> [...]
|
|
547
|
+
dev-memory-cli graduate <dry-run|apply|index> [...]
|
|
548
|
+
dev-memory-cli tidy <prepare|apply> [...]
|
|
549
|
+
dev-memory-cli branch [list|inspect|rename|fork|delete|init|inherit-worktree-base] [...] # no subcommand = interactive
|
|
550
|
+
|
|
551
|
+
Environment:
|
|
552
|
+
DEV_MEMORY_ROOT defaults to ${DEFAULT_STORAGE_ROOT}
|
|
553
|
+
(also accepts DEV_ASSETS_ROOT for backward-compat with dev-assets <0.13)
|
|
554
|
+
`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function main() {
|
|
558
|
+
const argv = process.argv.slice(2);
|
|
559
|
+
const command = argv[0];
|
|
560
|
+
if (!command || command === "-h" || command === "--help") {
|
|
561
|
+
printHelp();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (PY_SUBCOMMAND_SCRIPTS[command]) {
|
|
565
|
+
commandPySubcommand(command, argv.slice(1));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (command === "branch") {
|
|
569
|
+
commandBranch(argv.slice(1));
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
// Legacy commands keep using the lightweight Node-side parser.
|
|
573
|
+
const { positional, options } = parseArgs(argv);
|
|
574
|
+
positional.shift();
|
|
575
|
+
if (command === "hook") {
|
|
576
|
+
commandHook(positional, options);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (command === "install-hooks") {
|
|
580
|
+
commandInstallHooks(positional, options);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (command === "ui") {
|
|
584
|
+
commandUi(positional, options);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
fail(`unknown command: ${command}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
main();
|
package/hooks/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Lifecycle Hooks
|
|
2
|
+
|
|
3
|
+
这套仓库不再使用 Git hooks。
|
|
4
|
+
|
|
5
|
+
现在采用的是接近 ECC 的生命周期 hooks,并同时支持 Claude 和 Codex 两种 agent。
|
|
6
|
+
|
|
7
|
+
## 当前仓库里的接入方式
|
|
8
|
+
|
|
9
|
+
- 推荐的 repo-local 落地点:
|
|
10
|
+
- Claude: `.claude/settings.local.json`
|
|
11
|
+
- Codex: `.codex/hooks.json`
|
|
12
|
+
- 可复用模板:
|
|
13
|
+
- Claude: [hooks/hooks.json](hooks.json)
|
|
14
|
+
- Codex: [hooks/codex-hooks.json](codex-hooks.json)
|
|
15
|
+
- `dev-memory-cli` 是这两套配置共用的稳定执行入口
|
|
16
|
+
- 是否自动生效取决于你本地是否把对应配置文件落到了各自约定位置,而不是模板文件本身
|
|
17
|
+
|
|
18
|
+
### Codex 快速安装
|
|
19
|
+
|
|
20
|
+
在目标仓库根目录执行:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
sh scripts/install_codex_hooks.sh
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
如果只是想直接从 GitHub 拉脚本并把模板 merge 到当前目录的 `.codex/hooks.json`:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
sh -c "$(curl -fsSL https://raw.githubusercontent.com/xluos/dev-memory-skill-suite/main/scripts/install_codex_hooks.sh)"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
这个脚本会先确保 `dev-memory-cli` 可用,然后再 merge Codex hooks。
|
|
33
|
+
|
|
34
|
+
如果 CLI 已经存在,也可以直接执行:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
dev-memory-cli install-hooks codex
|
|
38
|
+
dev-memory-cli install-hooks claude
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
已有旧配置如果还写着 `dev-memory hook ...` 或 `npx dev-memory hook ...`,重新执行安装命令会按 hook id 覆盖为 `dev-memory-cli hook ...`。
|
|
42
|
+
|
|
43
|
+
## 这些 hook 做什么
|
|
44
|
+
|
|
45
|
+
- Claude:
|
|
46
|
+
- `SessionStart`
|
|
47
|
+
- `PreCompact`
|
|
48
|
+
- `Stop`
|
|
49
|
+
- `SessionEnd`
|
|
50
|
+
- Codex:
|
|
51
|
+
- `SessionStart`
|
|
52
|
+
- `Stop`
|
|
53
|
+
|
|
54
|
+
语义分别是:
|
|
55
|
+
|
|
56
|
+
- `SessionStart`
|
|
57
|
+
读取当前 repo+branch 的 dev-memory,并把可恢复上下文注入新会话
|
|
58
|
+
- `PreCompact`
|
|
59
|
+
在上下文压缩前刷新 working-tree 派生导航信息
|
|
60
|
+
- `Stop`
|
|
61
|
+
在每次回复后记录轻量 HEAD 标记
|
|
62
|
+
- `SessionEnd`
|
|
63
|
+
在会话结束时再落一次最终 HEAD 标记
|
|
64
|
+
|
|
65
|
+
## 和 ECC 的差异
|
|
66
|
+
|
|
67
|
+
ECC 是插件形态,安装后可以靠插件机制自动加载 hook 配置。
|
|
68
|
+
|
|
69
|
+
这个仓库当前是 skill suite,不是独立 Claude 插件,所以:
|
|
70
|
+
|
|
71
|
+
- 对本仓库自身开发:
|
|
72
|
+
- Claude 把 [hooks/hooks.json](hooks.json) 合并到本地 `.claude/settings.local.json`
|
|
73
|
+
- Codex 把 [hooks/codex-hooks.json](codex-hooks.json) 放到或合并到本地 `.codex/hooks.json`
|
|
74
|
+
- 对其他仓库:
|
|
75
|
+
- 先安装 `dev-memory-cli`
|
|
76
|
+
- 再把模板 merge 到对应 repo-local 配置
|
|
77
|
+
- hook 运行时统一走 `dev-memory-cli hook ...`
|
|
78
|
+
- 不能假装成“像插件一样对所有仓库自动加载”
|
|
79
|
+
|
|
80
|
+
## 原则
|
|
81
|
+
|
|
82
|
+
- hook 只做低摩擦恢复和轻量刷新
|
|
83
|
+
- 不在 hook 里重写高语义正文
|
|
84
|
+
- 不把实现流水账和 Git 历史复制进 dev-memory
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "*",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "dev-memory-cli hook session-start",
|
|
10
|
+
"timeout": 20
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"description": "Load repo+branch dev-memory context at session start",
|
|
14
|
+
"id": "dev-memory:session-start"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"Stop": [
|
|
18
|
+
{
|
|
19
|
+
"matcher": "*",
|
|
20
|
+
"hooks": [
|
|
21
|
+
{
|
|
22
|
+
"type": "command",
|
|
23
|
+
"command": "dev-memory-cli hook stop",
|
|
24
|
+
"timeout": 15
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"description": "Persist lightweight HEAD markers after each response",
|
|
28
|
+
"id": "dev-memory:stop"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|