open-memex 0.3.0 → 0.4.0-alpha.10

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 (49) hide show
  1. package/AGENTS.md +20 -1
  2. package/README.md +117 -9
  3. package/README.zh-CN.md +103 -9
  4. package/dist/cli.js +583 -14
  5. package/dist/config.js +12 -0
  6. package/dist/distill-agents.js +55 -0
  7. package/dist/doctor.js +1 -1
  8. package/dist/export.js +157 -0
  9. package/dist/github.js +215 -0
  10. package/dist/index.js +6 -0
  11. package/dist/init.js +43 -18
  12. package/dist/mcp.js +119 -12
  13. package/dist/paths.js +31 -0
  14. package/dist/providers/git.js +142 -0
  15. package/dist/retrieve/inject.js +2 -2
  16. package/dist/retrieve/search.js +31 -6
  17. package/dist/review.js +437 -0
  18. package/dist/store/db.js +3 -2
  19. package/dist/store/lifecycle.js +28 -13
  20. package/dist/store/markdown.js +57 -14
  21. package/dist/store/sync.js +74 -6
  22. package/dist/submit.js +347 -0
  23. package/dist/tools/ops.js +171 -8
  24. package/docs/CURATOR.md +59 -0
  25. package/docs/USER-GUIDE.md +197 -0
  26. package/docs/USER-GUIDE.zh-CN.md +161 -0
  27. package/docs/V2-DESIGN.md +287 -13
  28. package/package.json +1 -1
  29. package/scripts/smoke-mcp.ts +2 -2
  30. package/src/cli.ts +608 -15
  31. package/src/config.ts +26 -0
  32. package/src/distill-agents.ts +70 -0
  33. package/src/doctor.ts +1 -1
  34. package/src/export.ts +206 -0
  35. package/src/github.ts +257 -0
  36. package/src/index.ts +6 -0
  37. package/src/init.ts +43 -17
  38. package/src/mcp.ts +166 -11
  39. package/src/paths.ts +32 -0
  40. package/src/providers/git.ts +191 -0
  41. package/src/retrieve/inject.ts +2 -2
  42. package/src/retrieve/search.ts +33 -6
  43. package/src/review.ts +504 -0
  44. package/src/store/db.ts +3 -2
  45. package/src/store/lifecycle.ts +30 -12
  46. package/src/store/markdown.ts +91 -14
  47. package/src/store/sync.ts +85 -4
  48. package/src/submit.ts +424 -0
  49. package/src/tools/ops.ts +203 -9
@@ -1,11 +1,14 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { db } from "./db.js";
3
4
  import { cjkIndexText } from "../retrieve/cjk.js";
4
5
  import { contentHash, repairChain } from "./lifecycle.js";
5
- import { iterMemoryFiles, readMemoryFile, timeToMs, } from "./markdown.js";
6
+ import { iterMemoryFiles, iterInRepoMemoryFiles, readMemoryFile, timeToMs, } from "./markdown.js";
7
+ import { projectRoot, paths } from "../paths.js";
8
+ import { loadConfig } from "../config.js";
6
9
  const UPSERT_SQL = `
7
- INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at)
8
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
10
+ INSERT INTO memories (id, scope_key, scope, visibility, project_name, type, role, importance, status, tags, content, cjk, content_hash, superseded_by, source, file_path, mtime_ms, created_at, updated_at, review_state)
11
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
9
12
  ON CONFLICT(id) DO UPDATE SET
10
13
  scope_key = excluded.scope_key,
11
14
  scope = excluded.scope,
@@ -21,6 +24,7 @@ const UPSERT_SQL = `
21
24
  content_hash = excluded.content_hash,
22
25
  superseded_by = excluded.superseded_by,
23
26
  source = excluded.source,
27
+ review_state = excluded.review_state,
24
28
  file_path = excluded.file_path,
25
29
  mtime_ms = excluded.mtime_ms,
26
30
  updated_at = excluded.updated_at
