@xqli02/mneme 0.1.0

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.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * mneme init — Initialize three-layer memory architecture in the current directory.
3
+ *
4
+ * Steps:
5
+ * 1. Install dependencies (git, dolt, bd)
6
+ * 2. git init (if needed)
7
+ * 3. Scaffold .openclaw/facts/, .opencode/prompt.md, AGENTS.md, .gitignore
8
+ * 4. Start dolt server + bd init
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { has, run, runLive, log, color, getPlatform } from "../utils.mjs";
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+ const TEMPLATES_DIR = join(__dirname, "..", "templates");
19
+
20
+ // ── Template scaffolding ────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Map of destination path (relative to project root) -> template filename.
24
+ */
25
+ const SCAFFOLD = {
26
+ "AGENTS.md": "AGENTS.md",
27
+ ".opencode/prompt.md": "opencode-prompt.md",
28
+ ".openclaw/facts/architecture.md": "facts-architecture.md",
29
+ ".openclaw/facts/invariants.md": "facts-invariants.md",
30
+ ".openclaw/facts/performance_rules.md": "facts-performance_rules.md",
31
+ ".openclaw/facts/pitfalls.md": "facts-pitfalls.md",
32
+ ".gitignore": "gitignore",
33
+ };
34
+
35
+ function scaffoldFiles() {
36
+ let created = 0;
37
+ let skipped = 0;
38
+
39
+ for (const [dest, templateName] of Object.entries(SCAFFOLD)) {
40
+ if (existsSync(dest)) {
41
+ log.ok(`${dest} ${color.dim("(already exists)")}`);
42
+ skipped++;
43
+ continue;
44
+ }
45
+
46
+ // Ensure parent directory exists
47
+ const dir = dirname(dest);
48
+ if (dir !== "." && !existsSync(dir)) {
49
+ mkdirSync(dir, { recursive: true });
50
+ }
51
+
52
+ const templatePath = join(TEMPLATES_DIR, templateName);
53
+ const content = readFileSync(templatePath, "utf-8");
54
+
55
+ // For .gitignore, we append rather than overwrite if it already exists
56
+ writeFileSync(dest, content, "utf-8");
57
+ log.ok(`${dest} ${color.dim("(created)")}`);
58
+ created++;
59
+ }
60
+
61
+ // If .gitignore already existed, ensure our entries are in it
62
+ if (existsSync(".gitignore") && skipped > 0) {
63
+ const existing = readFileSync(".gitignore", "utf-8");
64
+ const templateContent = readFileSync(
65
+ join(TEMPLATES_DIR, "gitignore"),
66
+ "utf-8",
67
+ );
68
+
69
+ // Check if our marker entries are present
70
+ if (!existing.includes(".beads/dolt/")) {
71
+ appendFileSync(".gitignore", `\n# Added by mneme init\n${templateContent}`);
72
+ log.ok(`.gitignore ${color.dim("(mneme entries appended)")}`);
73
+ }
74
+ }
75
+
76
+ return { created, skipped };
77
+ }
78
+
79
+ // ── Dependency installation ─────────────────────────────────────────────────
80
+
81
+ function installGit() {
82
+ if (has("git")) {
83
+ const ver = run("git --version") ?? "";
84
+ log.ok(`git ${color.dim(ver)}`);
85
+ return true;
86
+ }
87
+
88
+ log.info("Installing git...");
89
+ const { os } = getPlatform();
90
+
91
+ if (os === "darwin") {
92
+ runLive("xcode-select --install 2>/dev/null || true");
93
+ } else if (os === "linux") {
94
+ if (has("apt-get")) runLive("sudo apt-get update -qq && sudo apt-get install -y -qq git");
95
+ else if (has("dnf")) runLive("sudo dnf install -y git");
96
+ else if (has("yum")) runLive("sudo yum install -y git");
97
+ else if (has("pacman")) runLive("sudo pacman -S --noconfirm git");
98
+ else {
99
+ log.fail("Cannot auto-install git. Please install manually.");
100
+ return false;
101
+ }
102
+ } else {
103
+ log.fail(`Unsupported platform for auto-install: ${os}. Please install git manually.`);
104
+ return false;
105
+ }
106
+
107
+ return has("git");
108
+ }
109
+
110
+ function checkOpencode() {
111
+ if (has("opencode")) {
112
+ const ver = run("opencode --version 2>/dev/null | head -1") ?? "";
113
+ log.ok(`opencode ${color.dim(ver)}`);
114
+ return true;
115
+ }
116
+
117
+ log.warn(
118
+ "opencode not installed — mneme wraps opencode for the AI agent experience",
119
+ );
120
+ log.info(" Install: https://opencode.ai");
121
+ log.info(' After installing, run `mneme` to start or `mneme doctor` to verify');
122
+ return false;
123
+ }
124
+
125
+ function installDolt() {
126
+ if (has("dolt")) {
127
+ const ver = run("dolt version") ?? "";
128
+ log.ok(`dolt ${color.dim(ver)}`);
129
+ return true;
130
+ }
131
+
132
+ log.info("Installing dolt...");
133
+ const code = runLive(
134
+ "curl -fsSL https://github.com/dolthub/dolt/releases/latest/download/install.sh | bash",
135
+ { timeout: 120_000 },
136
+ );
137
+
138
+ if (code === 0 && has("dolt")) {
139
+ log.ok("dolt installed");
140
+ return true;
141
+ }
142
+
143
+ log.fail("Failed to install dolt. See https://github.com/dolthub/dolt");
144
+ return false;
145
+ }
146
+
147
+ function installBd() {
148
+ if (has("bd")) {
149
+ const ver = run("bd version 2>/dev/null | head -1") ?? "";
150
+ log.ok(`bd ${color.dim(ver)}`);
151
+ return true;
152
+ }
153
+
154
+ log.info("Installing bd (beads)...");
155
+
156
+ // Strategy: brew > npm > GitHub release binary
157
+ if (has("brew")) {
158
+ log.info(" via Homebrew...");
159
+ if (runLive("brew install beads") === 0 && has("bd")) {
160
+ log.ok("bd installed via brew");
161
+ return true;
162
+ }
163
+ }
164
+
165
+ if (has("npm")) {
166
+ log.info(" via npm...");
167
+ if (runLive("npm install -g @beads/bd") === 0 && has("bd")) {
168
+ log.ok("bd installed via npm");
169
+ return true;
170
+ }
171
+ }
172
+
173
+ // Fallback: download binary from GitHub releases
174
+ log.info(" via GitHub release binary...");
175
+ const { os, arch } = getPlatform();
176
+ const osMap = { linux: "linux", darwin: "darwin", win32: "windows" };
177
+ const archMap = { x64: "amd64", arm64: "arm64" };
178
+ const osStr = osMap[os];
179
+ const archStr = archMap[arch];
180
+
181
+ if (!osStr || !archStr) {
182
+ log.fail(`Unsupported platform: ${os}/${arch}. Please install bd manually.`);
183
+ return false;
184
+ }
185
+
186
+ // Fetch latest version tag
187
+ const latestJson = run(
188
+ 'curl -fsSL https://api.github.com/repos/steveyegge/beads/releases/latest',
189
+ { timeout: 30_000 },
190
+ );
191
+ if (!latestJson) {
192
+ log.fail("Failed to fetch beads release info.");
193
+ return false;
194
+ }
195
+
196
+ let version;
197
+ try {
198
+ const data = JSON.parse(latestJson);
199
+ version = data.tag_name?.replace(/^v/, "");
200
+ } catch {
201
+ log.fail("Failed to parse beads release info.");
202
+ return false;
203
+ }
204
+
205
+ const ext = os === "win32" ? "zip" : "tar.gz";
206
+ const url = `https://github.com/steveyegge/beads/releases/download/v${version}/beads_${version}_${osStr}_${archStr}.${ext}`;
207
+
208
+ const dlCmd =
209
+ ext === "tar.gz"
210
+ ? `curl -fsSL "${url}" -o /tmp/beads.tar.gz && tar xzf /tmp/beads.tar.gz -C /tmp/ && install /tmp/bd /usr/local/bin/bd`
211
+ : `curl -fsSL "${url}" -o /tmp/beads.zip && unzip -o /tmp/beads.zip -d /tmp/beads && install /tmp/beads/bd.exe /usr/local/bin/bd`;
212
+
213
+ if (runLive(dlCmd, { timeout: 60_000 }) === 0 && has("bd")) {
214
+ log.ok("bd installed via GitHub release");
215
+ return true;
216
+ }
217
+
218
+ log.fail("Failed to install bd. See https://github.com/steveyegge/beads");
219
+ return false;
220
+ }
221
+
222
+ // ── Dolt server + bd init ───────────────────────────────────────────────────
223
+
224
+ function ensureDoltServer() {
225
+ // Check if bd can already talk to dolt
226
+ const test = run("bd list --status=open 2>&1");
227
+ if (test !== null && !test.includes("unreachable") && !test.includes("connection refused")) {
228
+ log.ok("dolt server already running");
229
+ return true;
230
+ }
231
+
232
+ log.info("Starting dolt server...");
233
+ const dataDir = existsSync(".beads/dolt") ? ".beads/dolt" : `${process.env.HOME}/.dolt/databases`;
234
+
235
+ if (!existsSync(dataDir)) {
236
+ mkdirSync(dataDir, { recursive: true });
237
+ }
238
+
239
+ // Start in background
240
+ run(
241
+ `nohup dolt sql-server --host 127.0.0.1 --port 3307 --data-dir "${dataDir}" > /tmp/dolt-server.log 2>&1 &`,
242
+ );
243
+
244
+ // Wait for server to be ready (up to 10s)
245
+ for (let i = 0; i < 10; i++) {
246
+ run("sleep 1");
247
+ const check = run("bd list --status=open 2>&1");
248
+ if (check !== null && !check.includes("unreachable") && !check.includes("connection refused")) {
249
+ log.ok("dolt server started");
250
+ return true;
251
+ }
252
+ }
253
+
254
+ log.fail("dolt server failed to start. Check /tmp/dolt-server.log");
255
+ return false;
256
+ }
257
+
258
+ function initBeads() {
259
+ if (existsSync(".beads/config.yaml")) {
260
+ log.ok(`.beads/ ${color.dim("(already initialized)")}`);
261
+ return true;
262
+ }
263
+
264
+ log.info("Initializing beads...");
265
+ const code = runLive("bd init");
266
+ if (code === 0) {
267
+ log.ok("beads initialized");
268
+ return true;
269
+ }
270
+
271
+ log.fail("bd init failed");
272
+ return false;
273
+ }
274
+
275
+ function initGit() {
276
+ if (existsSync(".git")) {
277
+ log.ok(`.git/ ${color.dim("(already initialized)")}`);
278
+ return true;
279
+ }
280
+
281
+ log.info("Initializing git...");
282
+ if (runLive("git init -q") === 0) {
283
+ log.ok("git initialized");
284
+ return true;
285
+ }
286
+
287
+ log.fail("git init failed");
288
+ return false;
289
+ }
290
+
291
+ // ── Main ────────────────────────────────────────────────────────────────────
292
+
293
+ export async function init() {
294
+ console.log(`
295
+ ${color.bold("mneme init")} — Three-layer memory architecture for AI agents
296
+ `);
297
+
298
+ const { os, arch } = getPlatform();
299
+ log.info(`Platform: ${os} ${arch}`);
300
+
301
+ // Step 1: Dependencies
302
+ log.step(1, 4, "Install dependencies");
303
+ const gitOk = installGit();
304
+ if (!gitOk) {
305
+ log.fail("git is required. Aborting.");
306
+ process.exit(1);
307
+ }
308
+ checkOpencode();
309
+ const doltOk = installDolt();
310
+ const bdOk = installBd();
311
+
312
+ // Step 2: Git init
313
+ log.step(2, 4, "Initialize git");
314
+ initGit();
315
+
316
+ // Step 3: Scaffold files
317
+ log.step(3, 4, "Scaffold project structure");
318
+ const { created, skipped } = scaffoldFiles();
319
+ log.info(` ${created} file(s) created, ${skipped} already existed`);
320
+
321
+ // Step 4: Dolt + Beads
322
+ log.step(4, 4, "Initialize beads");
323
+ if (doltOk && bdOk) {
324
+ const serverOk = ensureDoltServer();
325
+ if (serverOk) {
326
+ initBeads();
327
+ }
328
+ } else {
329
+ log.warn("Skipping beads init (dolt or bd not installed)");
330
+ log.warn("Run `mneme doctor` after installing dependencies to check status");
331
+ }
332
+
333
+ // Done
334
+ console.log(`
335
+ ${color.bold("===============================")}
336
+ ${color.green("mneme init complete")}
337
+ ${color.bold("===============================")}
338
+
339
+ ${color.bold("Next steps:")}
340
+ mneme # Start coding with AI agent
341
+ bd ready # Check available tasks
342
+ mneme doctor # Verify everything is healthy
343
+ `);
344
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * mneme propose — Propose a new fact for OpenClaw.
3
+ *
4
+ * Creates a pending proposal in `.openclaw/proposals/` that requires
5
+ * human review via `mneme review` before being written to facts.
6
+ *
7
+ * Usage:
8
+ * mneme propose --file=architecture --content="..." --reason="..."
9
+ * mneme propose --file=pitfalls --content="..." --reason="..."
10
+ *
11
+ * Options:
12
+ * --file Target facts file name (without .md extension)
13
+ * --content The fact content to append
14
+ * --reason Why this qualifies as a long-term fact
15
+ * --action append (default) | create
16
+ */
17
+
18
+ import { existsSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { createHash } from "node:crypto";
21
+ import { log, color } from "../utils.mjs";
22
+
23
+ const PROPOSALS_DIR = ".openclaw/proposals";
24
+ const FACTS_DIR = ".openclaw/facts";
25
+
26
+ /**
27
+ * Parse --key=value and --key "value" style args.
28
+ */
29
+ function parseArgs(args) {
30
+ const result = {};
31
+ for (let i = 0; i < args.length; i++) {
32
+ const arg = args[i];
33
+ if (!arg.startsWith("--")) continue;
34
+
35
+ const eqIdx = arg.indexOf("=");
36
+ if (eqIdx !== -1) {
37
+ // --key=value
38
+ const key = arg.slice(2, eqIdx);
39
+ result[key] = arg.slice(eqIdx + 1);
40
+ } else {
41
+ // --key value
42
+ const key = arg.slice(2);
43
+ const next = args[i + 1];
44
+ if (next && !next.startsWith("--")) {
45
+ result[key] = next;
46
+ i++;
47
+ } else {
48
+ result[key] = true;
49
+ }
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+
55
+ /**
56
+ * Generate a short proposal ID from timestamp + content hash.
57
+ */
58
+ function generateId(content) {
59
+ const ts = Date.now().toString(36);
60
+ const hash = createHash("sha256")
61
+ .update(content + ts)
62
+ .digest("hex")
63
+ .slice(0, 4);
64
+ return `p-${ts.slice(-4)}${hash}`;
65
+ }
66
+
67
+ export async function propose(args) {
68
+ const opts = parseArgs(args);
69
+
70
+ // Validate required fields
71
+ if (!opts.file) {
72
+ console.error(`
73
+ Usage: mneme propose --file=<facts-file> --content="<fact>" --reason="<why>"
74
+
75
+ Options:
76
+ --file Target facts file (e.g. architecture, invariants, pitfalls)
77
+ --content The fact content to append
78
+ --reason Why this qualifies as a long-term fact
79
+ --action append (default) | create
80
+
81
+ Example:
82
+ mneme propose --file=pitfalls --content="bd export does not exist in v0.56.1" --reason="Verified by testing; agents will hit this repeatedly"
83
+ `);
84
+ process.exit(1);
85
+ }
86
+
87
+ if (!opts.content) {
88
+ log.fail('--content is required. What fact do you want to propose?');
89
+ process.exit(1);
90
+ }
91
+
92
+ if (!opts.reason) {
93
+ log.fail('--reason is required. Why is this a long-term fact?');
94
+ process.exit(1);
95
+ }
96
+
97
+ const action = opts.action || "append";
98
+ if (action !== "append" && action !== "create") {
99
+ log.fail(`Invalid action: ${action}. Use "append" or "create".`);
100
+ process.exit(1);
101
+ }
102
+
103
+ // Validate target file exists (for append) or doesn't exist (for create)
104
+ const targetFile = opts.file.endsWith(".md") ? opts.file : `${opts.file}.md`;
105
+ const targetPath = join(FACTS_DIR, targetFile);
106
+
107
+ if (action === "append" && !existsSync(targetPath)) {
108
+ log.fail(`Facts file not found: ${targetPath}`);
109
+ const available = existsSync(FACTS_DIR)
110
+ ? readdirSync(FACTS_DIR)
111
+ .filter((f) => f.endsWith(".md"))
112
+ .map((f) => f.replace(/\.md$/, ""))
113
+ : [];
114
+ if (available.length > 0) {
115
+ console.log(`Available files: ${available.join(", ")}`);
116
+ }
117
+ console.log('Use --action=create to propose a new facts file.');
118
+ process.exit(1);
119
+ }
120
+
121
+ // Create proposals directory
122
+ if (!existsSync(PROPOSALS_DIR)) {
123
+ mkdirSync(PROPOSALS_DIR, { recursive: true });
124
+ }
125
+
126
+ // Build proposal
127
+ const id = generateId(opts.content);
128
+ const proposal = {
129
+ id,
130
+ file: targetFile,
131
+ action,
132
+ content: opts.content,
133
+ reason: opts.reason,
134
+ status: "pending",
135
+ created: new Date().toISOString(),
136
+ };
137
+
138
+ // Write proposal file
139
+ const proposalPath = join(PROPOSALS_DIR, `${id}.json`);
140
+ writeFileSync(proposalPath, JSON.stringify(proposal, null, 2) + "\n", "utf-8");
141
+
142
+ log.ok(`Proposal created: ${color.bold(id)}`);
143
+ console.log(` File: ${targetFile}`);
144
+ console.log(` Action: ${action}`);
145
+ console.log(` Content: ${opts.content.slice(0, 80)}${opts.content.length > 80 ? "..." : ""}`);
146
+ console.log(` Reason: ${opts.reason}`);
147
+ console.log();
148
+ console.log(color.dim(`Review with: mneme review`));
149
+ console.log(color.dim(`Approve: mneme review ${id} --approve`));
150
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * mneme review — Review, approve, or reject OpenClaw fact proposals.
3
+ *
4
+ * Usage:
5
+ * mneme review List all pending proposals
6
+ * mneme review <id> --approve Approve and write to facts
7
+ * mneme review <id> --reject Reject proposal
8
+ * mneme review <id> Show proposal details
9
+ */
10
+
11
+ import {
12
+ existsSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ writeFileSync,
16
+ appendFileSync,
17
+ mkdirSync,
18
+ } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { log, color } from "../utils.mjs";
21
+
22
+ const PROPOSALS_DIR = ".openclaw/proposals";
23
+ const FACTS_DIR = ".openclaw/facts";
24
+
25
+ /**
26
+ * Load all proposals, optionally filtered by status.
27
+ */
28
+ function loadProposals(status) {
29
+ if (!existsSync(PROPOSALS_DIR)) return [];
30
+
31
+ return readdirSync(PROPOSALS_DIR)
32
+ .filter((f) => f.endsWith(".json"))
33
+ .map((f) => {
34
+ try {
35
+ return JSON.parse(readFileSync(join(PROPOSALS_DIR, f), "utf-8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ })
40
+ .filter(Boolean)
41
+ .filter((p) => !status || p.status === status)
42
+ .sort((a, b) => new Date(a.created) - new Date(b.created));
43
+ }
44
+
45
+ /**
46
+ * Load a single proposal by ID.
47
+ */
48
+ function loadProposal(id) {
49
+ const filePath = join(PROPOSALS_DIR, `${id}.json`);
50
+ if (!existsSync(filePath)) return null;
51
+ try {
52
+ return JSON.parse(readFileSync(filePath, "utf-8"));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Save a proposal back to disk.
60
+ */
61
+ function saveProposal(proposal) {
62
+ const filePath = join(PROPOSALS_DIR, `${proposal.id}.json`);
63
+ writeFileSync(filePath, JSON.stringify(proposal, null, 2) + "\n", "utf-8");
64
+ }
65
+
66
+ /**
67
+ * List pending proposals.
68
+ */
69
+ function listPending() {
70
+ const pending = loadProposals("pending");
71
+
72
+ if (pending.length === 0) {
73
+ log.ok("No pending proposals");
74
+ return;
75
+ }
76
+
77
+ console.log(
78
+ `\n${color.bold("Pending proposals")} (${pending.length})\n`,
79
+ );
80
+
81
+ for (const p of pending) {
82
+ const date = p.created.split("T")[0];
83
+ console.log(
84
+ ` ${color.yellow("○")} ${color.bold(p.id)} → ${p.file} (${p.action}) ${color.dim(date)}`,
85
+ );
86
+ console.log(` ${p.content.slice(0, 100)}${p.content.length > 100 ? "..." : ""}`);
87
+ console.log(` ${color.dim(`Reason: ${p.reason}`)}`);
88
+ console.log();
89
+ }
90
+
91
+ console.log(color.dim(" Approve: mneme review <id> --approve"));
92
+ console.log(color.dim(" Reject: mneme review <id> --reject"));
93
+ console.log(color.dim(" Detail: mneme review <id>"));
94
+ }
95
+
96
+ /**
97
+ * Show full detail of a proposal.
98
+ */
99
+ function showProposal(proposal) {
100
+ const statusColor =
101
+ proposal.status === "pending"
102
+ ? color.yellow
103
+ : proposal.status === "approved"
104
+ ? color.green
105
+ : color.red;
106
+
107
+ console.log(`
108
+ ${color.bold("Proposal")} ${color.bold(proposal.id)} [${statusColor(proposal.status.toUpperCase())}]
109
+
110
+ Target: ${proposal.file} (${proposal.action})
111
+ Created: ${proposal.created}
112
+
113
+ ${color.bold("Content:")}
114
+ ${proposal.content}
115
+
116
+ ${color.bold("Reason:")}
117
+ ${proposal.reason}
118
+ `);
119
+ }
120
+
121
+ /**
122
+ * Approve a proposal: append/create fact content.
123
+ */
124
+ function approveProposal(proposal) {
125
+ const targetPath = join(FACTS_DIR, proposal.file);
126
+
127
+ if (proposal.action === "append") {
128
+ if (!existsSync(targetPath)) {
129
+ log.fail(`Target file not found: ${targetPath}`);
130
+ process.exit(1);
131
+ }
132
+ // Append with a blank line separator
133
+ appendFileSync(targetPath, `\n${proposal.content}\n`, "utf-8");
134
+ log.ok(`Appended to ${proposal.file}`);
135
+ } else if (proposal.action === "create") {
136
+ if (existsSync(targetPath)) {
137
+ log.warn(`File already exists: ${targetPath} — appending instead`);
138
+ appendFileSync(targetPath, `\n${proposal.content}\n`, "utf-8");
139
+ } else {
140
+ const dir = join(FACTS_DIR);
141
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
142
+ writeFileSync(targetPath, proposal.content + "\n", "utf-8");
143
+ log.ok(`Created ${proposal.file}`);
144
+ }
145
+ }
146
+
147
+ // Update proposal status
148
+ proposal.status = "approved";
149
+ proposal.reviewed = new Date().toISOString();
150
+ saveProposal(proposal);
151
+
152
+ log.ok(`Proposal ${color.bold(proposal.id)} approved`);
153
+ }
154
+
155
+ /**
156
+ * Reject a proposal.
157
+ */
158
+ function rejectProposal(proposal) {
159
+ proposal.status = "rejected";
160
+ proposal.reviewed = new Date().toISOString();
161
+ saveProposal(proposal);
162
+
163
+ log.ok(`Proposal ${color.bold(proposal.id)} rejected`);
164
+ }
165
+
166
+ // ── Main ────────────────────────────────────────────────────────────────────
167
+
168
+ export async function review(args) {
169
+ const doApprove = args.includes("--approve");
170
+ const doReject = args.includes("--reject");
171
+ const id = args.find((a) => !a.startsWith("-"));
172
+
173
+ // No ID → list pending
174
+ if (!id) {
175
+ listPending();
176
+ return;
177
+ }
178
+
179
+ // Load proposal
180
+ const proposal = loadProposal(id);
181
+ if (!proposal) {
182
+ log.fail(`Proposal not found: ${id}`);
183
+ const pending = loadProposals("pending");
184
+ if (pending.length > 0) {
185
+ console.log(
186
+ `\nPending: ${pending.map((p) => p.id).join(", ")}`,
187
+ );
188
+ }
189
+ process.exit(1);
190
+ }
191
+
192
+ // Action: approve or reject
193
+ if (doApprove) {
194
+ if (proposal.status !== "pending") {
195
+ log.warn(`Proposal already ${proposal.status}`);
196
+ process.exit(1);
197
+ }
198
+ showProposal(proposal);
199
+ approveProposal(proposal);
200
+ } else if (doReject) {
201
+ if (proposal.status !== "pending") {
202
+ log.warn(`Proposal already ${proposal.status}`);
203
+ process.exit(1);
204
+ }
205
+ rejectProposal(proposal);
206
+ } else {
207
+ // Just show detail
208
+ showProposal(proposal);
209
+ }
210
+ }