@williamthorsen/nmr 0.30.0 → 0.32.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 (73) hide show
  1. package/.readyup/kits/default.js +56 -16
  2. package/.readyup/manifest.json +34 -3
  3. package/README.md +265 -64
  4. package/agents/guidance/rulebooks/nmr.md +5 -2
  5. package/bin/nmr-report-catalog.js +11 -0
  6. package/dist/esm/UserError.d.ts +3 -0
  7. package/dist/esm/UserError.js +6 -0
  8. package/dist/esm/check-cache.d.ts +36 -1
  9. package/dist/esm/check-cache.js +91 -23
  10. package/dist/esm/cli-build.js +2 -1
  11. package/dist/esm/cli-clean.js +2 -1
  12. package/dist/esm/cli-ensure-prepublish-hooks.js +4 -24
  13. package/dist/esm/cli-report-catalog.d.ts +1 -0
  14. package/dist/esm/cli-report-catalog.js +11 -0
  15. package/dist/esm/cli-report-overrides.js +2 -1
  16. package/dist/esm/cli-taze.js +2 -1
  17. package/dist/esm/cli.js +2 -1
  18. package/dist/esm/commands/build-output.d.ts +1 -0
  19. package/dist/esm/commands/build-output.js +25 -2
  20. package/dist/esm/commands/build.d.ts +5 -1
  21. package/dist/esm/commands/build.js +22 -8
  22. package/dist/esm/commands/clean.js +12 -0
  23. package/dist/esm/commands/ensure-prepublish-hooks.d.ts +1 -0
  24. package/dist/esm/commands/ensure-prepublish-hooks.js +43 -0
  25. package/dist/esm/commands/report-catalog.d.ts +1 -0
  26. package/dist/esm/commands/report-catalog.js +40 -0
  27. package/dist/esm/commands/report-overrides.js +30 -7
  28. package/dist/esm/commands/taze.js +2 -1
  29. package/dist/esm/config.d.ts +1 -0
  30. package/dist/esm/config.js +66 -18
  31. package/dist/esm/default-scripts.d.ts +5 -1
  32. package/dist/esm/default-scripts.js +13 -10
  33. package/dist/esm/defineConfig.d.ts +1 -1
  34. package/dist/esm/help.js +3 -1
  35. package/dist/esm/helpers/clampToBytes.d.ts +2 -0
  36. package/dist/esm/helpers/clampToBytes.js +20 -0
  37. package/dist/esm/helpers/createBoundedBuffer.js +1 -4
  38. package/dist/esm/helpers/deriveExcerpt.d.ts +1 -0
  39. package/dist/esm/helpers/deriveExcerpt.js +30 -0
  40. package/dist/esm/helpers/duration.js +16 -9
  41. package/dist/esm/helpers/package-json.d.ts +11 -4
  42. package/dist/esm/helpers/package-json.js +51 -22
  43. package/dist/esm/helpers/readStringValues.d.ts +1 -0
  44. package/dist/esm/helpers/readStringValues.js +8 -0
  45. package/dist/esm/helpers/reportClosing.d.ts +1 -0
  46. package/dist/esm/helpers/reportClosing.js +3 -0
  47. package/dist/esm/helpers/transcript.d.ts +5 -0
  48. package/dist/esm/helpers/transcript.js +59 -0
  49. package/dist/esm/recording.d.ts +65 -0
  50. package/dist/esm/recording.js +96 -0
  51. package/dist/esm/replay-assembly.d.ts +9 -0
  52. package/dist/esm/replay-assembly.js +26 -0
  53. package/dist/esm/report-format.d.ts +18 -0
  54. package/dist/esm/report-format.js +25 -0
  55. package/dist/esm/reportCliFailure.d.ts +2 -0
  56. package/dist/esm/reportCliFailure.js +11 -0
  57. package/dist/esm/resolve-scripts.d.ts +1 -1
  58. package/dist/esm/resolver.d.ts +9 -1
  59. package/dist/esm/resolver.js +28 -18
  60. package/dist/esm/runCli.js +339 -74
  61. package/dist/esm/runner.d.ts +9 -3
  62. package/dist/esm/runner.js +25 -4
  63. package/dist/esm/scripts.d.ts +1 -1
  64. package/dist/esm/steps.d.ts +9 -2
  65. package/dist/esm/steps.js +117 -4
  66. package/dist/esm/types.d.ts +11 -2
  67. package/dist/esm/verbosity.d.ts +15 -4
  68. package/dist/esm/verbosity.js +32 -12
  69. package/dist/esm/verdict.d.ts +29 -0
  70. package/dist/esm/verdict.js +150 -0
  71. package/dist/esm/workspace.d.ts +2 -0
  72. package/dist/esm/workspace.js +20 -3
  73. package/package.json +14 -5
package/README.md CHANGED
@@ -3,25 +3,31 @@
3
3
  Context-aware script runner for pnpm monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
4
4
 
5
5
  <!-- section:release-notes -->
6
- ## Release notes — v0.30.0 (2026-08-10)
6
+ ## Release notes — v0.32.0 (2026-08-17)
7
7
 
8
8
  ### 🎉 Features
9
9
 
