@williamthorsen/nmr 0.48.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 (111) hide show
  1. package/.meta/changelog.json +158 -0
  2. package/.readyup/kits/default.js +1527 -1460
  3. package/.readyup/manifest.json +96 -11
  4. package/CHANGELOG.md +41 -0
  5. package/README.md +36 -8
  6. package/agents/guidance/rulebooks/nmr.md +2 -2
  7. package/dist/esm/check-cache-build-output.d.ts +24 -0
  8. package/dist/esm/check-cache-build-output.js +49 -0
  9. package/dist/esm/check-cache-delegation.d.ts +27 -0
  10. package/dist/esm/check-cache-delegation.js +92 -0
  11. package/dist/esm/check-cache-entries.d.ts +109 -0
  12. package/dist/esm/check-cache-entries.js +87 -0
  13. package/dist/esm/check-cache-gate.d.ts +64 -0
  14. package/dist/esm/check-cache-gate.js +226 -0
  15. package/dist/esm/check-cache-key.d.ts +47 -0
  16. package/dist/esm/check-cache-key.js +77 -0
  17. package/dist/esm/check-cache-record.d.ts +69 -0
  18. package/dist/esm/check-cache-record.js +167 -0
  19. package/dist/esm/check-cache.d.ts +8 -176
  20. package/dist/esm/check-cache.js +9 -214
  21. package/dist/esm/child-env.d.ts +57 -0
  22. package/dist/esm/child-env.js +59 -0
  23. package/dist/esm/child-process.d.ts +55 -0
  24. package/dist/esm/child-process.js +112 -0
  25. package/dist/esm/commands/build.d.ts +0 -16
  26. package/dist/esm/commands/build.js +9 -214
  27. package/dist/esm/commands/clean.js +3 -2
  28. package/dist/esm/commands/compiler.d.ts +18 -0
  29. package/dist/esm/commands/compiler.js +49 -0
  30. package/dist/esm/commands/fmt.d.ts +0 -39
  31. package/dist/esm/commands/fmt.js +4 -81
  32. package/dist/esm/commands/format-targets.d.ts +39 -0
  33. package/dist/esm/commands/format-targets.js +83 -0
  34. package/dist/esm/commands/rewrite-specifiers.d.ts +17 -0
  35. package/dist/esm/commands/rewrite-specifiers.js +122 -0
  36. package/dist/esm/commands/tsconfig-chain.d.ts +8 -0
  37. package/dist/esm/commands/tsconfig-chain.js +63 -0
  38. package/dist/esm/config-check-cache.d.ts +17 -0
  39. package/dist/esm/config-check-cache.js +63 -0
  40. package/dist/esm/config-validation.d.ts +20 -0
  41. package/dist/esm/config-validation.js +236 -0
  42. package/dist/esm/config.js +4 -286
  43. package/dist/esm/crossing-diagnostics.d.ts +39 -0
  44. package/dist/esm/crossing-diagnostics.js +132 -0
  45. package/dist/esm/default-scripts.js +1 -1
  46. package/dist/esm/delegate-host.d.ts +43 -0
  47. package/dist/esm/delegate-host.js +178 -0
  48. package/dist/esm/delegation-target.d.ts +52 -0
  49. package/dist/esm/delegation-target.js +71 -0
  50. package/dist/esm/fan-out.d.ts +57 -0
  51. package/dist/esm/fan-out.js +146 -0
  52. package/dist/esm/glyphs.d.ts +1 -1
  53. package/dist/esm/help.js +9 -3
  54. package/dist/esm/helpers/deriveExcerpt.d.ts +9 -0
  55. package/dist/esm/helpers/deriveExcerpt.js +1 -1
  56. package/dist/esm/helpers/renderQuotedList.d.ts +2 -0
  57. package/dist/esm/helpers/renderQuotedList.js +9 -0
  58. package/dist/esm/hooks.d.ts +17 -0
  59. package/dist/esm/hooks.js +23 -0
  60. package/dist/esm/invocation-args.d.ts +40 -0
  61. package/dist/esm/invocation-args.js +178 -0
  62. package/dist/esm/invocation.d.ts +67 -0
  63. package/dist/esm/invocation.js +3 -0
  64. package/dist/esm/machine-lock.d.ts +2 -0
  65. package/dist/esm/machine-lock.js +1 -1
  66. package/dist/esm/output-channel.d.ts +23 -0
  67. package/dist/esm/output-channel.js +14 -0
  68. package/dist/esm/pass-manifest.d.ts +53 -0
  69. package/dist/esm/pass-manifest.js +89 -0
  70. package/dist/esm/presentation.d.ts +47 -0
  71. package/dist/esm/presentation.js +32 -0
  72. package/dist/esm/recording.d.ts +1 -1
  73. package/dist/esm/recording.js +2 -2
  74. package/dist/esm/remote-host-session.d.ts +62 -0
  75. package/dist/esm/remote-host-session.js +277 -0
  76. package/dist/esm/remote-probe-host.d.ts +22 -0
  77. package/dist/esm/remote-probe-host.js +50 -0
  78. package/dist/esm/remote-probe.d.ts +31 -0
  79. package/dist/esm/remote-probe.js +78 -0
  80. package/dist/esm/remote-run.d.ts +28 -73
  81. package/dist/esm/remote-run.js +103 -165
  82. package/dist/esm/remote-session.d.ts +40 -0
  83. package/dist/esm/remote-session.js +151 -0
  84. package/dist/esm/remote-snapshot.d.ts +21 -0
  85. package/dist/esm/remote-snapshot.js +80 -0
  86. package/dist/esm/replay-assembly.d.ts +2 -2
  87. package/dist/esm/replay-assembly.js +1 -1
  88. package/dist/esm/run-chain.d.ts +21 -0
  89. package/dist/esm/run-chain.js +108 -0
  90. package/dist/esm/runCli.d.ts +1 -9
  91. package/dist/esm/runCli.js +39 -1002
  92. package/dist/esm/runner.d.ts +1 -54
  93. package/dist/esm/runner.js +2 -125
  94. package/dist/esm/shell-syntax.d.ts +30 -0
  95. package/dist/esm/shell-syntax.js +120 -0
  96. package/dist/esm/steps.d.ts +0 -2
  97. package/dist/esm/steps.js +3 -119
  98. package/dist/esm/types.d.ts +5 -0
  99. package/dist/esm/user-config.d.ts +34 -0
  100. package/dist/esm/user-config.js +93 -0
  101. package/dist/esm/verbosity.d.ts +6 -0
  102. package/dist/esm/verbosity.js +7 -7
  103. package/dist/esm/verdict.d.ts +1 -1
  104. package/dist/esm/verdict.js +1 -1
  105. package/docs/check-cache.md +28 -12
  106. package/docs/delegate.md +96 -16
  107. package/docs/reporting.md +6 -1
  108. package/docs/scripts.md +2 -1
  109. package/package.json +3 -3
  110. /package/dist/esm/{prettier-default-config.d.ts → prettier-default.config.d.ts} +0 -0
  111. /package/dist/esm/{prettier-default-config.js → prettier-default.config.js} +0 -0
