claude-nomad 0.64.1 → 0.65.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,76 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.65.0](https://github.com/funkadelic/claude-nomad/compare/v0.64.1...v0.65.0) (2026-08-13)
4
+
5
+ ### What's new
6
+
7
+ **On native Windows (PowerShell or cmd).** Note: WSL2 behaves like Linux and none of these apply to
8
+ it.
9
+
10
+ - **`nomad pull --force-remote` no longer discards edits you have not published.** The flag exists
11
+ to rescue a sync repo that got stuck part-way through an update. On native Windows it also skipped
12
+ the step that saves your local shared-config edits into the repo, every time it ran, even on a
13
+ healthy machine with nothing to rescue. That was the flag's only visible effect on a healthy repo,
14
+ and nothing told you it had happened. It now rescues a stuck repo and nothing else, and when a
15
+ rescue genuinely does replace your copies, the pull says how many and where your previous ones
16
+ were saved.
17
+ - **A pull now says what it did to your shared config.** On Windows your shared config is kept as
18
+ real file copies rather than symlinks, so every pull starts by copying your local edits into the
19
+ sync repo. That step used to be silent. It now prints a `Symlinks` section naming each file it
20
+ picked up, and each file it removed from the repo because you deleted it here. If a file cannot be
21
+ read at all, because another program is holding it open or its permissions changed, the pull names
22
+ it and says why rather than leaving a silent gap. `nomad diff` and `nomad pull --dry-run` read
23
+ from the same place, so a preview now matches what a real pull does.
24
+ - **Windows keeps the same things out of your sync repo that your other machines do.** The Windows
25
+ copy step used to hold back only files whose names looked like passwords or keys. It now skips
26
+ everything on the never-sync list, so the folders Claude Code uses for its own state, named things
27
+ like `sessions`, `tasks`, `plans`, and `cache`, stay on the machine even when they sit inside a
28
+ folder you share. If one of them is already sitting in your sync repo, the pull tells you, and
29
+ takes a backup copy before removing it.
30
+
31
+ **On all supported platforms, i.e. \*nix, Mac, WSL2, and native Windows.**
32
+
33
+ - **`nomad doctor` names the files that have drifted.** When it reports that your skills or your
34
+ shared config no longer match the repo, it lists them. Before, you got a count and had to run it
35
+ again with `--verbose` to find out which files it meant.
36
+ - **`nomad pull --force-remote` tells you when there was nothing to rescue.** On a repo that is not
37
+ stuck it now says so and pulls normally, instead of appearing to do nothing. If it cannot tell
38
+ whether the repo is stuck, it says that too rather than reporting a clean repo. The help text,
39
+ README, and docs site now agree on what the flag does.
40
+
41
+
42
+ ### Added
43
+
44
+ * **doctor:** open with the nomad wordmark and name the diverging files ([#508](https://github.com/funkadelic/claude-nomad/issues/508)) ([1fea0c7](https://github.com/funkadelic/claude-nomad/commit/1fea0c7913b38f934d529d888a0aef96dc1dfc08))
45
+ * make the win32 pre-pull mirror visible and gate what it writes ([#503](https://github.com/funkadelic/claude-nomad/issues/503)) ([8d3a76d](https://github.com/funkadelic/claude-nomad/commit/8d3a76dd42c89efd38b2a76b2684b8b4014878ac))
46
+ * **pull:** report the shared files a pull removes, not just the ones it captures ([#510](https://github.com/funkadelic/claude-nomad/issues/510)) ([9ab6ef5](https://github.com/funkadelic/claude-nomad/commit/9ab6ef5b9961a8542eb25a650ada27bd3bdf9de6))
47
+
48
+
49
+ ### Fixed
50
+
51
+ * **mirror:** report what the never-sync cleanup actually removed ([#509](https://github.com/funkadelic/claude-nomad/issues/509)) ([6596ed3](https://github.com/funkadelic/claude-nomad/commit/6596ed341d4e764b29a56bb73f900aa6dd48fcc9))
52
+ * **mirror:** warn when the win32 mirror skips a name it cannot stat ([#505](https://github.com/funkadelic/claude-nomad/issues/505)) ([5806db6](https://github.com/funkadelic/claude-nomad/commit/5806db6e2d2861747738d498d0255fe0483a6650))
53
+ * **pull:** make --force-remote mean wedge recovery again ([#506](https://github.com/funkadelic/claude-nomad/issues/506)) ([2bf8d5d](https://github.com/funkadelic/claude-nomad/commit/2bf8d5d3b19e8c022ff1137be96b0f009e5503df))
54
+
55
+
56
+ ### Changed
57
+
58
+ * fail the gitleaks install on a download error, not a checksum mismatch ([#504](https://github.com/funkadelic/claude-nomad/issues/504)) ([724dd3c](https://github.com/funkadelic/claude-nomad/commit/724dd3c276bdea113f9719e5b62ee0741a3c3885))
59
+
60
+
61
+ ### Documentation
62
+
63
+ * describe what the force-remote recovery warning actually counts ([#507](https://github.com/funkadelic/claude-nomad/issues/507)) ([6235858](https://github.com/funkadelic/claude-nomad/commit/6235858e40459e136deff4b081187c89b9e7b670))
64
+ * keep the Windows explainer in one place ([#511](https://github.com/funkadelic/claude-nomad/issues/511)) ([9a9287f](https://github.com/funkadelic/claude-nomad/commit/9a9287f87d86e2353ca4a0d936346482e37a1041))
65
+
66
+
67
+ ### Dependencies
68
+
69
+ * bump @astrojs/starlight from 0.41.6 to 0.41.7 in /docs-site in the prod-dependencies group ([#499](https://github.com/funkadelic/claude-nomad/issues/499)) ([61ecb3f](https://github.com/funkadelic/claude-nomad/commit/61ecb3fef0213c6bc0f2a36713da0e166e356238))
70
+ * bump astro from 7.1.6 to 7.2.0 in /docs-site ([#500](https://github.com/funkadelic/claude-nomad/issues/500)) ([4909e02](https://github.com/funkadelic/claude-nomad/commit/4909e02e5754081432e567cae2344989ab8b1309))
71
+ * bump the codeql-action group across 1 directory with 2 updates ([#498](https://github.com/funkadelic/claude-nomad/issues/498)) ([ad3310d](https://github.com/funkadelic/claude-nomad/commit/ad3310d33e9662ae407d304ef949efd1bebf9c3e))
72
+ * bump the dev-dependencies group across 1 directory with 4 updates ([#501](https://github.com/funkadelic/claude-nomad/issues/501)) ([446c6da](https://github.com/funkadelic/claude-nomad/commit/446c6dae3905609bef1d3ec4366d58e50651c44a))
73
+
3
74
  ## [0.64.1](https://github.com/funkadelic/claude-nomad/compare/v0.64.0...v0.64.1) (2026-08-07)
4
75
 
5
76
  ### What's new
package/README.md CHANGED
@@ -71,7 +71,8 @@ survives different file paths and your secrets never ride along.
71
71
  parks stranded work on a branch, refuses if shared config is at risk), and a repo where the rebase
72
72
  was interrupted but the git index was left with unmerged entries and no active operation (clears
73
73
  the index via `git reset --mixed HEAD`, surfaces any orphaned stash entry left by the interrupted
74
- autostash, never discards working-tree edits).
74
+ autostash, never discards working-tree edits). On a repo that is not stuck, the flag reports there
75
+ is nothing to recover and pulls normally instead of doing nothing silently.
75
76
  - **Easy off.** `nomad eject` replaces every managed `~/.claude/` symlink with a real copy in one
76
77
  step, so your setup keeps working after you delete the sync checkout and uninstall the CLI.
77
78
 
@@ -118,27 +119,40 @@ export NOMAD_HOST=<your-host-label> # add to ~/.zshrc or ~/.bashrc
118
119
  $ nomad pull
119
120
  ```
120
121
 
122
+ Everyday loop on any host:
123
+
124
+ ```bash
125
+ $ nomad doctor # confirm setup
126
+ $ nomad sync # pull config, then publish local changes, in one step
127
+ ```
128
+
129
+ `nomad sync` is the command to reach for day to day: it always pulls first (so changes from your
130
+ other machines land before anything is pushed, and work that exists only on this machine is kept,
131
+ not deleted) and then pushes, under one lock, so there is no ordering to remember. Output is compact
132
+ by default: a run prints only a short Sync summary, not the full status tree; pass
133
+ `nomad sync --verbose` (or `--all` / `-v`) to see the full tree. `nomad pull` and `nomad push` are
134
+ still available as lower-level commands for cases `sync` does not cover: recovering a wedged repo
135
+ with `nomad pull --force-remote`, or resolving a detected secret without the interactive menu via
136
+ `nomad push --redact-all` / `--allow` / `--allow-all` (see [Changing settings](#changing-settings)
137
+ and [Recovery flows](https://funkadelic.github.io/claude-nomad/recovery/)). The
138
+ [FAQ](https://funkadelic.github.io/claude-nomad/faq/) covers what `sync` does under the hood and the
139
+ push/pull order it enforces.
140
+
121
141
  ### Windows
122
142
 
123
143
  claude-nomad runs natively on Windows (PowerShell or cmd), and WSL2 works too. The everyday loop is
124
144
  the same either way; the difference is under the hood.
125
145
 
126
- On macOS, Linux, and WSL2, claude-nomad symlinks your shared config into `~/.claude/`, so there is
127
- only ever one file: the one in your sync repo. Native Windows cannot use symlinks, because creating
128
- one there needs Developer Mode or admin rights, so claude-nomad keeps a real copy there instead.
129
- That leaves two files to keep in step, and claude-nomad does it for you: `nomad pull` and
130
- `nomad sync` both copy your edits into the repo before they fetch, so an unpublished edit is never
131
- overwritten. Enabling Developer Mode does not change this; copies are used on every native Windows
132
- host either way.
133
-
134
- Two things come from native Windows specifically, both covered in the bullets below: a
135
- `.gitleaksignore` allow entry may not travel to a macOS, Linux, or WSL2 host, and deep session paths
136
- can hit the native Windows 260-character path limit.
137
-
138
- The native Windows steps are the same as above with a couple of PowerShell-specific swaps:
146
+ Native Windows cannot use symlinks, so claude-nomad keeps a real copy of your shared config in
147
+ `~/.claude/` instead of a symlink. `nomad pull` and `nomad sync` both mirror your local copies into
148
+ the repo before they fetch, so an edit you have not published yet is captured rather than
149
+ overwritten. If a file cannot be read, the pull leaves it out and warns naming the file: fix that
150
+ and run again before you rely on the result.
139
151
 
140
152
  ```powershell
141
- # 1. Install the CLI.
153
+ # 1. Install prerequisites and the CLI. (Using Scoop instead? scoop install gh gitleaks.)
154
+ > winget install GitHub.cli
155
+ > winget install gitleaks.gitleaks
142
156
  > npm i -g claude-nomad
143
157
 
144
158
  # 2. Create your private sync repo and scaffold it.
@@ -155,66 +169,9 @@ The native Windows steps are the same as above with a couple of PowerShell-speci
155
169
  > nomad push
156
170
  ```
157
171
 
158
- A few native Windows specifics worth knowing. WSL2 behaves like Linux, so the copy-sync and
159
- path-length items below do not apply to it; the `.gitleaksignore` one can still reach it, from the
160
- other side:
161
-
162
- - **Installing gh:** `winget install GitHub.cli` (or `scoop install gh`), then `gh auth login`.
163
- Needed before `nomad init` on the first host; later hosts only clone with it.
164
- - **Installing gitleaks:** `winget install gitleaks.gitleaks` (or `scoop install gitleaks` if you
165
- use Scoop). `nomad doctor` prints the same hint whenever gitleaks is missing from PATH.
166
- - **Shared config is copied, not symlinked.** On macOS and Linux, files like `CLAUDE.md` and your
167
- skills live in the sync repo and are symlinked into `~/.claude/`, so there is one source of truth
168
- on disk. Creating a symlink on native Windows needs Developer Mode or admin rights, so there these
169
- are real copies instead, whether or not you have Developer Mode enabled. WSL2 is unaffected and
170
- behaves like Linux. What this means for you: nothing extra. On native Windows both `nomad pull`
171
- and `nomad sync` mirror your local copies into the repo before they fetch, so an unpublished edit
172
- is captured rather than reverted. A file you delete from a shared directory is handled the same
173
- way: it is removed from the sync repo by the next pull, exactly as deleting inside a symlinked
174
- directory already removes it on macOS or Linux. The removal is left uncommitted, so it publishes
175
- on your next push and passes the same secret scan as everything else, and the file is snapshotted
176
- to the backup dir first. The safety rule behind this: nomad only removes a file it has a record of
177
- having given this machine, so a repo file this machine has never synced is never touched. That
178
- record is also why the first pull after you upgrade to this version is an exception: there is
179
- nothing to compare against yet, so a deletion made before that pull comes back once, and deleting
180
- it again sticks. If a file you created has the same name as one another machine just created, the
181
- pull stops before it changes anything and tells you which file under `~/.claude/` to move or
182
- rename, with the two ways to finish. Nothing is lost either way: your file stays exactly as you
183
- left it and the update simply waits for the next pull. The one command that deliberately takes the
184
- repo's version is `nomad pull --force-remote`, which is what that flag is for; the copy it
185
- replaces is snapshotted to the backup dir first. This is the same behavior claude-nomad's
186
- `skills/` sync already has on every platform.
187
- - **A `.gitleaksignore` allow entry may not travel across hosts.** gitleaks fingerprints each
188
- finding using the file path exactly as it saw it: backslashes on native Windows, forward slashes
189
- on macOS/Linux/WSL2. If you allow a finding with `nomad push --allow` (or `nomad allow`) on native
190
- Windows, the identical finding can reappear as "new" the first time it is scanned from a macOS,
191
- Linux, or WSL2 host, and the same happens in reverse. This is a known gitleaks limitation, not a
192
- claude-nomad bug; just allow it again from the other host.
193
- - **Line endings stay put.** A fresh `nomad init` writes a `.gitattributes` with `* -text`, so Git
194
- never converts line endings between hosts. If you are joining a sync repo created before this file
195
- existed, add that one line from any host (or watch for the `nomad doctor` warning that nudges
196
- you), otherwise a native Windows checkout with the common `core.autocrlf=true` Git default would
197
- rewrite every text file's line endings, and every host would then see the whole tree as
198
- permanently changed.
199
-
200
- Everyday loop on any host:
201
-
202
- ```bash
203
- $ nomad doctor # confirm setup
204
- $ nomad sync # pull config, then publish local changes, in one step
205
- ```
206
-
207
- `nomad sync` is the command to reach for day to day: it always pulls first (so changes from your
208
- other machines land before anything is pushed, and work that exists only on this machine is kept,
209
- not deleted) and then pushes, under one lock, so there is no ordering to remember. Output is compact
210
- by default: a run prints only a short Sync summary, not the full status tree; pass
211
- `nomad sync --verbose` (or `--all` / `-v`) to see the full tree. `nomad pull` and `nomad push` are
212
- still available as lower-level commands for cases `sync` does not cover: recovering a wedged repo
213
- with `nomad pull --force-remote`, or resolving a detected secret without the interactive menu via
214
- `nomad push --redact-all` / `--allow` / `--allow-all` (see [Changing settings](#changing-settings)
215
- and [Recovery flows](https://funkadelic.github.io/claude-nomad/recovery/)). The
216
- [FAQ](https://funkadelic.github.io/claude-nomad/faq/) covers what `sync` does under the hood and the
217
- push/pull order it enforces.
172
+ See the full explainer at <https://funkadelic.github.io/claude-nomad/quickstart/#windows> for how
173
+ the copy-sync behaves on pull and push, why a `.gitleaksignore` allow entry may not travel to a
174
+ macOS, Linux, or WSL2 host, the 260-character path limit, and the line-endings setting.
218
175
 
219
176
  ### Make your sessions follow you
220
177
 
@@ -321,8 +278,8 @@ independently from the CLI, but requires nomad `>= 0.35.0` because it calls rece
321
278
  - `gh` ([GitHub CLI](https://cli.github.com/)), required by `nomad init`
322
279
 
323
280
  Works on macOS, Linux (including WSL2), and native Windows (PowerShell or cmd). See
324
- [Windows](#windows) above for the native Windows equivalents of the install and host-label steps,
325
- the copy-sync trade-off, and the `.gitleaksignore` cross-host caveat.
281
+ [Windows](#windows) above for the native Windows equivalents of the install and host-label steps and
282
+ the copy-sync trade-off.
326
283
 
327
284
  **Optional:** [curl](https://curl.se/) or [wget](https://www.gnu.org/software/wget/) for the
328
285
  version-staleness check and `nomad doctor --check-schema`. The CLI works without them. The opt-in
package/dist/nomad.mjs CHANGED
@@ -58,7 +58,26 @@ function isDeniedName(blockSet, name) {
58
58
  function isClaudeExtraName(name) {
59
59
  return stripTrailingDotsAndWhitespace(name).toLowerCase() === ".claude";
60
60
  }
61
- var NEVER_SYNC, CLAUDE_EXTRA_NEVER_SYNC, SECRET_FILE_PATTERNS;
61
+ function isExtrasScoped(segments) {
62
+ return segments[0] === "shared" && segments[1] === "extras";
63
+ }
64
+ function blockSetFor(segments) {
65
+ if (!isExtrasScoped(segments)) return NEVER_SYNC;
66
+ return isClaudeExtraName(segments[3] ?? "") ? CLAUDE_EXTRA_NEVER_SYNC : ALWAYS_NEVER_SYNC;
67
+ }
68
+ function deniedSegmentFor(path) {
69
+ const segments = path.split("/");
70
+ const blockSet = blockSetFor(segments);
71
+ const scan = isExtrasScoped(segments) ? segments.slice(4) : segments;
72
+ for (const segment of scan) {
73
+ if (isDeniedName(blockSet, segment)) return segment;
74
+ }
75
+ return null;
76
+ }
77
+ function isNeverSync(path) {
78
+ return deniedSegmentFor(path) !== null;
79
+ }
80
+ var NEVER_SYNC, CLAUDE_EXTRA_NEVER_SYNC, ALWAYS_NEVER_SYNC, SECRET_FILE_PATTERNS;
62
81
  var init_config_never_sync = __esm({
63
82
  "src/config.never-sync.ts"() {
64
83
  "use strict";
@@ -88,6 +107,13 @@ var init_config_never_sync = __esm({
88
107
  "sessions"
89
108
  ]);
90
109
  CLAUDE_EXTRA_NEVER_SYNC = /* @__PURE__ */ new Set([...NEVER_SYNC, "projects"]);
110
+ ALWAYS_NEVER_SYNC = /* @__PURE__ */ new Set([
111
+ ".claude.json",
112
+ ".credentials.json",
113
+ "settings.local.json",
114
+ "history.jsonl",
115
+ "stats-cache.json"
116
+ ]);
91
117
  SECRET_FILE_PATTERNS = [
92
118
  /^\.env(\..+)?$/i,
93
119
  // .env, .env.local, .env.production
@@ -639,7 +665,7 @@ function sharedDirEntries(map) {
639
665
  const raw = map.sharedDirs;
640
666
  return Array.isArray(raw) ? raw : [];
641
667
  }
642
- var SETTINGS_SCHEMA_URL, NPM_REGISTRY_LATEST_URL, GITLEAKS_PINNED_VERSION, GITLEAKS_SCAN_TIMEOUT_MS, HOST, SHARED_LINKS, GSD_PREFIX, GSD_DROPPED_NAMES, SUPPORTED_EXTRAS, ALWAYS_NEVER_SYNC, PUSH_ALLOWED_STATIC;
668
+ var SETTINGS_SCHEMA_URL, NPM_REGISTRY_LATEST_URL, GITLEAKS_PINNED_VERSION, GITLEAKS_SCAN_TIMEOUT_MS, HOST, SHARED_LINKS, GSD_PREFIX, GSD_DROPPED_NAMES, SUPPORTED_EXTRAS, PUSH_ALLOWED_STATIC;
643
669
  var init_config = __esm({
644
670
  "src/config.ts"() {
645
671
  "use strict";
@@ -656,13 +682,6 @@ var init_config = __esm({
656
682
  GSD_PREFIX = "gsd-";
657
683
  GSD_DROPPED_NAMES = ["hooks", "agents"];
658
684
  SUPPORTED_EXTRAS = [".planning", "CLAUDE.md", ".claude"];
659
- ALWAYS_NEVER_SYNC = /* @__PURE__ */ new Set([
660
- ".claude.json",
661
- ".credentials.json",
662
- "settings.local.json",
663
- "history.jsonl",
664
- "stats-cache.json"
665
- ]);
666
685
  PUSH_ALLOWED_STATIC = [
667
686
  "shared/CLAUDE.md",
668
687
  "shared/my-statusline.cjs",
@@ -894,13 +913,20 @@ function probeUnmergedIndex(repo) {
894
913
  }
895
914
  return raw.split("\0").some(Boolean) ? "unmerged" : "clean";
896
915
  }
897
- function unmergedIndexPresent(repo) {
898
- return probeUnmergedIndex(repo) === "unmerged";
916
+ function classifyWedgeWithProbe(repo) {
917
+ const mode = detectWedge(repo);
918
+ if (mode !== null) return { state: mode, probe: "clean" };
919
+ const probe = probeUnmergedIndex(repo);
920
+ return { state: probe === "unmerged" ? "unmerged-index" : null, probe };
899
921
  }
900
922
  function classifyWedge(repo) {
901
- const mode = detectWedge(repo);
902
- if (mode !== null) return mode;
903
- return unmergedIndexPresent(repo) ? "unmerged-index" : null;
923
+ return classifyWedgeWithProbe(repo).state;
924
+ }
925
+ function cleanRepoForceRemoteMessage(probe) {
926
+ if (probe === "error") {
927
+ return "could not determine whether the repo is wedged; continuing with a normal pull";
928
+ }
929
+ return "repo is clean, nothing to recover; continuing with a normal pull";
904
930
  }
905
931
  function unmergedIndexRunbookText(resumeCmd2) {
906
932
  return `repo has an unmerged index with no active rebase or merge in progress (torn-down rebase or merge left stage-2/3 entries behind).
@@ -2090,7 +2116,7 @@ function applySharedLinks(ts, map, opts = {}) {
2090
2116
  const dryRun = opts.dryRun === true;
2091
2117
  const claude = claudeHome();
2092
2118
  const repo = repoHome();
2093
- const linkNames = allSharedLinks(map);
2119
+ const linkNames = allSharedLinks(map, { quiet: opts.quietNames === true });
2094
2120
  if (process.platform === "win32") {
2095
2121
  applySharedLinksWin32(linkNames, claude, repo, ts, dryRun, opts.onPreview);
2096
2122
  return;
@@ -2108,35 +2134,6 @@ function applySharedLinks(ts, map, opts = {}) {
2108
2134
  ensureSymlink(linkPath, target);
2109
2135
  }
2110
2136
  }
2111
- function syncSharedLinksPush(map) {
2112
- mirrorSharedNames(map, { adoptNew: true, overlay: false });
2113
- }
2114
- function stageLocalSharedEdits(map, ts) {
2115
- mirrorSharedNames(map, { adoptNew: false, overlay: true, backupTs: ts });
2116
- }
2117
- function mirrorOneSharedName(name, claude, repo, policy) {
2118
- const localPath = join5(claude, name);
2119
- const stat = lstatSync4(localPath, { throwIfNoEntry: false });
2120
- if (stat === void 0) return;
2121
- if (stat.isSymbolicLink()) return;
2122
- const target = join5(repo, "shared", name);
2123
- if (!policy.adoptNew && !existsSync4(target)) return;
2124
- if (policy.backupTs !== void 0) backupRepoWrite(target, policy.backupTs, repo);
2125
- if (policy.overlay && stat.isDirectory()) {
2126
- copyExtrasOverlayFiltered(localPath, target, ALWAYS_NEVER_SYNC);
2127
- } else {
2128
- copyExtrasFiltered(localPath, target, ALWAYS_NEVER_SYNC);
2129
- }
2130
- }
2131
- function mirrorSharedNames(map, policy) {
2132
- if (process.platform !== "win32") return;
2133
- if (map === null) return;
2134
- const claude = claudeHome();
2135
- const repo = repoHome();
2136
- for (const name of allSharedLinks(map)) {
2137
- mirrorOneSharedName(name, claude, repo, policy);
2138
- }
2139
- }
2140
2137
  function readExistingSettings(settingsPath) {
2141
2138
  if (!existsSync4(settingsPath)) return { existing: {}, present: false, malformed: false };
2142
2139
  try {
@@ -2867,7 +2864,7 @@ import { join as join45 } from "node:path";
2867
2864
  init_color();
2868
2865
  init_config();
2869
2866
  import { existsSync as existsSync11, lstatSync as lstatSync8, readdirSync as readdirSync4, statSync as statSync3 } from "node:fs";
2870
- import { basename as basename3, join as join13 } from "node:path";
2867
+ import { join as join13, win32 as win32Path } from "node:path";
2871
2868
 
2872
2869
  // src/commands.doctor.format.ts
2873
2870
  init_color();
@@ -3121,19 +3118,30 @@ function reportRepoState(section2) {
3121
3118
  function repoHasSharedSource(name) {
3122
3119
  return existsSync11(join13(repoHome(), "shared", name));
3123
3120
  }
3124
- function win32CopyOkRow(name) {
3125
- return { line: `${green(okGlyph)} ${name}: real copy (win32 copy-sync)`, fail: false };
3121
+ function win32CopyOkRow(name, exempt = 0) {
3122
+ const note = exempt > 0 ? dim(` (${exempt} never-synced path(s) not compared)`) : "";
3123
+ return { line: `${green(okGlyph)} ${name}: real copy (win32 copy-sync)${note}`, fail: false };
3124
+ }
3125
+ function relativeUnder(root, path) {
3126
+ const rel = win32Path.relative(root, path);
3127
+ const segments = rel.split(win32Path.sep);
3128
+ if (rel === "" || segments[0] === ".." || win32Path.isAbsolute(rel)) return null;
3129
+ return segments.join("/");
3126
3130
  }
3127
- function divergingBasename(line) {
3128
- return basename3(line.replace(/ \((?:local|repo) only\)$/, ""));
3131
+ function divergingRepoPath(line, name, local, shared) {
3132
+ const path = line.replace(/ \((?:local|repo) only\)$/, "");
3133
+ const rel = relativeUnder(local, path) ?? relativeUnder(shared, path);
3134
+ return rel === null ? `shared/${name}` : `shared/${name}/${rel}`;
3129
3135
  }
3130
3136
  function classifyWin32Copy(name, p) {
3131
3137
  const sharedPath = join13(repoHome(), "shared", name);
3132
3138
  if (!existsSync11(sharedPath)) return win32CopyOkRow(name);
3133
- const diverging = listDivergingFiles(p, sharedPath).filter(
3134
- (line) => !isDeniedName(ALWAYS_NEVER_SYNC, divergingBasename(line))
3139
+ const compared = listDivergingFiles(p, sharedPath);
3140
+ const diverging = compared.filter(
3141
+ (line) => deniedSegmentFor(divergingRepoPath(line, name, p, sharedPath)) === null
3135
3142
  );
3136
- if (diverging.length === 0) return win32CopyOkRow(name);
3143
+ const exempt = compared.length - diverging.length;
3144
+ if (diverging.length === 0) return win32CopyOkRow(name, exempt);
3137
3145
  return {
3138
3146
  line: `${yellow(warnGlyph)} ${name}: ${diverging.length} file(s) diverge from shared/${name}`,
3139
3147
  fail: false,
@@ -5389,12 +5397,12 @@ import { join as join37 } from "node:path";
5389
5397
 
5390
5398
  // src/skills-sync.tracked.ts
5391
5399
  init_utils();
5392
- import { basename as basename4 } from "node:path";
5400
+ import { basename as basename3 } from "node:path";
5393
5401
  function trackedRootSkillsAt(ref, repo) {
5394
5402
  try {
5395
5403
  const raw = gitCaptureRaw(["ls-tree", "--name-only", "-z", ref, "--", "shared/skills/"], repo);
5396
5404
  const entries = raw.split("\0").filter((entry) => entry !== "");
5397
- return new Set(entries.map((entry) => basename4(entry)));
5405
+ return new Set(entries.map((entry) => basename3(entry)));
5398
5406
  } catch {
5399
5407
  return /* @__PURE__ */ new Set();
5400
5408
  }
@@ -6110,8 +6118,8 @@ function sessionSuffix(finding) {
6110
6118
  const sid = sessionIdFromFinding(finding);
6111
6119
  if (sid === null) return "";
6112
6120
  const rawBasename = finding.File.replace(/^.*\//, "");
6113
- const basename5 = rawBasename.endsWith(".jsonl") ? rawBasename.slice(0, -".jsonl".length) : rawBasename;
6114
- return basename5 === sid ? "" : ` (session: ${sid})`;
6121
+ const basename4 = rawBasename.endsWith(".jsonl") ? rawBasename.slice(0, -".jsonl".length) : rawBasename;
6122
+ return basename4 === sid ? "" : ` (session: ${sid})`;
6115
6123
  }
6116
6124
  function renderFindingBlock(finding, readLine, siblings = []) {
6117
6125
  const lines = [` file: ${finding.File}:${finding.StartLine}${sessionSuffix(finding)}`];
@@ -6780,6 +6788,20 @@ function buildVerdictSection(sections) {
6780
6788
  return summary;
6781
6789
  }
6782
6790
 
6791
+ // src/commands.doctor.banner.ts
6792
+ var NOMAD_BANNER = [
6793
+ " __",
6794
+ " ____ ____ ____ ___ ____ _ ____/ /",
6795
+ " / __ \\ / __ \\ / __ `__ \\ / __ `// __ /",
6796
+ " / / / // /_/ // / / / / // /_/ // /_/ /",
6797
+ String.raw`/_/ /_/ \____//_/ /_/ /_/ \__,_/ \__,_/`
6798
+ ];
6799
+ function printBanner(log2, stdout) {
6800
+ if (stdout.isTTY !== true) return;
6801
+ for (const line of NOMAD_BANNER) log2(line);
6802
+ log2("");
6803
+ }
6804
+
6783
6805
  // src/commands.doctor.compact.ts
6784
6806
  init_color();
6785
6807
  var ALWAYS_FULL = /* @__PURE__ */ new Set([
@@ -6798,18 +6820,27 @@ function isRepoStateLine(item2) {
6798
6820
  function isCopySyncModalityLine(item2) {
6799
6821
  return item2.includes("sync modality: copy-sync");
6800
6822
  }
6823
+ function isKeptEnvironmentRow(item2) {
6824
+ return isRepoStateLine(item2) || isCopySyncModalityLine(item2) || isProblem(item2);
6825
+ }
6826
+ function keepWithChildren(items, keep) {
6827
+ const out = [];
6828
+ let parentKept = false;
6829
+ for (const item2 of items) {
6830
+ if (isChild(item2)) {
6831
+ if (parentKept) out.push(item2);
6832
+ continue;
6833
+ }
6834
+ parentKept = keep(item2);
6835
+ if (parentKept) out.push(item2);
6836
+ }
6837
+ return out;
6838
+ }
6801
6839
  function compactSections(sections) {
6802
6840
  return sections.map((s) => {
6803
6841
  if (ALWAYS_FULL.has(s.header)) return s;
6804
- if (s.header === "Environment") {
6805
- return {
6806
- ...s,
6807
- items: s.items.filter(
6808
- (it) => isRepoStateLine(it) || isCopySyncModalityLine(it) || isProblem(it)
6809
- )
6810
- };
6811
- }
6812
- return { ...s, items: s.items.filter(isProblem) };
6842
+ const keep = s.header === "Environment" ? isKeptEnvironmentRow : isProblem;
6843
+ return { ...s, items: keepWithChildren(s.items, keep) };
6813
6844
  });
6814
6845
  }
6815
6846
 
@@ -6886,6 +6917,7 @@ function gatherDoctorSections(opts) {
6886
6917
  return [...body, buildVerdictSection(body)];
6887
6918
  }
6888
6919
  function cmdDoctor(opts = {}) {
6920
+ printBanner(console.log, process.stdout);
6889
6921
  const makeSpinner = opts.startSpinner ?? startSpinner;
6890
6922
  const sp = makeSpinner("Running checks");
6891
6923
  let report;
@@ -7415,7 +7447,8 @@ function remapExtrasPush(ts, opts = {}) {
7415
7447
  // The filter prevents ALWAYS_NEVER_SYNC files from landing in the repo
7416
7448
  // working tree before the allow-list gate fires, eliminating the
7417
7449
  // "residue wedges repeat push" regression. The allow-list gate
7418
- // (enforceAllowList / blockSetFor in commands.push.allowlist.ts)
7450
+ // (enforceAllowList in commands.push.allowlist.ts, blockSetFor in
7451
+ // config.never-sync.ts)
7419
7452
  // remains the hard security boundary.
7420
7453
  // All others: copyExtrasFiltered with per-extra denylist.
7421
7454
  (src, dst, dirname14) => dirname14 === ".planning" ? copyExtrasOverlayFiltered(src, dst, extrasDenySet(dirname14)) : copyExtrasFiltered(src, dst, extrasDenySet(dirname14))
@@ -7566,10 +7599,10 @@ function collectSharedFiles(dir, claude, scan) {
7566
7599
  addLocalPath(abs, claude, scan);
7567
7600
  }
7568
7601
  }
7569
- function enumerateLocalSharedScan(map) {
7602
+ function enumerateLocalSharedScan(map, opts = {}) {
7570
7603
  const claude = claudeHome();
7571
7604
  const scan = { files: {}, declined: [] };
7572
- for (const name of allSharedLinks(map)) {
7605
+ for (const name of opts.linkNames ?? allSharedLinks(map, { quiet: opts.quiet === true })) {
7573
7606
  if (isDeniedName(ALWAYS_NEVER_SYNC, name)) {
7574
7607
  scan.declined.push(name);
7575
7608
  continue;
@@ -7578,13 +7611,13 @@ function enumerateLocalSharedScan(map) {
7578
7611
  }
7579
7612
  return scan;
7580
7613
  }
7581
- function enumerateLocalSharedFiles(map) {
7582
- return enumerateLocalSharedScan(map).files;
7614
+ function enumerateLocalSharedFiles(map, opts = {}) {
7615
+ return enumerateLocalSharedScan(map, opts).files;
7583
7616
  }
7584
- function buildSharedBaseline(map) {
7617
+ function buildSharedBaseline(map, opts = {}) {
7585
7618
  const claude = claudeHome();
7586
7619
  const files = {};
7587
- for (const [key, st] of Object.entries(enumerateLocalSharedFiles(map))) {
7620
+ for (const [key, st] of Object.entries(enumerateLocalSharedFiles(map, opts))) {
7588
7621
  try {
7589
7622
  files[key] = { size: st.size, mtime: st.mtime, hash: hashFile(join51(claude, key)) };
7590
7623
  } catch {
@@ -7592,27 +7625,69 @@ function buildSharedBaseline(map) {
7592
7625
  }
7593
7626
  return buildManifest(files, SHARED_BASELINE_KIND, SHARED_BASELINE_CONFIG_HASH);
7594
7627
  }
7595
- function writeSharedBaseline(map) {
7628
+ function writeSharedBaseline(map, opts = {}) {
7596
7629
  if (process.platform !== "win32") return;
7597
7630
  try {
7598
- writeManifest(sharedBaselinePath(), buildSharedBaseline(map));
7631
+ writeManifest(sharedBaselinePath(), buildSharedBaseline(map, opts));
7599
7632
  } catch (err) {
7600
7633
  warn(`could not record the shared-config baseline: ${err.message}`);
7601
7634
  }
7602
7635
  }
7603
7636
 
7604
7637
  // src/commands.pull.win32.ts
7605
- init_config();
7638
+ init_color();
7606
7639
  import { existsSync as existsSync44 } from "node:fs";
7607
7640
  import { join as join56 } from "node:path";
7608
7641
 
7609
- // src/git-probe.ts
7642
+ // src/commands.pull.recovery.git.ts
7643
+ init_utils();
7610
7644
  import { execFileSync as execFileSync21 } from "node:child_process";
7645
+ function gitCapture(args, cwd) {
7646
+ return execFileSync21("git", args, {
7647
+ cwd,
7648
+ stdio: ["ignore", "pipe", "pipe"],
7649
+ maxBuffer: 64 * 1024 * 1024
7650
+ }).toString().trim();
7651
+ }
7652
+ function parsePorcelainZ(raw) {
7653
+ const tracked = [];
7654
+ const untracked = [];
7655
+ if (!raw) return { tracked, untracked };
7656
+ const records = raw.split("\0");
7657
+ for (let i = 0; i < records.length; i++) {
7658
+ const record = records[i];
7659
+ if (record.length < 3) continue;
7660
+ const xy = record.slice(0, 2);
7661
+ const filePath = record.slice(3);
7662
+ if (xy === "??") {
7663
+ untracked.push(filePath);
7664
+ continue;
7665
+ }
7666
+ tracked.push(filePath);
7667
+ if (xy.startsWith("R") || xy.startsWith("C")) {
7668
+ const src = records[i + 1];
7669
+ if (src) {
7670
+ tracked.push(src);
7671
+ i++;
7672
+ }
7673
+ }
7674
+ }
7675
+ return { tracked, untracked };
7676
+ }
7677
+ function parseDirtyPaths(repo, opts = {}) {
7678
+ return parsePorcelainZ(gitStatusPorcelainZ(repo, opts));
7679
+ }
7680
+
7681
+ // src/commands.pull.win32.ts
7682
+ init_config();
7683
+
7684
+ // src/git-probe.ts
7685
+ import { execFileSync as execFileSync22 } from "node:child_process";
7611
7686
  var PROBE_TIMEOUT_MS4 = 1e4;
7612
7687
  var PROBE_MAX_BUFFER = 64 * 1024 * 1024;
7613
7688
  function gitProbe(args, repo) {
7614
7689
  try {
7615
- return execFileSync21("git", args, {
7690
+ return execFileSync22("git", args, {
7616
7691
  cwd: repo,
7617
7692
  stdio: ["ignore", "pipe", "pipe"],
7618
7693
  timeout: PROBE_TIMEOUT_MS4,
@@ -7623,37 +7698,10 @@ function gitProbe(args, repo) {
7623
7698
  }
7624
7699
  }
7625
7700
 
7626
- // src/links.captures.ts
7627
- init_config();
7628
- import { existsSync as existsSync40, lstatSync as lstatSync17 } from "node:fs";
7629
- import { join as join52 } from "node:path";
7630
- function planSharedLinkCaptures(map) {
7631
- if (process.platform !== "win32") return [];
7632
- if (map === null) return [];
7633
- const claude = claudeHome();
7634
- const repo = repoHome();
7635
- const plan = [];
7636
- for (const name of allSharedLinks(map)) {
7637
- const localPath = join52(claude, name);
7638
- let stat;
7639
- try {
7640
- stat = lstatSync17(localPath, { throwIfNoEntry: false });
7641
- } catch {
7642
- continue;
7643
- }
7644
- if (stat === void 0) continue;
7645
- if (stat.isSymbolicLink()) continue;
7646
- const repoPath = join52(repo, "shared", name);
7647
- if (!existsSync40(repoPath)) continue;
7648
- plan.push({ name, localPath, repoPath });
7649
- }
7650
- return plan;
7651
- }
7652
-
7653
7701
  // src/links.deletions.ts
7654
7702
  init_config();
7655
- import { existsSync as existsSync41, lstatSync as lstatSync18, rmSync as rmSync17 } from "node:fs";
7656
- import { join as join53, relative as relative8, resolve as resolve4, sep as sep13 } from "node:path";
7703
+ import { existsSync as existsSync40, lstatSync as lstatSync17, rmSync as rmSync17 } from "node:fs";
7704
+ import { join as join52, relative as relative8, resolve as resolve4, sep as sep13 } from "node:path";
7657
7705
  init_utils();
7658
7706
  init_utils_fs();
7659
7707
  function isUnknown(declined, key) {
@@ -7663,27 +7711,28 @@ function deletionFor(key, names, claude, sharedRoot) {
7663
7711
  const segments = key.split("/");
7664
7712
  const name = segments[0];
7665
7713
  if (!names.has(name)) return null;
7666
- if (!existsSync41(join53(claude, name))) return null;
7714
+ if (!existsSync40(join52(claude, name))) return null;
7667
7715
  if (segments.some((segment) => isDeniedName(ALWAYS_NEVER_SYNC, segment))) return null;
7668
7716
  const repoPath = resolve4(sharedRoot, key);
7669
- const rel = relative8(join53(sharedRoot, name), repoPath);
7717
+ const rel = relative8(join52(sharedRoot, name), repoPath);
7670
7718
  if (rel === "" || rel === ".." || rel.startsWith(`..${sep13}`)) return null;
7671
7719
  if (segments.some((segment) => segment === "" || segment === "." || segment === ".."))
7672
7720
  return null;
7673
- if (!existsSync41(repoPath)) return null;
7674
- return { name, localPath: join53(claude, key), repoPath };
7721
+ if (!existsSync40(repoPath)) return null;
7722
+ return { kind: "deletion", name, localPath: join52(claude, key), repoPath };
7675
7723
  }
7676
- function planSharedLinkDeletions(map) {
7724
+ function planSharedLinkDeletions(map, opts = {}) {
7677
7725
  if (process.platform !== "win32") return [];
7678
7726
  if (map === null) return [];
7679
7727
  const baseline = readSharedBaseline();
7680
7728
  if (baseline === null) return [];
7681
7729
  const claude = claudeHome();
7682
- const sharedRoot = join53(repoHome(), "shared");
7683
- const scan = enumerateLocalSharedScan(map);
7730
+ const sharedRoot = join52(repoHome(), "shared");
7731
+ const linkNames = opts.linkNames ?? allSharedLinks(map);
7732
+ const scan = enumerateLocalSharedScan(map, { linkNames });
7684
7733
  const present = new Set(Object.keys(scan.files).map((key) => key.toLowerCase()));
7685
7734
  const declined = scan.declined.map((path) => path.toLowerCase());
7686
- const names = new Set(allSharedLinks(map));
7735
+ const names = new Set(linkNames);
7687
7736
  const plan = [];
7688
7737
  for (const key of Object.keys(baseline.files)) {
7689
7738
  const folded = key.toLowerCase();
@@ -7694,17 +7743,151 @@ function planSharedLinkDeletions(map) {
7694
7743
  }
7695
7744
  return plan;
7696
7745
  }
7697
- function applySharedLinkDeletions(map, ts) {
7746
+ function applySharedLinkDeletions(map, ts, opts = {}) {
7698
7747
  const repo = repoHome();
7699
- for (const entry of planSharedLinkDeletions(map)) {
7748
+ const removed = [];
7749
+ for (const entry of planSharedLinkDeletions(map, opts)) {
7700
7750
  try {
7701
- if (!lstatSync18(entry.repoPath).isFile()) continue;
7751
+ if (!lstatSync17(entry.repoPath).isFile()) continue;
7702
7752
  backupRepoWrite(entry.repoPath, ts, repo);
7703
7753
  rmSync17(entry.repoPath, { force: true });
7754
+ removed.push(entry);
7704
7755
  } catch (err) {
7705
7756
  warn(`could not remove ${entry.repoPath}: ${err.message}`);
7706
7757
  }
7707
7758
  }
7759
+ return removed;
7760
+ }
7761
+
7762
+ // src/links.mirror.ts
7763
+ init_config();
7764
+ import { existsSync as existsSync41, lstatSync as lstatSync18, rmSync as rmSync18 } from "node:fs";
7765
+ import { join as join53 } from "node:path";
7766
+ init_utils();
7767
+ init_utils_fs();
7768
+ function emitMirror(onPreview, name, localPath, repoPath) {
7769
+ if (onPreview) {
7770
+ onPreview({ kind: "mirror", name, localPath, repoPath });
7771
+ } else {
7772
+ log(`would capture: ${localPath} -> ${repoPath}`);
7773
+ }
7774
+ }
7775
+ function emitMirrorWet(onPreview, name, localPath, repoPath) {
7776
+ if (onPreview) onPreview({ kind: "mirror", name, localPath, repoPath });
7777
+ }
7778
+ function notShared(policy, target) {
7779
+ return !policy.adoptNew && !existsSync41(target);
7780
+ }
7781
+ function mirrorOneSharedName(name, claude, repo, policy, opts) {
7782
+ const localPath = join53(claude, name);
7783
+ const target = join53(repo, "shared", name);
7784
+ let stat;
7785
+ try {
7786
+ stat = lstatSync18(localPath, { throwIfNoEntry: false });
7787
+ } catch (err) {
7788
+ if (notShared(policy, target)) return;
7789
+ warn(
7790
+ `${name} could not be read (${err.message}), so it was left out of shared/ this run. Check its permissions, or whether another program has it open`
7791
+ );
7792
+ return;
7793
+ }
7794
+ if (stat === void 0) return;
7795
+ if (stat.isSymbolicLink()) return;
7796
+ if (notShared(policy, target)) return;
7797
+ if (opts.dryRun === true) {
7798
+ emitMirror(opts.onPreview, name, localPath, target);
7799
+ return;
7800
+ }
7801
+ if (policy.backupTs !== void 0) backupRepoWrite(target, policy.backupTs, repo);
7802
+ if (policy.overlay && stat.isDirectory()) {
7803
+ copyExtrasOverlayFiltered(localPath, target, NEVER_SYNC);
7804
+ } else {
7805
+ copyExtrasFiltered(localPath, target, NEVER_SYNC);
7806
+ }
7807
+ emitMirrorWet(opts.onPreview, name, localPath, target);
7808
+ }
7809
+ function mirrorSharedNames(map, policy, opts = {}) {
7810
+ if (process.platform !== "win32") return;
7811
+ if (map === null) return;
7812
+ const claude = claudeHome();
7813
+ const repo = repoHome();
7814
+ const linkNames = opts.linkNames ?? allSharedLinks(map);
7815
+ for (const name of linkNames) {
7816
+ mirrorOneSharedName(name, claude, repo, policy, opts);
7817
+ }
7818
+ }
7819
+ function syncSharedLinksPush(map, opts = {}) {
7820
+ mirrorSharedNames(map, { adoptNew: true, overlay: false }, opts);
7821
+ }
7822
+ function stageLocalSharedEdits(map, ts, opts = {}) {
7823
+ mirrorSharedNames(map, { adoptNew: false, overlay: true, backupTs: ts }, opts);
7824
+ }
7825
+ function presentAt(abs) {
7826
+ try {
7827
+ return lstatSync18(abs, { throwIfNoEntry: false }) !== void 0;
7828
+ } catch {
7829
+ return true;
7830
+ }
7831
+ }
7832
+ function removeUntrackedDenied(repo, path, segment, ts) {
7833
+ const abs = join53(repo, path);
7834
+ const denied = `the path segment "${segment}" is on the never-sync list`;
7835
+ if (!presentAt(abs)) {
7836
+ warn(
7837
+ `nothing was removed for ${path}: ${denied}, but nothing is at that path now. Either it went away after git listed it, in which case there is nothing left to do, or its name did not survive the decode of git's output and the real file is still in the sync repo under a name nomad cannot address. Look for it in ${repo} with "git status --untracked-files=all -- shared/"`
7838
+ );
7839
+ return;
7840
+ }
7841
+ const snapshotted = existsSync41(abs);
7842
+ try {
7843
+ backupRepoWrite(abs, ts, repo);
7844
+ } catch (err) {
7845
+ warn(
7846
+ `could not snapshot ${abs} before removing it (${err.message}), so it was left in place: ${denied}, so remove it by hand`
7847
+ );
7848
+ return;
7849
+ }
7850
+ try {
7851
+ rmSync18(abs, { recursive: true, force: true });
7852
+ if (presentAt(abs)) {
7853
+ warn(`could not remove ${abs}: ${denied}, so remove it by hand`);
7854
+ return;
7855
+ }
7856
+ const where = snapshotted ? `. A copy was snapshotted under backup/${ts}/repo/ first` : "";
7857
+ warn(`removed ${path} from the sync repo working tree: ${denied}${where}`);
7858
+ } catch (err) {
7859
+ warn(`could not remove ${abs}: ${err.message}`);
7860
+ }
7861
+ }
7862
+ function reportTrackedDenied(repo, path, segment) {
7863
+ const inHead = gitProbe(["ls-tree", "--name-only", "HEAD", "--", path], repo);
7864
+ const denied = `the path segment "${segment}" is on the never-sync list`;
7865
+ if (inHead === null) {
7866
+ warn(
7867
+ `could not check ${path} against HEAD: ${denied}. Nothing was changed. Inspect it with "git status -- ${path}" and take it out of shared/ before committing`
7868
+ );
7869
+ return;
7870
+ }
7871
+ if (inHead.trim() === "") {
7872
+ warn(
7873
+ `${path} is staged and has no committed version: ${denied}. Nothing was changed. Run "git rm --cached -- ${path}" to take it out of the index; that leaves the file on disk but makes it untracked, which the next nomad pull removes from the sync repo working tree (snapshotting it into the backup cache first), so move it outside shared/ instead if you want to keep it. If it is the destination half of a staged rename, "git diff --cached --name-status" names the source, whose content IS committed, so restore that half with "git checkout HEAD -- <source>" rather than leaving its deletion staged`
7874
+ );
7875
+ return;
7876
+ }
7877
+ if (!existsSync41(join53(repo, path))) return;
7878
+ warn(
7879
+ `${path} is tracked and has changes against HEAD: ${denied}. Nothing was changed. Run "git checkout HEAD -- ${path}" to put the committed content back, or move the file outside shared/ if you want to keep it. Neither of those takes the committed copy out of the repo: "git rm -- ${path}" and a commit does that going forward, and if it holds a real secret, rotate it and rewrite history, because nomad only changes your local worktree and index and cannot scrub what a previous push already sent to the remote`
7880
+ );
7881
+ }
7882
+ function revertDeniedMirrorPaths(repo, status, ts) {
7883
+ for (const path of new Set(status.untracked)) {
7884
+ const segment = deniedSegmentFor(path);
7885
+ if (segment !== null) removeUntrackedDenied(repo, path, segment, ts);
7886
+ }
7887
+ for (const path of new Set(status.tracked)) {
7888
+ const segment = deniedSegmentFor(path);
7889
+ if (segment !== null) reportTrackedDenied(repo, path, segment);
7890
+ }
7708
7891
  }
7709
7892
 
7710
7893
  // src/preview.ts
@@ -8051,8 +8234,8 @@ function formatLinkRow(e) {
8051
8234
  if (e.kind === "copy") return `would copy ${e.from} -> ${e.to}`;
8052
8235
  return `${e.kind} ${e.from} -> ${e.to}`;
8053
8236
  }
8054
- function formatCaptureRow(c) {
8055
- return `would capture ${c.localPath} -> ${c.repoPath}`;
8237
+ function formatMirrorRow(e) {
8238
+ return `would capture ${e.localPath} -> ${e.repoPath}`;
8056
8239
  }
8057
8240
  function formatDeletionRow(d) {
8058
8241
  return `would remove ${d.repoPath} (gone from ${d.localPath})`;
@@ -8060,6 +8243,23 @@ function formatDeletionRow(d) {
8060
8243
  function formatSessionRow(e) {
8061
8244
  return e.kind === "overwrite" ? `overwrite ${e.dst} (from ${e.src})` : e.text;
8062
8245
  }
8246
+ function appendMirrorPlanRows(links, ts, map, plans) {
8247
+ if (plans) {
8248
+ for (const capture of plans.captures) addItem(links, formatMirrorRow(capture));
8249
+ for (const deletion of plans.deletions) addItem(links, formatDeletionRow(deletion));
8250
+ return plans.namesDerived;
8251
+ }
8252
+ const linkNames = allSharedLinks(map);
8253
+ stageLocalSharedEdits(map, ts, {
8254
+ dryRun: true,
8255
+ onPreview: (e) => addItem(links, formatMirrorRow(e)),
8256
+ linkNames
8257
+ });
8258
+ for (const deletion of planSharedLinkDeletions(map, { linkNames })) {
8259
+ addItem(links, formatDeletionRow(deletion));
8260
+ }
8261
+ return true;
8262
+ }
8063
8263
  function buildSettingsSectionForPreview(result) {
8064
8264
  const s = section("settings.json", true);
8065
8265
  if (result.diff !== "") {
@@ -8078,18 +8278,10 @@ function computePreview(ts, map, verb = "pull", plans) {
8078
8278
  console.log(`would pull on host=${HOST} (preview; nothing applied)`);
8079
8279
  console.log("");
8080
8280
  const links = section("Symlinks");
8081
- const { captures, deletions } = plans ?? {
8082
- captures: planSharedLinkCaptures(map),
8083
- deletions: planSharedLinkDeletions(map)
8084
- };
8085
- for (const capture of captures) {
8086
- addItem(links, formatCaptureRow(capture));
8087
- }
8088
- for (const deletion of deletions) {
8089
- addItem(links, formatDeletionRow(deletion));
8090
- }
8281
+ const namesDerived = appendMirrorPlanRows(links, ts, map, plans);
8091
8282
  applySharedLinks(ts, map, {
8092
8283
  dryRun: true,
8284
+ quietNames: namesDerived,
8093
8285
  onPreview: (e) => addItem(links, formatLinkRow(e))
8094
8286
  });
8095
8287
  const settingsResult = previewSettings(
@@ -8139,6 +8331,19 @@ function readMapForMirror(mapPath) {
8139
8331
  return null;
8140
8332
  }
8141
8333
  }
8334
+ function sharedDirsUnchanged(before, after) {
8335
+ return JSON.stringify(before) === JSON.stringify(after);
8336
+ }
8337
+ function namesAlreadyReported(namesDerived, derivedSharedDirs, map) {
8338
+ return namesDerived && sharedDirsUnchanged(derivedSharedDirs, map.sharedDirs);
8339
+ }
8340
+ function plansAgainst(plans, map) {
8341
+ if (plans === void 0) return void 0;
8342
+ return {
8343
+ ...plans,
8344
+ namesDerived: namesAlreadyReported(plans.namesDerived, plans.derivedSharedDirs, map)
8345
+ };
8346
+ }
8142
8347
  function untrackedUnderShared(repo) {
8143
8348
  const out = gitProbe(["ls-files", "--others", "--exclude-standard", "-z", "--", "shared/"], repo);
8144
8349
  if (out === null) return null;
@@ -8148,27 +8353,94 @@ function newlyUntracked(before, after) {
8148
8353
  if (before === null || after === null) return [];
8149
8354
  return [...after].filter((p) => !before.has(p));
8150
8355
  }
8356
+ function revertDeniedUnderShared(repo, ts) {
8357
+ const out = gitProbe(
8358
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "shared/"],
8359
+ repo
8360
+ );
8361
+ if (out === null) return;
8362
+ revertDeniedMirrorPaths(repo, parsePorcelainZ(out), ts);
8363
+ }
8151
8364
  function reconcileSharedLinksBeforePull(repo, ts) {
8152
- if (process.platform !== "win32") return [];
8365
+ if (process.platform !== "win32") {
8366
+ return { mirrored: [], events: [], namesDerived: false, derivedSharedDirs: void 0 };
8367
+ }
8153
8368
  const before = untrackedUnderShared(repo);
8369
+ const events = [];
8370
+ let linkNames = [];
8371
+ let namesDerived = false;
8372
+ let derivedSharedDirs;
8154
8373
  try {
8155
8374
  const map = readMapForMirror(join56(repo, "path-map.json"));
8156
- stageLocalSharedEdits(map, ts);
8157
- applySharedLinkDeletions(map, ts);
8375
+ if (map !== null) {
8376
+ linkNames = allSharedLinks(map);
8377
+ namesDerived = true;
8378
+ derivedSharedDirs = map.sharedDirs;
8379
+ }
8380
+ stageLocalSharedEdits(map, ts, { onPreview: (e) => events.push(e), linkNames });
8381
+ events.push(...applySharedLinkDeletions(map, ts, { linkNames }));
8158
8382
  } catch (err) {
8159
8383
  warn(`could not reconcile local shared edits before the pull: ${err.message}`);
8160
8384
  }
8161
- return newlyUntracked(before, untrackedUnderShared(repo));
8385
+ revertDeniedUnderShared(repo, ts);
8386
+ return {
8387
+ mirrored: newlyUntracked(before, untrackedUnderShared(repo)),
8388
+ events,
8389
+ namesDerived,
8390
+ derivedSharedDirs
8391
+ };
8392
+ }
8393
+ function buildMirrorSection(events, discard) {
8394
+ const s = section("Symlinks");
8395
+ for (const e of events) {
8396
+ addItem(
8397
+ s,
8398
+ e.kind === "deletion" ? `removed ${e.repoPath} (gone from ${e.localPath})` : `captured ${e.localPath} -> ${e.repoPath}`
8399
+ );
8400
+ }
8401
+ if (discard) {
8402
+ const names = discard.count === 1 ? "name was" : "names were";
8403
+ addItem(
8404
+ s,
8405
+ `${yellow(warnGlyph)} recovered from a wedged repo, ${discard.count} shared ${names} restored from the repo copy, previous host copies backed up to ${discard.backupPath}`
8406
+ );
8407
+ }
8408
+ return s;
8162
8409
  }
8163
- function planSharedReconcileBeforePull(repo) {
8410
+ function describeSkippedMirrorDiscard(repo, ts) {
8411
+ if (process.platform !== "win32") return null;
8412
+ try {
8413
+ const map = readMapForMirror(join56(repo, "path-map.json"));
8414
+ const linkNames = map !== null ? allSharedLinks(map, { quiet: true }) : void 0;
8415
+ const captures = [];
8416
+ stageLocalSharedEdits(map, ts, {
8417
+ dryRun: true,
8418
+ onPreview: (e) => captures.push(e),
8419
+ linkNames
8420
+ });
8421
+ if (captures.length === 0) return null;
8422
+ return { count: captures.length, backupPath: join56(backupBase(), ts) };
8423
+ } catch {
8424
+ return null;
8425
+ }
8426
+ }
8427
+ function planSharedReconcileBeforePull(repo, ts) {
8164
8428
  const map = readMapForMirror(join56(repo, "path-map.json"));
8165
- return { captures: planSharedLinkCaptures(map), deletions: planSharedLinkDeletions(map) };
8429
+ const captures = [];
8430
+ const linkNames = process.platform === "win32" && map !== null ? allSharedLinks(map) : void 0;
8431
+ stageLocalSharedEdits(map, ts, { dryRun: true, onPreview: (e) => captures.push(e), linkNames });
8432
+ return {
8433
+ captures,
8434
+ deletions: planSharedLinkDeletions(map, { linkNames }),
8435
+ namesDerived: linkNames !== void 0,
8436
+ derivedSharedDirs: map?.sharedDirs
8437
+ };
8166
8438
  }
8167
8439
 
8168
8440
  // src/commands.pull.collision.ts
8169
8441
  init_config();
8170
8442
  init_exit_codes();
8171
- import { existsSync as existsSync45, lstatSync as lstatSync19, readFileSync as readFileSync20, rmSync as rmSync18 } from "node:fs";
8443
+ import { existsSync as existsSync45, lstatSync as lstatSync19, readFileSync as readFileSync20, rmSync as rmSync19 } from "node:fs";
8172
8444
  import { join as join57 } from "node:path";
8173
8445
  init_utils();
8174
8446
  var SHARED_PREFIX = /^shared\//;
@@ -8237,7 +8509,7 @@ function removeMirroredCopies(repo, repoRelPaths) {
8237
8509
  try {
8238
8510
  if (lstatSync19(abs, { throwIfNoEntry: false })?.isFile() !== true) continue;
8239
8511
  if (!matchesLocalOriginal(repo, rel)) continue;
8240
- rmSync18(abs, { force: true });
8512
+ rmSync19(abs, { force: true });
8241
8513
  } catch (err) {
8242
8514
  warn(`could not remove nomad's copy at ${abs}: ${err.message}`);
8243
8515
  }
@@ -8264,47 +8536,6 @@ init_commands_pull_wedge();
8264
8536
  init_config();
8265
8537
  init_utils();
8266
8538
  init_utils_fs();
8267
-
8268
- // src/commands.pull.recovery.git.ts
8269
- init_utils();
8270
- import { execFileSync as execFileSync22 } from "node:child_process";
8271
- function gitCapture(args, cwd) {
8272
- return execFileSync22("git", args, {
8273
- cwd,
8274
- stdio: ["ignore", "pipe", "pipe"],
8275
- maxBuffer: 64 * 1024 * 1024
8276
- }).toString().trim();
8277
- }
8278
- function parsePorcelainZ(raw) {
8279
- const tracked = [];
8280
- const untracked = [];
8281
- if (!raw) return { tracked, untracked };
8282
- const records = raw.split("\0");
8283
- for (let i = 0; i < records.length; i++) {
8284
- const record = records[i];
8285
- if (record.length < 3) continue;
8286
- const xy = record.slice(0, 2);
8287
- const filePath = record.slice(3);
8288
- if (xy === "??") {
8289
- untracked.push(filePath);
8290
- continue;
8291
- }
8292
- tracked.push(filePath);
8293
- if (xy.startsWith("R") || xy.startsWith("C")) {
8294
- const src = records[i + 1];
8295
- if (src) {
8296
- tracked.push(src);
8297
- i++;
8298
- }
8299
- }
8300
- }
8301
- return { tracked, untracked };
8302
- }
8303
- function parseDirtyPaths(repo, opts = {}) {
8304
- return parsePorcelainZ(gitStatusPorcelainZ(repo, opts));
8305
- }
8306
-
8307
- // src/commands.pull.recovery.ts
8308
8539
  function isSyncedConfig(path) {
8309
8540
  return PUSH_ALLOWED_STATIC.some(
8310
8541
  (entry) => entry.endsWith("/") ? path.startsWith(entry) : path === entry
@@ -8420,9 +8651,9 @@ function capturePrePostHeads(repo, rebase) {
8420
8651
  if (pre === void 0 || post === void 0) return void 0;
8421
8652
  return { pre, post };
8422
8653
  }
8423
- function buildWetPullSections(ts, map, prePostHeads) {
8424
- applySharedLinks(ts, map);
8425
- writeSharedBaseline(map);
8654
+ function buildWetPullSections(ts, map, prePostHeads, namesDerived = false) {
8655
+ applySharedLinks(ts, map, { quietNames: namesDerived });
8656
+ writeSharedBaseline(map, { quiet: true });
8426
8657
  const { label } = regenerateSettings(ts);
8427
8658
  syncSkillsPull(ts, prePostHeads);
8428
8659
  const remapResult = withSpinner("Syncing sessions", () => remapPull(ts));
@@ -8445,22 +8676,24 @@ function buildWetPullSections(ts, map, prePostHeads) {
8445
8676
  };
8446
8677
  }
8447
8678
  function handleWedge(repo, forceRemote) {
8448
- const wedge = classifyWedge(repo);
8449
- if (wedge === null) return;
8679
+ const { state: wedge, probe } = classifyWedgeWithProbe(repo);
8680
+ if (wedge === null) {
8681
+ if (forceRemote) log(cleanRepoForceRemoteMessage(probe));
8682
+ return false;
8683
+ }
8450
8684
  if (wedge === "unmerged-index") {
8451
8685
  if (forceRemote) {
8452
8686
  recoverUnmergedIndex(repo);
8453
- } else {
8454
- die(unmergedIndexRunbookText("nomad pull"), { code: EXIT.CONFLICT });
8687
+ return false;
8455
8688
  }
8456
- return;
8689
+ return die(unmergedIndexRunbookText("nomad pull"), { code: EXIT.CONFLICT });
8457
8690
  }
8458
8691
  if (forceRemote) {
8459
8692
  recoverForceRemote(wedge, repo);
8460
- return;
8693
+ return true;
8461
8694
  }
8462
8695
  const state = wedge === "rebase" ? "mid-rebase" : "mid-merge";
8463
- die(wedgeMarkerRunbookText(state), { code: EXIT.CONFLICT });
8696
+ return die(wedgeMarkerRunbookText(state), { code: EXIT.CONFLICT });
8464
8697
  }
8465
8698
  function runPullCore(opts = {}) {
8466
8699
  const dryRun = opts.dryRun === true;
@@ -8469,7 +8702,7 @@ function runPullCore(opts = {}) {
8469
8702
  const repo = repoHome();
8470
8703
  const backup = backupBase();
8471
8704
  const ts = freshBackupTs(backup);
8472
- handleWedge(repo, forceRemote);
8705
+ const recovered = handleWedge(repo, forceRemote);
8473
8706
  if (!dryRun) {
8474
8707
  const backupRoot = join58(backup, ts);
8475
8708
  try {
@@ -8483,8 +8716,13 @@ function runPullCore(opts = {}) {
8483
8716
  dryRun ? `pulling on host=${HOST} (backup=${ts}; dry-run)` : `pull on host=${HOST} (backup=${ts})`
8484
8717
  );
8485
8718
  }
8486
- const mirrored = !dryRun && !forceRemote ? reconcileSharedLinksBeforePull(repo, ts) : [];
8487
- const sharedPlans = dryRun ? planSharedReconcileBeforePull(repo) : void 0;
8719
+ const {
8720
+ mirrored,
8721
+ events: mirrorEvents,
8722
+ namesDerived,
8723
+ derivedSharedDirs
8724
+ } = !dryRun && !recovered ? reconcileSharedLinksBeforePull(repo, ts) : { mirrored: [], events: [], namesDerived: false, derivedSharedDirs: void 0 };
8725
+ const sharedPlans = dryRun ? planSharedReconcileBeforePull(repo, ts) : void 0;
8488
8726
  const prePostHeads = capturePrePostHeads(repo, () => {
8489
8727
  pullWithCollisionRunbook(repo, mirrored);
8490
8728
  });
@@ -8493,18 +8731,20 @@ function runPullCore(opts = {}) {
8493
8731
  const map = existsSync46(mapPath) ? readPathMap(mapPath) : { projects: {} };
8494
8732
  const divergedKeptLocal = divergenceCheckExtras(ts, dryRun ? prePostHeads : void 0);
8495
8733
  if (dryRun) {
8496
- computePreview(ts, map, "pull", sharedPlans);
8734
+ computePreview(ts, map, "pull", plansAgainst(sharedPlans, map));
8497
8735
  return { tag: "dry" };
8498
8736
  }
8737
+ const discardedMirror = recovered && !dryRun ? describeSkippedMirrorDiscard(repo, ts) : void 0;
8499
8738
  const { sections, localOnly, settingsLabel, unmapped, extrasSkipped } = buildWetPullSections(
8500
8739
  ts,
8501
8740
  map,
8502
- prePostHeads
8741
+ prePostHeads,
8742
+ namesAlreadyReported(namesDerived, derivedSharedDirs, map)
8503
8743
  );
8504
8744
  const incomingChanges = prePostHeads === void 0 ? true : prePostHeads.pre !== prePostHeads.post;
8505
8745
  return {
8506
8746
  tag: "wet",
8507
- sections,
8747
+ sections: [buildMirrorSection(mirrorEvents, discardedMirror), ...sections],
8508
8748
  localOnly,
8509
8749
  divergedKeptLocal,
8510
8750
  incomingChanges,
@@ -8612,19 +8852,6 @@ function isAllowed(path, allowed) {
8612
8852
  }
8613
8853
  return false;
8614
8854
  }
8615
- function blockSetFor(segments) {
8616
- if (segments[0] !== "shared" || segments[1] !== "extras") return NEVER_SYNC;
8617
- return isClaudeExtraName(segments[3] ?? "") ? CLAUDE_EXTRA_NEVER_SYNC : ALWAYS_NEVER_SYNC;
8618
- }
8619
- function isNeverSync(path) {
8620
- const segments = path.split("/");
8621
- const blockSet = blockSetFor(segments);
8622
- const scan = segments[0] === "shared" && segments[1] === "extras" ? segments.slice(4) : segments;
8623
- for (const segment of scan) {
8624
- if (isDeniedName(blockSet, segment)) return true;
8625
- }
8626
- return false;
8627
- }
8628
8855
  var GSD_HOOKS_SUPPORT_FILES = /* @__PURE__ */ new Set(["managed-hooks-registry.cjs", "package.json"]);
8629
8856
  var GSD_HOOKS_SUPPORT_DIR = "lib";
8630
8857
  function isGsdDropped(path) {
@@ -8849,7 +9076,7 @@ init_color();
8849
9076
  init_config();
8850
9077
  init_config_sharedDirs_guard();
8851
9078
  import { randomBytes as randomBytes4 } from "node:crypto";
8852
- import { copyFileSync, existsSync as existsSync49, lstatSync as lstatSync20, mkdirSync as mkdirSync15, readdirSync as readdirSync19, rmSync as rmSync19 } from "node:fs";
9079
+ import { copyFileSync, existsSync as existsSync49, lstatSync as lstatSync20, mkdirSync as mkdirSync15, readdirSync as readdirSync19, rmSync as rmSync20 } from "node:fs";
8853
9080
  import { homedir as homedir7 } from "node:os";
8854
9081
  import { join as join62, relative as relative10, sep as sep14 } from "node:path";
8855
9082
  init_push_leak_verdict();
@@ -8947,7 +9174,7 @@ function previewPushLeaks(map, opts = {}) {
8947
9174
  }
8948
9175
  return verdictFromFindings(findings);
8949
9176
  } finally {
8950
- rmSync19(tmpRoot, { recursive: true, force: true });
9177
+ rmSync20(tmpRoot, { recursive: true, force: true });
8951
9178
  }
8952
9179
  }
8953
9180
 
@@ -9076,7 +9303,8 @@ async function runPushCore(opts = {}) {
9076
9303
  const extras = withSpinner("Syncing extras", () => remapExtrasPush(ts, { dryRun }));
9077
9304
  if (!dryRun) {
9078
9305
  syncSkillsPush();
9079
- syncSharedLinksPush(map);
9306
+ const linkNames = map !== null ? allSharedLinks(map) : void 0;
9307
+ syncSharedLinksPush(map, { linkNames });
9080
9308
  stripGsdHooksFromBase(repo, backup);
9081
9309
  }
9082
9310
  const st = { dryRun, remap, extras, globalConfig: [] };
@@ -9199,7 +9427,14 @@ function buildSyncSummarySection(pull, pushOutcome) {
9199
9427
  for (const row2 of buildSkipAndCollisionRows(pull, result)) addItem(s, row2);
9200
9428
  return s;
9201
9429
  }
9202
- var SYNC_SECTION_ORDER = ["Settings", "Global config", "Sessions", "Extras", "Leak scan"];
9430
+ var SYNC_SECTION_ORDER = [
9431
+ "Symlinks",
9432
+ "Settings",
9433
+ "Global config",
9434
+ "Sessions",
9435
+ "Extras",
9436
+ "Leak scan"
9437
+ ];
9203
9438
  function isDroppedSummaryHeader(header) {
9204
9439
  return header === PULL_SUMMARY_HEADER || header === "Push summary";
9205
9440
  }
@@ -9428,7 +9663,7 @@ function buildCrashReport(input) {
9428
9663
  }
9429
9664
 
9430
9665
  // src/crash-report.redact.ts
9431
- import { mkdtempSync as mkdtempSync3, rmSync as rmSync20, writeFileSync as writeFileSync10 } from "node:fs";
9666
+ import { mkdtempSync as mkdtempSync3, rmSync as rmSync21, writeFileSync as writeFileSync10 } from "node:fs";
9432
9667
  import { tmpdir as tmpdir2 } from "node:os";
9433
9668
  import { join as join65 } from "node:path";
9434
9669
  init_push_gitleaks_scan();
@@ -9448,7 +9683,7 @@ function redactWithGitleaks(text, scan = scanFile) {
9448
9683
  } finally {
9449
9684
  if (dir !== void 0) {
9450
9685
  try {
9451
- rmSync20(dir, { recursive: true, force: true });
9686
+ rmSync21(dir, { recursive: true, force: true });
9452
9687
  } catch {
9453
9688
  }
9454
9689
  }
@@ -9626,7 +9861,7 @@ init_config();
9626
9861
  init_utils();
9627
9862
  init_utils_fs();
9628
9863
  init_utils_json();
9629
- import { copyFileSync as copyFileSync2, cpSync as cpSync10, existsSync as existsSync53, rmSync as rmSync21, statSync as statSync13 } from "node:fs";
9864
+ import { copyFileSync as copyFileSync2, cpSync as cpSync10, existsSync as existsSync53, rmSync as rmSync22, statSync as statSync13 } from "node:fs";
9630
9865
  import { join as join68 } from "node:path";
9631
9866
  function snapshotIntoShared(map) {
9632
9867
  const repo = repoHome();
@@ -9637,7 +9872,7 @@ function snapshotIntoShared(map) {
9637
9872
  const dst = join68(repo, "shared", name);
9638
9873
  if (statSync13(src).isDirectory()) {
9639
9874
  const gk = join68(dst, ".gitkeep");
9640
- if (existsSync53(gk)) rmSync21(gk);
9875
+ if (existsSync53(gk)) rmSync22(gk);
9641
9876
  cpSync10(src, dst, { recursive: true, force: false, errorOnExist: true });
9642
9877
  } else {
9643
9878
  copyFileSync2(src, dst);
@@ -10098,7 +10333,7 @@ function parseSyncArgs(argv) {
10098
10333
  // package.json
10099
10334
  var package_default = {
10100
10335
  name: "claude-nomad",
10101
- version: "0.64.1",
10336
+ version: "0.65.0",
10102
10337
  type: "module",
10103
10338
  description: "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
10104
10339
  keywords: [
@@ -10209,7 +10444,8 @@ var DEFAULT_HELP = [
10209
10444
  cont("abort the in-progress rebase/merge, park stranded commits on"),
10210
10445
  cont("nomad/stranded-<ts>, reset to origin/main, and re-pull. Refuses"),
10211
10446
  cont("if stranded or dirty tracked changes touch synced config (shared/,"),
10212
- cont("hosts/, path-map.json). Cannot combine with --dry-run."),
10447
+ cont("hosts/, path-map.json). Cannot combine with --dry-run. On a clean"),
10448
+ cont("repo, reports nothing to recover and pulls normally."),
10213
10449
  "",
10214
10450
  row(" push", "Rebase, run safety checks (gitleaks, gitlinks, allow-list), commit, push."),
10215
10451
  row(" --dry-run", "Run pre-checks (rebase, gitleaks probe, gitlink scan) and preview"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.64.1",
3
+ "version": "0.65.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": [