@roulabs/mx 2.1.0 → 2.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/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", "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.0",
3
+ "version": "2.2.0",
4
4
  "description": "mx — run several features in parallel across shared repos using git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
@@ -72,6 +72,9 @@ mx/
72
72
  ├── scripts/ # ad-hoc per-work scripts
73
73
  ├── files/ # artifacts worth keeping (agent/user drop zone)
74
74
  ├── tmp/ # throwaway scratch — may be deleted at any time
75
+ ├── hooks/ # per-work lifecycle hooks (see § Work lifecycle hooks)
76
+ │ ├── pre-archive.sh · post-archive.sh
77
+ │ └── pre-unarchive.sh · post-unarchive.sh
75
78
  └── sessions/ # session summaries (see § Session summaries)
76
79
  ```
77
80
 
@@ -85,9 +88,39 @@ mx/
85
88
  `CLAUDE.md` for any session started in the work folder (Claude Code walks up from the session's cwd).
86
89
  mx stamps it once (an explanatory comment, otherwise empty) and then **never touches it** — it's where
87
90
  you and the user record rules specific to this work.
91
+ - `works/<feature>/hooks/` holds **per-work lifecycle hooks** — mx-owned scripts mx runs around
92
+ `mx work archive`/`unarchive`. mx stamps documented no-op scripts you customize (see § Work
93
+ lifecycle hooks).
88
94
  - `works/<feature>/{scripts,files,tmp}/` are the only places to put non-mx files in a work — see
89
95
  § The work folder holds mx-native files only.
90
96
 
97
+ ## Work lifecycle hooks
98
+
99
+ Each work has a `hooks/` folder with mx-owned scripts that fire around archive/unarchive. mx stamps
100
+ four documented **no-op** scripts (they just `exit 0`) when the work is created — edit a script's body
101
+ to make it do something; leave it as-is to opt out.
102
+
103
+ | hook | when it runs | non-zero exit |
104
+ |---|---|---|
105
+ | `pre-archive.sh` | before `mx work archive` removes worktrees (worktrees still on disk) | **aborts** the archive (`HOOK_FAILED`); nothing is mutated |
106
+ | `post-archive.sh` | after the work is archived (worktrees gone, branches kept) | warning only — the archive already happened |
107
+ | `pre-unarchive.sh` | before `mx work unarchive` re-creates worktrees (none on disk yet) | **aborts** the unarchive (`HOOK_FAILED`) |
108
+ | `post-unarchive.sh` | after worktrees are restored | warning only |
109
+
110
+ A `pre-*` hook is a veto point (e.g. block archive if a branch has unpushed commits); a `post-*` hook
111
+ is for cleanup/notification after the fact. mx runs each with the **work folder** as the working
112
+ directory and passes context as positional args and environment variables:
113
+
114
+ ```
115
+ $1 / $MX_EVENT the event name (e.g. "pre-archive")
116
+ $2 / $MX_WORK_PATH absolute path to the work folder
117
+ $MX_WORK work name
118
+ $MX_RUNTIME runtime root
119
+ ```
120
+
121
+ These hooks are mx-owned but **yours to edit** — `mx sync` only re-stamps a script if it's missing,
122
+ never clobbering your changes, and backfills `hooks/` for works created before this feature existed.
123
+
91
124
  ## The work folder holds mx-native files only
92
125
 
93
126
  **Never create ad-hoc files directly in the work-folder root.** The root is reserved for mx-native