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
package/dist/cli.js CHANGED
@@ -6,15 +6,213 @@ import { syncScope, upsertFromFile, deleteFromIndex } from "./store/sync.js";
6
6
  import { migrateScope, scopeHasFiles } from "./store/migrate.js";
7
7
  import { migrateV2 } from "./store/v2migrate.js";
8
8
  import { findDuplicates, supersede, setStatus } from "./store/lifecycle.js";
9
- import { search, list } from "./retrieve/search.js";
10
- import { writeMemoryFile, readMemoryFile, deleteMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
9
+ import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.js";
10
+ import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.js";
11
+ import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.js";
12
+ import { search, list, hitStateLabel } from "./retrieve/search.js";
13
+ import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, } from "./store/markdown.js";
11
14
  import { loadConfig } from "./config.js";
12
- import { paths } from "./paths.js";
15
+ import { paths, projectRoot } from "./paths.js";
13
16
  import { redact } from "./redact.js";
14
17
  import { resolveMcpCommand } from "./init.js";
15
18
  import fs from "node:fs";
16
19
  import path from "node:path";
17
20
  import { fileURLToPath } from "node:url";
21
+ /** Per-command help, printed by `open-memex <command> --help`.
22
+ AI assistants discover the CLI through --help, so every command needs one. */
23
+ const COMMAND_HELP = {
24
+ where: `Show which project scope the current directory resolves to, and where its data lives.
25
+
26
+ Usage: open-memex where`,
27
+ list: `List memories in a scope, newest first.
28
+
29
+ Usage: open-memex list [--scope project|personal] [--type T] [--limit N]
30
+
31
+ Flags:
32
+ --scope project (default) or personal
33
+ --type filter by memory type
34
+ --limit max results
35
+
36
+ Examples:
37
+ open-memex list
38
+ open-memex list --scope personal --limit 20`,
39
+ search: `Search memories by keyword (BM25 full-text), best matches first.
40
+
41
+ Usage: open-memex search "query" [--scope project|personal|both] [--type T] [--limit N]
42
+
43
+ Flags:
44
+ --scope project (default), personal, or both
45
+ --type filter by memory type
46
+ --limit max results
47
+
48
+ Example:
49
+ open-memex search "deploy checklist" --scope both`,
50
+ add: `Save a fact, preference, decision, or note to local memory.
51
+
52
+ Usage: open-memex add "content" [--scope project|personal] [--type T] [--tag t1,t2]
53
+
54
+ Flags:
55
+ --scope project (default) or personal (personal never leaves this machine)
56
+ --type memory type (default: fact)
57
+ --tag comma-separated tags
58
+
59
+ Example:
60
+ open-memex add "We deploy on Fridays" --scope project --tag process`,
61
+ supersede: `Replace a memory with a newer version. The old one is kept as history.
62
+
63
+ Usage: open-memex supersede <id> "new content" [--type T] [--tag t1,t2]`,
64
+ status: `Change a memory's lifecycle status.
65
+
66
+ Usage: open-memex status <id> active|deprecated|retracted|archived`,
67
+ forget: `Delete a memory by id.
68
+
69
+ Usage: open-memex forget <id>`,
70
+ propose: `Copy personal memories into the project outbox as review drafts.
71
+ The personal originals stay put. Nothing enters git at this step.
72
+
73
+ Usage: open-memex propose <id...> --to project [--local-approve]
74
+
75
+ Flags:
76
+ --to project (required)
77
+ --local-approve mark the copies approved right away (solo-dev shortcut)
78
+
79
+ Example:
80
+ open-memex propose 01ABC 01DEF --to project`,
81
+ promote: `Advance a project memory one step up the review ladder
82
+ (proposed → approved → published), or reject it with a note.
83
+ Every transition is appended to the memory's review_history.
84
+ Rejected memories are never deleted — they can be revised and resubmitted.
85
+
86
+ Usage: open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
87
+
88
+ Flags:
89
+ --reject move back to rejected (requires --note)
90
+ --resubmit move a rejected memory back to proposed
91
+ --note reason for the transition (recorded in review_history)
92
+ --by reviewer name (defaults to the git user)
93
+
94
+ Examples:
95
+ open-memex promote 01ABC --note "verified against the runbook"
96
+ open-memex promote 01ABC --reject --note "outdated after the migration"`,
97
+ resolve: `List conflicted memories, or 3-way-merge one.
98
+
99
+ Usage: open-memex resolve [id-or-path]
100
+
101
+ With no argument, lists conflicts. With an id or file path, shows the
102
+ 3-way merge (base / outbox / repo) so you can resolve it by hand.
103
+ Conflicts are never auto-resolved.`,
104
+ "sync-status": `Show the project memory sync pipeline: when the index last synced
105
+ and what triggered it, drafts waiting in the outbox (appdata), memories in the
106
+ repo awaiting review or published, and repo files not yet committed.
107
+
108
+ Usage: open-memex sync-status`,
109
+ pull: `Pull shared project memories from the git remote: fetch + fast-forward
110
+ only. Never auto-merges — a diverged branch fails with a clear message and is
111
+ left for you to resolve by hand. On success the local index re-syncs.
112
+
113
+ Usage: open-memex pull`,
114
+ push: `Push the current branch (with its submitted memories) to the git
115
+ remote. Explicit only — open-memex never pushes on its own.
116
+
117
+ Usage: open-memex push`,
118
+ export: `Export memories to a portable .tar.gz bundle (markdown source of
119
+ truth + manifest.json) for moving to another machine or another app.
120
+ Excludes visibility:private memories by default; --all includes everything.
121
+
122
+ Usage: open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
123
+
124
+ Flags:
125
+ --scope project (default), personal, or both
126
+ --type filter by memory type
127
+ --tag filter by tag
128
+ --all, -a include private memories (full migration)
129
+ -o output file (default: ./open-memex-export-<timestamp>.tar.gz)`,
130
+ import: `Import a bundle created by \`open-memex export\`. Personal memories
131
+ go to the personal dir; project memories are re-keyed to the current project
132
+ and land in the outbox as drafts. Existing identical memories are skipped;
133
+ conflicting ids are reported, never overwritten.
134
+
135
+ Usage: open-memex import <bundle.tar.gz> [--dry-run]`,
136
+ "distill-agents": `Propose an AGENTS.md snippet distilled from project
137
+ memories (decisions, constraints, lessons, gotchas, howtos). Prints markdown
138
+ to stdout, or writes it with -o. Review and merge by hand — open-memex never
139
+ rewrites your AGENTS.md on its own.
140
+
141
+ Usage: open-memex distill-agents [--scope project|personal] [--type t1,t2] [--limit N] [-o <file>]`,
142
+ submit: `Move outbox drafts into the repo for review: copies the drafts into
143
+ the repo memory dir as proposed (a local-approved copy keeps its approval),
144
+ commits locally on the CURRENT branch, and moves the outbox originals out.
145
+ Never creates a branch on its own — branch creation is your call.
146
+
147
+ Usage: open-memex submit <id...> [--branch <name>] [--base <branch>]
148
+
149
+ Flags:
150
+ --branch create this branch and submit onto it (only with your explicit
151
+ approval for the full chain); default: stay on current branch
152
+ --base PR base override (default: the branch you're on)
153
+
154
+ Example:
155
+ open-memex submit 01ABC 01DEF`,
156
+ "pr-status": `Map the current branch's GitHub PR state back onto review_state:
157
+ merged → published, approval → approved (approved_by = the reviewer),
158
+ changes-requested → suggestion only (never auto-rejects).
159
+ Each memory in the PR is mapped independently; a human rejection is never
160
+ overwritten. Report-only by default.
161
+
162
+ Usage: open-memex pr-status [--apply]
163
+
164
+ Flags:
165
+ --apply write the transitions locally (still never pushes)`,
166
+ reindex: `Rebuild the SQLite index from the markdown files.
167
+
168
+ Usage: open-memex reindex`,
169
+ scopes: `List the known scopes (personal + project).
170
+
171
+ Usage: open-memex scopes`,
172
+ migrate: `Move memories between scopes, or convert a legacy my-o-memory data dir.
173
+
174
+ Usage: open-memex migrate [--from <key>] [--to <key>] [--dry-run] [--on-conflict newer|overwrite|skip]
175
+ open-memex migrate --to-v2 [--dry-run]
176
+
177
+ Flags:
178
+ --from / --to scope keys (default: current project → personal)
179
+ --dry-run preview without moving anything
180
+ --on-conflict newer (default), overwrite, or skip
181
+ --to-v2 convert a legacy my-o-memory data dir to the v2 layout
182
+
183
+ Always preview with --dry-run first; nothing moves without confirmation.`,
184
+ mcp: `Start the stdio MCP server (the same server editors connect to).
185
+
186
+ Usage: open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
187
+
188
+ Flags:
189
+ --print-config print the MCP client config instead of starting the server`,
190
+ init: `One-command project setup: writes the MCP config for your editor and the
191
+ agent memory instructions. Existing files are merged, never clobbered.
192
+
193
+ Usage: open-memex init [--client vscode|cursor|opencode|visualstudio]
194
+ [--instructions personal|project] [--force] [--yes]
195
+
196
+ Flags:
197
+ --client editor to configure (default: auto-detect)
198
+ --instructions personal (default, ~/.copilot/copilot-instructions.md) or project
199
+ --force overwrite existing config
200
+ --yes accept all defaults, never prompt`,
201
+ config: `Show config, or set a key.
202
+
203
+ Usage: open-memex config [set <key> <value>]
204
+
205
+ Example:
206
+ open-memex config set sync.autoPull false`,
207
+ capture: `Preview what the keyword-capture watcher would extract from text.
208
+
209
+ Usage: open-memex capture --dry-run "text"`,
210
+ doctor: `Environment health check: Node version, config source, scope resolution,
211
+ storage writability, then boots a real MCP server and runs initialize +
212
+ tools/list against it — all eleven tools must show up.
213
+
214
+ Usage: open-memex doctor`,
215
+ };
18
216
  function usage(exitCode = 1) {
19
217
  console.log(`open-memex CLI
20
218
 
@@ -26,6 +224,17 @@ Usage:
26
224
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
27
225
  open-memex status <id> active|deprecated|retracted|archived
28
226
  open-memex forget <id>
227
+ open-memex propose <id...> --to project [--local-approve]
228
+ open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
229
+ open-memex resolve [id-or-path]
230
+ open-memex sync-status
231
+ open-memex pull
232
+ open-memex push
233
+ open-memex export [--scope project|personal|both] [--type T] [--tag t] [--all] [-o <file>]
234
+ open-memex import <bundle.tar.gz> [--dry-run]
235
+ open-memex distill-agents [--scope project|personal] [--type t1,t2] [--limit N] [-o <file>]
236
+ open-memex submit <id...> [--branch <name>] [--base <branch>]
237
+ open-memex pr-status [--apply]
29
238
  open-memex reindex
30
239
  open-memex scopes
31
240
  open-memex migrate [--from <key>] [--to <key>]
@@ -63,9 +272,36 @@ the \`--from\` key when migrating.
63
272
  git remote after memories were already stored under the cwd-based key.
64
273
  \`migrate --to-v2\` converts v1 memory files to the v2 format (§19):
65
274
  user→personal scope rename, epoch→RFC 3339 times, priority→importance,
66
- type: instruction→role split. Always preview with --dry-run first.`);
275
+ type: instruction→role split. Always preview with --dry-run first.
276
+
277
+ Run \`open-memex <command> --help\` for details on a single command.`);
67
278
  process.exit(exitCode);
