@williamthorsen/nmr 0.47.0 → 0.49.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.
Files changed (117) hide show
  1. package/.meta/changelog.json +206 -0
  2. package/.readyup/kits/default.js +1534 -1466
  3. package/.readyup/manifest.json +142 -12
  4. package/CHANGELOG.md +55 -0
  5. package/README.md +44 -6
  6. package/agents/guidance/rulebooks/nmr.md +2 -1
  7. package/dist/esm/bin-path.js +4 -3
  8. package/dist/esm/check-cache-build-output.d.ts +24 -0
  9. package/dist/esm/check-cache-build-output.js +49 -0
  10. package/dist/esm/check-cache-delegation.d.ts +27 -0
  11. package/dist/esm/check-cache-delegation.js +92 -0
  12. package/dist/esm/check-cache-entries.d.ts +109 -0
  13. package/dist/esm/check-cache-entries.js +87 -0
  14. package/dist/esm/check-cache-gate.d.ts +64 -0
  15. package/dist/esm/check-cache-gate.js +226 -0
  16. package/dist/esm/check-cache-key.d.ts +47 -0
  17. package/dist/esm/check-cache-key.js +77 -0
  18. package/dist/esm/check-cache-record.d.ts +69 -0
  19. package/dist/esm/check-cache-record.js +167 -0
  20. package/dist/esm/check-cache.d.ts +8 -176
  21. package/dist/esm/check-cache.js +9 -214
  22. package/dist/esm/child-env.d.ts +57 -0
  23. package/dist/esm/child-env.js +59 -0
  24. package/dist/esm/child-process.d.ts +55 -0
  25. package/dist/esm/child-process.js +112 -0
  26. package/dist/esm/commands/build.d.ts +0 -16
  27. package/dist/esm/commands/build.js +9 -214
  28. package/dist/esm/commands/clean.js +3 -2
  29. package/dist/esm/commands/compiler.d.ts +18 -0
  30. package/dist/esm/commands/compiler.js +49 -0
  31. package/dist/esm/commands/fmt.d.ts +0 -39
  32. package/dist/esm/commands/fmt.js +4 -81
  33. package/dist/esm/commands/format-targets.d.ts +39 -0
  34. package/dist/esm/commands/format-targets.js +83 -0
  35. package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
  36. package/dist/esm/commands/rewrite-specifiers.js +122 -0
  37. package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
  38. package/dist/esm/commands/tsconfig-chain.js +63 -0
  39. package/dist/esm/config-check-cache.d.ts +17 -0
  40. package/dist/esm/config-check-cache.js +63 -0
  41. package/dist/esm/config-validation.d.ts +20 -0
  42. package/dist/esm/config-validation.js +236 -0
  43. package/dist/esm/config.js +4 -286
  44. package/dist/esm/crossing-diagnostics.d.ts +39 -0
  45. package/dist/esm/crossing-diagnostics.js +132 -0
  46. package/dist/esm/default-scripts.js +1 -1
  47. package/dist/esm/delegate-host.d.ts +43 -0
  48. package/dist/esm/delegate-host.js +178 -0
  49. package/dist/esm/delegation-target.d.ts +52 -0
  50. package/dist/esm/delegation-target.js +71 -0
  51. package/dist/esm/fan-out.d.ts +57 -0
  52. package/dist/esm/fan-out.js +146 -0
  53. package/dist/esm/glyphs.d.ts +1 -1
  54. package/dist/esm/help.js +10 -0
  55. package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
  56. package/dist/esm/helpers/deriveExcerpt.js +1 -1
  57. package/dist/esm/helpers/findClosestName.js +4 -3
  58. package/dist/esm/helpers/quoteForShell.d.ts +5 -0
  59. package/dist/esm/helpers/quoteForShell.js +3 -0
  60. package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
  61. package/dist/esm/helpers/renderQuotedList.js +9 -0
  62. package/dist/esm/hooks.d.ts +17 -0
  63. package/dist/esm/hooks.js +23 -0
  64. package/dist/esm/invocation-args.d.ts +40 -0
  65. package/dist/esm/invocation-args.js +178 -0
  66. package/dist/esm/invocation.d.ts +67 -0
  67. package/dist/esm/invocation.js +3 -0
  68. package/dist/esm/machine-lock.d.ts +2 -0
  69. package/dist/esm/machine-lock.js +1 -1
  70. package/dist/esm/output-channel.d.ts +23 -0
  71. package/dist/esm/output-channel.js +14 -0
  72. package/dist/esm/pass-manifest.d.ts +53 -0
  73. package/dist/esm/pass-manifest.js +89 -0
  74. package/dist/esm/presentation.d.ts +47 -0
  75. package/dist/esm/presentation.js +32 -0
  76. package/dist/esm/recording.d.ts +1 -1
  77. package/dist/esm/recording.js +2 -2
  78. package/dist/esm/remote-host-session.d.ts +62 -0
  79. package/dist/esm/remote-host-session.js +277 -0
  80. package/dist/esm/remote-probe-host.d.ts +22 -0
  81. package/dist/esm/remote-probe-host.js +50 -0
  82. package/dist/esm/remote-probe.d.ts +31 -0
  83. package/dist/esm/remote-probe.js +78 -0
  84. package/dist/esm/remote-run.d.ts +91 -0
  85. package/dist/esm/remote-run.js +189 -0
  86. package/dist/esm/remote-session.d.ts +40 -0
  87. package/dist/esm/remote-session.js +151 -0
  88. package/dist/esm/remote-snapshot.d.ts +21 -0
  89. package/dist/esm/remote-snapshot.js +80 -0
  90. package/dist/esm/replay-assembly.d.ts +2 -2
  91. package/dist/esm/replay-assembly.js +1 -1
  92. package/dist/esm/run-chain.d.ts +21 -0
  93. package/dist/esm/run-chain.js +108 -0
  94. package/dist/esm/runCli.d.ts +1 -9
  95. package/dist/esm/runCli.js +41 -895
  96. package/dist/esm/runner.d.ts +1 -54
  97. package/dist/esm/runner.js +2 -125
  98. package/dist/esm/shell-syntax.d.ts +30 -0
  99. package/dist/esm/shell-syntax.js +120 -0
  100. package/dist/esm/steps.d.ts +0 -2
  101. package/dist/esm/steps.js +3 -119
  102. package/dist/esm/types.d.ts +5 -0
  103. package/dist/esm/user-config.d.ts +34 -0
  104. package/dist/esm/user-config.js +93 -0
  105. package/dist/esm/verbosity.d.ts +6 -0
  106. package/dist/esm/verbosity.js +7 -7
  107. package/dist/esm/verdict.d.ts +3 -1
  108. package/dist/esm/verdict.js +4 -3
  109. package/dist/esm/vitest-source-resolution.js +2 -3
  110. package/docs/check-cache.md +30 -14
  111. package/docs/delegate.md +141 -0
  112. package/docs/machine-lock.md +2 -1
  113. package/docs/reporting.md +6 -1
  114. package/docs/scripts.md +2 -1
  115. package/package.json +5 -5
  116. /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
  117. /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
