claude-nomad 0.21.0 → 0.22.1

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/.gitleaks.toml CHANGED
@@ -6,7 +6,7 @@
6
6
  [extend]
7
7
  useDefault = true
8
8
 
9
- [allowlist]
9
+ [[allowlists]]
10
10
  description = "claude-nomad: structurally-distinguishable tool-output noise"
11
11
  regexes = [
12
12
  '''AY[A-Za-z0-9_-]{20,}''',
@@ -14,3 +14,22 @@ regexes = [
14
14
  '''"id"\s*:\s*"[a-f0-9]{40,64}"''',
15
15
  '''key=[a-f0-9]{8,} [\w./-]+\.\w+:\d+''',
16
16
  ]
17
+
18
+ # Path-scoped: the documented test-fixture github-pat literal accumulates
19
+ # in Claude Code session transcripts whenever a conversation touches the
20
+ # Pitfall 4 docs or this allowlist itself. Live sessions cannot be
21
+ # safely sed-scrubbed (sed -i renames out from under the running CLI's
22
+ # open file descriptor and silently drops post-rename writes), so the
23
+ # only sustainable false-positive handler is a narrow allowlist scoped
24
+ # to synced session paths. `condition = "AND"` requires BOTH the literal
25
+ # AND a `shared/projects/<logical>/.../*.jsonl` path; a real PAT in the
26
+ # same file (different 36-char body) still fires.
27
+ [[allowlists]]
28
+ description = "claude-nomad: documented test-fixture github-pat literal in synced session transcripts"
29
+ regexes = [
30
+ '''ghp_xJZbT3qfV2nLpKR8mYwH4dGtCsW9aE1uF6oA''',
31
+ ]
32
+ paths = [
33
+ '''^shared/projects/[^/]+/.*\.jsonl$''',
34
+ ]
35
+ condition = "AND"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.22.1](https://github.com/funkadelic/claude-nomad/compare/v0.22.0...v0.22.1) (2026-05-23)
4
+
5
+
6
+ ### Fixed
7
+
8
+ * **gitignore:** anchor .planning/ to source repo root ([#105](https://github.com/funkadelic/claude-nomad/issues/105)) ([50c403d](https://github.com/funkadelic/claude-nomad/commit/50c403d7223f79f30fa28b99ce6e5b2dcc350356))
9
+
10
+ ## [0.22.0](https://github.com/funkadelic/claude-nomad/compare/v0.21.0...v0.22.0) (2026-05-23)
11
+
12
+
13
+ ### Added
14
+
15
+ * **extras-sync:** sync per-project .planning/ directories across hosts ([#103](https://github.com/funkadelic/claude-nomad/issues/103)) ([4563fe3](https://github.com/funkadelic/claude-nomad/commit/4563fe32a143dd9a2450baab8ec94a902800c2b3))
16
+
3
17
  ## [0.21.0](https://github.com/funkadelic/claude-nomad/compare/v0.20.0...v0.21.0) (2026-05-22)
4
18
 
5
19
 
package/README.md CHANGED
@@ -12,10 +12,11 @@ claude-nomad keeps all of it in sync through a private Git repo you control. `no
12
12
 
13
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.
14
14
 
15
- Two things it does that ad-hoc dotfiles syncing can't:
15
+ Three things it does that ad-hoc dotfiles syncing can't:
16
16
 
17
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
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
20
 
20
21
  ## Table of contents
21
22
 
@@ -114,7 +115,8 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
114
115
  │ ├── rules/
115
116
  │ ├── my-statusline.cjs # any script you want symlinked into ~/.claude/
116
117
  │ ├── .gitignore # defense-in-depth: blocks .claude.json, settings.local.json, *.token, *.key, .env
117
- └── projects/ # session transcripts under logical names
118
+ ├── projects/ # session transcripts under logical names
119
+ │ └── extras/ # opt-in per-project content (materializes when path-map.json declares extras)
118
120
  ├── hosts/
119
121
  │ ├── <your-mac>.json # patches merged over settings.base.json
120
122
  │ ├── <your-wsl-host>.json
@@ -125,13 +127,14 @@ By default the CLI operates on `~/claude-nomad/` (see `REPO_HOME` in `src/config
125
127
 
126
128
  ## What gets synced vs. not
127
129
 
128
- | Category | Items | Behavior |
129
- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
130
- | **Synced** | `CLAUDE.md`, `agents/`, `skills/`, `commands/`, `rules/`, `my-statusline.cjs` | Symlinked into `~/.claude/` from `shared/` (see `SHARED_LINKS` in `src/config.ts`). |
131
- | **Generated** | `settings.json` | Deep-merge of `settings.base.json` with `hosts/<hostname>.json`. Rewritten on every pull. |
132
- | **Remapped** | `projects/` session transcripts | Copied with path translation per `path-map.json`. |
133
- | **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. |
134
- | **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 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. |
135
138
 
136
139
  > [!NOTE]
137
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.
@@ -142,7 +145,7 @@ For the rationale behind these choices, see [What does NOT sync (deliberate trad
142
145
 
143
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.
144
147
 
145
- `path-map.json` defines logical names and where the repo lives on each host:
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:
146
149
 
147
150
  ```json
148
151
  {
@@ -152,6 +155,9 @@ The hard problem: Claude Code stores sessions in `~/.claude/projects/<encoded-pa
152
155
  "<your-wsl-host>": "/home/you/code/ha-acwd",
153
156
  "<your-nuc>": "TBD"
154
157
  }
158
+ },
159
+ "extras": {
160
+ "ha-acwd": [".planning"]
155
161
  }
156
162
  }
157
163
  ```
@@ -163,6 +169,8 @@ Use the literal string `"TBD"` for hosts you haven't onboarded yet; `remapPull`
163
169
 
164
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).
165
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).
173
+
166
174
  ## Per-host overrides
167
175
 
168
176
  `settings.base.json` holds portable defaults (model, permissions, plugins). `hosts/<NOMAD_HOST>.json` holds machine-specific patches. They're deep-merged on every pull (scalars override, objects merge recursively, arrays replace). Keys that used to be force-marked per-host because they embedded absolute paths (`statusLine.command`, `hooks`) can live in base if you write the commands with `$HOME` (e.g. `"command": "node \"$HOME/.claude/my-statusline.cjs\""`); Claude Code runs them through a shell so shell expansion applies. Reserve per-host files for truly machine-specific values (env, MCP URLs, host-only model overrides).
@@ -198,12 +206,13 @@ Read these before adopting so you opt in with eyes open.
198
206
  - **Manual push/pull.** No file watcher. Shell hooks recommended.
199
207
  - **OAuth doesn't sync.** You'll log in once per host. Intentional.
200
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`.
201
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.
202
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.
203
212
 
204
213
  ## Requirements
205
214
 
206
- - Node.js 22.22.1 or newer (24 LTS recommended; the npm `engines` field declares the 22.22.1 floor and surfaces a warning on older runtimes npm only blocks the install when `engine-strict=true` is configured)
215
+ - Node.js 22.22.1 or newer (24 LTS recommended; the npm `engines` field declares the 22.22.1 floor and surfaces a warning on older runtimes - npm only blocks the install when `engine-strict=true` is configured)
207
216
  - `tsx` (ships as a runtime dependency of the published package; no separate global install required)
208
217
  - Git
209
218
  - A **private** GitHub repo (or any Git remote you control)
@@ -354,10 +363,10 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
354
363
  | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
355
364
  | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
356
365
  | `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
357
- | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths. FATAL if scaffold missing. |
366
+ | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. FATAL if scaffold missing. |
358
367
  | `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
359
368
  | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
360
- | `nomad push` | Export local sessions to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
369
+ | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
361
370
  | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
362
371
  | `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` is preserved. See [Recovery flows](#recovery-flows). |
363
372
  | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
@@ -435,7 +444,7 @@ The file extends the default gitleaks ruleset, so real high-entropy secrets like
435
444
  [extend]
436
445
  useDefault = true
437
446
 
438
- [allowlist]
447
+ [[allowlists]]
439
448
  description = "claude-nomad: structurally-distinguishable tool-output noise"
440
449
  regexes = [
441
450
  '''AY[A-Za-z0-9_-]{20,}''',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
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": [
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  import { HOME, HOST, REPO_HOME } from './config.ts';
5
+ import { divergenceCheckExtras, remapExtrasPull } from './extras-sync.ts';
5
6
  import { applySharedLinks, regenerateSettings } from './links.ts';
6
7
  import { computePreview } from './preview.ts';
7
8
  import { remapPull } from './remap.ts';
@@ -12,18 +13,23 @@ import { acquireLock, die, fail, freshBackupTs, gitOrFatal, log, NomadFatal, rel
12
13
  /**
13
14
  * `nomad pull` command. Acquires the push/pull lock, takes a backup
14
15
  * timestamp, runs `git pull --rebase --autostash` in `REPO_HOME`, then
15
- * applies the three side-effecting sync steps in order:
16
- * 1. `applySharedLinks` (symlink shared/* into ~/.claude/)
17
- * 2. `regenerateSettings` (deep-merge base + host-override into settings.json)
18
- * 3. `remapPull` (copy repo-side session transcripts into host-encoded dirs)
16
+ * applies the side-effecting sync steps in order:
17
+ * 1. `divergenceCheckExtras` (read-only WARN naming local files that
18
+ * diverge from origin; fires in BOTH wet and dry modes per D-08)
19
+ * 2. `applySharedLinks` (symlink shared/* into ~/.claude/)
20
+ * 3. `regenerateSettings` (deep-merge base + host-override into settings.json)
21
+ * 4. `remapPull` (copy repo-side session transcripts into host-encoded dirs)
22
+ * 5. `remapExtrasPull` (copy `shared/extras/<logical>/<dirname>/` back
23
+ * into each project's localRoot; SKIPPED under dryRun)
19
24
  *
20
25
  * `opts.dryRun` (default `false`): when `true`, the lock IS still acquired
21
26
  * and `git pull --rebase` still runs (so concurrent invocations cannot race
22
- * and so the user sees the same network round-trip behavior they would on a
23
- * real pull). Then `computePreview` runs in place of the three mutating
24
- * steps. The per-run backup directory under
25
- * `~/.cache/claude-nomad/backup/<ts>/` is intentionally NOT created (no
26
- * backups are written under dryRun and an empty dir would pollute the cache).
27
+ * and the user sees the same network round-trip as a real pull).
28
+ * `divergenceCheckExtras` still fires (read-only by design). Then
29
+ * `computePreview` runs in place of the four mutating steps. The per-run
30
+ * backup directory under `~/.cache/claude-nomad/backup/<ts>/` is
31
+ * intentionally NOT created (no backups are written under dryRun and an
32
+ * empty dir would pollute the cache).
27
33
  *
28
34
  * Any `NomadFatal` thrown along the way is caught here so the `finally` block
29
35
  * releases the lock before exit (a raw `process.exit()` would skip `finally`
@@ -62,16 +68,28 @@ export function cmdPull(opts: { dryRun?: boolean } = {}): void {
62
68
  }
63
69
  log(`pulling on host=${HOST} (backup=${ts}${dryRun ? '; dry-run' : ''})`);
64
70
  gitOrFatal(['pull', '--rebase', '--autostash'], 'git pull --rebase', REPO_HOME);
71
+ // Read-only pre-pull check: fires in BOTH wet and dry modes (D-08).
72
+ // Runs AFTER the rebase (so origin content is fetched) and BEFORE any
73
+ // mutation (so local state is intact for byte-level comparison). The
74
+ // function itself silently skips when no `extras` key is declared.
75
+ divergenceCheckExtras(ts);
65
76
  if (dryRun) {
66
77
  const previewResult = computePreview(ts);
78
+ // dryRun deliberately omits remapExtrasPull to preserve the
79
+ // zero-mutation contract; users still see the divergence WARN above.
67
80
  log('dry-run complete; no mutation');
68
81
  emitSummary('pull', previewResult.unmapped);
69
82
  } else {
70
83
  applySharedLinks(ts);
71
84
  regenerateSettings(ts);
72
85
  const remapResult = remapPull(ts);
86
+ const extrasResult = remapExtrasPull(ts);
73
87
  log('pull complete');
74
- emitSummary('pull', remapResult.unmapped);
88
+ // Combine session-unmapped and extras-unmapped into one user-visible
89
+ // count; from the operator's perspective both mean "couldn't sync this
90
+ // for the host". extras-skipped (non-whitelisted dirname) stays
91
+ // separate because it signals config misuse, not a host-config gap.
92
+ emitSummary('pull', remapResult.unmapped + extrasResult.unmapped, 0, extrasResult.skipped);
75
93
  }
76
94
  } catch (err) {
77
95
  // Catch fatal errors here so the finally block runs and releases the
@@ -2,13 +2,14 @@ import { existsSync } from 'node:fs';
2
2
  import { join, relative } from 'node:path';
3
3
 
4
4
  // prettier-ignore
5
- import { HOME, HOST, NEVER_SYNC, PUSH_ALLOWED_STATIC, REPO_HOME, type PathMap } from './config.ts';
5
+ import { HOME, HOST, NEVER_SYNC, PUSH_ALLOWED_STATIC, REPO_HOME, SUPPORTED_EXTRAS, type PathMap } from './config.ts';
6
+ import { remapExtrasPush } from './extras-sync.ts';
6
7
  import { findGitlinks, probeGitleaks, rebaseBeforePush } from './push-checks.ts';
7
8
  import { runGitleaksScan } from './push-gitleaks.ts';
8
9
  import { remapPush } from './remap.ts';
9
10
  import { emitSummary } from './summary.ts';
10
11
  // prettier-ignore
11
- import { acquireLock, die, fail, freshBackupTs, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, readJson, releaseLock } from './utils.ts';
12
+ import { acquireLock, die, fail, freshBackupTs, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, readPathMap, releaseLock } from './utils.ts';
12
13
 
13
14
  /**
14
15
  * Match `path` against an entry in the push allow-list. Exact match for
@@ -29,8 +30,16 @@ function isAllowed(path: string, allowed: readonly string[]): boolean {
29
30
  return false;
30
31
  }
31
32
 
32
- /** True when any path segment matches a `NEVER_SYNC` entry (hard-block list). */
33
+ /**
34
+ * True when any path segment matches a `NEVER_SYNC` entry (hard-block list).
35
+ * Scope exception (Pitfall 6): paths beginning with `shared/extras/` are
36
+ * exempt. The segment list was authored against `~/.claude/` semantics for
37
+ * ephemeral Claude Code state (`todos/`, `shell-snapshots/`, etc.); inside
38
+ * the extras tree, `.planning/todos/` is a meaningful GSD-managed path. The
39
+ * narrowed scope preserves the original hard-block for all other surface.
40
+ */
33
41
  function isNeverSync(path: string): boolean {
42
+ if (path.startsWith('shared/extras/')) return false;
34
43
  for (const segment of path.split('/')) {
35
44
  if (NEVER_SYNC.has(segment)) return true;
36
45
  }
@@ -76,14 +85,23 @@ export function parsePorcelainZ(statusPorcelain: string): string[] {
76
85
  * Reject any staged path that is not on the push allow-list or that matches a
77
86
  * `NEVER_SYNC` entry. Builds the runtime allow-list by combining
78
87
  * `PUSH_ALLOWED_STATIC` with one `shared/projects/<logical>/` prefix per entry
79
- * in `path-map.json`. Logs every violation as a FATAL line so the user sees
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
80
94
  * the full set (not just the first), then throws `NomadFatal` to unwind the
81
95
  * caller's try/finally and release the push lock.
82
96
  */
83
97
  export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
98
+ const extrasWhitelist: readonly string[] = SUPPORTED_EXTRAS;
84
99
  const allowed = [
85
100
  ...PUSH_ALLOWED_STATIC,
86
101
  ...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}/`),
104
+ ),
87
105
  ];
88
106
  const neverSyncHits: string[] = [];
89
107
  const violations: string[] = [];
@@ -111,11 +129,13 @@ export function enforceAllowList(statusPorcelain: string, map: PathMap): void {
111
129
  * 2. `rebaseBeforePush` (surface remote conflicts against committed state,
112
130
  * not against in-flight `remapPush` copies)
113
131
  * 3. `remapPush` (copy host-encoded session dirs into shared logical names)
114
- * 4. `findGitlinks` walk of `shared/` (refuse to push nested .git entries;
115
- * runs AFTER `remapPush` so it catches .git dirs copied in from the host)
116
- * 5. allow-list enforcement on the resulting `git status` (refuse any path
117
- * not on `PUSH_ALLOWED_STATIC` or matching `NEVER_SYNC`)
118
- * 6. `git add -A` -> `runGitleaksScan` on staged tree -> `git commit` -> `git push`
132
+ * 4. `remapExtrasPush` (copy whitelisted per-project extras under
133
+ * `shared/extras/<logical>/<dirname>/`, between `remapPush` and the
134
+ * gitlink walk so produced paths reach both the walk and the allow-list)
135
+ * 5. `findGitlinks` walk of `shared/` (refuse to push nested .git entries)
136
+ * 6. allow-list enforcement on the resulting `git status` (runtime
137
+ * `shared/extras/<logical>/` prefix per declared logical added)
138
+ * 7. `git add -A` -> `runGitleaksScan` on staged tree -> `git commit` -> `git push`
119
139
  *
120
140
  * The gitleaks scan runs AFTER staging so it sees what would actually be
121
141
  * pushed, but BEFORE commit so a detection unwinds cleanly without leaving a
@@ -149,6 +169,11 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
149
169
  // remapPush runs BEFORE the empty-status check: it produces the diffs status
150
170
  // observes, so swapping the order would short-circuit before anything is staged.
151
171
  const remapResult = remapPush(ts, { dryRun });
172
+ // remapExtrasPush lands between remapPush and findGitlinks so the
173
+ // produced `shared/extras/<logical>/<dirname>/` paths are visible to
174
+ // both the gitlink walk and the downstream allow-list classification.
175
+ // dryRun is forwarded so a preview push reports the same skipped count.
176
+ const extrasResult = remapExtrasPush(ts, { dryRun });
152
177
  // Gitlink walk of shared/ AFTER remapPush so it inspects the post-copy tree.
153
178
  // A nested .git copied in from a host's encoded session dir would slip past a
154
179
  // pre-remap scan and reach the remote via the shared/projects/<logical>/ prefix.
@@ -171,25 +196,34 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
171
196
  const status = gitStatusPorcelainZ(REPO_HOME);
172
197
  if (!status) {
173
198
  log('nothing to commit');
174
- emitSummary('push', remapResult.unmapped, remapResult.collisions);
199
+ // Combine session-unmapped and extras-unmapped into one user-visible
200
+ // count; both mean "couldn't sync this for the host". extras-skipped
201
+ // (non-whitelisted dirname) stays separate because it signals config
202
+ // misuse, not a host-config gap.
203
+ emitSummary(
204
+ 'push',
205
+ remapResult.unmapped + extrasResult.unmapped,
206
+ remapResult.collisions,
207
+ extrasResult.skipped,
208
+ );
175
209
  return;
176
210
  }
177
211
  const mapPath = join(REPO_HOME, 'path-map.json');
178
212
  if (!existsSync(mapPath)) die('path-map.json missing, cannot enforce push allow-list');
179
- // Route a malformed path-map.json through NomadFatal so finally releases the lock.
180
- let map: PathMap;
181
- try {
182
- map = readJson<PathMap>(mapPath);
183
- } catch (err) {
184
- throw new NomadFatal(`could not parse path-map.json: ${(err as Error).message}`);
185
- }
213
+ // readPathMap routes parse failures through NomadFatal so finally releases the lock.
214
+ const map = readPathMap(mapPath);
186
215
  enforceAllowList(status, map);
187
216
  if (dryRun) {
188
217
  // Skip the staging quartet so no commit lands and nothing is pushed.
189
218
  // The user has already seen probeGitleaks pass, the rebase result, the
190
219
  // remap preview, the gitlink scan, and the allow-list classification.
191
220
  log('push: dry-run; skipping git add, gitleaks scan, commit, and push');
192
- emitSummary('push', remapResult.unmapped, remapResult.collisions);
221
+ emitSummary(
222
+ 'push',
223
+ remapResult.unmapped + extrasResult.unmapped,
224
+ remapResult.collisions,
225
+ extrasResult.skipped,
226
+ );
193
227
  return;
194
228
  }
195
229
  // gitOrFatal uses execFileSync (no shell) so NOMAD_HOST cannot escape quoting.
@@ -201,7 +235,12 @@ export function cmdPush(opts: { dryRun?: boolean } = {}): void {
201
235
  gitOrFatal(['commit', '-m', `chore: sync from ${HOST}`], 'git commit', REPO_HOME);
202
236
  gitOrFatal(['push'], 'git push', REPO_HOME);
203
237
  log('push complete');
204
- emitSummary('push', remapResult.unmapped, remapResult.collisions);
238
+ emitSummary(
239
+ 'push',
240
+ remapResult.unmapped + extrasResult.unmapped,
241
+ remapResult.collisions,
242
+ extrasResult.skipped,
243
+ );
205
244
  } catch (err) {
206
245
  if (err instanceof NomadFatal) {
207
246
  fail(err.message);
package/src/config.ts CHANGED
@@ -57,6 +57,18 @@ export const SHARED_LINKS = [
57
57
  'my-statusline.cjs',
58
58
  ] as const;
59
59
 
60
+ /**
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
67
+ * intent: a short, append-only `as const` tuple that downstream callers
68
+ * narrow against.
69
+ */
70
+ export const SUPPORTED_EXTRAS = ['.planning'] as const;
71
+
60
72
  /**
61
73
  * Path segments that must never cross the sync boundary in either direction.
62
74
  * Defense-in-depth pair with `PUSH_ALLOWED_STATIC`: even if the allow-list
@@ -146,5 +158,15 @@ export const PUSH_ALLOWED_STATIC = [
146
158
  * against `HOST`) to the absolute path the project lives at on that host. Use
147
159
  * the literal string `'TBD'` as a placeholder while a host has not yet cloned
148
160
  * the project; `remapPull` / `remapPush` skip `'TBD'` entries.
161
+ *
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
165
+ * consumers against `SUPPORTED_EXTRAS`. Absence of the field is equivalent
166
+ * to no extras for any project; legacy `path-map.json` files without an
167
+ * `extras` block continue to work unchanged (no migration required).
149
168
  */
150
- export type PathMap = { projects: Record<string, Record<string, string>> };
169
+ export type PathMap = {
170
+ projects: Record<string, Record<string, string>>;
171
+ extras?: Record<string, string[]>;
172
+ };
@@ -0,0 +1,290 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { isAbsolute, join, normalize } from 'node:path';
4
+
5
+ import { HOME, HOST, REPO_HOME, SUPPORTED_EXTRAS } from './config.ts';
6
+ // prettier-ignore
7
+ import { backupExtrasWrite, backupRepoWrite, encodePath, log, NomadFatal, readPathMap, warn } from './utils.ts';
8
+
9
+ /**
10
+ * `logical` keys in `path-map.json` are project identifiers (e.g. `ha-acwd`,
11
+ * `foo`), never path fragments. A crafted key like `../escape` or `foo/bar`
12
+ * would escape `shared/extras/` via `join()` (which normalizes `..`) and land
13
+ * content somewhere unexpected on the filesystem. The push allow-list catches
14
+ * such commits at the `git add` boundary, but the filesystem mutation has
15
+ * already happened by then. This check fails fast before any write. The
16
+ * pattern matches what every reasonable project name looks like and rejects
17
+ * everything else; tighten only if a real project needs broader characters.
18
+ */
19
+ const SAFE_LOGICAL = /^[A-Za-z0-9._-]+$/;
20
+ function assertSafeLogical(logical: string): void {
21
+ if (!SAFE_LOGICAL.test(logical) || logical === '.' || logical === '..') {
22
+ throw new NomadFatal(
23
+ `invalid logical name in path-map.json extras: ${JSON.stringify(logical)} (must match [A-Za-z0-9._-]+; no path separators or '..')`,
24
+ );
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Reject `localRoot` values that contain unnormalized segments (`..`,
30
+ * redundant `/.`, trailing slashes that don't survive `normalize`). A
31
+ * poisoned `path-map.json` with `host: '/tmp/x/../escape'` would silently
32
+ * land writes at `/tmp/escape/.planning/` because `path.join` normalizes
33
+ * `..` before `cpSync` sees the destination. The user thinks they declared
34
+ * one path and got another. Requiring `localRoot === normalize(localRoot)`
35
+ * (and an absolute path on top) catches the obvious traversal trick and
36
+ * forces poisoned-map writes to surface as a FATAL before any filesystem
37
+ * mutation. Same defense-in-depth shape as `assertSafeLogical`.
38
+ */
39
+ function assertSafeLocalRoot(localRoot: string, logical: string): void {
40
+ if (!isAbsolute(localRoot)) {
41
+ throw new NomadFatal(
42
+ `invalid localRoot for ${logical} in path-map.json: ${JSON.stringify(localRoot)} (must be absolute)`,
43
+ );
44
+ }
45
+ if (localRoot !== normalize(localRoot)) {
46
+ throw new NomadFatal(
47
+ `invalid localRoot for ${logical} in path-map.json: ${JSON.stringify(localRoot)} (must be already-normalized; no '..' or redundant segments)`,
48
+ );
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Recursive mirror copy: `rmSync` then `cpSync` so dst-only entries are
54
+ * removed (true mirror, not just overwrite). Passes `verbatimSymlinks: true`
55
+ * to keep relative symlink targets unrewritten across hosts (Pitfall 1;
56
+ * nodejs/node issue 41693). Exported so the test file can call it directly;
57
+ * `remapExtrasPush` and `remapExtrasPull` are the primary public API.
58
+ */
59
+ export function copyExtras(src: string, dst: string): void {
60
+ rmSync(dst, { recursive: true, force: true });
61
+ cpSync(src, dst, { recursive: true, force: true, verbatimSymlinks: true });
62
+ }
63
+
64
+ /**
65
+ * Push: copy whitelisted extras directories under each project's localRoot
66
+ * into the repo at `shared/extras/<logical>/<dirname>/`. Returns
67
+ * `{ unmapped, skipped }` with intentionally asymmetric granularity:
68
+ * `unmapped` is per-project (one increment per `logical` with no host path,
69
+ * which short-circuits before its dirnames are visited) and `skipped` is
70
+ * per-dirname (one increment per non-whitelisted entry inside an otherwise
71
+ * mapped project). Both counts feed `emitSummary`; the asymmetry mirrors
72
+ * the underlying skip-loop control flow (outer per-logical, inner
73
+ * per-dirname) and matches what an operator wants to see in the summary
74
+ * line. `opts.dryRun` logs `would push extras:` lines without writing,
75
+ * with identical count semantics. Legacy `path-map.json` without an
76
+ * `extras` key returns `{ unmapped: 0, skipped: 0 }` cleanly.
77
+ */
78
+ export function remapExtrasPush(
79
+ ts: string,
80
+ opts: { dryRun?: boolean } = {},
81
+ ): { unmapped: number; skipped: number } {
82
+ const dryRun = opts.dryRun === true;
83
+ let unmapped = 0;
84
+ let skipped = 0;
85
+ const mapPath = join(REPO_HOME, 'path-map.json');
86
+ if (!existsSync(mapPath)) {
87
+ log('no path-map.json; skipping extras push');
88
+ return { unmapped: 0, skipped: 0 };
89
+ }
90
+
91
+ const map = readPathMap(mapPath);
92
+ const extrasMap = map.extras ?? {};
93
+ if (Object.keys(extrasMap).length === 0) return { unmapped: 0, skipped: 0 };
94
+
95
+ // Validation pass: FATAL on any poisoned logical or unnormalized
96
+ // localRoot before any filesystem mutation. Runs over the entire extras
97
+ // map up-front so the documented "FATAL before any filesystem mutation"
98
+ // contract holds even when a clean entry sits ahead of a poisoned one in
99
+ // the iteration order (otherwise `mkdirSync(shared/extras/)` and the
100
+ // first cpSync would already have landed before the FATAL fired).
101
+ for (const logical of Object.keys(extrasMap)) {
102
+ assertSafeLogical(logical);
103
+ const localRoot = map.projects[logical]?.[HOST];
104
+ if (localRoot && localRoot !== 'TBD') assertSafeLocalRoot(localRoot, logical);
105
+ }
106
+
107
+ const repoExtras = join(REPO_HOME, 'shared', 'extras');
108
+ if (!dryRun) mkdirSync(repoExtras, { recursive: true });
109
+
110
+ const whitelist: readonly string[] = SUPPORTED_EXTRAS;
111
+
112
+ for (const [logical, dirnames] of Object.entries(extrasMap)) {
113
+ const localRoot = map.projects[logical]?.[HOST];
114
+ if (!localRoot || localRoot === 'TBD') {
115
+ unmapped++;
116
+ log(`skip ${logical}: no path for ${HOST}`);
117
+ continue;
118
+ }
119
+ for (const dirname of dirnames) {
120
+ if (!whitelist.includes(dirname)) {
121
+ skipped++;
122
+ log(`skip ${dirname} for ${logical}: not in SUPPORTED_EXTRAS`);
123
+ continue;
124
+ }
125
+ const src = join(localRoot, dirname);
126
+ if (!existsSync(src)) continue;
127
+ const dst = join(repoExtras, logical, dirname);
128
+ if (dryRun) {
129
+ log(`would push extras: ${src} -> ${dst}`);
130
+ continue;
131
+ }
132
+ backupRepoWrite(dst, ts, REPO_HOME);
133
+ copyExtras(src, dst);
134
+ log(`pushed extras ${logical}/${dirname} -> shared/extras/${logical}/${dirname}`);
135
+ }
136
+ }
137
+ return { unmapped, skipped };
138
+ }
139
+
140
+ /**
141
+ * Pull: copy whitelisted extras from `shared/extras/<logical>/<dirname>/`
142
+ * back into each project's localRoot on this host. Returns `{ unmapped,
143
+ * skipped }` with the same asymmetric granularity as `remapExtrasPush`:
144
+ * `unmapped` per-project, `skipped` per-dirname. `opts.dryRun` logs `would
145
+ * overwrite extras:` lines without writing. Uses `backupExtrasWrite` (not
146
+ * `backupBeforeWrite`) because `<localRoot>/<dirname>` lives outside
147
+ * `CLAUDE_HOME` and the standard helper's relative-path guard would no-op
148
+ * and lose prior content. Legacy `path-map.json` without an `extras` key,
149
+ * or a missing `shared/extras/`, both produce a clean no-op.
150
+ */
151
+ export function remapExtrasPull(
152
+ ts: string,
153
+ opts: { dryRun?: boolean } = {},
154
+ ): { unmapped: number; skipped: number } {
155
+ const dryRun = opts.dryRun === true;
156
+ let unmapped = 0;
157
+ let skipped = 0;
158
+ const mapPath = join(REPO_HOME, 'path-map.json');
159
+ const repoExtras = join(REPO_HOME, 'shared', 'extras');
160
+ if (!existsSync(mapPath) || !existsSync(repoExtras)) {
161
+ log('no path-map or repo extras dir; skipping extras remap');
162
+ return { unmapped: 0, skipped: 0 };
163
+ }
164
+
165
+ const map = readPathMap(mapPath);
166
+ const extrasMap = map.extras ?? {};
167
+ if (Object.keys(extrasMap).length === 0) return { unmapped: 0, skipped: 0 };
168
+
169
+ // Validation pass: FATAL on any poisoned logical or unnormalized
170
+ // localRoot before any host-side `backupExtrasWrite` or `copyExtras`
171
+ // runs. Symmetric with `remapExtrasPush`: a poisoned entry anywhere in
172
+ // the map must fail the whole pull up-front, otherwise a clean entry
173
+ // earlier in iteration order would already have clobbered the host
174
+ // before the FATAL fired (partial host-side mutation breaks the
175
+ // documented "fail before mutation" contract).
176
+ for (const logical of Object.keys(extrasMap)) {
177
+ assertSafeLogical(logical);
178
+ const localRoot = map.projects[logical]?.[HOST];
179
+ if (localRoot && localRoot !== 'TBD') assertSafeLocalRoot(localRoot, logical);
180
+ }
181
+
182
+ const whitelist: readonly string[] = SUPPORTED_EXTRAS;
183
+
184
+ for (const [logical, dirnames] of Object.entries(extrasMap)) {
185
+ const localRoot = map.projects[logical]?.[HOST];
186
+ if (!localRoot || localRoot === 'TBD') {
187
+ unmapped++;
188
+ log(`skip ${logical}: no path for ${HOST}`);
189
+ continue;
190
+ }
191
+ for (const dirname of dirnames) {
192
+ if (!whitelist.includes(dirname)) {
193
+ skipped++;
194
+ log(`skip ${dirname} for ${logical}: not in SUPPORTED_EXTRAS`);
195
+ continue;
196
+ }
197
+ const src = join(repoExtras, logical, dirname);
198
+ if (!existsSync(src)) continue;
199
+ const dst = join(localRoot, dirname);
200
+
201
+ if (dryRun) {
202
+ log(`would overwrite extras: ${dst} (from ${src})`);
203
+ continue;
204
+ }
205
+ // Snapshot the host-side dst BEFORE copyExtras clobbers it. Anchor
206
+ // on localRoot so the backup tree mirrors the project layout.
207
+ backupExtrasWrite(dst, ts, localRoot);
208
+ copyExtras(src, dst);
209
+ log(`pulled extras ${logical}/${dirname} -> ${dst}`);
210
+ }
211
+ }
212
+ return { unmapped, skipped };
213
+ }
214
+
215
+ /**
216
+ * List files that differ between two paths via `git diff --no-index
217
+ * --name-only`. Exit 0 = identical, exit 1 = differences exist (not an
218
+ * error: read names from `e.stdout`). Other failures are surfaced via WARN
219
+ * so the operator can tell the difference between "no diff" (silent),
220
+ * "git not on PATH" (WARN), and other git failures (WARN) instead of all
221
+ * three paths collapsing to a silent empty list and defeating D-08's
222
+ * loud-doctor contract. Argv-array `execFileSync` (no shell) so paths
223
+ * cannot inject.
224
+ */
225
+ function listDivergingFiles(a: string, b: string): string[] {
226
+ try {
227
+ const stdout = execFileSync('git', ['diff', '--no-index', '--name-only', a, b], {
228
+ stdio: ['ignore', 'pipe', 'pipe'],
229
+ }).toString();
230
+ return stdout.split('\n').filter((line) => line.length > 0);
231
+ } catch (err) {
232
+ const e = err as NodeJS.ErrnoException & { status?: number; stdout?: Buffer };
233
+ if (e.status === 1 && e.stdout !== undefined) {
234
+ return e.stdout
235
+ .toString()
236
+ .split('\n')
237
+ .filter((line) => line.length > 0);
238
+ }
239
+ if (e.code === 'ENOENT') {
240
+ warn(`git not on PATH; divergence check skipped for ${a}`);
241
+ return [];
242
+ }
243
+ warn(`divergence check failed for ${a}: ${e.message ?? String(err)}`);
244
+ return [];
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Read-only pre-pull check: compare local `<localRoot>/<dirname>/` against
250
+ * the just-pulled `shared/extras/<logical>/<dirname>/` and emit a WARN per
251
+ * diverging file plus a count summary. Runs AFTER `git pull --rebase` and
252
+ * BEFORE `remapExtrasPull` (so local state is intact for comparison).
253
+ * Non-blocking per the inherited LWW model; the WARN message names the
254
+ * per-project `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-localRoot>/`
255
+ * path that `remapExtrasPull` will write to so users can recover the
256
+ * overwritten content. The `<encoded-localRoot>` namespace mirrors
257
+ * `backupExtrasWrite`'s layout so two opted-in projects with the same
258
+ * relative extras path do not collide. Silent skip on missing path-map, no
259
+ * `extras` key, missing or `'TBD'` host path, non-whitelisted dirname, or
260
+ * either side absent.
261
+ */
262
+ export function divergenceCheckExtras(ts: string): void {
263
+ const mapPath = join(REPO_HOME, 'path-map.json');
264
+ if (!existsSync(mapPath)) return;
265
+
266
+ const map = readPathMap(mapPath);
267
+ const extrasMap = map.extras ?? {};
268
+ const whitelist: readonly string[] = SUPPORTED_EXTRAS;
269
+ const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts, 'extras');
270
+ for (const [logical, dirnames] of Object.entries(extrasMap)) {
271
+ assertSafeLogical(logical);
272
+ const localRoot = map.projects[logical]?.[HOST];
273
+ if (!localRoot || localRoot === 'TBD') continue;
274
+ assertSafeLocalRoot(localRoot, logical);
275
+ const projectBackupRoot = join(backupRoot, encodePath(localRoot));
276
+ for (const dirname of dirnames) {
277
+ if (!whitelist.includes(dirname)) continue;
278
+ const local = join(localRoot, dirname);
279
+ const repo = join(REPO_HOME, 'shared', 'extras', logical, dirname);
280
+ if (!existsSync(local) || !existsSync(repo)) continue;
281
+ const diff = listDivergingFiles(local, repo);
282
+ if (diff.length > 0) {
283
+ warn(
284
+ `local ${dirname} for ${logical} diverges from origin in ${diff.length} file(s); next remapExtrasPull will overwrite them (backups at ${projectBackupRoot}/)`,
285
+ );
286
+ for (const f of diff) warn(` ${f}`);
287
+ }
288
+ }
289
+ }
290
+ }
package/src/summary.ts CHANGED
@@ -4,40 +4,48 @@ import { ok, warn } from './utils.ts';
4
4
  * Emit the single end-of-run summary line shared by cmdPull, cmdPush, and
5
5
  * cmdDiff. Canonical phrasing:
6
6
  * - `summary: clean` when nothing was unmapped (and, for push, no
7
- * collisions). Always printed so users see a consistent terminator
8
- * and can spot when behavior changes.
7
+ * collisions or extras skipped). Always printed so users see a consistent
8
+ * terminator and can spot when behavior changes.
9
9
  * - `summary: <N> unmapped on pull (run nomad doctor to list)`
10
+ * - `summary: <N> unmapped on pull, <X> extras skipped (run nomad doctor to list)`
10
11
  * - `summary: <N> unmapped on diff (run nomad doctor to list)`
11
12
  * - `summary: <N> unmapped on push, <M> collisions (run nomad doctor to list)`
13
+ * - `summary: <N> unmapped on push, <M> collisions, <X> extras skipped (run nomad doctor to list)`
12
14
  *
13
15
  * Clean outcomes go through `ok()` (green `✓` glyph, stdout) and unmapped /
14
- * collision outcomes go through `warn()` (yellow `⚠︎` glyph, stderr). The
15
- * status glyph carries the success/warn semantics; users see e.g.
16
+ * collision / extras-skipped outcomes go through `warn()` (yellow `⚠︎` glyph,
17
+ * stderr). The status glyph carries the success/warn semantics; users see e.g.
16
18
  * `✓ summary: clean` or `⚠︎ summary: 3 unmapped on pull (...)`. Note: clean
17
19
  * still goes to stdout so it survives backgrounded shell-rc invocations
18
20
  * like `nomad pull 2>/dev/null &`. `collisions` is meaningful only for
19
- * `'push'`; for `'pull'` / `'diff'` it is ignored and defaults to 0. This
20
- * module is the SINGLE source of truth for the phrasing, eliminating drift
21
- * risk across the three callers by construction.
21
+ * `'push'`; for `'pull'` / `'diff'` it is ignored and defaults to 0.
22
+ * `extrasSkipped` counts dirnames that the per-project whitelist
23
+ * (`SUPPORTED_EXTRAS`) declined to sync; surfaces from `remapExtrasPush`
24
+ * and `remapExtrasPull`. The fourth positional parameter defaults to 0 so
25
+ * legacy three-arg call sites continue to work unchanged (D-03 additive
26
+ * contract). This module is the SINGLE source of truth for the phrasing,
27
+ * eliminating drift risk across the three callers by construction.
22
28
  */
23
29
  export function emitSummary(
24
30
  verb: 'pull' | 'push' | 'diff',
25
31
  unmapped: number,
26
32
  collisions = 0,
33
+ extrasSkipped = 0,
27
34
  ): void {
28
35
  if (verb === 'push') {
29
- if (unmapped === 0 && collisions === 0) {
36
+ if (unmapped === 0 && collisions === 0 && extrasSkipped === 0) {
30
37
  ok('summary: clean');
31
38
  return;
32
39
  }
33
- warn(
34
- `summary: ${unmapped} unmapped on push, ${collisions} collisions (run nomad doctor to list)`,
35
- );
40
+ const base = `summary: ${unmapped} unmapped on push, ${collisions} collisions`;
41
+ const extras = extrasSkipped > 0 ? `, ${extrasSkipped} extras skipped` : '';
42
+ warn(`${base}${extras} (run nomad doctor to list)`);
36
43
  return;
37
44
  }
38
- if (unmapped === 0) {
45
+ if (unmapped === 0 && extrasSkipped === 0) {
39
46
  ok('summary: clean');
40
47
  return;
41
48
  }
42
- warn(`summary: ${unmapped} unmapped on ${verb} (run nomad doctor to list)`);
49
+ const extras = extrasSkipped > 0 ? `, ${extrasSkipped} extras skipped` : '';
50
+ warn(`summary: ${unmapped} unmapped on ${verb}${extras} (run nomad doctor to list)`);
43
51
  }
package/src/utils.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  import { dirname, join, relative } from 'node:path';
18
18
 
19
19
  import { dim, failGlyph, green, infoGlyph, okGlyph, red, warnGlyph, yellow } from './color.ts';
20
- import { CLAUDE_HOME, HOME } from './config.ts';
20
+ import { CLAUDE_HOME, HOME, type PathMap } from './config.ts';
21
21
 
22
22
  const LOCK_PATH = join(HOME, '.cache', 'claude-nomad', 'nomad.lock');
23
23
 
@@ -117,6 +117,29 @@ export function readJson<T>(path: string): T {
117
117
  return data as T;
118
118
  }
119
119
 
120
+ /**
121
+ * Read `path-map.json` and wrap failures as `NomadFatal` so callers route the
122
+ * failure through their `try/finally` lock-release path instead of exposing a
123
+ * raw `SyntaxError` (or `ENOENT`/`EACCES`) past `NomadFatal`-only catch
124
+ * blocks. Equivalent to the inline `try { readJson } catch { throw NomadFatal }`
125
+ * pattern in `cmdPush`; use this helper at every other read site so the
126
+ * lock-release contract holds uniformly across the pipeline.
127
+ *
128
+ * Error verb is conditioned on the cause so ops can distinguish parse
129
+ * failures (malformed JSON) from IO failures (permission denied, file
130
+ * removed mid-run) without scraping the wrapped message. Callers gate on
131
+ * `existsSync(mapPath)` first in the happy path, so an `ENOENT` here means
132
+ * a TOCTOU race rather than the expected absent-file case.
133
+ */
134
+ export function readPathMap(mapPath: string): PathMap {
135
+ try {
136
+ return readJson<PathMap>(mapPath);
137
+ } catch (err) {
138
+ const verb = err instanceof SyntaxError ? 'parse' : 'read';
139
+ throw new NomadFatal(`could not ${verb} path-map.json: ${(err as Error).message}`);
140
+ }
141
+ }
142
+
120
143
  /**
121
144
  * Atomic write: temp + fsync + rename + parent-dir fsync. Survives
122
145
  * interrupted pulls. Preserves the destination file's existing mode when it
@@ -242,6 +265,37 @@ export function backupRepoWrite(absPath: string, ts: string, repoHome: string):
242
265
  cpSync(absPath, dst, { recursive: true, force: false, preserveTimestamps: true });
243
266
  }
244
267
 
268
+ /**
269
+ * Parallel of `backupBeforeWrite` and `backupRepoWrite`, scoped to an
270
+ * explicit `projectRoot` instead of `CLAUDE_HOME` or `REPO_HOME`. Used by
271
+ * `remapExtrasPull` to snapshot host-side extras content (e.g.
272
+ * `<localRoot>/.planning/`) before `copyExtras` clobbers it. The existing
273
+ * helpers cannot serve this case: their `relative(CLAUDE_HOME, absPath)` and
274
+ * `relative(repoHome, absPath)` guards return a `..`-prefixed string for any
275
+ * path outside their anchor and silently no-op, so a pull-side
276
+ * `<localRoot>/.planning/` would never be backed up.
277
+ *
278
+ * Backup root is `extras/`-prefixed inside the same `<ts>` dir so the
279
+ * snapshot is distinguishable from `CLAUDE_HOME` dumps (no prefix) and
280
+ * `repo/` dumps. Layout:
281
+ * `~/.cache/claude-nomad/backup/<ts>/extras/<encoded-projectRoot>/<rel>/`
282
+ * where `<rel>` is `relative(projectRoot, absPath)` and
283
+ * `<encoded-projectRoot>` is `encodePath(projectRoot)`. The encoded prefix
284
+ * namespaces snapshots by project so two opted-in projects with the same
285
+ * relative extras path (e.g. both with `.planning/PLAN.md`) cannot collide
286
+ * inside the same `<ts>` directory (`cpSync` runs with `force: false`, so a
287
+ * collision would silently drop the second snapshot).
288
+ */
289
+ export function backupExtrasWrite(absPath: string, ts: string, projectRoot: string): void {
290
+ if (!existsSync(absPath)) return;
291
+ const rel = relative(projectRoot, absPath);
292
+ if (rel.startsWith('..') || rel === '') return;
293
+ const backupRoot = join(HOME, '.cache', 'claude-nomad', 'backup', ts, 'extras');
294
+ const dst = join(backupRoot, encodePath(projectRoot), rel);
295
+ mkdirSync(dirname(dst), { recursive: true });
296
+ cpSync(absPath, dst, { recursive: true, force: false, preserveTimestamps: true });
297
+ }
298
+
245
299
  /**
246
300
  * Acquire the exclusive nomad lockfile so two pulls/pushes cannot mutate
247
301
  * `~/.claude/` concurrently. Returns the handle on success, or `null` on