@roulabs/mx 1.1.1 → 1.2.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.
package/bin/mx.js CHANGED
@@ -298,19 +298,37 @@ function removeFolderFromWorkspace(root, name, repo) {
298
298
  ws.folders = (ws.folders ?? []).filter((f) => f.path !== repo);
299
299
  writeJson(file, ws);
300
300
  }
301
+ function clearWorkspaceFolders(root, name) {
302
+ const file = workspaceFile(root, name);
303
+ if (!exists(file)) return;
304
+ const ws = readJson(file);
305
+ ws.folders = [];
306
+ writeJson(file, ws);
307
+ }
301
308
  function workNew(root, name, description = "") {
302
309
  const dir = workDir(root, name);
303
310
  if (exists(dir)) throw new MxError(`work already exists: ${name}`, "EXISTS");
304
311
  fs6.mkdirSync(dir, { recursive: true });
312
+ fs6.mkdirSync(path4.join(dir, "sessions"), { recursive: true });
305
313
  const work = { name, description, worktrees: [] };
306
314
  writeWork(root, work);
307
315
  writeJson(workspaceFile(root, name), { folders: [], settings: {} });
308
316
  return { ...work, path: dir };
309
317
  }
310
- function listWorksInfo(root) {
318
+ function listWorksInfo(root, opts = {}) {
311
319
  return listWorkNames(root).map((name) => {
312
320
  const w = readWork(root, name);
313
- return { name, description: w.description ?? "", worktrees: (w.worktrees ?? []).length };
321
+ return {
322
+ name,
323
+ description: w.description ?? "",
324
+ worktrees: (w.worktrees ?? []).length,
325
+ isArchived: w.isArchived === true,
326
+ archived_at: w.archived_at ?? null
327
+ };
328
+ }).filter((s) => {
329
+ if (opts.onlyArchived) return s.isArchived;
330
+ if (opts.includeArchived) return true;
331
+ return !s.isArchived;
314
332
  });
315
333
  }
316
334
  function workInfo(root, name) {
@@ -372,7 +390,13 @@ function worktreeRemove(root, name, repo) {
372
390
  removeFolderFromWorkspace(root, name, repo);
373
391
  return { work: name, repo, branch: wt.branch, removed: true };
374
392
  }
375
- function workDestroy(root, name) {
393
+ function workDestroy(root, name, opts = {}) {
394
+ if (!opts.force) {
395
+ throw new MxError(
396
+ `refusing to destroy "${name}" \u2014 destroy is permanent and removes the work folder including any session summaries. Use \`mx work archive\` to soft-delete (recoverable via \`mx work unarchive\`), or re-run with \`--force\` if you really want this gone.`,
397
+ "NEED_FORCE"
398
+ );
399
+ }
376
400
  const work = readWork(root, name);
377
401
  const dirty = [];
378
402
  for (const wt of work.worktrees ?? []) {
@@ -394,6 +418,74 @@ function workDestroy(root, name) {
394
418
  fs6.rmSync(workDir(root, name), { recursive: true, force: true });
395
419
  return { work: name, removedWorktrees: removed, branchesKept: true };
396
420
  }
421
+ function archiveWork(root, name) {
422
+ const work = readWork(root, name);
423
+ if (work.isArchived === true) {
424
+ throw new MxError(`work "${name}" is already archived`, "ALREADY_ARCHIVED");
425
+ }
426
+ const dirty = [];
427
+ for (const wt of work.worktrees ?? []) {
428
+ const dest = path4.join(workDir(root, name), wt.repo);
429
+ if (exists(dest) && isDirty(dest)) dirty.push(wt.repo);
430
+ }
431
+ if (dirty.length) {
432
+ throw new MxError(
433
+ `cannot archive "${name}" \u2014 uncommitted changes in: ${dirty.join(", ")}. Commit or discard, then retry.`,
434
+ "DIRTY"
435
+ );
436
+ }
437
+ const removed = [];
438
+ for (const wt of work.worktrees ?? []) {
439
+ const dest = path4.join(workDir(root, name), wt.repo);
440
+ if (exists(dest)) git(["-C", repoPath(root, wt.repo), "worktree", "remove", dest]);
441
+ removed.push(wt.repo);
442
+ }
443
+ clearWorkspaceFolders(root, name);
444
+ const archived_at = (/* @__PURE__ */ new Date()).toISOString();
445
+ work.isArchived = true;
446
+ work.archived_at = archived_at;
447
+ writeWork(root, work);
448
+ return { work: name, archived_at, removedWorktrees: removed, branchesKept: true };
449
+ }
450
+ function unarchiveWork(root, name, overrides = {}) {
451
+ const work = readWork(root, name);
452
+ if (work.isArchived !== true) {
453
+ throw new MxError(`work "${name}" is not archived`, "NOT_ARCHIVED");
454
+ }
455
+ const desired = (work.worktrees ?? []).map((wt) => ({
456
+ repo: wt.repo,
457
+ branch: overrides[wt.repo] ?? wt.branch,
458
+ ports: wt.ports ?? {}
459
+ }));
460
+ const missing = [];
461
+ for (const d of desired) {
462
+ const rp = repoPath(root, d.repo);
463
+ if (!isGitRepo(rp)) {
464
+ throw new MxError(`pristine clone missing for repo: ${d.repo}`, "NO_REPO");
465
+ }
466
+ if (!branchExists(rp, d.branch)) missing.push({ repo: d.repo, branch: d.branch });
467
+ }
468
+ if (missing.length) {
469
+ const list = missing.map((m) => `${m.repo}=${m.branch}`).join(", ");
470
+ const overrideHint = missing.map((m) => `${m.repo}=<branch>`).join(" ");
471
+ throw new MxError(
472
+ `cannot unarchive "${name}" \u2014 branch(es) not found: ${list}. Re-run with explicit overrides: \`mx work -n ${name} unarchive ${overrideHint}\`.`,
473
+ "NO_REF"
474
+ );
475
+ }
476
+ const restored = [];
477
+ for (const d of desired) {
478
+ const dest = path4.join(workDir(root, name), d.repo);
479
+ git(["-C", repoPath(root, d.repo), "worktree", "add", dest, d.branch]);
480
+ addFolderToWorkspace(root, name, d.repo);
481
+ restored.push({ repo: d.repo, branch: d.branch, path: dest, ports: d.ports });
482
+ }
483
+ work.worktrees = restored.map((r) => ({ repo: r.repo, branch: r.branch, ports: r.ports }));
484
+ work.isArchived = false;
485
+ delete work.archived_at;
486
+ writeWork(root, work);
487
+ return { work: name, restored };
488
+ }
397
489
 
398
490
  // ../../packages/core/src/ports.ts
399
491
  var PORT_BASE = 3e3;
@@ -480,7 +572,14 @@ var VALUE_FLAGS = {
480
572
  };
481
573
  function parseArgs(argv) {
482
574
  const positionals = [];
483
- const flags = { porcelain: false, help: false, version: false };
575
+ const flags = {
576
+ porcelain: false,
577
+ help: false,
578
+ version: false,
579
+ force: false,
580
+ all: false,
581
+ archived: false
582
+ };
484
583
  for (let i = 0; i < argv.length; i++) {
485
584
  const a = argv[i];
486
585
  if (a === "--porcelain" || a === "--json") {
@@ -489,6 +588,12 @@ function parseArgs(argv) {
489
588
  flags.help = true;
490
589
  } else if (a === "--version" || a === "-v") {
491
590
  flags.version = true;
591
+ } else if (a === "--force") {
592
+ flags.force = true;
593
+ } else if (a === "--all") {
594
+ flags.all = true;
595
+ } else if (a === "--archived") {
596
+ flags.archived = true;
492
597
  } else if (a.startsWith("--") && a.includes("=")) {
493
598
  const eq = a.indexOf("=");
494
599
  const key = VALUE_FLAGS[a.slice(0, eq)];
@@ -546,18 +651,20 @@ Repos (pristine clones):
546
651
  mx repo -n <name> rm refuses if any work uses it
547
652
 
548
653
  Works (features):
549
- mx work new <name> [--description <t>]
550
- mx work ls [--porcelain]
654
+ mx work new <name> [--description <t>] creates folder + empty work.json + sessions/
655
+ mx work ls [--all|--archived] [--porcelain] default: active only
551
656
  mx work -n <name> info [--porcelain]
552
- mx work -n <name> path print the work folder path (cd "$(mx work -n <name> path)")
657
+ mx work -n <name> path print the work folder path (cd "$(mx work -n <name> path)")
553
658
  mx work -n <name> describe <text>
554
659
  mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>]
555
660
  mx work -n <name> worktree ls [--porcelain]
556
- mx work -n <name> worktree rm <repo> refuses on uncommitted changes; keeps branch
557
- mx work -n <name> port set <repo> <service> [<port>] auto-picks a free port if omitted
661
+ mx work -n <name> worktree rm <repo> refuses on uncommitted changes; keeps branch
662
+ mx work -n <name> port set <repo> <service> [<port>] auto-picks a free port if omitted
558
663
  mx work -n <name> port unset <repo> <service>
559
664
  mx work -n <name> port ls [--porcelain]
560
- mx work -n <name> destroy removes worktrees + folder; keeps branches
665
+ mx work -n <name> archive removes worktrees; keeps folder + work.json + sessions; recoverable
666
+ mx work -n <name> unarchive [<repo>=<branch>...] re-creates worktrees from work.json; override per-repo branch if recorded one is missing
667
+ mx work -n <name> destroy --force PERMANENT: deletes the work folder including session summaries (branches kept). Prefer archive.
561
668
 
562
669
  The -n <name> selector may be omitted when your cwd implies it: inside a work folder or
563
670
  worktree (works/<work>/...) the work is inferred; inside repos/<repo>/... the repo is inferred.
@@ -733,12 +840,16 @@ function dispatchWork(positionals, flags) {
733
840
  }
734
841
  if (action === "ls") {
735
842
  const root2 = requireRuntime({ runtime: flags.runtime });
736
- const works = listWorksInfo(root2);
843
+ const works = listWorksInfo(root2, {
844
+ includeArchived: flags.all,
845
+ onlyArchived: flags.archived
846
+ });
737
847
  emit(() => {
738
848
  for (const w of works) {
739
- console.log(
740
- `${w.name} (${w.worktrees} worktree${w.worktrees === 1 ? "" : "s"})${w.description ? ` \u2014 ${w.description}` : ""}`
741
- );
849
+ const chip = w.isArchived ? `[archived ${(w.archived_at ?? "").slice(0, 10)}] ` : "";
850
+ const desc = w.description ? ` \u2014 ${w.description}` : "";
851
+ const wts = `(${w.worktrees} worktree${w.worktrees === 1 ? "" : "s"})`;
852
+ console.log(`${chip}${w.name} ${wts}${desc}`);
742
853
  }
743
854
  }, works);
744
855
  return;
@@ -770,7 +881,13 @@ function dispatchWork(positionals, flags) {
770
881
  case "port":
771
882
  return workPort(root, name, positionals);
772
883
  case "destroy": {
773
- const res = workDestroy(root, name);
884
+ if (flags.force && !flags.porcelain) {
885
+ process.stderr.write(
886
+ `\u26A0 permanently removing work "${name}" \u2014 folder and any session summaries will be deleted (branches kept). This cannot be undone.
887
+ `
888
+ );
889
+ }
890
+ const res = workDestroy(root, name, { force: flags.force });
774
891
  emit(
775
892
  () => console.log(
776
893
  `destroyed work ${name} (worktrees removed: ${res.removedWorktrees.join(", ") || "none"}; branches kept)`
@@ -779,6 +896,41 @@ function dispatchWork(positionals, flags) {
779
896
  );
780
897
  return;
781
898
  }
899
+ case "archive": {
900
+ if (!flags.porcelain) {
901
+ process.stderr.write(
902
+ `Reminder: write any pending session summary into works/${name}/sessions/ before archiving.
903
+ `
904
+ );
905
+ }
906
+ const res = archiveWork(root, name);
907
+ emit(
908
+ () => console.log(
909
+ `archived work ${name} at ${res.archived_at} (worktrees removed: ${res.removedWorktrees.join(", ") || "none"}; branches kept)`
910
+ ),
911
+ res
912
+ );
913
+ return;
914
+ }
915
+ case "unarchive": {
916
+ const overrides = {};
917
+ for (const tok of positionals.slice(2)) {
918
+ const eq = tok.indexOf("=");
919
+ if (eq <= 0 || eq === tok.length - 1) {
920
+ throw new MxError(
921
+ `bad override: "${tok}" \u2014 expected <repo>=<branch>`,
922
+ "BAD_ARGS"
923
+ );
924
+ }
925
+ overrides[tok.slice(0, eq)] = tok.slice(eq + 1);
926
+ }
927
+ const res = unarchiveWork(root, name, overrides);
928
+ emit(() => {
929
+ console.log(`unarchived work ${name}`);
930
+ for (const r of res.restored) console.log(` ${r.repo} [${r.branch}] -> ${r.path}`);
931
+ }, res);
932
+ return;
933
+ }
782
934
  default:
783
935
  throw new MxError(`unknown work command: ${action ?? "(none)"}`, "BAD_ARGS");
784
936
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roulabs/mx",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "mx — run several features in parallel across shared repos using git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,6 +41,7 @@ mx/
41
41
  └── feature-a/
42
42
  ├── work.json # manifest — owned by `mx`, do not hand-edit
43
43
  ├── feature-a.code-workspace
44
+ ├── sessions/ # session summaries (see § Session summaries)
44
45
  ├── repo-a/ # worktree of repo-a on this feature's branch
45
46
  └── repo-b/ # worktree of repo-b on this feature's branch
46
47
  ```
@@ -150,6 +151,37 @@ For ephemeral scratch (a debugging journey in progress, a hypothesis you're test
150
151
 
151
152
  **What's worth writing:** rationale, history, gotchas, cross-system invariants, debugging journeys, RCAs, session summaries, project-local procedures, imported reference material — your call. When in doubt, save it; prune later.
152
153
 
154
+ ## Session summaries — detailed record of each working session
155
+
156
+ Each work has a `sessions/` folder that accumulates one markdown file per working session. The goal: another agent (a fresh Claude session, or a different agent like Codex / Cursor) can read these files and start a new session with full context — including findings from external sources that may no longer be accessible.
157
+
158
+ ### Single trigger — never auto-write
159
+
160
+ Write a session summary **only when the user explicitly asks at end of session**: *"add the session summary before I close"*, *"save this session"*, etc. Never auto-write. Never propose mid-session. Never proactively suggest at end of session unless asked. The user owns the trigger.
161
+
162
+ ### Filename
163
+
164
+ `works/<feature>/sessions/YYYY-MM-DD-HH-MM-<slug>.md` — date, 24-hour time (dash-separated; colons aren't portable on Windows), then a short kebab-case slug describing the session's subject. Use the time you started the session (or now, if unsure).
165
+
166
+ ### Content — distillation, not transcript
167
+
168
+ The summary is detailed but **not a transcript**. Capture the substance of what happened so a future agent can pick up the work without re-doing all the discovery:
169
+
170
+ - **Goal** — what we set out to do this session.
171
+ - **What we learned from external sources.** When you fetched URLs, looked at attached images, read attached files, or ran web searches: distill the relevant findings into the summary. The URL, file path, or image may not be accessible later — the *information* must live in the summary.
172
+ - **What worked** — code shipped, decisions reached, approaches that succeeded. Include commit SHAs and PR links when applicable.
173
+ - **Dead ends** — hypotheses tried and falsified, approaches abandoned, and *why* each was abandoned (saves the next session from re-running the same dead end).
174
+ - **Files touched** — paths modified, with a sentence on what changed and why.
175
+ - **State at session end** — in-flight changes, tests passing/failing, PRs open, commits ahead of main.
176
+ - **Next steps / open questions** — what should the next session pick up?
177
+ - **Cross-references** — context-registry entries (by `path`) created or updated this session; prior session files this builds on (by filename).
178
+
179
+ Length is not a virtue; completeness is. The bar: a fresh agent can read just this file and continue the work cold.
180
+
181
+ ### Cross-link with the context registry
182
+
183
+ Sessions and the context registry complement each other. When this session created or updated an entry in `<runtime>/context/`, list it under "Cross-references" in the session file. Conversely, when promoting a finding into a durable registry entry, you may reference the session file in the entry's body for provenance.
184
+
153
185
  ## How to do things (always via mx)
154
186
 
155
187
  You are launched from the work folder, so you can **omit `-n <feature>`** — mx infers the work from