specrails-desktop 2.29.4 → 2.29.5

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.
@@ -24,10 +24,24 @@ exports.applyWorktreeOverlay = applyWorktreeOverlay;
24
24
  * (in-repo) projects have the sibling problem for their UNTRACKED `.claude`
25
25
  * entries: present on disk in the repo, absent from the checkout.
26
26
  *
27
+ * Sources are ORDERED roots: `sourceRoot` (the effective artifact root — the
28
+ * workspace for relocated projects, the repo for legacy ones) plus optional
29
+ * `fallbackSourceRoots`. Relocated launches pass the REPO as the fallback so
30
+ * repo-resident untracked carve-outs (OpenSpec's `/opsx:*` command dirs,
31
+ * `openspec-*` skills, user extras) reach the worktree exactly as they do for
32
+ * legacy projects — a relocated workspace links the framework `commands/`
33
+ * subtree, which ships only `specrails/`, so without the fallback the claude
34
+ * CLI reports `Unknown command: /opsx:ff` inside isolated rails.
35
+ *
27
36
  * Mechanics — a MERGE-overlay under `<worktree>/<providerDir>/`:
28
37
  * - For each source entry missing in the worktree, SYMLINK it (dir symlink when
29
38
  * the whole dir is absent, per-file/per-child when partially present). The
30
39
  * checkout's own content is NEVER overwritten — tracked files always win.
40
+ * - Earlier roots win per entry. When SEVERAL roots contribute children to the
41
+ * same directory (workspace `commands/specrails` + repo `commands/opsx`),
42
+ * the dir is materialized as a REAL directory of per-child links — a
43
+ * whole-dir link to either root would hide the other's children. A prior
44
+ * pass's own whole-dir link is upgraded in place on resume.
31
45
  * - `agent-memory` is linked like everything else: all runs SHARE agent memory,
32
46
  * exactly matching the pre-isolation shared-cwd semantics (deliberate).
33
47
  * - The providerDir ROOT itself is never linked (a real dir is created) so
@@ -41,11 +55,11 @@ exports.applyWorktreeOverlay = applyWorktreeOverlay;
41
55
  *
42
56
  * Idempotent + resume-safe: every created entry is recorded in a manifest
43
57
  * (`.sr-rail-overlay.json`, itself overlay-owned) whose UNION across passes is
44
- * returned as `createdPaths` — the caller excludes those paths from
45
- * `commitWorktree`'s `git add -A` so overlay scaffolding NEVER lands on the
46
- * ticket branch / PR. Failures degrade per entry into `warnings` (the spawn
47
- * proceeds — a partial surface beats an aborted rail); this function never
48
- * throws.
58
+ * returned as `createdPaths`. `commitWorktree` uses those paths for its index
59
+ * audit/reset and authoritative `git commit --only` literal exclusions, so
60
+ * overlay scaffolding NEVER lands on the ticket branch / PR. Failures degrade
61
+ * per entry into `warnings` (the spawn proceeds — a partial surface beats an
62
+ * aborted rail); this function never throws.
49
63
  */
50
64
  const fs_1 = __importDefault(require("fs"));
51
65
  const path_1 = __importDefault(require("path"));
@@ -102,17 +116,37 @@ function safeRelativePath(value) {
102
116
  return null;
103
117
  return normalized;
104
118
  }
