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/dist/github.js ADDED
@@ -0,0 +1,215 @@
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
+ import { execFileSync } from "node:child_process";
22
+ import * as fs from "node:fs";
23
+ import { loadConfig } from "./config.js";
24
+ import { projectRoot } from "./paths.js";
25
+ import { parse as parseMemory } from "./store/markdown.js";
26
+ import { iterInRepoMemoryFiles } from "./store/markdown.js";
27
+ import { promoteMemory, currentAuthor } from "./review.js";
28
+ function fail(msg) {
29
+ throw new Error(`[open-memex] ${msg}`);
30
+ }
31
+ function sh(cmd, args, cwd) {
32
+ try {
33
+ return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
34
+ }
35
+ catch (e) {
36
+ const err = e;
37
+ const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
38
+ fail(`${cmd} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
39
+ }
40
+ }
41
+ /** Read the PR for a branch (default: current branch) via the GitHub CLI. */
42
+ export function getPrInfo(branch) {
43
+ const root = projectRoot();
44
+ // `gh` must exist and be authenticated on the user's machine.
45
+ try {
46
+ execFileSync("gh", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
47
+ }
48
+ catch {
49
+ fail("the GitHub CLI (`gh`) is not installed — install it and run `gh auth login` first.");
50
+ }
51
+ const ref = branch ?? sh("git", ["branch", "--show-current"], root);
52
+ if (!ref)
53
+ fail("not on a branch — check out the memory branch first.");
54
+ let raw;
55
+ try {
56
+ raw = sh("gh", ["pr", "view", ref, "--json",
57
+ "number,url,state,mergedAt,reviewDecision,baseRefName,headRefName,title,reviews"], root);
58
+ }
59
+ catch {
60
+ return null; // no PR for this branch yet
61
+ }
62
+ let j;
63
+ try {
64
+ j = JSON.parse(raw);
65
+ }
66
+ catch {
67
+ fail(`could not parse \`gh pr view\` output for branch ${ref}.`);
68
+ }
69
+ const approvedBy = [];
70
+ const changesRequestedBy = [];
71
+ // Latest review per author wins.
72
+ const latest = new Map();
73
+ for (const r of j.reviews ?? []) {
74
+ const login = r.author?.login;
75
+ if (!login)
76
+ continue;
77
+ const prev = latest.get(login);
78
+ if (!prev || r.submittedAt >= prev.at)
79
+ latest.set(login, { state: r.state, at: r.submittedAt });
80
+ }
81
+ for (const [login, r] of latest) {
82
+ if (r.state === "APPROVED")
83
+ approvedBy.push(login);
84
+ else if (r.state === "CHANGES_REQUESTED")
85
+ changesRequestedBy.push(login);
86
+ }
87
+ return {
88
+ number: j.number,
89
+ url: j.url,
90
+ state: j.state === "OPEN" ? "open" : "closed",
91
+ merged: !!j.mergedAt,
92
+ mergedAt: j.mergedAt,
93
+ reviewDecision: j.reviewDecision ?? null,
94
+ approvedBy,
95
+ changesRequestedBy,
96
+ base: j.baseRefName,
97
+ head: j.headRefName,
98
+ title: j.title,
99
+ };
100
+ }
101
+ /** Map the branch PR's state onto each in-repo memory's review_state. */
102
+ export function getPrStatus(branch) {
103
+ const root = projectRoot();
104
+ const cfg = loadConfig();
105
+ const current = branch ?? sh("git", ["branch", "--show-current"], root);
106
+ const pr = getPrInfo(current);
107
+ const mappings = [];
108
+ for (const fp of iterInRepoMemoryFiles(root, cfg.memoryDir)) {
109
+ const raw = fs.readFileSync(fp, "utf8");
110
+ const parsed = parseMemory(raw);
111
+ if (!parsed)
112
+ continue;
113
+ const from = parsed.fm.review_state ?? "draft";
114
+ const id = parsed.fm.id;
115
+ if (!pr) {
116
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: `no PR for branch ${current} yet`, approvedBy: null });
117
+ continue;
118
+ }
119
+ if (from === "published") {
120
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: "already published", approvedBy: null });
121
+ continue;
122
+ }
123
+ if (from === "rejected") {
124
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: `stays rejected — a PR ${pr.merged ? "merge" : "approval"} never overrides a human rejection`, approvedBy: null });
125
+ continue;
126
+ }
127
+ if (pr.merged) {
128
+ if (from === "proposed" || from === "approved") {
129
+ mappings.push({ memoryId: id, from, suggestedTo: "published", reason: `PR #${pr.number} merged`, approvedBy: null });
130
+ }
131
+ else {
132
+ mappings.push({ memoryId: id, from, suggestedTo: null, reason: `PR #${pr.number} merged, but memory is "${from}"`, approvedBy: null });
133
+ }
134
+ continue;
135
+ }
136
+ if (pr.reviewDecision === "APPROVED" && from === "proposed") {
137
+ const by = pr.approvedBy[0] ?? null;
138
+ mappings.push({
139
+ memoryId: id, from, suggestedTo: "approved",
140
+ reason: `PR #${pr.number} approved${by ? ` by @${by}` : ""}`, approvedBy: by,
141
+ });
142
+ continue;
143
+ }
144
+ if (pr.reviewDecision === "CHANGES_REQUESTED") {
145
+ const by = pr.changesRequestedBy[0] ?? null;
146
+ mappings.push({
147
+ memoryId: id, from, suggestedTo: null,
148
+ reason: `PR #${pr.number} has changes requested${by ? ` by @${by}` : ""} — run \`open-memex promote ${id} --reject --note "..."\` yourself if you agree`,
149
+ approvedBy: null,
150
+ });
151
+ continue;
152
+ }
153
+ mappings.push({
154
+ memoryId: id, from, suggestedTo: null,
155
+ reason: pr.state === "open" ? `PR #${pr.number} still awaiting review` : `PR #${pr.number} is closed without merge`,
156
+ approvedBy: null,
157
+ });
158
+ }
159
+ return { branch: current, pr, mappings };
160
+ }
161
+ /** Apply the mapped transitions locally (no push — D27). Advances step by
162
+ * step through the ladder, so a merged PR moves proposed → approved →
163
+ * published with each step in the audit trail. */
164
+ export function applyPrStatus(st) {
165
+ const out = [];
166
+ const actor = currentAuthor();
167
+ const order = ["draft", "proposed", "approved", "published"];
168
+ for (const m of st.mappings) {
169
+ if (!m.suggestedTo || order.indexOf(m.from) >= order.indexOf(m.suggestedTo))
170
+ continue;
171
+ const by = m.approvedBy ?? actor;
172
+ const note = m.suggestedTo === "published" && st.pr
173
+ ? `PR #${st.pr.number} merged`
174
+ : m.reason;
175
+ let cur = m.from;
176
+ while (order.indexOf(cur) < order.indexOf(m.suggestedTo)) {
177
+ const r = promoteMemory(m.memoryId, { by, note });
178
+ cur = r.to;
179
+ out.push({ memoryId: m.memoryId, from: r.from, to: r.to, by });
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+ export function formatPrStatus(st) {
185
+ const lines = [];
186
+ lines.push(`branch: ${st.branch}`);
187
+ if (!st.pr) {
188
+ lines.push(`PR: none yet — push the branch and open one, then re-run.`);
189
+ }
190
+ else {
191
+ const p = st.pr;
192
+ lines.push(`PR #${p.number}: ${p.title}`);
193
+ lines.push(` ${p.url}`);
194
+ lines.push(` state: ${p.merged ? `merged${p.mergedAt ? ` at ${p.mergedAt}` : ""}` : p.state}` +
195
+ (p.reviewDecision ? `, reviews: ${p.reviewDecision}` : ""));
196
+ if (p.approvedBy.length > 0)
197
+ lines.push(` approved by: ${p.approvedBy.map((l) => "@" + l).join(", ")}`);
198
+ if (p.changesRequestedBy.length > 0)
199
+ lines.push(` changes requested by: ${p.changesRequestedBy.map((l) => "@" + l).join(", ")}`);
200
+ }
201
+ if (st.mappings.length === 0) {
202
+ lines.push(`no in-repo memories on this branch.`);
203
+ }
204
+ else {
205
+ lines.push(`memories (${st.mappings.length}):`);
206
+ for (const m of st.mappings) {
207
+ const arrow = m.suggestedTo ? ` → [${m.suggestedTo}]` : "";
208
+ lines.push(` ${m.memoryId.slice(0, 8)} [${m.from}]${arrow} ${m.reason}`);
209
+ }
210
+ if (st.mappings.some((m) => m.suggestedTo)) {
211
+ lines.push(`run with --apply to perform the mapped transitions locally (no push).`);
212
+ }
213
+ }
214
+ return lines.join("\n");
215
+ }
package/dist/index.js CHANGED
@@ -83,6 +83,12 @@ const plugin = async ({ worktree, directory }) => {
83
83
  updated_at: rfc,
84
84
  supersedes: null,
85
85
  superseded_by: null,
86
+ review_state: "draft",
87
+ proposed_by: null,
88
+ approved_by: null,
89
+ derived_from: null,
90
+ review_note: null,
91
+ review_history: [],
86
92
  };
87
93
  try {
88
94
  const { filePath } = writeMemoryFile(fm, content);
package/dist/init.js 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.js";
8
+ import { projectRoot } from "./paths.js";
9
9
  const MARKER = "<!-- open-memex -->";
10
10
  /** npm dist-tag carrying the 0.3.x preview line. */
11
11
  const ALPHA_TAG = "open-memex@alpha";
@@ -39,8 +39,10 @@ const INSTRUCTIONS = `${MARKER}
39
39
  > Applies only when the \`open-memex\` MCP server is available in this session
40
40
  > (the \`memory_*\` tools exist). Otherwise ignore this section.
41
41
 
42
- You have a local memory MCP server (\`open-memex\`) with five tools:
43
- \`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`.
42
+ You have a local memory MCP server (\`open-memex\`) with eleven tools:
43
+ \`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`, \`memory_forget\`,
44
+ \`memory_status\`, \`memory_submit\`, \`memory_propose\`, \`memory_promote\`, \`memory_resolve\`,
45
+ \`memory_pr_status\`.
44
46
 
45
47
  - BE PROACTIVE. When the user shares something worth remembering across sessions
46
48
  (a decision, a preference, a project convention, a fix and its cause), call
@@ -51,22 +53,32 @@ You have a local memory MCP server (\`open-memex\`) with five tools:
51
53
  - Memories default to this project's scope; use the \`personal\` scope for facts about
52
54
  the user that hold across all projects. When a saved fact becomes outdated, call
53
55
  \`memory_supersede\` instead of adding a duplicate.
56
+
57
+ ## Syncing project memories for review (D26)
58
+
59
+ Project memories you save land in a local outbox first — they are NOT in git yet.
60
+ Syncing them into the repo for review is an explicit, user-approved step:
61
+
62
+ - At session start, and when you finish a meaningful chunk of work, call
63
+ \`memory_status\`. If the outbox has drafts, summarize them (one line each) and ask
64
+ the user which ones to sync. Sync NOTHING the user did not name.
65
+ - When the user approves, ask ONE follow-up: a separate memory-only branch/PR
66
+ (recommended), or fold the memories into the current branch alongside code?
67
+ A "yes, you do it" answer covers the whole chain — branch, local commit,
68
+ push, PR creation — do NOT re-ask at each step. If the user says they will
69
+ do it themselves, hand them the printed push/PR commands and do nothing.
70
+ Then call \`memory_submit\` with the approved ids (\`onto\` = current branch name
71
+ only for the fold-into-code path). It creates the branch, copies the drafts in
72
+ as \`proposed\`, commits locally, and prints the push + PR commands.
73
+ - Base branch for the memory PR defaults to the branch you are on; the user may
74
+ redirect it to the integration branch (main) for branch-independent knowledge.
75
+ - If anything conflicts (same id with different content, push rejected), STOP and
76
+ let the user judge — never overwrite.
77
+ - After the PR merges, call \`memory_pr_status\` (with \`apply\` when the user
78
+ approves) to map the PR's review state back onto each memory — merged means
79
+ \`published\`, an approval means \`approved\` (credited to the reviewer).
80
+ - \`personal\` scope memories NEVER leave the machine.
54
81
  `;
55
- /** Project root: git top-level, falling back to cwd. */
56
- function projectRoot() {
57
- try {
58
- const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
59
- encoding: "utf8",
60
- stdio: ["ignore", "pipe", "ignore"],
61
- }).trim();
62
- if (top)
63
- return top;
64
- }
65
- catch {
66
- /* not a git repo — use cwd */
67
- }
68
- return process.cwd();
69
- }
70
82
  function writeMcpJson(root, client, force) {
71
83
  if (client === "opencode")
72
84
  return writeOpencodeMcpJson(root, force);
package/dist/mcp.js CHANGED
@@ -24,7 +24,7 @@ import { loadConfig } from "./config.js";
24
24
  import { resolveProjectScope, PERSONAL_SCOPE } from "./scope.js";
25
25
  import { db } from "./store/db.js";
26
26
  import { syncScope } from "./store/sync.js";
27
- import { addMemory, searchMemories, listMemories, supersedeMemory, forgetMemory, memoryAddArgs, memorySearchArgs, memoryListArgs, memorySupersedeArgs, memoryForgetArgs, TOOL_DESCRIPTIONS, } from "./tools/ops.js";
27
+ import { addMemory, searchMemories, listMemories, supersedeMemory, forgetMemory, statusMemories, submitMemoriesOp, proposeMemoriesOp, promoteMemoryOp, resolveMemoryOp, prStatusOp, memoryAddArgs, memorySearchArgs, memoryListArgs, memorySupersedeArgs, memoryForgetArgs, memoryStatusArgs, memorySubmitArgs, memoryProposeArgs, memoryPromoteArgs, memoryResolveArgs, memoryPrStatusArgs, TOOL_DESCRIPTIONS, } from "./tools/ops.js";
28
28
  const SERVER_VERSION = "0.2.0-alpha";
29
29
  /** Adapt a framework-agnostic op result to an MCP tool response. */
30
30
  function toMcp(p) {
@@ -44,33 +44,69 @@ export async function runMcpServer() {
44
44
  const getScope = () => scope;
45
45
  // Init DB and one-shot sync of markdown -> index, mirroring the plugin.
46
46
  db();
47
- syncScope(scope.key);
48
- syncScope(PERSONAL_SCOPE.key);
47
+ syncScope(scope.key, "session");
48
+ syncScope(PERSONAL_SCOPE.key, "session");
49
49
  console.error(`[open-memex] MCP server up. scope=${scope.key}`);
50
+ // D26: re-sync on every request, not just at startup. The in-repo dir
51
+ // follows the current git branch, so a branch switch mid-session would
52
+ // otherwise leave the index pointing at files that no longer exist.
53
+ const withSync = (fn) => {
54
+ return (args) => {
55
+ syncScope(scope.key, "request");
56
+ syncScope(PERSONAL_SCOPE.key, "request");
57
+ return fn(args);
58
+ };
59
+ };
50
60
  const server = new McpServer({ name: "open-memex", version: SERVER_VERSION });
51
61
  server.registerTool("memory_add", {
52
62
  description: TOOL_DESCRIPTIONS.memory_add,
53
63
  inputSchema: z.object(memoryAddArgs),
54
- }, (args) => toMcp(addMemory(getScope, cfg, args)));
64
+ }, withSync((args) => toMcp(addMemory(getScope, cfg, args))));
55
65
  server.registerTool("memory_search", {
56
66
  description: TOOL_DESCRIPTIONS.memory_search,
57
67
  inputSchema: z.object(memorySearchArgs),
58
68
  annotations: { readOnlyHint: true },
59
- }, (args) => toMcp(searchMemories(getScope, args)));
69
+ }, withSync((args) => toMcp(searchMemories(getScope, args))));
60
70
  server.registerTool("memory_list", {
61
71
  description: TOOL_DESCRIPTIONS.memory_list,
62
72
  inputSchema: z.object(memoryListArgs),
63
73
  annotations: { readOnlyHint: true },
64
- }, (args) => toMcp(listMemories(getScope, args)));
74
+ }, withSync((args) => toMcp(listMemories(getScope, args))));
65
75
  server.registerTool("memory_supersede", {
66
76
  description: TOOL_DESCRIPTIONS.memory_supersede,
67
77
  inputSchema: z.object(memorySupersedeArgs),
68
- }, (args) => toMcp(supersedeMemory(cfg, args)));
78
+ }, withSync((args) => toMcp(supersedeMemory(cfg, args))));
69
79
  server.registerTool("memory_forget", {
70
80
  description: TOOL_DESCRIPTIONS.memory_forget,
71
81
  inputSchema: z.object(memoryForgetArgs),
72
82
  annotations: { destructiveHint: true },
73
- }, (args) => toMcp(forgetMemory(args)));
83
+ }, withSync((args) => toMcp(forgetMemory(args))));
84
+ server.registerTool("memory_status", {
85
+ description: TOOL_DESCRIPTIONS.memory_status,
86
+ inputSchema: z.object(memoryStatusArgs),
87
+ annotations: { readOnlyHint: true },
88
+ }, withSync((_args) => toMcp(statusMemories())));
89
+ server.registerTool("memory_submit", {
90
+ description: TOOL_DESCRIPTIONS.memory_submit,
91
+ inputSchema: z.object(memorySubmitArgs),
92
+ }, withSync((args) => toMcp(submitMemoriesOp(args))));
93
+ server.registerTool("memory_propose", {
94
+ description: TOOL_DESCRIPTIONS.memory_propose,
95
+ inputSchema: z.object(memoryProposeArgs),
96
+ }, withSync((args) => toMcp(proposeMemoriesOp(args))));
97
+ server.registerTool("memory_promote", {
98
+ description: TOOL_DESCRIPTIONS.memory_promote,
99
+ inputSchema: z.object(memoryPromoteArgs),
100
+ }, withSync((args) => toMcp(promoteMemoryOp(args))));
101
+ server.registerTool("memory_resolve", {
102
+ description: TOOL_DESCRIPTIONS.memory_resolve,
103
+ inputSchema: z.object(memoryResolveArgs),
104
+ }, withSync((args) => toMcp(resolveMemoryOp(args))));
105
+ server.registerTool("memory_pr_status", {
106
+ description: TOOL_DESCRIPTIONS.memory_pr_status,
107
+ inputSchema: z.object(memoryPrStatusArgs),
108
+ annotations: { readOnlyHint: true },
109
+ }, withSync((args) => toMcp(prStatusOp(args))));
74
110
  const transport = new StdioServerTransport();
