claude-nomad 0.23.0 → 0.24.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.24.0](https://github.com/funkadelic/claude-nomad/compare/v0.23.0...v0.24.0) (2026-05-24)
4
+
5
+
6
+ ### Added
7
+
8
+ * **doctor:** add --check-shared preflight gitleaks scan ([#117](https://github.com/funkadelic/claude-nomad/issues/117)) ([0089d09](https://github.com/funkadelic/claude-nomad/commit/0089d09ef91ff7b6778b065bcfe8be97f4c54d1b))
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **readme:** document nomad doctor --check-shared preflight ([#119](https://github.com/funkadelic/claude-nomad/issues/119)) ([d08ed91](https://github.com/funkadelic/claude-nomad/commit/d08ed91bbfd4c976f56c510b086e375c4595e682))
14
+
3
15
  ## [0.23.0](https://github.com/funkadelic/claude-nomad/compare/v0.22.3...v0.23.0) (2026-05-23)
4
16
 
5
17
 
package/README.md CHANGED
@@ -287,10 +287,11 @@ nomad init --keep-actions
287
287
  Edit `path-map.json` to add your logical projects (see [Path remapping](#path-remapping)), then:
288
288
 
289
289
  ```bash
290
- nomad doctor # read-only state check; reports host, repo state, every check as ✓ (pass) / ✗ (fail) / ⚠︎ (warn)
291
- nomad diff # preview what nomad pull would change on this host; no lock, no network, no mutation
292
- nomad push # send current state to the private remote
293
- nomad pull # apply on another host (or this one after a remote update)
290
+ nomad doctor # read-only state check; reports host, repo state, every check as ✓ (pass) / ✗ (fail) / ⚠︎ (warn)
291
+ nomad doctor --check-shared # read-only gitleaks preflight over the session transcripts a push would stage
292
+ nomad diff # preview what nomad pull would change on this host; no lock, no network, no mutation
293
+ nomad push # send current state to the private remote
294
+ nomad pull # apply on another host (or this one after a remote update)
294
295
  ```
295
296
 
296
297
  `nomad pull --dry-run` is the network-aware twin of `nomad diff`: it acquires the lock and runs `git pull` so you see what the next real pull would do given the latest remote, then exits without mutating.
@@ -358,21 +359,22 @@ If you installed an earlier version via `./install.sh` and a shell alias (the pr
358
359
 
359
360
  ## Commands
360
361
 
361
- | Command | Description |
362
- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
363
- | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
364
- | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
365
- | `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
366
- | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. FATAL if scaffold missing. |
367
- | `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
368
- | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
369
- | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
370
- | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
371
- | `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` is preserved. See [Recovery flows](#recovery-flows). |
372
- | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
373
- | `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check. |
374
- | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
375
- | `nomad --version` | Print the installed CLI version as bare semver to stdout; exits 0. Used by the npm-publish smoke test and useful for ad-hoc upgrade checks. |
362
+ | Command | Description |
363
+ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
364
+ | `nomad init` | Scaffold empty `shared/`, `hosts/`, `path-map.json` on a fresh clone. Refuses to clobber existing scaffold. Auto-disables Actions on a detected private GitHub mirror (see [Privacy by default](#privacy-by-default)). |
365
+ | `nomad init --snapshot` | Overlay current host's `~/.claude/` into `shared/` and write `~/.claude/settings.json` verbatim into `hosts/<NOMAD_HOST>.json`. Originals not modified. Same auto-disable behavior as `nomad init`. |
366
+ | `nomad init --keep-actions` | Skip the auto-disable. Combinable with `--snapshot`. Use when an upstream org policy already governs Actions, or you intentionally want CI on the private mirror. |
367
+ | `nomad pull` | `git pull --rebase --autostash`, apply symlinks, regenerate `settings.json`, remap session paths, and pull opted-in per-project extras. FATAL if scaffold missing. |
368
+ | `nomad pull --dry-run` | Network-aware preview: acquire lock + `git pull --rebase`, print planned changes (symlink moves, `settings.json` diff, transcript overwrites), exit without writing. |
369
+ | `nomad diff` | Offline, lockless twin of `pull --dry-run`. No network, no lock. Works against the current local repo state. |
370
+ | `nomad push` | Export local sessions and opted-in per-project extras to logical names, commit (`chore: sync from <NOMAD_HOST>`), push. |
371
+ | `nomad push --dry-run` | Run pre-push safety checks (gitleaks probe, rebase, remap preview, gitlink scan, allow-list); skip stage, scan, commit, and push. |
372
+ | `nomad drop-session <id>` | Surgically unstage every `shared/projects/*/<id>.jsonl` from the staged tree of `~/claude-nomad/`. Idempotent; the local `~/.claude/projects/<encoded>/<id>.jsonl` is preserved. See [Recovery flows](#recovery-flows). |
373
+ | `nomad update` | Topology-aware upgrade to the latest upstream. Flags: `--dry-run`, `--force`, `--push-origin`. See [Upgrading the tool](#upgrading-the-tool). |
374
+ | `nomad doctor` | Read-only health check. Each line carries a status glyph (`✓` pass, `✗` fail, `⚠︎` warn); any `✗` sets `process.exitCode = 1` (`⚠︎` does not). Includes an offline-tolerant release-version staleness check. |
375
+ | `nomad doctor --resume-cmd <id>` | Print a host-local `cd ... && claude --resume <id>` line for a session (see [Cross-OS resume](#cross-os-resume)). |
376
+ | `nomad doctor --check-shared` | Read-only gitleaks preflight: stages the session transcripts a `push` would publish into a temp tree and scans them, failing (`✗`, exit 1) per affected session with rotate-and-scrub guidance. Skips with a `⚠︎` when gitleaks is not on PATH. See [Recovery flow: gitleaks FATAL on a session JSONL](#recovery-flow-gitleaks-fatal-on-a-session-jsonl). |
377
+ | `nomad --version` | Print the installed CLI version as bare semver to stdout; exits 0. Used by the npm-publish smoke test and useful for ad-hoc upgrade checks. |
376
378
 
377
379
  The version-check emits ``⚠︎ version: <local> -> <latest> (run `nomad update`)`` when the local install is behind the latest upstream release, and `✓ version: <local> (latest)` when current. It silently skips on network failures.
378
380
 
@@ -409,7 +411,7 @@ What it does NOT do: touch the local `~/.claude/projects/<encoded>/<id>.jsonl` f
409
411
 
410
412
  ### Recovery flow: gitleaks FATAL on a session JSONL
411
413
 
412
- `nomad push` runs `gitleaks protect --staged` before commit. When findings live in a session transcript, the FATAL names every affected session id and the recovery command:
414
+ `nomad push` runs `gitleaks protect --staged` before commit. To catch the same findings before you push (and without mutating anything), run the read-only preflight `nomad doctor --check-shared`, which stages and scans the exact transcripts a push would publish. When findings live in a session transcript, the push FATAL names every affected session id and the recovery command:
413
415
 
414
416
  ```text
415
417
  ✗ gitleaks detected secrets in 1 session transcript(s).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nomad",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "description": "Sync Claude Code config (~/.claude/) across machines via a private Git repo, with path remapping and per-host settings overrides.",
6
6
  "keywords": [
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Owns the `nomad doctor --check-shared` preflight reporter.
3
+ *
4
+ * Read-only diagnostic that runs gitleaks against the LOCAL session
5
+ * transcripts `nomad push` would stage (each path-map entry mapped to this
6
+ * host), surfacing secret leaks BEFORE the push pipeline fires. Mirrors the
7
+ * push-time scan (`runGitleaksScan` in `./push-gitleaks.ts`) but: scans a
8
+ * temp COPY of the live transcripts (never the live dir), uses the
9
+ * purpose-built `gitleaks dir` subcommand, and emits doctor-flavored glyph
10
+ * rows + `process.exitCode` instead of throwing a push-flavored FATAL.
11
+ *
12
+ * Composition only: reuses `partitionFindings` / `readGitleaksReport` /
13
+ * `SESSION_PATH` (the gitleaks JSON parser) and `copyDirJsonlOnly` (the
14
+ * push-fidelity source filter) verbatim. The doctor-flavored guidance
15
+ * composer is new (push's `buildSessionAwareFatal` is wrong at doctor time:
16
+ * `nomad drop-session` operates on the staged tree, and nothing is staged
17
+ * during a preflight).
18
+ *
19
+ * All external calls use `execFileSync` argv-array form (no shell), the
20
+ * codebase PUSH-04 invariant.
21
+ */
22
+
23
+ import { randomBytes } from 'node:crypto';
24
+ import { execFileSync } from 'node:child_process';
25
+ import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
26
+ import { homedir } from 'node:os';
27
+ import { join } from 'node:path';
28
+
29
+ import { green, red, yellow, okGlyph, failGlyph, warnGlyph } from './color.ts';
30
+ import { addItem, type DoctorSection } from './commands.doctor.format.ts';
31
+ import { CLAUDE_HOME, HOST, REPO_HOME, type PathMap } from './config.ts';
32
+ import { type Finding, partitionFindings, readGitleaksReport } from './push-gitleaks.ts';
33
+ import { copyDirJsonlOnly } from './remap.ts';
34
+ import { encodePath, nowTimestamp, readJson } from './utils.ts';
35
+
36
+ /**
37
+ * Result of staging the scan tree. `malformed` is true when `path-map.json`
38
+ * exists but does not parse as JSON; the caller emits a FAIL row and stops
39
+ * (mirroring `reportPathMap`'s `readJsonSafe` degradation) rather than letting
40
+ * the `SyntaxError` propagate past `nomad.ts`'s `NomadFatal`-only handler and
41
+ * abort the whole doctor run with a stack trace.
42
+ */
43
+ type ScanTree = {
44
+ logicalToEncoded: Map<string, string>;
45
+ staged: number;
46
+ malformed: boolean;
47
+ };
48
+
49
+ /**
50
+ * Build the temp staging tree under `tmpRoot/shared/projects/<logical>/` by
51
+ * copying each local encoded session dir that resolves to a path-map logical
52
+ * for this host. Returns the `logical -> encoded-dir` association so the
53
+ * scrub-path hint can name the live `~/.claude/projects/<encoded>/<sid>.jsonl`
54
+ * file, plus the count of session dirs staged. Skips `TBD`/unmapped entries
55
+ * (the D-03 scope: exactly what `remapPush` would stage). Uses the same
56
+ * depth-0 `*.jsonl` filter as push via `copyDirJsonlOnly`. A malformed
57
+ * `path-map.json` sets `malformed: true` rather than throwing.
58
+ */
59
+ function buildScanTree(tmpRoot: string): ScanTree {
60
+ const logicalToEncoded = new Map<string, string>();
61
+ let staged = 0;
62
+ const mapPath = join(REPO_HOME, 'path-map.json');
63
+ if (!existsSync(mapPath)) return { logicalToEncoded, staged, malformed: false };
64
+ let map: PathMap;
65
+ try {
66
+ map = readJson<PathMap>(mapPath);
67
+ } catch {
68
+ return { logicalToEncoded, staged, malformed: true };
69
+ }
70
+ if (typeof map.projects !== 'object' || map.projects === null) {
71
+ return { logicalToEncoded, staged, malformed: false };
72
+ }
73
+
74
+ const reverse = new Map<string, string>();
75
+ for (const [logical, hosts] of Object.entries(map.projects)) {
76
+ if (typeof hosts !== 'object' || hosts === null) continue;
77
+ const p = hosts[HOST];
78
+ if (!p || p === 'TBD') continue;
79
+ reverse.set(encodePath(p), logical);
80
+ }
81
+
82
+ const localProjects = join(CLAUDE_HOME, 'projects');
83
+ if (!existsSync(localProjects)) return { logicalToEncoded, staged, malformed: false };
84
+ for (const dir of readdirSync(localProjects)) {
85
+ const logical = reverse.get(dir);
86
+ if (!logical) continue;
87
+ copyDirJsonlOnly(join(localProjects, dir), join(tmpRoot, 'shared', 'projects', logical));
88
+ logicalToEncoded.set(logical, dir);
89
+ staged++;
90
+ }
91
+ return { logicalToEncoded, staged, malformed: false };
92
+ }
93
+
94
+ /**
95
+ * Probe for the gitleaks binary on PATH, distinguishing the not-installed case
96
+ * (ENOENT -> `'missing'`, a WARN skip per the read-only doctor contract) from a
97
+ * real probe failure (EACCES, corrupt binary -> `{ fail: message }`, a FAIL).
98
+ * Mirrors `reportGitleaksProbe`'s ENOENT-vs-other split rather than collapsing
99
+ * every failure into "not on PATH". Probes directly (not via `probeGitleaks`)
100
+ * so the doctor flavor stays read-only and need not unwrap a `NomadFatal`.
101
+ */
102
+ function probeGitleaksForScan(): 'ok' | 'missing' | { fail: string } {
103
+ try {
104
+ execFileSync('gitleaks', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] });
105
+ return 'ok';
106
+ } catch (err) {
107
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 'missing';
108
+ return { fail: (err as Error).message };
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Recover the live encoded-dir for a finding by mapping its `<logical>`
114
+ * segment through the staging association. Returns the absolute live
115
+ * transcript path `~/.claude/projects/<encoded>/<sid>.jsonl`, falling back to
116
+ * the logical name when the association is missing (defensive; the temp-tree
117
+ * model guarantees a hit).
118
+ */
119
+ function scrubPath(logical: string, sid: string, logicalToEncoded: Map<string, string>): string {
120
+ /* c8 ignore next -- the `?? logical` fallback is defensive; the temp-tree build keys every staged logical */
121
+ const encoded = logicalToEncoded.get(logical) ?? logical;
122
+ return join(CLAUDE_HOME, 'projects', encoded, `${sid}.jsonl`);
123
+ }
124
+
125
+ /**
126
+ * Emit one fail row per affected session plus rotate-and-scrub + allowlist
127
+ * guidance, and set `process.exitCode = 1`. `logicalBySession` carries the
128
+ * `<logical>` segment captured from the same `SESSION_PATH` match that keyed
129
+ * `bySession`, so the scrub-path hint reuses the authoritative parse rather
130
+ * than re-deriving the logical name from the finding `File`. Every `bySession`
131
+ * sid is keyed in `logicalBySession` (both come from the identical sid capture),
132
+ * so the scrub hint always renders; the guard omits the hint rather than
133
+ * printing a wrong path if that invariant ever breaks, and the leak row itself
134
+ * is always emitted.
135
+ */
136
+ function reportSessionFindings(
137
+ section: DoctorSection,
138
+ bySession: Map<string, Map<string, number>>,
139
+ logicalBySession: Map<string, string>,
140
+ logicalToEncoded: Map<string, string>,
141
+ ): void {
142
+ for (const [sid, counts] of bySession) {
143
+ const summary = [...counts.entries()].map(([rule, n]) => `${rule} (${n})`).join(', ');
144
+ addItem(section, `${red(failGlyph)} session ${sid}: ${summary}`);
145
+ const logical = logicalBySession.get(sid);
146
+ /* c8 ignore next -- false branch is defensive; every bySession sid is keyed in logicalBySession */
147
+ if (logical !== undefined) {
148
+ addItem(
149
+ section,
150
+ ` rotate the credential, then scrub ${scrubPath(logical, sid, logicalToEncoded)}`,
151
+ );
152
+ }
153
+ addItem(section, ` false positive? add a pattern to .gitleaks.toml`);
154
+ }
155
+ process.exitCode = 1;
156
+ }
157
+
158
+ /**
159
+ * Emit one fail row per non-session ("other"-bucket) finding and set
160
+ * `process.exitCode = 1`. These are findings whose `File` did not match the
161
+ * flat `SESSION_PATH` shape (nested transcripts under `subagents/`, `memory/`,
162
+ * etc., which `copyDirJsonlOnly` copies recursively and `nomad push` would
163
+ * stage). Names the repo-relative path and RuleID only, never the matched
164
+ * secret. Mirrors the push-side guarantee that any finding outside `bySession`
165
+ * still fails the scan (`buildSessionAwareFatal`'s `LEGACY_FATAL` fallback).
166
+ */
167
+ function reportOtherFindings(section: DoctorSection, other: Finding[]): void {
168
+ for (const f of other) {
169
+ addItem(section, `${red(failGlyph)} leak in ${f.File}: ${f.RuleID}`);
170
+ }
171
+ process.exitCode = 1;
172
+ }
173
+
174
+ /**
175
+ * Captures both the `<logical>` segment and the `<sid>` from a repo-relative
176
+ * `shared/projects/<logical>/<sid>.jsonl` path. The session-id group matches
177
+ * the exported `SESSION_PATH` shape; the extra `<logical>` group lets the
178
+ * scrub-path hint reuse this single authoritative parse.
179
+ */
180
+ const SESSION_PATH_LOGICAL = /^shared\/projects\/([^/]+)\/([^/]+)\.jsonl$/;
181
+
182
+ /**
183
+ * Emit the single canonical clean row reporting the scanned-project count
184
+ * (`staged` is the number of mapped project directories whose transcripts were
185
+ * staged, not a transcript total). Centralizing the literal (zero-staged,
186
+ * scanned-clean, and the findings-but-no-`other` paths all route through here)
187
+ * keeps the phrasing consistent and prevents one copy drifting from another,
188
+ * which is what let a "no session findings == clean" path slip past the
189
+ * `other`-bucket gate.
190
+ */
191
+ function emitClean(section: DoctorSection, staged: number): void {
192
+ addItem(section, `${green(okGlyph)} ${staged} project(s) scanned, no leaks`);
193
+ }
194
+
195
+ /**
196
+ * Run the `--check-shared` preflight and append its rows to `section`.
197
+ *
198
+ * Flow (D-01..D-10): probe gitleaks (missing -> one WARN row, exit untouched;
199
+ * a non-ENOENT probe failure -> FAIL row + exit 1, mirroring
200
+ * `reportGitleaksProbe`); stage a temp copy of this-host mapped session dirs
201
+ * (a malformed `path-map.json` -> FAIL row + exit 1, no crash); scan with the
202
+ * positional `gitleaks dir shared/projects` invocation (NOT `--source`, which
203
+ * `gitleaks dir` rejects with exit 126); on a clean scan emit one ok row
204
+ * reporting the scanned-project count; on findings emit per-session fail rows
205
+ * with rotate-and-scrub guidance and set `process.exitCode = 1`; on a non-zero
206
+ * exit with no parseable report emit a scan-failed fail row carrying the
207
+ * gitleaks error message (never its stderr/stdout, which may hold secrets) +
208
+ * exit 1 (do not chase phantom sessions). Removes both the temp report and the
209
+ * temp tree in `finally` on success and failure. Never writes to stderr
210
+ * (read-only doctor contract).
211
+ *
212
+ * `gitleaksReady` lets the doctor orchestrator pass the result of the
213
+ * Repository section's gitleaks probe so the `version` subcommand is not
214
+ * invoked a second time on a `--check-shared` run. When omitted (the module's
215
+ * standalone contract) this reporter probes for itself.
216
+ */
217
+ export function reportCheckShared(section: DoctorSection, gitleaksReady?: boolean): void {
218
+ if (gitleaksReady !== true) {
219
+ const probe = probeGitleaksForScan();
220
+ if (probe === 'missing') {
221
+ addItem(section, `${yellow(warnGlyph)} gitleaks not on PATH; shared scan skipped`);
222
+ return;
223
+ }
224
+ if (probe !== 'ok') {
225
+ addItem(section, `${red(failGlyph)} gitleaks probe failed: ${probe.fail}`);
226
+ process.exitCode = 1;
227
+ return;
228
+ }
229
+ }
230
+
231
+ const cacheDir = join(homedir(), '.cache', 'claude-nomad');
232
+ mkdirSync(cacheDir, { recursive: true });
233
+ // nowTimestamp() is second-resolution and --check-shared takes no lock
234
+ // (read-only), so two same-second, same-pid invocations would otherwise
235
+ // share a stamp and clobber each other's temp tree / report. The random
236
+ // suffix makes the stamp collision-resistant, matching the push report path.
237
+ const stamp = `${nowTimestamp()}-${process.pid}-${randomBytes(4).toString('hex')}`;
238
+ const reportPath = join(cacheDir, `check-shared-${stamp}.json`);
239
+ const tmpRoot = join(cacheDir, `check-shared-tree-${stamp}`);
240
+
241
+ try {
242
+ const { logicalToEncoded, staged, malformed } = buildScanTree(tmpRoot);
243
+ if (malformed) {
244
+ addItem(section, `${red(failGlyph)} path-map.json malformed JSON; shared scan skipped`);
245
+ process.exitCode = 1;
246
+ return;
247
+ }
248
+ if (staged === 0) {
249
+ // No path-map entry maps to this host (or all are TBD). Nothing would be
250
+ // staged by push either, so report clean without invoking gitleaks (a
251
+ // scan of a non-existent dir would exit non-zero and misfire).
252
+ emitClean(section, 0);
253
+ return;
254
+ }
255
+ const tomlPath = join(REPO_HOME, '.gitleaks.toml');
256
+ const args: string[] = [
257
+ 'dir',
258
+ 'shared/projects',
259
+ '--redact',
260
+ '-v',
261
+ '--report-format=json',
262
+ `--report-path=${reportPath}`,
263
+ ];
264
+ if (existsSync(tomlPath)) args.push('--config', tomlPath);
265
+
266
+ try {
267
+ execFileSync('gitleaks', args, { cwd: tmpRoot, stdio: ['ignore', 'pipe', 'pipe'] });
268
+ emitClean(section, staged);
269
+ } catch (err) {
270
+ const findings = readGitleaksReport(reportPath);
271
+ if (findings === null) {
272
+ // Carry the gitleaks error message only (never e.stderr/e.stdout, which
273
+ // can echo the redacted-but-still-sensitive scan output), matching
274
+ // runGitleaksScan on the push side.
275
+ const msg = (err as Error).message;
276
+ addItem(section, `${red(failGlyph)} scan failed: no parseable gitleaks report (${msg})`);
277
+ process.exitCode = 1;
278
+ return;
279
+ }
280
+ const { bySession, other } = partitionFindings(findings);
281
+ // Both buckets must gate the clean row. A finding routed to `other`
282
+ // (nested transcripts that match neither the flat SESSION_PATH nor any
283
+ // session) is still a stageable secret push would catch, so reporting
284
+ // clean on `bySession.size === 0` alone would make the preflight weaker
285
+ // than the push scan it stands in for.
286
+ if (bySession.size === 0 && other.length === 0) {
287
+ emitClean(section, staged);
288
+ return;
289
+ }
290
+ if (other.length > 0) reportOtherFindings(section, other);
291
+ if (bySession.size > 0) {
292
+ // Capture <logical> alongside <sid> from the same authoritative match
293
+ // so the scrub hint never re-derives the logical name independently.
294
+ const logicalBySession = new Map<string, string>();
295
+ for (const f of findings) {
296
+ const m = SESSION_PATH_LOGICAL.exec(f.File);
297
+ if (m?.[2] !== undefined && !logicalBySession.has(m[2])) {
298
+ /* c8 ignore next -- `?? ''` is defensive; group 1 is always captured when the match succeeds */
299
+ logicalBySession.set(m[2], m[1] ?? '');
300
+ }
301
+ }
302
+ reportSessionFindings(section, bySession, logicalBySession, logicalToEncoded);
303
+ }
304
+ }
305
+ } finally {
306
+ rmSync(reportPath, { force: true });
307
+ rmSync(tmpRoot, { recursive: true, force: true });
308
+ }
309
+ }
@@ -272,13 +272,19 @@ export function reportNeverSync(section: DoctorSection): void {
272
272
  addItem(section, `${dim(infoGlyph)} never-sync items: ${[...NEVER_SYNC].join(', ')}`);
273
273
  }
274
274
 
275
- /** Probes for gitleaks on PATH; emits okGlyph with version, or failGlyph with ENOENT vs other-error distinction (sets exitCode=1). */
276
- export function reportGitleaksProbe(section: DoctorSection): void {
275
+ /**
276
+ * Probes for gitleaks on PATH; emits okGlyph with version, or failGlyph with
277
+ * ENOENT vs other-error distinction (sets exitCode=1). Returns `true` when a
278
+ * usable binary was found so the caller can skip a redundant second `version`
279
+ * probe (e.g. the `--check-shared` Shared scan section).
280
+ */
281
+ export function reportGitleaksProbe(section: DoctorSection): boolean {
277
282
  try {
278
283
  const v = execFileSync('gitleaks', ['version'], { stdio: ['ignore', 'pipe', 'pipe'] })
279
284
  .toString()
280
285
  .trim();
281
286
  addItem(section, `${green(okGlyph)} gitleaks: ${dim(v)}`);
287
+ return true;
282
288
  } catch (err) {
283
289
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
284
290
  addItem(section, `${red(failGlyph)} gitleaks: not on PATH (required for nomad push)`);
@@ -286,6 +292,7 @@ export function reportGitleaksProbe(section: DoctorSection): void {
286
292
  addItem(section, `${red(failGlyph)} gitleaks: probe failed: ${(err as Error).message}`);
287
293
  }
288
294
  process.exitCode = 1;
295
+ return false;
289
296
  }
290
297
  }
291
298
 
@@ -12,6 +12,7 @@ import {
12
12
  reportRepoState,
13
13
  reportSharedLinks,
14
14
  } from './commands.doctor.checks.ts';
15
+ import { reportCheckShared } from './commands.doctor.check-shared.ts';
15
16
  import { reportNodeEngineCheck } from './commands.doctor.engine.ts';
16
17
  import { renderDoctor, section } from './commands.doctor.format.ts';
17
18
  import { reportVersionCheck } from './commands.doctor.version.ts';
@@ -24,8 +25,13 @@ import { reportVersionCheck } from './commands.doctor.version.ts';
24
25
  * inside the individual reporters, so a piped
25
26
  * `nomad doctor 2>/dev/null` still exposes failures to scripts. Differs from
26
27
  * `cmdPull` / `cmdPush` / `resumeCmd`, where FATAL is on stderr.
28
+ *
29
+ * `opts.checkShared` (the `--check-shared` sub-flag) appends a "Shared scan"
30
+ * section that runs the gitleaks preflight over the session transcripts a
31
+ * `nomad push` would stage. It is OFF by default so plain `nomad doctor`
32
+ * stays the fast read-only smoke test (no scan, no temp tree).
27
33
  */
28
- export function cmdDoctor(): void {
34
+ export function cmdDoctor(opts: { checkShared?: boolean } = {}): void {
29
35
  const host = section('Host');
30
36
  reportHostAndPaths(host);
31
37
  reportRepoState(host);
@@ -45,7 +51,7 @@ export function cmdDoctor(): void {
45
51
  reportNeverSync(neverSync);
46
52
 
47
53
  const repository = section('Repository');
48
- reportGitleaksProbe(repository);
54
+ const gitleaksReady = reportGitleaksProbe(repository);
49
55
  reportGitlinks(repository);
50
56
  reportRemote(repository);
51
57
  reportRebaseClean(repository);
@@ -54,5 +60,11 @@ export function cmdDoctor(): void {
54
60
  reportVersionCheck(version);
55
61
  reportNodeEngineCheck(version);
56
62
 
57
- renderDoctor([version, host, links, settings, pathMap, neverSync, repository]);
63
+ const sharedScan = section('Shared scan');
64
+ // Pass the Repository-section probe result so gitleaks `version` is not
65
+ // invoked a second time on a --check-shared run; reportCheckShared still
66
+ // probes for itself when called standalone.
67
+ if (opts.checkShared === true) reportCheckShared(sharedScan, gitleaksReady);
68
+
69
+ renderDoctor([version, host, links, settings, pathMap, neverSync, repository, sharedScan]);
58
70
  }
package/src/nomad.ts CHANGED
@@ -62,6 +62,8 @@ const DEFAULT_HELP = [
62
62
  '',
63
63
  ' doctor Read-only health check (symlinks, host file, path-map,',
64
64
  ' gitleaks, gitlinks).',
65
+ ' --check-shared Preflight gitleaks scan of the session transcripts a',
66
+ ' `nomad push` would stage (a temp copy, never the live dir).',
65
67
  ' --resume-cmd <id> Print `cd <abspath> && claude --resume <id>` for a session id',
66
68
  ' from ~/.claude/projects/.',
67
69
  '',
@@ -188,17 +190,26 @@ try {
188
190
  break;
189
191
  }
190
192
  case 'doctor':
191
- // Sub-flag: `doctor --resume-cmd <session-id>` dispatches to the
192
- // read-only sidecar that prints `cd <abspath> && claude --resume <id>`.
193
- if (process.argv[3] === '--resume-cmd') {
193
+ // Sub-flags: `doctor --resume-cmd <session-id>` dispatches to the
194
+ // read-only sidecar that prints `cd <abspath> && claude --resume <id>`;
195
+ // `doctor --check-shared` (no positional) appends the gitleaks preflight
196
+ // scan of the transcripts a push would stage. Bare `doctor` runs the
197
+ // plain read-only health check. Any other shape (unknown flag, extra
198
+ // positional, `--check-shared` with trailing args) is a usage error.
199
+ if (process.argv[3] === undefined) {
200
+ cmdDoctor();
201
+ } else if (process.argv[3] === '--check-shared' && process.argv.length === 4) {
202
+ cmdDoctor({ checkShared: true });
203
+ } else if (process.argv[3] === '--resume-cmd') {
194
204
  const id = process.argv[4];
195
- if (typeof id !== 'string' || id.length === 0) {
205
+ if (process.argv.length !== 5 || typeof id !== 'string' || id.length === 0) {
196
206
  console.error('usage: nomad doctor --resume-cmd <session-id>');
197
207
  process.exit(1);
198
208
  }
199
209
  resumeCmd(id);
200
210
  } else {
201
- cmdDoctor();
211
+ console.error('usage: nomad doctor [--check-shared | --resume-cmd <session-id>]');
212
+ process.exit(1);
202
213
  }
203
214
  break;
204
215
  case 'drop-session': {
@@ -43,7 +43,7 @@ export type Finding = {
43
43
  * paths (e.g., `shared/projects/<logical>/subagents/<id>.jsonl`) fall
44
44
  * through to the non-session `other` bucket.
45
45
  */
46
- const SESSION_PATH = /^shared\/projects\/[^/]+\/([^/]+)\.jsonl$/;
46
+ export const SESSION_PATH = /^shared\/projects\/[^/]+\/([^/]+)\.jsonl$/;
47
47
 
48
48
  /**
49
49
  * Legacy fallback FATAL emitted when no finding's File matches the session
@@ -129,7 +129,7 @@ export function buildSessionAwareFatal(
129
129
  * the failure path must NOT cascade into a parse-error stack trace; the
130
130
  * caller falls back to the legacy FATAL string in that case.
131
131
  */
132
- function readGitleaksReport(reportPath: string): Finding[] | null {
132
+ export function readGitleaksReport(reportPath: string): Finding[] | null {
133
133
  try {
134
134
  const raw = readFileSync(reportPath, 'utf8');
135
135
  const parsed: unknown = JSON.parse(raw);
package/src/remap.ts CHANGED
@@ -27,7 +27,7 @@ function copyDir(src: string, dst: string): void {
27
27
  * keeps the unfiltered copyDir because the repo side is already curated
28
28
  * by the push gate.
29
29
  */
30
- function copyDirJsonlOnly(src: string, dst: string): void {
30
+ export function copyDirJsonlOnly(src: string, dst: string): void {
31
31
  rmSync(dst, { recursive: true, force: true });
32
32
  cpSync(src, dst, {
33
33
  recursive: true,