claude-nomad 0.24.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,39 @@
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
+
3
37
  ## [0.24.0](https://github.com/funkadelic/claude-nomad/compare/v0.23.0...v0.24.0) (2026-05-24)
4
38
 
5
39
 
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
 
@@ -334,9 +334,9 @@ nomad update
334
334
  `nomad update` (see `cmdUpdate` in `src/commands.update.ts`) detects which layout your `~/claude-nomad/` uses and does the right thing:
335
335
 
336
336
  - **vanilla** (`origin` points at the public repo): `git pull --ff-only origin main`.
337
- - **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`.
338
338
 
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, 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.
340
340
 
341
341
  Common cases:
342
342
 
@@ -369,15 +369,17 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
369
369
  | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
370
370
  | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
371
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` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` is preserved. See [Recovery flows](#recovery-flows). |
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
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. |
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
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
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
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. |
378
378
 
379
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.
380
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
+
381
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:
382
384
 
383
385
  ```text
@@ -392,7 +394,7 @@ Every `nomad pull`, `nomad push`, and `nomad diff` run ends with a single `summa
392
394
 
393
395
  ### `nomad drop-session <id>`
394
396
 
395
- 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.
396
398
 
397
399
  ```bash
398
400
  nomad drop-session <id>
@@ -400,14 +402,14 @@ nomad drop-session <id>
400
402
 
401
403
  Single positional id (the session filename minus `.jsonl`). Anything else (missing id, leading dash, extra arg) exits 1 with a `usage:` line.
402
404
 
403
- 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.
404
406
 
405
407
  Exit codes:
406
408
 
407
409
  - `0` on any drop, including an idempotent re-run.
