claude-nomad 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.0](https://github.com/funkadelic/claude-nomad/compare/v0.24.0...v0.25.0) (2026-05-25)
4
+
5
+
6
+ ### Added
7
+
8
+ * **doctor:** add gitleaks version and mirror-Actions drift checks ([#125](https://github.com/funkadelic/claude-nomad/issues/125)) ([8a16e0c](https://github.com/funkadelic/claude-nomad/commit/8a16e0c274bb7b961c6fd2df9912874c572023ee))
9
+ * **extras:** support a single root file as an extras entry ([#132](https://github.com/funkadelic/claude-nomad/issues/132)) ([6303b62](https://github.com/funkadelic/claude-nomad/commit/6303b62de84406da6a7fa2b7eb7af28a0473d0aa))
10
+
11
+
12
+ ### Fixed
13
+
14
+ * **drop-session:** cascade unstage into subagent transcript directory ([#120](https://github.com/funkadelic/claude-nomad/issues/120)) ([7f10d51](https://github.com/funkadelic/claude-nomad/commit/7f10d5135a7eaa9b6e74fb1aa863506a7b914a09))
15
+ * **push,update:** handle untracked extras in allow-list and fork merge ([#122](https://github.com/funkadelic/claude-nomad/issues/122)) ([e710a48](https://github.com/funkadelic/claude-nomad/commit/e710a48973a7c4e7479a9671d5891458a182dac7))
16
+ * **update:** skip push prompt when fork merge is a no-op ([#123](https://github.com/funkadelic/claude-nomad/issues/123)) ([77fdb20](https://github.com/funkadelic/claude-nomad/commit/77fdb20244a096e44a1fecd0a283e3401a78972d))
17
+
18
+
19
+ ### Changed
20
+
21
+ * pin workflow actions to SHAs and drop persisted CI credentials ([#130](https://github.com/funkadelic/claude-nomad/issues/130)) ([b60109c](https://github.com/funkadelic/claude-nomad/commit/b60109c0cbb5dff1b13c4155cc12c3d98ce1b141))
22
+
23
+
24
+ ### Documentation
25
+
26
+ * add CONTRIBUTING, PR template, and SECURITY policy ([#131](https://github.com/funkadelic/claude-nomad/issues/131)) ([774eb58](https://github.com/funkadelic/claude-nomad/commit/774eb58a36af032f7d1cd109d59f313c4ff6affa))
27
+ * lead README with a benefit-first pitch ([#133](https://github.com/funkadelic/claude-nomad/issues/133)) ([289e299](https://github.com/funkadelic/claude-nomad/commit/289e2994fd7dffb10818fe02aad64b193ad800b6))
28
+ * **readme:** foreground secret-safety in the intro ([#126](https://github.com/funkadelic/claude-nomad/issues/126)) ([e661e9f](https://github.com/funkadelic/claude-nomad/commit/e661e9f02247fc30630fd90d5facc65aa7b4f2a0))
29
+
30
+
31
+ ### Dependencies
32
+
33
+ * bump SonarSource/sonarqube-scan-action from 8.0.0 to 8.1.0 ([#127](https://github.com/funkadelic/claude-nomad/issues/127)) ([603f20d](https://github.com/funkadelic/claude-nomad/commit/603f20d9a22528c8df19d328c91a02012690836c))
34
+ * bump the dev-dependencies group across 1 directory with 2 updates ([#128](https://github.com/funkadelic/claude-nomad/issues/128)) ([50b1b87](https://github.com/funkadelic/claude-nomad/commit/50b1b87b7288eed60280843504e6ccc2598f10b0))
35
+ * bump tsx from 4.22.2 to 4.22.3 in the prod-dependencies group ([#129](https://github.com/funkadelic/claude-nomad/issues/129)) ([a7ada00](https://github.com/funkadelic/claude-nomad/commit/a7ada000ec2d3dbee132c7b61dc207012e53c1c3))
36
+
37
+ ## [0.24.0](https://github.com/funkadelic/claude-nomad/compare/v0.23.0...v0.24.0) (2026-05-24)
38
+
39
+
40
+ ### Added
41
+
42
+ * **doctor:** add --check-shared preflight gitleaks scan ([#117](https://github.com/funkadelic/claude-nomad/issues/117)) ([0089d09](https://github.com/funkadelic/claude-nomad/commit/0089d09ef91ff7b6778b065bcfe8be97f4c54d1b))
43
+
44
+
45
+ ### Documentation
46
+
47
+ * **readme:** document nomad doctor --check-shared preflight ([#119](https://github.com/funkadelic/claude-nomad/issues/119)) ([d08ed91](https://github.com/funkadelic/claude-nomad/commit/d08ed91bbfd4c976f56c510b086e375c4595e682))
48
+
3
49
  ## [0.23.0](https://github.com/funkadelic/claude-nomad/compare/v0.22.3...v0.23.0) (2026-05-23)
4
50
 
5
51
 
package/README.md CHANGED
@@ -6,17 +6,17 @@
6
6
 
7
7
  ![claude-nomad - Sync your Claude Code setup. Same environment. Any machine.](docs/hero.svg)
8
8
 
9
- Claude Code's state is per-machine. Your `CLAUDE.md`, custom agents, skills, slash commands, settings, and session history live in `~/.claude/` and don't follow you to your laptop, your work machine, or your homelab box.
9
+ **Your entire Claude Code setup, on every machine. History included, secrets excluded.**
10
10
 
11
- claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on another, and your full setup is there, including past sessions you can resume.
11
+ Open Claude Code on a second machine and it is a blank slate: none of your custom agents, slash commands, tuned settings, or past conversations. claude-nomad keeps all of it in sync through a private Git repo you control. `nomad push` on one machine, `nomad pull` on the next, and everything is there, conversations included.
12
12
 
13
- **Who this is for:** anyone running Claude Code on more than one machine. A laptop and a desktop, a Mac and a WSL box, a personal rig and a work machine, or any combination. If you've ever felt the friction of starting fresh on a second machine or copying files around by hand, this is for you.
13
+ - **Resume your sessions on any machine.** Start a conversation on your desktop and pick it up on your laptop. claude-nomad remaps the file paths Claude Code embeds in every transcript, so your history follows you instead of getting stranded on the box where it started.
14
+ - **Private by default.** Your `~/.claude/` also holds OAuth tokens, MCP credentials, and the full text of every conversation. Every push is secret-scanned before it leaves your machine, credentials and ephemeral state never sync, and `nomad init` disables CI on your private mirror by default, so transcripts can't leak through Actions logs.
15
+ - **One setup, every machine.** Your agents, skills, slash commands, and settings live in one place and follow you everywhere. Per-machine tweaks like model choice, MCP URLs, and env vars merge on top instead of clobbering your shared defaults.
14
16
 
15
- Three things it does that ad-hoc dotfiles syncing can't:
17
+ Not dotfiles, not rsync. claude-nomad understands Claude Code's state, so your session history survives different file paths and your secrets never ride along.
16
18
 
17
- - **Session history survives path differences.** The same project at `/Users/norm/code/foo` on your Mac and `/home/norm/foo` on Linux gets remapped automatically, so `claude --resume` finds your past conversations on whichever machine you're on.
18
- - **Per-host settings via deep merge.** Shared defaults live in one file; machine-specific overrides (model choice, MCP server URLs, env vars, hooks) live in a per-host file. They're merged on every pull instead of overwriting each other.
19
- - **Per-project content rides along, opt-in.** Whitelisted directories at a project's root (declared via `path-map.json`'s `extras` field) sync alongside session transcripts, so project-attached state like `.planning/` follows you across hosts. Off by default; projects without an `extras` entry behave exactly as before.
19
+ For anyone running Claude Code on more than one machine: a laptop and a desktop, a Mac and a WSL box, a personal rig and a work machine. [Get started in three steps.](#quickstart)
20
20
 
21
21
  ## Table of contents
22
22
 
@@ -114,7 +114,7 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
114
114
  │ ├── commands/
115
115
  │ ├── rules/
116
116
  │ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
117
- │ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, .env
117
+ │ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, *.pem, id_rsa, id_ed25519, .env, .env.*
118
118
  │ ├── projects/ # session transcripts under logical names
119
119
  │ └── extras/ # opt-in per-project content (materializes when path-map.json declares extras)
120
120
  ├── hosts/
@@ -127,14 +127,14 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
127
127
 
128
128
  ## What gets synced vs. not
129
129
 
130
- | Category | Items | Behavior |
131
- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
132
- | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
133
- | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
134
- | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
135
- | **Per-project extras** | `<localRoot>/.planning/` and other directories whitelisted by `SUPPORTED_EXTRAS` in `src/config.ts` | Opt-in via the `extras` field in `path-map.json`. Mirrored to/from `shared/extras/<logical>/<dirname>/`. Pre-pull divergence WARN flags local edits before they get overwritten. |
136
- | **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state. |
137
- | **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Plugin payloads not synced. Claude Code re-downloads them on first use from the `enabledPlugins` list in the regenerated `settings.json`; no manual `claude plugins install ...` per host. |
130
+ | Category | Items | Behavior |
131
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
132
+ | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
133
+ | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
134
+ | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
135
+ | **Per-project extras** | `<localRoot>/.planning/` and other directories, or a single root file like `CLAUDE.md`, whitelisted by `SUPPORTED_EXTRAS` in `src/config.ts` | Opt-in via the `extras` field in `path-map.json`. Mirrored to/from `shared/extras/<logical>/<name>` (directory subtree or single file). Pre-pull divergence WARN flags local edits before they get overwritten. |
136
+ | **Never synced** | `~/.claude.json` (OAuth, MCP state), `history.jsonl`, `settings.local.json` (per-host overrides), `stats-cache.json`, `todos/`, `shell-snapshots/`, `debug/`, `file-history/`, `plans/`, `session-env/`, `statsig/`, `telemetry/`, `ide/` | Per-host ephemeral state. |
137
+ | **Auto-rehydrated** | `~/.claude/plugins/cache/<plugin>/...` | Plugin payloads not synced. Claude Code re-downloads them on first use from the `enabledPlugins` list in the regenerated `settings.json`; no manual `claude plugins install ...` per host. |
138
138
 
139
139
  > [!NOTE]
140
140
  > Plugins that depend on host-specific state (external binaries, API keys in env, MCP server URLs) still need that side set up on each host. Put them in `hosts/<host>.json` or the plugin's own per-host config.
@@ -145,7 +145,7 @@ For the rationale behind these choices, see [What does NOT sync (deliberate trad
145
145
 
146
146
  The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-path>/` where the encoded path is the absolute path with `/` replaced by `-`. So the same logical project ends up in different directories on each host.
147
147
 
148
- `path-map.json` defines logical names and where the repo lives on each host. The optional `extras` block opts a project into syncing whitelisted directories at its root:
148
+ `path-map.json` defines logical names and where the repo lives on each host. The optional `extras` block opts a project into syncing whitelisted directories (or a single root file) at its root:
149
149
 
150
150
  ```json
151
151
  {
@@ -157,7 +157,7 @@ The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-pa
157
157
  }
158
158
  },
159
159
  "extras": {
160
- "ha-acwd": [".planning"]
160
+ "ha-acwd": [".planning", "CLAUDE.md"]
161
161
  }
162
162
  }
163
163
  ```
@@ -169,7 +169,7 @@ Use the literal string `"TBD"` for hosts you haven't onboarded yet; `remapPull`
169
169
 
170
170
  On `push`, sessions in `~/.claude/projects/-Users-you-code-ha-acwd/` get copied to `shared/projects/ha-acwd/`. On `pull` on another machine, they get copied to that host's encoded path. `claude --resume` then finds them (see [What does NOT sync (deliberate trade-offs)](#what-does-not-sync-deliberate-trade-offs) for the cross-OS cwd-binding gotcha).
171
171
 
172
- The `extras` block is additive and back-compatible: legacy `path-map.json` files without it continue to work unchanged. Each value is an array of directory names validated against `SUPPORTED_EXTRAS` in `src/config.ts`; values outside the whitelist are skipped with a log line so an unrecognized name cannot widen the sync surface. On `push`, opted-in directories at `<localRoot>/<dirname>/` are copied to `shared/extras/<logical>/<dirname>/` and inherit the staged-tree gitleaks scan. On `pull`, the reverse copy runs after `git pull --rebase`; just before it overwrites your working tree, a divergence check compares the incoming content against your local copy and emits a per-file WARN naming the diverging files. The existing local content is backed up to `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-localRoot>/<rel>/` before the pull copy lands (`<encoded-localRoot>` is the `localRoot` with `/` rewritten as `-`, so two opted-in projects with the same relative extras path do not collide in one backup run).
172
+ The `extras` block is additive and back-compatible: legacy `path-map.json` files without it continue to work unchanged. Each value is an array of directory or root-file names (e.g. `.planning`, `CLAUDE.md`) validated against `SUPPORTED_EXTRAS` in `src/config.ts`; values outside the whitelist are skipped with a log line so an unrecognized name cannot widen the sync surface. On `push`, opted-in content at `<localRoot>/<name>` (a directory subtree or a single file) is copied to `shared/extras/<logical>/<name>` and inherits the staged-tree gitleaks scan. On `pull`, the reverse copy runs after `git pull --rebase`; just before it overwrites your working tree, a divergence check compares the incoming content against your local copy and emits a per-file WARN naming the diverging files. The existing local content is backed up to `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-localRoot>/<rel>/` before the pull copy lands (`<encoded-localRoot>` is the `localRoot` with `/` rewritten as `-`, so two opted-in projects with the same relative extras path do not collide in one backup run).
173
173
 
174
174
  ## Per-host overrides
175
175
 
@@ -206,7 +206,7 @@ Read these before adopting so you opt in with eyes open.
206
206
  - **Manual push/pull.** No file watcher. Shell hooks recommended.
207
207
  - **OAuth doesn't sync.** You'll log in once per host. Intentional.
208
208
  - **Only sessions in `path-map.json` are remapped.** Drive-by sessions on un-mapped paths are left alone.
209
- - **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are unaffected. Dirnames outside `SUPPORTED_EXTRAS` are skipped with a `skip ... not in SUPPORTED_EXTRAS` log line so an unrecognized name cannot widen the sync surface. Unsafe path-map values (path-traversal in `logical` keys, non-absolute or unnormalized `localRoot` values) FATAL before any filesystem mutation via `assertSafeLogical` / `assertSafeLocalRoot` in `src/extras-sync.ts`.
209
+ - **Extras are opt-in and whitelisted.** Projects without an `extras` entry in `path-map.json` are unaffected. Names (a directory or a single root file) outside `SUPPORTED_EXTRAS` are skipped with a `skip ... not in SUPPORTED_EXTRAS` log line so an unrecognized name cannot widen the sync surface. Unsafe path-map values (path-traversal in `logical` keys, non-absolute or unnormalized `localRoot` values) FATAL before any filesystem mutation via `assertSafeLogical` / `assertSafeLocalRoot` in `src/extras-sync.ts`.
210
210
  - **Cross-OS `claude --resume` cwd binding.** Sessions embed the cwd where they were created, so the picker's `cd ... && claude --resume <id>` line fails on a different host. Use `nomad doctor --resume-cmd <id>` for a host-local equivalent (see [Cross-OS resume](#cross-os-resume)). The sidecar approach preserves transcript byte-equality.
211
211
  - **Empty directories don't survive sync.** Git doesn't track empty dirs; `nomad doctor` reports them as `missing` (benign). Drop a `.gitkeep` to force materialization.
212
212
 
@@ -287,10 +287,11 @@ nomad init --keep-actions
287
287
  Edit `path-map.json` to add your logical projects (see [Path remapping](#path-remapping)), then:
288
288
 
289
289
  ```bash
290
- nomad doctor # read-only state check; reports host, repo state, every check as ✓ (pass) / ✗ (fail) / ⚠︎ (warn)
291
- nomad diff # preview what nomad pull would change on this host; no lock, no network, no mutation
292
- nomad push # send current state to the private remote
293
- nomad pull # apply on another host (or this one after a remote update)
290
+ nomad doctor # read-only state check; reports host, repo state, every check as ✓ (pass) / ✗ (fail) / ⚠︎ (warn)
291
+ nomad doctor --check-shared # read-only gitleaks preflight over the session transcripts a push would stage
292
+ nomad diff # preview what nomad pull would change on this host; no lock, no network, no mutation
293
+ nomad push # send current state to the private remote
294
+ nomad pull # apply on another host (or this one after a remote update)
294
295
  ```
295
296
 
296
297
  `nomad pull --dry-run` is the network-aware twin of `nomad diff`: it acquires the lock and runs `git pull` so you see what the next real pull would do given the latest remote, then exits without mutating.
@@ -333,9 +334,9 @@ nomad update
333
334
  `nomad update` (see `cmdUpdate` in `src/commands.update.ts`) detects which layout your `~/claude-nomad/` uses and does the right thing:
334
335
 
335
336
  - **vanilla** (`origin` points at the public repo): `git pull --ff-only origin main`.
336
- - **fork** (`upstream` points at the public repo, `origin` points at your private mirror): `git fetch upstream`, `git merge upstream/main`, then prompt before pushing the merge to `origin/main`. Pass `--push-origin` to skip the prompt.
337
+ - **fork** (`upstream` points at the public repo, `origin` points at your private mirror): `git fetch upstream`, then (before merging) commit any whitelisted `shared/extras/` content that is still untracked locally so an overlap with upstream becomes a normal file merge instead of an untracked-overwrite abort, `git merge upstream/main`, then prompt before pushing the merge to `origin/main`. Pass `--push-origin` to skip the prompt. When the merge is a no-op (HEAD unchanged, nothing new to push) the prompt is skipped entirely and `nomad update` logs `already in sync with origin/main`.
337
338
 
338
- Pre-flight checks run before any mutation: `REPO_HOME` exists, topology resolves to `vanilla` or `fork`, current branch is `main`, working tree is clean per `git status --porcelain -z` (override with `--force`), and `--push-origin` is rejected on vanilla topology. After the merge or pull, `nomad update` re-runs `npm install` only when `package-lock.json` actually shifted, then invokes `nomad doctor`. The trailing version-check is non-fatal: `✓` when local matches the latest release, `⚠︎` when behind, an informational `ℹ︎ ... ahead of latest release` line when ahead (e.g. a `-dev` build between releases), and silent on network failures.
339
+ Pre-flight checks run before any mutation: `REPO_HOME` exists, topology resolves to `vanilla` or `fork`, current branch is `main`, working tree is clean per `git status --porcelain -z` (override with `--force`), and `--push-origin` is rejected on vanilla topology. After the merge or pull, `nomad update` re-runs `npm install` only when `package-lock.json` actually shifted, commits the regenerated `package-lock.json` (fork topology) if the reinstall changed it, then invokes `nomad doctor`. The trailing version-check is non-fatal: `✓` when local matches the latest release, `⚠︎` when behind, an informational `ℹ︎ ... ahead of latest release` line when ahead (e.g. a `-dev` build between releases), and silent on network failures.
339
340
 
340
341
  Common cases:
341
342
 
@@ -358,24 +359,27 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
358
359
 
359
360
  ## Commands
360
361
 
361
- | Command | Description |
362
- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
363
- | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
364
- | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
365
- | `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
366
- | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. FATAL if scaffold missing. |
367
- | `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
368
- | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
369
- | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
370
- | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
371
- | `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` is preserved. See [Recovery flows](#recovery-flows). |
372
- | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
373
- | `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check. |
374
- | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
375
- | `nomad --version` | Print the installed CLI version as bare semver to stdout; exits 0. Used by the npm-publish smoke test and useful for ad-hoc upgrade checks. |
362
+ | Command | Description |
363
+ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
364
+ | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
365
+ | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
366
+ | `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
367
+ | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. FATAL if scaffold missing. |
368
+ | `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
369
+ | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
370
+ | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
371
+ | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
372
+ | `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` and the sibling `shared/projects/*/<id>/` subagent directory from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` and `<id>/` tree are preserved. See [Recovery flows](#recovery-flows). |
373
+ | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
374
+ | `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check plus two `⚠︎`-only drift checks: gitleaks version drift and, on a private GitHub mirror, re-enabled Actions. |
375
+ | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
376
+ | `nomad doctor --check-shared` | Read-only gitleaks preflight: stages the session transcripts a `push` would publish into a temp tree and scans them, failing (`✗`, exit 1) per affected session with rotate-and-scrub guidance. Skips with a `⚠︎` when gitleaks is not on PATH. See [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl). |
377
+ | `nomad --version` | Print the installed CLI version as bare semver to stdout; exits 0. Used by the npm-publish smoke test and useful for ad-hoc upgrade checks. |
376
378
 
377
379
  The version-check emits ``⚠︎ version: <local> -> <latest> (run `nomad update`)`` when the local install is behind the latest upstream release, and `✓ version: <local> (latest)` when current. It silently skips on network failures.
378
380
 
381
+ Two further `⚠︎`-only drift checks run in `nomad doctor`. The gitleaks version-drift line `⚠︎ gitleaks: <local> -> <pinned> (...)` fires when the local gitleaks major.minor differs from the CI-pinned `GITLEAKS_PINNED_VERSION` (gitleaks rule and allowlist behavior tracks the minor line, so a patch-only difference stays `✓`), and is silent when gitleaks is not on PATH. The mirror-Actions line (carrying a `gh api -X PUT repos/<owner>/<repo>/actions/permissions -F enabled=false` remediation hint) fires when origin is a private GitHub mirror that is gh-authed with Actions re-enabled, complementing the auto-disable that runs on `nomad init` (see [Privacy by default](#privacy-by-default)); it is silent on every prerequisite miss (non-GitHub origin, `gh` unauthed, public repo, or Actions already off).
382
+
379
383
  Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summary:` line. The status glyph (`✓` green / `⚠︎` yellow / `✗` red / `ℹ︎` dim) carries the severity, mirroring `nomad doctor`'s left-gutter format:
380
384
 
381
385
  ```text
@@ -390,7 +394,7 @@ Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summa
390
394
 
391
395
  ### `nomad drop-session <id>`
392
396
 
393
- Surgically unstages every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl` is never touched.
397
+ Surgically unstages every `shared/projects/*/<id>.jsonl` plus the sibling `shared/projects/*/<id>/` subagent directory (whose nested transcripts are keyed by the same session id) from the staged tree of `~/claude-nomad/`. The local `~/.claude/projects/<encoded>/<id>.jsonl` and the local `<id>/` tree are never touched.
394
398
 
395
399
  ```bash
396
400
  nomad drop-session <id>
@@ -398,18 +402,18 @@ nomad drop-session <id>
398
402
 
399
403
  Single positional id (the session filename minus `.jsonl`). Anything else (missing id, leading dash, extra arg) exits 1 with a `usage:` line.
400
404
 
401
- For each match in the staged tree, `cmdDropSession` (in `src/commands.drop-session.ts`) classifies the entry as tracked-in-HEAD vs newly-staged and unstages it via `git restore --staged --worktree --` or `git rm --cached -f --` respectively. Idempotent: a second run on the same id sees no matching staged file and exits 0.
405
+ For each match in the staged tree, `cmdDropSession` (in `src/commands.drop-session.ts`) classifies the entry as tracked-in-HEAD vs newly-staged and unstages it via `git restore --staged --worktree --` or `git rm --cached -f --` respectively. The `<id>/` subagent directory is expanded into its staged entries via `git ls-files -z` so every nested transcript flows through the same per-entry classification; a session that has only a subagent directory (no flat `<id>.jsonl`) is still droppable. Idempotent: a second run on the same id sees no matching staged entries and exits 0.
402
406
 
403
407
  Exit codes:
404
408
 
405
409
  - `0` on any drop, including an idempotent re-run.
406
- - `1` with `✗ no staged session matches <id>` on stderr when no `shared/projects/*/<id>.jsonl` matches.
410
+ - `1` with `✗ no staged session matches <id>` on stderr when neither a `shared/projects/*/<id>.jsonl` nor a `shared/projects/*/<id>/` directory with staged entries matches.
407
411
 
408
- What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file. The local copy is preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real, scrub the local file separately.
412
+ What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` file or the local `<id>/` subagent tree. The local copies are preserved for `claude --resume`, grep recovery, or whatever the user wants. If the underlying secret is real, scrub the local files separately.
409
413
 
410
414
  ### Recovery flow: gitleaks FATAL on a session JSONL
411
415
 
412
- `nomad push` runs `gitleaks protect --staged` before commit. When findings live in a session transcript, the FATAL names every affected session id and the recovery command:
416
+ `nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you push (and without mutating anything), run the read-only preflight `nomad doctor --check-shared`, which stages and scans the exact transcripts a push would publish. When findings live in a session transcript, the push FATAL names every affected session id and the recovery command:
413
417
 
414
418
  ```text
415
419
  ✗ gitleaks detected secrets in 1 session transcript(s).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Owns the `nomad doctor --check-shared` preflight reporter.
3
+ *
4
+ * Read-only diagnostic that runs gitleaks against the LOCAL session
5
+ * transcripts `nomad push` would stage (each path-map entry mapped to this
6
+ * host), surfacing secret leaks BEFORE the push pipeline fires. Mirrors the
7
+ * push-time scan (`runGitleaksScan` in `./push-gitleaks.ts`) but: scans a
8
+ * temp COPY of the live transcripts (never the live dir), uses the
9
+ * purpose-built `gitleaks dir` subcommand, and emits doctor-flavored glyph
10
+ * rows + `process.exitCode` instead of throwing a push-flavored FATAL.
11
+ *
12
+ * Composition only: reuses `partitionFindings` / `readGitleaksReport` /
13
+ * `SESSION_PATH` (the gitleaks JSON parser) and `copyDirJsonlOnly` (the
14
+ * push-fidelity source filter) verbatim. The doctor-flavored guidance
15
+ * composer is new (push's `buildSessionAwareFatal` is wrong at doctor time:
16
+ * `nomad drop-session` operates on the staged tree, and nothing is staged
17
+ * during a preflight).
18
+ *
19
+ * All external calls use `execFileSync` argv-array form (no shell), the
20
+ * codebase PUSH-04 invariant.
21
+ */
22
+
23
+ import { randomBytes } from 'node:crypto';
24
+ import { execFileSync } from 'node:child_process';
25
+ import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
26
+ import { homedir } from 'node:os';
27
+ import { join } from 'node:path';
28
+
29
+ import { green, red, yellow, okGlyph, failGlyph, warnGlyph } from './color.ts';
30
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
31
+ import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
32
+ import { type Finding, partitionFindings, readGitleaksReport } from './push-gitleaks.ts';
33
+ import { copyDirJsonlOnly } from './remap.ts';
34
+ import { encodePath, nowTimestamp, readJson } from './utils.ts';
35
+
36
+ /**
37
+ * Result of staging the scan tree. `malformed` is true when `path-map.json`
38
+ * exists but does not parse as JSON; the caller emits a FAIL row and stops
39
+ * (mirroring `reportPathMap`'s `readJsonSafe` degradation) rather than letting
40
+ * the `SyntaxError` propagate past `nomad.ts`'s `NomadFatal`-only handler and
41
+ * abort the whole doctor run with a stack trace.
42
+ */
43
+ type ScanTree = {
44
+ logicalToEncoded: Map<string, string>;
45
+ staged: number;
46
+ malformed: boolean;
47
+ };
48
+
49
+ /**
50
+ * Build the temp staging tree under `tmpRoot/shared/projects/<logical>/` by
51
+ * copying each local encoded session dir that resolves to a path-map logical
52
+ * for this host. Returns the `logical -> encoded-dir` association so the
53
+ * scrub-path hint can name the live `~/.claude/projects/<encoded>/<sid>.jsonl`
54
+ * file, plus the count of session dirs staged. Skips `TBD`/unmapped entries
55
+ * (the D-03 scope: exactly what `remapPush` would stage). Uses the same
56
+ * depth-0 `*.jsonl` filter as push via `copyDirJsonlOnly`. A malformed
57
+ * `path-map.json` sets `malformed: true` rather than throwing.
58
+ */
59
+ function buildScanTree(tmpRoot: string): ScanTree {
60
+ const logicalToEncoded = new Map<string, string>();
61
+ let staged = 0;
62
+ const mapPath = join(REPO_HOME, 'path-map.json');
63
+ if (!existsSync(mapPath)) return { logicalToEncoded, staged, malformed: false };
64
+ let map: PathMap;
65
+ try {
66
+ map = readJson<PathMap>(mapPath);
67
+ } catch {
68
+ return { logicalToEncoded, staged, malformed: true };
69
+ }
70
+ if (typeof map.projects !== 'object' || map.projects === null) {
71
+ return { logicalToEncoded, staged, malformed: false };
72
+ }
73
+
74
+ const reverse = new Map<string, string>();
75
+ for (const [logical, hosts] of Object.entries(map.projects)) {
76
+ if (typeof hosts !== 'object' || hosts === null) continue;
77
+ const p = hosts[HOST];
78
+ if (!p || p === 'TBD') continue;
79
+ reverse.set(encodePath(p), logical);
80
+ }
81
+
82
+ const localProjects = join(CLAUDE_HOME, 'projects');
83
+ if (!existsSync(localProjects)) return { logicalToEncoded, staged, malformed: false };
84
+ for (const dir of readdirSync(localProjects)) {
85
+ const logical = reverse.get(dir);
86
+ if (!logical) continue;
87
+ copyDirJsonlOnly(join(localProjects, dir), join(tmpRoot, 'shared', 'projects', logical));
88
+ logicalToEncoded.set(logical, dir);
89
+ staged++;
90
+ }
91
+ return { logicalToEncoded, staged, malformed: false };
92
+ }
93
+
94
+ /**
95
+ * Probe for the gitleaks binary on PATH, distinguishing the not-installed case
96
+ * (ENOENT -> `'missing'`, a WARN skip per the read-only doctor contract) from a
97
+ * real probe failure (EACCES, corrupt binary -> `{ fail: message }`, a FAIL).
98
+ * Mirrors `reportGitleaksProbe`'s ENOENT-vs-other split rather than collapsing
99
+ * every failure into "not on PATH". Probes directly (not via `probeGitleaks`)
100
+ * so the doctor flavor stays read-only and need not unwrap a `NomadFatal`.
101
+ */
102
+ function probeGitleaksForScan(): 'ok' | 'missing' | { fail: string } {
103
+ try {
104
+ execFileSync('gitleaks', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] });
105
+ return 'ok';
106
+ } catch (err) {
107
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 'missing';
108
+ return { fail: (err as Error).message };
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Recover the live encoded-dir for a finding by mapping its `<logical>`
114
+ * segment through the staging association. Returns the absolute live
115
+ * transcript path `~/.claude/projects/<encoded>/<sid>.jsonl`, falling back to
116
+ * the logical name when the association is missing (defensive; the temp-tree
117
+ * model guarantees a hit).
118
+ */
119
+ function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, string>): string {
120
+ /* c8 ignore next -- the `?? logical` fallback is defensive; the temp-tree build keys every staged logical */
121
+ const encoded = logicalToEncoded.get(logical) ?? logical;
122
+ return join(CLAUDE_HOME, 'projects', encoded, `${sid}.jsonl`);
123
+ }
124
+
125
+ /**
126
+ * Emit one fail row per affected session plus rotate-and-scrub + allowlist
127
+ * guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
128
+ * `<logical>` segment captured from the same `SESSION_PATH` match that keyed
129
+ * `bySession`, so the scrub-path hint reuses the authoritative parse rather
130
+ * than re-deriving the logical name from the finding `File`. Every `bySession`
131
+ * sid is keyed in `logicalBySession` (both come from the identical sid capture),
132
+ * so the scrub hint always renders; the guard omits the hint rather than
133
+ * printing a wrong path if that invariant ever breaks, and the leak row itself
134
+ * is always emitted.
135
+ */
136
+ function reportSessionFindings(
137
+ section: DoctorSection,
138
+ bySession: Map<string, Map<string, number>>,
139
+ logicalBySession: Map<string, string>,
140
+ logicalToEncoded: Map<string, string>,
141
+ ): void {
142
+ for (const [sid, counts] of bySession) {
143
+ const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
144
+ addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
145
+ const logical = logicalBySession.get(sid);
146
+ /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
147
+ if (logical !== undefined) {
148
+ addItem(
149
+ section,
150
+ ` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
151
+ );
152
+ }
153
+ addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
154
+ }
155
+ process.exitCode = 1;
156
+ }
157
+
158
+ /**
159
+ * Emit one fail row per non-session ("other"-bucket) finding and set
160
+ * `process.exitCode = 1`. These are findings whose `File` did not match the
161
+ * flat `SESSION_PATH` shape (nested transcripts under `subagents/`, `memory/`,
162
+ * etc., which `copyDirJsonlOnly` copies recursively and `nomad push` would
163
+ * stage). Names the repo-relative path and RuleID only, never the matched
164
+ * secret. Mirrors the push-side guarantee that any finding outside `bySession`
165
+ * still fails the scan (`buildSessionAwareFatal`'s `LEGACY_FATAL` fallback).
166
+ */
167
+ function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
168
+ for (const f of other) {
169
+ addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
170
+ }
171
+ process.exitCode = 1;
172
+ }
173
+
174
+ /**
175
+ * Captures both the `<logical>` segment and the `<sid>` from a repo-relative
176
+ * `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
177
+ * the exported `SESSION_PATH` shape; the extra `<logical>` group lets the
178
+ * scrub-path hint reuse this single authoritative parse.
179
+ */
180
+ const SESSION_PATH_LOGICAL = /^shared\/projects\/([^/]+)\/([^/]+)\.jsonl$/;
181
+
182
+ /**
183
+ * Emit the single canonical clean row reporting the scanned-project count
184
+ * (`staged` is the number of mapped project directories whose transcripts were
185
+ * staged, not a transcript total). Centralizing the literal (zero-staged,
186
+ * scanned-clean, and the findings-but-no-`other` paths all route through here)
187
+ * keeps the phrasing consistent and prevents one copy drifting from another,
188
+ * which is what let a "no session findings == clean" path slip past the
189
+ * `other`-bucket gate.
190
+ */
191
+ function emitClean(section: DoctorSection, staged: number): void {
192
+ addItem(section, `${green(okGlyph)} ${staged} project(s) scanned, no leaks`);
193
+ }
194
+
195
+ /**
196
+ * Run the `--check-shared` preflight and append its rows to `section`.
197
+ *
198
+ * Flow (D-01..D-10): probe gitleaks (missing -> one WARN row, exit untouched;
199
+ * a non-ENOENT probe failure -> FAIL row + exit 1, mirroring
200
+ * `reportGitleaksProbe`); stage a temp copy of this-host mapped session dirs
201
+ * (a malformed `path-map.json` -> FAIL row + exit 1, no crash); scan with the
202
+ * positional `gitleaks dir shared/projects` invocation (NOT `--source`, which
203
+ * `gitleaks dir` rejects with exit 126); on a clean scan emit one ok row
204
+ * reporting the scanned-project count; on findings emit per-session fail rows
205
+ * with rotate-and-scrub guidance and set `process.exitCode = 1`; on a non-zero
206
+ * exit with no parseable report emit a scan-failed fail row carrying the
207
+ * gitleaks error message (never its stderr/stdout, which may hold secrets) +
208
+ * exit 1 (do not chase phantom sessions). Removes both the temp report and the
209
+ * temp tree in `finally` on success and failure. Never writes to stderr
210
+ * (read-only doctor contract).
211
+ *
212
+ * `gitleaksReady` lets the doctor orchestrator pass the result of the
213
+ * Repository section's gitleaks probe so the `version` subcommand is not
214
+ * invoked a second time on a `--check-shared` run. When omitted (the module's
215
+ * standalone contract) this reporter probes for itself.
216
+ */
217
+ export function reportCheckShared(section: DoctorSection, gitleaksReady?: boolean): void {
218
+ if (gitleaksReady !== true) {
219
+ const probe = probeGitleaksForScan();
220
+ if (probe === 'missing') {
221
+ addItem(section, `${yellow(warnGlyph)} gitleaks not on PATH; shared scan skipped`);
222
+ return;
223
+ }
224
+ if (probe !== 'ok') {
225
+ addItem(section, `${red(failGlyph)} gitleaks probe failed: ${probe.fail}`);
226
+ process.exitCode = 1;
227
+ return;
228
+ }
229
+ }
230
+
231
+ const cacheDir = join(homedir(), '.cache', 'claude-nomad');
232
+ mkdirSync(cacheDir, { recursive: true });
233
+ // nowTimestamp() is second-resolution and --check-shared takes no lock
234
+ // (read-only), so two same-second, same-pid invocations would otherwise
235
+ // share a stamp and clobber each other's temp tree / report. The random
236
+ // suffix makes the stamp collision-resistant, matching the push report path.
237
+ const stamp = `${nowTimestamp()}-${process.pid}-${randomBytes(4).toString('hex')}`;
238
+ const reportPath = join(cacheDir, `check-shared-${stamp}.json`);
239
+ const tmpRoot = join(cacheDir, `check-shared-tree-${stamp}`);
240
+
241
+ try {
242
+ const { logicalToEncoded, staged, malformed } = buildScanTree(tmpRoot);
243
+ if (malformed) {
244
+ addItem(section, `${red(failGlyph)} path-map.json malformed JSON; shared scan skipped`);
245
+ process.exitCode = 1;
246
+ return;
247
+ }
248
+ if (staged === 0) {
249
+ // No path-map entry maps to this host (or all are TBD). Nothing would be
250
+ // staged by push either, so report clean without invoking gitleaks (a
251
+ // scan of a non-existent dir would exit non-zero and misfire).
252
+ emitClean(section, 0);
253
+ return;
254
+ }
255
+ const tomlPath = join(REPO_HOME, '.gitleaks.toml');
256
+ const args: string[] = [
257
+ 'dir',
258
+ 'shared/projects',
259
+ '--redact',
260
+ '-v',
261
+ '--report-format=json',
262
+ `--report-path=${reportPath}`,
263
+ ];
264
+ if (existsSync(tomlPath)) args.push('--config', tomlPath);
265
+
266
+ try {
267
+ execFileSync('gitleaks', args, { cwd: tmpRoot, stdio: ['ignore', 'pipe', 'pipe'] });
268
+ emitClean(section, staged);
269
+ } catch (err) {
270
+ const findings = readGitleaksReport(reportPath);
271
+ if (findings === null) {
272
+ // Carry the gitleaks error message only (never e.stderr/e.stdout, which
273
+ // can echo the redacted-but-still-sensitive scan output), matching
274
+ // runGitleaksScan on the push side.
275
+ const msg = (err as Error).message;
276
+ addItem(section, `${red(failGlyph)} scan failed: no parseable gitleaks report (${msg})`);
277
+ process.exitCode = 1;
278
+ return;
279
+ }
280
+ const { bySession, other } = partitionFindings(findings);
281
+ // Both buckets must gate the clean row. A finding routed to `other`
282
+ // (nested transcripts that match neither the flat SESSION_PATH nor any
283
+ // session) is still a stageable secret push would catch, so reporting
284
+ // clean on `bySession.size === 0` alone would make the preflight weaker
285
+ // than the push scan it stands in for.
286
+ if (bySession.size === 0 && other.length === 0) {
287
+ emitClean(section, staged);
288
+ return;
289
+ }
290
+ if (other.length > 0) reportOtherFindings(section, other);
291
+ if (bySession.size > 0) {
292
+ // Capture <logical> alongside <sid> from the same authoritative match
293
+ // so the scrub hint never re-derives the logical name independently.
294
+ const logicalBySession = new Map<string, string>();
295
+ for (const f of findings) {
296
+ const m = SESSION_PATH_LOGICAL.exec(f.File);
297
+ if (m?.[2] !== undefined && !logicalBySession.has(m[2])) {
298
+ /* c8 ignore next -- `?? ''` is defensive; group 1 is always captured when the match succeeds */
299
+ logicalBySession.set(m[2], m[1] ?? '');
300
+ }
301
+ }
302
+ reportSessionFindings(section, bySession, logicalBySession, logicalToEncoded);
303
+ }
304
+ }
305
+ } finally {
306
+ rmSync(reportPath, { force: true });
307
+ rmSync(tmpRoot, { recursive: true, force: true });
308
+ }
309
+ }