@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 +167 -15
- package/package.json +1 -1
- package/templates/CLAUDE.md +32 -0
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 {
|
|
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 = {
|
|
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
|
|
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>
|
|
557
|
-
mx work -n <name> port set <repo> <service> [<port>]
|
|
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>
|
|
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
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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
package/templates/CLAUDE.md
CHANGED
|
@@ -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
|