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/tools/ops.js CHANGED
@@ -6,14 +6,18 @@
6
6
  * They take plain validated args and return a plain { title, output }
7
7
  * result; each host adapts that to its own tool-result shape.
8
8
  */
9
+ import fs from "node:fs";
9
10
  import { z } from "zod";
10
11
  import { PERSONAL_SCOPE } from "../scope.js";
11
- import { search, list } from "../retrieve/search.js";
12
- import { writeMemoryFile, deleteMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, } from "../store/markdown.js";
12
+ import { search, list, hitStateLabel } from "../retrieve/search.js";
13
+ import { writeMemoryFile, readMemoryFile, ulid, msToRfc3339, MEMORY_TYPE_TAXONOMY, } from "../store/markdown.js";
13
14
  import { upsertFromFile, deleteFromIndex } from "../store/sync.js";
14
15
  import { findDuplicates, supersede } from "../store/lifecycle.js";
15
16
  import { db } from "../store/db.js";
16
17
  import { redact } from "../redact.js";
18
+ import { getSyncStatus, formatSyncStatus, submitMemories } from "../submit.js";
19
+ import { getPrStatus, formatPrStatus, applyPrStatus } from "../github.js";
20
+ import { proposeMemories, promoteMemory, listConflicts, resolveConflict, formatReviewHistory, } from "../review.js";
17
21
  /** LLM-facing tool descriptions, shared by the opencode plugin and the MCP server. */