@@ -49,12 +53,12 @@ export function upsertFromFile(mf) {
49
53
  function writeRow(mf, mtimeMs) {
50
54
  const { fm, body, filePath } = mf;
51
55
  const tags = (fm.tags ?? []).join(",");
52
- db().prepare(UPSERT_SQL).run(fm.id, fm.scope_key, fm.scope, fm.visibility, fm.project_name, fm.type, fm.role, fm.importance, fm.status, tags, body, cjkIndexText(body + "\n" + tags), contentHash(body), fm.superseded_by ?? null, fm.source ?? "", filePath, mtimeMs, timeToMs(fm.created_at), timeToMs(fm.updated_at));
56
+ db().prepare(UPSERT_SQL).run(fm.id, fm.scope_key, fm.scope, fm.visibility, fm.project_name, fm.type, fm.role, fm.importance, fm.status, tags, body, cjkIndexText(body + "\n" + tags), contentHash(body), fm.superseded_by ?? null, fm.source ?? "", filePath, mtimeMs, timeToMs(fm.created_at), timeToMs(fm.updated_at), fm.review_state ?? "draft");
53
57
  }
54
58
  export function deleteFromIndex(id) {
55
59
  db().prepare(`DELETE FROM memories WHERE id = ?`).run(id);
56
60
  }
57
- export function syncScope(scopeKey) {
61
+ export function syncScope(scopeKey, kind = "auto") {
58
62
  const d = db();
59
63
  const stats = { added: 0, updated: 0, removed: 0, scanned: 0 };
60
64
  const existing = d
@@ -62,7 +66,25 @@ export function syncScope(scopeKey) {
62
66
  .all(scopeKey);
63
67
  const existingById = new Map(existing.map((r) => [r.id, r]));
64
68
  const seen = new Set();
65
- for (const fp of iterMemoryFiles(scopeKey)) {
69
+ // 2B/D26: project scopes have two homes — the appdata outbox (drafts,
70
+ // branch-independent) and the in-repo dir (submitted memories, following
71
+ // the current branch). No migration: appdata files stay put until an
72
+ // explicit `submit` moves them. On the near-impossible id collision the
73
+ // in-repo (submitted) copy wins, so scan appdata first.
74
+ const filePaths = [];
75
+ if (scopeKey.startsWith("project__")) {
76
+ const root = projectRoot();
77
+ const memoryDir = loadConfig().memoryDir;
78
+ for (const fp of iterMemoryFiles(scopeKey))
79
+ filePaths.push(fp);
80
+ for (const fp of iterInRepoMemoryFiles(root, memoryDir))
81
+ filePaths.push(fp);
82
+ }
83
+ else {
84
+ for (const fp of iterMemoryFiles(scopeKey))
85
+ filePaths.push(fp);
86
+ }
87
+ for (const fp of filePaths) {
66
88
  const mf = readMemoryFile(fp);
67
89
  if (!mf)
68
90
  continue;
@@ -84,5 +106,51 @@ export function syncScope(scopeKey) {
84
106
  stats.removed++;
85
107
  }
86
108
  }
109
+ recordSync(scopeKey, kind, stats);
87
110
  return stats;
88
111
  }
112
+ function syncStatePath() {
113
+ return path.join(paths().root, "sync-state.json");
114
+ }
115
+ /** Best-effort: a failed write never breaks the sync itself. */
116
+ export function recordSync(scopeKey, kind, stats) {
117
+ try {
118
+ const p = syncStatePath();
119
+ let all = {};
120
+ if (fs.existsSync(p)) {
121
+ try {
122
+ all = JSON.parse(fs.readFileSync(p, "utf8"));
123
+ }
124
+ catch {
125
+ /* corrupt — start fresh */
126
+ }
127
+ }
128
+ all[scopeKey] = {
129
+ lastSyncAt: new Date().toISOString(),
130
+ lastSyncKind: kind,
131
+ added: stats.added,
132
+ updated: stats.updated,
133
+ removed: stats.removed,
134
+ scanned: stats.scanned,
135
+ };
136
+ fs.writeFileSync(p, JSON.stringify(all, null, 2), "utf8");
137
+ }
138
+ catch {
139
+ /* visibility is advisory — never fail a sync over it */
140
+ }
141
+ }
142
+ export function readSyncState(scopeKey) {
143
+ try {
144
+ const p = syncStatePath();
145
+ if (!fs.existsSync(p))
146
+ return null;
147
+ const all = JSON.parse(fs.readFileSync(p, "utf8"));
148
+ const e = all?.[scopeKey];
149
+ if (!e || typeof e.lastSyncAt !== "string")
150
+ return null;
151
+ return e;
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
package/dist/submit.js ADDED
@@ -0,0 +1,347 @@
1
+ /**
2
+ * 2B/D26: submit — move outbox drafts into a git branch for review.
3
+ *
4
+ * The AI-driven sync loop:
5
+ * 1. `open-memex sync-status` — what is waiting (outbox drafts, in-review, published)
6
+ * 2. user picks memories to sync
7
+ * 3. `open-memex submit <ids>` — new branch (default `mem/sync-*`), copy
8
+ * drafts into the repo dir as `proposed`, commit locally, move the
9
+ * outbox originals out (copy + verify + delete)
10
+ * 4. push + open PR — done by the agent holding the user's approval, or by
11
+ * the human; the tool only prints the exact commands (D27)
12
+ *
13
+ * Git automation line (D27): the tool automates LOCAL git
14
+ * only (branch, copy, commit). Nothing leaves the machine without the user's
15
+ * explicit approval — push/PR are the agent's or the human's job.
16
+ *
17
+ * `submit` is idempotent: re-submitting an id whose file is already on the
18
+ * branch with identical content just completes the outbox move. A same-id
19
+ * file with DIFFERENT content is a conflict — abort, the human judges.
20
+ */
21
+ import { execFileSync } from "node:child_process";
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { loadConfig } from "./config.js";
25
+ import { inRepoMemoriesDir, memoriesDirPath, projectRoot } from "./paths.js";
26
+ import { resolveProjectScope } from "./scope.js";
27
+ import { contentHash } from "./store/lifecycle.js";
28
+ import { msToRfc3339, parse as parseMemory, readMemoryFile, serialize, } from "./store/markdown.js";
29
+ import { upsertFromFile, recordSync, readSyncState } from "./store/sync.js";
30
+ import { db } from "./store/db.js";
31
+ import { currentAuthor } from "./review.js";
32
+ function fail(msg) {
33
+ throw new Error(`[open-memex] ${msg}`);
34
+ }
35
+ function git(root, args) {
36
+ try {
37
+ return execFileSync("git", args, {
38
+ cwd: root,
39
+ encoding: "utf8",
40
+ stdio: ["ignore", "pipe", "pipe"],
41
+ }).trim();
42
+ }
43
+ catch (e) {
44
+ const err = e;
45
+ const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
46
+ fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
47
+ }
48
+ }
49
+ function snippetOf(body) {
50
+ const line = body.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
51
+ return line.length > 60 ? line.slice(0, 60) + "…" : line;
52
+ }
53
+ /** Files under .ai/open-memex that git sees as new/modified/untracked. */
54
+ function uncommittedRepoFiles(root, repoDir) {
55
+ const out = new Set();
56
+ try {
57
+ const porcelain = git(root, ["status", "--porcelain", "--", repoDir]);
58
+ for (const line of porcelain.split("\n")) {
59
+ const m = line.match(/^.{2}\s+(.*)$/);
60
+ if (!m)
61
+ continue;
62
+ let p = m[1].trim();
63
+ // handle renames: "R old -> new"
64
+ if (p.includes(" -> "))
65
+ p = p.split(" -> ")[1];
66
+ // strip quotes git adds around unusual names
67
+ if (p.startsWith('"') && p.endsWith('"'))
68
+ p = p.slice(1, -1);
69
+ out.add(path.resolve(root, p));
70
+ }
71
+ }
72
+ catch {
73
+ /* not a git repo or git unavailable — no git state */
74
+ }
75
+ return out;
76
+ }
77
+ export function getSyncStatus() {
78
+ const root = projectRoot();
79
+ const scope = resolveProjectScope(root);
80
+ const cfg = loadConfig();
81
+ const outboxDir = memoriesDirPath(scope.key);
82
+ const repoDir = path.join(root, cfg.memoryDir);
83
+ const rows = db()
84
+ .prepare(`SELECT id, review_state, file_path, content FROM memories WHERE scope_key = ? ORDER BY updated_at DESC`)
85
+ .all(scope.key);
86
+ const uncommitted = fs.existsSync(repoDir) ? uncommittedRepoFiles(root, repoDir) : new Set();
87
+ const outbox = [];
88
+ const inRepo = [];
89
+ const uncommittedList = [];
90
+ for (const r of rows) {
91
+ const inOutbox = r.file_path.startsWith(outboxDir + path.sep);
92
+ const entry = {
93
+ id: r.id,
94
+ title: snippetOf(r.content ?? ""),
95
+ reviewState: (r.review_state ?? "draft"),
96
+ location: inOutbox ? "outbox" : "repo",
97
+ gitState: "n/a",
98
+ };
99
+ if (inOutbox) {
100
+ outbox.push(entry);
101
+ }
102
+ else {
103
+ entry.gitState = uncommitted.has(path.resolve(r.file_path)) ? "uncommitted" : "committed";
104
+ inRepo.push(entry);
105
+ if (entry.gitState === "uncommitted")
106
+ uncommittedList.push(entry);
107
+ }
108
+ }
109
+ return {
110
+ scopeKey: scope.key,
111
+ projectName: scope.projectName,
112
+ outbox,
113
+ inRepo,
114
+ uncommitted: uncommittedList,
115
+ };
116
+ }
117
+ export function formatSyncStatus(st) {
118
+ const lines = [];
119
+ lines.push(`project: ${st.projectName} (${st.scopeKey})`);
120
+ // D31: when the index was last synced and what triggered it.
121
+ const last = readSyncState(st.scopeKey);
122
+ if (last) {
123
+ lines.push(`last sync: ${last.lastSyncAt} (${last.lastSyncKind}) — +${last.added} ~${last.updated} -${last.removed} (scanned ${last.scanned})`);
124
+ }
125
+ else {
126
+ lines.push(`last sync: never`);
127
+ }
128
+ lines.push(`outbox (appdata, pending sync): ${st.outbox.length}`);
129
+ for (const e of st.outbox) {
130
+ lines.push(` ${e.id} [${e.reviewState}] ${e.title}`);
131
+ }
132
+ const byState = new Map();
133
+ for (const e of st.inRepo)
134
+ byState.set(e.reviewState, (byState.get(e.reviewState) ?? 0) + 1);
135
+ const breakdown = [...byState.entries()].map(([s, n]) => `${n} ${s}`).join(", ") || "none";
136
+ lines.push(`repo .ai/open-memex: ${st.inRepo.length} (${breakdown})`);
137
+ for (const e of st.inRepo) {
138
+ const u = e.gitState === "uncommitted" ? " (uncommitted)" : "";
139
+ lines.push(` ${e.id} [${e.reviewState}]${u} ${e.title}`);
140
+ }
141
+ if (st.uncommitted.length > 0) {
142
+ lines.push(`note: ${st.uncommitted.length} repo file(s) not yet committed — they ride with the working tree until you commit.`);
143
+ }
144
+ if (st.outbox.length > 0) {
145
+ lines.push(`next: open-memex submit ${st.outbox.map((e) => e.id).join(" ")}`);
146
+ }
147
+ return lines.join("\n");
148
+ }
149
+ export function submitMemories(ids, opts = {}) {
150
+ if (ids.length === 0)
151
+ fail("submit needs at least one memory id");
152
+ const root = projectRoot();
153
+ // submit needs a git repo — the memories land in .ai/open-memex/ and are
154
+ // committed locally. Branch creation is never automatic (D36).
155
+ git(root, ["rev-parse", "--git-dir"]);
156
+ const scope = resolveProjectScope(root);
157
+ const cfg = loadConfig();
158
+ const outboxDir = memoriesDirPath(scope.key);
159
+ const repoDir = inRepoMemoriesDir(root, cfg.memoryDir);
160
+ // 1. Validate everything first (all-or-nothing — nothing is written yet).
161
+ // Read the outbox files directly from disk: the index resolves same-id
162
+ // collisions in favor of the repo copy, which would hide a genuine
163
+ // outbox-vs-branch conflict from us.
164
+ const problems = [];
165
+ const validated = [];
166
+ for (const id of ids) {
167
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) {
168
+ problems.push(`${id}: not a valid memory id`);
169
+ continue;
170
+ }
171
+ const srcPath = path.join(outboxDir, `${id}.md`);
172
+ if (!fs.existsSync(srcPath)) {
173
+ problems.push(`${id}: not in the outbox (never staged, or already submitted? see sync-status)`);
174
+ continue;
175
+ }
176
+ const mf = readMemoryFile(srcPath);
177
+ if (!mf) {
178
+ problems.push(`${id}: unreadable outbox file`);
179
+ continue;
180
+ }
181
+ if (mf.fm.id !== id) {
182
+ problems.push(`${id}: outbox file's frontmatter id is ${mf.fm.id} — not submitting a mislabeled file`);
183
+ continue;
184
+ }
185
+ if (mf.fm.scope !== "project") {
186
+ problems.push(`${id}: scope is ${mf.fm.scope}, only project memories can be submitted`);
187
+ continue;
188
+ }
189
+ const rs = (mf.fm.review_state ?? "draft");
190
+ // draft/rejected move to proposed; a local-approved copy keeps its approval.
191
+ if (rs !== "draft" && rs !== "rejected" && rs !== "approved") {
192
+ problems.push(`${id}: review_state is ${rs}, only draft/rejected/approved can be submitted`);
193
+ continue;
194
+ }
195
+ validated.push({ id, srcPath, body: mf.body, reviewState: rs });
196
+ }
197
+ if (problems.length > 0) {
198
+ fail(`submit aborted — nothing was written:\n ${problems.join("\n ")}`);
199
+ }
200
+ // 2. Resolve the target branch.
201
+ // D36: submit never creates a branch on its own — it works on the branch
202
+ // you're already on. Pass --branch <name> (explicitly) only when the user
203
+ // approved the full chain (branch + push + PR).
204
+ const startBranch = git(root, ["branch", "--show-current"]) || git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
205
+ let branch = startBranch;
206
+ let createdBranch = false;
207
+ if (opts.branch) {
208
+ branch = opts.branch;
209
+ let n = 0;
210
+ let candidate = branch;
211
+ while (true) {
212
+ const exists = git(root, ["branch", "--list", candidate]);
213
+ if (!exists)
214
+ break;
215
+ n++;
216
+ candidate = `${branch}-${n}`;
217
+ }
218
+ branch = candidate;
219
+ git(root, ["checkout", "-b", branch]);
220
+ createdBranch = true;
221
+ }
222
+ const base = opts.base ?? startBranch;
223
+ const author = currentAuthor();
224
+ // 3. Copy drafts into the repo dir as proposed (same id — one memory, one id).
225
+ // Same id + different content on the branch = conflict → abort, human judges.
226
+ const submitted = [];
227
+ const skippedIdentical = [];
228
+ const now = msToRfc3339(Date.now());
229
+ const written = [];
230
+ const conflicts = [];
231
+ for (const v of validated) {
232
+ const destPath = path.join(repoDir, `${v.id}.md`);
233
+ const raw = fs.existsSync(destPath) ? fs.readFileSync(destPath, "utf8") : null;
234
+ if (raw !== null) {
235
+ const existing = parseMemory(raw);
236
+ const sameContent = existing !== null && contentHash(existing.body) === contentHash(v.body);
237
+ if (sameContent) {
238
+ skippedIdentical.push(v.id);
239
+ written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(v.body) });
240
+ continue;
241
+ }
242
+ conflicts.push(`${v.id}: already on branch ${branch} with different content — resolve it manually, then re-submit`);
243
+ continue;
244
+ }
245
+ const parsed = parseMemory(fs.readFileSync(v.srcPath, "utf8"));
246
+ if (!parsed)
247
+ fail(`${v.id}: unreadable source file`);
248
+ // draft/rejected → proposed; a local-approved copy arrives already
249
+ // approved and keeps its approval (and its approved_by).
250
+ const destState = v.reviewState === "approved" ? "approved" : "proposed";
251
+ const fm = {
252
+ ...parsed.fm,
253
+ review_state: destState,
254
+ updated_at: now,
255
+ proposed_by: parsed.fm.proposed_by ?? author,
256
+ // D29: the outbox → repo move is part of the audit trail.
257
+ review_history: [
258
+ ...(parsed.fm.review_history ?? []),
259
+ {
260
+ at: now,
261
+ by: author,
262
+ from: parsed.fm.review_state ?? "draft",
263
+ to: destState,
264
+ note: `submitted to branch ${branch}`,
265
+ },
266
+ ],
267
+ };
268
+ const text = serialize(fm, parsed.body);
269
+ fs.writeFileSync(destPath, text, "utf8");
270
+ written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(parsed.body) });
271
+ submitted.push({ id: v.id, filePath: destPath, reviewState: destState });
272
+ }
273
+ if (conflicts.length > 0) {
274
+ // Roll back the copies we just made — nothing half-submitted.
275
+ for (const w of written) {
276
+ if (submitted.some((s) => s.id === w.id) && fs.existsSync(w.destPath)) {
277
+ fs.unlinkSync(w.destPath);
278
+ }
279
+ }
280
+ // If we created the branch and never committed, remove it too — an
281
+ // aborted submit leaves no trace.
282
+ if (createdBranch) {
283
+ try {
284
+ git(root, ["checkout", "--quiet", startBranch]);
285
+ git(root, ["branch", "--quiet", "-D", branch]);
286
+ }
287
+ catch {
288
+ /* best effort — the error below is what matters */
289
+ }
290
+ }
291
+ fail(`submit aborted — nothing was committed:\n ${conflicts.join("\n ")}`);
292
+ }
293
+ // 4. Commit locally (tool automates local git only — D27 line).
294
+ // Only our own files are committed: anything else the user staged stays
295
+ // staged and untouched.
296
+ const destPaths = written.map((w) => w.destPath);
297
+ const relPaths = destPaths.map((p) => path.relative(root, p));
298
+ git(root, ["add", "--", ...destPaths]);
299
+ const staged = git(root, ["diff", "--cached", "--name-only", "--", ...relPaths]);
300
+ let committed = false;
301
+ if (staged.trim()) {
302
+ const short = submitted.map((s) => s.id.slice(0, 8)).join(" ");
303
+ git(root, ["commit", "-m", `mem: submit ${submitted.length} ${submitted.length === 1 ? "memory" : "memories"} for review (${short})`, "--", ...relPaths]);
304
+ committed = true;
305
+ }
306
+ // 5. Verify the committed copies, then move the outbox originals out
307
+ // (copy + verify + delete — never a bare rename across the move).
308
+ for (const w of written) {
309
+ const back = fs.readFileSync(w.destPath, "utf8");
310
+ const bParsed = parseMemory(back);
311
+ if (!bParsed || contentHash(bParsed.body) !== w.hash) {
312
+ fail(`${w.id}: verification failed after commit — outbox copy kept at ${w.srcPath}`);
313
+ }
314
+ // Same id, new home: ON CONFLICT(id) rewrites the row to the repo path.
315
+ upsertFromFile({
316
+ fm: bParsed.fm,
317
+ body: bParsed.body,
318
+ filePath: w.destPath,
319
+ mtimeMs: fs.statSync(w.destPath).mtimeMs,
320
+ });
321
+ fs.unlinkSync(w.srcPath);
322
+ }
323
+ const ids8 = validated.map((v) => v.id.slice(0, 8)).join(" ");
324
+ // D31: the submit itself is a sync event — visible in sync-status.
325
+ recordSync(scope.key, "submit", {
326
+ added: submitted.length,
327
+ updated: skippedIdentical.length,
328
+ removed: 0,
329
+ scanned: validated.length,
330
+ });
331
+ // D36: no auto-branch — the commit sits on the branch you were already on.
332
+ // Next steps are printed, not run. For a separate memory PR, create a branch
333
+ // first (the commit comes along), then push + open the PR.
334
+ const branchCmd = `git checkout -b mem/sync-YYYYMMDD`;
335
+ return {
336
+ branch,
337
+ base,
338
+ submitted,
339
+ skippedIdentical,
340
+ committed,
341
+ createdBranch,
342
+ pushCommand: `git push -u origin ${branch}`,
343
+ prCommand: createdBranch
344
+ ? `gh pr create --base ${base} --title "mem: review ${validated.length} ${validated.length === 1 ? "memory" : "memories"}" --body "Submitted from the open-memex outbox: ${ids8}."`
345
+ : `${branchCmd} # if you want a separate memory PR (else push ${branch} directly)\n git push -u origin <new-branch> && gh pr create --base ${base} --title "mem: review ${validated.length}" --body "Submitted from the open-memex outbox: ${ids8}."`,
346
+ };
347
+ }