@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roulabs/mx",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "description": "mx — run several features in parallel across shared repos using git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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>/{scripts,files,tmp}/` are the only places to put non-mx files in a work — see
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