@roulabs/mx 2.1.1 → 2.3.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/README.md +1 -1
- package/bin/mx.js +105 -1
- package/package.json +1 -1
- package/templates/CLAUDE.md +38 -2
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ Inside a work folder or worktree you can drop `-n` — mx infers the work/repo f
|
|
|
52
52
|
| `mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>] [--no-hydrate]` | add a worktree (runs the repo's `hydrate.sh` unless `--no-hydrate`) |
|
|
53
53
|
| `mx work -n <name> worktree ls\|rm\|hydrate <repo>` | list / remove / re-run hydrate for a worktree |
|
|
54
54
|
| `mx work -n <name> port set\|unset\|ls <repo> <service> [<port>]` | allocate/release ports |
|
|
55
|
-
| `mx work -n <name> archive [--yes]` / `unarchive` | soft-delete / restore a work (keeps branches) |
|
|
55
|
+
| `mx work -n <name> archive [--yes]` / `unarchive` | soft-delete / restore a work (keeps branches); runs the work's `hooks/{pre,post}-{archive,unarchive}.sh` (a `pre-*` non-zero exit aborts) |
|
|
56
56
|
| `mx work -n <name> destroy --force` | permanently remove the work folder (keeps branches) |
|
|
57
57
|
|
|
58
58
|
## License
|
package/bin/mx.js
CHANGED
|
@@ -168,6 +168,14 @@ var workManifest = (root, name) => path3.join(workDir(root, name), "work.json");
|
|
|
168
168
|
var workspaceFile = (root, name) => path3.join(workDir(root, name), `${name}.code-workspace`);
|
|
169
169
|
var worktreesDir = (root, name) => path3.join(workDir(root, name), "wt");
|
|
170
170
|
var worktreePath = (root, name, repo) => path3.join(worktreesDir(root, name), repo);
|
|
171
|
+
var WORK_HOOK_EVENTS = [
|
|
172
|
+
"pre-archive",
|
|
173
|
+
"post-archive",
|
|
174
|
+
"pre-unarchive",
|
|
175
|
+
"post-unarchive"
|
|
176
|
+
];
|
|
177
|
+
var workHooksDir = (root, name) => path3.join(workDir(root, name), "hooks");
|
|
178
|
+
var workHookScript = (root, name, event) => path3.join(workHooksDir(root, name), `${event}.sh`);
|
|
171
179
|
var RUNTIME_VERSION = 2;
|
|
172
180
|
var mxConfigFile = (root) => path3.join(root, "mx.json");
|
|
173
181
|
function readMxConfig(root) {
|
|
@@ -355,7 +363,7 @@ function initRuntime(target0, templatesDir2) {
|
|
|
355
363
|
function ensureWorkScaffolding(root, workName) {
|
|
356
364
|
const created = [];
|
|
357
365
|
const wd = workDir(root, workName);
|
|
358
|
-
for (const d of ["wt", "scripts", "files", "tmp", "sessions"]) {
|
|
366
|
+
for (const d of ["wt", "scripts", "bin", "files", "tmp", "sessions", "hooks"]) {
|
|
359
367
|
const p = path3.join(wd, d);
|
|
360
368
|
if (!exists(p)) {
|
|
361
369
|
fs4.mkdirSync(p, { recursive: true });
|
|
@@ -373,6 +381,14 @@ function ensureWorkScaffolding(root, workName) {
|
|
|
373
381
|
fs4.writeFileSync(settings, workClaudeSettings(root));
|
|
374
382
|
created.push(settings);
|
|
375
383
|
}
|
|
384
|
+
for (const event of WORK_HOOK_EVENTS) {
|
|
385
|
+
const hook = workHookScript(root, workName, event);
|
|
386
|
+
if (!exists(hook)) {
|
|
387
|
+
fs4.writeFileSync(hook, workHookScriptBody(event));
|
|
388
|
+
fs4.chmodSync(hook, 493);
|
|
389
|
+
created.push(hook);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
376
392
|
return created;
|
|
377
393
|
}
|
|
378
394
|
function workClaudeMd(name) {
|
|
@@ -402,6 +418,40 @@ function workClaudeSettings(root) {
|
|
|
402
418
|
};
|
|
403
419
|
return JSON.stringify(settings, null, 2) + "\n";
|
|
404
420
|
}
|
|
421
|
+
function workHookScriptBody(event) {
|
|
422
|
+
const isPre = event.startsWith("pre-");
|
|
423
|
+
const op = event.replace(/^(pre|post)-/, "");
|
|
424
|
+
const failNote = isPre ? `# A NON-ZERO EXIT ABORTS the ${op}: nothing is mutated and mx errors with
|
|
425
|
+
# HOOK_FAILED. Use this to veto (e.g. block on unpushed commits).` : `# Runs only after the ${op} succeeds. A non-zero exit is reported as a
|
|
426
|
+
# warning; it cannot undo the ${op}.`;
|
|
427
|
+
let stateNote;
|
|
428
|
+
if (op === "archive") {
|
|
429
|
+
stateNote = isPre ? "# State when this runs: worktrees still present under wt/." : "# State when this runs: worktrees removed, branches kept, work flagged archived.";
|
|
430
|
+
} else {
|
|
431
|
+
stateNote = isPre ? "# State when this runs: work is archived, no worktrees on disk yet." : "# State when this runs: worktrees re-created under wt/, archive flag cleared.";
|
|
432
|
+
}
|
|
433
|
+
return `#!/usr/bin/env bash
|
|
434
|
+
#
|
|
435
|
+
# mx ${event} hook for this work.
|
|
436
|
+
#
|
|
437
|
+
# Runs around \`mx work -n <work> ${op}\`.
|
|
438
|
+
${failNote}
|
|
439
|
+
${stateNote}
|
|
440
|
+
#
|
|
441
|
+
# mx runs this with the work folder as the working directory and passes context
|
|
442
|
+
# both as positional args and environment variables:
|
|
443
|
+
#
|
|
444
|
+
# $1 / $MX_EVENT the lifecycle event ("${event}")
|
|
445
|
+
# $2 / $MX_WORK_PATH absolute path to the work folder
|
|
446
|
+
# $MX_WORK work name
|
|
447
|
+
# $MX_RUNTIME runtime root
|
|
448
|
+
#
|
|
449
|
+
set -euo pipefail
|
|
450
|
+
|
|
451
|
+
# No-op by default. Add your ${event} logic below.
|
|
452
|
+
exit 0
|
|
453
|
+
`;
|
|
454
|
+
}
|
|
405
455
|
function syncRuntime(root, templatesDir2) {
|
|
406
456
|
const updated = [];
|
|
407
457
|
updated.push(stampClaudeMd(root, templatesDir2));
|
|
@@ -1643,6 +1693,28 @@ function runWorktreeHydrate(ctx, quiet) {
|
|
|
1643
1693
|
return { ran: true, ok: r.status === 0, missing: false };
|
|
1644
1694
|
}
|
|
1645
1695
|
|
|
1696
|
+
// src/workhooks.ts
|
|
1697
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
1698
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1699
|
+
function runWorkHook(root, work, event, quiet) {
|
|
1700
|
+
const script = workHookScript(root, work, event);
|
|
1701
|
+
if (!existsSync3(script)) return { ran: false, ok: true, missing: true };
|
|
1702
|
+
const wp = workPath(root, work).path;
|
|
1703
|
+
const env = {
|
|
1704
|
+
...process.env,
|
|
1705
|
+
MX_RUNTIME: root,
|
|
1706
|
+
MX_WORK: work,
|
|
1707
|
+
MX_WORK_PATH: wp,
|
|
1708
|
+
MX_EVENT: event
|
|
1709
|
+
};
|
|
1710
|
+
const r = spawnSync4(script, [event, wp], {
|
|
1711
|
+
cwd: wp,
|
|
1712
|
+
env,
|
|
1713
|
+
stdio: quiet ? ["ignore", "ignore", "ignore"] : "inherit"
|
|
1714
|
+
});
|
|
1715
|
+
return { ran: true, ok: r.status === 0, missing: false };
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1646
1718
|
// src/commands/work.ts
|
|
1647
1719
|
function need2(v, msg) {
|
|
1648
1720
|
if (v == null || v === "") throw new MxError(msg, "BAD_ARGS");
|
|
@@ -1806,7 +1878,23 @@ function dispatchWork(positionals, flags) {
|
|
|
1806
1878
|
return;
|
|
1807
1879
|
}
|
|
1808
1880
|
}
|
|
1881
|
+
if (workInfo(root, name).isArchived !== true) {
|
|
1882
|
+
const preArchive = runWorkHook(root, name, "pre-archive", flags.porcelain);
|
|
1883
|
+
if (preArchive.ran && !preArchive.ok) {
|
|
1884
|
+
throw new MxError(
|
|
1885
|
+
`pre-archive hook for "${name}" exited non-zero \u2014 archive aborted`,
|
|
1886
|
+
"HOOK_FAILED"
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1809
1890
|
const res = archiveWork(root, name);
|
|
1891
|
+
const postArchive = runWorkHook(root, name, "post-archive", flags.porcelain);
|
|
1892
|
+
if (postArchive.ran && !postArchive.ok && !flags.porcelain) {
|
|
1893
|
+
process.stderr.write(
|
|
1894
|
+
`${warn()} ${dim(`post-archive hook for ${name} exited non-zero (archive already applied)`)}
|
|
1895
|
+
`
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1810
1898
|
emit(() => {
|
|
1811
1899
|
const removed = res.removedWorktrees.join(", ") || "none";
|
|
1812
1900
|
console.log(`${check()} archived work ${bold(name)}`);
|
|
@@ -1827,7 +1915,23 @@ function dispatchWork(positionals, flags) {
|
|
|
1827
1915
|
}
|
|
1828
1916
|
overrides[tok.slice(0, eq)] = tok.slice(eq + 1);
|
|
1829
1917
|
}
|
|
1918
|
+
if (workInfo(root, name).isArchived === true) {
|
|
1919
|
+
const preUnarchive = runWorkHook(root, name, "pre-unarchive", flags.porcelain);
|
|
1920
|
+
if (preUnarchive.ran && !preUnarchive.ok) {
|
|
1921
|
+
throw new MxError(
|
|
1922
|
+
`pre-unarchive hook for "${name}" exited non-zero \u2014 unarchive aborted`,
|
|
1923
|
+
"HOOK_FAILED"
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1830
1927
|
const res = unarchiveWork(root, name, overrides);
|
|
1928
|
+
const postUnarchive = runWorkHook(root, name, "post-unarchive", flags.porcelain);
|
|
1929
|
+
if (postUnarchive.ran && !postUnarchive.ok && !flags.porcelain) {
|
|
1930
|
+
process.stderr.write(
|
|
1931
|
+
`${warn()} ${dim(`post-unarchive hook for ${name} exited non-zero (unarchive already applied)`)}
|
|
1932
|
+
`
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1831
1935
|
emit(() => {
|
|
1832
1936
|
console.log(`${check()} unarchived work ${bold(name)}`);
|
|
1833
1937
|
for (const r of res.restored) {
|
package/package.json
CHANGED
package/templates/CLAUDE.md
CHANGED
|
@@ -70,8 +70,12 @@ mx/
|
|
|
70
70
|
│ ├── repo-a/ # worktree of repo-a on this feature's branch
|
|
71
71
|
│ └── repo-b/ # worktree of repo-b on this feature's branch
|
|
72
72
|
├── scripts/ # ad-hoc per-work scripts
|
|
73
|
+
├── bin/ # executables/binaries a session builds or fetches
|
|
73
74
|
├── files/ # artifacts worth keeping (agent/user drop zone)
|
|
74
75
|
├── tmp/ # throwaway scratch — may be deleted at any time
|
|
76
|
+
├── hooks/ # per-work lifecycle hooks (see § Work lifecycle hooks)
|
|
77
|
+
│ ├── pre-archive.sh · post-archive.sh
|
|
78
|
+
│ └── pre-unarchive.sh · post-unarchive.sh
|
|
75
79
|
└── sessions/ # session summaries (see § Session summaries)
|
|
76
80
|
```
|
|
77
81
|
|
|
@@ -85,9 +89,39 @@ mx/
|
|
|
85
89
|
`CLAUDE.md` for any session started in the work folder (Claude Code walks up from the session's cwd).
|
|
86
90
|
mx stamps it once (an explanatory comment, otherwise empty) and then **never touches it** — it's where
|
|
87
91
|
you and the user record rules specific to this work.
|
|
88
|
-
- `works/<feature>/
|
|
92
|
+
- `works/<feature>/hooks/` holds **per-work lifecycle hooks** — mx-owned scripts mx runs around
|
|
93
|
+
`mx work archive`/`unarchive`. mx stamps documented no-op scripts you customize (see § Work
|
|
94
|
+
lifecycle hooks).
|
|
95
|
+
- `works/<feature>/{scripts,bin,files,tmp}/` are the only places to put non-mx files in a work — see
|
|
89
96
|
§ The work folder holds mx-native files only.
|
|
90
97
|
|
|
98
|
+
## Work lifecycle hooks
|
|
99
|
+
|
|
100
|
+
Each work has a `hooks/` folder with mx-owned scripts that fire around archive/unarchive. mx stamps
|
|
101
|
+
four documented **no-op** scripts (they just `exit 0`) when the work is created — edit a script's body
|
|
102
|
+
to make it do something; leave it as-is to opt out.
|
|
103
|
+
|
|
104
|
+
| hook | when it runs | non-zero exit |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `pre-archive.sh` | before `mx work archive` removes worktrees (worktrees still on disk) | **aborts** the archive (`HOOK_FAILED`); nothing is mutated |
|
|
107
|
+
| `post-archive.sh` | after the work is archived (worktrees gone, branches kept) | warning only — the archive already happened |
|
|
108
|
+
| `pre-unarchive.sh` | before `mx work unarchive` re-creates worktrees (none on disk yet) | **aborts** the unarchive (`HOOK_FAILED`) |
|
|
109
|
+
| `post-unarchive.sh` | after worktrees are restored | warning only |
|
|
110
|
+
|
|
111
|
+
A `pre-*` hook is a veto point (e.g. block archive if a branch has unpushed commits); a `post-*` hook
|
|
112
|
+
is for cleanup/notification after the fact. mx runs each with the **work folder** as the working
|
|
113
|
+
directory and passes context as positional args and environment variables:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
$1 / $MX_EVENT the event name (e.g. "pre-archive")
|
|
117
|
+
$2 / $MX_WORK_PATH absolute path to the work folder
|
|
118
|
+
$MX_WORK work name
|
|
119
|
+
$MX_RUNTIME runtime root
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
These hooks are mx-owned but **yours to edit** — `mx sync` only re-stamps a script if it's missing,
|
|
123
|
+
never clobbering your changes, and backfills `hooks/` for works created before this feature existed.
|
|
124
|
+
|
|
91
125
|
## The work folder holds mx-native files only
|
|
92
126
|
|
|
93
127
|
**Never create ad-hoc files directly in the work-folder root.** The root is reserved for mx-native
|
|
@@ -98,6 +132,8 @@ subfolders. When you or the user need to write anything else, use one of these,
|
|
|
98
132
|
- **`tmp/`** — throwaway scratch. Its contents may be deleted at **any** time, with no guarantees —
|
|
99
133
|
never rely on anything here persisting.
|
|
100
134
|
- **`scripts/`** — ad-hoc scripts for this work.
|
|
135
|
+
- **`bin/`** — executables and binaries this work needs: tools you compile, CLIs you download, helper
|
|
136
|
+
binaries. Add it to `PATH` for the work if useful. Starts empty.
|
|
101
137
|
|
|
102
138
|
The one exception: a runtime file a session legitimately needs to create at the work root for tooling
|
|
103
139
|
to work (e.g. an MCP connection file like `.<something>-mcp`) is fine. The rule targets *ad-hoc*
|
|
@@ -273,7 +309,7 @@ clarity; dropping it works while you're inside the work.
|
|
|
273
309
|
5. **Don't destroy anything unless asked.** Worktrees stay until the user confirms the feature is merged.
|
|
274
310
|
Teardown keeps feature branches; never delete them.
|
|
275
311
|
6. **Never create ad-hoc files in the work-folder root.** Keepable artifacts go in `files/`, throwaway
|
|
276
|
-
scratch in `tmp/`, scripts in `scripts/`. The root is mx-native only (only exception: a tooling
|
|
312
|
+
scratch in `tmp/`, scripts in `scripts/`, executables/binaries in `bin/`. The root is mx-native only (only exception: a tooling
|
|
277
313
|
file a session genuinely needs there, e.g. an MCP connection file). See § The work folder holds
|
|
278
314
|
mx-native files only.
|
|
279
315
|
|