open-memex 0.3.0 → 0.4.0-alpha.1

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.
@@ -2,7 +2,11 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import yaml from "js-yaml";
5
- import { memoriesDirFor, memoriesDirPath } from "../paths.ts";
5
+ import {
6
+ memoriesDirFor,
7
+ memoriesDirPath,
8
+ inRepoMemoriesDirPath,
9
+ } from "../paths.ts";
6
10
 
7
11
  /** v2 content-kind taxonomy (V2-DESIGN §3.1). `type` = what the memory IS. */
8
12
  export const MEMORY_TYPE_TAXONOMY = [
@@ -52,6 +56,62 @@ export interface Frontmatter {
52
56
  updated_at: string; // RFC 3339
53
57
  supersedes: string | null; // on the NEW memory → points BACK (§3.3)
54
58
  superseded_by: string | null; // on the OLD memory → points FORWARD
59
+ /** 2B/D25: where this memory sits in the propose → promote workflow. */
60
+ review_state: ReviewState;
61
+ /** Who proposed / approved it (git user.name, fallback OS user). */
62
+ proposed_by: string | null;
63
+ approved_by: string | null;
64
+ /** For propose-copies: the personal memory this was derived from. */
65
+ derived_from: string | null;
66
+ /** Curator note on the latest review transition (e.g. rejection reason). */
67
+ review_note: string | null;
68
+ /** 2B/D29: append-only audit trail of review transitions. Lives in the
69
+ * file (source of truth), so it travels through branches and PRs. */
70
+ review_history: ReviewTransition[];
71
+ }
72
+
73
+ /** One step in a memory's review lifecycle — who moved it, when, why. */
74
+ export interface ReviewTransition {
75
+ at: string; // RFC 3339
76
+ by: string; // author identity (git user.name, fallback OS user)
77
+ from: ReviewState;
78
+ to: ReviewState;
79
+ note: string | null;
80
+ }
81
+
82
+ /** Defensive parse: malformed entries are dropped, never fatal. */
83
+ export function asReviewHistory(v: unknown): ReviewTransition[] {
84
+ if (!Array.isArray(v)) return [];
85
+ const out: ReviewTransition[] = [];
86
+ for (const e of v) {
87
+ if (!e || typeof e !== "object") continue;
88
+ const r = e as Record<string, unknown>;
89
+ const from = asReviewState(r.from);
90
+ const to = asReviewState(r.to);
91
+ const at = typeof r.at === "string" && r.at ? r.at : null;
92
+ const by = typeof r.by === "string" && r.by ? r.by : null;
93
+ if (!at || !by) continue;
94
+ out.push({
95
+ at,
96
+ by,
97
+ from,
98
+ to,
99
+ note: typeof r.note === "string" && r.note ? r.note : null,
100
+ });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /** Review lifecycle for shared memories (2B/D25): draft → proposed →
106
+ * approved/rejected → published. Personal memories stay `draft`. */
107
+ export type ReviewState = "draft" | "proposed" | "approved" | "rejected" | "published";
108
+
109
+ const REVIEW_STATES: ReviewState[] = ["draft", "proposed", "approved", "rejected", "published"];
110
+
111
+ export function asReviewState(v: unknown): ReviewState {
112
+ return typeof v === "string" && (REVIEW_STATES as string[]).includes(v)
113
+ ? (v as ReviewState)
114
+ : "draft";
55
115
  }
56
116
 
57
117
  export interface MemoryFile {
@@ -179,6 +239,16 @@ export function normalizeFrontmatter(
179
239
  typeof raw.superseded_by === "string" && raw.superseded_by
180
240
  ? raw.superseded_by
181
241
  : null,
242
+ review_state: asReviewState(raw.review_state),
243
+ proposed_by:
244
+ typeof raw.proposed_by === "string" && raw.proposed_by ? raw.proposed_by : null,
245
+ approved_by:
246
+ typeof raw.approved_by === "string" && raw.approved_by ? raw.approved_by : null,
247
+ derived_from:
248
+ typeof raw.derived_from === "string" && raw.derived_from ? raw.derived_from : null,
249
+ review_note:
250
+ typeof raw.review_note === "string" && raw.review_note ? raw.review_note : null,
251
+ review_history: asReviewHistory(raw.review_history),
182
252
  };
183
253
  }
184
254
 
@@ -217,6 +287,9 @@ export function writeMemoryFile(
217
287
  fm: Frontmatter,
218
288
  body: string,
219
289
  ): { filePath: string; mtimeMs: number } {
290
+ // 2B/D26: project drafts live in appdata (the outbox) — the in-repo dir
291
+ // only ever holds submitted memories (proposed/approved/published).
292
+ // Personal stays in appdata and never leaves the machine.
220
293
  const dir = memoriesDirFor(fm.scope_key);
221
294
  const filePath = path.join(dir, `${fm.id}.md`);
222
295
  fs.writeFileSync(filePath, serialize(fm, body), "utf8");
@@ -224,14 +297,16 @@ export function writeMemoryFile(
224
297
  return { filePath, mtimeMs: st.mtimeMs };
225
298
  }
226
299
 
227
- export function deleteMemoryFile(scopeKey: string, id: string): boolean {
228
- const dir = memoriesDirPath(scopeKey);
229
- const filePath = path.join(dir, `${id}.md`);
230
- if (fs.existsSync(filePath)) {
231
- fs.unlinkSync(filePath);
232
- return true;
300
+ /** Yield `*.md` files in the in-repo dir (read path — never creates it). */
301
+ export function* iterInRepoMemoryFiles(
302
+ root: string,
303
+ memoryDir: string,
304
+ ): Generator<string> {
305
+ const dir = inRepoMemoriesDirPath(root, memoryDir);
306
+ if (!fs.existsSync(dir)) return;
307
+ for (const name of fs.readdirSync(dir)) {
308
+ if (name.endsWith(".md")) yield path.join(dir, name);
233
309
  }
234
- return false;
235
310
  }
236
311
 
237
312
  export function readMemoryFile(filePath: string): MemoryFile | null {
package/src/store/sync.ts CHANGED
@@ -1,17 +1,21 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { db } from "./db.ts";
3
4
  import { cjkIndexText } from "../retrieve/cjk.ts";
4
5
  import { contentHash, repairChain } from "./lifecycle.ts";
5
6
  import {
6
7
  iterMemoryFiles,
8
+ iterInRepoMemoryFiles,
7
9
  readMemoryFile,
8
10
  timeToMs,
9
11
  type MemoryFile,
10
12
  } from "./markdown.ts";
13
+ import { projectRoot, paths } from "../paths.ts";
14
+ import { loadConfig } from "../config.ts";
11
15
 
12
16
  const UPSERT_SQL = `
13
- 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)
14
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
17
+ 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)
18
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
15
19
  ON CONFLICT(id) DO UPDATE SET
16
20
  scope_key = excluded.scope_key,
17
21
  scope = excluded.scope,
@@ -27,6 +31,7 @@ const UPSERT_SQL = `
27
31
  content_hash = excluded.content_hash,
28
32
  superseded_by = excluded.superseded_by,
29
33
  source = excluded.source,
34
+ review_state = excluded.review_state,
30
35
  file_path = excluded.file_path,
31
36
  mtime_ms = excluded.mtime_ms,
32
37
  updated_at = excluded.updated_at
@@ -77,6 +82,7 @@ function writeRow(mf: MemoryFile, mtimeMs: number): void {
77
82
  mtimeMs,
78
83
  timeToMs(fm.created_at),
79
84
  timeToMs(fm.updated_at),
85
+ fm.review_state ?? "draft",
80
86
  );
81
87
  }
82
88
 
@@ -91,7 +97,7 @@ export interface SyncStats {
91
97
  scanned: number;
92
98
  }
93
99
 
94
- export function syncScope(scopeKey: string): SyncStats {
100
+ export function syncScope(scopeKey: string, kind: SyncKind = "auto"): SyncStats {
95
101
  const d = db();
96
102
  const stats: SyncStats = { added: 0, updated: 0, removed: 0, scanned: 0 };
97
103
 
@@ -101,7 +107,22 @@ export function syncScope(scopeKey: string): SyncStats {
101
107
  const existingById = new Map(existing.map((r) => [r.id, r]));
102
108
  const seen = new Set<string>();
103
109
 
104
- for (const fp of iterMemoryFiles(scopeKey)) {
110
+ // 2B/D26: project scopes have two homes — the appdata outbox (drafts,
111
+ // branch-independent) and the in-repo dir (submitted memories, following
112
+ // the current branch). No migration: appdata files stay put until an
113
+ // explicit `submit` moves them. On the near-impossible id collision the
114
+ // in-repo (submitted) copy wins, so scan appdata first.
115
+ const filePaths: string[] = [];
116
+ if (scopeKey.startsWith("project__")) {
117
+ const root = projectRoot();
118
+ const memoryDir = loadConfig().memoryDir;
119
+ for (const fp of iterMemoryFiles(scopeKey)) filePaths.push(fp);
120
+ for (const fp of iterInRepoMemoryFiles(root, memoryDir)) filePaths.push(fp);
121
+ } else {
122
+ for (const fp of iterMemoryFiles(scopeKey)) filePaths.push(fp);
123
+ }
124
+
125
+ for (const fp of filePaths) {
105
126
  const mf = readMemoryFile(fp);
106
127
  if (!mf) continue;
107
128
  stats.scanned++;
@@ -123,5 +144,65 @@ export function syncScope(scopeKey: string): SyncStats {
123
144
  }
124
145
  }
125
146
 
147
+ recordSync(scopeKey, kind, stats);
126
148
  return stats;
127
149
  }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // D31: last-sync visibility
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /** What triggered the sync — shown in sync-status so the user can see it. */
156
+ export type SyncKind = "session" | "request" | "cli" | "submit" | "auto";
157
+
158
+ export interface SyncStateEntry {
159
+ lastSyncAt: string; // RFC 3339
160
+ lastSyncKind: SyncKind;
161
+ added: number;
162
+ updated: number;
163
+ removed: number;
164
+ scanned: number;
165
+ }
166
+
167
+ function syncStatePath(): string {
168
+ return path.join(paths().root, "sync-state.json");
169
+ }
170
+
171
+ /** Best-effort: a failed write never breaks the sync itself. */
172
+ export function recordSync(scopeKey: string, kind: SyncKind, stats: SyncStats): void {
173
+ try {
174
+ const p = syncStatePath();
175
+ let all: Record<string, SyncStateEntry> = {};
176
+ if (fs.existsSync(p)) {
177
+ try {
178
+ all = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, SyncStateEntry>;
179
+ } catch {
180
+ /* corrupt — start fresh */
181
+ }
182
+ }
183
+ all[scopeKey] = {
184
+ lastSyncAt: new Date().toISOString(),
185
+ lastSyncKind: kind,
186
+ added: stats.added,
187
+ updated: stats.updated,
188
+ removed: stats.removed,
189
+ scanned: stats.scanned,
190
+ };
191
+ fs.writeFileSync(p, JSON.stringify(all, null, 2), "utf8");
192
+ } catch {
193
+ /* visibility is advisory — never fail a sync over it */
194
+ }
195
+ }
196
+
197
+ export function readSyncState(scopeKey: string): SyncStateEntry | null {
198
+ try {
199
+ const p = syncStatePath();
200
+ if (!fs.existsSync(p)) return null;
201
+ const all = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, unknown>;
202
+ const e = all?.[scopeKey] as SyncStateEntry | undefined;
203
+ if (!e || typeof e.lastSyncAt !== "string") return null;
204
+ return e;
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
package/src/submit.ts ADDED
@@ -0,0 +1,411 @@
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
+
22
+ import { execFileSync } from "node:child_process";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { loadConfig } from "./config.ts";
26
+ import { inRepoMemoriesDir, memoriesDirPath, projectRoot } from "./paths.ts";
27
+ import { resolveProjectScope } from "./scope.ts";
28
+ import { contentHash } from "./store/lifecycle.ts";
29
+ import {
30
+ msToRfc3339,
31
+ parse as parseMemory,
32
+ readMemoryFile,
33
+ serialize,
34
+ type ReviewState,
35
+ } from "./store/markdown.ts";
36
+ import { upsertFromFile, recordSync, readSyncState } from "./store/sync.ts";
37
+ import { db } from "./store/db.ts";
38
+ import { currentAuthor } from "./review.ts";
39
+
40
+ function fail(msg: string): never {
41
+ throw new Error(`[open-memex] ${msg}`);
42
+ }
43
+
44
+ function git(root: string, args: string[]): string {
45
+ try {
46
+ return execFileSync("git", args, {
47
+ cwd: root,
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ }).trim();
51
+ } catch (e) {
52
+ const err = e as { stderr?: string; message?: string };
53
+ const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
54
+ fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
55
+ }
56
+ }
57
+
58
+ function snippetOf(body: string): string {
59
+ const line = body.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
60
+ return line.length > 60 ? line.slice(0, 60) + "…" : line;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // status
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export type OutboxLocation = "outbox" | "repo";
68
+ export type GitState = "uncommitted" | "committed" | "n/a";
69
+
70
+ export interface StatusEntry {
71
+ id: string;
72
+ title: string;
73
+ reviewState: ReviewState;
74
+ location: OutboxLocation;
75
+ gitState: GitState;
76
+ }
77
+
78
+ export interface SyncStatus {
79
+ scopeKey: string;
80
+ projectName: string;
81
+ outbox: StatusEntry[]; // appdata drafts waiting for submit
82
+ inRepo: StatusEntry[]; // files in the repo dir (any review_state)
83
+ uncommitted: StatusEntry[]; // repo files not yet committed (the untracked window)
84
+ }
85
+
86
+ /** Files under .ai/open-memex that git sees as new/modified/untracked. */
87
+ function uncommittedRepoFiles(root: string, repoDir: string): Set<string> {
88
+ const out = new Set<string>();
89
+ try {
90
+ const porcelain = git(root, ["status", "--porcelain", "--", repoDir]);
91
+ for (const line of porcelain.split("\n")) {
92
+ const m = line.match(/^.{2}\s+(.*)$/);
93
+ if (!m) continue;
94
+ let p = m[1].trim();
95
+ // handle renames: "R old -> new"
96
+ if (p.includes(" -> ")) p = p.split(" -> ")[1];
97
+ // strip quotes git adds around unusual names
98
+ if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
99
+ out.add(path.resolve(root, p));
100
+ }
101
+ } catch {
102
+ /* not a git repo or git unavailable — no git state */
103
+ }
104
+ return out;
105
+ }
106
+
107
+ export function getSyncStatus(): SyncStatus {
108
+ const root = projectRoot();
109
+ const scope = resolveProjectScope(root);
110
+ const cfg = loadConfig();
111
+ const outboxDir = memoriesDirPath(scope.key);
112
+ const repoDir = path.join(root, cfg.memoryDir);
113
+
114
+ const rows = db()
115
+ .prepare(
116
+ `SELECT id, review_state, file_path, content FROM memories WHERE scope_key = ? ORDER BY updated_at DESC`,
117
+ )
118
+ .all(scope.key) as Array<{
119
+ id: string;
120
+ review_state: string;
121
+ file_path: string;
122
+ content: string;
123
+ }>;
124
+
125
+ const uncommitted = fs.existsSync(repoDir) ? uncommittedRepoFiles(root, repoDir) : new Set<string>();
126
+ const outbox: StatusEntry[] = [];
127
+ const inRepo: StatusEntry[] = [];
128
+ const uncommittedList: StatusEntry[] = [];
129
+
130
+ for (const r of rows) {
131
+ const inOutbox = r.file_path.startsWith(outboxDir + path.sep);
132
+ const entry: StatusEntry = {
133
+ id: r.id,
134
+ title: snippetOf(r.content ?? ""),
135
+ reviewState: (r.review_state ?? "draft") as ReviewState,
136
+ location: inOutbox ? "outbox" : "repo",
137
+ gitState: "n/a",
138
+ };
139
+ if (inOutbox) {
140
+ outbox.push(entry);
141
+ } else {
142
+ entry.gitState = uncommitted.has(path.resolve(r.file_path)) ? "uncommitted" : "committed";
143
+ inRepo.push(entry);
144
+ if (entry.gitState === "uncommitted") uncommittedList.push(entry);
145
+ }
146
+ }
147
+ return {
148
+ scopeKey: scope.key,
149
+ projectName: scope.projectName,
150
+ outbox,
151
+ inRepo,
152
+ uncommitted: uncommittedList,
153
+ };
154
+ }
155
+
156
+ export function formatSyncStatus(st: SyncStatus): string {
157
+ const lines: string[] = [];
158
+ lines.push(`project: ${st.projectName} (${st.scopeKey})`);
159
+ // D31: when the index was last synced and what triggered it.
160
+ const last = readSyncState(st.scopeKey);
161
+ if (last) {
162
+ lines.push(
163
+ `last sync: ${last.lastSyncAt} (${last.lastSyncKind}) — +${last.added} ~${last.updated} -${last.removed} (scanned ${last.scanned})`,
164
+ );
165
+ } else {
166
+ lines.push(`last sync: never`);
167
+ }
168
+ lines.push(`outbox (appdata, pending sync): ${st.outbox.length}`);
169
+ for (const e of st.outbox) {
170
+ lines.push(` ${e.id} [${e.reviewState}] ${e.title}`);
171
+ }
172
+ const byState = new Map<string, number>();
173
+ for (const e of st.inRepo) byState.set(e.reviewState, (byState.get(e.reviewState) ?? 0) + 1);
174
+ const breakdown = [...byState.entries()].map(([s, n]) => `${n} ${s}`).join(", ") || "none";
175
+ lines.push(`repo .ai/open-memex: ${st.inRepo.length} (${breakdown})`);
176
+ for (const e of st.inRepo) {
177
+ const u = e.gitState === "uncommitted" ? " (uncommitted)" : "";
178
+ lines.push(` ${e.id} [${e.reviewState}]${u} ${e.title}`);
179
+ }
180
+ if (st.uncommitted.length > 0) {
181
+ lines.push(`note: ${st.uncommitted.length} repo file(s) not yet committed — they ride with the working tree until you commit.`);
182
+ }
183
+ if (st.outbox.length > 0) {
184
+ lines.push(`next: open-memex submit ${st.outbox.map((e) => e.id).join(" ")}`);
185
+ }
186
+ return lines.join("\n");
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // submit
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export interface SubmitOptions {
194
+ /** Submit onto this branch instead of creating mem/sync-*. Must be the current branch. */
195
+ onto?: string;
196
+ /** PR base override (default: the branch we branched from). */
197
+ base?: string;
198
+ }
199
+
200
+ export interface SubmitResult {
201
+ branch: string;
202
+ base: string;
203
+ submitted: Array<{ id: string; filePath: string; reviewState: ReviewState }>;
204
+ /** already on the branch with identical content — outbox move completed */
205
+ skippedIdentical: string[];
206
+ /** false when everything was already on the branch (nothing new to commit) */
207
+ committed: boolean;
208
+ pushCommand: string;
209
+ prCommand: string;
210
+ }
211
+
212
+ interface Validated {
213
+ id: string;
214
+ srcPath: string;
215
+ body: string;
216
+ reviewState: ReviewState;
217
+ }
218
+
219
+ export function submitMemories(ids: string[], opts: SubmitOptions = {}): SubmitResult {
220
+ if (ids.length === 0) fail("submit needs at least one memory id");
221
+ const root = projectRoot();
222
+ // submit needs a git repo — the whole point is branch + commit.
223
+ git(root, ["rev-parse", "--git-dir"]);
224
+
225
+ const scope = resolveProjectScope(root);
226
+ const cfg = loadConfig();
227
+ const outboxDir = memoriesDirPath(scope.key);
228
+ const repoDir = inRepoMemoriesDir(root, cfg.memoryDir);
229
+
230
+ // 1. Validate everything first (all-or-nothing — nothing is written yet).
231
+ // Read the outbox files directly from disk: the index resolves same-id
232
+ // collisions in favor of the repo copy, which would hide a genuine
233
+ // outbox-vs-branch conflict from us.
234
+ const problems: string[] = [];
235
+ const validated: Validated[] = [];
236
+ for (const id of ids) {
237
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) {
238
+ problems.push(`${id}: not a valid memory id`);
239
+ continue;
240
+ }
241
+ const srcPath = path.join(outboxDir, `${id}.md`);
242
+ if (!fs.existsSync(srcPath)) {
243
+ problems.push(`${id}: not in the outbox (never staged, or already submitted? see sync-status)`);
244
+ continue;
245
+ }
246
+ const mf = readMemoryFile(srcPath);
247
+ if (!mf) {
248
+ problems.push(`${id}: unreadable outbox file`);
249
+ continue;
250
+ }
251
+ if (mf.fm.id !== id) {
252
+ problems.push(`${id}: outbox file's frontmatter id is ${mf.fm.id} — not submitting a mislabeled file`);
253
+ continue;
254
+ }
255
+ if (mf.fm.scope !== "project") {
256
+ problems.push(`${id}: scope is ${mf.fm.scope}, only project memories can be submitted`);
257
+ continue;
258
+ }
259
+ const rs = (mf.fm.review_state ?? "draft") as ReviewState;
260
+ // draft/rejected move to proposed; a local-approved copy keeps its approval.
261
+ if (rs !== "draft" && rs !== "rejected" && rs !== "approved") {
262
+ problems.push(`${id}: review_state is ${rs}, only draft/rejected/approved can be submitted`);
263
+ continue;
264
+ }
265
+ validated.push({ id, srcPath, body: mf.body, reviewState: rs });
266
+ }
267
+ if (problems.length > 0) {
268
+ fail(`submit aborted — nothing was written:\n ${problems.join("\n ")}`);
269
+ }
270
+
271
+ // 2. Resolve the target branch.
272
+ const startBranch = git(root, ["branch", "--show-current"]) || git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
273
+ let branch: string;
274
+ if (opts.onto) {
275
+ if (opts.onto !== startBranch) {
276
+ fail(`--onto ${opts.onto} is not the current branch (${startBranch || "(detached)"}). submit only targets the branch you're on.`);
277
+ }
278
+ branch = opts.onto;
279
+ } else {
280
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
281
+ branch = `mem/sync-${stamp}`;
282
+ let n = 0;
283
+ while (true) {
284
+ const exists = git(root, ["branch", "--list", branch]);
285
+ if (!exists) break;
286
+ n++;
287
+ branch = `mem/sync-${stamp}-${n}`;
288
+ }
289
+ git(root, ["checkout", "-b", branch]);
290
+ }
291
+ const base = opts.base ?? startBranch;
292
+ const author = currentAuthor();
293
+
294
+ // 3. Copy drafts into the repo dir as proposed (same id — one memory, one id).
295
+ // Same id + different content on the branch = conflict → abort, human judges.
296
+ const submitted: Array<{ id: string; filePath: string; reviewState: ReviewState }> = [];
297
+ const skippedIdentical: string[] = [];
298
+ const now = msToRfc3339(Date.now());
299
+ const written: Array<{ id: string; srcPath: string; destPath: string; hash: string }> = [];
300
+ const conflicts: string[] = [];
301
+ for (const v of validated) {
302
+ const destPath = path.join(repoDir, `${v.id}.md`);
303
+ const raw = fs.existsSync(destPath) ? fs.readFileSync(destPath, "utf8") : null;
304
+ if (raw !== null) {
305
+ const existing = parseMemory(raw);
306
+ const sameContent = existing !== null && contentHash(existing.body) === contentHash(v.body);
307
+ if (sameContent) {
308
+ skippedIdentical.push(v.id);
309
+ written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(v.body) });
310
+ continue;
311
+ }
312
+ conflicts.push(`${v.id}: already on branch ${branch} with different content — resolve it manually, then re-submit`);
313
+ continue;
314
+ }
315
+ const parsed = parseMemory(fs.readFileSync(v.srcPath, "utf8"));
316
+ if (!parsed) fail(`${v.id}: unreadable source file`);
317
+ // draft/rejected → proposed; a local-approved copy arrives already
318
+ // approved and keeps its approval (and its approved_by).
319
+ const destState: ReviewState = v.reviewState === "approved" ? "approved" : "proposed";
320
+ const fm = {
321
+ ...parsed.fm,
322
+ review_state: destState,
323
+ updated_at: now,
324
+ proposed_by: parsed.fm.proposed_by ?? author,
325
+ // D29: the outbox → repo move is part of the audit trail.
326
+ review_history: [
327
+ ...(parsed.fm.review_history ?? []),
328
+ {
329
+ at: now,
330
+ by: author,
331
+ from: parsed.fm.review_state ?? "draft",
332
+ to: destState,
333
+ note: `submitted to branch ${branch}`,
334
+ },
335
+ ],
336
+ };
337
+ const text = serialize(fm, parsed.body);
338
+ fs.writeFileSync(destPath, text, "utf8");
339
+ written.push({ id: v.id, srcPath: v.srcPath, destPath, hash: contentHash(parsed.body) });
340
+ submitted.push({ id: v.id, filePath: destPath, reviewState: destState });
341
+ }
342
+ if (conflicts.length > 0) {
343
+ // Roll back the copies we just made — nothing half-submitted.
344
+ for (const w of written) {
345
+ if (submitted.some((s) => s.id === w.id) && fs.existsSync(w.destPath)) {
346
+ fs.unlinkSync(w.destPath);
347
+ }
348
+ }
349
+ // If we created the branch and never committed, remove it too — an
350
+ // aborted submit leaves no trace.
351
+ if (!opts.onto) {
352
+ try {
353
+ git(root, ["checkout", "--quiet", startBranch]);
354
+ git(root, ["branch", "--quiet", "-D", branch]);
355
+ } catch {
356
+ /* best effort — the error below is what matters */
357
+ }
358
+ }
359
+ fail(`submit aborted — nothing was committed:\n ${conflicts.join("\n ")}`);
360
+ }
361
+
362
+ // 4. Commit locally (tool automates local git only — D27 line).
363
+ // Only our own files are committed: anything else the user staged stays
364
+ // staged and untouched.
365
+ const destPaths = written.map((w) => w.destPath);
366
+ const relPaths = destPaths.map((p: string) => path.relative(root, p));
367
+ git(root, ["add", "--", ...destPaths]);
368
+ const staged = git(root, ["diff", "--cached", "--name-only", "--", ...relPaths]);
369
+ let committed = false;
370
+ if (staged.trim()) {
371
+ const short = submitted.map((s) => s.id.slice(0, 8)).join(" ");
372
+ git(root, ["commit", "-m", `mem: submit ${submitted.length} ${submitted.length === 1 ? "memory" : "memories"} for review (${short})`, "--", ...relPaths]);
373
+ committed = true;
374
+ }
375
+
376
+ // 5. Verify the committed copies, then move the outbox originals out
377
+ // (copy + verify + delete — never a bare rename across the move).
378
+ for (const w of written) {
379
+ const back = fs.readFileSync(w.destPath, "utf8");
380
+ const bParsed = parseMemory(back);
381
+ if (!bParsed || contentHash(bParsed.body) !== w.hash) {
382
+ fail(`${w.id}: verification failed after commit — outbox copy kept at ${w.srcPath}`);
383
+ }
384
+ // Same id, new home: ON CONFLICT(id) rewrites the row to the repo path.
385
+ upsertFromFile({
386
+ fm: bParsed.fm,
387
+ body: bParsed.body,
388
+ filePath: w.destPath,
389
+ mtimeMs: fs.statSync(w.destPath).mtimeMs,
390
+ });
391
+ fs.unlinkSync(w.srcPath);
392
+ }
393
+
394
+ const ids8 = validated.map((v) => v.id.slice(0, 8)).join(" ");
395
+ // D31: the submit itself is a sync event — visible in sync-status.
396
+ recordSync(scope.key, "submit", {
397
+ added: submitted.length,
398
+ updated: skippedIdentical.length,
399
+ removed: 0,
400
+ scanned: validated.length,
401
+ });
402
+ return {
403
+ branch,
404
+ base,
405
+ submitted,
406
+ skippedIdentical,
407
+ committed,
408
+ pushCommand: `git push -u origin ${branch}`,
409
+ prCommand: `gh pr create --base ${base} --title "mem: review ${validated.length} ${validated.length === 1 ? "memory" : "memories"}" --body "Submitted from the open-memex outbox: ${ids8}."`,
410
+ };
411
+ }