@@ -24,7 +24,7 @@
24
24
  "checklist": "nmr",
25
25
  "checks": [
26
26
  "@williamthorsen/nmr in devDependencies",
27
- "@williamthorsen/nmr >= 0.48.0",
27
+ "@williamthorsen/nmr >= 0.49.0",
28
28
  "pnpm-workspace.yaml exists",
29
29
  "catalogued dependencies use catalog:",
30
30
  "package.json has packageManager field",
@@ -75,6 +75,16 @@
75
75
  "kind": "module",
76
76
  "path": "../../nmr-core/dist/esm/cache-store.js"
77
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
+ },
78
88
  {
79
89
  "hash": "54384dca",
80
90
  "kind": "module",
@@ -86,7 +96,7 @@
86
96
  "path": "../../nmr-core/dist/esm/hasErrnoCode.js"
87
97
  },
88
98
  {
89
- "hash": "98509862",
99
+ "hash": "1eb2b90c",
90
100
  "kind": "module",
91
101
  "path": "../../nmr-core/dist/esm/hashWorkingTree.js"
92
102
  },
@@ -105,6 +115,11 @@
105
115
  "kind": "module",
106
116
  "path": "../../nmr-core/dist/esm/parseArgs.js"
107
117
  },
118
+ {
119
+ "hash": "7915ba49",
120
+ "kind": "module",
121
+ "path": "../../nmr-core/dist/esm/runGit.js"
122
+ },
108
123
  {
109
124
  "hash": "14547f78",
110
125
  "kind": "module",
@@ -126,12 +141,67 @@
126
141
  "path": "../../nmr-core/dist/esm/writeFileWithCheck.js"
127
142
  },
128
143
  {
129
- "hash": "ffa13ccc",
144
+ "hash": "9fd61bf4",
130
145
  "kind": "module",
131
146
  "path": "kits/default.ts"
132
147
  },
133
148
  {
134
- "hash": "47f25de7",
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",
135
205
  "kind": "inline",
136
206
  "path": "../package.json",
137
207
  "paths": [
@@ -139,12 +209,22 @@
139
209
  ]
140
210
  },
141
211
  {
142
- "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",
143
223
  "kind": "module",
144
224
  "path": "../src/config.ts"
145
225
  },
146
226
  {
147
- "hash": "0fb883a1",
227
+ "hash": "d2448f72",
148
228
  "kind": "module",
149
229
  "path": "../src/default-scripts.ts"
150
230
  },
