@roulabs/mx 1.1.1 → 1.2.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/bin/mx.js +167 -15
- package/package.json +1 -1
- package/templates/CLAUDE.md +34 -4
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
|
```
|
|
@@ -114,7 +115,7 @@ Example entry:
|
|
|
114
115
|
|
|
115
116
|
Primary path:
|
|
116
117
|
|
|
117
|
-
1. Read `<runtime>/context/INDEX.json`
|
|
118
|
+
1. **Read `<runtime>/context/INDEX.json` on every task** — trivial or not, small or large. Skimming a metadata index is cheap; the cost of missing a relevant entry is high. This is a hard rule, not a heuristic.
|
|
118
119
|
2. Open files at `<runtime>/context/<path>.md` for entries whose metadata matches the current task.
|
|
119
120
|
|
|
120
121
|
When INDEX descriptions don't surface what you need — and often they won't — fall back to anything that works:
|
|
@@ -122,12 +123,10 @@ When INDEX descriptions don't surface what you need — and often they won't —
|
|
|
122
123
|
- **Grep `<runtime>/context/`** for keywords. Frequently the term you need lives in a body, not in any description.
|
|
123
124
|
- **`ls` the folder recursively** to spot entries on disk that aren't indexed (orphans), and read them directly when relevant.
|
|
124
125
|
- **Follow `related` chains** outward from a known-relevant entry to find the rest of a cluster.
|
|
125
|
-
- **Read everything**
|
|
126
|
+
- **Read everything** when in doubt — better than guessing.
|
|
126
127
|
|
|
127
128
|
INDEX is the *primary* discovery surface, not the only one. Use whatever gets you to the right entry fastest — direct grep, full-content scan, recursive read, following links, your judgment.
|
|
128
129
|
|
|
129
|
-
A 30-second skim of INDEX is free; do it before any non-trivial task. Skip only for typo-fix-level work.
|
|
130
|
-
|
|
131
130
|
### Maintain INDEX.json as you go
|
|
132
131
|
|
|
133
132
|
When you add, rename, remove, or restructure an entry, update INDEX in the same change. Drift means orphan files (invisible to future sessions) or stale entries (false positives). After editing INDEX, sanity-check it parses as valid JSON.
|
|
@@ -150,6 +149,37 @@ For ephemeral scratch (a debugging journey in progress, a hypothesis you're test
|
|
|
150
149
|
|
|
151
150
|
**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
151
|
|
|
152
|
+
## Session summaries — detailed record of each working session
|
|
153
|
+
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
### Single trigger — never auto-write
|
|
157
|
+
|
|
158
|
+
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.
|
|
159
|
+
|
|
160
|
+
### Filename
|
|
161
|
+
|
|
162
|
+
`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).
|
|
163
|
+
|
|
164
|
+
### Content — distillation, not transcript
|
|
165
|
+
|
|
166
|
+
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:
|
|
167
|
+
|
|
168
|
+
- **Goal** — what we set out to do this session.
|
|
169
|
+
- **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.
|
|
170
|
+
- **What worked** — code shipped, decisions reached, approaches that succeeded. Include commit SHAs and PR links when applicable.
|
|
171
|
+
- **Dead ends** — hypotheses tried and falsified, approaches abandoned, and *why* each was abandoned (saves the next session from re-running the same dead end).
|
|
172
|
+
- **Files touched** — paths modified, with a sentence on what changed and why.
|
|
173
|
+
- **State at session end** — in-flight changes, tests passing/failing, PRs open, commits ahead of main.
|
|
174
|
+
- **Next steps / open questions** — what should the next session pick up?
|
|
175
|
+
- **Cross-references** — context-registry entries (by `path`) created or updated this session; prior session files this builds on (by filename).
|
|
176
|
+
|
|
177
|
+
Length is not a virtue; completeness is. The bar: a fresh agent can read just this file and continue the work cold.
|
|
178
|
+
|
|
179
|
+
### Cross-link with the context registry
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
153
183
|
## How to do things (always via mx)
|
|
154
184
|
|
|
155
185
|
You are launched from the work folder, so you can **omit `-n <feature>`** — mx infers the work from
|