@memfork/cli 0.1.20 → 0.1.22

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/cli.js CHANGED
@@ -25,7 +25,7 @@ const { version } = createRequire(import.meta.url)("../package.json");
25
25
  import { cmdInit } from "./commands/init.js";
26
26
  import { cmdDoctor, cmdDoctorEnv } from "./commands/doctor.js";
27
27
  import { cmdInstall } from "./commands/install.js";
28
- import { cmdStatus, cmdLog, cmdRecall, cmdCommit, cmdMerge, cmdProposals, cmdUi, cmdShow, cmdDiff, cmdDelegates, cmdGrant, cmdGrantMemwal, cmdRevoke, cmdBranch, cmdCheckout, } from "./commands/ops.js";
28
+ import { cmdStatus, cmdLog, cmdRecall, cmdCommit, cmdMerge, cmdProposals, cmdResolverCreate, cmdPrComment, cmdUi, cmdShow, cmdDiff, cmdDelegates, cmdGrant, cmdGrantMemwal, cmdRevoke, cmdBranch, cmdCheckout, } from "./commands/ops.js";
29
29
  import { cmdJoin } from "./commands/join.js";
30
30
  const program = new Command();
31
31
  program
@@ -90,13 +90,29 @@ program
90
90
  program
91
91
  .command("merge <from> <into>")
92
92
  .description("merge memory from one branch into another")
93
- .option("-r, --resolver <id>", "ResolverRef object ID (default: LastWriteWins, or MEMFORK_RESOLVER_ID env var)")
93
+ .option("-r, --resolver <id>", "ResolverRef object ID enables governed jury merge (or set MEMFORK_RESOLVER_ID)")
94
+ .option("--lww", "force LastWriteWins even when MEMFORK_RESOLVER_ID is set")
94
95
  .option("--ttl <ms>", "proposal TTL in milliseconds", parseInt, 86_400_000)
95
96
  .action(wrap((from, into, opts) => cmdMerge(from, into, opts)));
96
97
  program
97
98
  .command("proposals")
98
99
  .description("list open merge proposals")
99
100
  .action(wrap(cmdProposals));
101
+ const resolverCmd = new Command("resolver").description("manage resolver objects");
102
+ resolverCmd
103
+ .command("create")
104
+ .description("create a jury resolver (k-of-n)")
105
+ .requiredOption("--jury <addresses>", "comma-separated judge Sui addresses")
106
+ .option("-k, --k <n>", "approval threshold (default: majority)", parseInt)
107
+ .action(wrap((opts) => cmdResolverCreate({ jury: opts.jury, k: opts.k ?? 2 })));
108
+ program.addCommand(resolverCmd);
109
+ program
110
+ .command("pr-comment")
111
+ .description("post a MemForks decision summary to a GitHub PR")
112
+ .requiredOption("--pr <number>", "PR number", parseInt)
113
+ .option("--repo <owner/repo>", "GitHub repo (default: inferred from git remote)")
114
+ .option("--branch <name>", "branch to recall decided fact from (default: into_branch of last merge)")
115
+ .action(wrap((opts) => cmdPrComment(opts)));
100
116
  program
101
117
  .command("ui")
102
118
  .description("open the MemForks DAG visualizer")