@@ -199,7 +279,12 @@
199
279
  "path": "../src/resolver.ts"
200
280
  },
201
281
  {
202
- "hash": "ed585ab6",
282
+ "hash": "bb18a285",
283
+ "kind": "module",
284
+ "path": "../src/shell-syntax.ts"
285
+ },
286
+ {
287
+ "hash": "a1b70d65",
203
288
  "kind": "module",
204
289
  "path": "../src/steps.ts"
205
290
  },
@@ -214,7 +299,7 @@
214
299
  "path": "../src/UserError.ts"
215
300
  },
216
301
  {
217
- "hash": "857c630c",
302
+ "hash": "ecf8a873",
218
303
  "kind": "module",
219
304
  "path": "../src/verbosity.ts"
220
305
  },
@@ -231,10 +316,10 @@
231
316
  ],
232
317
  "name": "default",
233
318
  "path": "kits/default.js",
234
- "readyupVersion": "0.41.0",
319
+ "readyupVersion": "0.42.0",
235
320
  "source": "kits/default.ts",
236
- "sourceHash": "ffa13ccc",
237
- "targetHash": "6baed250"
321
+ "sourceHash": "9fd61bf4",
322
+ "targetHash": "b0939edd"
238
323
  }
239
324
  ]
240
325
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
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
+
5
46
  ## 0.48.0 — 2026-10-17
6
47
 
7
48
  ### 🎉 Features
package/README.md CHANGED
@@ -5,13 +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.48.0 (2026-10-17)
8
+ ## Release notes — v0.49.0 (2026-10-18)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - 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)
13
- - 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)
14
- - 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)
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)
15
35
  <!-- /section:release-notes -->
16
36
 
17
37
  ## Installation
@@ -195,14 +215,22 @@ The lock lives in `/tmp/nmr-<uid>/`, or in `NMR_LOCK_DIR`; a sandboxed session n
195
215
 
196
216
  ## Delegating to another machine
197
217
 
198
- `nmr --delegate <host> <command>` runs the command on another machine over SSH, against this working tree, and exits with its status. `NMR_DELEGATE=<host>` makes that the default:
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:
199
227
 
200
228
  ```console
201
- $ nmr --delegate imac2 ci
229
+ $ nmr --delegate-to imac2 ci
202
230
  ✅ my-repo: ci: passed on imac2 in 1m 48s
203
231
  ```
204
232
 
205
- [Delegating to another machine](docs/delegate.md) covers which commands can be delegated, what the host runs, how failures are reported, and setting up the host.
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.
206
234
 
207
235
  ## Shared configs and helpers
208
236
 
@@ -308,7 +336,7 @@ rdy list --from npm:@williamthorsen/nmr # what nmr publishes
308
336
  - [What nmr reports](docs/reporting.md): Verdicts, `--json`, output style, and verbosity
309
337
  - [Check-result cache](docs/check-cache.md): What is skipped, what a hit requires, and replaying, reading, and bypassing recorded runs
310
338
  - [Machine lock](docs/machine-lock.md): Which commands wait for one another, where the lock lives, and bypassing it
311
- - [Delegating to another machine](docs/delegate.md): `--delegate`, `NMR_DELEGATE`, what the host runs, and setting it up
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
312
340
  - [Dependency upgrades](docs/upgrades.md): `nmr upgrade`, the upgrade policy, `report-catalog`, `report-overrides`, and `nmr-taze`
313
341
  - [Standalone utilities](docs/utilities.md): `nmr-clean`, `nmr-compile`, `nmr-fmt`, and `ensure-prepublish-hooks`
314
342
  - [Shared Vitest config](docs/vitest.md): Test tiers, test selections, the conventions check, and customization