@@ -19,6 +19,51 @@
19
19
  "wrap-ansi": "10.0.2",
20
20
  "yaml": "2.9.1"
21
21
  },
22
+ "checklistChecks": [
23
+ {
24
+ "checklist": "nmr",
25
+ "checks": [
26
+ "@williamthorsen/nmr in devDependencies",
27
+ "@williamthorsen/nmr >= 0.49.0",
28
+ "pnpm-workspace.yaml exists",
29
+ "catalogued dependencies use catalog:",
30
+ "package.json has packageManager field",
31
+ ".tool-versions does not list pnpm",
32
+ "no package.json declares a pnpm field",
33
+ "no package.json declares a workspaces field",
34
+ ".config/nmr.config.ts uses defineConfig",
35
+ "eslint >= 10.0.0",
36
+ "@williamthorsen/strict-lint >= 9.3.0",
37
+ "root package.json has no nmr-provided scripts",
38
+ "no root install script runs lefthook install unguarded",
39
+ "all workspace packages can build",
40
+ "every bin target is a committed wrapper",
41
+ "every bin wrapper's build-output target is covered by files",
42
+ "no retired Vitest config variants",
43
+ "every vitest.config builds on @williamthorsen/nmr/vitest",
44
+ "vitest.root.config.ts builds on @williamthorsen/nmr/vitest",
45
+ "every workspace with a Vite config has a Vitest config",
46
+ "the test suite gates the test-file conventions",
47
+ "every test file names its isolation tier",
48
+ "every test file is under a __tests__ directory",
49
+ "no package re-exports the ancestor Vitest config",
50
+ "Prettier config builds on @williamthorsen/nmr/prettier",
51
+ "taze.config.ts builds on @williamthorsen/nmr/taze",
52
+ "taze config declares no option taze discards",
53
+ "v11y-check in devDependencies",
54
+ "scripts/run-workspace-script.ts does not exist",
55
+ "no workspace packages reference run-workspace-script or \"pnpm run ws\""
56
+ ]
57
+ },
58
+ {
59
+ "checklist": "self-hosted runner",
60
+ "checks": [
61
+ "GitHub reports the repo's runner settings",
62
+ "CI_RUNS_ON selects the self-hosted runner",
63
+ "the self-hosted runner can run CI"
64
+ ]
65
+ }
66
+ ],
22
67
  "checklists": [
23
68
  "nmr",
24
69
  "self-hosted runner"
@@ -30,6 +75,16 @@
30
75
  "kind": "module",
31
76
  "path": "../../nmr-core/dist/esm/cache-store.js"
32
77
  },
78
+ {
79
+ "hash": "81f1406b",
80
+ "kind": "module",
81
+ "path": "../../nmr-core/dist/esm/collectChangedPaths.js"
82
+ },
83
+ {
84
+ "hash": "200f69f1",
85
+ "kind": "module",
86
+ "path": "../../nmr-core/dist/esm/gitObjects.js"
87
+ },
33
88
  {
34
89
  "hash": "54384dca",
35
90
  "kind": "module",
@@ -41,7 +96,7 @@
41
96
  "path": "../../nmr-core/dist/esm/hasErrnoCode.js"
42
97
  },
43
98
  {
44
- "hash": "211453c6",
99
+ "hash": "1eb2b90c",
45
100
  "kind": "module",
46
101
  "path": "../../nmr-core/dist/esm/hashWorkingTree.js"
47
102
  },
@@ -56,10 +111,15 @@
56
111
  "path": "../../nmr-core/dist/esm/index.js"
57
112
  },
58
113
  {
59
- "hash": "2507870d",
114
+ "hash": "89d3e3b4",
60
115
  "kind": "module",
61
116
  "path": "../../nmr-core/dist/esm/parseArgs.js"
62
117
  },
118
+ {
119
+ "hash": "7915ba49",
120
+ "kind": "module",
121
+ "path": "../../nmr-core/dist/esm/runGit.js"
122
+ },
63
123
  {
64
124
  "hash": "14547f78",
65
125
  "kind": "module",
@@ -81,12 +141,67 @@
81
141
  "path": "../../nmr-core/dist/esm/writeFileWithCheck.js"
82
142
  },
83
143
  {
84
- "hash": "aaeb1a84",
144
+ "hash": "9fd61bf4",
85
145
  "kind": "module",
86
146
  "path": "kits/default.ts"
87
147
  },