105
- function sourcePathForOverlayEntry(input, rel) {
119
+ /** The configured source roots in priority order, deduped, never the worktree
120
+ * itself (self-overlay paranoia extends to fallback roots). */
121
+ function overlayRootsOf(input) {
122
+ const worktree = path_1.default.resolve(input.worktreePath);
123
+ const roots = [];
124
+ const seen = new Set();
125
+ for (const root of [input.sourceRoot, ...(input.fallbackSourceRoots ?? [])]) {
126
+ if (!root)
127
+ continue;
128
+ const resolved = path_1.default.resolve(root);
129
+ if (resolved === worktree || seen.has(resolved))
130
+ continue;
131
+ seen.add(resolved);
132
+ roots.push(root);
133
+ }
134
+ return roots;
135
+ }
136
+ /** Candidate source paths (one per configured root, priority order) an overlay
137
+ * manifest entry may authenticate against. Empty for unauthorised rels. */
138
+ function sourceCandidatesForOverlayEntry(input, rel) {
139
+ const roots = overlayRootsOf(input);
106
140
  if (rel === '.mcp.json' || rel === input.instructionsFilename) {
107
- return path_1.default.join(input.sourceRoot, rel);
141
+ return roots.map((root) => path_1.default.join(root, rel));
108
142
  }
109
143
  const providerPrefix = `${input.providerDir}/`;
110
144
  if (!rel.startsWith(providerPrefix))
111
- return null;
145
+ return [];
112
146
  const providerRel = rel.slice(providerPrefix.length);
113
147
  if (!providerRel || providerRel === 'worktrees' || providerRel.startsWith('worktrees/'))
114
- return null;
115
- return path_1.default.join(input.sourceRoot, ...rel.split('/'));
148
+ return [];
149
+ return roots.map((root) => path_1.default.join(root, ...rel.split('/')));
116
150
  }
117
151
  function sha256(parts) {
118
152
  const hash = (0, crypto_1.createHash)('sha256');
@@ -188,25 +222,28 @@ function captureOverlayCleanupEvidence(input, candidates, includeManifest = fals
188
222
  }
189
223
  continue;
190
224
  }
191
- const source = sourcePathForOverlayEntry(input, rel);
192
- if (!source)
225
+ const sources = sourceCandidatesForOverlayEntry(input, rel);
226
+ if (sources.length === 0)
193
227
  continue;
194
228
  const destinationStat = lstatSafe(destination);
195
229
  if (!destinationStat)
196
230
  continue;
197
231
  if (destinationStat.isSymbolicLink()) {
232
+ let target;
198
233
  try {
199
- if (path_1.default.resolve(path_1.default.dirname(destination), fs_1.default.readlinkSync(destination)) !== path_1.default.resolve(source))
200
- continue;
234
+ target = path_1.default.resolve(path_1.default.dirname(destination), fs_1.default.readlinkSync(destination));
201
235
  }
202
236
  catch {
203
237
  continue;
204
238
  }
239
+ if (!sources.some((source) => path_1.default.resolve(source) === target))
240
+ continue;
205
241
  }
206
242
  else {
207
- const sourceDigest = dereferencedDigest(source);
208
243
  const destinationDigest = dereferencedDigest(destination);
209
- if (!sourceDigest || sourceDigest !== destinationDigest)
244
+ if (!destinationDigest)
245
+ continue;
246
+ if (!sources.some((source) => dereferencedDigest(source) === destinationDigest))
210
247
  continue;
211
248
  }
212
249
  const fingerprint = fingerprintOverlayCleanupPath(destination);
@@ -286,47 +323,175 @@ function linkOrCopyEntry(src, dest, rel, created, warnings) {
286
323
  }
287
324
  created.push(rel);
288
325
  }
326
+ /** True when `secondary` contributes any entry name (recursively, by tree
327
+ * shape) that is not reachable through `primary`. Drives the resume-time
328
+ * upgrade of a prior whole-dir link into a merged real dir. Follows dir
329
+ * symlinks with a realpath ancestor guard (framework subtrees are links). */
330
+ function contributesExtraEntries(secondary, primary, ancestors = new Set()) {
331
+ let names;
332
+ try {
333
+ names = fs_1.default.readdirSync(secondary);
334
+ }
335
+ catch {
336
+ return false;
337
+ }
338
+ let real;
339
+ try {
340
+ real = fs_1.default.realpathSync(secondary);
341
+ }
342
+ catch {
343
+ return false;
344
+ }
345
+ if (ancestors.has(real))
346
+ return false;
347
+ const nextAncestors = new Set(ancestors).add(real);
348
+ for (const name of names) {
349
+ const primaryChild = path_1.default.join(primary, name);
350
+ if (!lstatSafe(primaryChild))
351
+ return true;
352
+ const secondaryChild = path_1.default.join(secondary, name);
353
+ if (isDir(secondaryChild) && isDir(primaryChild) &&
354
+ contributesExtraEntries(secondaryChild, primaryChild, nextAncestors)) {
355
+ return true;
356
+ }
357
+ }
358
+ return false;
359
+ }
289
360
  /**
290
- * Merge `src` into `dest` non-destructively:
291
- * - dest missing → link (whole entry).
292
- * - dest is OUR prior link → re-claim (record) without touching it.
293
- * - dest is a real dir AND src is a dir → recurse per child (per-file links
294
- * where the checkout is partially present).
295
- * - anything else → skip (the checkout's content always wins).
361
+ * Merge the ordered existing sources for ONE entry into `dest`
362
+ * non-destructively (`srcs` = the roots' paths that exist for this rel,
363
+ * priority order — earlier roots win):
364
+ * - dest missing, one contributing dir (or a file first) → link whole entry.
365
+ * - dest missing, several contributing dirs → REAL dir + per-child recursion
366
+ * (a whole-dir link to either root would hide the other's children).
367
+ * - dest is OUR prior link/copy → re-claim; rebuild from the current ordered
368
+ * sources when a higher-priority winner appears or another root contributes
369
+ * extra directory entries (resume path).
370
+ * - dest is a real dir AND the highest-priority src is a dir → recurse per
371
+ * child over the union (per-file links where checkout is partially present).
372
+ * - anything else → skip (the checkout's content always wins).
296
373
  */