18
22
  export const TOOL_DESCRIPTIONS = {
19
23
  memory_add: "Save a fact, preference, decision, or note to persistent local memory. Call this PROACTIVELY whenever the user shares something worth remembering across sessions — project conventions, tool choices, personal preferences, decisions made, error fixes and their causes. Do not wait to be asked. Keep each memory to one self-contained statement. Default scope is the current project; use the personal scope for facts about the user that apply across all projects.",
@@ -21,6 +25,12 @@ export const TOOL_DESCRIPTIONS = {
21
25
  memory_list: "List memories in a scope, newest first. Useful for browsing what is remembered, or verifying that a save landed.",
22
26
  memory_supersede: "Replace an existing memory with a newer version. The old memory is kept as history (status: superseded) and retrieval returns the new one. Use when a saved fact becomes outdated and should be replaced rather than duplicated.",
23
27
  memory_forget: "Delete a memory by id. Use when the user asks to forget something.",
28
+ memory_status: "Show the project memory sync pipeline: drafts waiting in the outbox (appdata), memories in the repo awaiting review or published, and any repo files not yet committed. Call this at session start and at task checkpoints, then ask the user which drafts to sync. The user may also trigger this flow by saying 'sync memory' (or '同步记忆').",
29
+ memory_submit: "Move outbox drafts into the repo memory dir for review: copies the drafts in as proposed (or keeps a local approval), commits locally on the current branch, and moves the outbox originals out. Never creates a branch on its own — pass branch= only with the user's explicit approval for the full chain. Prints the push and PR commands — those need the user's explicit approval and are never run automatically.",
30
+ memory_propose: "Copy personal memories into the project outbox as review drafts. The personal originals stay put.",
31
+ memory_promote: "Advance a project memory one step up the review ladder (proposed → approved → published), or reject it with a note. Rejected memories are never deleted — they can be revised and resubmitted.",
32
+ memory_resolve: "List git-conflicted memory files, or attempt a field-level 3-way merge of one. Semantic conflicts are reported, never auto-resolved.",
33
+ memory_pr_status: "Read the current branch's GitHub PR and map its review state onto each in-repo memory: merged PR → published, PR approval → approved (approved_by = reviewer), changes-requested → suggestion only. Report by default; apply=true performs the mapped transitions locally (no push).",
24
34
  };
25
35
  /** Shared zod input shapes (raw shape, not z.object — hosts wrap as needed). */
26
36
  export const scopeArg = z
@@ -32,9 +42,13 @@ export const memoryAddArgs = {
32
42
  type: z
33
43
  .enum(MEMORY_TYPE_TAXONOMY)
34
44
  .optional()
35
- .describe("Category of memory. Default: note."),
45
+ .describe("Category of memory. Default: fact."),
36
46
  scope: scopeArg,
37
47
  tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
48
+ source: z
49
+ .string()
50
+ .optional()
51
+ .describe("Where this memory came from. Default: tool. Pass 'inference' for agent-proposed captures at checkpoints (V2-DESIGN §3.5)."),
38
52
  };
39
53
  export const memorySearchArgs = {
40
54
  query: z
@@ -65,6 +79,44 @@ export const memorySupersedeArgs = {
65
79
  export const memoryForgetArgs = {
66
80
  id: z.string().min(1),
67
81
  };
82
+ export const memoryStatusArgs = {};
83
+ export const memorySubmitArgs = {
84
+ ids: z.array(z.string().min(1)).min(1).describe("Outbox draft ids to submit."),
85
+ branch: z
86
+ .string()
87
+ .optional()
88
+ .describe("Create this branch and submit onto it. If omitted, submit stays on the current branch — branches are never auto-created. Only pass this when the user explicitly approved the full chain (branch + push + PR)."),
89
+ base: z
90
+ .string()
91
+ .optional()
92
+ .describe("PR base branch override. Default: the branch the submit ran on."),
93
+ };
94
+ export const memoryProposeArgs = {
95
+ ids: z.array(z.string().min(1)).min(1).describe("Personal memory ids to copy into the project outbox."),
96
+ localApprove: z
97
+ .boolean()
98
+ .optional()
99
+ .describe("Single-developer shortcut: mark the copies approved immediately."),
100
+ };
101
+ export const memoryPromoteArgs = {
102
+ id: z.string().min(1).describe("Project memory id."),
103
+ reject: z.boolean().optional().describe("Reject instead of advancing."),
104
+ resubmit: z.boolean().optional().describe("Move rejected back to proposed for another round."),
105
+ note: z.string().optional().describe("Review note recorded on reject."),
106
+ by: z.string().optional().describe("Reviewer name override."),
107
+ };
108
+ export const memoryResolveArgs = {
109
+ target: z
110
+ .string()
111
+ .optional()
112
+ .describe("Memory id or file path to resolve. Omit to list conflicts."),
113
+ };
114
+ export const memoryPrStatusArgs = {
115
+ apply: z
116
+ .boolean()
117
+ .optional()
118
+ .describe("Perform the mapped review transitions locally (no push). Default: report only."),
119
+ };
68
120
  function resolveScope(getScope, kind) {
69
121
  return kind === "personal" || kind === "user" ? PERSONAL_SCOPE : getScope();
70
122
  }
@@ -87,6 +139,12 @@ function buildFrontmatter(s, body) {
87
139
  updated_at: rfc,
88
140
  supersedes: null,
89
141
  superseded_by: null,
142
+ review_state: "draft",
143
+ proposed_by: null,
144
+ approved_by: null,
145
+ derived_from: null,
146
+ review_note: null,
147
+ review_history: [],
90
148
  };
91
149
  }
92
150
  export async function addMemory(getScope, cfg, args) {
@@ -107,7 +165,7 @@ export async function addMemory(getScope, cfg, args) {
107
165
  const fm = buildFrontmatter(s, {
108
166
  type: args.type ?? "fact",
109
167
  tags: args.tags ?? [],
110
- source: "tool",
168
+ source: args.source ?? "tool",
111
169
  });
112
170
  const { filePath } = writeMemoryFile(fm, redacted);
113
171
  const mf = readMemoryFile(filePath);
@@ -137,7 +195,7 @@ export async function searchMemories(getScope, args) {
137
195
  output: `No memories matched "${args.query}".`,
138
196
  };
139
197
  }
140
- const lines = hits.map((h) => `- [${h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project"}/${h.type}] id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`);
198
+ const lines = hits.map((h) => `- [${h.scope_key === PERSONAL_SCOPE.key ? "personal" : "project"}/${h.type}]${hitStateLabel(h)} id=${h.id}\n ${h.snippet.replace(/\s+/g, " ").trim()}`);
141
199
  return { title: `memory: ${hits.length} result(s)`, output: lines.join("\n") };
142
200
  }
143
201
  export async function listMemories(getScope, args) {
@@ -146,7 +204,7 @@ export async function listMemories(getScope, args) {
146
204
  if (hits.length === 0) {
147
205
  return { title: "memory: empty", output: `No memories in scope ${s.key}.` };
148
206
  }
149
- const lines = hits.map((h) => `- [${h.type}] id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
207
+ const lines = hits.map((h) => `- [${h.type}]${hitStateLabel(h)} id=${h.id} — ${h.snippet.replace(/\s+/g, " ").trim()}`);
150
208
  return { title: `memory: ${hits.length} in ${s.key}`, output: lines.join("\n") };
151
209
  }
152
210
  export async function supersedeMemory(cfg, args) {
@@ -175,12 +233,117 @@ export async function supersedeMemory(cfg, args) {
175
233
  }
176
234
  export async function forgetMemory(args) {
177
235
  const row = db()
178
- .prepare(`SELECT scope_key FROM memories WHERE id = ?`)
236
+ .prepare(`SELECT file_path FROM memories WHERE id = ?`)
179
237
  .get(args.id);
180
238
  if (!row) {
181
239
  return { title: "memory: not found", output: `No memory with id ${args.id}.` };
182
240
  }
183
- deleteMemoryFile(row.scope_key, args.id);
241
+ // Delete by indexed file_path — location-agnostic (appdata or in-repo, 2B/D24).
242
+ if (row.file_path) {
243
+ try {
244
+ fs.unlinkSync(row.file_path);
245
+ }
246
+ catch {
247
+ /* already gone */
248
+ }
249
+ }
184
250
  deleteFromIndex(args.id);
185
251
  return { title: "memory: forgotten", output: `Deleted ${args.id}.` };
186
252
  }
253
+ // ---------------------------------------------------------------------------
254
+ // 2B/D26: review workflow ops (also exposed over MCP so agents can drive the
255
+ // sync loop: status → ask user → submit → push/PR on approval).
256
+ // ---------------------------------------------------------------------------
257
+ export async function statusMemories() {
258
+ const st = getSyncStatus();
259
+ const n = st.outbox.length +
260
+ st.inRepo.filter((e) => e.reviewState === "proposed" || e.reviewState === "approved").length;
261
+ return {
262
+ title: `memory: sync status (${n} pending)`,
263
+ output: formatSyncStatus(st),
264
+ };
265
+ }
266
+ export async function submitMemoriesOp(args) {
267
+ const r = submitMemories(args.ids, { branch: args.branch, base: args.base });
268
+ const lines = [];
269
+ for (const s of r.submitted)
270
+ lines.push(`submitted ${s.id} [${s.reviewState}]`);
271
+ for (const id of r.skippedIdentical)
272
+ lines.push(`already on branch: ${id} (outbox copy removed)`);
273
+ lines.push(r.committed ? `committed on ${r.branch} (you are still on this branch).` : `nothing new to commit on ${r.branch}.`);
274
+ lines.push(`Next — ask the user: "want me to create a branch + push + open the PR, or will you handle it yourself?"`);
275
+ lines.push(`Never create branches, push, or open PRs without their explicit approval. If they handle it themselves, hand them these:`);
276
+ lines.push(` ${r.pushCommand}`);
277
+ for (const l of r.prCommand.split("\n"))
278
+ lines.push(` ${l}`);
279
+ return { title: `memory: submitted ${r.submitted.length}`, output: lines.join("\n") };
280
+ }
281
+ export async function proposeMemoriesOp(args) {
282
+ const batch = proposeMemories(args.ids, { localApprove: args.localApprove });
283
+ const lines = batch.map(({ sourceId, result: r }) => `proposed ${sourceId} → ${r.id} [${r.reviewState}] (outbox draft)`);
284
+ lines.push(`Next: open-memex sync-status, then submit the new ids to move them into a branch.`);
285
+ return { title: `memory: proposed ${batch.length}`, output: lines.join("\n") };
286
+ }
287
+ export async function promoteMemoryOp(args) {
288
+ const r = promoteMemory(args.id, {
289
+ reject: args.reject,
290
+ resubmit: args.resubmit,
291
+ note: args.note,
292
+ by: args.by,
293
+ });
294
+ const lines = [`promote ${r.id}: ${r.from} → ${r.to}`];
295
+ if (r.to === "rejected") {
296
+ lines.push(`not deleted — the file stays on the branch. Accept (close the PR), revise + resubmit, or keep as a [rejected] record.`);
297
+ }
298
+ if (r.history.length > 0) {
299
+ lines.push(`history (${r.history.length}):`);
300
+ lines.push(...formatReviewHistory(r.history));
301
+ }
302
+ return { title: `memory: ${r.id} → ${r.to}`, output: lines.join("\n") };
303
+ }
304
+ export async function resolveMemoryOp(args) {
305
+ if (!args.target) {
306
+ const conflicts = listConflicts();
307
+ if (conflicts.length === 0) {
308
+ return { title: "memory: no conflicts", output: "(no conflicted memory files)" };
309
+ }
310
+ return {
311
+ title: `memory: ${conflicts.length} conflicted`,
312
+ output: conflicts.map((c) => `conflicted: ${c.id} ${c.filePath}`).join("\n"),
313
+ };
314
+ }
315
+ const outcome = resolveConflict(args.target);
316
+ if (!outcome.ok) {
317
+ const lines = [
318
+ `cannot auto-resolve ${outcome.filePath} — semantic conflicts need a human:`,
319
+ ...outcome.conflicts.flatMap((c) => [
320
+ ` ${c.field}:`,
321
+ ` base: ${c.base}`,
322
+ ` ours: ${c.ours}`,
323
+ ` theirs: ${c.theirs}`,
324
+ ]),
325
+ `Edit the file manually, then \`git add\` it. Nothing was written.`,
326
+ ];
327
+ return { title: "memory: resolve blocked", output: lines.join("\n") };
328
+ }
329
+ const lines = [`resolved ${outcome.filePath}`];
330
+ if (outcome.autoMerged.length > 0)
331
+ lines.push(` auto-merged: ${outcome.autoMerged.join(", ")}`);
332
+ return { title: "memory: resolved", output: lines.join("\n") };
333
+ }
334
+ export async function prStatusOp(args) {
335
+ const st = getPrStatus();
336
+ const lines = [formatPrStatus(st)];
337
+ if (args.apply) {
338
+ const applied = applyPrStatus(st);
339
+ if (applied.length === 0) {
340
+ lines.push(`nothing to apply.`);
341
+ }
342
+ else {
343
+ for (const a of applied) {
344
+ lines.push(`applied ${a.memoryId.slice(0, 8)}: ${a.from} → ${a.to} (by ${a.by})`);
345
+ }
346
+ }
347
+ }
348
+ return { title: "memory: pr-status", output: lines.join("\n") };
349
+ }
@@ -0,0 +1,59 @@
1
+ # Curator Convention
2
+
3
+ The **curator** is the human who tends a project's shared memory. It is a
4
+ documented convention, not a permission system — anyone on the team can act
5
+ as curator; the tool records *who* did *what* (the audit trail), it doesn't
6
+ decide who is *allowed* to.
7
+
8
+ ## What the curator does
9
+
10
+ 1. **Triage proposals.** `open-memex propose` puts memories up for review.
11
+ The curator reads them, then `open-memex promote <id>` to approve or
12
+ `open-memex promote <id> --reject` to send back, with `--note` saying why.
13
+ 2. **Resolve conflicts.** `open-memex resolve` lists file-level and semantic
14
+ conflicts. The curator merges or picks a winner — Core never silently
15
+ resolves a semantic conflict; both sides stay `active` until a human
16
+ decides.
17
+ 3. **Keep the garden.** Deprecate what's stale (`open-memex status <id>
18
+ deprecated`), supersede what's been replaced, forget what's noise.
19
+ Shared memory rots without pruning.
20
+ 4. **Watch the pipeline.** `open-memex sync-status` shows the outbox, review
21
+ states, and uncommitted files; `open-memex pr-status --apply` maps the
22
+ GitHub PR state back onto `review_state`.
23
+
24
+ ## Admission bar
25
+
26
+ Approve a memory when it is:
27
+
28
+ - **True** — you believe it, or it cites something verifiable.
29
+ - **Durable** — it will still matter in a month. Chat logs are not memories.
30
+ - **Scoped right** — project knowledge in project scope; personal stuff stays
31
+ personal (personal memories are never the curator's business).
32
+ - **Well-typed** — `type` says what it IS (`decision`, `gotcha`, `lesson`…),
33
+ `tags` say what it's ABOUT.
34
+
35
+ Send back (don't silently fix) when it's vague, duplicated, or belongs in
36
+ `docs/` as formal documentation instead — memory is the fast-changing long
37
+ tail, `docs/` is the slow-changing core.
38
+
39
+ ## What the curator does NOT do
40
+
41
+ - **Never rewrite someone else's memory in place.** Propose a superseding
42
+ memory instead — the chain (`supersedes` / `superseded_by`) is the audit
43
+ trail.
44
+ - **Never approve their own proposals silently in team mode.** That's what
45
+ `--local-approve` is for — solo projects only.
46
+ - **Never pull rank with the tool.** If the team disagrees with a call, the
47
+ disagreement itself is worth a memory.
48
+
49
+ ## Cadence
50
+
51
+ There is no required cadence. A workable default: triage proposals at the
52
+ end of each work chunk (the same checkpoint where §3.5 distillation runs),
53
+ and do a pruning pass when `sync-status` starts feeling noisy.
54
+
55
+ ## Solo mode
56
+
57
+ No team, no curator needed. `open-memex propose --local-approve` records
58
+ `approved_by: self` and skips the PR. You are the curator, the proposer,
59
+ and the gardener — the same hygiene rules apply, just faster.
@@ -0,0 +1,197 @@
1
+ # OpenMemex User Guide
2
+
3
+ > This document describes open-memex's **mental model**: where your memories live,
4
+ > how they flow, and who can see them. The in-repo directory (§2, §6 write path)
5
+ > and the propose → promote → resolve workflow (§5) are implemented on the
6
+ > `V2-dev-p2b` branch; features marked **2B** are still to be built;
7
+ > everything else is 0.3.0 behavior.
8
+
9
+ ## In one sentence
10
+
11
+ open-memex is a local-first memory layer: **Markdown is the source of truth;
12
+ SQLite is just a rebuildable index.** Lose the index and you rebuild it.
13
+ Lose the Markdown and it's really gone.
14
+
15
+ ## 1. Two scopes: personal vs project
16
+
17
+ Every memory belongs to a scope, which decides **how far it may travel**:
18
+
19
+ | | personal | project |
20
+ |---|---|---|
21
+ | Holds | Facts about you: preferences, habits, cross-project info | Facts about the project: stack, conventions, decisions, gotchas |
22
+ | Lives | Only on your machine (see §2) | Local + eligible for team sharing (see §2, §6) |
23
+ | Rule | **Never leaves this machine** | Can be promoted to team knowledge |
24
+
25
+ Keywords route automatically (D18): "remember… / I think… / I like…" (I) → personal;
26
+ "we decided… / we think… / remember for us…" (we) → project.
27
+
28
+ ## 2. Two homes: appdata vs the repo directory
29
+
30
+ | | appdata (the desk) | repo `.ai/open-memex/` (the shelf) |
31
+ |---|---|---|
32
+ | Location | Windows `%APPDATA%/open-memex`, Linux `~/.local/share/open-memex` | Project root, default `.ai/open-memex/` (D23, configurable via `memoryDir`) |
33
+ | Holds | `memories/personal/` personal memories; project-scope **draft outbox**; `index.db` local index | Submitted project memories (`proposed` and up) |
34
+ | In git? | No | Yes — git is its courier |
35
+ | The index? | `index.db` is rebuildable, **never committed** | No index stored; rebuilt from Markdown on demand |
36
+
37
+ The private notebook (personal) never goes on the shelf. That's an iron rule, not a setting.
38
+
39
+ Project memories start life as **drafts in the appdata outbox** — git-invisible,
40
+ branch-independent. Only drafts you explicitly name move to the shelf, via
41
+ `open-memex submit` (§5). One stage, one home: after a successful submit the
42
+ outbox original is gone; before it, the repo knows nothing.
43
+
44
+ ## 3. First-turn injection: how the 8 and 5 are chosen
45
+
46
+ Before the agent's first turn, open-memex injects an `[OPEN-MEMEX]` context block so it
47
+ doesn't need a search call just to get oriented. Defaults: **8 project + 5 personal**
48
+ memories (`maxProjectMemories` / `maxProfileItems`, tunable).
49
+
50
+ Selection has exactly one criterion: **most recently updated first**
51
+ (`ORDER BY updated_at DESC`), minus retracted/archived items, with superseded
52
+ versions resolved away. Not "the N most important" — "the N most recently touched".
53
+ What you just updated is most likely relevant to what you're doing now.
54
+
55
+ Token budget: each item is squeezed to one line (≤240 chars, ~60 tokens), so 8 items
56
+ cost ≈ 500 tokens worst case. Beyond that, diminishing returns — the long tail is what
57
+ `memory_search` is for; stuffing everything into the first turn buries the signal.
58
+
59
+ Raise it: `open-memex config set maxProjectMemories 12`
60
+
61
+ ## 4. Later turns: when memory gets searched again
62
+
63
+ First-turn injection happens once. After that there is **no automatic re-search** —
64
+ MCP is request/response; the server cannot push. Whether `memory_search` gets called
65
+ is entirely the model's judgment, guided by two things:
66
+
67
+ - The injected block's footer: "Use the `memory_search` tool to look up more."
68
+ - The Copilot instructions written by `init`: "before asking the user about something
69
+ they may have told you before, call `memory_search` first — try a few keyword
70
+ variants before giving up."
71
+
72
+ So: when the conversation touches past decisions, preferences, or conventions, the
73
+ model *should* search first — but nothing enforces it. This is a known gap in the
74
+ current version; Phase 2C's native plugin hooks (e.g. Claude Code's
75
+ `UserPromptSubmit`) are meant to close it.
76
+
77
+ ## 5. Promotion workflow: how a memory becomes team knowledge
78
+
79
+ A personal observation becomes team knowledge by exactly one road —
80
+ **explicit promotion, never automatic sync**:
81
+
82
+ ```
83
+ personal idea ──propose──▶ outbox draft ──submit──▶ proposed ──┬──promote──▶ approved ──promote──▶ published/shared
84
+ │ │
85
+ --reject --reject
86
+ │ │ (approval withdrawn
87
+ ▼ │ before merge)
88
+ rejected ──resubmit──▶ proposed
89
+ ```
90
+
91
+ - `open-memex propose <id...> --to project`: **copies** one or more personal
92
+ memories into the **appdata outbox** as review candidates (`review_state:
93
+ draft`, each with its own new id, `derived_from` pointing back at the
94
+ personal original). **Copy, not move — the personal original stays.**
95
+ All-or-nothing: a bad id aborts the whole batch. Nothing touches the repo
96
+ yet — the outbox is git-invisible and branch-independent.
97
+ Solo devs can use `--local-approve` to self-approve.
98
+ - `open-memex sync-status`: shows **when the index was last synced** (and
99
+ what triggered it — session start, a request, a CLI run, a submit), the
100
+ outbox (pending sync), the repo review states
101
+ (`draft / proposed / approved / published / rejected`), and any uncommitted
102
+ repo memory files. Your agent calls this at session start, at meaningful
103
+ checkpoints, after you commit, and after memory actions — then asks which
104
+ drafts (if any) you want synced. You can also just say "sync memory".
105
+ - `open-memex submit <id...>`: moves **your named drafts** into
106
+ `<repo>/.ai/open-memex/` as `proposed`. It copies the files, flips
107
+ `review_state`, and makes a **local** git commit **on your current branch** —
108
+ it never creates a branch on its own (branch creation is your call, or your
109
+ agent's with your explicit approval via `--branch <name>`).
110
+ All-or-nothing, idempotent, crash-safe. It prints the `git push` +
111
+ `gh pr create` commands; if your agent already has your Yes for this sync,
112
+ it carries through push and PR itself. The PR base defaults to the current
113
+ branch; `--base` redirects to `main` or your integration branch.
114
+ Same id with different content on the branch **aborts** — a human decides,
115
+ never auto-overwrite.
116
+ - Keep the four jobs straight: **propose crosses the boundary**
117
+ (personal → project outbox, the only step that copies across);
118
+ **submit moves drafts into the repo** (outbox → `.ai/open-memex/`,
119
+ `draft → proposed`); **promote only flips the status label** on a file
120
+ already in `.ai/open-memex/` (`proposed → approved → published`) — it
121
+ never moves files between directories; **git does the transport**
122
+ (push, PR, merge).
123
+ - A standalone memory PR contains **only memory files, no code**, reviewed and
124
+ audited separately from code PRs. Reviewers check "is this true? is it safe
125
+ to share? any secrets?" — things a code PR's CI never checks. You create the
126
+ branch yourself when you want one (or your agent does, with your approval).
127
+ - "Request changes" needs no command: while the PR is open, the author edits
128
+ the same file (directly, or by asking their agent in chat), commits, and
129
+ pushes. The state stays `proposed`; the PR is the review mechanism.
130
+ - `open-memex promote <id>`: advances the memory one step up the ladder
131
+ (`proposed → approved → published`). `--reject --note "..."` rejects with a
132
+ reason (also allowed from `approved`, before merge — withdrawing approval).
133
+ Every transition is appended to the memory's `review_history` — who moved it,
134
+ when, from what to what, and why — so the review trail survives long after
135
+ the PR is closed.
136
+ After the PR merges, run `promote <id>` once more to mark it `published`
137
+ — or let `pr-status --apply` do it for you (below).
138
+ (Lifting project memories to org level is Phase 4.)
139
+ - **A rejection never deletes anything.** The file stays on your branch; what
140
+ happens next is the human's call:
141
+ 1. **Accept**: close the PR and delete the branch — the file goes with it
142
+ (the local index cleans itself up on the next sync);
143
+ 2. **Revise and resubmit**: edit the file, run
144
+ `open-memex promote <id> --resubmit`, commit, push — review continues on
145
+ the same PR;
146
+ 3. **Keep as a record**: leave it; it stays visible with a `[rejected]` tag
147
+ and your note, so the team can see what was considered and why not.
148
+ - `open-memex pr-status [--apply]`: reads the current branch's GitHub PR
149
+ and maps its state onto each in-repo memory's review state — merged PR →
150
+ `published`, PR approval → `approved` (`approved_by` = the reviewer), review
151
+ "request changes" → a suggestion for you to act on (never auto-rejects).
152
+ Each memory keeps its own state: a human `rejected` is never overridden by
153
+ a PR signal. Report by default; `--apply` performs the mapped transitions
154
+ locally (no push). This is how a team review on GitHub closes the loop back
155
+ into the memory index without new review UI.
156
+ - `open-memex resolve [id]`: with no argument, lists conflicted memory files;
157
+ with an id, attempts a **field-level 3-way merge** of the YAML frontmatter
158
+ (`tags` union, `updated_at` takes latest, body merged when only one side
159
+ changed). Semantic conflicts — both sides changed the same field or the
160
+ body differently — are **reported, never auto-resolved**: the file is left
161
+ untouched for a human to decide.
162
+ - `list` / `search` show a `[draft]` / `[proposed]` / `[approved]` /
163
+ `[published]` / `[rejected]` tag next to project memories in review, and
164
+ retrieval ranks reviewed knowledge (`approved` / `published`) above
165
+ unreviewed outbox drafts — drafts stay findable but never pose as vetted.
166
+
167
+ ## 6. Sync: git is the courier, not the brain
168
+
169
+ - **Write**: `memory_add` (project scope) → writes the **appdata outbox** and
170
+ updates the local index. **Never touches the repo, never auto-commits,
171
+ never auto-pushes.**
172
+ - **Submit** (explicit, your call): `open-memex submit <id...>` → local branch
173
+ + local commit into `.ai/open-memex/`; push/PR are printed for you (or done
174
+ by your agent on your Yes).
175
+ - **Pull** **2B**: `open-memex pull` (always explicit, never automatic) → git fetch +
176
+ fast-forward → scans `.ai/open-memex/*.md` → merges into the local `index.db` by
177
+ file mtime. Retrieval always goes through SQLite, never walks git.
178
+ - **personal scope**: never syncs (§1 iron rule).
179
+ - **Projects without git**: keep working; project scope degrades to local-only with
180
+ a clear notice. Nothing breaks.
181
+
182
+ ## 7. Security baseline
183
+
184
+ - Memory is **data** by default, never instructions — external content (pulled from
185
+ sync, promoted by others) passes an admission check before it lands; the
186
+ prompt-injection screen on shared files is heuristic, the real boundary is the
187
+ data/instruction separation.
188
+ - Every memory carries provenance: `source` + `confidence` + `via` + author.
189
+ Shared-scope writes go into an audit log.
190
+ - A pre-commit hook scans shared scopes for secrets and guards against history
191
+ rewrites.
192
+ - `<private>…</private>` sections are stripped at write time; detected secret
193
+ patterns are masked in place and the write proceeds.
194
+
195
+ ---
196
+
197
+ *Companion design record: `docs/V2-DESIGN.md` (decisions D1–D24).*