75
111
  await server.connect(transport);
76
112
  }
package/dist/paths.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
+ import { execFileSync } from "node:child_process";
4
5
  function dataRoot() {
5
6
  if (process.env.MY_O_MEMORY_HOME)
6
7
  return path.resolve(process.env.MY_O_MEMORY_HOME);
@@ -34,3 +35,33 @@ export function memoriesDirPath(scopeKey) {
34
35
  const { memories } = paths();
35
36
  return path.join(memories, scopeKey);
36
37
  }
38
+ /** Project root: git top-level, falling back to cwd (2B: in-repo memory dir anchor). */
39
+ export function projectRoot(cwd = process.cwd()) {
40
+ try {
41
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
42
+ cwd,
43
+ encoding: "utf8",
44
+ stdio: ["ignore", "pipe", "ignore"],
45
+ }).trim();
46
+ if (top)
47
+ return top;
48
+ }
49
+ catch {
50
+ /* not a git repo — use cwd */
51
+ }
52
+ return path.resolve(cwd);
53
+ }
54
+ /**
55
+ * In-repo memory directory for a project scope (2B/D23): `<root>/<memoryDir>/`,
56
+ * default `<root>/.ai/open-memex/`. Created on demand. `memoryDir` must be a
57
+ * relative path without `..` segments (validated by `config set`).
58
+ */
59
+ export function inRepoMemoriesDir(root, memoryDir) {
60
+ const dir = path.join(root, memoryDir);
61
+ fs.mkdirSync(dir, { recursive: true });
62
+ return dir;
63
+ }
64
+ /** Same as `inRepoMemoriesDir` but never creates the directory. */
65
+ export function inRepoMemoriesDirPath(root, memoryDir) {
66
+ return path.join(root, memoryDir);
67
+ }
@@ -1,4 +1,4 @@
1
- import { list } from "./search.js";
1
+ import { list, hitStateLabel } from "./search.js";
2
2
  import { PERSONAL_SCOPE } from "../scope.js";