68
279
  }
280
+ /**
281
+ * Build a saveConfig patch for a (possibly dotted) config key, preserving
282
+ * sibling keys already present in the nested object.
283
+ */
284
+ function setConfigPath(key, value) {
285
+ const parts = key.split(".");
286
+ if (parts.length === 1)
287
+ return { [key]: value };
288
+ const cfg = loadConfig();
289
+ const top = parts[0];
290
+ const cur = cfg[top] && typeof cfg[top] === "object"
291
+ ? { ...cfg[top] }
292
+ : {};
293
+ let node = cur;
294
+ for (let i = 1; i < parts.length - 1; i++) {
295
+ const seg = parts[i];
296
+ const nxt = node[seg] && typeof node[seg] === "object"
297
+ ? { ...node[seg] }
298
+ : {};
299
+ node[seg] = nxt;
300
+ node = nxt;
301
+ }
302
+ node[parts[parts.length - 1]] = value;
303
+ return { [top]: cur };
304
+ }
69
305
  function parseFlags(argv) {
70
306
  const out = {};
71
307
  for (let i = 0; i < argv.length; i++) {
@@ -163,6 +399,16 @@ async function main() {
163
399
  console.log(`open-memex ${pkg.version}`);
164
400
  return;
165
401
  }
402
+ // Per-command help: `open-memex <command> --help`. Checked before loadConfig()
403
+ // so it works even when the environment is broken.
404
+ if (rest.includes("--help") || rest.includes("-h")) {
405
+ const h = COMMAND_HELP[cmd];
406
+ if (h) {
407
+ console.log(`open-memex ${cmd}\n\n${h}`);
408
+ return;
409
+ }
410
+ usage(0);
411
+ }
166
412
  const cfg = loadConfig();
167
413
  const project = resolveProjectScope(process.cwd());
168
414
  // `migrate --to-v2` is a pure file operation (V2-DESIGN §19) — it runs
@@ -235,7 +481,9 @@ async function main() {
235
481
  }
236
482
  try {
237
483
  const saved = validate(value);
238
- const file = saveConfig({ [key]: saved });
484
+ // Dotted keys (e.g. sync.autoPull) write into the nested config
485
+ // object, preserving sibling keys already on disk.
486
+ const file = saveConfig(setConfigPath(key, saved));
239
487
  console.log(`set ${key} = ${JSON.stringify(saved)} (${file})`);
240
488
  }
241
489
  catch (err) {
@@ -294,15 +542,15 @@ async function main() {
294
542
  return;
295
543
  }
296
544
  if (cmd === "reindex") {
297
- const a = syncScope(project.key);
298
- const b = syncScope(PERSONAL_SCOPE.key);
545
+ const a = syncScope(project.key, "cli");
546
+ const b = syncScope(PERSONAL_SCOPE.key, "cli");
299
547
  console.log(`reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`);
300
548
  return;
301
549
  }
302
550
  if (cmd === "list") {
303
551
  const flags = parseFlags(rest);
304
552
  const s = resolveCliScope(flags, project);
305
- syncScope(s.key);
553
+ syncScope(s.key, "cli");
306
554
  const hits = list(s.key, {
307
555
  type: flags.type,
308
556
  limit: flags.limit ? Number(flags.limit) : undefined,
@@ -313,7 +561,8 @@ async function main() {
313
561
  }
314
562
  for (const h of hits) {
315
563
  const dep = h.status === "deprecated" ? " [deprecated]" : "";
316
- console.log(`[${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
564
+ const rev = hitStateLabel(h);
565
+ console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
317
566
  }
318
567
  return;
319
568
  }
@@ -322,8 +571,8 @@ async function main() {
322
571
  if (!query || query.startsWith("--"))
323
572
  usage();
324
573
  const flags = parseFlags(rest.slice(1));
325
- syncScope(project.key);
326
- syncScope(PERSONAL_SCOPE.key);
574
+ syncScope(project.key, "cli");
575
+ syncScope(PERSONAL_SCOPE.key, "cli");
327
576
  const keys = flags.scope === "personal" || flags.scope === "user"
328
577
  ? [PERSONAL_SCOPE.key]
329
578
  : flags.scope === "project"
@@ -341,7 +590,8 @@ async function main() {
341
590
  for (const h of hits) {
342
591
  const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
343
592
  const dep = h.status === "deprecated" ? " [deprecated]" : "";
344
- console.log(`[${tag}/${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
593
+ const rev = hitStateLabel(h);
594
+ console.log(`[${tag}/${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
345
595
  }
346
596
  return;
347
597
  }
@@ -384,6 +634,12 @@ async function main() {
384
634
  updated_at: rfc,
385
635
  supersedes: null,
386
636
  superseded_by: null,
637
+ review_state: "draft",
638
+ proposed_by: null,
639
+ approved_by: null,
640
+ derived_from: null,
641
+ review_note: null,
642
+ review_history: [],
387
643
  };
388
644
  const { filePath } = writeMemoryFile(fm, red);
389
645
  const mf = readMemoryFile(filePath);
@@ -447,16 +703,329 @@ async function main() {
447
703
  const id = rest[0];
448
704
  if (!id)
449
705
  usage();
450
- const row = db().prepare(`SELECT scope_key FROM memories WHERE id = ?`).get(id);
706
+ const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id);
451
707
  if (!row) {
452
708
  console.error("not found");
453
709
  process.exit(1);
454
710
  }
455
- deleteMemoryFile(row.scope_key, id);
711
+ // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
712
+ if (row.file_path) {
713
+ try {
714
+ fs.unlinkSync(row.file_path);
715
+ }
716
+ catch {
717
+ /* already gone */
718
+ }
719
+ }
456
720
  deleteFromIndex(id);
457
721
  console.log(`deleted ${id}`);
458
722
  return;
459
723
  }
724
+ /** Positional args with flags (and their values) skipped. */
725
+ function positionalArgs(argv) {
726
+ const out = [];
727
+ for (let i = 0; i < argv.length; i++) {
728
+ const a = argv[i];
729
+ if (a.startsWith("--")) {
730
+ const val = argv[i + 1];
731
+ if (val !== undefined && !val.startsWith("--"))
732
+ i++; // skip the flag's value
733
+ }
734
+ else {
735
+ out.push(a);
736
+ }
737
+ }
738
+ return out;
739
+ }
740
+ if (cmd === "propose") {
741
+ const flags = parseFlags(rest);
742
+ const ids = positionalArgs(rest);
743
+ if (ids.length === 0)
744
+ usage();
745
+ const to = flags.to ?? "project";
746
+ if (to !== "project") {
747
+ console.error(`propose --to "${to}" is not supported yet (org sharing is Phase 4).`);
748
+ process.exit(2);
749
+ }
750
+ try {
751
+ const batch = proposeMemories(ids, { localApprove: flags["local-approve"] === "true" });
752
+ for (const { sourceId, result: r } of batch) {
753
+ console.log(`proposed ${sourceId} → ${r.id} [${r.reviewState}]`);
754
+ }
755
+ console.log(`Next: these are drafts in the outbox (appdata) — nothing is in git yet.`);
756
+ console.log(` open-memex sync-status`);
757
+ console.log(` open-memex submit ${batch.map(({ result: r }) => r.id).join(" ")} # new branch, local commit; prints push + PR commands`);
758
+ }
759
+ catch (e) {
760
+ console.error(`propose failed: ${e.message}`);
761
+ process.exit(2);
762
+ }
763
+ return;
764
+ }
765
+ if (cmd === "promote") {
766
+ const flags = parseFlags(rest);
767
+ const id = rest.find((a) => !a.startsWith("--"));
768
+ if (!id)
769
+ usage();
770
+ try {
771
+ const r = promoteMemory(id, {
772
+ reject: flags.reject === "true",
773
+ resubmit: flags.resubmit === "true",
774
+ note: flags.note,
775
+ by: flags.by,
776
+ });
777
+ console.log(`promote ${r.id}: ${r.from} → ${r.to}`);
778
+ if (r.to === "published") {
779
+ console.log(` merged to the shared branch? Teammates pick it up with an explicit pull.`);
780
+ }
781
+ if (r.to === "rejected") {
782
+ console.log(` not deleted — the file stays on your branch. Your call:`);
783
+ console.log(` accept: close the PR and delete the branch;`);
784
+ console.log(` revise: edit the file, then \`open-memex promote ${r.id} --resubmit\`;`);
785
+ console.log(` keep: leave it as a [rejected] record.`);
786
+ }
787
+ if (r.history.length > 0) {
788
+ console.log(` history (${r.history.length}):`);
789
+ for (const h of formatReviewHistory(r.history))
790
+ console.log(h);
791
+ }
792
+ }
793
+ catch (e) {
794
+ console.error(`promote failed: ${e.message}`);
795
+ process.exit(2);
796
+ }
797
+ return;
798
+ }
799
+ if (cmd === "resolve") {
800
+ const target = rest.find((a) => !a.startsWith("--"));
801
+ try {
802
+ if (!target) {
803
+ const conflicts = listConflicts();
804
+ if (conflicts.length === 0) {
805
+ console.log("(no conflicted memory files)");
806
+ }
807
+ else {
808
+ for (const c of conflicts)
809
+ console.log(`conflicted: ${c.id} ${c.filePath}`);
810
+ console.log(`\nRun \`open-memex resolve <id>\` to attempt a field-level 3-way merge.`);
811
+ }
812
+ return;
813
+ }
814
+ const outcome = resolveConflict(target);
815
+ if (!outcome.ok) {
816
+ console.error(`cannot auto-resolve ${path.basename(outcome.filePath)} — semantic conflicts need a human:`);
817
+ for (const c of outcome.conflicts) {
818
+ console.error(` ${c.field}:`);
819
+ console.error(` base: ${c.base}`);
820
+ console.error(` ours: ${c.ours}`);
821
+ console.error(` theirs: ${c.theirs}`);
822
+ }
823
+ console.error(`Edit the file manually, then \`git add\` it. Nothing was written.`);
824
+ process.exit(3);
825
+ }
826
+ console.log(`resolved ${path.basename(outcome.filePath)}`);
827
+ if (outcome.autoMerged.length > 0)
828
+ console.log(` auto-merged: ${outcome.autoMerged.join(", ")}`);
829
+ console.log(` next: git add ${path.relative(process.cwd(), outcome.filePath)}`);
830
+ }
831
+ catch (e) {
832
+ console.error(`resolve failed: ${e.message}`);
833
+ process.exit(2);
834
+ }
835
+ return;
836
+ }
837
+ if (cmd === "sync-status") {
838
+ syncScope(project.key, "cli");
839
+ syncScope(PERSONAL_SCOPE.key, "cli");
840
+ try {
841
+ console.log(formatSyncStatus(getSyncStatus()));
842
+ }
843
+ catch (e) {
844
+ console.error(`sync-status failed: ${e.message}`);
845
+ process.exit(2);
846
+ }
847
+ return;
848
+ }
849
+ // §9 / D12: explicit pull — fetch + fast-forward only, never auto-merge.
850
+ if (cmd === "pull") {
851
+ const { GitProvider } = await import("./providers/git.js");
852
+ const root = projectRoot();
853
+ try {
854
+ const r = new GitProvider().pull(root);
855
+ const stats = syncScope(project.key, "pull");
856
+ if (r.fastForwarded) {
857
+ console.log(`pulled ${r.branch} from ${r.remote}: ${r.before.slice(0, 8)} → ${r.after.slice(0, 8)} (fast-forward)`);
858
+ }
859
+ else {
860
+ console.log(`already up to date: ${r.branch} @ ${r.after.slice(0, 8)}`);
861
+ }
862
+ console.log(`index: +${stats.added} ~${stats.updated} -${stats.removed} (scanned ${stats.scanned})`);
863
+ }
864
+ catch (e) {
865
+ console.error(`pull failed: ${e.message}`);
866
+ process.exit(2);
867
+ }
868
+ return;
869
+ }
870
+ // Explicit push — open-memex never pushes on its own (D36).
871
+ if (cmd === "push") {
872
+ const { GitProvider } = await import("./providers/git.js");
873
+ const root = projectRoot();
874
+ try {
875
+ const r = new GitProvider().push(root);
876
+ syncScope(project.key, "push");
877
+ console.log(`pushed ${r.branch} to ${r.remote} @ ${r.head.slice(0, 8)}`);
878
+ }
879
+ catch (e) {
880
+ console.error(`push failed: ${e.message}`);
881
+ process.exit(2);
882
+ }
883
+ return;
884
+ }
885
+ // §9 / D40: portable export bundle (markdown + manifest).
886
+ if (cmd === "export") {
887
+ const { exportMemories } = await import("./export.js");
888
+ const flags = parseFlags(rest);
889
+ const all = flags["all"] === "true" || flags["a"] === "true" || rest.includes("--all") || rest.includes("-a");
890
+ const scopeFlag = flags["scope"] ?? "project";
891
+ // parseFlags only handles `--` flags; `-o <file>` is picked up here.
892
+ const oIdx = rest.findIndex((a) => a === "-o");
893
+ const outFile = flags["o"] ?? flags["output"] ?? (oIdx >= 0 ? rest[oIdx + 1] : undefined);
894
+ const scopeKeys = scopeFlag === "both"
895
+ ? [project.key, PERSONAL_SCOPE.key]
896
+ : scopeFlag === "personal"
897
+ ? [PERSONAL_SCOPE.key]
898
+ : [project.key];
899
+ try {
900
+ const r = exportMemories({
901
+ scopeKeys,
902
+ type: flags["type"],
903
+ tag: flags["tag"],
904
+ includePrivate: all,
905
+ outFile,
906
+ });
907
+ console.log(`exported ${r.exported} memories → ${r.file}`);
908
+ if (!r.includePrivate && r.skippedPrivate > 0) {
909
+ console.log(`skipped ${r.skippedPrivate} private memories (use --all to include them)`);
910
+ }
911
+ }
912
+ catch (e) {
913
+ console.error(`export failed: ${e.message}`);
914
+ process.exit(2);
915
+ }
916
+ return;
917
+ }
918
+ if (cmd === "import") {
919
+ const { importBundle } = await import("./export.js");
920
+ const flags = parseFlags(rest);
921
+ const bundle = positionalArgs(rest)[0];
922
+ if (!bundle) {
923
+ console.error(`usage: open-memex import <bundle.tar.gz> [--dry-run]`);
924
+ process.exit(2);
925
+ }
926
+ const dryRun = flags["dry-run"] === "true";
927
+ try {
928
+ const r = importBundle(bundle, { projectScopeKey: project.key, dryRun });
929
+ if (!dryRun) {
930
+ syncScope(project.key, "cli");
931
+ syncScope(PERSONAL_SCOPE.key, "cli");
932
+ }
933
+ console.log(`${dryRun ? "DRY RUN: " : ""}imported ${r.imported}, skipped ${r.skippedIdentical} identical`);
934
+ for (const c of r.skippedConflict) {
935
+ console.log(` conflict (kept existing): ${c.id} from ${c.file}`);
936
+ }
937
+ }
938
+ catch (e) {
939
+ console.error(`import failed: ${e.message}`);
940
+ process.exit(2);
941
+ }
942
+ return;
943
+ }
944
+ // Phase 3: distill project memories into a proposed AGENTS.md snippet.
945
+ if (cmd === "distill-agents") {
946
+ const { distillAgentsMarkdown } = await import("./distill-agents.js");
947
+ const flags = parseFlags(rest);
948
+ const scopeFlag = flags["scope"] ?? "project";
949
+ const scopeKeys = scopeFlag === "personal" ? [PERSONAL_SCOPE.key] : [project.key];
950
+ const types = flags["type"]
951
+ ? flags["type"].split(",").map((t) => t.trim()).filter(Boolean)
952
+ : undefined;
953
+ const limit = flags["limit"] ? parseInt(flags["limit"], 10) : undefined;
954
+ const oIdx = rest.findIndex((a) => a === "-o");
955
+ const outFile = oIdx >= 0 ? rest[oIdx + 1] : undefined;
956
+ try {
957
+ const md = distillAgentsMarkdown({ scopeKeys, types, limit });
958
+ if (!md) {
959
+ console.log("no distillable memories found (decisions, constraints, lessons, gotchas, howtos)");
960
+ return;
961
+ }
962
+ if (outFile) {
963
+ fs.writeFileSync(outFile, md, "utf8");
964
+ console.log(`wrote proposed AGENTS.md snippet → ${outFile} (review and merge by hand)`);
965
+ }
966
+ else {
967
+ console.log(md);
968
+ }
969
+ }
970
+ catch (e) {
971
+ console.error(`distill-agents failed: ${e.message}`);
972
+ process.exit(2);
973
+ }
974
+ return;
975
+ }
976
+ if (cmd === "pr-status") {
977
+ const flags = parseFlags(rest);
978
+ try {
979
+ const st = getPrStatus();
980
+ console.log(formatPrStatus(st));
981
+ if (flags.apply === "true") {
982
+ const applied = applyPrStatus(st);
983
+ if (applied.length === 0) {
984
+ console.log(`nothing to apply.`);
985
+ }
986
+ else {
987
+ for (const a of applied) {
988
+ console.log(`applied ${a.memoryId.slice(0, 8)}: ${a.from} → ${a.to} (by ${a.by})`);
989
+ }
990
+ }
991
+ }
992
+ }
993
+ catch (e) {
994
+ console.error(`pr-status failed: ${e.message}`);
995
+ process.exit(2);
996
+ }
997
+ return;
998
+ }
999
+ if (cmd === "submit") {
1000
+ const flags = parseFlags(rest);
1001
+ const ids = positionalArgs(rest);
1002
+ if (ids.length === 0)
1003
+ usage();
1004
+ syncScope(project.key, "submit");
1005
+ try {
1006
+ const r = submitMemories(ids, { branch: flags.branch, base: flags.base });
1007
+ for (const s of r.submitted) {
1008
+ console.log(`submitted ${s.id} → ${path.relative(process.cwd(), s.filePath)} [${s.reviewState}]`);
1009
+ }
1010
+ for (const id of r.skippedIdentical) {
1011
+ console.log(`already on branch: ${id} (identical content — outbox copy removed)`);
1012
+ }
1013
+ if (!r.committed) {
1014
+ console.log(`nothing new to commit — branch ${r.branch} already holds these memories.`);
1015
+ }
1016
+ else {
1017
+ console.log(`committed on ${r.branch}.`);
1018
+ }
1019
+ console.log(`Next (needs your approval — open-memex never pushes for you):`);
1020
+ console.log(` ${r.pushCommand}`);
1021
+ console.log(` ${r.prCommand}`);
1022
+ }
1023
+ catch (e) {
1024
+ console.error(`submit failed: ${e.message}`);
1025
+ process.exit(2);
1026
+ }
1027
+ return;
1028
+ }
460
1029
  if (cmd === "scopes") {
461
1030
  const { memories } = paths();
462
1031
  if (!fs.existsSync(memories)) {