297
- function mergeLink(src, dest, rel, created, warnings) {
374
+ function mergeLink(srcs, dest, rel, created, warnings, priorOwned, converted) {
375
+ if (srcs.length === 0)
376
+ return;
377
+ const primary = srcs[0];
378
+ const primaryIsDir = isDir(primary);
379
+ // A highest-priority FILE shadows every lower-priority entry, including
380
+ // directories. Only collect mergeable dirs when the primary itself is one.
381
+ const dirSrcs = primaryIsDir ? srcs.filter((src) => isDir(src)) : [];
298
382
  const destSt = lstatSafe(dest);
299
383
  if (!destSt) {
300
- linkOrCopyEntry(src, dest, rel, created, warnings);
384
+ if (!primaryIsDir || dirSrcs.length <= 1) {
385
+ // A file in the highest-priority root shadows lower roots entirely; a
386
+ // dir contributed by a single root keeps the whole-dir link (status quo).
387
+ linkOrCopyEntry(primary, dest, rel, created, warnings);
388
+ return;
389
+ }
390
+ try {
391
+ fs_1.default.mkdirSync(dest, { recursive: true });
392
+ }
393
+ catch (err) {
394
+ warnings.push(`failed to create ${rel}: ${errMsg(err)}`);
395
+ return;
396
+ }
397
+ // The merged real dir itself is NOT recorded (precedent: the providerDir
398
+ // root); only its leaf links carry manifest entries + cleanup evidence.
399
+ mergeChildren(dirSrcs, dest, rel, created, warnings, priorOwned, converted);
301
400
  return;
302
401
  }
303
402
  if (destSt.isSymbolicLink()) {
304
403
  // Idempotent re-claim of a link a prior pass created; a foreign symlink
305
404
  // (brought by the checkout) is left alone and NOT claimed.
405
+ let target;
306
406
  try {
307
- if (path_1.default.resolve(path_1.default.dirname(dest), fs_1.default.readlinkSync(dest)) === path_1.default.resolve(src))
308
- created.push(rel);
407
+ target = path_1.default.resolve(path_1.default.dirname(dest), fs_1.default.readlinkSync(dest));
309
408
  }
310
409
  catch {
311
- /* unreadable link — leave it */
410
+ return; /* unreadable link — leave it */
411
+ }
412
+ const owned = srcs.find((src) => path_1.default.resolve(src) === target);
413
+ // Target equality authenticates WHAT the link points at, but not WHO made
414
+ // it. Destructive resume conversion additionally requires authenticated
415
+ // prior-manifest ownership; a checkout/foreign link may legitimately point
416
+ // at the same configured source and must never be replaced or claimed.
417
+ if (!owned || !priorOwned.has(rel))
418
+ return;
419
+ const winnerChanged = path_1.default.resolve(owned) !== path_1.default.resolve(primary);
420
+ const needsDirectoryMerge = isDir(owned) && primaryIsDir && dirSrcs.length > 1 &&
421
+ dirSrcs.some((src) => src !== owned && contributesExtraEntries(src, owned));
422
+ if (winnerChanged || needsDirectoryMerge) {
423
+ // Resume upgrade: rebuild OUR authenticated entry when a higher-priority
424
+ // source appeared or another dir now contributes children. Re-entering
425
+ // mergeLink with a missing destination selects the current winner and
426
+ // materializes a merged REAL dir when required.
427
+ try {
428
+ fs_1.default.unlinkSync(dest);
429
+ }
430
+ catch (err) {
431
+ warnings.push(`failed to upgrade ${rel}: ${errMsg(err)}`);
432
+ created.push(rel); // still ours — keep it commit-excluded
433
+ return;
434
+ }
435
+ converted.add(rel);
436
+ mergeLink(srcs, dest, rel, created, warnings, priorOwned, converted);
437
+ return;
438
+ }
439
+ created.push(rel);
440
+ return;
441
+ }
442
+ if ((destSt.isDirectory() || destSt.isFile()) && priorOwned.has(rel)) {
443
+ // Windows may have materialized a prior overlay as a COPY when link
444
+ // creation was unavailable. Rebuild an authenticated source-identical copy
445
+ // when a higher-priority source appeared or a directory needs children
446
+ // from another root. Merely recursing into a prior dir copy would leave its
447
+ // old children unrecorded after the parent digest stops matching.
448
+ const destinationDigest = dereferencedDigest(dest);
449
+ const owned = destinationDigest
450
+ ? srcs.find((src) => dereferencedDigest(src) === destinationDigest)
451
+ : undefined;
452
+ const winnerChanged = !!owned && path_1.default.resolve(owned) !== path_1.default.resolve(primary);
453
+ const needsDirectoryMerge = !!owned && isDir(owned) && primaryIsDir && dirSrcs.length > 1 &&
454
+ dirSrcs.some((src) => src !== owned && contributesExtraEntries(src, owned));
455
+ if (owned && (winnerChanged || needsDirectoryMerge)) {
456
+ try {
457
+ // Recompute immediately before removal so a user modification between
458
+ // manifest authentication and conversion revokes destructive authority.
459
+ if (dereferencedDigest(dest) !== destinationDigest)
460
+ return;
461
+ fs_1.default.rmSync(dest, { recursive: destSt.isDirectory() });
462
+ }
463
+ catch (err) {
464
+ warnings.push(`failed to upgrade ${rel}: ${errMsg(err)}`);
465
+ return;
466
+ }
467
+ converted.add(rel);
468
+ mergeLink(srcs, dest, rel, created, warnings, priorOwned, converted);
469
+ return;
312
470
  }
471
+ }
472
+ if (destSt.isDirectory() && primaryIsDir && dirSrcs.length > 0) {
473
+ mergeChildren(dirSrcs, dest, rel, created, warnings, priorOwned, converted);
313
474
  return;
314
475
  }
315
- if (destSt.isDirectory() && isDir(src)) {
316
- let entries;
476
+ // dest exists as a file (or src is a file while dest is a dir) — never overwrite.
477
+ }
478
+ /** Recurse `mergeLink` per child over the UNION of children across the
479
+ * contributing dirs, preserving root priority order. */
480
+ function mergeChildren(dirSrcs, dest, rel, created, warnings, priorOwned, converted) {
481
+ const names = new Set();
482
+ for (const dir of dirSrcs) {
317
483
  try {
318
- entries = fs_1.default.readdirSync(src);
484
+ for (const name of fs_1.default.readdirSync(dir))
485
+ names.add(name);
319
486
  }
320
487
  catch (err) {
321
488
  warnings.push(`failed to read ${rel}: ${errMsg(err)}`);
322
- return;
323
- }
324
- for (const name of entries) {
325
- mergeLink(path_1.default.join(src, name), path_1.default.join(dest, name), `${rel}/${name}`, created, warnings);
326
489
  }
327
- return;
328
490
  }
329
- // dest exists as a file (or src is a file while dest is a dir) — never overwrite.
491
+ for (const name of [...names].sort()) {
492
+ const childSrcs = dirSrcs.map((dir) => path_1.default.join(dir, name)).filter((p) => lstatSafe(p) !== null);
493
+ mergeLink(childSrcs, path_1.default.join(dest, name), `${rel}/${name}`, created, warnings, priorOwned, converted);
494
+ }
330
495
  }
331
496
  /** Copy `src` → `dest` only when `dest` does not exist. Records `rel`. */
332
497
  function copyIfAbsent(src, dest, rel, created, warnings) {
@@ -351,6 +516,8 @@ function applyWorktreeOverlay(input) {
351
516
  const created = [];
352
517
  const manifestPath = path_1.default.join(worktreePath, exports.OVERLAY_MANIFEST);
353
518
  const prior = captureOverlayCleanupEvidence(input, readManifest(manifestPath)).map((entry) => entry.path);
519
+ const priorOwned = new Set(prior);
520
+ const converted = new Set();
354
521
  try {
355
522
  if (!isDir(worktreePath)) {
356
523
  warnings.push(`worktree dir missing: ${worktreePath}`);
@@ -360,9 +527,11 @@ function applyWorktreeOverlay(input) {
360
527
  // Paranoia: never overlay a worktree onto itself.
361
528
  return { createdPaths: [], cleanupEvidence: [], warnings };
362
529
  }
363
- // 1. providerDir merge-overlay (commands/agents/skills/rules/settings/…).
364
- const srcProvider = path_1.default.join(sourceRoot, providerDir);
365
- if (isDir(srcProvider)) {
530
+ const roots = overlayRootsOf(input);
531
+ // 1. providerDir merge-overlay (commands/agents/skills/rules/settings/…),
532
+ // over the UNION of entries across the configured roots (earlier wins).
533
+ const srcProviders = roots.map((root) => path_1.default.join(root, providerDir)).filter((p) => isDir(p));
534
+ if (srcProviders.length > 0) {
366
535
  const destProvider = path_1.default.join(worktreePath, providerDir);
367
536
  // The providerDir root is ALWAYS a real local dir (never a link) so
368
537
  // nested `.claude/worktrees/**` stay local — see the module header.
@@ -382,32 +551,45 @@ function applyWorktreeOverlay(input) {
382
551
  providerReady = false;
383
552
  }
384
553
  if (providerReady) {
385
- let entries = [];
386
- try {
387
- entries = fs_1.default.readdirSync(srcProvider);
554
+ const names = new Set();
555
+ for (const srcProvider of srcProviders) {
556
+ try {
557
+ for (const name of fs_1.default.readdirSync(srcProvider))
558
+ names.add(name);
559
+ }
560
+ catch (err) {
561
+ warnings.push(`failed to read source ${providerDir}: ${errMsg(err)}`);
562
+ }
388
563
  }
389
- catch (err) {
390
- warnings.push(`failed to read source ${providerDir}: ${errMsg(err)}`);
391
- }
392
- for (const name of entries) {
564
+ for (const name of [...names].sort()) {
393
565
  if (SKIP_PROVIDER_ENTRIES.has(name))
394
566
  continue;
395
- mergeLink(path_1.default.join(srcProvider, name), path_1.default.join(destProvider, name), `${providerDir}/${name}`, created, warnings);
567
+ const childSrcs = srcProviders.map((srcProvider) => path_1.default.join(srcProvider, name)).filter((p) => lstatSafe(p) !== null);
568
+ mergeLink(childSrcs, path_1.default.join(destProvider, name), `${providerDir}/${name}`, created, warnings, priorOwned, converted);
396
569
  }
397
570
  }
398
571
  }
399
572
  // 2. `.mcp.json` — COPY (spawn-local), only when the checkout lacks one.
400
- copyIfAbsent(path_1.default.join(sourceRoot, '.mcp.json'), path_1.default.join(worktreePath, '.mcp.json'), '.mcp.json', created, warnings);
401
- // 3. Provider instruction file — COPY when the source has one and the
573
+ // First root that has one wins.
574
+ const mcpSrc = roots.map((root) => path_1.default.join(root, '.mcp.json')).find((p) => fs_1.default.existsSync(p));
575
+ if (mcpSrc)
576
+ copyIfAbsent(mcpSrc, path_1.default.join(worktreePath, '.mcp.json'), '.mcp.json', created, warnings);
577
+ // 3. Provider instruction file — COPY when a source has one and the
402
578
  // checkout doesn't (a repo-tracked CLAUDE.md always wins).
403
- copyIfAbsent(path_1.default.join(sourceRoot, instructionsFilename), path_1.default.join(worktreePath, instructionsFilename), instructionsFilename, created, warnings);
579
+ const instructionsSrc = roots.map((root) => path_1.default.join(root, instructionsFilename)).find((p) => fs_1.default.existsSync(p));
580
+ if (instructionsSrc) {
581
+ copyIfAbsent(instructionsSrc, path_1.default.join(worktreePath, instructionsFilename), instructionsFilename, created, warnings);
582
+ }
404
583
  }
405
584
  catch (err) {
406
585
  warnings.push(`overlay failed: ${errMsg(err)}`);
407
586
  }
408
587
  // Union with the prior manifest so a RESUMED worktree keeps every overlay
409
588
  // entry excluded from commits, then persist (the manifest is overlay-owned).
410
- const all = [...new Set([...prior, ...created])];
589
+ // A converted whole-dir link is no longer an overlay leaf. Drop it even if
590
+ // the merged real directory happens to have the same dereferenced digest as
591
+ // one source root (e.g. a fallback root already contains the full superset).
592
+ const all = [...new Set([...prior.filter((rel) => !converted.has(rel)), ...created])];
411
593
  if (all.length > 0) {
412
594
  try {
413
595
  fs_1.default.writeFileSync(manifestPath, JSON.stringify({ version: 1, paths: all }, null, 2));