@@ -22,8 +22,18 @@ export declare function cmdCommit(opts: {
22
22
  export declare function cmdMerge(from: string, into: string, opts: {
23
23
  resolver?: string;
24
24
  ttl?: number;
25
+ lww?: boolean;
25
26
  }): Promise<void>;
26
27
  export declare function cmdProposals(): Promise<void>;
28
+ export declare function cmdResolverCreate(opts: {
29
+ jury: string;
30
+ k: number;
31
+ }): Promise<void>;
32
+ export declare function cmdPrComment(opts: {
33
+ pr: number;
34
+ repo?: string;
35
+ branch?: string;
36
+ }): Promise<void>;
27
37
  export declare function cmdUi(opts?: {
28
38
  share?: boolean;
29
39
  port?: number;
@@ -129,11 +129,12 @@ export async function cmdMerge(from, into, opts) {
129
129
  const cfg = resolveConfig();
130
130
  const clientCfg = {
131
131
  ...toClientConfig(cfg),
132
- // --resolver flag overrides MEMFORK_RESOLVER_ID env var for this call
133
- ...(opts.resolver ? { defaultResolverId: opts.resolver } : {}),
132
+ // --resolver flag overrides MEMFORK_RESOLVER_ID env var for this call.
133
+ // --lww forces the LWW path even when MEMFORK_RESOLVER_ID is set.
134
+ ...(opts.lww ? { defaultResolverId: undefined } : opts.resolver ? { defaultResolverId: opts.resolver } : {}),
134
135
  };
135
136
  const client = await MemForksClient.connect(clientCfg);
136
- const governed = !!(opts.resolver ?? process.env["MEMFORK_RESOLVER_ID"]);
137
+ const governed = !opts.lww && !!(opts.resolver ?? process.env["MEMFORK_RESOLVER_ID"]);
137
138
  process.stdout.write(chalk.dim(`Merging ${chalk.green(from)} → ${chalk.green(into)}`) +
138
139
  chalk.dim(governed ? " (governed — awaiting resolver…)" : " (LWW — self-finalizing…)") +
139
140
  " ");
@@ -158,20 +159,192 @@ export async function cmdMerge(from, into, opts) {
158
159
  }
159
160
  // ─── proposals ────────────────────────────────────────────────────────────────
160
161
  export async function cmdProposals() {
161
- const { client, cfg } = await getClient();
162
- // Fetch open MergeProposed events from the indexer.
163
- // For now this polls recent events (the indexer / app/ maintains the live view).
162
+ const { cfg } = await getClient();
163
+ const { SuiJsonRpcClient, JsonRpcHTTPTransport, getJsonRpcFullnodeUrl } = await import("@mysten/sui/jsonRpc");
164
+ const rpcUrl = cfg.rpcUrl ?? getJsonRpcFullnodeUrl(cfg.network ?? "testnet");
165
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
166
+ const sui = new SuiJsonRpcClient({ transport: new JsonRpcHTTPTransport({ url: rpcUrl }), network: cfg.network ?? "testnet" });
164
167
  console.log("");
165
- console.log(chalk.bold("Open merge proposals"));
166
- console.log(chalk.dim(" (live status in the visualizer: memfork ui)"));
168
+ console.log(chalk.bold("Merge proposals") + chalk.dim(" tree: " + cfg.treeId.slice(0, 12) + "…"));
167
169
  console.log("");
168
- console.log(chalk.dim(" Tree: " + cfg.treeId));
170
+ const PROPOSAL_STATUS = { PENDING: 0, FINALIZED: 1, ABORTED: 2 };
171
+ let events;
172
+ try {
173
+ const result = await sui.queryEvents({
174
+ query: { MoveEventType: `${cfg.packageId}::resolver::MergeProposed` },
175
+ limit: 20,
176
+ order: "descending",
177
+ });
178
+ events = result.data.filter((e) => e.parsedJson["tree_id"] === cfg.treeId);
179
+ }
180
+ catch {
181
+ console.log(chalk.dim(" Could not query Sui events."));
182
+ console.log(chalk.cyan(" →") + " Run " + chalk.bold("memfork ui") + " for the live view.");
183
+ console.log("");
184
+ return;
185
+ }
186
+ if (events.length === 0) {
187
+ console.log(chalk.dim(" No proposals found for this tree."));
188
+ console.log("");
189
+ return;
190
+ }
191
+ for (const ev of events.slice(0, 10)) {
192
+ const p = ev.parsedJson;
193
+ const id = String(p["proposal_id"] ?? "");
194
+ // Fetch live status from the proposal object.
195
+ let statusLabel = chalk.yellow("pending");
196
+ try {
197
+ const obj = await sui.getObject({ id, options: { showContent: true } });
198
+ if (obj.data?.content && obj.data.content.dataType === "moveObject") {
199
+ const status = Number(obj.data.content.fields["status"]);
200
+ if (status === PROPOSAL_STATUS.FINALIZED)
201
+ statusLabel = chalk.green("finalized");
202
+ else if (status === PROPOSAL_STATUS.ABORTED)
203
+ statusLabel = chalk.red("aborted");
204
+ }
205
+ }
206
+ catch { /* proposal may be consumed */ }
207
+ console.log(` ${statusLabel} ` +
208
+ chalk.green(String(p["from_branch"]) + " → " + String(p["into_branch"])) + " " +
209
+ chalk.dim(id.slice(0, 12) + "…"));
210
+ }
169
211
  console.log("");
170
- console.log(chalk.dim(" Polling Sui events…"));
171
- // TODO: drive through MemForksIndexer once it's wired into the CLI.
172
- // For the hackathon: redirect to the visualizer.
212
+ console.log(chalk.dim(" Full detail: memfork ui → Merges view"));
173
213
  console.log("");
174
- console.log(chalk.cyan(" →") + " Run " + chalk.bold("memfork ui") + " for the full live proposal view.");
214
+ }
215
+ // ─── resolver create ──────────────────────────────────────────────────────────
216
+ export async function cmdResolverCreate(opts) {
217
+ const { client } = await getClient();
218
+ const { resolvers } = await import("@memfork/core");
219
+ const juryAddrs = opts.jury.split(",").map((a) => a.trim()).filter(Boolean);
220
+ if (juryAddrs.length === 0) {
221
+ console.error(chalk.red("Pass at least one judge address via --jury <addr1,addr2,...>"));
222
+ process.exit(1);
223
+ }
224
+ const k = opts.k ?? Math.ceil(juryAddrs.length / 2 + 0.5);
225
+ process.stdout.write(chalk.dim(`Creating jury resolver (${k}-of-${juryAddrs.length}) … `));
226
+ const def = resolvers.jury(juryAddrs, k, juryAddrs.length);
227
+ const { digest, resolverId } = await client.createResolver(def);
228
+ console.log(chalk.green("done"));
229
+ console.log("");
230
+ console.log(chalk.dim(" ResolverRef: ") + chalk.cyan(resolverId));
231
+ console.log(chalk.dim(" tx: ") + chalk.dim(digest));
232
+ console.log("");
233
+ console.log(chalk.bold(" Save this to your environment:"));
234
+ console.log(" " + chalk.cyan(`export MEMFORK_RESOLVER_ID=${resolverId}`));
235
+ console.log("");
236
+ console.log(chalk.dim(" Or add resolverId to .memfork/config.json for project-wide use."));
237
+ console.log("");
238
+ }
239
+ // ─── pr-comment ───────────────────────────────────────────────────────────────
240
+ export async function cmdPrComment(opts) {
241
+ const { client, cfg } = await getClient();
242
+ const { SuiJsonRpcClient, JsonRpcHTTPTransport, getJsonRpcFullnodeUrl } = await import("@mysten/sui/jsonRpc");
243
+ const rpcUrl = cfg.rpcUrl ?? getJsonRpcFullnodeUrl(cfg.network ?? "testnet");
244
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
245
+ const sui = new SuiJsonRpcClient({ transport: new JsonRpcHTTPTransport({ url: rpcUrl }), network: cfg.network ?? "testnet" });
246
+ console.log("");
247
+ console.log(chalk.dim("Fetching latest merge anchor…"));
248
+ // Find the most recent MergeFinalized event for this tree.
249
+ let anchorId = "";
250
+ let proposalId = "";
251
+ let suiTx = "";
252
+ let walrusBlob = "";
253
+ let fromBranch = "";
254
+ let intoBranch = "";
255
+ try {
256
+ const result = await sui.queryEvents({
257
+ query: { MoveEventType: `${cfg.packageId}::resolver::MergeFinalized` },
258
+ limit: 10,
259
+ order: "descending",
260
+ });
261
+ const ev = result.data
262
+ .find((e) => e.parsedJson["tree_id"] === cfg.treeId);
263
+ if (!ev) {
264
+ console.error(chalk.red("No finalized merges found for this tree. Run `memfork merge` first."));
265
+ process.exit(1);
266
+ }
267
+ anchorId = String(ev.parsedJson["merge_commit_id"] ?? "");
268
+ walrusBlob = String(ev.parsedJson["resolved_blob_id"] ?? "");
269
+ suiTx = ev.id.txDigest;
270
+ proposalId = String(ev.parsedJson["proposal_id"] ?? "");
271
+ }
272
+ catch (e) {
273
+ console.error(chalk.red("Failed to query Sui: " + String(e)));
274
+ process.exit(1);
275
+ }
276
+ // Fetch proposal for branch names and attestation count.
277
+ let voteCount = "?";
278
+ let threshold = "?";
279
+ try {
280
+ const obj = await sui.getObject({ id: proposalId, options: { showContent: true } });
281
+ if (obj.data?.content && obj.data.content.dataType === "moveObject") {
282
+ const fields = obj.data.content.fields;
283
+ fromBranch = String(fields["from_branch"] ?? "");
284
+ intoBranch = String(fields["into_branch"] ?? "");
285
+ const attests = fields["attestations"];
286
+ voteCount = String(attests?.length ?? "?");
287
+ }
288
+ }
289
+ catch { /* non-critical */ }
290
+ // Get the decided fact from the into_branch via recall.
291
+ const targetBranch = opts.branch ?? intoBranch ?? currentGitBranch();
292
+ let decision = `Use ${fromBranch || "winning branch"} approach.`;
293
+ try {
294
+ const results = await client.recall("decided", { branch: targetBranch, limit: 1 });
295
+ if (results.length > 0) {
296
+ decision = results[0].text.replace(/^decided:\s*/i, "").split(".")[0] + ".";
297
+ }
298
+ }
299
+ catch { /* fallback to default */ }
300
+ // Find rejected paths via recall on the into_branch.
301
+ let rejectedPath = "";
302
+ try {
303
+ const rejected = await client.recall("rejected-path", { branch: targetBranch, limit: 1 });
304
+ if (rejected.length > 0) {
305
+ const match = rejected[0].text.match(/(\S+)\s+was not merged/);
306
+ if (match)
307
+ rejectedPath = match[1];
308
+ }
309
+ }
310
+ catch { /* non-critical */ }
311
+ const shortAnchor = anchorId.replace(/^0x/, "").slice(0, 7);
312
+ const shortTx = suiTx.replace(/^0x/, "").slice(0, 8);
313
+ const shortBlob = walrusBlob.slice(0, 12);
314
+ const vizUrl = `memforks.dev/${cfg.treeId.replace(/^0x/, "").slice(0, 8)}#${shortAnchor}`;
315
+ const body = [
316
+ `🔗 **MemForks decision attached**`,
317
+ ``,
318
+ `**Decision:**`,
319
+ decision,
320
+ ``,
321
+ `**How it was decided:**`,
322
+ `Jury vote, ${voteCount} of ${threshold} — enforced on Sui`,
323
+ ``,
324
+ `**Merge:** \`${shortAnchor}\``,
325
+ ``,
326
+ `**Sui:** \`${shortTx}…\``,
327
+ ``,
328
+ `**Walrus:** \`${shortBlob}…\``,
329
+ rejectedPath
330
+ ? [``, `**Rejected path:**`, `\`${rejectedPath}@latest\` remains queryable`].join("\n")
331
+ : "",
332
+ ``,
333
+ `**Full audit trail:** ${vizUrl}`,
334
+ ].filter((l) => l !== undefined).join("\n");
335
+ // Post via gh CLI.
336
+ const repoFlag = opts.repo ? `--repo ${opts.repo}` : "";
337
+ try {
338
+ execSync(`gh pr comment ${opts.pr} ${repoFlag} --body ${JSON.stringify(body)}`, {
339
+ stdio: ["ignore", "inherit", "inherit"],
340
+ });
341
+ console.log(chalk.green("✓") + " Comment posted to PR #" + opts.pr);
342
+ }
343
+ catch {
344
+ console.log(chalk.yellow("gh CLI not available or auth required. Copy this comment:"));
345
+ console.log("");
346
+ console.log(body);
347
+ }
175
348
  console.log("");
176
349
  }
177
350
  // ─── ui ───────────────────────────────────────────────────────────────────────
@@ -441,15 +614,21 @@ function extractFacts(response) {
441
614
  }
442
615
  // ─── Helpers ──────────────────────────────────────────────────────────────────
443
616
  function findAppDir() {
444
- // dist/commands/ops.js → packages/cli → packages → repo root → apps/visualizer
617
+ // Resolution order:
618
+ // 1. packages/cli/ui/ — bundled at publish time (npm install path)
619
+ // 2. apps/visualizer/ — monorepo dev path (two depths to handle symlinks)
445
620
  const candidates = [
446
- new URL("../../../../apps/visualizer", import.meta.url).pathname,
447
- new URL("../../../../../apps/visualizer", import.meta.url).pathname,
621
+ new URL("../../ui", import.meta.url).pathname, // dist/commands/ → cli root → ui/
622
+ new URL("../../../../apps/visualizer", import.meta.url).pathname, // monorepo: packages/cli
623
+ new URL("../../../../../apps/visualizer", import.meta.url).pathname, // monorepo: alternate depth
448
624
  ];
449
625
  for (const c of candidates) {
450
626
  try {
451
- if (fs.existsSync(c + "/package.json"))
627
+ // Bundled path: presence of index.html is the signal (no package.json shipped).
628
+ // Monorepo path: package.json marks the source root.
629
+ if (fs.existsSync(path.join(c, "index.html")) || fs.existsSync(path.join(c, "package.json"))) {
452
630
  return c;
631
+ }
453
632
  }
454
633
  catch {
455
634
  continue;
package/dist/index.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memfork/cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "MemForks CLI — init, commit, recall, merge, install plugins",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,7 +21,7 @@
21
21
  "./config": "./dist/config.js"
22
22
  },
23
23
  "scripts": {
24
- "build": "tsc",
24
+ "build": "node scripts/copy-plugins.mjs && tsc && node scripts/bundle-ui.mjs",
25
25
  "dev": "tsc --watch",
26
26
  "start": "node dist/index.js"
27
27
  },
@@ -30,11 +30,12 @@
30
30
  },
31
31
  "files": [
32
32
  "dist",
33
- "plugins"
33
+ "plugins",
34
+ "ui"
34
35
  ],
35
36
  "dependencies": {
36
37
  "@inquirer/prompts": "^8.5.2",
37
- "@memfork/core": "^0.1.4",
38
+ "@memfork/core": "^0.1.10",
38
39
  "chalk": "^5.6.2",
39
40
  "commander": "^15.0.0"
40
41
  },
@@ -6,8 +6,8 @@
6
6
  "name": "MemForks",
7
7
  "email": "team@memforks.dev"
8
8
  },
9
- "homepage": "https://github.com/memforks/memforks",
10
- "repository": "https://github.com/memforks/memforks",
9
+ "homepage": "https://github.com/memforks-dev/memforks",
10
+ "repository": "https://github.com/memforks-dev/memforks",
11
11
  "license": "Apache-2.0",
12
12
  "keywords": [
13
13
  "memory",
@@ -26,13 +26,10 @@
26
26
  "longDescription": "MemForks gives Codex a tamper-proof, branch-synced memory DAG anchored on Sui. Memory recall and storage are handled natively by the MemWal MCP server. MemForks adds the version-control layer: immutable on-chain commits, Git branch-scoped history, and conflict-free cross-branch merges via an on-chain resolver.",
27
27
  "developerName": "MemForks",
28
28
  "category": "Productivity",
29
- "capabilities": [
30
- "Read",
31
- "Write"
32
- ],
33
- "websiteURL": "https://github.com/memforks/memforks",
34
- "privacyPolicyURL": "https://github.com/memforks/memforks",
35
- "termsOfServiceURL": "https://github.com/memforks/memforks/blob/main/LICENSE",
29
+ "capabilities": ["Read", "Write"],
30
+ "websiteURL": "https://github.com/memforks-dev/memforks",
31
+ "privacyPolicyURL": "https://github.com/memforks-dev/memforks",
32
+ "termsOfServiceURL": "https://github.com/memforks-dev/memforks/blob/main/LICENSE",
36
33
  "defaultPrompt": [
37
34
  "Recall any relevant context for this branch",
38
35
  "Show my MemForks memory status",
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: memory-fork
3
+ description: >-
4
+ Fork agent memory into parallel branches to explore competing hypotheses.
5
+ Use when the user asks to explore multiple approaches, compare options, or
6
+ says phrases like "explore both", "try two paths", "compare A vs B".
7
+ ---
8
+
9
+ # Memory Fork
10
+
11
+ When you detect a multi-hypothesis prompt — the user wants to explore two or
12
+ more competing approaches — fork the MemForks memory tree so each path stays
13
+ isolated. Never collapse competing ideas into a single stream.
14
+
15
+ ## When to trigger
16
+
17
+ Trigger this skill when the user prompt contains signals like:
18
+ - "explore both paths" / "try both" / "compare X and Y"
19
+ - "what if we did X instead of Y" (two real alternatives)
20
+ - "should we do A or B?" (genuine decision fork, not a rhetorical question)
21
+ - Any request to investigate multiple competing solutions side-by-side
22
+
23
+ ## Procedure
24
+
25
+ ### 1. Announce the fork
26
+
27
+ Print exactly:
28
+
29
+ ```
30
+ [memforks] Multi-hypothesis detected.
31
+ [memforks] Forking agent memory from <current-branch>@HEAD
32
+ ```
33
+
34
+ Then list the branches you will create, one per hypothesis:
35
+
36
+ ```
37
+ ├── dev/<short-hypothesis-a>
38
+ └── dev/<short-hypothesis-b>
39
+ ```
40
+
41
+ Use kebab-case branch names derived from the hypothesis (e.g. `dev/redis-first`,
42
+ `dev/bcrypt-cost`, `dev/approach-a`).
43
+
44
+ ### 2. Create the branches
45
+
46
+ For each hypothesis, run:
47
+
48
+ ```bash
49
+ memfork branch dev/<hypothesis> --from <current-branch>
50
+ ```
51
+
52
+ ### 3. Investigate hypothesis A
53
+
54
+ Switch to the first branch and investigate:
55
+
56
+ ```bash
57
+ memfork checkout dev/<hypothesis-a>
58
+ ```
59
+
60
+ Work through the hypothesis. As you discover facts, commit them:
61
+
62
+ ```bash
63
+ memfork commit \
64
+ --branch dev/<hypothesis-a> \
65
+ --message "<what you found>" \
66
+ --facts "<concrete measurable fact>" "<another fact>"
67
+ ```
68
+
69
+ Commit at each meaningful step — hypothesis statement, baseline measurement,
70
+ result. Three commits is normal; more is fine.
71
+
72
+ ### 4. Investigate hypothesis B
73
+
74
+ ```bash
75
+ memfork checkout dev/<hypothesis-b>
76
+ ```
77
+
78
+ Repeat the same commit cadence.
79
+
80
+ ### 5. Summarise
81
+
82
+ After both branches have evidence, summarise findings side by side and tell
83
+ the user which branch has stronger evidence. Do NOT merge — merging is a
84
+ human governance act (`memfork merge`).
85
+
86
+ ## Output format for each commit
87
+
88
+ Use this fact structure for clarity and later recall:
89
+
90
+ ```
91
+ hypothesis: <one-sentence statement of what this branch is testing>
92
+ fact: <measured or researched datum — numbers are better than adjectives>
93
+ result: <conclusion or outcome of the investigation>
94
+ ```
95
+
96
+ ## Rules
97
+
98
+ - Never commit to `main` or the parent branch during a fork investigation.
99
+ - Never type `memfork merge` — that is the operator's call.
100
+ - If the user asks "which won?", answer from memory; do not merge.
101
+ - Keep branch names short and descriptive (`dev/redis-first` not `dev/add-redis-caching-to-auth-flow`).
102
+ - If `memfork branch` fails because the branch already exists, use `memfork checkout` and continue.
@@ -72,6 +72,40 @@ Use it for decisions that matter for audit trail, not routine facts.
72
72
 
73
73
  ---
74
74
 
75
+ ## Forking — exploring competing approaches
76
+
77
+ When the user asks you to explore multiple competing approaches (e.g. "try both",
78
+ "compare X vs Y", "explore both paths"), fork the memory tree so each hypothesis
79
+ stays isolated. Never collapse competing ideas into a single branch.
80
+
81
+ **Step 1 — announce and create branches:**
82
+
83
+ ```bash
84
+ memfork branch dev/<hypothesis-a> --from $(git rev-parse --abbrev-ref HEAD)
85
+ memfork branch dev/<hypothesis-b> --from $(git rev-parse --abbrev-ref HEAD)
86
+ ```
87
+
88
+ Use short kebab-case names (`dev/redis-first`, `dev/bcrypt-cost`).
89
+
90
+ **Step 2 — investigate each path and commit evidence:**
91
+
92
+ ```bash
93
+ memfork commit \
94
+ --branch dev/<hypothesis-a> \
95
+ --message "<what you found>" \
96
+ --facts "<measured fact>" "<result>"
97
+ ```
98
+
99
+ Commit at each meaningful step — hypothesis, measurement, result.
100
+
101
+ **Step 3 — summarise, do not merge.**
102
+
103
+ Report findings side by side and recommend which branch has stronger evidence.
104
+ Never run `memfork merge` as part of a fork investigation — merging is a
105
+ human governance act.
106
+
107
+ ---
108
+
75
109
  ## Suggesting a merge — proactive but not autonomous
76
110
 
77
111
  You may **suggest** a merge when you notice the current branch has accumulated