88
148
  {
89
- "hash": "a06b470a",
149
+ "hash": "fbdddbc3",
150
+ "kind": "module",
151
+ "path": "lib/bin-targets.ts"
152
+ },
153
+ {
154
+ "hash": "56e92527",
155
+ "kind": "module",
156
+ "path": "lib/catalog.ts"
157
+ },
158
+ {
159
+ "hash": "369c92bb",
160
+ "kind": "module",
161
+ "path": "lib/github.ts"
162
+ },
163
+ {
164
+ "hash": "4f0d7859",
165
+ "kind": "module",
166
+ "path": "lib/manifest-fields.ts"
167
+ },
168
+ {
169
+ "hash": "e3de73f8",
170
+ "kind": "module",
171
+ "path": "lib/repo-files.ts"
172
+ },
173
+ {
174
+ "hash": "2a90a1d3",
175
+ "kind": "module",
176
+ "path": "lib/root-scripts.ts"
177
+ },
178
+ {
179
+ "hash": "3d661ab8",
180
+ "kind": "module",
181
+ "path": "lib/self-hosted-runner.ts"
182
+ },
183
+ {
184
+ "hash": "f8f79929",
185
+ "kind": "module",
186
+ "path": "lib/setup.ts"
187
+ },
188
+ {
189
+ "hash": "400757e6",
190
+ "kind": "module",
191
+ "path": "lib/shared-configs.ts"
192
+ },
193
+ {
194
+ "hash": "b3bdf8ee",
195
+ "kind": "module",
196
+ "path": "lib/vitest.ts"
197
+ },
198
+ {
199
+ "hash": "00130733",
200
+ "kind": "module",
201
+ "path": "lib/workspace-build.ts"
202
+ },
203
+ {
204
+ "hash": "08c09ade",
90
205
  "kind": "inline",
91
206
  "path": "../package.json",
92
207
  "paths": [
@@ -94,12 +209,22 @@
94
209
  ]
95
210
  },
96
211
  {
97
- "hash": "7e066d18",
212
+ "hash": "19b57f2e",
213
+ "kind": "module",
214
+ "path": "../src/config-check-cache.ts"
215
+ },
216
+ {
217
+ "hash": "2cfe8c2e",
218
+ "kind": "module",
219
+ "path": "../src/config-validation.ts"
220
+ },
221
+ {
222
+ "hash": "5b589c7e",
98
223
  "kind": "module",
99
224
  "path": "../src/config.ts"
100
225
  },
101
226
  {
102
- "hash": "0fb883a1",
227
+ "hash": "d2448f72",
103
228
  "kind": "module",
104
229
  "path": "../src/default-scripts.ts"
105
230
  },
@@ -119,7 +244,7 @@
119
244
  "path": "../src/helpers/escapeControlCharacters.ts"
120
245
  },
121
246
  {
122
- "hash": "47249ca4",
247
+ "hash": "2d1cf397",
123
248
  "kind": "module",
124
249
  "path": "../src/helpers/findClosestName.ts"
125
250
  },
@@ -154,7 +279,12 @@
154
279
  "path": "../src/resolver.ts"
155
280
  },
156
281
  {
157
- "hash": "ed585ab6",
282
+ "hash": "bb18a285",
283
+ "kind": "module",
284
+ "path": "../src/shell-syntax.ts"
285
+ },
286
+ {
287
+ "hash": "a1b70d65",
158
288
  "kind": "module",
159
289
  "path": "../src/steps.ts"
160
290
  },
@@ -169,7 +299,7 @@
169
299
  "path": "../src/UserError.ts"
170
300
  },
171
301
  {
172
- "hash": "857c630c",
302
+ "hash": "ecf8a873",
173
303
  "kind": "module",
174
304
  "path": "../src/verbosity.ts"
175
305
  },
@@ -186,10 +316,10 @@
186
316
  ],
187
317
  "name": "default",
188
318
  "path": "kits/default.js",
189
- "readyupVersion": "0.40.0",
319
+ "readyupVersion": "0.42.0",
190
320
  "source": "kits/default.ts",
191
- "sourceHash": "aaeb1a84",
192
- "targetHash": "125a39cc"
321
+ "sourceHash": "9fd61bf4",
322
+ "targetHash": "b0939edd"
193
323
  }
194
324
  ]
