claude-code-session-manager 0.76.0 → 0.77.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.
Files changed (123) hide show
  1. package/dist/assets/{AgentLibrary-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
  2. package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
  3. package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
  4. package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
  5. package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
  6. package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
  7. package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
  9. package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
  10. package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
  11. package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
  12. package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
  13. package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
  15. package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
  16. package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
  18. package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
  20. package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
  21. package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
  22. package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
  23. package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
  24. package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
  25. package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
  26. package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
  27. package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-6IOLjZZN.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +8 -2
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  31. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  32. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  33. package/scripts/render-project-pages.cjs +70 -0
  34. package/scripts/scheduler-mcp-server.cjs +115 -1
  35. package/scripts/validate-project-pages-summary.cjs +62 -0
  36. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  37. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  38. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  39. package/src/main/__tests__/prdCreate.test.cjs +138 -0
  40. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  41. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  42. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  43. package/src/main/__tests__/projectPages.test.cjs +73 -1
  44. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  45. package/src/main/__tests__/runVerify.test.cjs +94 -0
  46. package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
  47. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  48. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  49. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  50. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  51. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  52. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  53. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  54. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  55. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  56. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  57. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
  58. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  59. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  60. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  61. package/src/main/config.cjs +4 -1
  62. package/src/main/index.cjs +8 -1
  63. package/src/main/ipcSchemas.cjs +51 -0
  64. package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
  65. package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -2
  66. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
  67. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  68. package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
  69. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  70. package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
  71. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  72. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  73. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
  74. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  75. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  76. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  77. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  78. package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
  79. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  80. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  81. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  82. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  83. package/src/main/lib/agentModelResolve.cjs +58 -0
  84. package/src/main/lib/childWithLog.cjs +40 -5
  85. package/src/main/lib/claudeBin.cjs +54 -1
  86. package/src/main/lib/definitionOfDone.cjs +3 -2
  87. package/src/main/lib/delegationReadiness.cjs +115 -9
  88. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  89. package/src/main/lib/fixChainDepth.cjs +45 -0
  90. package/src/main/lib/gitWorktree.cjs +464 -19
  91. package/src/main/lib/jobWorktree.cjs +1 -0
  92. package/src/main/lib/landedSinceRun.cjs +55 -0
  93. package/src/main/lib/launchFailure.cjs +357 -0
  94. package/src/main/lib/mcpToolCatalog.cjs +87 -2
  95. package/src/main/lib/opsOwnership.cjs +12 -0
  96. package/src/main/lib/prdAgentType.cjs +84 -0
  97. package/src/main/lib/prdCreate.cjs +57 -1
  98. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  99. package/src/main/lib/prdFrontmatter.cjs +17 -3
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  101. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  102. package/src/main/lib/queueHealth.cjs +38 -0
  103. package/src/main/lib/queueStore.cjs +9 -2
  104. package/src/main/lib/quietMachineLease.cjs +48 -0
  105. package/src/main/lib/rcaReport.cjs +53 -3
  106. package/src/main/lib/reaperHelpers.cjs +18 -1
  107. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  108. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  109. package/src/main/lib/schedulerBatch.cjs +133 -29
  110. package/src/main/lib/schedulerConfig.cjs +19 -0
  111. package/src/main/projectPages.cjs +160 -2
  112. package/src/main/runVerify.cjs +50 -9
  113. package/src/main/scheduler/prdParser.cjs +18 -1
  114. package/src/main/scheduler.cjs +1371 -97
  115. package/src/main/seedAgentPersonas.cjs +62 -21
  116. package/src/main/templates/project-pages-catalog.json +741 -0
  117. package/src/main/templates/project-pages-pipeline.md +417 -0
  118. package/src/preload/api.d.ts +118 -2
  119. package/src/preload/index.cjs +7 -0
  120. package/src/seed/agents/project-home-builder.md +59 -0
  121. package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
  122. package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
  123. package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
@@ -77,6 +77,12 @@ const { execFile } = require('node:child_process');
77
77
  // The job cap's assumptions (few, short-lived, quickly-reclaimed checkouts)
78
78
  // simply don't transfer, so the epic kind gets a separately-configurable,
79
79
  // much higher ceiling rather than sharing the job kind's default.
80
+ // staleSweepAgeEnv/defaultStaleSweepAgeMs back the on-disk orphan sweep
81
+ // (reconcileWorktreesOnBoot's root-level pass, below) — how old an on-disk
82
+ // checkout must be before it's reclaimed even though no caller-supplied cwd
83
+ // vouches for it. The job default (24h) matches a job's whole lifetime being
84
+ // minutes; the epic default (7d) matches an Epic legitimately living for
85
+ // days, same rationale as defaultMax above.
80
86
  const KIND_CONFIG = {
81
87
  job: {
82
88
  root: path.join(os.tmpdir(), 'session-manager-job-worktrees'),
@@ -84,6 +90,8 @@ const KIND_CONFIG = {
84
90
  disableEnv: 'SM_JOB_WORKTREE_DISABLE',
85
91
  maxEnv: 'SM_JOB_WORKTREE_MAX',
86
92
  defaultMax: 4,
93
+ staleSweepAgeEnv: 'SM_JOB_WORKTREE_STALE_MS',
94
+ defaultStaleSweepAgeMs: 24 * 60 * 60 * 1000,
87
95
  },
88
96
  epic: {
89
97
  root: path.join(os.tmpdir(), 'session-manager-epic-worktrees'),
@@ -91,6 +99,8 @@ const KIND_CONFIG = {
91
99
  disableEnv: 'SM_EPIC_WORKTREE_DISABLE',
92
100
  maxEnv: 'SM_EPIC_WORKTREE_MAX',
93
101
  defaultMax: 50,
102
+ staleSweepAgeEnv: 'SM_EPIC_WORKTREE_STALE_MS',
103
+ defaultStaleSweepAgeMs: 7 * 24 * 60 * 60 * 1000,
94
104
  },
95
105
  };
96
106
 
@@ -121,10 +131,40 @@ function isWorktreeDisabled(kind, cwd) {
121
131
  return false;
122
132
  }
123
133
 
134
+ /**
135
+ * The job-kind default alone is floored at sessionSlots' machine-wide `claude
136
+ * -p` cap (lazily required to avoid a load-order cycle): a job that fails to
137
+ * get session-slot capacity already waits for a slot to free up rather than
138
+ * degrading, so the worktree cap below it must never be tighter than the
139
+ * slot pool — otherwise raising slots to 10 in the UI silently strands 6 jobs
140
+ * without isolation, running in-place instead of waiting (the exact bug this
141
+ * PRD fixes). An explicit SM_JOB_WORKTREE_MAX still wins verbatim — a
142
+ * deliberate operator override, not a value this floor should touch.
143
+ */
124
144
  function getMaxConcurrentWorktrees(kind) {
125
145
  const cfg = configFor(kind);
126
146
  const raw = Number(process.env[cfg.maxEnv]);
127
- return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : cfg.defaultMax;
147
+ if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
148
+ if (kind === 'job') {
149
+ const sessionSlots = require('./sessionSlots.cjs');
150
+ return Math.max(cfg.defaultMax, sessionSlots.totalSlots());
151
+ }
152
+ return cfg.defaultMax;
153
+ }
154
+
155
+ function getStaleSweepAgeMs(kind) {
156
+ const cfg = configFor(kind);
157
+ const raw = Number(process.env[cfg.staleSweepAgeEnv]);
158
+ return Number.isFinite(raw) && raw > 0 ? raw : cfg.defaultStaleSweepAgeMs;
159
+ }
160
+
161
+ /** True when resolved path `p` is `root` itself or nested under it — the one
162
+ * gate every deletion routine below must pass before touching disk, so a
163
+ * path-traversal-shaped key can never escape this kind's own tmpdir root. */
164
+ function isUnderRoot(p, root) {
165
+ const resolvedRoot = path.resolve(root);
166
+ const resolvedP = path.resolve(p);
167
+ return resolvedP === resolvedRoot || resolvedP.startsWith(resolvedRoot + path.sep);
128
168
  }
129
169
 
130
170
  // In-memory count of worktrees currently checked out by THIS process, kept
@@ -139,6 +179,11 @@ function execGit(args, { cwd, timeout = 20_000 } = {}) {
139
179
  execFile('git', args, { cwd, timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
140
180
  if (err) {
141
181
  err.stderrText = stderr;
182
+ // Some git subcommands (e.g. `diff --no-index`) exit non-zero to mean
183
+ // "found a difference", not "failed" — stdout still carries the real
184
+ // result in that case, so callers that need it can recover it off
185
+ // the rejected error rather than losing it.
186
+ err.stdoutText = stdout;
142
187
  reject(err);
143
188
  return;
144
189
  }
@@ -190,7 +235,16 @@ function branchNameFor(kind, key) {
190
235
  return `${configFor(kind).branchPrefix}${key}`;
191
236
  }
192
237
 
193
- /** Best-effort teardown of one worktree checkout — never throws. */
238
+ /**
239
+ * Best-effort teardown of one worktree checkout — never throws. Also rmdirs
240
+ * `dir`'s parent (the per-cwd hash directory `worktreeDirFor` nests every
241
+ * checkout under) when it's left empty, so a torn-down checkout doesn't
242
+ * leave a permanent empty leftover behind (RCA: PRD 1108 — 1125 of 1133
243
+ * on-disk hash dirs under the job root were exactly this, never reclaimed).
244
+ * Uses non-recursive `fs.rmdir`, which only succeeds on a truly empty
245
+ * directory, so a sibling checkout still living under the same hash dir can
246
+ * never be destroyed by this call.
247
+ */
194
248
  async function removeWorktreeDir(cwd, dir) {
195
249
  try {
196
250
  await execGit(['worktree', 'remove', '--force', dir], { cwd, timeout: 15_000 });
@@ -203,18 +257,238 @@ async function removeWorktreeDir(cwd, dir) {
203
257
  } catch {
204
258
  /* best-effort */
205
259
  }
260
+ const parentDir = path.dirname(dir);
261
+ // Guard against ever rmdir-ing os.tmpdir() itself — every real caller's
262
+ // `dir` is `<kind root>/<hash>/<key>`, so `parentDir` is always the hash
263
+ // dir, never the tmpdir root, but this keeps the guarantee explicit rather
264
+ // than relying solely on call-site discipline.
265
+ if (parentDir === os.tmpdir()) return;
266
+ try {
267
+ const remaining = await fsp.readdir(parentDir);
268
+ if (remaining.length === 0) await fsp.rmdir(parentDir);
269
+ } catch {
270
+ // Not empty (a sibling checkout survives), doesn't exist, or a race —
271
+ // all fine to ignore.
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Reads a linked worktree checkout's own `.git` file (a plain text pointer,
277
+ * not a directory, for any `git worktree add` checkout) to recover the path
278
+ * of the MAIN tree it's registered against, without needing a caller-
279
+ * supplied cwd. Content looks like `gitdir: /path/to/main/.git/worktrees/
280
+ * <name>`; the main tree is that path with the trailing `/.git/worktrees/
281
+ * <name>` segment stripped off.
282
+ *
283
+ * Round-trip verified before being trusted, mirroring the same defense
284
+ * `scripts/lib/activeSessions.cjs`'s `worktreeMainRootOf` already applies: a
285
+ * `.git` pointer file is a plain text file, so its content must not be
286
+ * trusted to redirect `git worktree remove --force` at an arbitrary path
287
+ * without the candidate main tree's OWN admin dir
288
+ * (`<main>/.git/worktrees/<name>/gitdir`) agreeing this checkout is really
289
+ * one of its registered worktrees. Returns null on any read/parse/mismatch —
290
+ * never throws.
291
+ */
292
+ async function mainTreeFromWorktreeGitFile(dir) {
293
+ try {
294
+ const gitFile = path.join(dir, '.git');
295
+ const content = await fsp.readFile(gitFile, 'utf8');
296
+ const match = content.match(/^gitdir:\s*(.+)$/m);
297
+ if (!match) return null;
298
+ const gitdir = match[1].trim();
299
+ const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`;
300
+ const idx = gitdir.lastIndexOf(marker);
301
+ if (idx === -1) return null;
302
+ const candidateMain = gitdir.slice(0, idx);
303
+ const worktreeName = gitdir.slice(idx + marker.length).split(path.sep)[0];
304
+ if (!worktreeName) return null;
305
+
306
+ const adminGitdirFile = path.join(candidateMain, '.git', 'worktrees', worktreeName, 'gitdir');
307
+ const backRef = (await fsp.readFile(adminGitdirFile, 'utf8')).trim();
308
+ if (path.resolve(backRef) !== path.resolve(gitFile)) return null;
309
+
310
+ return candidateMain;
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Tear down one orphaned on-disk checkout that `sweepStaleWorktreeCheckouts`
318
+ * found with no caller-supplied cwd to run `git worktree remove` against.
319
+ * Recovers the main tree from the checkout's own `.git` file and removes the
320
+ * registration there first, so the main repo's `git worktree list` is never
321
+ * left with a stale entry pointing at a directory we just deleted. Falls back
322
+ * to a plain `rm -rf` (plus a best-effort `worktree prune` run from inside
323
+ * the checkout itself, in case git can still resolve it) when that lookup
324
+ * fails. Never throws.
325
+ */
326
+ async function teardownOrphanedCheckout(dir) {
327
+ const mainTree = await mainTreeFromWorktreeGitFile(dir);
328
+ if (mainTree) {
329
+ try {
330
+ await execGit(['worktree', 'remove', '--force', dir], { cwd: mainTree, timeout: 15_000 });
331
+ } catch {
332
+ // Registration already gone, or the main tree no longer agrees this is
333
+ // one of its worktrees — fall through to the plain rm below either way.
334
+ }
335
+ try { await fsp.rm(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
336
+ try { await execGit(['worktree', 'prune'], { cwd: mainTree, timeout: 10_000 }); } catch { /* best-effort */ }
337
+ return;
338
+ }
339
+ try { await execGit(['worktree', 'prune'], { cwd: dir, timeout: 10_000 }); } catch { /* best-effort */ }
340
+ try { await fsp.rm(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
341
+ }
342
+
343
+ /**
344
+ * Sweeps this kind's ENTIRE on-disk worktree root directly — not limited to
345
+ * any caller-supplied list of cwds — for checkouts belonging to a project
346
+ * `reconcileWorktreesOnBoot` was never handed a cwd for (a project removed
347
+ * from Session Manager, or a stray leftover from before this sweep existed).
348
+ * The per-cwd loop in `reconcileWorktreesOnBoot` can only clean what it's
349
+ * told about; this covers the rest.
350
+ *
351
+ * A checkout younger than `staleAgeMs` (mtime-based) is NEVER touched — this
352
+ * is what keeps an in-progress job or Epic safe from a concurrent boot sweep.
353
+ * This intentionally does NOT accept an `isLive` predicate the way
354
+ * `reconcileWorktreesOnBoot`'s per-cwd pass does: that predicate is scoped to
355
+ * ONE project's own active-index.json per call, but this sweep walks EVERY
356
+ * project's checkouts under the shared kind root in one pass — applying one
357
+ * project's liveness answer to another project's checkout would be worse
358
+ * than no answer at all (a false "not live" for a foreign key would tear
359
+ * down a genuinely active Epic before its own project's turn ever runs this
360
+ * sweep). The age threshold (7 days for epics, by default) is the only
361
+ * safety margin here, deliberately.
362
+ *
363
+ * Every path visited is verified (`isUnderRoot`) to be nested under this
364
+ * kind's own root before any read or delete, so a maliciously-shaped on-disk
365
+ * name can never walk this sweep outside its own tmpdir root.
366
+ *
367
+ * Returns `{ checkoutsRemoved, emptyRootsRemoved }`. Never throws.
368
+ */
369
+ async function sweepStaleWorktreeCheckouts(kind, opts = {}) {
370
+ const root = worktreeRootFor(kind);
371
+ const staleAgeMs = Number.isFinite(opts.staleAgeMs) && opts.staleAgeMs >= 0
372
+ ? opts.staleAgeMs
373
+ : getStaleSweepAgeMs(kind);
374
+ const result = { checkoutsRemoved: 0, emptyRootsRemoved: 0 };
375
+
376
+ let hashEntries;
377
+ try {
378
+ hashEntries = await fsp.readdir(root, { withFileTypes: true });
379
+ } catch {
380
+ return result; // root doesn't exist yet (or unreadable) — nothing to sweep
381
+ }
382
+
383
+ for (const hashEntry of hashEntries) {
384
+ if (!hashEntry.isDirectory()) continue;
385
+ const hashDir = path.join(root, hashEntry.name);
386
+ if (!isUnderRoot(hashDir, root)) continue;
387
+
388
+ let keyEntries;
389
+ try {
390
+ keyEntries = await fsp.readdir(hashDir, { withFileTypes: true });
391
+ } catch {
392
+ continue;
393
+ }
394
+
395
+ for (const keyEntry of keyEntries) {
396
+ if (!keyEntry.isDirectory()) continue;
397
+ const checkoutDir = path.join(hashDir, keyEntry.name);
398
+ if (!isUnderRoot(checkoutDir, root)) continue;
399
+
400
+ let stat;
401
+ try {
402
+ stat = await fsp.stat(checkoutDir);
403
+ } catch {
404
+ continue;
405
+ }
406
+ if (Date.now() - stat.mtimeMs < staleAgeMs) continue; // still fresh — never touched
407
+
408
+ try {
409
+ await teardownOrphanedCheckout(checkoutDir);
410
+ result.checkoutsRemoved++;
411
+ } catch {
412
+ /* best-effort */
413
+ }
414
+ }
415
+
416
+ try {
417
+ const remaining = await fsp.readdir(hashDir);
418
+ if (remaining.length === 0) {
419
+ await fsp.rmdir(hashDir);
420
+ result.emptyRootsRemoved++;
421
+ }
422
+ } catch {
423
+ // Not empty (a fresh checkout survives, or one this pass just skipped),
424
+ // doesn't exist, or a race — all fine to ignore.
425
+ }
426
+ }
427
+
428
+ return result;
429
+ }
430
+
431
+ /**
432
+ * Capture the base tree's outstanding TRACKED diff (`git diff HEAD
433
+ * --binary` — read-only, never stages or touches `cwd`) and apply it inside
434
+ * the freshly created worktree `dir`, so a job started against a dirty base
435
+ * tree sees the SAME content it would have seen running in place, without
436
+ * the base tree ever being written to. Returns `{ ok: true, paths }` or
437
+ * `{ ok: false, reason }`; never throws.
438
+ *
439
+ * This is deliberately read-context, not work-product: the authoritative
440
+ * copy of the human's WIP stays in `cwd` the whole time. Nothing here ever
441
+ * `git add`s or commits the carried paths on the job's behalf, and nothing
442
+ * ever restores them back into `cwd` — they were never removed from it.
443
+ */
444
+ async function captureAndCarryBaseDiff({ cwd, dir }) {
445
+ let paths = [];
446
+ let patch = '';
447
+ try {
448
+ const nameOut = await execGit(['diff', 'HEAD', '--name-only'], { cwd, timeout: 15_000 });
449
+ paths = nameOut.split('\n').map((l) => l.trim()).filter(Boolean);
450
+ patch = await execGit(['diff', 'HEAD', '--binary'], { cwd, timeout: 30_000 });
451
+ } catch (e) {
452
+ return { ok: false, reason: `capturing base diff failed: ${(e && (e.stderrText || e.message)) || e}` };
453
+ }
454
+ if (!patch || !patch.trim()) return { ok: true, paths: [] };
455
+
456
+ const patchFile = path.join(os.tmpdir(), `sm-worktree-carry-${crypto.randomBytes(8).toString('hex')}.patch`);
457
+ try {
458
+ await fsp.writeFile(patchFile, patch, 'utf8');
459
+ await execGit(['apply', '--binary', patchFile], { cwd: dir, timeout: 30_000 });
460
+ } catch (e) {
461
+ return { ok: false, reason: `git apply failed: ${(e && (e.stderrText || e.message)) || e}` };
462
+ } finally {
463
+ try { await fsp.rm(patchFile, { force: true }); } catch { /* best-effort */ }
464
+ }
465
+ return { ok: true, paths };
206
466
  }
207
467
 
208
468
  /**
209
469
  * Create a linked worktree on a fresh branch checked out from the main
210
- * tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd }` on
211
- * success, or `{ ok: false, reason }` — the reason is always a short,
212
- * human-readable string meant to be logged verbatim so a fallback to running
213
- * in place is never silent.
470
+ * tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd,
471
+ * carriedPaths }` on success, or `{ ok: false, reason }` — the reason is
472
+ * always a short, human-readable string meant to be logged verbatim so a
473
+ * fallback to running in place is never silent.
214
474
  *
215
- * Never throws: every failure mode (not a repo, dirty base, cap reached, git
216
- * error) is a normal, expected outcome for a project that hasn't opted into
217
- * — or currently can't support — isolation, not an exceptional one.
475
+ * A dirty base tree (tracked modifications) no longer disables isolation —
476
+ * a worktree only ever checks out committed HEAD content, so the base
477
+ * tree's outstanding diff is captured and applied inside the fresh worktree
478
+ * (`captureAndCarryBaseDiff`) instead: the job sees the same content it
479
+ * would have seen running in place, while the shared base tree is never
480
+ * written to and stays untouchable for the run's whole duration. If that
481
+ * capture/apply fails for any reason (binary conflict, non-zero `git
482
+ * apply`), creation degrades to the OLD behaviour — the worktree is torn
483
+ * down and this returns `{ ok: false, reason: 'carry-over of base WIP
484
+ * failed: ...' }` — so this change can never turn a working fallback into a
485
+ * hard failure. `carriedPaths` (possibly empty) is threaded back so callers
486
+ * can exclude the human's carried WIP from branch integration.
487
+ *
488
+ * Never throws: every failure mode (not a repo, cap reached, carry-over
489
+ * failure, git error) is a normal, expected outcome for a project that
490
+ * hasn't opted into — or currently can't support — isolation, not an
491
+ * exceptional one.
218
492
  */
219
493
  async function createWorktree({ kind, cwd, key }) {
220
494
  configFor(kind); // throws on an unknown kind before anything else runs
@@ -224,12 +498,7 @@ async function createWorktree({ kind, cwd, key }) {
224
498
 
225
499
  if (!(await isGitRepo(cwd))) return { ok: false, reason: 'not a git repository' };
226
500
 
227
- // A dirty base tree means the caller may be depending on the human's own
228
- // uncommitted WIP in `cwd` — a worktree only ever checks out committed
229
- // HEAD content, so isolating into one here would silently drop that WIP
230
- // from what it sees. Falling back to running in place is strictly safer
231
- // than guessing.
232
- if (!(await isBaseTreeClean(cwd))) return { ok: false, reason: 'base working tree has uncommitted changes' };
501
+ const baseWasClean = await isBaseTreeClean(cwd);
233
502
 
234
503
  if (activeWorktreeCount[kind] >= getMaxConcurrentWorktrees(kind)) {
235
504
  return { ok: false, reason: `worktree cap reached (${getMaxConcurrentWorktrees(kind)} concurrent)` };
@@ -256,7 +525,23 @@ async function createWorktree({ kind, cwd, key }) {
256
525
  activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
257
526
  return { ok: false, reason: `git worktree add failed: ${(e && (e.stderrText || e.message)) || e}` };
258
527
  }
259
- return { ok: true, dir, branch, baseCwd: cwd };
528
+
529
+ let carriedPaths = [];
530
+ if (!baseWasClean) {
531
+ // Routed through module.exports (not the bare local function) so tests
532
+ // can substitute a failing carry-over without needing a real git-apply
533
+ // conflict fixture — see gitWorktree.test.cjs's carry-over-failure case.
534
+ const carry = await module.exports.captureAndCarryBaseDiff({ cwd, dir });
535
+ if (!carry.ok) {
536
+ await removeWorktreeDir(cwd, dir);
537
+ try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* best-effort */ }
538
+ activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
539
+ return { ok: false, reason: `carry-over of base WIP failed: ${carry.reason}` };
540
+ }
541
+ carriedPaths = carry.paths;
542
+ }
543
+
544
+ return { ok: true, dir, branch, baseCwd: cwd, carriedPaths };
260
545
  }
261
546
 
262
547
  /**
@@ -269,8 +554,16 @@ async function createWorktree({ kind, cwd, key }) {
269
554
  * e.g. a genuine content conflict. On failure the branch is left un-merged
270
555
  * and NOT deleted (see cleanupWorktree) so the work is recoverable, never
271
556
  * silently discarded.
557
+ *
558
+ * `carriedPaths` (optional — from createWorktree's WIP carry-over) skips the
559
+ * merge entirely, returning `{ ok: true, integrated: false, reason:
560
+ * 'carried-wip-only' }`, when the branch's ONLY committed changes touch
561
+ * paths that were carried in as the human's base-tree WIP rather than the
562
+ * job's own work — landing such a commit would just re-apply the human's
563
+ * uncommitted edit back onto itself via a merge, and could conflict with
564
+ * the base tree still holding that same path dirty.
272
565
  */
273
- async function integrateBranch({ cwd, branch, key, kind }) {
566
+ async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
274
567
  if (!cwd || !branch) return { ok: false, reason: 'missing cwd/branch' };
275
568
  let branchHead;
276
569
  try {
@@ -288,6 +581,46 @@ async function integrateBranch({ cwd, branch, key, kind }) {
288
581
  return { ok: true, integrated: false, reason: 'branch has no new commits' };
289
582
  }
290
583
 
584
+ if (Array.isArray(carriedPaths) && carriedPaths.length) {
585
+ try {
586
+ const changedOut = await execGit(['diff', `${mergeBase || 'HEAD'}..${branch}`, '--name-only'], { cwd, timeout: 10_000 });
587
+ const changed = changedOut.split('\n').map((l) => l.trim()).filter(Boolean);
588
+ if (changed.length && changed.every((p) => carriedPaths.includes(p))) {
589
+ // Path membership alone is NOT enough: a job that legitimately edits
590
+ // the SAME file the base tree had carried-in WIP on (e.g. this
591
+ // repo's own scheduler.cjs churns queue.json/active-index.json while
592
+ // jobs run) would otherwise have its real commit misclassified as
593
+ // "just the carried WIP" and dropped — the caller treats
594
+ // `integrated: false` as safe-to-delete-the-branch, so that commit
595
+ // would be gone for good, worse than the ordinary merge-conflict
596
+ // path (branch kept, flagged for manual recovery) this shortcut is
597
+ // supposed to be a safe subset of. Content-verify: only when every
598
+ // changed path's committed blob on `branch` is byte-identical to
599
+ // what's STILL sitting dirty in `cwd` right now proves the job
600
+ // committed exactly the carried WIP and nothing more. Any mismatch
601
+ // (including a read failure — fail toward the safer default) falls
602
+ // through to the normal merge attempt below instead of skipping.
603
+ let allIdentical = true;
604
+ for (const p of changed) {
605
+ try {
606
+ const branchContent = await execGit(['show', `${branch}:${p}`], { cwd, timeout: 10_000 });
607
+ const baseContent = await fsp.readFile(path.join(cwd, p), 'utf8');
608
+ if (branchContent !== baseContent) { allIdentical = false; break; }
609
+ } catch {
610
+ allIdentical = false;
611
+ break;
612
+ }
613
+ }
614
+ if (allIdentical) {
615
+ return { ok: true, integrated: false, reason: 'carried-wip-only' };
616
+ }
617
+ }
618
+ } catch {
619
+ // Best-effort classification only — if the diff can't be read, fall
620
+ // through to the normal integration attempt below.
621
+ }
622
+ }
623
+
291
624
  try {
292
625
  await execGit(['merge', '--ff-only', branch], { cwd, timeout: 30_000 });
293
626
  return { ok: true, integrated: true, fastForward: true };
@@ -365,6 +698,83 @@ async function salvageWorktreeDiff({ cwd, outFile }) {
365
698
  }
366
699
  }
367
700
 
701
+ /**
702
+ * Best-effort dump of a caller-supplied DELTA of paths — never the whole
703
+ * tree — to `outFile`. This is the shared-tree counterpart to
704
+ * salvageWorktreeDiff above: a job that ran IN PLACE (no throwaway worktree
705
+ * checkout to diff and discard) shares `cwd` with a human's own WIP and any
706
+ * sibling job, so it must never touch anything outside the exact paths the
707
+ * caller says this job itself dirtied (typically: `git status` at exit,
708
+ * minus a pre-run baseline snapshot).
709
+ *
710
+ * Deliberately does NOT reuse salvageWorktreeDiff's `git add -A
711
+ * --intent-to-add` — that stages the ENTIRE tree's untracked files into the
712
+ * shared index, which is exactly the kind of blanket mutation this function
713
+ * exists to avoid. Instead: tracked/modified delta paths are read via `git
714
+ * diff HEAD --binary -- <paths>` (never stages anything), and untracked
715
+ * delta paths are read individually via `git diff --no-index --binary --
716
+ * /dev/null <path>` (also never touches the index) — `--no-index` exits
717
+ * non-zero to mean "found a difference", which execGit rejects, so its
718
+ * stdout is recovered off the rejected error via `err.stdoutText`.
719
+ *
720
+ * Never throws, and never mutates `cwd` in any way (no add/stash/reset/
721
+ * checkout/clean) — a caller running this against a tree shared with a
722
+ * human or a sibling job must be able to trust `git status --porcelain` and
723
+ * the stash list are byte-identical before and after. Writes nothing (and
724
+ * returns `{ ok: false }`) when none of the given paths are actually dirty,
725
+ * or on any git failure, so a run with nothing to salvage never leaves a
726
+ * 0-byte artifact behind.
727
+ */
728
+ async function salvageDirtyDelta({ cwd, paths, outFile }) {
729
+ try {
730
+ const list = Array.isArray(paths) ? [...new Set(paths.filter(Boolean))] : [];
731
+ if (!list.length) return { ok: false };
732
+
733
+ const statusOut = await execGit(['status', '--porcelain', '--', ...list], { cwd, timeout: 15_000 }).catch(() => '');
734
+ const trackedPaths = [];
735
+ const untrackedPaths = [];
736
+ for (const line of String(statusOut || '').split('\n')) {
737
+ if (!line) continue;
738
+ const code = line.slice(0, 2);
739
+ const p = line.slice(3);
740
+ if (!p) continue;
741
+ if (code === '??') untrackedPaths.push(p);
742
+ else trackedPaths.push(p);
743
+ }
744
+ // Any requested path `git status` didn't report on (already clean by the
745
+ // time this runs — e.g. a race with a concurrent write) is simply
746
+ // skipped, never guessed at.
747
+
748
+ let patch = '';
749
+ if (trackedPaths.length) {
750
+ try {
751
+ const d = await execGit(['diff', 'HEAD', '--binary', '--', ...trackedPaths], { cwd, timeout: 30_000 });
752
+ if (d) patch += d;
753
+ } catch {
754
+ // Best-effort — a diff failure for the tracked set must not block
755
+ // salvaging the untracked set below.
756
+ }
757
+ }
758
+ for (const p of untrackedPaths) {
759
+ try {
760
+ const d = await execGit(['diff', '--no-index', '--binary', '--', '/dev/null', p], { cwd, timeout: 15_000 });
761
+ if (d) patch += d;
762
+ } catch (e) {
763
+ // git diff --no-index exits 1 (not an error) whenever it finds a
764
+ // difference, which is the expected outcome here every time — the
765
+ // real diff text is on stdout despite the non-zero exit.
766
+ if (e && typeof e.stdoutText === 'string' && e.stdoutText) patch += e.stdoutText;
767
+ }
768
+ }
769
+ if (!patch.trim()) return { ok: false };
770
+ const { writeTextAtomic } = require('../config.cjs');
771
+ await writeTextAtomic(outFile, patch);
772
+ return { ok: true, bytes: Buffer.byteLength(patch, 'utf8') };
773
+ } catch {
774
+ return { ok: false };
775
+ }
776
+ }
777
+
368
778
  /** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
369
779
  function parseWorktreeListPorcelain(text) {
370
780
  const entries = [];
@@ -407,6 +817,14 @@ function keyFromBranch(kind, branch) {
407
817
  * equivalent concept (a job worktree found at boot is, by definition, from a
408
818
  * run that didn't finish) and typically omits `isLive`.
409
819
  *
820
+ * After the per-cwd pass, this also runs `sweepStaleWorktreeCheckouts` —
821
+ * a second, root-directory-driven pass that is NOT limited to `cwds`, so a
822
+ * checkout belonging to a project no longer known to the caller (removed
823
+ * from Session Manager, or a leftover from before this sweep existed) still
824
+ * gets reclaimed once it's older than `opts.staleAgeMs` (default per-kind,
825
+ * see `KIND_CONFIG`). A one-shot log line reports the totals so a recurring
826
+ * leak stays visible (RCA: PRD 1108).
827
+ *
410
828
  * Never throws — a project that isn't a git repo, or has no worktrees, is a
411
829
  * silent no-op.
412
830
  */
@@ -415,6 +833,7 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
415
833
  const isLive = typeof opts.isLive === 'function' ? opts.isLive : null;
416
834
  const root = worktreeRootFor(kind);
417
835
  const list = Array.isArray(cwds) ? cwds.filter(Boolean) : [];
836
+ let checkoutsRemoved = 0;
418
837
  for (const cwd of list) {
419
838
  if (!(await isGitRepo(cwd))) continue;
420
839
  let out = '';
@@ -429,12 +848,29 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
429
848
  const key = keyFromBranch(kind, entry.branch);
430
849
  if (isLive && key && (await isLive(key, entry))) continue;
431
850
  await removeWorktreeDir(cwd, entry.worktree);
851
+ checkoutsRemoved++;
432
852
  if (entry.branch) {
433
853
  try { await execGit(['branch', '-D', entry.branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
434
854
  }
435
855
  }
436
856
  try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
437
857
  }
858
+
859
+ let sweep = { checkoutsRemoved: 0, emptyRootsRemoved: 0 };
860
+ try {
861
+ sweep = await sweepStaleWorktreeCheckouts(kind, { staleAgeMs: opts.staleAgeMs });
862
+ } catch {
863
+ /* never let the orphan sweep take down boot reconciliation */
864
+ }
865
+ const totals = {
866
+ checkoutsRemoved: checkoutsRemoved + sweep.checkoutsRemoved,
867
+ emptyRootsRemoved: sweep.emptyRootsRemoved,
868
+ };
869
+ console.log(
870
+ `[gitWorktree] boot sweep (kind:${kind}): reclaimed ${totals.checkoutsRemoved} checkout(s), ` +
871
+ `${totals.emptyRootsRemoved} empty root dir(s)`
872
+ );
873
+ return totals;
438
874
  }
439
875
 
440
876
  // ──────────────────────────────────────────── kind-scoped convenience wrappers
@@ -442,8 +878,8 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
442
878
  async function createJobWorktree({ cwd, slug }) {
443
879
  return createWorktree({ kind: 'job', cwd, key: slug });
444
880
  }
445
- async function integrateJobBranch({ cwd, branch, slug }) {
446
- return integrateBranch({ kind: 'job', cwd, branch, key: slug });
881
+ async function integrateJobBranch({ cwd, branch, slug, carriedPaths }) {
882
+ return integrateBranch({ kind: 'job', cwd, branch, key: slug, carriedPaths });
447
883
  }
448
884
  async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
449
885
  return cleanupWorktree({ kind: 'job', cwd, dir, branch, keepBranch });
@@ -451,6 +887,9 @@ async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
451
887
  async function salvageJobWorktreeDiff({ dir, outFile }) {
452
888
  return salvageWorktreeDiff({ cwd: dir, outFile });
453
889
  }
890
+ async function salvageJobDirtyDelta({ cwd, paths, outFile }) {
891
+ return salvageDirtyDelta({ cwd, paths, outFile });
892
+ }
454
893
 
455
894
  async function createEpicWorktree({ cwd, epicId }) {
456
895
  return createWorktree({ kind: 'epic', cwd, key: epicId });
@@ -467,23 +906,29 @@ module.exports = {
467
906
  worktreeRootFor,
468
907
  isWorktreeDisabled,
469
908
  getMaxConcurrentWorktrees,
909
+ getStaleSweepAgeMs,
470
910
  isGitRepo,
471
911
  isBaseTreeClean,
472
912
  worktreeDirFor,
473
913
  branchNameFor,
474
914
  keyFromBranch,
475
915
  createWorktree,
916
+ captureAndCarryBaseDiff,
476
917
  integrateBranch,
477
918
  cleanupWorktree,
478
919
  salvageWorktreeDiff,
920
+ salvageDirtyDelta,
479
921
  parseWorktreeListPorcelain,
480
922
  reconcileWorktreesOnBoot,
923
+ sweepStaleWorktreeCheckouts,
924
+ mainTreeFromWorktreeGitFile,
481
925
  // Job-kind convenience wrappers — same call shape jobWorktree.cjs has
482
926
  // always exposed.
483
927
  createJobWorktree,
484
928
  integrateJobBranch,
485
929
  cleanupJobWorktree,
486
930
  salvageJobWorktreeDiff,
931
+ salvageJobDirtyDelta,
487
932
  // Epic-kind convenience wrappers.
488
933
  createEpicWorktree,
489
934
  integrateEpicBranch,
@@ -63,6 +63,7 @@ module.exports = {
63
63
  integrateJobBranch: gitWorktree.integrateJobBranch,
64
64
  cleanupJobWorktree: gitWorktree.cleanupJobWorktree,
65
65
  salvageJobWorktreeDiff: gitWorktree.salvageJobWorktreeDiff,
66
+ salvageJobDirtyDelta: gitWorktree.salvageJobDirtyDelta,
66
67
  parseWorktreeListPorcelain: gitWorktree.parseWorktreeListPorcelain,
67
68
  reconcileWorktreesOnBoot: (cwds) => gitWorktree.reconcileWorktreesOnBoot(cwds, { kind: KIND }),
68
69
  // Test-only escape hatch for the in-memory concurrency counter.