408
- - `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.
409
411
 
410
- 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.
411
413
 
412
414
  ### Recovery flow: gitleaks FATAL on a session JSONL
413
415
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.24.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,132 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { green, okGlyph, warnGlyph, yellow } from './color.ts';
6
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
7
+ import { GITLEAKS_PINNED_VERSION, REPO_HOME } from './config.ts';
8
+ import type { SpawnSyncFn } from './gh-actions.ts';
9
+
10
+ /**
11
+ * Soft gitleaks version-drift check appended to the Version section of
12
+ * `nomad doctor`. Parses `gitleaks version` stdout and compares its
13
+ * major.minor against `GITLEAKS_PINNED_VERSION` (the value CI installs),
14
+ * emitting one of:
15
+ * - `✓ gitleaks: X.Y.Z (matches pinned A.B)` when major.minor agree
16
+ * - `⚠︎ gitleaks: <local> -> <pin> (CI pins this; local drift may change scan results)`
17
+ * when major.minor diverge
18
+ * Only major.minor is compared: a patch-only difference is treated as OK,
19
+ * because gitleaks rule/allowlist behavior tracks the minor line, not the
20
+ * patch. Every failure path (gitleaks absent, subprocess error, unparseable
21
+ * or two-segment output) is a SILENT skip; this module never sets
22
+ * `process.exitCode` and never writes to stderr, mirroring the sibling
23
+ * release-version and node-engine checks.
24
+ */
25
+
26
+ /** Strict three-segment matcher capturing major and minor. Anchored on both
27
+ * ends so a two-segment string like `8.30` does not parse (feeding such a
28
+ * value to a triple-segment comparator would be undecidable). */
29
+ const SEMVER_MAJOR_MINOR = /^(\d+)\.(\d+)\.\d+$/;
30
+
31
+ /** Hard cap on the `gitleaks version` subprocess (matching the gh-actions
32
+ * primitives' `GH_TIMEOUT_MS` convention) so a wedged binary cannot hang the
33
+ * synchronous doctor run; the timeout throws and is swallowed as a silent skip. */
34
+ const GITLEAKS_TIMEOUT_MS = 5_000;
35
+
36
+ /**
37
+ * Capture the `[major, minor]` pair from a strict `X.Y.Z` semver string.
38
+ * Returns `null` when the input does not match a three-segment semver (e.g. a
39
+ * two-segment `8.30`, or non-numeric noise), which the caller treats as a
40
+ * silent skip.
41
+ *
42
+ * @param value - Candidate version string (already trimmed).
43
+ * @returns A `[major, minor]` tuple of bare numeric strings, or `null`.
44
+ */
45
+ function majorMinorOf(value: string): [string, string] | null {
46
+ const m = SEMVER_MAJOR_MINOR.exec(value);
47
+ return m === null ? null : [m[1], m[2]];
48
+ }
49
+
50
+ /**
51
+ * Run `gitleaks version` via the injected runner and return the trimmed
52
+ * stdout, or `null` on any throw (missing binary, subprocess failure). Mirrors
53
+ * the `probeGitleaks` invocation form in `push-checks.ts`: argv-array
54
+ * `execFileSync` (no shell), piped stdio, and a conditional
55
+ * `--config <REPO_HOME>/.gitleaks.toml` when that allowlist exists at call
56
+ * time, plus a `GITLEAKS_TIMEOUT_MS` cap so a wedged binary cannot hang the
57
+ * synchronous doctor run. Swallowing the error here is what makes both the
58
+ * absent-gitleaks case and a timeout a silent skip rather than a doctor failure.
59
+ *
60
+ * @param run - Injectable subprocess runner; defaults to `execFileSync`.
61
+ * @param tomlExists - Injectable allowlist-file existence check; defaults to
62
+ * `existsSync`. Injected in tests so the `--config` branch is exercised
63
+ * independent of the host filesystem (REPO_HOME varies per host and in CI).
64
+ * @returns The trimmed `gitleaks version` output, or `null` on any failure.
65
+ */
66
+ function readGitleaksVersion(
67
+ run: SpawnSyncFn,
68
+ tomlExists: (path: string) => boolean,
69
+ ): string | null {
70
+ const tomlPath = join(REPO_HOME, '.gitleaks.toml');
71
+ const args: string[] = ['version'];
72
+ if (tomlExists(tomlPath)) args.push('--config', tomlPath);
73
+ try {
74
+ return run('gitleaks', args, {
75
+ stdio: ['ignore', 'pipe', 'pipe'],
76
+ timeout: GITLEAKS_TIMEOUT_MS,
77
+ })
78
+ .toString()
79
+ .trim();
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Emit a single, non-fatal gitleaks version-drift diagnostic for
87
+ * `nomad doctor` by comparing the host's `gitleaks version` major.minor to
88
+ * `GITLEAKS_PINNED_VERSION`.
89
+ *
90
+ * Logs one of:
91
+ * - `✓ gitleaks: X.Y.Z (matches pinned A.B)` when the major.minor agree
92
+ * (including a patch-only difference from the pin)
93
+ * - `⚠︎ gitleaks: <local> -> <pin> (...)` when the major.minor diverge
94
+ *
95
+ * A missing gitleaks binary, a subprocess error, or output that does not match
96
+ * a strict `X.Y.Z` semver results in no output and does not change
97
+ * `process.exitCode`.
98
+ *
99
+ * @param section - The Version section to append the diagnostic line to.
100
+ * @param run - Injectable subprocess runner; defaults to `execFileSync`.
101
+ * @param tomlExists - Injectable allowlist-file existence check; defaults to
102
+ * `existsSync`. Mirrors the `run` seam so tests cover the `--config` branch
103
+ * deterministically.
104
+ */
105
+ export function reportGitleaksVersionCheck(
106
+ section: DoctorSection,
107
+ run: SpawnSyncFn = execFileSync,
108
+ tomlExists: (path: string) => boolean = existsSync,
109
+ ): void {
110
+ const raw = readGitleaksVersion(run, tomlExists);
111
+ if (raw === null) return;
112
+ const local = majorMinorOf(raw);
113
+ if (local === null) return;
114
+ const pin = majorMinorOf(GITLEAKS_PINNED_VERSION);
115
+ // Defensive: GITLEAKS_PINNED_VERSION is a hardcoded strict semver, so this
116
+ // never fires in practice; skip silently rather than risk a false WARN if a
117
+ // future edit ever malforms the constant.
118
+ /* c8 ignore next */
119
+ if (pin === null) return;
120
+ // Compare major.minor ONLY (D-02). Inline numeric compare on the captured
121
+ // segments; do NOT feed a two-segment string to compareSemver (its
122
+ // triple-segment contract returns an undecidable 0).
123
+ const sameMajorMinor = local[0] === pin[0] && local[1] === pin[1];
124
+ if (sameMajorMinor) {
125
+ addItem(section, `${green(okGlyph)} gitleaks: ${raw} (matches pinned ${pin[0]}.${pin[1]})`);
126
+ return;
127
+ }
128
+ addItem(
129
+ section,
130
+ `${yellow(warnGlyph)} gitleaks: ${raw} -> ${GITLEAKS_PINNED_VERSION} (CI pins this; local drift may change scan results)`,
131
+ );
132
+ }
@@ -0,0 +1,83 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ import { warnGlyph, yellow } from './color.ts';
4
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
5
+ import { REPO_HOME } from './config.ts';
6
+ import {
7
+ ghAuthStatus,
8
+ isActionsEnabled,
9
+ isRepoPrivate,
10
+ parseGitHubRemote,
11
+ readOriginRemote,
12
+ type SpawnSyncFn,
13
+ } from './gh-actions.ts';
14
+
15
+ /**
16
+ * Drift check appended to the Repository section of `nomad doctor`. WARNs (never
17
+ * FAILs, never sets `process.exitCode`) when the origin remote is a private
18
+ * GitHub mirror that is gh-authed with Actions re-enabled, the quiet failure
19
+ * mode where Actions get turned back on after `nomad init` auto-disabled them
20
+ * (via the GitHub web UI or a stray `gh` call) and the mirror starts firing its
21
+ * workflows on every push again.
22
+ *
23
+ * Reuses the five `gh-actions.ts` primitives unchanged (no new gh wrapper) and
24
+ * clones the `cmdInit` auto-disable gate ORDER, but strips every tip-log and
25
+ * the `disableActions` call: doctor is read-only and SILENT on every miss where
26
+ * init is chatty. The gate chain returns with no output when any of these hold:
27
+ * gate 1 - the origin remote cannot be read (not a git repo / no origin)
28
+ * gate 2 - the origin is not a GitHub URL (`parseGitHubRemote` null)
29
+ * gate 3 - `gh` is not installed or not authed
30
+ * gate 4 - the repo is public, or the privacy probe throws
31
+ * gate 5 - Actions are already disabled, or the Actions probe throws
32
+ * Only when all five gates pass does it emit the single yellow WARN line with a
33
+ * `gh api -X PUT ... -F enabled=false` remediation hint (the exact shape
34
+ * `disableActions` would run). The three gh probes (gates 3-5) carry the shared
35
+ * `GH_TIMEOUT_MS` internally; gates 1-2 are local (a `git remote get-url` config
36
+ * read and a regex parse), so no network and no timeout needed. No new doctor
37
+ * section; no opt-out flag.
38
+ *
39
+ * @param section - The Repository section to append the WARN line to.
40
+ * @param run - Injectable subprocess runner; defaults to `execFileSync`.
41
+ */
42
+ export function reportMirrorActions(section: DoctorSection, run: SpawnSyncFn = execFileSync): void {
43
+ // Gate 1: origin remote. Throws on no remote / non-repo -> silent skip.
44
+ let remote: string;
45
+ try {
46
+ remote = readOriginRemote(REPO_HOME, run);
47
+ } catch {
48
+ return;
49
+ }
50
+
51
+ // Gate 2: GitHub remote. Non-GitHub URL parses to null -> silent skip.
52
+ const ref = parseGitHubRemote(remote);
53
+ if (ref === null) return;
54
+
55
+ // Gate 3: gh available and authed. Doctor stays silent on both miss reasons
56
+ // (init prints a tip here; doctor does not, per the read-only contract).
57
+ if (ghAuthStatus(run) !== null) return;
58
+
59
+ // Gate 4: private mirror. A public repo, or a probe that throws, is a skip.
60
+ let isPrivate: boolean;
61
+ try {
62
+ isPrivate = isRepoPrivate(ref, run);
63
+ } catch {
64
+ return;
65
+ }
66
+ if (!isPrivate) return;
67
+
68
+ // Gate 5: Actions enabled. Already-disabled, or a probe that throws, is a skip.
69
+ let enabled: boolean;
70
+ try {
71
+ enabled = isActionsEnabled(ref, run);
72
+ } catch {
73
+ return;
74
+ }
75
+ if (!enabled) return;
76
+
77
+ // All gates passed: the private mirror has Actions re-enabled. Emit the
78
+ // single yellow WARN with the exact disable command as the remediation hint.
79
+ addItem(
80
+ section,
81
+ `${yellow(warnGlyph)} mirror Actions: enabled on private mirror ${ref.owner}/${ref.repo} (re-disable with 'gh api -X PUT repos/${ref.owner}/${ref.repo}/actions/permissions -F enabled=false')`,
82
+ );
83
+ }
@@ -15,6 +15,8 @@ import {
15
15
  import { reportCheckShared } from './commands.doctor.check-shared.ts';
16
16
  import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
17
17
  import { renderDoctor, section } from './commands.doctor.format.ts';
18
+ import { reportGitleaksVersionCheck } from './commands.doctor.gitleaks-version.ts';
19
+ import { reportMirrorActions } from './commands.doctor.mirror-actions.ts';
18
20
  import { reportVersionCheck } from './commands.doctor.version.ts';
19
21
 
20
22
  /**
@@ -55,15 +57,18 @@ export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
55
57
  reportGitlinks(repository);
56
58
  reportRemote(repository);
57
59
  reportRebaseClean(repository);
60
+ reportMirrorActions(repository);
58
61
 
59
62
  const version = section('Version');
60
63
  reportVersionCheck(version);
61
64
  reportNodeEngineCheck(version);
65
+ reportGitleaksVersionCheck(version);
62
66
 
63
67
  const sharedScan = section('Shared scan');
64
- // Pass the Repository-section probe result so gitleaks `version` is not
65
- // invoked a second time on a --check-shared run; reportCheckShared still
66
- // probes for itself when called standalone.
68
+ // Reuse the Repository-section readiness probe so reportCheckShared does not
69
+ // re-spawn gitleaks for its own readiness on a --check-shared run; it still
70
+ // probes standalone when called without a prior result. (The Version-section
71
+ // drift check above spawns `gitleaks version` separately, by design.)
67
72
  if (opts.checkShared === true) reportCheckShared(sharedScan, gitleaksReady);
68
73
 
69
74
  renderDoctor([version, host, links, settings, pathMap, neverSync, repository, sharedScan]);
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, readdirSync } from 'node:fs';
2
+ import { existsSync, readdirSync, statSync } from 'node:fs';
3
3
  import { join, relative } from 'node:path';
4
4
 
5
5
  import { REPO_HOME } from './config.ts';
@@ -7,27 +7,32 @@ import { acquireLock, die, fail, log, NomadFatal, releaseLock } from './utils.ts
7
7
 
8
8
  /**
9
9
  * Surgical removal of a contaminated session from the staged tree of
10
- * `~/claude-nomad/`. Walks `shared/projects/<logical>/<id>.jsonl` at the
11
- * top level only, classifies each match via `git ls-files --error-unmatch`,
12
- * and unstages with the appropriate primitive:
10
+ * `~/claude-nomad/`. For each `shared/projects/<logical>/` child this
11
+ * matches both the flat `<id>.jsonl` AND the sibling subagent directory
12
+ * `<id>/` (whose nested transcripts are keyed by the same session id),
13
+ * classifies each staged entry via `git ls-files --error-unmatch`, and
14
+ * unstages with the appropriate primitive:
13
15
  *
14
16
  * - tracked-in-HEAD -> `git restore --staged --worktree -- <rel>`
15
17
  * - newly-staged -> `git rm --cached -f -- <rel>`
16
18
  *
17
- * Idempotent: files that are not in the index at all are skipped silently
18
- * rather than treated as errors. Exits 0 on any drop, including an
19
- * idempotent re-run that finds the matches already absent. Exits 1 with
20
- * `✗ no staged session matches <id>` (red `✗` fail glyph) only when no
21
- * `shared/projects/<logical>/<id>.jsonl` exists at all in the shared tree.
19
+ * The directory tree is expanded into its staged entries via
20
+ * `git ls-files -z -- <dir-rel>` so every nested file flows through the
21
+ * same per-entry classification loop as the flat jsonl; this closes the
22
+ * leak where a "dropped" session still shipped its subagent transcripts.
23
+ *
24
+ * Idempotent: entries not in the index are skipped silently. Exits 0 on
25
+ * any drop (including an idempotent re-run); exits 1 with `✗ no staged
26
+ * session matches <id>` only when neither a flat `<id>.jsonl` nor a
27
+ * `<id>/` directory with staged entries exists anywhere in the tree.
22
28
  *
23
29
  * Defense-in-depth: the id is validated against the same allowlist regex
24
30
  * used in `src/resume.ts` before any path composition. argv-array form
25
31
  * for every git invocation.
26
32
  *
27
- * NEVER touches `~/.claude/projects/<encoded>/<id>.jsonl`. The local file
28
- * is preserved so it can race-safely coexist with active Claude Code
29
- * writers; rotate-and-scrub of the local copy is the user's
30
- * responsibility.
33
+ * NEVER touches `~/.claude/projects/<encoded>/<id>.jsonl` or the local
34
+ * `<id>/` tree; the local copies are preserved so they race-safely
35
+ * coexist with active Claude Code writers.
31
36
  *
32
37
  * @param id Session id (filename minus `.jsonl`). Must match `[A-Za-z0-9_-]+`
33
38
  * with length 1..128.
@@ -49,21 +54,32 @@ export function cmdDropSession(id: string): void {
49
54
  if (!existsSync(repoProjects)) {
50
55
  throw new NomadFatal(`no staged session matches ${id}`);
51
56
  }
52
- // Top-level walk only: for each `shared/projects/<logical>/` child,
53
- // check whether `<id>.jsonl` exists. No descent into
54
- // subagents/memory/tool-results subdirectories.
57
+ // For each `shared/projects/<logical>/` child, match the flat
58
+ // `<id>.jsonl` plus the sibling subagent directory `<id>/`. The
59
+ // directory is expanded into its staged entries so every nested file
60
+ // flows through the same per-entry unstage loop as the flat jsonl.
55
61
  const matches: string[] = [];
56
62
  for (const logical of readdirSync(repoProjects)) {
57
63
  const candidate = join(repoProjects, logical, `${id}.jsonl`);
58
64
  if (existsSync(candidate)) {
59
- matches.push(candidate);
65
+ matches.push(relative(REPO_HOME, candidate));
66
+ }
67
+ const dir = join(repoProjects, logical, id);
68
+ if (existsSync(dir) && statSync(dir).isDirectory()) {
69
+ const dirRel = relative(REPO_HOME, dir);
70
+ const staged = expandStagedDir(dirRel);
71
+ // A dir present on disk but absent from the index is an already-dropped
72
+ // rerun: push the dir path itself so the per-entry isInIndex() guard
73
+ // logs it as "already absent" rather than letting an empty match set
74
+ // escalate to the no-match fatal (idempotency for dir-only sessions).
75
+ if (staged.length > 0) matches.push(...staged);
76
+ else matches.push(dirRel);
60
77
  }
61
78
  }
62
79
  if (matches.length === 0) {
63
80
  throw new NomadFatal(`no staged session matches ${id}`);
64
81
  }
65
- for (const m of matches) {
66
- const rel = relative(REPO_HOME, m);
82
+ for (const rel of matches) {
67
83
  // Pitfall 7: skip files that are not in the index at all (the
68
84
  // load-bearing guard for the idempotent second-run case, where the
69
85
  // first drop already removed the entry from the index but left the
@@ -120,6 +136,31 @@ export function cmdDropSession(id: string): void {
120
136
  }
121
137
  }
122
138
 
139
+ /**
140
+ * Expand a repo-relative directory into its staged entries via
141
+ * `git ls-files -z -- <dirRel>` (argv-array form, NUL-split for path
142
+ * safety). Returns repo-relative POSIX paths for every staged file under
143
+ * the directory, or an empty array when none are staged or `git` fails
144
+ * (missing/corrupt index); the caller then falls through to the existing
145
+ * per-entry idempotency guard rather than escalating to a FATAL.
146
+ *
147
+ * @param dirRel Repo-relative directory path (`shared/projects/<logical>/<id>`).
148
+ */
149
+ function expandStagedDir(dirRel: string): string[] {
150
+ try {
151
+ const out = execFileSync('git', ['ls-files', '-z', '--', dirRel], {
152
+ cwd: REPO_HOME,
153
+ stdio: ['ignore', 'pipe', 'pipe'],
154
+ });
155
+ return out
156
+ .toString()
157
+ .split('\0')
158
+ .filter((p) => p !== '');
159
+ } catch {
160
+ return [];
161
+ }
162
+ }
163
+
123
164
  /**
124
165
  * Is `rel` (repo-relative path) present in the HEAD tree? Wraps
125
166
  * `git cat-file -e HEAD:<rel>`: exit 0 means tracked in HEAD,
@@ -85,22 +85,28 @@ export function parsePorcelainZ(statusPorcelain: string): string[] {
85
85
  * Reject any staged path that is not on the push allow-list or that matches a
86
86
  * `NEVER_SYNC` entry. Builds the runtime allow-list by combining
87
87
  * `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