195
325
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,61 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.49.0 — 2026-10-18
6
+
7
+ ### 🎉 Features
8
+
9
+ - Makes a delegation wait while another delegation from the same worktree holds the host checkout, printing `nmr: waiting for <command> in <dir> (pid <pid>) to finish on <host>` and leaving the wait out of the verdict's duration. (#1079)
10
+ - Extends Ctrl-C, which previously ended only the run step on the host, to end the delegation during the wait for the checkout, the check-out step, and the install step as well. (#1079)
11
+ - Makes `nmr --delegate` fail at the check-out step with `could not check out on <host>: node is not on the PATH that the interactive login shell sets there` when the host's interactive login shell does not put Node on `PATH`, and documents that Node on the host must satisfy nmr's `engines` range (`>=24.16.0`). (#1079)
12
+ - Makes `nmr --delegate` and `NMR_DELEGATE` look up this machine's check-result cache before contacting the host, so that a tree with a pass recorded here is reported as `passed … ago on this tree` and an unknown command is reported here, without either one reaching the host. (#1080)
13
+ - Adds a record in this machine's check-result cache for each pass that a delegation host reports, so that a later delegation to the same host recalls it as `passed … ago on <host>` and `nmr --log` prints the host's transcript under a header that names the host. (#1080)
14
+ - Makes `nmr --delegate` record the passes that the host reports for a delegated composite's constituents, so that `nmr --delegate <host> check` after `nmr --delegate <host> ci` on the same tree recalls the pass without contacting the host. (#1082)
15
+ - Makes `nmr --delegate` record the excerpts that the host reports with each delegated pass, so that a recall of a delegated command, such as `ci`, replays them instead of nothing. (#1082)
16
+ - Lets `--delegate` and `NMR_DELEGATE` take a comma-separated list of hosts in order of preference, from which nmr chooses the first host on which the delegation would not wait for a lock, otherwise the first host that answered nmr's lock check, and never the delegating machine. (#1083)
17
+ - Extends the check-result cache to recall a pass earned on any host in the list, without checking any host's locks. (#1083)
18
+ - 🚨 **Breaking:** Makes `--delegate` a boolean flag, under which `nmr` runs the command on a host that `NMR_DELEGATE` or the user config names, and adds `--delegate-to <hosts>` to name the hosts for one run. (#1084)
19
+ - Adds a machine-level user config, `~/.config/nmr/config.ts`, in which `delegate.hosts` names this machine's hosts in order of preference and `delegate.when` selects the runs that `nmr` delegates to them without a flag: runs from a coding agent (`'agent'`) or every run (`'always'`), except when `CI` is set and for a `-F` or `-R` invocation. (#1084)
20
+ - Adds the repo config key `delegate`, whose only accepted value, `false`, makes `nmr` run every check on the machine that has the working tree, overriding `NMR_DELEGATE` and the user config but not a delegation flag. (#1084)
21
+ - Adds a stderr line, printed by `nmr` when a delegation decided by the user config cannot reach a host, that names the config file and the remedies in order: network access to the hosts, then `nmr --no-delegate <command>`. (#1084)
22
+ - Updates the `nmr` rulebook to direct agents not to pass `--delegate` or `--delegate-to`, since the user config decides delegation, and to ask for network access to a host before running `nmr --no-delegate`. (#1084)
23
+
24
+ ### 🐛 Bug fixes
25
+
26
+ - Fixes the issue that the snapshot sent to the host by `nmr --delegate` omitted a file edited in the same second as git's last write of the index. (#1078)
27
+ - Fixes the issue that `NMR_NO_CACHE`, `NMR_DEBUG`, and `NMR_RUN_ID` set on this machine did not reach the delegation host, which left the host's cache in use under `NMR_NO_CACHE=1`. (#1080)
28
+
29
+ ### ⚡ Performance
30
+
31
+ - Keys the check cache on the git tree id that `git add --all` would record for the working tree, which lets `nmr` skip a check after the checked tree is committed, and invalidates every pass recorded by earlier versions once. (#1078)
32
+
33
+ ### ♻️ Refactoring
34
+
35
+ - Splits the `nmr` source modules that exceeded the 300-line `max-lines` limit, such as `check-cache.ts` and `commands/build.ts`, into one module per concern, and moves each module's tests with its code. (#1061)
36
+ - Splits `config.ts`, `steps.ts`, and the default readyup kit `.readyup/kits/default.ts` to bring each under the 300-line `max-lines` limit, moving field-shape validation into `config-validation.ts`, the `checkCache` name check into `config-check-cache.ts`, shell tokenizing and quoting into `shell-syntax.ts`, and each kit section into a module under `.readyup/lib/`. (#1063)
37
+ - Moves the argument parser, the invocation types, pnpm fan-out, the delegate host, chain assembly, hook wrapping, crossing diagnostics, the check-cache gate and its recording, the child environment, and presentation out of `runCli.ts` into modules of their own, leaving `runCli.ts` with the run orchestration and bringing it under the 300-line `max-lines` limit. (#1064)
38
+ - Rewrites the code that the new and widened rules in `eslint-plugin-unicorn` 78 report, mostly by changing `flatMap` callbacks to return a single value rather than a one-element array. (#1072)
39
+ - Renames nmr's default Prettier config from `prettier-default-config.ts` to `prettier-default.config.ts`, so that the upstream exemption of tool-loaded `*.config.*` files from `unicorn/no-top-level-side-effects` covers it, and updates `nmr-fmt` to resolve the new name. (#1072)
40
+
41
+ ### 🧪 Tests
42
+
43
+ - Splits the `nmr` test files that exceeded the 500-line `max-lines` limit for tests into files by aspect, and moves the fixtures and fake-spawn helpers that the new files share into `test-utils/`. (#1061)
44
+ - Splits `runCli.unit.test.ts` into four files named for the aspect that each covers and moves the pre/post hooks suite out of `cli.tool.test.ts` into `cli.hooks.tool.test.ts`, with the workspace-tree factory, the in-process runners, and the bin harness moved into shared files under `test-utils/`. (#1064)
45
+
46
+ ## 0.48.0 — 2026-10-17
47
+
48
+ ### 🎉 Features
49
+
50
+ - Adds the `--delegate <host>` flag to `nmr`, which runs a command whose result is its exit status, such as `ci`, `check`, or a `test` command, on that host over SSH against the local working tree with its uncommitted and untracked files, and exits 255 when the delegation fails outside the command. (#1058)
51
+ - Adds the `NMR_DELEGATE` environment variable, which names the host on which `nmr` runs every command that can be delegated, and the `--no-delegate` flag, which runs a command on the local machine whatever `NMR_DELEGATE` names. (#1058)
52
+ - Adds the host to the verdict for a delegated command, as `passed on <host>` in the line and as a `host` field in the `--json` object. (#1058)
53
+
54
+ ### 📦 Dependencies
55
+
56
+ - Upgrades `@williamthorsen/eslint-config-typescript` from 18.0.0 to 19.0.0 and rewrites the code in `build-info`, `nmr-core`, `nmr`, and `release-kit` that its new and widened rules report, without changing behavior. (#1047)
57
+ - Upgrades `readyup` from 0.40.0 to 0.41.0 and recompiles the kits in `nmr`, `release-kit`, and `v11y-check`, whose manifests now record each checklist's check names under `checklistChecks`. (#1047)
58
+ - Upgrades `pnpm` to 12.11.2, `@types/node` to 24.19.2, `lefthook` to 2.2.1, `@williamthorsen/strict-lint` to 14.2.0, `@williamthorsen/tsconfig` to 0.11.0, `smol-toml` to 1.9.1, and `vite` to 8.3.4. (#1047)
59
+
5
60
  ## 0.47.0 — 2026-10-15
6
61
 
7
62
  ### 🎉 Features
package/README.md CHANGED
@@ -5,15 +5,33 @@
5
5
  Context-aware script runner for pnpm monorepos. Includes an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v0.47.0 (2026-10-15)
8
+ ## Release notes — v0.49.0 (2026-10-18)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Sets `maxWorkers` on every project built by `defineVitestConfig` and `defineRootVitestConfig` to one worker per 4 GiB of total memory, bounded by one fewer than the core count, in place of Vitest's default of one fewer than the core count, which does not take memory into account; machines with 2 or fewer cores keep 3 workers. (#1044)
13
- - Applies the `--maxWorkers` (or `--max-workers`) flag to every project, which Vitest does not do itself, making the flag override both the derived count and a `maxWorkers` set through the `project` or `tiers` seam, while the `VITEST_MAX_WORKERS` environment variable still overrides the flag. (#1044)
14
- - Adds a machine lock that makes a heavy command (`check`, `ci`, and every `test` command except `test:watch`) wait, printing `nmr: waiting for …`, until the heavy command already running on the machine finishes; a nested command and a `-R` or `-F` fan-out take the lock once, and a cached pass does not wait. (#1045)
15
- - Adds the `--no-lock` flag and the `NMR_NO_LOCK=1` environment variable, which run a command and the commands nested in it without waiting for the machine lock, for example on a self-hosted CI machine that runs several runners in parallel. (#1045)
16
- - Extends the `nmr` rulebook to state that a run printing `nmr: waiting for …` is waiting for the machine lock rather than stuck, and that an agent must neither kill it nor rerun it with `--no-lock`. (#1045)
12
+ - Makes a delegation wait while another delegation from the same worktree holds the host checkout, printing `nmr: waiting for <command> in <dir> (pid <pid>) to finish on <host>` and leaving the wait out of the verdict's duration. (#1079)
13
+ - Extends Ctrl-C, which previously ended only the run step on the host, to end the delegation during the wait for the checkout, the check-out step, and the install step as well. (#1079)
14
+ - Makes `nmr --delegate` fail at the check-out step with `could not check out on <host>: node is not on the PATH that the interactive login shell sets there` when the host's interactive login shell does not put Node on `PATH`, and documents that Node on the host must satisfy nmr's `engines` range (`>=24.16.0`). (#1079)
15
+ - Makes `nmr --delegate` and `NMR_DELEGATE` look up this machine's check-result cache before contacting the host, so that a tree with a pass recorded here is reported as `passed … ago on this tree` and an unknown command is reported here, without either one reaching the host. (#1080)
16
+ - Adds a record in this machine's check-result cache for each pass that a delegation host reports, so that a later delegation to the same host recalls it as `passed … ago on <host>` and `nmr --log` prints the host's transcript under a header that names the host. (#1080)
17
+ - Makes `nmr --delegate` record the passes that the host reports for a delegated composite's constituents, so that `nmr --delegate <host> check` after `nmr --delegate <host> ci` on the same tree recalls the pass without contacting the host. (#1082)
18
+ - Makes `nmr --delegate` record the excerpts that the host reports with each delegated pass, so that a recall of a delegated command, such as `ci`, replays them instead of nothing. (#1082)
19
+ - Lets `--delegate` and `NMR_DELEGATE` take a comma-separated list of hosts in order of preference, from which nmr chooses the first host on which the delegation would not wait for a lock, otherwise the first host that answered nmr's lock check, and never the delegating machine. (#1083)
20
+ - Extends the check-result cache to recall a pass earned on any host in the list, without checking any host's locks. (#1083)
21
+ - 🚨 **Breaking:** Makes `--delegate` a boolean flag, under which `nmr` runs the command on a host that `NMR_DELEGATE` or the user config names, and adds `--delegate-to <hosts>` to name the hosts for one run. (#1084)
22
+ - Adds a machine-level user config, `~/.config/nmr/config.ts`, in which `delegate.hosts` names this machine's hosts in order of preference and `delegate.when` selects the runs that `nmr` delegates to them without a flag: runs from a coding agent (`'agent'`) or every run (`'always'`), except when `CI` is set and for a `-F` or `-R` invocation. (#1084)
23
+ - Adds the repo config key `delegate`, whose only accepted value, `false`, makes `nmr` run every check on the machine that has the working tree, overriding `NMR_DELEGATE` and the user config but not a delegation flag. (#1084)
24
+ - Adds a stderr line, printed by `nmr` when a delegation decided by the user config cannot reach a host, that names the config file and the remedies in order: network access to the hosts, then `nmr --no-delegate <command>`. (#1084)
25
+ - Updates the `nmr` rulebook to direct agents not to pass `--delegate` or `--delegate-to`, since the user config decides delegation, and to ask for network access to a host before running `nmr --no-delegate`. (#1084)
26
+
27
+ ### 🐛 Bug fixes
28
+
29
+ - Fixes the issue that the snapshot sent to the host by `nmr --delegate` omitted a file edited in the same second as git's last write of the index. (#1078)
30
+ - Fixes the issue that `NMR_NO_CACHE`, `NMR_DEBUG`, and `NMR_RUN_ID` set on this machine did not reach the delegation host, which left the host's cache in use under `NMR_NO_CACHE=1`. (#1080)
31
+
32
+ ### ⚡ Performance
33
+
34
+ - Keys the check cache on the git tree id that `git add --all` would record for the working tree, which lets `nmr` skip a check after the checked tree is committed, and invalidates every pass recorded by earlier versions once. (#1078)
17
35
  <!-- /section:release-notes -->
18
36
 
19
37
  ## Installation
@@ -195,6 +213,25 @@ nmr: waiting for test in /Users/me/repos/other-repo (pid 41872) to finish
195
213
 
196
214
  The lock lives in `/tmp/nmr-<uid>/`, or in `NMR_LOCK_DIR`; a sandboxed session needs write access to it. `nmr --no-lock <command>` or `NMR_NO_LOCK=1` runs without waiting. [Machine lock](docs/machine-lock.md) covers which commands take it, nesting and fan-outs, reclaiming a lock that its holder left behind, and CI.
197
215
 
216
+ ## Delegating to another machine
217
+
218
+ nmr runs a check on another machine over SSH, against this working tree, and exits with its status. A user config on each machine, `~/.config/nmr/config.ts`, names the hosts in order of preference, and with `when` delegates agents' runs, or every run, without being asked:
219
+
220
+ ```ts
221
+ export default {
222
+ delegate: { hosts: ['imac2', 'imac3'], when: 'agent' },
223
+ };
224
+ ```
225
+
226
+ `--delegate-to` names the hosts for one run, and nmr runs on the first one that is not busy:
227
+
228
+ ```console
229
+ $ nmr --delegate-to imac2 ci
230
+ ✅ my-repo: ci: passed on imac2 in 1m 48s
231
+ ```
232
+
233
+ A delegation consults the [check-result cache](#check-result-cache) first, so a tree that already passed is not sent to the host; a pass that a host earned does not satisfy a run here. A repo whose checks must run on the machine that has the tree declares `delegate: false` in its config. [Delegating to another machine](docs/delegate.md) covers the user config, `--delegate`, `NMR_DELEGATE`, the rules that decide where a command runs, which commands can be delegated, how nmr chooses among several hosts, how the cache is consulted, what the host runs, how failures are reported, and setting up the host.
234
+
198
235
  ## Shared configs and helpers
199
236
 
200
237
  ### Shared Vitest config
@@ -299,6 +336,7 @@ rdy list --from npm:@williamthorsen/nmr # what nmr publishes
299
336
  - [What nmr reports](docs/reporting.md): Verdicts, `--json`, output style, and verbosity
300
337
  - [Check-result cache](docs/check-cache.md): What is skipped, what a hit requires, and replaying, reading, and bypassing recorded runs
301
338
  - [Machine lock](docs/machine-lock.md): Which commands wait for one another, where the lock lives, and bypassing it
339
+ - [Delegating to another machine](docs/delegate.md): The user config, `--delegate`, `--delegate-to`, `NMR_DELEGATE`, where a command runs, choosing among several hosts, what the host runs, and setting it up
302
340
  - [Dependency upgrades](docs/upgrades.md): `nmr upgrade`, the upgrade policy, `report-catalog`, `report-overrides`, and `nmr-taze`
303
341
  - [Standalone utilities](docs/utilities.md): `nmr-clean`, `nmr-compile`, `nmr-fmt`, and `ensure-prepublish-hooks`
304
342
  - [Shared Vitest config](docs/vitest.md): Test tiers, test selections, the conventions check, and customization
@@ -15,4 +15,5 @@ delivery: ambient
15
15
  - Every command that nmr runs reports one line -- `PASS <scope>: <command>: passed in 12.4s`, `FAIL … failed in 1.2s (exit 1)`, `SKIP … passed 4m ago on this tree`, or `NOOP … skipped, the override is empty` -- and a composite reports alongside every command into which it expands. Those are the words that a pipe and a CI log get; a terminal gets ✅, ❌, ⏩, and ⚪ in their place, and `--output-style rich` or `NMR_OUTPUT_STYLE=rich` forces the emoji on any stream. The lines print in every verbosity, so `nmr -q <command>` is the low-noise way to run rather than a silent one: It withholds the output of the commands that nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. nmr reads its own flags only before the command name and passes everything after the name to the commands that run: `nmr ci -q` hands `-q` to `nmr-fmt`, which rejects it. Expect a run to be quiet already without the flag: nmr recognizes a known agent harness in the environment and takes quiet from that, as it does from `NMR_COMMAND_VERBOSITY` or the repo's `output.commandVerbosity`. A command whose output you need in full is one to run with `NMR_COMMAND_VERBOSITY=full`, which outranks both.
16
16
  - Every test file lives under a `__tests__` directory and names its isolation tier last: `<subject>[.<aspect>].<tier>.test.ts`, where any earlier segment is free-form documentation. A tier is the furthest thing that the test reaches -- `tool` (a process that the test starts, or a program other than the package's own code that the test makes do its work, including an embedded engine, which the package installs and the test runs in-process, such as PGlite), `localhost` (a service already running on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. A file outside `__tests__`, or one whose tier segment is missing or misspelt, fails the test run in a repo that declares nmr's exported conventions check, which reports both. In a repo that has not, neither fails anything -- the first is collected by nothing and the second runs under `unit` -- and `rdy run --sources` reports both and warns that the check is missing.
17
17
  - A heavy command (`check`, `ci`, and the `test` commands but `test:watch`) may print `nmr: waiting for <command> in <dir> (pid <pid>) to finish` and wait: nmr runs one at a time on the machine, across every repository. The run is not stuck, and it continues once the holder ends. Do not kill it, and do not rerun it with `--no-lock` to get past the wait, which defeats the memory protection that the lock exists for. A warning that nmr is `running without the machine lock` names a directory that the sandbox cannot write.
18
- - The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (the repo does not define a build script of its own); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; `machine-lock.md` for which commands take the machine lock, where it lives, and its bypass; and `check-cache.md` for the check-result cache's key, the retention key to which a replayed excerpt is additionally held, the retention ceiling within which `--log` prints, its `checkCache` config, and every condition that disables it.
18
+ - Delegation is configured per machine, in nmr's user config (`~/.config/nmr/config.ts`), so a command may run on another machine without being asked: Never pass `--delegate` or `--delegate-to`. A verdict reading `passed on <host>` or `failed on <host>` reports a command that ran on that machine; the output above it is the host's. A delegation consults this machine's check-result cache first: `passed … ago on this tree` recalls a pass earned here, and `passed … ago on <host>` one that the host earned, without contacting it. A failure that names the user config, or exit 255 with `could not prepare <host>` inside a sandbox, means that the sandbox refused `ssh`: Ask for network access to the host first, and run the command here with `nmr --no-delegate <command>` only when that is refused.
19
+ - The rest is in `node_modules/@williamthorsen/nmr/docs/`: `scripts.md` for pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`) and script overrides and their `""`/`":"` skip values; `upgrades.md` for `nmr upgrade` ceilings (report-only until `nmr upgrade --write`); `utilities.md` for the `nmr-compile` build (the repo does not define a build script of its own); `vitest.md` for the test tiers' timeout budgets, the `tiers` seam that raises one, and which script reaches which tier; `machine-lock.md` for which commands take the machine lock, where it lives, and its bypass; `delegate.md` for the user config, the rules that decide where a command runs, which commands can run on another machine, what the host runs, and its setup; and `check-cache.md` for the check-result cache's key, the retention key to which a replayed excerpt is additionally held, the retention ceiling within which `--log` prints, its `checkCache` config, and every condition that disables it.
@@ -9,10 +9,11 @@ export function prependBinDirs(options) {
9
9
  const additions = [];
10
10
  for (const dir of [anchorDir, monorepoRoot]) {
11
11
  const binDir = path.resolve(dir, 'node_modules', '.bin');
12
- if (!seen.has(binDir)) {
13
- seen.add(binDir);
14
- additions.push(binDir);
12
+ if (seen.has(binDir)) {
13
+ continue;
15
14
  }
15
+ seen.add(binDir);
16
+ additions.push(binDir);
16
17
  }
17
18
  if (additions.length === 0) {
18
19
  return env;
@@ -0,0 +1,24 @@
1
+ import type { NmrConfig } from './types.js';
2
+ /** What nmr's own build has left on disk across the workspace. */
3
+ export interface BuildOutputState {
4
+ /** Covered packages whose output is absent. */
5
+ missingPackages: string[];
6
+ /** The digest of the inputs from which each covered package's output was built, keyed by package name. */
7
+ digests: Record<string, string>;
8
+ }
9
+ /**
10
+ * Names a covered package whose output differs between two observations of it, or `undefined` when every
11
+ * package agrees. A package that has appeared or disappeared between them counts as a disagreement, because
12
+ * the output that the earlier observation describes is not the output that the later one found.
13
+ */
14
+ export declare function findStaleBuildOutput(earlierDigests: Record<string, string>, laterDigests: Record<string, string>): string | undefined;
15
+ /**
16
+ * Reads the state of the build output that nmr's own build covers. Build output is git-ignored, so the tree id
17
+ * says nothing about it: a `ci` whose `build` constituent is cached would otherwise skip on a tree whose `dist`
18
+ * had been deleted, or whose `dist` was compiled from a different tree, and hand back a green exit over a
19
+ * repository that cannot run.
20
+ *
21
+ * Because a package whose `build` or `compile` is overridden emits to a location that this function does not
22
+ * know about, it is left out rather than made a permanent miss.
23
+ */
24
+ export declare function readBuildOutputState(monorepoRoot: string, config: NmrConfig): Promise<BuildOutputState>;
@@ -0,0 +1,49 @@
1
+ import path from 'node:path';
2
+ import { hasBuildOutput, readBuildDigest } from "./commands/build-output.js";
3
+ import { loadWorkspaceConfig } from "./config.js";
4
+ import { getDefaultWorkspaceScripts } from "./resolve-scripts.js";
5
+ import { buildWorkspaceRegistry, resolveScript } from "./resolver.js";
6
+ import { renderChain } from "./steps.js";
7
+ import { getWorkspacePackageDirs } from "./workspace.js";
8
+ const BUILT_IN_COMPILE = 'nmr-compile';
9
+ export function findStaleBuildOutput(earlierDigests, laterDigests) {
10
+ const names = [...new Set([...Object.keys(earlierDigests), ...Object.keys(laterDigests)])].toSorted();
11
+ return names.find((name) => earlierDigests[name] !== laterDigests[name]);
12
+ }
13
+ export async function readBuildOutputState(monorepoRoot, config) {
14
+ const state = { missingPackages: [], digests: {} };
15
+ let packageDirs;
16
+ try {
17
+ packageDirs = getWorkspacePackageDirs(monorepoRoot);
18
+ }
19
+ catch {
20
+ return state;
21
+ }
22
+ const registry = buildWorkspaceRegistry(config);
23
+ if (JSON.stringify(registry['build']) !== JSON.stringify(getDefaultWorkspaceScripts()['build'])) {
24
+ return state;
25
+ }
26
+ for (const packageDir of packageDirs) {
27
+ if (!isProbeSubject(packageDir, registry)) {
28
+ continue;
29
+ }
30
+ const { build } = await loadWorkspaceConfig(packageDir);
31
+ const options = build?.extraIgnorePatterns === undefined ? {} : { extraIgnorePatterns: build.extraIgnorePatterns };
32
+ const name = path.relative(monorepoRoot, packageDir);
33
+ if (await hasBuildOutput(packageDir, options)) {
34
+ state.digests[name] = (await readBuildDigest(packageDir)) ?? '';
35
+ }
36
+ else {
37
+ state.missingPackages.push(name);
38
+ }
39
+ }
40
+ return state;
41
+ }
42
+ function isProbeSubject(packageDir, registry) {
43
+ const build = resolveScript('build', registry, packageDir, false);
44
+ if (build === undefined || build.origin.tier === 'package') {
45
+ return false;
46
+ }
47
+ const compile = resolveScript('compile', registry, packageDir, false);
48
+ return compile !== undefined && renderChain(compile.steps) === BUILT_IN_COMPILE;
49
+ }
@@ -0,0 +1,27 @@
1
+ import { type TreeSnapshot } from './check-cache.js';
2
+ import type { BuildOutputState } from './check-cache-build-output.js';
3
+ import type { HostRunResult } from './delegate-host.js';
4
+ import { type RunPlan } from './invocation.js';
5
+ import type { OutputChannels } from './output-channel.js';
6
+ /** What the check-result cache gates a run on: the key, the tree, and the build output that the run saw. */
7
+ export interface Gate {
8
+ buildOutputBefore: BuildOutputState;
9
+ /** The channels on which the invocation's output runs, which every retention key that it records folds. */
10
+ channels: OutputChannels;
11
+ key: string;
12
+ retentionKey: string;
13
+ snapshot: TreeSnapshot;
14
+ }
15
+ /**
16
+ * Records the passes that the host earned, provided that the tree pushed to it is the tree on which the gate keyed,
17
+ * and that the working tree did not move while the host ran: a pass over any other tree says nothing about this one.
18
+ *
19
+ * Each valid entry of the host's pass manifest is recorded as a pass that the host earned at the entry's scope. It is
20
+ * keyed on this machine's inputs and the host's command string, so that an entry whose string differs from what would
21
+ * run here is never recalled, and its excerpts are keyed on this invocation's presentation, for which the host styled
22
+ * them. The invocation's own entry supplies only the retention of the invocation's pass, which is recorded last, so
23
+ * that a reader never sees a composite's pass ahead of its constituents' passes.
24
+ */
25
+ export declare function recordDelegatedPass(plan: RunPlan, gate: Gate, hostRun: Extract<HostRunResult, {
26
+ kind: 'ran';
27
+ }>): Promise<void>;
@@ -0,0 +1,92 @@
1
+ import path from 'node:path';
2
+ import { resolveTreeSnapshot, writeDebugNote } from "./check-cache.js";
3
+ import { computeCacheKey, computeRetentionKey } from "./check-cache-key.js";
4
+ import { recordHostPass } from "./check-cache-record.js";
5
+ import { VERSION } from "./invocation.js";
6
+ import { formatManifestScope, validatePassManifest } from "./pass-manifest.js";
7
+ export async function recordDelegatedPass(plan, gate, hostRun) {
8
+ const { anchorDir, command, context, env, stderr } = plan.invocation;
9
+ const { config, monorepoRoot } = context;
10
+ if (hostRun.treeId !== gate.snapshot.treeId) {
11
+ writeDebugNote(`not recording ${command}: the pushed tree is not the tree that the gate keyed on`, env, stderr);
12
+ return;
13
+ }
14
+ const currentRead = resolveTreeSnapshot({ monorepoRoot, env: {} });
15
+ if (!currentRead.ok) {
16
+ writeDebugNote(`not recording ${command}: ${currentRead.reason}`, env, stderr);
17
+ return;
18
+ }
19
+ if (currentRead.snapshot.treeId !== gate.snapshot.treeId) {
20
+ writeDebugNote(`not recording ${command}: the tree changed while it ran`, env, stderr);
21
+ return;
22
+ }
23
+ const entries = validatePassManifest({
24
+ checkCache: config.checkCache,
25
+ entries: hostRun.passes,
26
+ env,
27
+ monorepoRoot,
28
+ stderr,
29
+ treeId: gate.snapshot.treeId,
30
+ workspacePackageDirs: context.workspacePackageDirs,
31
+ });
32
+ const ownScope = formatManifestScope(monorepoRoot, anchorDir);
33
+ let ownReplay;
34
+ for (const entry of entries) {
35
+ if (entry.scope === ownScope && entry.command === command) {
36
+ if (entry.commandString === plan.commandString) {
37
+ ownReplay = entry.replay;
38
+ }
39
+ continue;
40
+ }
41
+ await recordLearnedPass(plan, gate, hostRun.host, entry);
42
+ }
43
+ await recordHostPass({
44
+ commandString: plan.commandString,
45
+ config,
46
+ durationMs: hostRun.durationMs,
47
+ env,
48
+ host: hostRun.host,
49
+ key: gate.key,
50
+ monorepoRoot,
51
+ ref: { anchorDir, command, monorepoRoot },
52
+ retainedOutput: hostRun.retainedOutput,
53
+ retention: composeRetention(gate.retentionKey, ownReplay, plan.runId),
54
+ snapshot: gate.snapshot,
55
+ stderr,
56
+ });
57
+ }
58
+ function composeRetention(key, replay, runId) {
59
+ return replay === undefined ? undefined : { key, replay, runId };
60
+ }
61
+ async function recordLearnedPass(plan, gate, host, entry) {
62
+ const { context, env, stderr } = plan.invocation;
63
+ const { monorepoRoot } = context;
64
+ const anchorDir = path.resolve(monorepoRoot, entry.scope);
65
+ const keyed = computeCacheKey({
66
+ anchorDir,
67
+ command: entry.command,
68
+ commandString: entry.commandString,
69
+ env,
70
+ monorepoRoot,
71
+ nmrVersion: VERSION,
72
+ snapshot: gate.snapshot,
73
+ });
74
+ if (!keyed.ok) {
75
+ writeDebugNote(`not recording ${entry.command} on ${host}: ${keyed.reason}`, env, stderr);
76
+ return;
77
+ }
78
+ await recordHostPass({
79
+ commandString: entry.commandString,
80
+ config: context.config,
81
+ durationMs: entry.durationMs,
82
+ env,
83
+ host,
84
+ key: keyed.key,
85
+ monorepoRoot,
86
+ ref: { anchorDir, command: entry.command, monorepoRoot },
87
+ retainedOutput: undefined,
88
+ retention: composeRetention(computeRetentionKey({ channels: gate.channels, env, passKey: keyed.key }), entry.replay, plan.runId),
89
+ snapshot: gate.snapshot,
90
+ stderr,
91
+ });
92
+ }