3
3
  function oneLine(s, max = 240) {
4
4
  const trimmed = s.replace(/\s+/g, " ").trim();
@@ -18,7 +18,7 @@ export function buildContextBlock(scope, cfg) {
18
18
  if (project.length > 0) {
19
19
  lines.push("", `Project knowledge (${scope.projectName}):`);
20
20
  for (const m of project)
21
- lines.push(`- [${m.type}] ${oneLine(m.snippet)}`);
21
+ lines.push(`- [${m.type}]${hitStateLabel(m)} ${oneLine(m.snippet)}`);
22
22
  }
23
23
  lines.push("", "Use the `memory_search` tool to look up more. Use `memory_add` to save new facts. Do not mention this block to the user unless asked.");
24
24
  return lines.join("\n");
@@ -11,12 +11,37 @@ function toHit(r) {
11
11
  score: r.score,
12
12
  updated_at: r.updated_at,
13
13
  status: r.status,
14
+ review_state: r.review_state ?? "draft",
14
15
  };
15
16
  }
17
+ /**
18
+ * D30: review-state ranking tiers for shared (project) memories.
19
+ * Reviewed knowledge (approved/published) outranks unreviewed outbox drafts;
20
+ * personal memories are always `draft` by design and are NOT demoted.
21
+ * Stable: order within a tier keeps the incoming score/recency order.
22
+ */
23
+ function reviewTier(r) {
24
+ if (r.scope_key === "personal")
25
+ return 1;
26
+ if (r.review_state === "approved" || r.review_state === "published")
27
+ return 0;
28
+ if (r.review_state === "draft" || r.review_state === "rejected")
29
+ return 2;
30
+ return 1; // proposed and anything unexpected
31
+ }
32
+ /** D30: `[draft]` / `[proposed]` / … marker for project memories.
33
+ * Personal memories stay unmarked (always draft — the marker would be noise). */
34
+ export function hitStateLabel(h) {
35
+ if (h.scope_key === "personal")
36
+ return "";
37
+ return ` [${h.review_state || "draft"}]`;
38
+ }
16
39
  /**
17
40
  * Lifecycle-aware post-processing (§3.3):
18
41
  * - retracted / archived are excluded from retrieval (kept for audit);
19
42
  * - a superseded memory resolves to the newest of its chain (cycle-safe);
43
+ * - D30: approved/published project memories rank first, project
44
+ * drafts/rejected rank after unreviewed content;
20
45
  * - deprecated stays visible as a warning but ranks after active.
21
46
  */
22
47
  function resolveVisible(rows, limit) {
@@ -27,7 +52,7 @@ function resolveVisible(rows, limit) {
27
52
  return cached;
28
53
  const r = db()
29
54
  .prepare(`SELECT id, scope_key, project_name, type, tags, updated_at, status,
30
- superseded_by, substr(content, 1, 240) AS snippet
55
+ superseded_by, review_state, substr(content, 1, 240) AS snippet
31
56
  FROM memories WHERE id = ?`)
32
57
  .get(id);
33
58
  if (!r)
@@ -37,7 +62,7 @@ function resolveVisible(rows, limit) {
37
62
  return full;
38
63
  };
39
64
  const seen = new Set();
40
- const active = [];
65
+ const tiers = [[], [], [], []];
41
66
  const deprecated = [];
42
67
  for (const r of rows) {
43
68
  if (r.status === "retracted" || r.status === "archived")
@@ -66,9 +91,9 @@ function resolveVisible(rows, limit) {
66
91
  if (target.status === "deprecated")
67
92
  deprecated.push(hit);
68
93
  else
69
- active.push(hit);
94
+ tiers[reviewTier(target)].push(hit);
70
95
  }
71
- return [...active, ...deprecated].slice(0, limit);
96
+ return [...tiers[0], ...tiers[1], ...tiers[2], ...deprecated].slice(0, limit);
72
97
  }
73
98
  /**
74
99
  * Convert free-text query into a safe FTS5 MATCH expression.
@@ -101,7 +126,7 @@ export function search(query, opts = {}) {
101
126
  const typeFilter = opts.type ? ` AND m.type = ?` : "";
102
127
  const sql = `
103
128
  SELECT m.id, m.scope_key, m.project_name, m.type, m.tags, m.updated_at,
104
- m.status, m.superseded_by,
129
+ m.status, m.superseded_by, m.review_state,
105
130
  snippet(memories_fts, 0, '[', ']', ' ... ', 12) AS snippet,
106
131
  bm25(memories_fts) AS score
107
132
  FROM memories_fts
@@ -129,7 +154,7 @@ export function list(scopeKey, opts = {}) {
129
154
  const typeFilter = opts.type ? ` AND type = ?` : "";
130
155
  const sql = `
131
156
  SELECT id, scope_key, project_name, type, tags, updated_at, status,
132
- superseded_by, substr(content, 1, 240) AS snippet
157
+ superseded_by, review_state, substr(content, 1, 240) AS snippet
133
158
  FROM memories
134
159
  WHERE scope_key = ?${typeFilter}
135
160
  ORDER BY updated_at DESC