88
- * in `path-map.json` AND one `shared/extras/<logical>/<dirname>/` prefix per
89
- * (logical, whitelisted dirname) pair in `map.extras ?? {}` (Pitfall 4 closed:
90
- * data-driven, no hand-rolled bypass). The dirname filter (`SUPPORTED_EXTRAS`)
91
- * is the same one `remapExtrasPush` honors, so manually staged content under a
92
- * non-whitelisted dirname surfaces as a FATAL instead of riding through on the
93
- * logical-only prefix. Logs every violation as a FATAL line so the user sees
94
- * the full set (not just the first), then throws `NomadFatal` to unwind the
95
- * caller's try/finally and release the push lock.
88
+ * in `path-map.json` AND, per (logical, whitelisted name) pair in
89
+ * `map.extras ?? {}`, an exact `shared/extras/<logical>/<name>` entry plus a
90
+ * `shared/extras/<logical>/<name>/` prefix entry (Pitfall 4 closed:
91
+ * data-driven, no hand-rolled bypass). The exact entry permits the declared
92
+ * name when it is a single root file (e.g. `CLAUDE.md`); the prefix entry
93
+ * permits the declared name's subtree when it is a directory. Neither widens
94
+ * to a logical-only prefix, so an arbitrary sibling file under the same
95
+ * logical stays rejected. The name filter (`SUPPORTED_EXTRAS`) is the same one
96
+ * `remapExtrasPush` honors, so manually staged content under a non-whitelisted
97
+ * name surfaces as a FATAL instead of riding through. Logs every violation as
98
+ * a FATAL line so the user sees the full set (not just the first), then throws
99
+ * `NomadFatal` to unwind the caller's try/finally and release the push lock.
96
100
  */