@@ -15,5 +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
- - A verdict reading `passed on <host>` or `failed on <host>` reports a command that ran on another machine, because `--delegate` or `NMR_DELEGATE` sent it there; the output above it is the host's. Exit 255 with `could not prepare <host>` inside a sandbox means that the sandbox refused `ssh`: Ask for network access to the host rather than treating the host as broken, or run the command here with `nmr --no-delegate <command>`.
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 which commands `--delegate` runs 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.
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.
@@ -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
+ }
@@ -0,0 +1,109 @@
1
+ import type { Writable } from 'node:stream';
2
+ /** A recorded pass: what ran, on which tree, and how long it took. */
3
+ export interface CheckCacheEntry {
4
+ /** The full cache key under which the pass was recorded; a hit is this key matching the key computed now. */
5
+ key: string;
6
+ treeHash: string;
7
+ headSha: string;
8
+ commandString: string;
9
+ nmrVersion: string;
10
+ nodeVersion: string;
11
+ durationMs: number;
12
+ /** ISO-8601 instant the pass completed. */
13
+ recordedAt: string;
14
+ /**
15
+ * The build digest that each covered package's output had when the pass was recorded. Build output is
16
+ * git-ignored, so the tree id cannot describe it; comparing these separates output built from this tree from
17
+ * output that another tree left behind.
18
+ */
19
+ buildDigests: Record<string, string>;
20
+ /** The host that earned a delegated pass, absent on a pass earned on this machine. */
21
+ host?: string;
22
+ /** What a skip replays in place of the run that it recalls, absent on a pass that retained nothing. */
23
+ retention?: Retention;
24
+ }
25
+ /** What locates one command's recorded pass at one scope, and the transcript beside it. */
26
+ export interface EntryRef {
27
+ anchorDir: string;
28
+ command: string;
29
+ monorepoRoot: string;
30
+ }
31
+ /** One command's excerpt, attributed to the scope and the command that produced it. */
32
+ export interface ReplayLine {
33
+ command: string;
34
+ excerpt: string;
35
+ scope: string;
36
+ }
37
+ /**
38
+ * What a recalled pass replays, the key certifying the excerpts describe this environment's output, and the
39
+ * run that last vouched for them.
40
+ *
41
+ * A list rather than one excerpt: a composite's entry contains its constituents' lines, and a nested one's are
42
+ * spliced into its parent's, after which the attribution cannot be re-derived from the entry that contains them.
43
+ *
44
+ * The witness admits a constituent's line into the assembly that its parent records: a run writes it when it
45
+ * records an excerpt, and restamps it when it recalls one and replays it.
46
+ */
47
+ export interface Retention {
48
+ key: string;
49
+ replay: ReplayLine[];
50
+ runId: string;
51
+ }
52
+ /**
53
+ * Restamps a recalled entry's retention with the run that is replaying it, so that an excerpt certified by this
54
+ * run can join the assembly recorded by a composite above it.
55
+ *
56
+ * A recall certifies as surely as a recording does. Because the pass key matched, the excerpt describes this
57
+ * tree; because the caller calls this only when the retention key matched too, the excerpt also describes this
58
+ * presentation environment. Everything else the entry records stands -- the instant and the duration belong to
59
+ * the run that earned the pass, and a recall must not make it read as later or longer than it was.
60
+ *
61
+ * A cache that cannot be written is not worth failing a green run over: `certifyRetention` reports that failure
62
+ * in the debug note.
63
+ */
64
+ export declare function certifyRetention(options: {
65
+ anchorDir: string;
66
+ command: string;
67
+ entry: CheckCacheEntry;
68
+ env: NodeJS.ProcessEnv;
69
+ monorepoRoot: string;
70
+ runId: string;
71
+ stderr: Writable;
72
+ }): Promise<void>;
73
+ /**
74
+ * Reads the entry recorded for one command at one scope, or `undefined` when there is none to trust.
75
+ *
76
+ * Retention is vouched for separately from the pass that contains it: an excerpt of a shape that this function
77
+ * cannot read is dropped, leaving a pass that skips cleanly and reports its verdict alone.
78
+ */
79
+ export declare function readCheckCacheEntry(options: {
80
+ anchorDir: string;
81
+ command: string;
82
+ monorepoRoot: string;
83
+ }): Promise<CheckCacheEntry | undefined>;
84
+ /**
85
+ * Reads the whole output that one recorded pass retained, or `undefined` when it retained none.
86
+ *
87
+ * Held to nothing on its own: the entry beside it says which tree the bytes describe, and a caller
88
+ * that has not matched the entry's key is reading a transcript of some other tree.
89
+ */
90
+ export declare function readTranscript(ref: EntryRef): Promise<string | undefined>;
91
+ /**
92
+ * Makes the transcript beside one entry be exactly what this pass retained, removing what an earlier pass
93
+ * left when this one retained nothing.
94
+ *
95
+ * A composite retains nothing of its own, so without the removal a leaf's transcript would remain beside an
96
+ * entry that never produced it, and `--log` would date another run's bytes by this one's instant.
97
+ */
98
+ export declare function recordTranscript(ref: EntryRef, transcript: string | undefined): Promise<void>;
99
+ /** Removes every recorded pass for a monorepo, or for a standalone package outside one. */
100
+ export declare function removeCheckCache(scopeDir: string): Promise<void>;
101
+ /** Narrows a recorded replay line, whose three fields a rendered line uses in full. */
102
+ export declare function isReplayLine(value: unknown): value is ReplayLine;
103
+ /** Records a pass, replacing whatever this command last recorded at this scope. */
104
+ export declare function writeCheckCacheEntry(options: {
105
+ anchorDir: string;
106
+ command: string;
107
+ entry: CheckCacheEntry;
108
+ monorepoRoot: string;
109
+ }): Promise<void>;