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.
package/src/cli.ts CHANGED
@@ -6,11 +6,13 @@ import { syncScope, upsertFromFile, deleteFromIndex } from "./store/sync.ts";
6
6
  import { migrateScope, scopeHasFiles, type ConflictStrategy } from "./store/migrate.ts";
7
7
  import { migrateV2 } from "./store/v2migrate.ts";
8
8
  import { findDuplicates, supersede, setStatus } from "./store/lifecycle.ts";
9
- import { search, list } from "./retrieve/search.ts";
9
+ import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory } from "./review.ts";
10
+ import { getSyncStatus, formatSyncStatus, submitMemories } from "./submit.ts";
11
+ import { getPrStatus, formatPrStatus, applyPrStatus } from "./github.ts";
12
+ import { search, list, hitStateLabel } from "./retrieve/search.ts";
10
13
  import {
11
14
  writeMemoryFile,
12
15
  readMemoryFile,
13
- deleteMemoryFile,
14
16
  ulid,
15
17
  msToRfc3339,
16
18
  type Frontmatter,
@@ -34,6 +36,12 @@ Usage:
34
36
  open-memex supersede <id> "new content" [--type T] [--tag t1,t2]
35
37
  open-memex status <id> active|deprecated|retracted|archived
36
38
  open-memex forget <id>
39
+ open-memex propose <id...> --to project [--local-approve]
40
+ open-memex promote <id> [--reject] [--resubmit] [--note "..."] [--by NAME]
41
+ open-memex resolve [id-or-path]
42
+ open-memex sync-status
43
+ open-memex submit <id...> [--onto <branch>] [--base <branch>]
44
+ open-memex pr-status [--apply]
37
45
  open-memex reindex
38
46
  open-memex scopes
39
47
  open-memex migrate [--from <key>] [--to <key>]
@@ -344,8 +352,8 @@ async function main() {
344
352
  }
345
353
 
346
354
  if (cmd === "reindex") {
347
- const a = syncScope(project.key);
348
- const b = syncScope(PERSONAL_SCOPE.key);
355
+ const a = syncScope(project.key, "cli");
356
+ const b = syncScope(PERSONAL_SCOPE.key, "cli");
349
357
  console.log(
350
358
  `reindexed. project: +${a.added} ~${a.updated} -${a.removed} (scanned ${a.scanned}), personal: +${b.added} ~${b.updated} -${b.removed} (scanned ${b.scanned})`,
351
359
  );
@@ -355,7 +363,7 @@ async function main() {
355
363
  if (cmd === "list") {
356
364
  const flags = parseFlags(rest);
357
365
  const s = resolveCliScope(flags, project);
358
- syncScope(s.key);
366
+ syncScope(s.key, "cli");
359
367
  const hits = list(s.key, {
360
368
  type: flags.type,
361
369
  limit: flags.limit ? Number(flags.limit) : undefined,
@@ -366,7 +374,8 @@ async function main() {
366
374
  }
367
375
  for (const h of hits) {
368
376
  const dep = h.status === "deprecated" ? " [deprecated]" : "";
369
- console.log(`[${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
377
+ const rev = hitStateLabel(h);
378
+ console.log(`[${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
370
379
  }
371
380
  return;
372
381
  }
@@ -375,8 +384,8 @@ async function main() {
375
384
  const query = rest[0];
376
385
  if (!query || query.startsWith("--")) usage();
377
386
  const flags = parseFlags(rest.slice(1));
378
- syncScope(project.key);
379
- syncScope(PERSONAL_SCOPE.key);
387
+ syncScope(project.key, "cli");
388
+ syncScope(PERSONAL_SCOPE.key, "cli");
380
389
  const keys =
381
390
  flags.scope === "personal" || flags.scope === "user"
382
391
  ? [PERSONAL_SCOPE.key]
@@ -395,7 +404,8 @@ async function main() {
395
404
  for (const h of hits) {
396
405
  const tag = h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project";
397
406
  const dep = h.status === "deprecated" ? " [deprecated]" : "";
398
- console.log(`[${tag}/${h.type}]${dep} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
407
+ const rev = hitStateLabel(h);
408
+ console.log(`[${tag}/${h.type}]${dep}${rev} ${h.id} ${h.snippet.replace(/\s+/g, " ").trim()}`);
399
409
  }
400
410
  return;
401
411
  }
@@ -438,6 +448,12 @@ async function main() {
438
448
  updated_at: rfc,
439
449
  supersedes: null,
440
450
  superseded_by: null,
451
+ review_state: "draft",
452
+ proposed_by: null,
453
+ approved_by: null,
454
+ derived_from: null,
455
+ review_note: null,
456
+ review_history: [],
441
457
  };
442
458
  const { filePath } = writeMemoryFile(fm, red);
443
459
  const mf = readMemoryFile(filePath);
@@ -504,19 +520,195 @@ async function main() {
504
520
  if (cmd === "forget") {
505
521
  const id = rest[0];
506
522
  if (!id) usage();
507
- const row = db().prepare(`SELECT scope_key FROM memories WHERE id = ?`).get(id) as
508
- | { scope_key: string }
523
+ const row = db().prepare(`SELECT file_path FROM memories WHERE id = ?`).get(id) as
524
+ | { file_path: string }
509
525
  | undefined;
510
526
  if (!row) {
511
527
  console.error("not found");
512
528
  process.exit(1);
513
529
  }
514
- deleteMemoryFile(row.scope_key, id);
530
+ // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
531
+ if (row.file_path) {
532
+ try {
533
+ fs.unlinkSync(row.file_path);
534
+ } catch {
535
+ /* already gone */
536
+ }
537
+ }
515
538
  deleteFromIndex(id);
516
539
  console.log(`deleted ${id}`);
517
540
  return;
518
541
  }
519
542
 
543
+ /** Positional args with flags (and their values) skipped. */
544
+ function positionalArgs(argv: string[]): string[] {
545
+ const out: string[] = [];
546
+ for (let i = 0; i < argv.length; i++) {
547
+ const a = argv[i]!;
548
+ if (a.startsWith("--")) {
549
+ const val = argv[i + 1];
550
+ if (val !== undefined && !val.startsWith("--")) i++; // skip the flag's value
551
+ } else {
552
+ out.push(a);
553
+ }
554
+ }
555
+ return out;
556
+ }
557
+
558
+ if (cmd === "propose") {
559
+ const flags = parseFlags(rest);
560
+ const ids = positionalArgs(rest);
561
+ if (ids.length === 0) usage();
562
+ const to = flags.to ?? "project";
563
+ if (to !== "project") {
564
+ console.error(`propose --to "${to}" is not supported yet (org sharing is Phase 4).`);
565
+ process.exit(2);
566
+ }
567
+ try {
568
+ const batch = proposeMemories(ids, { localApprove: flags["local-approve"] === "true" });
569
+ for (const { sourceId, result: r } of batch) {
570
+ console.log(`proposed ${sourceId} → ${r.id} [${r.reviewState}]`);
571
+ }
572
+ console.log(`Next: these are drafts in the outbox (appdata) — nothing is in git yet.`);
573
+ console.log(` open-memex sync-status`);
574
+ console.log(` open-memex submit ${batch.map(({ result: r }) => r.id).join(" ")} # new branch, local commit; prints push + PR commands`);
575
+ } catch (e) {
576
+ console.error(`propose failed: ${(e as Error).message}`);
577
+ process.exit(2);
578
+ }
579
+ return;
580
+ }
581
+
582
+ if (cmd === "promote") {
583
+ const flags = parseFlags(rest);
584
+ const id = rest.find((a) => !a.startsWith("--"));
585
+ if (!id) usage();
586
+ try {
587
+ const r = promoteMemory(id, {
588
+ reject: flags.reject === "true",
589
+ resubmit: flags.resubmit === "true",
590
+ note: flags.note,
591
+ by: flags.by,
592
+ });
593
+ console.log(`promote ${r.id}: ${r.from} → ${r.to}`);
594
+ if (r.to === "published") {
595
+ console.log(` merged to the shared branch? Teammates pick it up with an explicit pull.`);
596
+ }
597
+ if (r.to === "rejected") {
598
+ console.log(` not deleted — the file stays on your branch. Your call:`);
599
+ console.log(` accept: close the PR and delete the branch;`);
600
+ console.log(` revise: edit the file, then \`open-memex promote ${r.id} --resubmit\`;`);
601
+ console.log(` keep: leave it as a [rejected] record.`);
602
+ }
603
+ if (r.history.length > 0) {
604
+ console.log(` history (${r.history.length}):`);
605
+ for (const h of formatReviewHistory(r.history)) console.log(h);
606
+ }
607
+ } catch (e) {
608
+ console.error(`promote failed: ${(e as Error).message}`);
609
+ process.exit(2);
610
+ }
611
+ return;
612
+ }
613
+
614
+ if (cmd === "resolve") {
615
+ const target = rest.find((a) => !a.startsWith("--"));
616
+ try {
617
+ if (!target) {
618
+ const conflicts = listConflicts();
619
+ if (conflicts.length === 0) {
620
+ console.log("(no conflicted memory files)");
621
+ } else {
622
+ for (const c of conflicts) console.log(`conflicted: ${c.id} ${c.filePath}`);
623
+ console.log(`\nRun \`open-memex resolve <id>\` to attempt a field-level 3-way merge.`);
624
+ }
625
+ return;
626
+ }
627
+ const outcome = resolveConflict(target);
628
+ if (!outcome.ok) {
629
+ console.error(`cannot auto-resolve ${path.basename(outcome.filePath)} — semantic conflicts need a human:`);
630
+ for (const c of outcome.conflicts) {
631
+ console.error(` ${c.field}:`);
632
+ console.error(` base: ${c.base}`);
633
+ console.error(` ours: ${c.ours}`);
634
+ console.error(` theirs: ${c.theirs}`);
635
+ }
636
+ console.error(`Edit the file manually, then \`git add\` it. Nothing was written.`);
637
+ process.exit(3);
638
+ }
639
+ console.log(`resolved ${path.basename(outcome.filePath)}`);
640
+ if (outcome.autoMerged.length > 0)
641
+ console.log(` auto-merged: ${outcome.autoMerged.join(", ")}`);
642
+ console.log(` next: git add ${path.relative(process.cwd(), outcome.filePath)}`);
643
+ } catch (e) {
644
+ console.error(`resolve failed: ${(e as Error).message}`);
645
+ process.exit(2);
646
+ }
647
+ return;
648
+ }
649
+
650
+ if (cmd === "sync-status") {
651
+ syncScope(project.key, "cli");
652
+ syncScope(PERSONAL_SCOPE.key, "cli");
653
+ try {
654
+ console.log(formatSyncStatus(getSyncStatus()));
655
+ } catch (e) {
656
+ console.error(`sync-status failed: ${(e as Error).message}`);
657
+ process.exit(2);
658
+ }
659
+ return;
660
+ }
661
+
662
+ if (cmd === "pr-status") {
663
+ const flags = parseFlags(rest);
664
+ try {
665
+ const st = getPrStatus();
666
+ console.log(formatPrStatus(st));
667
+ if (flags.apply === "true") {
668
+ const applied = applyPrStatus(st);
669
+ if (applied.length === 0) {
670
+ console.log(`nothing to apply.`);
671
+ } else {
672
+ for (const a of applied) {
673
+ console.log(`applied ${a.memoryId.slice(0, 8)}: ${a.from} → ${a.to} (by ${a.by})`);
674
+ }
675
+ }
676
+ }
677
+ } catch (e) {
678
+ console.error(`pr-status failed: ${(e as Error).message}`);
679
+ process.exit(2);
680
+ }
681
+ return;
682
+ }
683
+
684
+ if (cmd === "submit") {
685
+ const flags = parseFlags(rest);
686
+ const ids = positionalArgs(rest);
687
+ if (ids.length === 0) usage();
688
+ syncScope(project.key, "submit");
689
+ try {
690
+ const r = submitMemories(ids, { onto: flags.onto, base: flags.base });
691
+ for (const s of r.submitted) {
692
+ console.log(`submitted ${s.id} → ${path.relative(process.cwd(), s.filePath)} [${s.reviewState}]`);
693
+ }
694
+ for (const id of r.skippedIdentical) {
695
+ console.log(`already on branch: ${id} (identical content — outbox copy removed)`);
696
+ }
697
+ if (!r.committed) {
698
+ console.log(`nothing new to commit — branch ${r.branch} already holds these memories.`);
699
+ } else {
700
+ console.log(`committed on ${r.branch}.`);
701
+ }
702
+ console.log(`Next (needs your approval — open-memex never pushes for you):`);
703
+ console.log(` ${r.pushCommand}`);
704
+ console.log(` ${r.prCommand}`);
705
+ } catch (e) {
706
+ console.error(`submit failed: ${(e as Error).message}`);
707
+ process.exit(2);
708
+ }
709
+ return;
710
+ }
711
+
520
712
  if (cmd === "scopes") {
521
713
  const { memories } = paths();
522
714
  if (!fs.existsSync(memories)) {
package/src/config.ts CHANGED
@@ -13,6 +13,12 @@ export interface MyOMemoryConfig {
13
13
  keywordPersonalPatterns: string[];
14
14
  redactPatterns: string[];
15
15
  logLevel: "debug" | "info" | "warn" | "error";
16
+ /**
17
+ * In-repo project-memory directory, relative to the project root (2B/D23).
18
+ * Default `.ai/open-memex/`. Must stay relative and `..`-free so project
19
+ * memories can never escape the repo.
20
+ */
21
+ memoryDir: string;
16
22
  }
17
23
 
18
24
  export const DEFAULT_CONFIG: MyOMemoryConfig = {
@@ -47,6 +53,7 @@ export const DEFAULT_CONFIG: MyOMemoryConfig = {
47
53
  // Add only your own extra patterns here.
48
54
  redactPatterns: [],
49
55
  logLevel: "info",
56
+ memoryDir: ".ai/open-memex",
50
57
  };
51
58
 
52
59
  function stripJsonComments(raw: string): string {
@@ -80,12 +87,21 @@ function toNonNegInt(v: unknown): number {
80
87
  return n;
81
88
  }
82
89
 
90
+ function toRelativeDir(v: unknown): string {
91
+ if (typeof v !== "string" || !v.trim()) throw new Error("must be a non-empty relative directory");
92
+ const t = v.trim().replace(/\\/g, "/").replace(/\/+$/, "");
93
+ if (!t || path.isAbsolute(t) || t.split("/").includes(".."))
94
+ throw new Error('must be a relative path without ".." (e.g. ".ai/open-memex")');
95
+ return t;
96
+ }
97
+
83
98
  /** Keys users may change via `open-memex config set <key> <value>`, with validators. */
84
99
  export const SETTABLE_KEYS: Record<string, (v: unknown) => unknown> = {
85
100
  maxProjectMemories: toNonNegInt,
86
101
  maxProfileItems: toNonNegInt,
87
102
  injectOnFirstTurn: toBool,
88
103
  keywordCaptureEnabled: toBool,
104
+ memoryDir: toRelativeDir,
89
105
  logLevel: (v) => {
90
106
  if (v !== "info" && v !== "debug") throw new Error('must be "info" or "debug"');
91
107
  return v;
package/src/github.ts ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * 2B/D32: GitHub PR ↔ review_state mapping.
3
+ *
4
+ * Memories ride into review inside a branch + PR (dedicated memory PR or
5
+ * together with code). The PR's lifecycle on GitHub is the team's review
6
+ * signal; this module reads it back and maps it onto each memory's own
7
+ * review_state:
8
+ *
9
+ * PR merged → proposed/approved → published
10
+ * PR reviewDecision == APPROVED → proposed → approved (approved_by = reviewer login)
11
+ * PR reviewDecision == CHANGES_REQUESTED → suggestion only, printed for the human
12
+ *
13
+ * Per-memory independence: every memory on the branch keeps its own state.
14
+ * A locally `rejected` memory is never flipped by a PR approval, and a
15
+ * changes-requested review never auto-rejects — the human runs `promote`.
16
+ *
17
+ * Report by default; `--apply` performs the mapped transitions locally
18
+ * (file rewrite + index, never a push — inside the D27 local-git line).
19
+ * Needs the GitHub CLI (`gh`) authenticated on the user's machine.
20
+ */
21
+
22
+ import { execFileSync } from "node:child_process";
23
+ import * as fs from "node:fs";
24
+ import * as path from "node:path";
25
+ import { loadConfig } from "./config.ts";
26
+ import { projectRoot } from "./paths.ts";
27
+ import { parse as parseMemory, type ReviewState } from "./store/markdown.ts";
28
+ import { iterInRepoMemoryFiles } from "./store/markdown.ts";
29
+ import { promoteMemory, currentAuthor } from "./review.ts";
30
+
31
+ function fail(msg: string): never {
32
+ throw new Error(`[open-memex] ${msg}`);
33
+ }
34
+
35
+ function sh(cmd: string, args: string[], cwd: string): string {
36
+ try {
37
+ return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
38
+ } catch (e) {
39
+ const err = e as { stderr?: string; message?: string };
40
+ const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
41
+ fail(`${cmd} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
42
+ }
43
+ }
44
+
45
+ export interface PrInfo {
46
+ number: number;
47
+ url: string;
48
+ state: "open" | "closed"; // GitHub PR state
49
+ merged: boolean;
50
+ mergedAt: string | null;
51
+ reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
52
+ approvedBy: string[];
53
+ changesRequestedBy: string[];
54
+ base: string;
55
+ head: string;
56
+ title: string;
57
+ }
58
+
59
+ interface GhPrJson {
60
+ number: number;
61
+ url: string;
62
+ state: string;
63
+ mergedAt: string | null;
64
+ isMerged?: boolean;
65
+ reviewDecision?: string | null;
66
+ baseRefName: string;
67
+ headRefName: string;
68
+ title: string;
69
+ reviews?: Array<{ author: { login: string }; state: string; submittedAt: string }>;
70
+ }
71
+
72
+ /** Read the PR for a branch (default: current branch) via the GitHub CLI. */
73
+ export function getPrInfo(branch?: string): PrInfo | null {
74
+ const root = projectRoot();
75
+ // `gh` must exist and be authenticated on the user's machine.
76
+ try {
77
+ execFileSync("gh", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
78
+ } catch {
79
+ fail("the GitHub CLI (`gh`) is not installed — install it and run `gh auth login` first.");
80
+ }
81
+ const ref = branch ?? sh("git", ["branch", "--show-current"], root);
82
+ if (!ref) fail("not on a branch — check out the memory branch first.");
83
+ let raw: string;
84
+ try {
85
+ raw = sh("gh", ["pr", "view", ref, "--json",
86
+ "number,url,state,mergedAt,reviewDecision,baseRefName,headRefName,title,reviews"], root);
87
+ } catch {
88
+ return null; // no PR for this branch yet
89
+ }
90
+ let j: GhPrJson;
91
+ try {
92
+ j = JSON.parse(raw) as GhPrJson;
93
+ } catch {
94
+ fail(`could not parse \`gh pr view\` output for branch ${ref}.`);
95
+ }
96
+ const approvedBy: string[] = [];
97
+ const changesRequestedBy: string[] = [];
98
+ // Latest review per author wins.
99
+ const latest = new Map<string, { state: string; at: string }>();
100
+ for (const r of j.reviews ?? []) {
101
+ const login = r.author?.login;
102
+ if (!login) continue;
103
+ const prev = latest.get(login);
104
+ if (!prev || r.submittedAt >= prev.at) latest.set(login, { state: r.state, at: r.submittedAt });
105
+ }
106
+ for (const [login, r] of latest) {
107
+ if (r.state === "APPROVED") approvedBy.push(login);
108
+ else if (r.state === "CHANGES_REQUESTED") changesRequestedBy.push(login);
109
+ }
110
+ return {
111
+ number: j.number,
112
+ url: j.url,
113
+ state: j.state === "OPEN" ? "open" : "closed",
114
+ merged: !!j.mergedAt,
115
+ mergedAt: j.mergedAt,
116
+ reviewDecision: (j.reviewDecision as PrInfo["reviewDecision"]) ?? null,
117
+ approvedBy,
118
+ changesRequestedBy,
119
+ base: j.baseRefName,
120
+ head: j.headRefName,
121
+ title: j.title,
122
+ };
123
+ }
124
+
125
+ export interface MemoryPrMapping {
126
+ memoryId: string;
127
+ from: ReviewState;
128
+ /** Null = no automatic transition; see reason. */
129
+ suggestedTo: ReviewState | null;
130
+ reason: string;
131
+ approvedBy: string | null;
132
+ }
133
+
134
+ export interface PrStatus {
135
+ branch: string;
136
+ pr: PrInfo | null;
137
+ mappings: MemoryPrMapping[];
138
+ }
139
+
140
+ /** Map the branch PR's state onto each in-repo memory's review_state. */
141
+ export function getPrStatus(branch?: string): PrStatus {
142
+ const root = projectRoot();
143
+ const cfg = loadConfig();
144
+ const current = branch ?? sh("git", ["branch", "--show-current"], root);
145
+ const pr = getPrInfo(current);
146
+
147
+ const mappings: MemoryPrMapping[] = [];
148
+ for (const fp of iterInRepoMemoryFiles(root, cfg.memoryDir)) {
149
+ const raw = fs.readFileSync(fp, "utf8");
150
+ const parsed = parseMemory(raw);
151
+ if (!parsed) continue;
152
+ const from = parsed.fm.review_state ?? "draft";
153
+ const id = parsed.fm.id;
154
+ if (!pr) {
155
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: `no PR for branch ${current} yet`, approvedBy: null });
156
+ continue;
157
+ }
158
+ if (from === "published") {
159
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: "already published", approvedBy: null });
160
+ continue;
161
+ }
162
+ if (from === "rejected") {
163
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: `stays rejected — a PR ${pr.merged ? "merge" : "approval"} never overrides a human rejection`, approvedBy: null });
164
+ continue;
165
+ }
166
+ if (pr.merged) {
167
+ if (from === "proposed" || from === "approved") {
168
+ mappings.push({ memoryId: id, from, suggestedTo: "published", reason: `PR #${pr.number} merged`, approvedBy: null });
169
+ } else {
170
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: `PR #${pr.number} merged, but memory is "${from}"`, approvedBy: null });
171
+ }
172
+ continue;
173
+ }
174
+ if (pr.reviewDecision === "APPROVED" && from === "proposed") {
175
+ const by = pr.approvedBy[0] ?? null;
176
+ mappings.push({
177
+ memoryId: id, from, suggestedTo: "approved",
178
+ reason: `PR #${pr.number} approved${by ? ` by @${by}` : ""}`, approvedBy: by,
179
+ });
180
+ continue;
181
+ }
182
+ if (pr.reviewDecision === "CHANGES_REQUESTED") {
183
+ const by = pr.changesRequestedBy[0] ?? null;
184
+ mappings.push({
185
+ memoryId: id, from, suggestedTo: null,
186
+ reason: `PR #${pr.number} has changes requested${by ? ` by @${by}` : ""} — run \`open-memex promote ${id} --reject --note "..."\` yourself if you agree`,
187
+ approvedBy: null,
188
+ });
189
+ continue;
190
+ }
191
+ mappings.push({
192
+ memoryId: id, from, suggestedTo: null,
193
+ reason: pr.state === "open" ? `PR #${pr.number} still awaiting review` : `PR #${pr.number} is closed without merge`,
194
+ approvedBy: null,
195
+ });
196
+ }
197
+ return { branch: current, pr, mappings };
198
+ }
199
+
200
+ export interface PrApplyResult {
201
+ memoryId: string;
202
+ from: ReviewState;
203
+ to: ReviewState;
204
+ by: string;
205
+ }
206
+
207
+ /** Apply the mapped transitions locally (no push — D27). Advances step by
208
+ * step through the ladder, so a merged PR moves proposed → approved →
209
+ * published with each step in the audit trail. */
210
+ export function applyPrStatus(st: PrStatus): PrApplyResult[] {
211
+ const out: PrApplyResult[] = [];
212
+ const actor = currentAuthor();
213
+ const order: ReviewState[] = ["draft", "proposed", "approved", "published"];
214
+ for (const m of st.mappings) {
215
+ if (!m.suggestedTo || order.indexOf(m.from) >= order.indexOf(m.suggestedTo)) continue;
216
+ const by = m.approvedBy ?? actor;
217
+ const note = m.suggestedTo === "published" && st.pr
218
+ ? `PR #${st.pr.number} merged`
219
+ : m.reason;
220
+ let cur = m.from;
221
+ while (order.indexOf(cur) < order.indexOf(m.suggestedTo)) {
222
+ const r = promoteMemory(m.memoryId, { by, note });
223
+ cur = r.to;
224
+ out.push({ memoryId: m.memoryId, from: r.from, to: r.to, by });
225
+ }
226
+ }
227
+ return out;
228
+ }
229
+
230
+ export function formatPrStatus(st: PrStatus): string {
231
+ const lines: string[] = [];
232
+ lines.push(`branch: ${st.branch}`);
233
+ if (!st.pr) {
234
+ lines.push(`PR: none yet — push the branch and open one, then re-run.`);
235
+ } else {
236
+ const p = st.pr;
237
+ lines.push(`PR #${p.number}: ${p.title}`);
238
+ lines.push(` ${p.url}`);
239
+ lines.push(` state: ${p.merged ? `merged${p.mergedAt ? ` at ${p.mergedAt}` : ""}` : p.state}` +
240
+ (p.reviewDecision ? `, reviews: ${p.reviewDecision}` : ""));
241
+ if (p.approvedBy.length > 0) lines.push(` approved by: ${p.approvedBy.map((l) => "@" + l).join(", ")}`);
242
+ if (p.changesRequestedBy.length > 0) lines.push(` changes requested by: ${p.changesRequestedBy.map((l) => "@" + l).join(", ")}`);
243
+ }
244
+ if (st.mappings.length === 0) {
245
+ lines.push(`no in-repo memories on this branch.`);
246
+ } else {
247
+ lines.push(`memories (${st.mappings.length}):`);
248
+ for (const m of st.mappings) {
249
+ const arrow = m.suggestedTo ? ` → [${m.suggestedTo}]` : "";
250
+ lines.push(` ${m.memoryId.slice(0, 8)} [${m.from}]${arrow} ${m.reason}`);
251
+ }
252
+ if (st.mappings.some((m) => m.suggestedTo)) {
253
+ lines.push(`run with --apply to perform the mapped transitions locally (no push).`);
254
+ }
255
+ }
256
+ return lines.join("\n");
257
+ }
package/src/index.ts CHANGED
@@ -93,6 +93,12 @@ const plugin: Plugin = async ({ worktree, directory }) => {
93
93
  updated_at: rfc,
94
94
  supersedes: null,
95
95
  superseded_by: null,
96
+ review_state: "draft",
97
+ proposed_by: null,
98
+ approved_by: null,
99
+ derived_from: null,
100
+ review_note: null,
101
+ review_history: [],
96
102
  };
97
103
  try {
98
104
  const { filePath } = writeMemoryFile(fm, content);
package/src/init.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { execFileSync } from "node:child_process";
7
6
  import { createInterface } from "node:readline/promises";
8
7
  import { DEFAULT_CONFIG, saveConfig } from "./config.ts";
8
+ import { projectRoot } from "./paths.ts";
9
9
 
10
10
  const MARKER = "<!-- open-memex -->";
11
11
 
@@ -50,8 +50,10 @@ const INSTRUCTIONS = `${MARKER}
50
50
  > Applies only when the \`open-memex\` MCP server is available in this session
51
51
  > (the \`memory_*\` tools exist). Otherwise ignore this section.
52
52
 
53
- You have a local memory MCP server (\`open-memex\`) with five tools:
54
- \`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`.
53
+ You have a local memory MCP server (\`open-memex\`) with eleven tools:
54
+ \`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`,
55
+ \`memory_status\`, \`memory_submit\`, \`memory_propose\`, \`memory_promote\`, \`memory_resolve\`,
56
+ \`memory_pr_status\`.
55
57
 
56
58
  - BE PROACTIVE. When the user shares something worth remembering across sessions
57
59
  (a decision, a preference, a project convention, a fix and its cause), call
@@ -62,21 +64,32 @@ You have a local memory MCP server (\`open-memex\`) with five tools:
62
64
  - Memories default to this project's scope; use the \`personal\` scope for facts about
63
65
  the user that hold across all projects. When a saved fact becomes outdated, call
64
66
  \`memory_supersede\` instead of adding a duplicate.
65
- `;
66
67
 
67
- /** Project root: git top-level, falling back to cwd. */
68
- function projectRoot(): string {
69
- try {
70
- const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
71
- encoding: "utf8",
72
- stdio: ["ignore", "pipe", "ignore"],
73
- }).trim();
74
- if (top) return top;
75
- } catch {
76
- /* not a git repo — use cwd */
77
- }
78
- return process.cwd();
79
- }
68
+ ## Syncing project memories for review (D26)
69
+
70
+ Project memories you save land in a local outbox first — they are NOT in git yet.
71
+ Syncing them into the repo for review is an explicit, user-approved step:
72
+
73
+ - At session start, and when you finish a meaningful chunk of work, call
74
+ \`memory_status\`. If the outbox has drafts, summarize them (one line each) and ask
75
+ the user which ones to sync. Sync NOTHING the user did not name.
76
+ - When the user approves, ask ONE follow-up: a separate memory-only branch/PR
77
+ (recommended), or fold the memories into the current branch alongside code?
78
+ A "yes, you do it" answer covers the whole chain — branch, local commit,
79
+ push, PR creation — do NOT re-ask at each step. If the user says they will
80
+ do it themselves, hand them the printed push/PR commands and do nothing.
81
+ Then call \`memory_submit\` with the approved ids (\`onto\` = current branch name
82
+ only for the fold-into-code path). It creates the branch, copies the drafts in
83
+ as \`proposed\`, commits locally, and prints the push + PR commands.
84
+ - Base branch for the memory PR defaults to the branch you are on; the user may
85
+ redirect it to the integration branch (main) for branch-independent knowledge.
86
+ - If anything conflicts (same id with different content, push rejected), STOP and
87
+ let the user judge — never overwrite.
88
+ - After the PR merges, call \`memory_pr_status\` (with \`apply\` when the user
89
+ approves) to map the PR's review state back onto each memory — merged means
90
+ \`published\`, an approval means \`approved\` (credited to the reviewer).
91
+ - \`personal\` scope memories NEVER leave the machine.
92
+ `;
80
93
 
81
94
  function writeMcpJson(root: string, client: string, force: boolean): string | null {
82
95
  if (client === "opencode") return writeOpencodeMcpJson(root, force);