97
101
  export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
98
102
  const extrasWhitelist: readonly string[] = SUPPORTED_EXTRAS;
99
103
  const allowed = [
100
104
  ...PUSH_ALLOWED_STATIC,
101
105
  ...Object.keys(map.projects).map((l) => `shared/projects/${l}/`),
102
- ...Object.entries(map.extras ?? {}).flatMap(([l, dirnames]) =>
103
- dirnames.filter((d) => extrasWhitelist.includes(d)).map((d) => `shared/extras/${l}/${d}/`),
106
+ ...Object.entries(map.extras ?? {}).flatMap(([l, names]) =>
107
+ names
108
+ .filter((n) => extrasWhitelist.includes(n))
109
+ .flatMap((n) => [`shared/extras/${l}/${n}`, `shared/extras/${l}/${n}/`]),
104
110
  ),
105
111
  ];
106
112
  const neverSyncHits: string[] = [];
@@ -193,7 +199,13 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
193
199
  }
194
200
  // Routed through the shell-free, untrimmed helper because `sh` would .trim()
195
201
  // the leading status-space and shift parsePorcelainZ's offsets.
196
- const status = gitStatusPorcelainZ(REPO_HOME);
202
+ // `untrackedAll` (issue #111): the allow-list runs on this snapshot BEFORE
203
+ // `git add -A`. Without it, a fresh host whose entire `shared/extras/`
204
+ // subtree is untracked yields a single collapsed `?? shared/extras/`
205
+ // record that the `shared/extras/<logical>/<dirname>/` child prefix cannot
206
+ // match, so the first extras push is rejected. Expanding to per-file paths
207
+ // lets the existing allow-list accept them while keeping the gate order.
208
+ const status = gitStatusPorcelainZ(REPO_HOME, { untrackedAll: true });
197
209
  if (!status) {
198
210
  log('nothing to commit');
199
211
  // Combine session-unmapped and extras-unmapped into one user-visible
@@ -3,6 +3,7 @@ import { closeSync, existsSync, openSync, readSync } from 'node:fs';
3
3
 
4
4
  import { cmdDoctor } from './commands.doctor.ts';
5
5
  import { REPO_HOME } from './config.ts';
6
+ import { commitRegeneratedLockfile, precommitForkExtras } from './update.fork-extras.ts';
6
7
  import { loadTopology } from './update.topology.ts';
7
8
  import { die, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, warn } from './utils.ts';
8
9
 
@@ -277,12 +278,21 @@ function tryAutoResolveMergeConflict(opts: CmdUpdateOpts): boolean {
277
278
  * pushes when the answer is `y` or `yes` (case-insensitive). Non-affirmative
278
279
  * answers skip the push and log a "run later" hint.
279
280
  *
281
+ * When the merge (and any extras precommit) leaves `HEAD` unchanged from
282
+ * `beforeSha`, there is nothing new to push: the function logs a one-line
283
+ * "already in sync" and returns without pushing or prompting (issue #66). An
284
+ * auto-resolved conflict always advances `HEAD` via its merge commit, so that
285
+ * path is never mistaken for a no-op.
286
+ *
280
287
  * @param opts - Update options; respected fields are:
281
288
  * - `dryRun`: when true, log actions instead of executing them
282
289
  * - `pushOrigin`: when true, push to `origin/main` without prompting
283
290
  * - `prompt`: optional prompt function used for interactive confirmation
291
+ * @param beforeSha - `HEAD` SHA captured before the fork update began; the
292
+ * post-merge `HEAD` is compared against it to detect a no-op. When omitted
293
+ * (dry-run preview) the no-op short-circuit is skipped.
284
294
  */
285
- function runFork(opts: CmdUpdateOpts): boolean {
295
+ function runFork(opts: CmdUpdateOpts, beforeSha?: string): boolean {
286
296
  const promptFn = opts.prompt ?? defaultPrompt;
287
297
  if (opts.dryRun === true) {
288
298
  log('DRY-RUN: would run `git fetch upstream`');
@@ -295,6 +305,10 @@ function runFork(opts: CmdUpdateOpts): boolean {
295
305
  return false;
296
306
  }
297
307
  gitOrFatal(['fetch', 'upstream'], 'git fetch upstream', REPO_HOME);
308
+ // Pre-commit whitelisted extras (issue #112): otherwise untracked
309
+ // shared/extras/ content that upstream also adds makes the merge abort
310
+ // pre-merge with no UU state, so the lone-lockfile auto-resolve never fires.
311
+ precommitForkExtras();
298
312
  let autoResolved = false;
299
313
  try {
300
314
  gitOrFatal(['merge', 'upstream/main'], 'git merge upstream/main', REPO_HOME);
@@ -302,6 +316,13 @@ function runFork(opts: CmdUpdateOpts): boolean {
302
316
  if (!tryAutoResolveMergeConflict(opts)) throw err;
303
317
  autoResolved = true;
304
318
  }
319
+ // No-op merge (and no extras precommit): HEAD never moved, so there is
320
+ // nothing new to push. A `beforeSha` of undefined (dry-run never reaches
321
+ // here) can never equal a real SHA, so the comparison is self-guarding.
322
+ if (headSha() === beforeSha) {
323
+ log('already in sync with origin/main, nothing to push');
324
+ return autoResolved;
325
+ }
305
326
  if (opts.pushOrigin === true) {
306
327
  gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
307
328
  return autoResolved;
@@ -376,8 +397,12 @@ export function cmdUpdate(opts: CmdUpdateOpts = {}): void {
376
397
  }
377
398
 
378
399
  const beforeSha = headSha();
379
- const installAlreadyRan = topology === 'vanilla' ? runVanilla(opts) : runFork(opts);
400
+ const installAlreadyRan = topology === 'vanilla' ? runVanilla(opts) : runFork(opts, beforeSha);
380
401
 
381
402
  if (!installAlreadyRan) reinstallIfNeeded(beforeSha);
403
+ // Secondary item of issue #112: a post-merge `npm install` that regenerated
404
+ // package-lock.json leaves uncommitted drift the trailing doctor flags.
405
+ // Commit just the lockfile (fork topology only) so the repo is clean.
406
+ if (topology === 'fork') commitRegeneratedLockfile();
382
407
  cmdDoctor();
383
408
  }
package/src/config.ts CHANGED
@@ -36,6 +36,17 @@ export const REPO_HOME = process.env.NOMAD_REPO || resolve(HOME, 'claude-nomad')
36
36
  */
37
37
  export const UPSTREAM_REPO_SLUG = 'funkadelic/claude-nomad';
38
38
 
39
+ /**
40
+ * Pinned gitleaks version. Single source of truth for the gitleaks pin used by
41
+ * `nomad doctor`'s version-drift check (`reportGitleaksVersionCheck`), which
42
+ * WARNs when the host's installed gitleaks major.minor diverges from this
43
+ * value. Mirrors the `GITLEAKS_VERSION` env in both `.github/workflows/tests.yml`
44
+ * and `.github/workflows/gitleaks.yml`; `config.gitleaks-pin.test.ts` asserts
45
+ * all three stay in lockstep so a CI bump that misses this constant (or vice
46
+ * versa) fails the suite. Bump here and in both workflow YAMLs together.
47
+ */
48
+ export const GITLEAKS_PINNED_VERSION = '8.30.1';
49
+
39
50
  /**
40
51
  * Resolved host identity used to pick `hosts/<HOST>.json` and key entries in
41
52
  * `path-map.json`. Reads `NOMAD_HOST` first, falls back to `hostname()`, then
@@ -58,16 +69,18 @@ export const SHARED_LINKS = [
58
69
  ] as const;
59
70
 
60
71
  /**
61
- * Whitelist of directory names allowed in `path-map.json`'s top-level
62
- * `extras` field. Gates the named-extras opt-in mechanism: only entries
63
- * appearing in this list are eligible for sync. Initial set contains
64
- * `.planning` only; widening to include `.notes`, `.scratch`, etc. is a
65
- * one-line edit here with no schema migration required (the field is
66
- * additive on the consumer side). Mirrors `SHARED_LINKS` in shape and
72
+ * Whitelist of names allowed in `path-map.json`'s top-level `extras` field.
73
+ * Each entry is either a directory name (e.g. `.planning`) OR a single
74
+ * root-level file name (e.g. `CLAUDE.md`); both are validated the same way
75
+ * and copied verbatim under `shared/extras/<logical>/<name>`. Gates the
76
+ * named-extras opt-in mechanism: only entries appearing in this list are
77
+ * eligible for sync. Widening to include `.notes`, `.scratch`, `AGENTS.md`,
78
+ * etc. is a one-line edit here with no schema migration required (the field
79
+ * is additive on the consumer side). Mirrors `SHARED_LINKS` in shape and
67
80
  * intent: a short, append-only `as const` tuple that downstream callers
68
81
  * narrow against.
69
82
  */
70
- export const SUPPORTED_EXTRAS = ['.planning'] as const;
83
+ export const SUPPORTED_EXTRAS = ['.planning', 'CLAUDE.md'] as const;
71
84
 
72
85
  /**
73
86
  * Path segments that must never cross the sync boundary in either direction.
@@ -159,9 +172,9 @@ export const PUSH_ALLOWED_STATIC = [
159
172
  * the literal string `'TBD'` as a placeholder while a host has not yet cloned
160
173
  * the project; `remapPull` / `remapPush` skip `'TBD'` entries.
161
174
  *
162
- * Optional `extras` field (additive, top-level): opt-in per-project
163
- * named-directory sync. Keyed by the same logical project name used in
164
- * `projects`; values are arrays of directory names validated by downstream
175
+ * Optional `extras` field (additive, top-level): opt-in per-project sync of
176
+ * named content. Keyed by the same logical project name used in `projects`;
177
+ * values are arrays of directory or root-file names validated by downstream
165
178
  * consumers against `SUPPORTED_EXTRAS`. Absence of the field is equivalent
166
179
  * to no extras for any project; legacy `path-map.json` files without an
167
180
  * `extras` block continue to work unchanged (no migration required).
@@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
3
3
  import { isAbsolute, join, normalize } from 'node:path';
4
4
 
5
- import { HOME, HOST, REPO_HOME, SUPPORTED_EXTRAS } from './config.ts';
5
+ import { HOME, HOST, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
6
6
  // prettier-ignore
7
7
  import { backupExtrasWrite, backupRepoWrite, encodePath, log, NomadFatal, readPathMap, warn } from './utils.ts';
8
8
 
@@ -61,6 +61,35 @@ export function copyExtras(src: string, dst: string): void {
61
61
  cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
62
62
  }
63
63
 
64
+ /**
65
+ * Repo-relative `shared/extras/<logical>/<dirname>` paths for every
66
+ * (logical, whitelisted dirname) pair declared in `map.extras`. This is the
67
+ * same prefix set the push allow-list permits (minus the trailing slash, so
68
+ * the values are usable directly as `git add` arguments). Used by the fork
69
+ * update path (issue #112) to pre-commit overlapping extras before
70
+ * `git merge upstream/main`, turning an untracked-overwrite abort into a
71
+ * tracked-file merge. Non-whitelisted dirnames are filtered out so manually
72
+ * staged content under a non-supported dirname is never auto-committed.
73
+ * Logical names are validated for path-traversal safety first, matching the
74
+ * `remapExtras*` contract.
75
+ *
76
+ * @param map - Parsed `path-map.json`. A missing `extras` key yields `[]`.
77
+ * @returns Sorted, de-duplicated repo-relative extras paths (no trailing slash).
78
+ */
79
+ export function whitelistedExtrasPaths(map: PathMap): string[] {
80
+ const extrasMap = map.extras ?? {};
81
+ const whitelist: readonly string[] = SUPPORTED_EXTRAS;
82
+ const paths = new Set<string>();
83
+ for (const [logical, dirnames] of Object.entries(extrasMap)) {
84
+ assertSafeLogical(logical);
85
+ for (const dirname of dirnames) {
86
+ if (!whitelist.includes(dirname)) continue;
87
+ paths.add(`shared/extras/${logical}/${dirname}`);
88
+ }
89
+ }
90
+ return [...paths].sort((a, b) => a.localeCompare(b));
91
+ }
92
+
64
93
  /**
65
94
  * Push: copy whitelisted extras directories under each project's localRoot
66
95
  * into the repo at `shared/extras/<logical>/<dirname>/`. Returns
@@ -93,7 +93,10 @@ export function partitionFindings(findings: Finding[]): {
93
93
  * transcript(s).` header, one block per affected session with a
94
94
  * `Recover with: nomad drop-session <id>` line, an optional `Also found:`
95
95
  * block for non-session paths, and a trailing `After recovery, re-run
96
- * nomad push.` line. Pure.
96
+ * nomad push.` line. The header carries a single clarifying note that the
97
+ * drop also clears any sibling subagent transcript directory for the
98
+ * session, since those nested paths route to the `other` bucket and are
99
+ * not listed per-session. Pure.
97
100
  */
98
101
  export function buildSessionAwareFatal(
99
102
  bySession: Map<string, Map<string, number>>,
@@ -101,24 +104,23 @@ export function buildSessionAwareFatal(
101
104
  ): string {
102
105
  if (bySession.size === 0) return LEGACY_FATAL;
103
106
  const lines: string[] = [];
104
- lines.push(`gitleaks detected secrets in ${bySession.size} session transcript(s).`);
107
+ lines.push(
108
+ `gitleaks detected secrets in ${bySession.size} session transcript(s).`,
109
+ "nomad drop-session also clears each session's sibling subagent transcript directory.",
110
+ );
105
111
  for (const [sid, counts] of bySession) {
106
112
  const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
107
- lines.push('');
108
- lines.push(`Session ${sid}:`);
109
- lines.push(` ${summary}`);
110
- lines.push(` Recover with: nomad drop-session ${sid}`);
113
+ lines.push('', `Session ${sid}:`, ` ${summary}`, ` Recover with: nomad drop-session ${sid}`);
111
114
  }
112
115
  if (other.length > 0) {
113
- lines.push('');
114
- lines.push('Also found:');
115
- for (const f of other) {
116
- lines.push(` ${f.File} ${f.RuleID}`);
117
- }
118
- lines.push(' Review with: git diff --cached, then unstage manually.');
116
+ lines.push(
117
+ '',
118
+ 'Also found:',
119
+ ...other.map((f) => ` ${f.File} ${f.RuleID}`),
120
+ ' Review with: git diff --cached, then unstage manually.',
121
+ );
119
122
  }
120
- lines.push('');
121
- lines.push('After recovery, re-run nomad push.');
123
+ lines.push('', 'After recovery, re-run nomad push.');
122
124
  return lines.join('\n');
123
125
  }
124
126
 
@@ -0,0 +1,101 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import { REPO_HOME } from './config.ts';
6
+ import { whitelistedExtrasPaths } from './extras-sync.ts';
7
+ import { gitOrFatal, log, readPathMap } from './utils.ts';
8
+
9
+ /**
10
+ * Pre-commit whitelisted extras before a fork merge so an untracked-overwrite
11
+ * abort becomes a tracked-file merge (issue #112).
12
+ *
13
+ * When a fork host has untracked `shared/extras/<logical>/<dirname>/` content
14
+ * that `upstream/main` also introduces, `git merge upstream/main` aborts
15
+ * before creating any merge state ("untracked working tree files would be
16
+ * overwritten by merge"). No `UU` is recorded, so the lone-lockfile
17
+ * auto-resolve never fires and the merge surfaces as an opaque failure.
18
+ * Staging alone is insufficient (git still refuses to overwrite staged-but-
19
+ * uncommitted local changes); the overlap must be a committed tracked path so
20
+ * the merge engine treats it as a content merge. After this commit, identical
21
+ * extras merge cleanly (the normal sync case, leaving the lone `UU
22
+ * package-lock.json` the existing auto-resolve handles) and divergent extras
23
+ * surface a real, resolvable conflict instead of the abort.
24
+ *
25
+ * Scoped strictly to the whitelisted `shared/extras/` paths declared in
26
+ * `path-map.json`; never a blanket `git add -A`. No-op when there is no
27
+ * `path-map.json`, no declared extras, or none of the declared extras paths
28
+ * exist on disk, and when staging produces no index change (nothing dirty).
29
+ */
30
+ export function precommitForkExtras(): void {
31
+ const mapPath = join(REPO_HOME, 'path-map.json');
32
+ if (!existsSync(mapPath)) return;
33
+ const map = readPathMap(mapPath);
34
+ const candidates = whitelistedExtrasPaths(map).filter((p) => existsSync(join(REPO_HOME, p)));
35
+ if (candidates.length === 0) return;
36
+
37
+ gitOrFatal(['add', '--', ...candidates], 'git add extras', REPO_HOME);
38
+ // Only commit when staging actually changed the index. The probe and commit
39
+ // are BOTH path-scoped to the extras candidates so an unrelated staged change
40
+ // present before update neither flips the dirty probe nor rides along in the
41
+ // commit. `git diff --cached --quiet -- <candidates>` exits 0 when those paths
42
+ // match HEAD (nothing to commit), 1 when they differ; avoids an empty-commit
43
+ // failure when the extras were already tracked and unmodified.
44
+ let dirty = false;
45
+ try {
46
+ execFileSync('git', ['diff', '--cached', '--quiet', '--', ...candidates], {
47
+ cwd: REPO_HOME,
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ });
50
+ } catch {
51
+ dirty = true;
52
+ }
53
+ if (!dirty) return;
54
+ gitOrFatal(
55
+ ['commit', '-m', 'chore: stage local extras before upstream merge', '--', ...candidates],
56
+ 'git commit extras',
57
+ REPO_HOME,
58
+ );
59
+ log(`staged local extras before merge: ${candidates.join(', ')}`);
60
+ }
61
+
62
+ /**
63
+ * After a successful fork merge, commit a `package-lock.json` that `npm
64
+ * install` regenerated and left uncommitted (secondary item of issue #112).
65
+ *
66
+ * The post-merge reinstall (`reinstallIfNeeded`) can rewrite the lockfile
67
+ * when the merge changed dependencies, leaving working-tree drift that the
68
+ * trailing `nomad doctor` reports as "uncommitted changes". This stages and
69
+ * commits ONLY `package-lock.json` so the repo is clean after update. No-op
70
+ * when the lockfile is absent or unchanged (the `git diff --quiet` probe
71
+ * exits 0). Tightly scoped: never touches any other path.
72
+ */
73
+ export function commitRegeneratedLockfile(): void {
74
+ const lockfile = join(REPO_HOME, 'package-lock.json');
75
+ if (!existsSync(lockfile)) return;
76
+ // `git diff --quiet -- package-lock.json` exits 0 when the working tree
77
+ // matches HEAD (no drift to commit), 1 when it differs.
78
+ let drifted = false;
79
+ try {
80
+ execFileSync('git', ['diff', '--quiet', '--', 'package-lock.json'], {
81
+ cwd: REPO_HOME,
82
+ stdio: ['ignore', 'pipe', 'pipe'],
83
+ });
84
+ } catch {
85
+ drifted = true;
86
+ }
87
+ if (!drifted) return;
88
+ gitOrFatal(['add', '--', 'package-lock.json'], 'git add package-lock.json', REPO_HOME);
89
+ gitOrFatal(
90
+ [
91
+ 'commit',
92
+ '-m',
93
+ 'chore: commit regenerated package-lock.json after update',
94
+ '--',
95
+ 'package-lock.json',
96
+ ],
97
+ 'git commit package-lock.json',
98
+ REPO_HOME,
99
+ );
100
+ log('committed regenerated package-lock.json after update');
101
+ }
package/src/utils.ts CHANGED
@@ -88,12 +88,32 @@ export const die = (msg: string): never => {
88
88
  * and the first record's leading space is part of the format (e.g.
89
89
  * `" M path\0"` for unstaged-modified). Going through `sh` would strip that
90
90
  * space and shift the fixed-offset parse in `parsePorcelainZ`.
91
+ *
92
+ * `opts.untrackedAll` (default `false`): when `true`, passes
93
+ * `--untracked-files=all` so git emits one record per untracked file instead
94
+ * of collapsing a fully-untracked subtree to its highest all-untracked parent
95
+ * directory (`?? shared/extras/`). The push allow-list (issue #111) needs the
96
+ * per-file paths so its `shared/extras/<logical>/<dirname>/` child prefix
97
+ * matches; the working-tree-clean checks in `cmdUpdate` and `cmdDoctor` only
98
+ * care whether output is empty, so they keep the cheaper default-collapse
99
+ * behavior. Opt-in rather than a global default so those consumers do not
100
+ * pay for the deeper walk.
101
+ *
102
+ * @param cwd - Working directory for the git invocation; defaults to the process cwd.
103
+ * @param opts - Reader options. `untrackedAll` expands collapsed untracked directory records into per-file paths.
104
+ * @returns The raw NUL-delimited porcelain v1 output as a string.
91
105
  */
92
- export const gitStatusPorcelainZ = (cwd?: string): string =>
93
- execFileSync('git', ['status', '--porcelain=v1', '-z'], {
106
+ export const gitStatusPorcelainZ = (
107
+ cwd?: string,
108
+ opts: { untrackedAll?: boolean } = {},
109
+ ): string => {
110
+ const args = ['status', '--porcelain=v1', '-z'];
111
+ if (opts.untrackedAll === true) args.push('--untracked-files=all');
112
+ return execFileSync('git', args, {
94
113
  cwd,
95
114
  stdio: ['ignore', 'pipe', 'pipe'],
96
115
  }).toString();
116
+ };
97
117
 
98
118
  /**
99
119
  * Run `git <args>` in `cwd`, forwarding stderr and converting non-zero exits