10
- - 🚨 **Breaking:** Resolve a command to a step list and reject a script element carrying shell syntax (#648)
10
+ - Carry the test-tier convention in the ambient rulebook (#689)
11
11
 
12
- A script defined in `.config/nmr.config.ts` as a list of nmr commands now holds one command name per entry, optionally preceded by nmr's own flags. An entry that carries a quoted argument or any other shell syntax previously ran as written and is now rejected when the config loads. Before upgrading, a repo relying on such an entry should define that command as a script of its own and reference it by name from the list.
12
+ Adds the test-tier convention to `nmr.md`, the ambient rulebook `codeassembly sync` deploys to each consuming repo's agent guidance. The guidance describes the `<subject>[.<aspect>].<tier>.test.ts` grammar and the four tiers, and warns that tests outside a `__tests__` directory are ignored. Tests not explicitly attributed to a tier are flagged by the ReadyUp kit bundled with `nmr`.
13
+
14
+ The previous guidance is removed from `AGENTS.md`.
13
15
 
14
- - 🚨 **Breaking:** Sequence a command's steps in nmr rather than in a shell (#649)
16
+ - Close each bin's output with a statement of what the run came to (#691)
15
17
 
16
- When a run in quiet mode fails, it now shows only the output of the command that failed instead of everything the run had produced up to that point. Quiet mode can now be set for a whole shell session with the `NMR_COMMAND_VERBOSITY` environment variable, which accepts `full` or `quiet`; `-q` still takes precedence for a single run, and an unrecognized value is reported as an error. Interrupting a run now stops the commands that would have followed it.
18
+ Five nmr bins now end their output with a statement of the run's final result, rather than on whichever line the run happened to reach last, so the last line a reader sees carries the finding.
17
19
 
18
- Breaking: a `devBin` entry that maps `nmr` itself no longer takes effect inside a command that runs other nmr commands. Mapping any other tool (the documented use) is unaffected.
20
+ Separately, `ensure-prepublish-hooks` prints its whole report, including failure messages, on stdout, to avoid splitting the report across two streams. Failure still results in a non-zero exit code.
19
21
 
20
- - 🚨 **Breaking:** Express the seven root defaults as steps (#650)
22
+ - Reject a dead pnpm field in nmr's ReadyUp kit (#692)
21
23
 
22
- Running `nmr build`, `nmr test`, `nmr test:all`, `nmr test:coverage`, `nmr test:tool`, `nmr test:unit`, or `nmr typecheck` from a monorepo root now passes over a package that does not define the command, instead of failing there. The first run of `test`, `test:coverage`, `test:tool`, `test:unit`, or `typecheck` after the upgrade no longer skips as already passed; skipping resumes on the run after.
24
+ nmr's ReadyUp kit now checks for the presence of a `pnpm` field in any `package.json`. pnpm 11 no longer reads any key from that field, but taze (the engine underlying `nmr upgrade`) reads `pnpm.overrides`. To avoid confusion, the kit flags this as an error.
23
25
 
24
- `nmr` now also warns when a command it is about to run invokes `nmr` again through a shell, where a failure reports the whole inner run's output instead of the failing command's. The warning catches only a direct `nmr` call, so a command that reaches `nmr` through another program goes unreported.
26
+ ### 🐛 Bug fixes
27
+
28
+ - Fold the running nmr's fingerprint into the build cache key (#693)
29
+
30
+ Fixes the issue that build output could be stale, but not reported to be stale, after an edit to `nmr-compile`'s own emit logic. The build cache key now folds a fingerprint of the `nmr` running the build alongside the TypeScript version it already carried. Any build keys generated against an `nmr` with a different fingerprint will be invalidated. Repos consuming `nmr` get the same behavior.
25
31
  <!-- /section:release-notes -->
26
32
 
27
33
  ## Installation
@@ -111,7 +117,7 @@ If no per-package override exists, the highest-tier value that is set wins. Set
111
117
 
112
118
  ### Script values
113
119
 
114
- Script values can be `string` or `string[]`. Arrays expand to chained `nmr` sub-invocations:
120
+ A script value is a string or an array of steps. Only the built-in defaults and `.config/nmr.config.ts` carry an array; a `package.json` script must be a string, and any other value is rejected when the scripts are read. Arrays expand to chained `nmr` sub-invocations:
115
121
 
116
122
  ```ts
117
123
  // "fix": ["lint", "fmt"]
@@ -120,11 +126,28 @@ Script values can be `string` or `string[]`. Arrays expand to chained `nmr` sub-
120
126
 
121
127
  Each array element is a command name, optionally preceded by nmr's own flags, split on whitespace — `-R test` runs the element in every package, the way `nmr -R test` does. An element carrying a quoted argument or shell syntax is rejected when the config loads: name that command as a script of its own and reference the name.
122
128
 
123
- Passthrough arguments attach to the final step alone: `nmr fix --dry-run` runs `nmr lint`, then `nmr fmt --dry-run`.
129
+ #### Where trailing arguments go
130
+
131
+ Trailing arguments reach every step of a composite, so `nmr fix --dry-run` runs `nmr lint --dry-run`, then `nmr fmt --dry-run`. A step whose work the arguments cannot narrow declines them by taking the long form of an element, and runs unnarrowed:
132
+
133
+ ```ts
134
+ // "ci": [{ run: "build", declinesArgs: true }, "check:strict"]
135
+ // nmr ci src/foo.ts -> nmr build && nmr check:strict src/foo.ts
136
+ ```
137
+
138
+ `{ run }` is the bare string element with room for the declaration; `declinesArgs` defaults to `false`, and an element that accepts is written as the string. Decline where the step is a prerequisite the narrowed steps run against, or where the tool it reaches would be misled — `tsgo --noEmit foo.ts` abandons the tsconfig, which is why every default `typecheck` step declines. A leaf tool that merely ignores what it is handed is not a reason to decline: nmr forwards, and the tool decides.
139
+
140
+ Where no step of a command accepts them, nmr rejects the invocation and runs nothing, rather than running the whole command unnarrowed. That is what `nmr typecheck src/foo.ts` gets at the root, whose two steps both decline.
141
+
142
+ Two kinds of step are not declarable. A string script is one command handed to a leaf tool, so it always receives the arguments — which is why `nmr typecheck src/foo.ts` inside a package still reaches `tsgo`, where the root rejects it. A `:pre` or `:post` hook never receives them, the arguments having been placed before the hooks wrap the chain.
124
143
 
125
- A string value is spawned through a shell as one command, so an `nmr` invocation inside it is invisible to the process above: a quiet failure surrenders the whole nested subtree rather than the failing step, and a loud run relays the nested tree through a forwarding pipe. nmr writes one line to stderr, in every verbosity, when a command resolves to a step whose first token is `nmr`.
144
+ An invocation carrying trailing arguments also serves no part of its work from the [check-result cache](#check-result-cache), the steps that decline them included.
126
145
 
127
- That line reports a boundary rather than enforcing a rule, and it is partial: a step reaching nmr through another program, such as `pnpm --recursive exec nmr build`, goes unreported. Only the built-in defaults and `.config/nmr.config.ts` carry arrays, so a `package.json` script embedding nmr has nowhere to move but up a tier.
146
+ A string value is spawned through a shell as one command, so an `nmr` invocation inside it is invisible to the process above: a quiet failure surrenders the whole nested subtree rather than the failing step, and a loud run relays the nested tree through a forwarding pipe. nmr writes one line to stderr, in every verbosity, when a command resolves to a step reaching nmr through a shell.
147
+
148
+ The line names the declaration it is an edit to — the file, the field, and the key — and its remedy follows from that site. A `.config/nmr.config.ts` entry keeps its nmr steps as a step list and moves anything else to a `<command>:pre` or `<command>:post` script: a step-list element is composed into `nmr <element>`, so it can name only an nmr command. A `package.json` entry has to go, since `package.json` holds no step list: delete it outright when it restates what nmr already runs for that command, and move the steps it adds to a `<command>:pre` or `<command>:post` script when they are the package's own. Where the registry defines no such command, both apply: name the command in `.config/nmr.config.ts`, and put the package-specific steps in a hook. The pair is named rather than `:post` alone because an entry's extra steps may run on either side of the chain: `rdy verify && nmr compile` puts them first.
149
+
150
+ nmr recognizes `nmr` in command position: at the start of a step, after an unquoted `&&`, `||`, `;`, or `|`, and behind `npx`, `bunx`, or an `exec`, `dlx`, or `x` subcommand of `npm`, `pnpm`, `yarn`, or `bun`. The line reports a boundary rather than enforcing a rule, and it stays partial in stated ways: a value-taking flag standing immediately before the program name hides it (`npx -p foo nmr`), a launcher outside that set goes unreported, and a `devBin` substitution that introduces `nmr` itself is not reported, the line being an edit to a declaration rather than to a substitution.
128
151
 
129
152
  ## Configuration
130
153
 
@@ -149,11 +172,11 @@ export default defineConfig({
149
172
  | ------------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------------------- |
150
173
  | `build` | `{ extraIgnorePatterns?: string[] }` | Patterns added to the build's ignore set (package config only) |
151
174
  | `checkCache` | `{ enabled?: boolean; extraCommands?: string[]; excludeCommands?: string[] }` | Which commands the [check-result cache](#check-result-cache) may skip |
152
- | `workspaceScripts` | `Record<string, string \| string[]>` | Scripts added or overridden in the workspace registry (tier 2) |
153
- | `rootScripts` | `Record<string, string \| string[]>` | Scripts added or overridden in the root registry (tier 2) |
175
+ | `workspaceScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the workspace registry (tier 2) |
176
+ | `rootScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the root registry (tier 2) |
154
177
  | `devBin` | `Record<string, string>` | Map binary names to source-repo replacement commands |
155
178
 
156
- All fields are optional. `workspaceScripts` and `rootScripts` values follow the same `string | string[]` convention described in [script values](#script-values).
179
+ All fields are optional. A `ScriptValue` is a command string or an array of steps, each a command string or `{ run, declinesArgs? }`; both are described in [script values](#script-values), and both types are exported from `@williamthorsen/nmr/config`.
157
180
 
158
181
  Each field belongs to exactly one tier, and a config loads only the fields its own tier honors: `build` in a package config, the other four in the monorepo-root config. Declaring a field at the wrong tier fails with a message naming it and where it goes, as does a key nmr does not recognize at all — a typo cannot degrade into a setting that silently applies nowhere.
159
182
 
@@ -230,6 +253,64 @@ Attach a step to one package's hook:
230
253
 
231
254
  The second example calls the bin directly: `report-overrides` is a root-registry command, so `nmr report-overrides` from inside a package fails with `Unknown command`, while the bin runs from either scope.
232
255
 
256
+ ## What nmr reports
257
+
258
+ Every command nmr runs reports one line naming the scope it ran at, the command, the outcome, and the timing:
259
+
260
+ ```console
261
+ ✅ nmr-core: test: passed in 12.4s
262
+ ❌ nmr: build: failed in 1.2s (exit 1)
263
+ ⏭️ nmr-core: test: passed 4m ago on this tree, saved ~12s
264
+ ⛔ nmr-core: lint: skipped, the override is empty
265
+ ```
266
+
267
+ A recalled pass also carries an excerpt of the run it recalls, where that run left one; see [Replaying a skipped run's output](#replaying-a-skipped-runs-output).
268
+
269
+ The scope is the directory the command's registry belongs to: a package's own directory, or the monorepo root. Durations and ages truncate and carry at most two units, so a check that passed 90 seconds ago reports `1m 30s ago` and never `2m ago`.
270
+
271
+ Verdicts nest. A composite reports, and so does every command it expands into; a `:pre` or `:post` hook reports nothing of its own, since the command it wraps already reports for the chain, though a hook that delegates to a named command reports under that name. A `-R` or `-F` invocation reports nothing either, every scope it fans out to reporting instead. A cold repo-wide `nmr ci` in this package's own monorepo spends 26 lines on a run that prints roughly 250.
272
+
273
+ A verdict is one write of at most 512 bytes, the smallest `PIPE_BUF` POSIX permits, so packages running concurrently under `pnpm --recursive` cannot interleave within a line. A line longer than that is cut and marked with `…`; nothing nmr reports today comes near it.
274
+
275
+ **Verdicts print in every verbosity.** `-q` withholds the output of the commands nmr runs, never nmr's own words: a passing quiet run reports its verdicts and nothing else. A command whose override resolved to `""` or `":"` reports the skip rather than exiting 0 in silence, which is what separates it from a command that passed. An `NMR_RUN_IF_PRESENT` miss reports nothing, having no command to report on.
276
+
277
+ ### Reporting for a machine
278
+
279
+ `--json` renders each verdict as a JSON object instead of a line of prose — one object per line, newline-delimited, with no enclosing array and no separators between them. A consumer parses each line as it arrives, and a run cut off partway still leaves every line it already emitted parseable, which an enclosing array would not.
280
+
281
+ ```console
282
+ $ nmr --json check
283
+ {"command":"typecheck","scope":"nmr-core","outcome":"passed","durationMs":255}
284
+ {"command":"fmt:check","scope":"nmr-core","outcome":"recalled","ageMs":240000,"savedMs":3888,"replay":[{"command":"fmt:check","excerpt":"Checking formatting... All matched files use Prettier code style!","scope":"nmr-core"}]}
285
+ {"command":"check","scope":"nmr-core","outcome":"passed","durationMs":31204}
286
+ ```
287
+
288
+ Every object carries `command`, `scope`, and `outcome`, and then what that outcome carries and no other does:
289
+
290
+ | `outcome` | Also carries |
291
+ | ---------- | ---------------------------------------------------------------------------------------------------------- |
292
+ | `passed` | `durationMs` |
293
+ | `failed` | `durationMs`, `exitCode` |
294
+ | `recalled` | `ageMs`, `savedMs`, and `replay` where the skip has excerpts to [replay](#replaying-a-skipped-runs-output) |
295
+ | `no-op` | `reason`, either `empty-override` or `noop-override` |
296
+
297
+ Both renderings are produced from one record, so neither reports what the other does not. Where the prose line makes a presentation decision the record does not — a saving too small to be worth a clause, say — the object carries the fact and leaves the decision to whoever reads it.
298
+
299
+ **An object is one write of at most 512 bytes**, the [same ceiling](#what-nmr-reports) a prose line is held to, so packages running concurrently under `pnpm --recursive` cannot interleave within one. Cuts land inside the record's text rather than across its structure, so the line still parses, and a record that overruns is fitted by rungs — each shedding what a reader can better spare than the one below it:
300
+
301
+ 1. **The excerpts are shortened toward one another**, so an assembly's constituents come down together rather than the first ones being emptied to leave the last whole. No excerpt is cut below `…`, which keeps a cut one distinguishable from a run that recorded nothing.
302
+ 2. **The excerpts go**, leaving the `scope` and `command` that name each constituent. An entry carrying no `excerpt` key is one whose text did not fit.
303
+ 3. **The trailing constituents go**, as the prose line drops its own tail, and the `replay` array itself once none fit.
304
+ 4. **The `scope` and `command` are cut**, each marked. This is the last resort and the reason for it is the ceiling itself: a line that overruns can be split across a pipe and corrupt the records of every scope sharing it, where a marked cut costs only its own.
305
+
306
+ A package-scoped `check` carries four constituents and fits uncut. A four-package root `check` assembles twelve — `typecheck` and `test` each fan out to every package, and a nested composite's list is spliced in flat — which is more than the ceiling can name: it reaches rung 3, keeping eight and dropping the trailing four. Rung 4 needs a scope and command running to hundreds of bytes between them.
307
+
308
+ **The mode implies quiet.** The commands' own output is withheld, so stdout carries the objects alone — no prose verdict, and no override notice. `NMR_COMMAND_VERBOSITY=full` does not opt back out. A failure still surrenders the failing command's output, on stderr, as under any quiet run. Both modes leave a command on the same channels, so a machine-readable run and a quiet one share a [retention key](#replaying-a-skipped-runs-output): each replays what the other recorded.
309
+
310
+ The format is nmr's own reporting and not a mode the commands it runs are told about, so it is out of the [pass key](#what-the-key-is-made-of) and the retention key alike. Selecting it neither invalidates a recorded pass nor costs a replay.
311
+
312
+ `--log`, `--help`, and `--version` are unaffected: none of them reports a verdict, and what `--log` prints is a tool's own transcript, which has no rendering as a record.
313
+
233
314
  ## Check-result cache
234
315
 
235
316
  A check that passed on a working tree passes again on the same working tree. nmr records that, and the next run of the same command on an unchanged tree exits 0 without doing the work:
@@ -239,10 +320,10 @@ $ nmr ci
239
320
  # ... four minutes of build, typecheck, lint, and coverage ...
240
321
 
241
322
  $ nmr ci
242
- ⏭️ ci: passed 2m ago on this tree (🚀 saved ~4m).
323
+ ⏭️ node-monorepo-tools: ci: passed 2m ago on this tree, saved ~4m
243
324
  ```
244
325
 
245
- Composite commands expand into child `nmr` processes, so one green `nmr ci` records a pass for every cacheable command in the chain, at every scope it ran at. A later `nmr check`, `nmr typecheck`, or `nmr -F core test` on the same tree skips too. The skip line is suppressed by `-q`.
326
+ Composite commands expand into child `nmr` processes, so one green `nmr ci` records a pass for every cacheable command in the chain, at every scope it ran at. A later `nmr check`, `nmr typecheck`, or `nmr -F core test` on the same tree skips too. The skip is [reported](#what-nmr-reports) in every verbosity.
246
327
 
247
328
  The cache is a working-tree cache, not a build cache: it answers "has this exact tree already passed this check?" and nothing else. It is also local to one checkout (the install fingerprint in its key is machine-specific), so it saves repeated work in a working copy rather than sharing results across machines or with CI.
248
329
 
@@ -302,10 +383,79 @@ The gate never wrongly skips. Where it cannot be sure, it does nothing and the c
302
383
  - When a changed path cannot be read, or is neither a file nor a symlink (an untracked nested repository, for instance).
303
384
  - When there is no pnpm install to fingerprint.
304
385
  - Under a `devBin` substitution, which runs a binary built from somewhere the hash does not describe.
305
- - For any invocation carrying arguments after the command name.
386
+ - For any invocation carrying arguments after the command name, and for every step below it: the arguments narrow what runs, and a step served from a recorded pass is a step that did not.
306
387
 
307
388
  `NMR_DEBUG=1` reports why a run did not skip and why the gate stood aside.
308
389
 
390
+ ### Replaying a skipped run's output
391
+
392
+ A skip reports an excerpt of the run it recalls, marked as a recording rather than as this run's output:
393
+
394
+ ```console
395
+ $ nmr test
396
+ # ... the suite runs ...
397
+
398
+ $ nmr test
399
+ ⏭️ nmr: test: passed 2m ago on this tree, saved ~12s — replayed: Test Files 6 passed (6) Tests 41 passed (41)
400
+ ```
401
+
402
+ The excerpt is the last blank-line-delimited block of what the command wrote, which is the closing statement a tool separates from its progress output. It is flattened onto one line, with escape sequences stripped, table rules dropped, and a redrawn progress line reduced to what a reader was left looking at. Nothing is parsed and no figure is computed: nmr replays bytes it recorded. A block wider than a few kilobytes is cut and marked with `…`, so a command whose closing statement is one long line cannot carry its whole output into the cache.
403
+
404
+ The excerpt and the whole transcript it is drawn from are written by the same operation that records the pass, so a pass declined because the tree moved or build output changed leaves neither behind, and `nmr clean` clears both along with the passes they belong to.
405
+
406
+ **Capture happens at the command, and only where nmr can see its output.** A command whose stdout or stderr is a terminal writes where nmr never reads, so an interactive run retains nothing and its progress display is untouched; a piped or redirected run, and any run under `-q`, retains both streams. A composite hands its descriptors to the child `nmr` processes below it and captures nothing of its own, and a `:pre` or `:post` hook contributes nothing to the command it wraps: an excerpt is the command's own output or none.
407
+
408
+ **A composite replays its constituents' excerpts.** Capturing nothing of its own, a composite assembles what its constituents recorded, each line attributed to the scope and command that produced it:
409
+
410
+ ```console
411
+ $ nmr check
412
+ ⏭️ nmr-core: check: passed 3m ago on this tree, saved ~48s — replayed: nmr-core: fmt:check: All matched files use Prettier code style!; nmr-core: test: Test Files 6 passed (6) Tests 41 passed (41)
413
+ ```
414
+
415
+ The assembly is flat: a constituent that is itself a composite contributes its own leaves' lines, so a skipped `ci` replays a package's `test:coverage` rather than one opaque `check:strict` line. A delegate expands into the scopes it fans out to, and a constituent that recorded no excerpt is absent rather than inferred: `typecheck` and `lint:check` print nothing on success and contribute nothing above, and a command outside the [cacheable set](#what-is-cached) records nothing at all, so `ci` replays what `check:strict` earned and nothing for `build`.
416
+
417
+ It is assembled when the composite records its pass rather than when it skips, so every excerpt in it was certified during that one run, on that one tree. `NMR_RUN_ID` is what makes that provable; see [reserved environment variables](#reserved-environment-variables).
418
+
419
+ **A skip certifies what it replays.** Recalling a pass proves the excerpt describes this tree, and a matching retention key proves it describes this presentation environment, so a skip restamps the recalled entry with the run replaying it. A constituent already warm when a composite ran therefore still contributes its line: `nmr check` after `nmr test` replays the test summary. The restamp touches nothing else -- the instant and the duration belong to the run that earned the pass.
420
+
421
+ **A replay is held to more than the pass is.** Retention carries its own key: the [pass key](#what-the-key-is-made-of) folded with the channel each output stream ran on and with `CI`, `COLUMNS`, `FORCE_COLOR`, `NO_COLOR`, and `TERM`. A recording made under a different one is recalled as a pass all the same; its excerpt is simply not replayed, and the verdict prints alone, as it does for a pass that retained nothing.
422
+
423
+ The whole line, excerpt included, is held to the same [512-byte ceiling](#what-nmr-reports) every verdict is, so one write still carries it whole under concurrent fan-out. An assembly wide enough to overrun it is cut and marked with `…` rather than spread over several lines.
424
+
425
+ ### Reading a retained run
426
+
427
+ The excerpt is a line; the run behind it is kept whole. `nmr --log <command>` prints it, led by a header dating what follows:
428
+
429
+ ```console
430
+ $ nmr --log test
431
+ 📼 nmr: test — recorded 2026-08-12T15:04:05.412Z (12m ago), ran in 12.4s
432
+ $ pnpm exec vitest --project unit --project tool
433
+
434
+ ✓ src/__tests__/resolver.unit.test.ts (18 tests) 12ms
435
+
436
+ Test Files 6 passed (6)
437
+ Tests 41 passed (41)
438
+ ```
439
+
440
+ The header is what presents the body as a recording rather than as this invocation's output. The command string is the whole chain, hooks included, so it names what earned the pass and not merely what was typed. A flag rather than a subcommand, since `log` would collide with the script registry.
441
+
442
+ **Reading a recording runs nothing.** No hook runs, no verdict prints, and nothing is recorded or restamped. `--no-cache` governs running rather than reading and is ignored.
443
+
444
+ **A recording is held to the tree, not to the terminal.** `--log` prints exactly what a skip would have recalled: the [pass key](#what-the-key-is-made-of) has to match. The [retention key](#replaying-a-skipped-runs-output) is not consulted, so a recording made under a different terminal width, or through a pipe, still prints -- a reader at a terminal being shown what a piped run wrote is the case the flag exists for.
445
+
446
+ **A composite prints its assembly.** Retaining nothing of its own, a composite prints the excerpts its constituents recorded, one attributed line each. The transcript of any one of them is that constituent's own `--log` to print.
447
+
448
+ **Nothing to show is said rather than left blank.** A refusal names which it is -- the command is outside the [cacheable set](#what-is-cached), the gate is [standing aside](#when-the-gate-stands-aside), nothing has recorded a pass, the last pass was recorded under something this run does not share, or the pass retained no output, having printed none or written to a terminal -- and exits non-zero, so a caller can tell an empty `stdout` from a recording. A mismatch names the ingredient that moved, the tree or the chain or a version, so a reader whose `git status` is clean is not sent looking there.
449
+
450
+ ```console
451
+ $ nmr --log test
452
+ 📭 nmr: test: no recording; the last pass was 3m ago, on a tree this is not
453
+ ```
454
+
455
+ **`--log` reaches the scopes `-F` and `-R` select.** The flag rides the delegate, so each scope prints its own recording. There a scope with nothing to show reports its gap and exits 0: partial coverage is a survey's normal shape, and failing on the first gap would hide every scope that had something.
456
+
457
+ **Retention has a ceiling of 256 KiB per recorded pass**, distinct from the 512 bytes a verdict line is held to. A transcript overrunning it keeps 128 KiB from each end, because a run's opening and its closing statement each carry what the other does not, and the drop is marked with a line naming the bytes it stands for rather than being cut silently.
458
+
309
459
  ### Bypassing and clearing
310
460
 
311
461
  Reach for these in order; the first is almost always the right one.
@@ -323,10 +473,29 @@ Reach for these in order; the first is almost always the right one.
323
473
 
324
474
  `NMR_TREE_SNAPSHOT` is nmr's own: it carries one observation of the tree from a top-level invocation down to the processes it spawns, so a chain hashes the tree once rather than at every link. nmr trusts an inherited value only while `HEAD` still stands where it did when the observation was taken, which bounds a process that outlives the run that spawned it. That bound does not extend to a tree edited without committing, so **a process that survives its run and later invokes nmr should clear `NMR_TREE_SNAPSHOT`** — a test suite that shells out to `nmr` is the case worth checking.
325
475
 
326
- `NMR_COMMAND_VERBOSITY` is nmr's own as well, and carries how loudly a run reports the output of the commands it runs. Its values are `full` and `quiet`; any other non-empty value is reported and exits 1, rather than falling back to a mode nobody chose, and an empty value reads as unset. Each process in a chain suppresses the output of the command it runs rather than of everything below it, so a failure still surrenders the failing command's output. nmr's own messages — the skip line, the override notice, and the `""`/`":"` skip notices — keep the suppression `-q` has always given them.
476
+ `NMR_RUN_ID` is nmr's own too: it carries one run's identity from a top-level invocation down to the processes it spawns, so the excerpts recorded at every scope during one run are recognizable as that run's, and a composite can assemble from them. Where `NMR_TREE_SNAPSHOT` is bounded by `HEAD`, this is bounded by the tree hash each entry records, so a process that outlives its run and carries the identity into a later one contributes nothing to that run's assemblies.
477
+
478
+ `NMR_COMMAND_VERBOSITY` is nmr's own as well, and carries how loudly a run reports the output of the commands it runs. Its values are `full` and `quiet`; any other non-empty value is reported and exits 1, rather than falling back to a mode nobody chose, and an empty value reads as unset. Each process in a chain suppresses the output of the command it runs rather than of everything below it, so a failure still surrenders the failing command's output. It governs those commands' output alone: nmr's [verdicts](#what-nmr-reports) print in every verbosity, and the override notice is the one message a quiet run withholds.
327
479
 
328
480
  `-q` sets it for the run and outranks an inherited value. Both values are spelled out so either direction is expressible: export `NMR_COMMAND_VERBOSITY=quiet` for a quiet shell, and set `full` on one invocation to opt back out.
329
481
 
482
+ `NMR_REPORT_FORMAT` is nmr's own as well, and carries how nmr renders its own verdicts. Its values are `text` and `json`; any other non-empty value is reported and exits 1, and an empty value reads as unset. `--json` sets it for the run and outranks an inherited value, and both values are spelled out so either direction is expressible: export `NMR_REPORT_FORMAT=json` for a shell whose consumer parses, and set `text` on one invocation to opt back out. Two sources, then, where a verbosity has four: no repo-level default and no harness detection, since detection would hand every agent JSON in place of the prose the [shipped guidance](#agent-guidance) teaches. See [reporting for a machine](#reporting-for-a-machine) for what the objects carry.
483
+
484
+ #### Where a verbosity comes from
485
+
486
+ Four sources, in precedence order. Each is read once, at the top of a chain: the resolved value travels down as `NMR_COMMAND_VERBOSITY`, so a nested process reads a decision already made rather than making its own.
487
+
488
+ | Source | Set it when |
489
+ | ----------------------------------------------------- | ---------------------------------------------------------- |
490
+ | `-q` | One invocation should be quiet. |
491
+ | `NMR_COMMAND_VERBOSITY` | A shell, or a harness that launches one, should be quiet. |
492
+ | `output.commandVerbosity` in the monorepo-root config | A repo should be quiet for everyone working in it. |
493
+ | An agent harness nmr recognizes in the environment | Nothing above applies and an agent is running the command. |
494
+
495
+ Falling off the bottom leaves the run `full`, which is what an unrecognized harness gets: detection adds quiet where it fires and changes nothing where it does not.
496
+
497
+ Detection reads a set of environment-variable names and fires on any one of them holding a non-empty value. The shipped set is `CLAUDECODE`, `ROVODEV_CLI`, and `ROVO_CLI`. **Treat that set as perishable.** The names belong to ecosystems nmr does not control and that rename, and a rename fails silently here, since the symptom is a return to loud output rather than an error. `output.extraAgentEnvVars` is how a repo adds a name — a harness nmr has never heard of, or one whose marker was renamed after the installed version shipped — without waiting for a release. A repo that wants no detection at all configures `output.commandVerbosity: 'full'`, which outranks it.
498
+
330
499
  ### Configuring
331
500
 
332
501
  ```ts
@@ -342,18 +511,35 @@ export default defineConfig({
342
511
 
343
512
  `extraCommands` extends the default set rather than replacing it, so naming one command cannot silently drop the rest; `excludeCommands` is applied afterwards, so a name in both is excluded. `enabled: false` turns the gate off entirely. The key is `checkCache`, in the monorepo-root config.
344
513
 
514
+ ```ts
515
+ export default defineConfig({
516
+ output: {
517
+ // The verbosity a run takes when neither `-q` nor the environment named one.
518
+ commandVerbosity: 'quiet',
519
+ // Markers of a harness the shipped set does not name, added to it rather than replacing it.
520
+ extraAgentEnvVars: ['MY_HARNESS'],
521
+ },
522
+ });
523
+ ```
524
+
525
+ `output` is a monorepo-root key, and both of its fields feed the [verbosity ladder](#where-a-verbosity-comes-from).
526
+
345
527
  ## Dependency upgrades
346
528
 
347
529
  `nmr upgrade` reports the dependency upgrades available to your repo. What it covers depends on where you run it:
348
530
 
349
- | Invocation | Covers |
350
- | ---------------------------- | ------------------------------------------- |
351
- | `nmr upgrade` (from root) | the root `package.json` and every workspace |
352
- | `nmr upgrade` (in a package) | that package |
353
- | `nmr -F <package> upgrade` | that package, from anywhere |
354
- | `nmr root:upgrade` | the root `package.json` alone |
531
+ | Invocation | Covers | Catalogs |
532
+ | ---------------------------- | ------------------------------------------- | -------- |
533
+ | `nmr upgrade` (from root) | the root `package.json` and every workspace | yes |
534
+ | `nmr upgrade` (in a package) | that package | no |
535
+ | `nmr -F <package> upgrade` | that package, from anywhere | no |
536
+ | `nmr root:upgrade` | the root `package.json` | yes |
537
+
538
+ A [pnpm catalog](https://pnpm.io/catalogs) is declared in `pnpm-workspace.yaml`, and the upgrade tool reads that file only when it sits at the working directory, irrespective of `--recursive`, which is why `root:upgrade` covers every catalog too. A package-scoped invocation therefore cannot see a dependency the catalog declares, and a package whose dependencies are all catalogued would otherwise report itself up to date. It runs [`report-catalog`](#report-catalog) first instead, which names each catalogued dependency and the root a covering pass runs from.
355
539
 
356
- `nmr upgrade` from the root precedes its report with any active `pnpm.overrides` declared in the root `package.json`; `nmr root:upgrade` does not. An override pins a transitive dependency, so an upgrade masked by one never appears in the report.
540
+ Both root-scoped invocations precede their report with any active override, which pnpm declares in `pnpm-workspace.yaml`. An override pins a transitive dependency, so an upgrade masked by one never appears in the report.
541
+
542
+ A `pnpm.overrides` block left in the root `package.json` fails the command instead. pnpm reads no setting from that field as of pnpm 11, so the block pins nothing, while the upgrade tool keeps its own list of dependency fields and goes on rewriting the versions in it under `--write` — a block that looks maintained and governs nothing. Failing ahead of the tool is what keeps that write from happening, on `root:upgrade` as much as on `upgrade`. nmr supports pnpm 11 and later.
357
543
 
358
544
  The upgrade tool ([taze](https://github.com/antfu-collective/taze)) arrives with nmr, so your repo declares no dependency on it. Everything after the command name is passed through, including the range mode:
359
545
 
@@ -390,29 +576,30 @@ These scripts are available out of the box. Repo-wide config (tier 2) and per-pa
390
576
 
391
577
  ### Workspace scripts
392
578
 
393
- | Command | Runs |
394
- | --------------- | ----------------------------------------------------------- |
395
- | `build` | `compile` |
396
- | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
397
- | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
398
- | `clean` | `nmr-clean` |
399
- | `compile` | `nmr-compile` |
400
- | `fix` | `lint`, `fmt` |
401
- | `fix:check` | `fmt:check`, `lint:check` |
402
- | `fmt` | `nmr-fmt --write` |
403
- | `fmt:check` | `nmr-fmt --check` |
404
- | `lint` | `eslint --fix .` |
405
- | `lint:check` | `eslint .` |
406
- | `lint:strict` | `strict-lint` |
407
- | `test` | `pnpm exec vitest --project unit --project tool` |
408
- | `test:all` | `pnpm exec vitest` |
409
- | `test:coverage` | `pnpm exec vitest --project unit --project tool --coverage` |
410
- | `test:tool` | `pnpm exec vitest --project tool` |
411
- | `test:unit` | `pnpm exec vitest --project unit` |
412
- | `test:watch` | `pnpm exec vitest --project unit --project tool --watch` |
413
- | `typecheck` | `tsgo --noEmit` |
414
- | `upgrade` | `nmr-taze --include-locked` |
415
- | `view-coverage` | `open coverage/index.html` |
579
+ | Command | Runs |
580
+ | ---------------- | ----------------------------------------------------------- |
581
+ | `build` | `compile` |
582
+ | `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
583
+ | `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
584
+ | `clean` | `nmr-clean` |
585
+ | `compile` | `nmr-compile` |
586
+ | `fix` | `lint`, `fmt` |
587
+ | `fix:check` | `fmt:check`, `lint:check` |
588
+ | `fmt` | `nmr-fmt --write` |
589
+ | `fmt:check` | `nmr-fmt --check` |
590
+ | `lint` | `eslint --fix .` |
591
+ | `lint:check` | `eslint .` |
592
+ | `lint:strict` | `strict-lint` |
593
+ | `report-catalog` | `nmr-report-catalog` |
594
+ | `test` | `pnpm exec vitest --project unit --project tool` |
595
+ | `test:all` | `pnpm exec vitest` |
596
+ | `test:coverage` | `pnpm exec vitest --project unit --project tool --coverage` |
597
+ | `test:tool` | `pnpm exec vitest --project tool` |
598
+ | `test:unit` | `pnpm exec vitest --project unit` |
599
+ | `test:watch` | `pnpm exec vitest --project unit --project tool --watch` |
600
+ | `typecheck` | `tsgo --noEmit` |
601
+ | `upgrade` | `nmr-report-catalog && nmr-taze --include-locked` |
602
+ | `view-coverage` | `open coverage/index.html` |
416
603
 
417
604
  `fmt` and `fmt:check` select their files through git rather than by walking the directory, which is what makes a package-level run and a root-level run apply the same ignore rules — see [file selection](#file-selection).
418
605
 
@@ -546,7 +733,7 @@ These scripts operate on root-level code only (not workspace packages):
546
733
  | `root:test:tool` | `vitest --config ./vitest.root.config.ts --project tool` |
547
734
  | `root:test:unit` | `vitest --config ./vitest.root.config.ts --project unit` |
548
735
  | `root:typecheck` | `tsgo --noEmit` |
549
- | `root:upgrade` | `nmr-taze --include-locked` |
736
+ | `root:upgrade` | `nmr-report-overrides && nmr-taze --include-locked` |
550
737
 
551
738
  #### Utilities
552
739
 
@@ -564,15 +751,17 @@ nmr [flags] <command> [args...]
564
751
 
565
752
  Position determines ownership: flags before the command name are nmr's own, and everything after the command name is forwarded untouched to the resolved command.
566
753
 
567
- | Flag | Description | Default |
568
- | ------------------------ | ------------------------------------------------------- | ------- |
569
- | `-F, --filter <pattern>` | Run command in matching packages | — |
570
- | `-R, --recursive` | Run command in all packages | — |
571
- | `-w, --workspace-root` | Use root scripts, running at the monorepo root | — |
572
- | `-q, --quiet` | Suppress info messages; show full output on failure | — |
573
- | `--no-cache` | Run even if this tree already passed; record the result | — |
574
- | `-?, --help` | Show available commands | — |
575
- | `-V, --version` | Show version number | — |
754
+ | Flag | Description | Default |
755
+ | ------------------------ | -------------------------------------------------------------- | ------- |
756
+ | `-F, --filter <pattern>` | Run command in matching packages | — |
757
+ | `-R, --recursive` | Run command in all packages | — |
758
+ | `-w, --workspace-root` | Use root scripts, running at the monorepo root | — |
759
+ | `-q, --quiet` | Suppress command output, keeping nmr's verdicts | — |
760
+ | `--json` | Report one JSON object per command, withholding command output | — |
761
+ | `--log` | Print the recorded run instead of running it | — |
762
+ | `--no-cache` | Run even if this tree already passed; record the result | — |
763
+ | `-?, --help` | Show available commands | — |
764
+ | `-V, --version` | Show version number | — |
576
765
 
577
766
  ### Examples
578
767
 
@@ -598,9 +787,19 @@ nmr --workspace-root check # Run root check from a package dir
598
787
 
599
788
  These commands are available as `nmr` subcommands and as standalone `nmr-`-prefixed binaries (for use in lifecycle hooks).
600
789
 
790
+ ### `report-catalog`
791
+
792
+ Report the dependencies the current package takes from a [pnpm catalog](https://pnpm.io/catalogs), which a package-scoped `upgrade` cannot reach (see [dependency upgrades](#dependency-upgrades) for why). Each line names the dependency, its `catalog:` specifier, and the monorepo root a covering pass runs from. The workspace `upgrade` script runs it automatically, so no per-repo wiring is needed. The subcommand belongs to the workspace registry, so it runs from a package directory; the `nmr-report-catalog` bin reports nothing when run from the monorepo root, because a root-scoped pass reads the catalog itself.
793
+
794
+ ```bash
795
+ nmr report-catalog
796
+ ```
797
+
601
798
  ### `report-overrides`
602
799
 
603
- Report any active `pnpm.overrides` in the root `package.json`, reminding developers of overrides that may need cleanup. The root `upgrade` script runs it automatically, so no per-repo wiring is needed. Invoking it directly is useful for a one-off report.
800
+ Report any active pnpm override, declared in the root `pnpm-workspace.yaml`, reminding developers of overrides that may need cleanup. The root `upgrade` script runs it automatically, so no per-repo wiring is needed. Invoking it directly is useful for a one-off report.
801
+
802
+ A `pnpm.overrides` block in the root `package.json` fails the command, naming every entry it holds and where to move it — see [dependency upgrades](#dependency-upgrades) for why the block is inert. `pnpx codemod run pnpm-v10-to-v11` performs the move.
604
803
 
605
804
  ```bash
606
805
  nmr report-overrides
@@ -646,7 +845,7 @@ nmr-clean
646
845
 
647
846
  ### `nmr-compile`
648
847
 
649
- Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and `.d.ts` in one pass. Because the compiler parses each source file, every relative import form — static, re-export, dynamic `import()`, and bare side-effect — is rewritten from `.ts` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when no input has changed and the previous output is still on disk (a content-and-path hash is cached under `node_modules/.cache/nmr-compile/`, outside the published output). Deleting the output by any means — `nmr clean`, `rm -rf dist`, `git clean` — therefore forces a rebuild rather than a skip. This is the default `compile` script — run it from a package directory.
848
+ Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler API, emitting `.js` and `.d.ts` in one pass. Because the compiler parses each source file, every relative import form — static, re-export, dynamic `import()`, and bare side-effect — is rewritten from `.ts` to `.js` in both outputs, and `.ts` occurrences inside strings and comments are left intact. tsconfig `paths` aliases are resolved to runnable relative `.js` specifiers in both outputs, sourced from the package's tsconfig. An aliased import whose target resolves outside the package's `src/` and is not resolvable without the alias mapping fails the build with a diagnostic, rather than being emitted verbatim to produce output that fails at runtime. The build is skipped when no input has changed and the previous output is still on disk (the key is cached under `node_modules/.cache/nmr-compile/`, outside the published output). Deleting the output by any means — `nmr clean`, `rm -rf dist`, `git clean` — therefore forces a rebuild rather than a skip. This is the default `compile` script — run it from a package directory.
650
849
 
651
850
  **What it compiles.** Entry points are `src/**/*.ts` less the directories that hold test scaffolding rather than shipped code: `__fixtures__/`, `__mocks__/`, `__tests__/`, and `test-utils/`. Add to that list per package with [`build.extraIgnorePatterns`](#package-level-configuration). Ignoring a directory drops it as an _entry point_, not from the emit: the compiler still emits whatever the surviving entry points import, so a helper that production code uses is still compiled and its importer never emits a dangling specifier. This list is deliberately not the Vitest [coverage exclusions](#what-the-config-excludes) — helpers live in `test-utils/` precisely so they stay covered.
652
851
 
@@ -654,6 +853,8 @@ Compile a single package's `src` tree to `dist/esm` with the TypeScript compiler
654
853
 
655
854
  **The replacement is atomic.** The emit is buffered in memory, written to a staging directory beside the output directory, and swapped into place by rename. A build that fails for any reason -- a malformed tsconfig, an unresolvable alias, a full disk -- therefore leaves the previous output exactly as it was. `dist/esm` is never observed mid-write: it holds the previous build or the new one, and is absent only between the two renames. This matters most when the package being rebuilt supplies a binary that other packages in the same recursive build invoke.
656
855
 
856
+ **What busts the cache.** The key folds each input's content and path, the emit options, the resolved TypeScript version, and a fingerprint of the nmr running the build -- its own build digest in a workspace checkout, its package version otherwise. Upgrading nmr in a consuming repo therefore rebuilds every package nmr compiles, and an edit to nmr's own build sources rebuilds each sibling once nmr itself has been rebuilt. A build of nmr's own package folds the version rather than the digest, which is the cache entry that build is about to write.
857
+
657
858
  `typescript` is a peer dependency (`>=5.7.0 <7`); the consuming repo provides it. The floor is what `rewriteRelativeImportExtensions` requires; the ceiling is because TypeScript 7 ships no compiler API — its root export is a version constant, so `nmr-compile` cannot run on it. Relative imports in source must carry explicit `.ts` extensions for them to be rewritten.
658
859
 
659
860
  ```bash
@@ -679,7 +880,7 @@ nmr-fmt --write packages/nmr
679
880
 
680
881
  ### `nmr-taze`
681
882
 
682
- Run the [taze](https://github.com/antfu-collective/taze) dependency-upgrade tool, forwarding every argument to it untouched. This is what `root:upgrade` resolves to, and what the root `upgrade` script ends with — see [dependency upgrades](#dependency-upgrades) for the workflow.
883
+ Run the [taze](https://github.com/antfu-collective/taze) dependency-upgrade tool, forwarding every argument to it untouched. This is what every `upgrade` chain ends with, `root:upgrade` included — see [dependency upgrades](#dependency-upgrades) for the workflow.
683
884
 
684
885
  Under pnpm's isolated `node_modules`, a transitive package's binary is absent from the consuming repo's `node_modules/.bin`, so a repo that depends on nmr cannot run `taze` directly. `nmr-taze` can, because nmr is a direct dependency, and it resolves the tool from the tree nmr controls.
685
886
 
@@ -703,7 +904,7 @@ ensure-prepublish-hooks
703
904
 
704
905
  ## Conformance checks
705
906
 
706
- nmr publishes a `readyup` kit that checks a consuming repo against the current release: the shared Vitest and Prettier configs, the workspace layout, the root script registry, and the [test-tier convention](#test-tiers). The kit ships inside the package, so it checks against the nmr version installed rather than whatever a repository ref happens to point at, and a tier added or renamed in nmr reaches the repo on upgrade.
907
+ nmr publishes a `readyup` kit that checks a consuming repo against the current release: the shared Vitest and Prettier configs, the workspace layout, the root script registry, the [test-tier convention](#test-tiers), and that no `package.json` in the tree declares a `pnpm` field, which pnpm 11 reads no key from. Those settings belong in `pnpm-workspace.yaml`, and `pnpx codemod run pnpm-v10-to-v11` moves them. The kit ships inside the package, so it checks against the nmr version installed rather than whatever a repository ref happens to point at, and a tier added or renamed in nmr reaches the repo on upgrade.
707
908
 
708
909
  Add `readyup` as a devDependency, then name nmr in its config:
709
910
 
@@ -10,5 +10,8 @@ delivery: ambient
10
10
  - Scope follows cwd, and bare `nmr` lists both registries: from the repo root a command covers root files and every workspace; from inside a package, that package alone. `nmr -F <pkg> <command>` targets one package from anywhere; `nmr root:<command>` targets root files alone, which isolates a failure to root code.
11
11
  - Never `npx nmr`: inside a git worktree it can resolve a different nmr from outside the tree. Fall back to `pnpm exec nmr`.
12
12
  - `nmr ci` runs what a code-quality workflow runs (build, then strict checks) and not the dependency audit, which belongs to a workflow of its own; `nmr prepush` runs the audit first, then `ci`, so a vulnerability stops the run before the long gate. Neither installs or binds a git hook.
13
- - A check that already passed on the current working tree skips, printing `⏭️ <command>: passed …`. Any edit, addition, or deletion re-runs it, so a skip is a real pass and needs no confirming: never pass `--no-cache` to get a "real" run, which costs the command's full runtime to reach the same answer. Its one use is a file rather than an exit status -- a skipped command writes none, so a fresh `coverage/` needs `nmr --no-cache test:coverage`.
14
- - The rest is in `node_modules/@williamthorsen/nmr/README.md`: pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`), script overrides and their `""`/`":"` skip values, `nmr upgrade` ceilings (report-only until `nmr upgrade --write`), the `nmr-compile` build (there is no repo-local build script), and the check-result cache's key, its `checkCache` config, and every condition that disables it.
13
+ - A check that already passed on the current working tree skips, printing `⏭️ <scope>: <command>: passed …` and, where the recalled run left one, `— replayed: <excerpt>` of what that run concluded. Any edit, addition, or deletion re-runs it, so read the replayed excerpt rather than paying for the answer again: never pass `--no-cache` to get a "real" run, which costs the command's full runtime to reach the same answer. Its one use is a file rather than an exit status -- a skipped command writes none, so a fresh `coverage/` needs `nmr --no-cache test:coverage`. A composite replays its constituents' excerpts, each attributed as `<scope>: <command>: <excerpt>` and cut at the line's ceiling where there are too many to fit. A skip carrying no excerpt is one whose run wrote to a terminal, or one whose constituents recorded none.
14
+ - `nmr --log <command>` prints the whole run behind that excerpt -- up to 256 KiB, led by when it was recorded, how long it ran, and the command string that produced it -- so output you need in full is a recording to read rather than a run to pay for again. It runs nothing, and it is held to the tree rather than to the terminal: a recording a piped run made prints for a reader at one. A composite prints its constituents' excerpts, so the transcript of one of them is `nmr -F <pkg> --log <command>` or that constituent's own name. Nothing to show is said on one line and exits non-zero, naming which it is.
15
+ - Every command nmr runs reports one line -- `✅ <scope>: <command>: passed in 12.4s`, `❌ … failed in 1.2s (exit 1)`, `⏭️ … passed 4m ago on this tree`, or `⛔ … skipped, the override is empty` -- and a composite reports alongside every command it expands into. The lines print in every verbosity, so `-q` is the low-noise way to run rather than a silent one: it withholds the output of the commands nmr runs and keeps nmr's own verdicts, which is how a pass, a cached skip, and a `""`/`":"` override stay distinguishable. 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
+ - 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 the test reaches -- `tool` (a program the environment supplies), `localhost` (a service on this machine), `remote` (a machine that isn't this one), or `unit`, the residual claiming everything else. Neither a file outside `__tests__` nor one whose tier segment is missing or misspelt fails anything: the first is collected by nothing and the second runs under `unit`, and no test run distinguishes either from a conformant file. `rdy run --packages` reports the misspelt tier, and nothing reports the misplaced file.
17
+ - The rest is in `node_modules/@williamthorsen/nmr/README.md`: pre/post hooks (every `nmr X` auto-wraps as `nmr X:pre && nmr X && nmr X:post`), script overrides and their `""`/`":"` skip values, `nmr upgrade` ceilings (report-only until `nmr upgrade --write`), the `nmr-compile` build (there is no repo-local build script), the test tiers' timeout budgets and the `tiers` seam that raises one, which script reaches which tier, and the check-result cache's key, the retention key a replayed excerpt is additionally held to, the retention ceiling `--log` prints within, its `checkCache` config, and every condition that disables it.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ await import('../dist/esm/cli-report-catalog.js');
4
+ } catch (error) {
5
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
+ process.stderr.write('nmr-report-catalog: build output not found — run `pnpm run bootstrap` first\n');
7
+ } else {
8
+ process.stderr.write(`nmr-report-catalog: failed to load: ${error.message}\n`);
9
+ }
10
+ process.exit(1);
11
+ }
@@ -0,0 +1,3 @@
1
+ export declare class UserError extends Error {
2
+ constructor(message: string);
3
+ }
@@ -0,0 +1,6 @@
1
+ export class UserError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'UserError';
5
+ }
6
+ }