@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
@@ -23,6 +23,12 @@ export type CommandVerbosity = (typeof COMMAND_VERBOSITIES)[number];
23
23
  * accepts a verbosity renders through this, the sources cannot come to name different ladders.
24
24
  */
25
25
  export declare function formatVerbosityRejection(source: string, value: string): string;
26
+ /**
27
+ * Reports whether the environment contains a marker of a known agent harness. Keys on a variable's name and a
28
+ * non-empty value: The value convention belongs to the harness, so only a name-only rule keeps working when a
29
+ * harness changes it.
30
+ */
31
+ export declare function hasAgentMarker(env: NodeJS.ProcessEnv, extraNames: readonly string[] | undefined): boolean;
26
32
  /** Narrows a raw value to a point on the loudness ladder. */
27
33
  export declare function isCommandVerbosity(value: string): value is CommandVerbosity;
28
34
  /**
@@ -4,6 +4,13 @@ export const COMMAND_VERBOSITY_ENV_VAR = 'NMR_COMMAND_VERBOSITY';
4
4
  export function formatVerbosityRejection(source, value) {
5
5
  return `${source} is \`${value}\`, which is not one of: ${COMMAND_VERBOSITIES.join(', ')}`;
6
6
  }
7
+ export function hasAgentMarker(env, extraNames) {
8
+ const names = extraNames === undefined ? AGENT_ENV_VARS : [...AGENT_ENV_VARS, ...extraNames];
9
+ return names.some((name) => {
10
+ const value = env[name];
11
+ return value !== undefined && value !== '';
12
+ });
13
+ }
7
14
  export function isCommandVerbosity(value) {
8
15
  const names = COMMAND_VERBOSITIES;
9
16
  return names.includes(value);
@@ -28,10 +35,3 @@ export function resolveVerbosity(options) {
28
35
  return output.commandVerbosity;
29
36
  return hasAgentMarker(env, output?.extraAgentEnvVars) ? 'quiet' : 'full';
30
37
  }
31
- function hasAgentMarker(env, extraNames) {
32
- const names = extraNames === undefined ? AGENT_ENV_VARS : [...AGENT_ENV_VARS, ...extraNames];
33
- return names.some((name) => {
34
- const value = env[name];
35
- return value !== undefined && value !== '';
36
- });
37
- }
@@ -1,6 +1,6 @@
1
1
  import type { Writable } from 'node:stream';
2
2
  import { type OutputStyle } from '@williamthorsen/nmr-core';
3
- import type { ReplayLine } from './check-cache.js';
3
+ import type { ReplayLine } from './check-cache-entries.js';
4
4
  import type { ReportFormat } from './report-format.js';
5
5
  /**
6
6
  * The ceiling to which a rendered verdict, its newline included, is held.
@@ -37,7 +37,7 @@ function describeOutcome(verdict, style) {
37
37
  const savingClause = saving === undefined ? '' : `, ${saving}`;
38
38
  return {
39
39
  marker: statuses.skipped.text,
40
- phrase: `passed ${formatDuration(verdict.ageMs)} ago on this tree${savingClause}`,
40
+ phrase: `passed ${formatDuration(verdict.ageMs)} ago on ${verdict.host ?? 'this tree'}${savingClause}`,
41
41
  };
42
42
  }
43
43
  case 'no-op':
@@ -33,41 +33,55 @@ Overriding a cacheable name, in `package.json` or in `workspaceScripts`, inherit
33
33
 
34
34
  A hit requires all of these to match the run that recorded the pass:
35
35
 
36
- - **The working tree's content**, hashed from the commit's tree object folded with the current content of every path that git reports as changed or untracked. Timestamps are not content, so a `touch` that does not rewrite any bytes leaves the hash alone.
36
+ - **The working tree's content**, identified by the git tree id that `git add --all` would record for it, computed without writing to the repository. Timestamps are not content, so a `touch` that does not rewrite any bytes leaves the id alone; a mode change, such as `chmod +x`, moves it.
37
37
  - **The command string that would run**, hooks and all, and the scope in which it would run.
38
38
  - **nmr's version**, the **Node version**, and the **platform and architecture**.
39
39
  - **What pnpm has installed.** An install, a prune, or a lockfile change forces a re-run.
40
40
  - **`TZ`, `LANG`, `LC_ALL`, and `NODE_OPTIONS`.** This set is fixed, so two machines that differ only in other environment variables still agree.
41
41
 
42
- A hit additionally requires the build output of every package that nmr's own build covers to be present, and to have been built from this tree. Because output is git-ignored, neither its removal nor its replacement changes the hash: Restoring a tree with `git stash` or `git checkout` does not restore any of the output with which that tree was built. nmr compares each covered package's recorded build digest against the one on disk, and treats a `dist` compiled from another tree as a miss that the following run repairs. A package that overrides `build` or `compile` emits on terms that nmr does not know and is exempt.
42
+ A hit additionally requires the build output of every package that nmr's own build covers to be present, and to have been built from this tree. Because output is git-ignored, neither its removal nor its replacement changes the tree id: Restoring a tree with `git stash` or `git checkout` does not restore any of the output with which that tree was built. nmr compares each covered package's recorded build digest against the one on disk, and treats a `dist` compiled from another tree as a miss that the following run repairs. A package that overrides `build` or `compile` emits on terms that nmr does not know and is exempt.
43
43
 
44
44
  A pass is recorded only when the command exits 0, only when the tree still matches the one against which the run started, only when every covered package's output is present and unchanged since the run started, and never for a run that executed nothing (a `""`/`":"` skip override, or an `NMR_RUN_IF_PRESENT` miss). When output changes mid-run, whether another process rebuilt it or the chain built it itself, nmr cannot tell over which output the pass was earned: `nmr ci` is the one default command whose chain builds its own output, and it does not record a pass on a run whose build did work. The `nmr check:strict` inside that chain still records a pass, because it starts after the build.
45
45
 
46
46
  ## Out of contract
47
47
 
48
- These change what a check concludes without changing the hash. When one applies, exclude the affected command:
48
+ These change what a check concludes without changing the tree id. When one applies, exclude the affected command:
49
49
 
50
- - **Content filters.** `core.autocrlf` and `.gitattributes` filters mean the bytes on disk differ from the bytes that git records; the hash follows what is on disk, but a checkout on another platform may not reproduce it.
51
- - **Symlinked inputs.** A symlink is hashed by the path that it names, not by the content of its target.
52
- - **Gitignored inputs.** Anything git does not report is invisible to the hash. The build-output probe is the one exception, and covers only nmr's own build.
50
+ - **Content filters and git configuration.** The tree id follows `core.autocrlf` and `.gitattributes` filters as `git add` applies them, so it describes the bytes that git would record; a checkout on another platform may not reproduce the same normalized content. Configuration that changes what `git add` records, such as `core.fileMode=false` or `core.symlinks=false`, is not modeled: The id follows the mode and the link on disk.
51
+ - **Symlinked inputs.** A symlink is identified by the path that it names, not by the content of its target.
52
+ - **Gitignored inputs.** Anything git does not report is invisible to the tree id. The build-output probe is the one exception, and covers only nmr's own build.
53
53
  - **`passWithNoTests`.** A suite that does not collect any files exits 0, and that pass is recorded like any other.
54
54
 
55
- Committing an already-checked tree also changes the hash, because the commit's tree object is the base of the fold; the next run does the work again. The reverse holds and is useful: A rebase or an amended message that preserves content leaves the hash alone, as does checking out a branch whose tree is identical.
55
+ Committing an already-checked tree keeps its tree id, so the next run skips. A rebase or an amended message that preserves content keeps it too, as does checking out a branch whose tree is identical.
56
56
 
57
57
  ## When the cache does not apply
58
58
 
59
59
  The gate never wrongly skips. When it cannot be sure, it does nothing and the command runs:
60
60
 
61
61
  - Outside a git repository, when git fails, or when `HEAD` does not resolve to a commit.
62
- - When the repository declares or contains submodules, whose content the hash does not cover.
62
+ - When the repository declares or contains submodules, whose content the tree id does not cover.
63
63
  - When the monorepo root is not the git toplevel.
64
- - When a changed path cannot be read, or is neither a file nor a symlink (an untracked nested repository, for instance).
64
+ - When a changed path cannot be read, contains a newline or bytes that are not UTF-8, or is neither a file nor a symlink (an untracked nested repository, for instance).
65
65
  - When the repository does not have a pnpm install to fingerprint.
66
- - Under a `devBin` substitution, which runs a binary built from a location that the hash does not describe.
66
+ - Under a `devBin` substitution, which runs a binary built from a location that the tree id does not describe.
67
67
  - 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.
68
68
 
69
69
  `NMR_DEBUG=1` reports why a run did not skip and why the cache did not apply.
70
70
 
71
+ ## Delegated passes
72
+
73
+ A [delegated](delegate.md#consulting-the-cache-first) invocation consults this cache before it contacts the host, under the same key as a run here: The key describes the work as this machine sees it, and each entry also records which machine earned the pass.
74
+
75
+ - **A pass earned here** satisfies a run here and a delegation to any host.
76
+ - **A pass earned on a host** satisfies only a delegation whose list includes that host, and is not held to this machine's build output, which says nothing about the host's. Under a [user config](delegate.md#the-user-config) whose `when` delegates by default, a run that `--no-delegate` keeps here therefore re-runs a check that the host passed.
77
+ - **The key contains this machine's Node version and install, not the host's.** A change on the host alone, such as a Node upgrade there, does not invalidate a pass recorded here. The host's own cache keys on its own.
78
+ - **A host pass is recorded only when the tree pushed to the host is the tree on which the key was computed**, and it does not replace a pass earned here under the same key while that pass would still be recalled, its build output present and built from this tree.
79
+ - **A host pass retains the transcript that the host sent back**, which `--log` prints under a header that names the host.
80
+ - **A delegated composite records its constituents' passes too.** The host reports every pass that its nmr processes recorded or recalled during the run, and nmr records each one here as a pass earned on that host, at the scope where it ran. After `nmr --delegate-to imac2 ci`, `nmr --delegate-to imac2 check` on the same tree recalls without contacting the host, and a recall of `ci` replays its constituents' excerpts.
81
+ - **A learned constituent is keyed on this machine's inputs and on the command string that the host ran**, so one whose string differs from what would run here is never recalled. Its excerpts are keyed on the [presentation environment](#replaying-a-skipped-runs-output) of the delegating invocation, for whose streams the host styled them. It does not retain a transcript, so `--log` prints its excerpts under the header that names the host.
82
+ - **nmr treats the host's report as untrusted.** It drops an entry that is malformed, whose scope is not the root or one of its workspace packages, whose command is not cacheable here, or that describes another tree, and records the valid entries beside it. A delegation to a host whose nmr predates the report records the composite's pass alone, as before.
83
+ - **A composite that the host recalled, and a `-F` or `-R` delegation, do not record any constituent's pass.** The first reports only its own entry; the second does not consult this machine's cache.
84
+
71
85
  ## Replaying a skipped run's output
72
86
 
73
87
  A skip reports an excerpt of the run that it recalls, marked as a recording rather than as this run's output:
@@ -154,9 +168,11 @@ Try these in order; the first is almost always the right one.
154
168
 
155
169
  ## Reserved environment variables
156
170
 
157
- `NMR_TREE_SNAPSHOT` is nmr's own: It passes one observation of the tree from a top-level invocation down to the processes that it spawns, so a chain hashes the tree once rather than at every link. nmr trusts an inherited value only while `HEAD` still points to the same commit as when the observation was taken, which bounds a process that outlives the run that spawned it. Because that bound does not extend to a tree edited without committing, **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.
171
+ `NMR_TREE_SNAPSHOT` is nmr's own: It passes one observation of the tree from a top-level invocation down to the processes that it spawns, so a chain computes the tree id once rather than at every link. nmr trusts an inherited value only while `HEAD` still points to the same commit as when the observation was taken, which bounds a process that outlives the run that spawned it. Because that bound does not extend to a tree edited without committing, **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.
172
+
173
+ `NMR_RUN_ID` is nmr's own too: It passes one run's identity from a top-level invocation down to the processes that it spawns, so that the excerpts recorded at every scope during one run are recognizable as that run's, and a composite can assemble from them. Whereas `NMR_TREE_SNAPSHOT` is bounded by `HEAD`, this one is bounded by the tree id that each entry records. A process that outlives its run and carries the identity into a later one contributes nothing to that run's assemblies.
158
174
 
159
- `NMR_RUN_ID` is nmr's own too: It passes one run's identity from a top-level invocation down to the processes that it spawns, so that the excerpts recorded at every scope during one run are recognizable as that run's, and a composite can assemble from them. Whereas `NMR_TREE_SNAPSHOT` is bounded by `HEAD`, this one is bounded by the tree hash that each entry records. A process that outlives its run and carries the identity into a later one contributes nothing to that run's assemblies.
175
+ `NMR_PASS_MANIFEST_DIR` is nmr's own as well, and only the [host session](delegate.md#what-the-host-runs) sets it: It names the directory into which every nmr process of a delegated run reports the passes that it records or recalls, so that the delegating machine can [record the constituents](#delegated-passes) of a delegated composite. nmr does not forward it from this machine to the host.
160
176
 
161
177
  ## Configuring
162
178
 
package/docs/delegate.md CHANGED
@@ -3,30 +3,96 @@
3
3
  nmr runs a command on another machine over SSH, against the working tree on this one, and reports the command's output and exit status here:
4
4
 
5
5
  ```console
6
- $ nmr --delegate imac2 ci
6
+ $ nmr --delegate-to imac2 ci
7
7
  # ... the host's own output and verdicts ...
8
8
  ✅ my-repo: ci: passed on imac2 in 1m 48s
9
9
  ```
10
10
 
11
- A machine that runs several worktrees' checks at once runs out of memory, and the [machine lock](machine-lock.md) protects it by making the checks wait for one another. Delegation moves a check to a machine that has the room for it instead.
11
+ A machine that runs several worktrees' checks at once runs out of memory, and the [machine lock](machine-lock.md) protects it by making the checks wait for one another. Delegation moves a check to a machine that has the room for it instead. Which hosts a machine can reach is a fact about the machine, so a user config on the machine, not the repo, names them and turns delegation on.
12
+
13
+ ## The user config
14
+
15
+ `~/.config/nmr/config.ts`, or `$XDG_CONFIG_HOME/nmr/config.ts` when `XDG_CONFIG_HOME` is set, declares this machine's hosts and when to delegate to them without being asked. It honors one key, `delegate`:
16
+
17
+ ```ts
18
+ export default {
19
+ delegate: { hosts: ['imac2', 'imac3'], when: 'agent' },
20
+ };
21
+ ```
22
+
23
+ - **`hosts`** lists the hosts in order of preference. It is required whenever `delegate` is present.
24
+ - **`when`** turns on delegation by default: `'agent'` for every run whose environment contains an agent-harness marker, the variables that also select [quiet output](reporting.md#configuring), extended by the repo's `output.extraAgentEnvVars`; `'always'` for every run. Without `when`, nmr delegates only on request, and `hosts` serves `--delegate`.
25
+
26
+ nmr reads the file only when a decision reaches it, and an unrecognized key, a malformed `hosts`, or an unknown `when` fails that run with an error naming the file and the key. The file cannot import anything from a repo, so it is a plain object rather than a `defineConfig` call. When a directory above it contains a `package.json` without `"type"`, such as a stray `~/package.json`, Node warns on every load that the file's module type is not specified; a `~/.config/nmr/package.json` containing `{ "type": "module" }` silences it.
27
+
28
+ A pass that a host earns satisfies a later delegation to that host, but not a run here: Under `when`, a run that `--no-delegate` keeps here re-runs a check that the host already passed. See [consulting the cache first](#consulting-the-cache-first).
12
29
 
13
30
  ## Choosing where a command runs
14
31
 
15
- | Step | Effect |
16
- | ----------------------------- | ----------------------------------------------------------------------- |
17
- | `nmr --delegate <host> <cmd>` | Runs the command on `<host>`. `--delegate=<host>` is the same. |
18
- | `NMR_DELEGATE=<host>` | Runs every command that can be delegated on `<host>`. |
19
- | `nmr --no-delegate <cmd>` | Runs the command here, whatever `NMR_DELEGATE` names. It outranks both. |
32
+ | Form | Effect |
33
+ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
34
+ | `nmr --delegate <cmd>` | Runs the command on `NMR_DELEGATE`'s hosts if it names any, otherwise the user config's; refused when neither does |
35
+ | `nmr --delegate-to <hosts> <cmd>` | Runs the command on `<hosts>`. `--delegate-to=<hosts>` is the same. |
36
+ | `nmr --no-delegate <cmd>` | Runs the command here |
37
+ | `NMR_DELEGATE=<hosts>` | Runs every command that can be delegated on `<hosts>` |
38
+ | `delegate: false` in the repo config | Keeps every command here unless a flag asks otherwise |
39
+
40
+ `<hosts>` is one host or a comma-separated list in order of preference, such as `imac2,imac3`; see [choosing among several hosts](#choosing-among-several-hosts). A host is anything that `ssh` accepts: a name from `~/.ssh/config`, `user@host`, or an address. Like every nmr flag, these belong before the command name; nmr warns about one written after it, because the command then runs here. `--delegate=<hosts>` is refused with a message naming `--delegate-to`, and so is a host name written after `--delegate`, which nmr reads as the command.
41
+
42
+ The first rule that applies decides where a command runs:
43
+
44
+ | # | Condition | Runs |
45
+ | --- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
46
+ | 1 | `--no-delegate` | Here |
47
+ | 2 | `--delegate` or `--delegate-to` | On a host; a command that cannot be delegated, or a tree that cannot be snapshotted, is refused |
48
+ | 3 | The repo config declares `delegate: false` | Here |
49
+ | 4 | `NMR_DELEGATE` names hosts | On a host; a command that cannot be delegated runs here, and a tree that cannot be snapshotted, here with one line |
50
+ | 5 | `NMR_DELEGATE` is set but does not name a host | Here |
51
+ | 6 | The user config's `when` matches, `CI` is unset, and the invocation is not `-F` or `-R` | On a host, as for rule 4 |
52
+ | 7 | Otherwise | Here |
53
+
54
+ - **A step and a `-F` or `-R` child run where their parent runs.** nmr gives every process that it starts an empty `NMR_DELEGATE`, which rule 5 reads as "here"; the run on a host already passes `--no-delegate`, so a host does not apply its own user config. A git hook that an agent's command starts is an agent run.
55
+ - **A repo whose checks must run on the machine that has the tree** declares `delegate: false` in `.config/nmr.config.ts`, the only value that the repo config accepts for the key. It outranks `NMR_DELEGATE` and the user config, but not a flag.
56
+ - **`NMR_DEBUG=1` names what sent a command to a host**, such as `delegating ci per ~/.config/nmr/config.ts (when: agent)`, `per NMR_DELEGATE`, or `per --delegate-to`.
57
+
58
+ ### Choosing among several hosts
59
+
60
+ With several hosts, nmr asks every host at once, with one `ssh` call each, whether a delegation started now would wait there: whether the host's [machine lock](machine-lock.md), or the lock on this worktree's checkout there, has a live holder. A lock that its holder left behind does not count. nmr then runs on the first host in the list that is free; when none is, on the first host that answered; and when none answered, on the first host, where the delegation fails at `prepare`. It never runs the command here, because the machine that delegates is the one that delegation protects.
61
+
62
+ ```console
63
+ $ NMR_DELEGATE=imac2,imac3 nmr ci
64
+ ✅ my-repo: ci: passed on imac3 in 1m 52s
65
+ ```
20
66
 
21
- `<host>` is anything that `ssh` accepts: a name from `~/.ssh/config`, `user@host`, or an address. Like every nmr flag, these belong before the command name; nmr warns about one written after it, because the command then runs here.
67
+ - **A single host is not asked.** The delegation goes to it directly.
68
+ - **The choice is advisory.** Two delegations that ask at the same moment can choose the same host, and then run one after the other under its locks.
69
+ - **`NMR_DEBUG=1` shows the answers**, one line per host, such as `imac2 is busy`.
70
+ - Whitespace, empty entries, and repeated hosts in the list are ignored; a `--delegate-to` value that does not name any host is refused.
22
71
 
23
- `NMR_DELEGATE` is how an agent session's checks reach the host without any change to the commands that the agent runs: Set it in the session's environment.
72
+ The user config's `when: 'agent'` is how an agent session's checks reach the host without any change to the commands that the agent runs.
24
73
 
25
74
  ## What can be delegated
26
75
 
27
76
  Every command whose contribution is its exit status: `ci`, `prepush`, `check`, the `test` commands, `typecheck`, and a custom script such as `test:e2e`. nmr decides on the name that the invocation gives, so `ci` runs on the host as one unit, `build` step included.
28
77
 
29
- These run here, and `--delegate` refuses them: `build`, `clean`, `compile`, `fix`, `fmt`, `lint`, `test:watch`, and `upgrade`, with their `root:` forms, which rewrite the working tree or run until stopped; and `--log`, which reads this machine's recordings. Under `NMR_DELEGATE`, they run here without comment.
78
+ These run here, and `--delegate` and `--delegate-to` refuse them: `build`, `clean`, `compile`, `fix`, `fmt`, `lint`, `test:watch`, and `upgrade`, with their `root:` forms, which rewrite the working tree or run until stopped; and `--log`, which reads this machine's recordings. Under `NMR_DELEGATE` or the user config, they run here without comment.
79
+
80
+ ## Consulting the cache first
81
+
82
+ nmr plans the command here and consults this machine's [check-result cache](check-cache.md#delegated-passes) before it contacts the host. When a recorded pass covers the tree, nmr reports the recall and does not reach the host:
83
+
84
+ ```console
85
+ $ nmr --delegate-to imac2 ci
86
+ ⏩ my-repo: ci: passed 4m ago on this tree, saved ~1m 48s
87
+ ```
88
+
89
+ - **A pass earned here satisfies a delegation** to any host.
90
+ - **A pass earned on a host is recorded here** with that host, and a later delegation whose list includes that host recalls it as `passed 4m ago on imac2`, without asking any host whether it is free. A run here, or a delegation to other hosts, does not recall it. `nmr --log <command>` prints the output that the host sent back, under a header that names the host.
91
+ - **A delegated composite records its constituents' passes too**, as the host reports them, so `nmr --delegate-to imac2 check` after `nmr --delegate-to imac2 ci` recalls without contacting the host, and a recall of `ci` replays its constituents' excerpts. A composite that the host itself recalled records only its own pass, and a `-F` or `-R` delegation does not record any pass; [delegated passes](check-cache.md#delegated-passes) has the rules.
92
+ - **Only a miss reaches the host**, and it does not take the [machine lock](machine-lock.md) here.
93
+ - **`--no-cache` and `NMR_NO_CACHE=1` skip the lookup** and reach the host, where they bypass its cache too.
94
+ - **An invocation that nmr settles while planning**, such as an unknown command or an empty override, settles here without contacting the host.
95
+ - **A `-F` or `-R` invocation is sent whole**, without consulting this machine's cache, and the host fans it out.
30
96
 
31
97
  ## What the host runs
32
98
 
@@ -35,12 +101,20 @@ nmr records the working tree as it is on disk as a commit: tracked changes, stag
35
101
  nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each repository on each machine that delegates to it, so concurrent delegations from different worktrees do not share one, and `node_modules` and build output persist between runs. nmr does not remove these checkouts.
36
102
 
37
103
  - **Ignored files stay here.** A command that needs a gitignored file, such as a local `.env`, does not find it on the host.
38
- - **A repository with submodules cannot be delegated**, nor can a tree containing a nested repository, because the snapshot does not include their content. `--delegate` refuses such a tree; under `NMR_DELEGATE`, the command runs here, and nmr prints one line saying why.
39
- - **One worktree delegates one command at a time.** Two delegations from the same worktree at once share a checkout on the host.
104
+ - **A repository with submodules cannot be delegated**, nor can a tree containing a nested repository, because the snapshot does not include their content. A delegation flag refuses such a tree; under `NMR_DELEGATE` or the user config, the command runs here, and nmr prints one line saying why.
40
105
 
41
- On the host, the command runs under the host's own [machine lock](machine-lock.md) and [check-result cache](check-cache.md), so a host that serves several worktrees runs their heavy commands one at a time, and a tree that already passed there skips. A delegated run does not take the lock here, and it neither consults nor records this machine's cache, whose key is specific to the machine.
106
+ Delegations from the same worktree share its checkout on the host, so they run one at a time. A delegation that starts while another holds the checkout waits for it, saying so on stderr, then runs:
42
107
 
43
- Ctrl-C ends the run on the host as well as here.
108
+ ```console
109
+ $ nmr --delegate-to imac2 test
110
+ nmr: waiting for ci in /Users/me/repos/my-repo (pid 41872) to finish on imac2
111
+ ```
112
+
113
+ The pid is that of the other delegation's nmr on this machine. The lock is the file `.git/nmr-delegate.lock` in the checkout, and it covers the checkout, the install, and the run, but not the push, which only transfers the snapshot. As with the machine lock, the time spent waiting does not count in the verdict's duration, and a lock left behind by an interrupted delegation is reclaimed by the next: at once when its holder's process on the host no longer exists, and otherwise once the holder has stopped refreshing the file for a minute. Delegations from different worktrees use different checkouts and do not wait for one another.
114
+
115
+ On the host, the command runs under the host's own [machine lock](machine-lock.md) and [check-result cache](check-cache.md), so a host that serves several worktrees runs their heavy commands one at a time, and a tree that already passed there skips. The host's nmr receives nmr's own flags as they were given, and the variables that decide how it reports (`NMR_COMMAND_VERBOSITY`, `NMR_OUTPUT_STYLE`, and `NMR_REPORT_FORMAT`), whether it bypasses its cache (`NMR_NO_CACHE`), whether it writes debug notes (`NMR_DEBUG`), and which run it belongs to (`NMR_RUN_ID`). An `NMR_NO_LOCK` set here does not reach the host, while `--no-lock` does. The host session sets one variable itself, `NMR_PASS_MANIFEST_DIR`, naming a directory under the checkout's `.git/` into which the host's nmr processes report their passes; the session returns those reports here when the run ends and then removes the directory.
116
+
117
+ Ctrl-C ends the delegation on the host as well as here, in every step, including the wait for the checkout.
44
118
 
45
119
  ## Reporting
46
120
 
@@ -54,8 +128,14 @@ A failure of the delegation itself, rather than of the command, exits 255, the s
54
128
 
55
129
  The steps are `prepare`, which reaches the host and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
56
130
 
131
+ A delegation does not fall back to running here when it cannot reach a host. When the user config decided it and it fails at `prepare`, which is also where it fails when no host answered the probe, nmr adds a line naming the file and the remedies in order:
132
+
133
+ ```console
134
+ nmr: ~/.config/nmr/config.ts sent ci to imac2, imac3; ask for network access to imac2, imac3, or run it here with nmr --no-delegate ci
135
+ ```
136
+
57
137
  ## Setting up the host
58
138
 
59
139
  - **SSH that does not prompt.** nmr runs `ssh` with `BatchMode=yes`, so a key that needs a passphrase must be in the agent, and the host key must already be known. An unreachable host fails within 10 seconds.
60
- - **git, Node, and pnpm on the interactive login shell's `PATH`.** nmr reads `PATH` from the host's login shell started as an interactive one, which loads `~/.zshrc` or `~/.bashrc` as well as the login profile: That is where pnpm's installer and the common version managers put Node and pnpm. What the startup files print does not reach the output, but whatever else they do runs before each step. When pnpm is not on that `PATH`, the install step fails, naming `PATH`. The login shell must accept `-ilc`: bash and zsh do.
61
- - **A sandboxed agent session needs network access to the host.** A sandbox that allows only HTTP egress refuses `ssh`, and the delegation fails at `prepare`.
140
+ - **git, Node, and pnpm on the interactive login shell's `PATH`.** nmr reads `PATH` from the host's login shell started as an interactive one, which loads `~/.zshrc` or `~/.bashrc` as well as the login profile: That is where pnpm's installer and the common version managers put Node and pnpm. What the startup files print does not reach the output, but whatever else they do runs before each step. Node on the host must satisfy nmr's own `engines` range (`>=24.16.0`). When Node is not on that `PATH`, the check-out step fails, and when pnpm is not, the install step fails; each failure names `PATH`. The login shell must accept `-ilc`: bash and zsh do.
141
+ - **A sandboxed agent session needs network access to the host.** A sandbox that allows only HTTP egress refuses `ssh`, and the delegation fails at `prepare`. Under `when: 'agent'`, every check that the session runs fails this way until the session has that access.
package/docs/reporting.md CHANGED
@@ -29,7 +29,12 @@ $ nmr --json check
29
29
  {"command":"check","scope":"nmr-core","outcome":"passed","durationMs":31204}
30
30
  ```
31
31
 
32
- Every object contains `command`, `scope`, and `outcome`, and then the fields that only that outcome includes. A [delegated](delegate.md) command's own verdict also contains `host`, the host on which it ran:
32
+ ```console
33
+ $ nmr --json --delegate-to imac2 typecheck
34
+ {"command":"typecheck","host":"imac2","scope":"nmr-core","outcome":"recalled","ageMs":240000,"savedMs":18450}
35
+ ```
36
+
37
+ Every object contains `command`, `scope`, and `outcome`, and then the fields that only that outcome includes. A [delegated](delegate.md) command's own verdict also contains `host`, the host on which it ran, and so does the recall of a pass that a host earned, whose prose line reads `passed 4m ago on <host>` in place of `on this tree`:
33
38
 
34
39
  | `outcome` | Also contains |
35
40
  | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
package/docs/scripts.md CHANGED
@@ -122,6 +122,7 @@ A minimal `.config/nmr.config.ts` is in the [README](../README.md#configuration)
122
122
  | ------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
123
  | `build` | `{ extraIgnorePatterns?: string[] }` | Patterns added to the build's ignore set (package config only) |
124
124
  | `checkCache` | `{ enabled?: boolean; extraCommands?: string[]; excludeCommands?: string[] }` | Which commands the [check-result cache](check-cache.md#check-result-cache) may skip |
125
+ | `delegate` | `false` | Keeps every check on the machine that has the tree, whatever `NMR_DELEGATE` or the [user config](delegate.md#the-user-config) says; a delegation flag still sends a run elsewhere (root config only) |
125
126
  | `workspaceScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the workspace registry (tier 2) |
126
127
  | `rootScripts` | `Record<string, ScriptValue>` | Scripts added or overridden in the root registry (tier 2) |
127
128
  | `devBin` | `Record<string, string>` | Map binary names to source-repo replacement commands |
@@ -130,7 +131,7 @@ A minimal `.config/nmr.config.ts` is in the [README](../README.md#configuration)
130
131
 
131
132
  All fields are optional. [Script values](#script-values) describes `ScriptValue` and its step form, and both types are exported from `@williamthorsen/nmr/config`.
132
133
 
133
- Each field belongs to exactly one tier, and nmr loads from a config only the fields that its tier honors: `build` in a package config, the other five 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 that nmr does not recognize at all: A typo cannot degrade into a setting that silently applies nowhere.
134
+ Each field belongs to exactly one tier, and nmr loads from a config only the fields that its tier honors: `build` in a package config, every other field 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 that nmr does not recognize at all: A typo cannot degrade into a setting that silently applies nowhere.
134
135
 
135
136
  ### Package-level configuration
136
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -81,7 +81,7 @@
81
81
  "@williamthorsen/toolbelt.errors": "0.8.3",
82
82
  "@williamthorsen/toolbelt.filesystem": "0.9.3",
83
83
  "glob": "13.0.6",
84
- "prettier": "3.9.9",
84
+ "prettier": "3.9.10",
85
85
  "prettier-plugin-sh": "0.20.2",
86
86
  "taze": "21.3.0",
87
87
  "unconfig": "7.5.0"
@@ -90,7 +90,7 @@
90
90
  "@williamthorsen/toolbelt.testing": "0.6.3",
91
91
  "@williamthorsen/toolbelt.vitest": "0.10.3",
92
92
  "esbuild": "0.28.2",
93
- "readyup": "0.41.0",
93
+ "readyup": "0.42.0",
94
94
  "tinyglobby": "0.2.17",
95
95
  "typescript": "6.0.3",
96
96
  "vite": "8.3.4",