claude-code-session-manager 0.75.3 → 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 (164) hide show
  1. package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
  2. package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
  3. package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
  4. package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
  5. package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
  6. package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
  7. package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
  9. package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
  10. package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
  11. package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
  12. package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
  13. package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BP_evfxE.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-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
  18. package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
  20. package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
  21. package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
  22. package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
  23. package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
  24. package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
  25. package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
  26. package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
  27. package/dist/assets/{settingsSchema-D6wzxAi6.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/lib/activeSessions.cjs +116 -6
  32. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  33. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  34. package/scripts/render-project-pages.cjs +70 -0
  35. package/scripts/scheduler-mcp-server.cjs +269 -96
  36. package/scripts/validate-project-pages-summary.cjs +62 -0
  37. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  38. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  39. package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
  40. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  41. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  42. package/src/main/__tests__/prdCreate.test.cjs +247 -0
  43. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  44. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  45. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  46. package/src/main/__tests__/projectPages.test.cjs +73 -1
  47. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  48. package/src/main/__tests__/runVerify.test.cjs +94 -0
  49. package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
  50. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  51. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  52. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  54. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  55. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  56. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  57. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  58. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  59. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  60. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  61. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
  62. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  63. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  64. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  65. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  66. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  67. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  68. package/src/main/bilkoHost.cjs +4 -3
  69. package/src/main/chatRunner.cjs +6 -1
  70. package/src/main/config.cjs +25 -33
  71. package/src/main/health.cjs +153 -2
  72. package/src/main/index.cjs +64 -5
  73. package/src/main/ipcSchemas.cjs +69 -1
  74. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  75. package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
  76. package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
  77. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  78. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
  79. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  80. package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
  81. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  82. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  83. package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
  84. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  85. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  86. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  87. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
  88. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  89. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
  90. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  91. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  92. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  93. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  94. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  95. package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
  96. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  97. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  98. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  99. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  100. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  101. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  102. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  103. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  104. package/src/main/lib/activeIndexMerge.cjs +15 -0
  105. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  106. package/src/main/lib/agentModelResolve.cjs +58 -0
  107. package/src/main/lib/buildTarget.cjs +3 -2
  108. package/src/main/lib/childWithLog.cjs +69 -2
  109. package/src/main/lib/claudeBin.cjs +54 -1
  110. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  111. package/src/main/lib/definitionOfDone.cjs +3 -2
  112. package/src/main/lib/delegationReadiness.cjs +514 -26
  113. package/src/main/lib/ephemeralCwd.cjs +78 -0
  114. package/src/main/lib/epicDelegationStats.cjs +2 -1
  115. package/src/main/lib/epicMint.cjs +17 -1
  116. package/src/main/lib/epicStatusMirror.cjs +95 -0
  117. package/src/main/lib/epicValidationHook.cjs +2 -1
  118. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  119. package/src/main/lib/fixChainDepth.cjs +45 -0
  120. package/src/main/lib/gitWorktree.cjs +520 -21
  121. package/src/main/lib/jobWorktree.cjs +2 -0
  122. package/src/main/lib/landedSinceRun.cjs +55 -0
  123. package/src/main/lib/launchFailure.cjs +357 -0
  124. package/src/main/lib/loadGate.cjs +134 -0
  125. package/src/main/lib/mcpToolCatalog.cjs +370 -0
  126. package/src/main/lib/opsErrorLog.cjs +12 -1
  127. package/src/main/lib/opsOwnership.cjs +106 -0
  128. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  129. package/src/main/lib/prdAgentType.cjs +84 -0
  130. package/src/main/lib/prdCreate.cjs +103 -15
  131. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  132. package/src/main/lib/prdFrontmatter.cjs +17 -3
  133. package/src/main/lib/prdLocations.cjs +13 -6
  134. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  135. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  136. package/src/main/lib/projectRootResolve.cjs +134 -0
  137. package/src/main/lib/promptSessionSchema.cjs +7 -0
  138. package/src/main/lib/queueHealth.cjs +38 -0
  139. package/src/main/lib/queueStore.cjs +40 -7
  140. package/src/main/lib/quietMachineLease.cjs +48 -0
  141. package/src/main/lib/rcaReport.cjs +54 -4
  142. package/src/main/lib/reaperHelpers.cjs +64 -1
  143. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  144. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  145. package/src/main/lib/schedulerBatch.cjs +301 -55
  146. package/src/main/lib/schedulerConfig.cjs +99 -0
  147. package/src/main/projectBrief.cjs +3 -2
  148. package/src/main/projectPages.cjs +162 -3
  149. package/src/main/promptSessionTranscript.cjs +0 -0
  150. package/src/main/pty.cjs +5 -0
  151. package/src/main/queueOps.cjs +15 -8
  152. package/src/main/runVerify.cjs +50 -9
  153. package/src/main/scheduler/prdParser.cjs +18 -1
  154. package/src/main/scheduler.cjs +1701 -130
  155. package/src/main/seedAgentPersonas.cjs +62 -21
  156. package/src/main/seedSchedulerMcp.cjs +58 -4
  157. package/src/main/templates/project-pages-catalog.json +741 -0
  158. package/src/main/templates/project-pages-pipeline.md +417 -0
  159. package/src/preload/api.d.ts +187 -3
  160. package/src/preload/index.cjs +9 -0
  161. package/src/seed/agents/project-home-builder.md +59 -0
  162. package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
  163. package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
  164. package/dist/assets/TagLibrary-E5CLeuVk.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
  }
@@ -157,10 +202,21 @@ async function isGitRepo(cwd) {
157
202
  }
158
203
  }
159
204
 
160
- /** True when `cwd`'s own working tree (not any worktree) has zero pending changes. */
205
+ /**
206
+ * True when `cwd`'s own working tree (not any worktree) has zero pending
207
+ * changes to TRACKED files (modified or staged). Untracked files are
208
+ * deliberately excluded (`--untracked-files=no`): an untracked file is never
209
+ * tracked WIP a job depends on, and `git worktree add` checks out only
210
+ * committed HEAD content into the new worktree — it never touches, copies, or
211
+ * is affected by the base tree's untracked files either way. Counting them as
212
+ * "dirty" here bought no safety: one stray scratch file in a project silently
213
+ * disabled isolation for every job in that project, permanently, since a
214
+ * failed-to-commit job leaves the tree dirty and re-triggers the same
215
+ * fallback for every subsequent job (RCA: PRD 1064, starry-night-ships).
216
+ */
161
217
  async function isBaseTreeClean(cwd) {
162
218
  try {
163
- const out = await execGit(['status', '--porcelain'], { cwd, timeout: 10_000 });
219
+ const out = await execGit(['status', '--porcelain', '--untracked-files=no'], { cwd, timeout: 10_000 });
164
220
  return out.trim().length === 0;
165
221
  } catch {
166
222
  return false;
@@ -179,7 +235,16 @@ function branchNameFor(kind, key) {
179
235
  return `${configFor(kind).branchPrefix}${key}`;
180
236
  }
181
237
 
182
- /** 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
+ */
183
248
  async function removeWorktreeDir(cwd, dir) {
184
249
  try {
185
250
  await execGit(['worktree', 'remove', '--force', dir], { cwd, timeout: 15_000 });
@@ -192,18 +257,238 @@ async function removeWorktreeDir(cwd, dir) {
192
257
  } catch {
193
258
  /* best-effort */
194
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 };
195
466
  }
196
467
 
197
468
  /**
198
469
  * Create a linked worktree on a fresh branch checked out from the main
199
- * tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd }` on
200
- * success, or `{ ok: false, reason }` — the reason is always a short,
201
- * human-readable string meant to be logged verbatim so a fallback to running
202
- * 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.
474
+ *
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.
203
487
  *
204
- * Never throws: every failure mode (not a repo, dirty base, cap reached, git
205
- * error) is a normal, expected outcome for a project that hasn't opted into
206
- * — or currently can't support — isolation, not an exceptional one.
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.
207
492
  */
208
493
  async function createWorktree({ kind, cwd, key }) {
209
494
  configFor(kind); // throws on an unknown kind before anything else runs
@@ -213,12 +498,7 @@ async function createWorktree({ kind, cwd, key }) {
213
498
 
214
499
  if (!(await isGitRepo(cwd))) return { ok: false, reason: 'not a git repository' };
215
500
 
216
- // A dirty base tree means the caller may be depending on the human's own
217
- // uncommitted WIP in `cwd` — a worktree only ever checks out committed
218
- // HEAD content, so isolating into one here would silently drop that WIP
219
- // from what it sees. Falling back to running in place is strictly safer
220
- // than guessing.
221
- if (!(await isBaseTreeClean(cwd))) return { ok: false, reason: 'base working tree has uncommitted changes' };
501
+ const baseWasClean = await isBaseTreeClean(cwd);
222
502
 
223
503
  if (activeWorktreeCount[kind] >= getMaxConcurrentWorktrees(kind)) {
224
504
  return { ok: false, reason: `worktree cap reached (${getMaxConcurrentWorktrees(kind)} concurrent)` };
@@ -245,7 +525,23 @@ async function createWorktree({ kind, cwd, key }) {
245
525
  activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
246
526
  return { ok: false, reason: `git worktree add failed: ${(e && (e.stderrText || e.message)) || e}` };
247
527
  }
248
- 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 };
249
545
  }
250
546
 
251
547
  /**
@@ -258,8 +554,16 @@ async function createWorktree({ kind, cwd, key }) {
258
554
  * e.g. a genuine content conflict. On failure the branch is left un-merged
259
555
  * and NOT deleted (see cleanupWorktree) so the work is recoverable, never
260
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.
261
565
  */
262
- async function integrateBranch({ cwd, branch, key, kind }) {
566
+ async function integrateBranch({ cwd, branch, key, kind, carriedPaths }) {
263
567
  if (!cwd || !branch) return { ok: false, reason: 'missing cwd/branch' };
264
568
  let branchHead;
265
569
  try {
@@ -277,6 +581,46 @@ async function integrateBranch({ cwd, branch, key, kind }) {
277
581
  return { ok: true, integrated: false, reason: 'branch has no new commits' };
278
582
  }
279
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
+
280
624
  try {
281
625
  await execGit(['merge', '--ff-only', branch], { cwd, timeout: 30_000 });
282
626
  return { ok: true, integrated: true, fastForward: true };
@@ -316,6 +660,121 @@ async function cleanupWorktree({ kind, cwd, dir, branch, keepBranch }) {
316
660
  activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
317
661
  }
318
662
 
663
+ /**
664
+ * Best-effort dump of a worktree's full outstanding diff (tracked
665
+ * modifications AND untracked files) to `outFile`, for a caller about to
666
+ * tear the worktree down. A job killed before its finish-protocol commit
667
+ * (rate limit, timeout, crash, hard kill) otherwise loses that work
668
+ * outright the moment `cleanupWorktree` removes the checkout — no branch,
669
+ * no stash, no patch survives it. `git add -A --intent-to-add` stages
670
+ * untracked paths (empty blobs) without touching their content, so the
671
+ * subsequent `git diff HEAD --binary` includes their full contents exactly
672
+ * like a tracked modification; the result is a patch a later `git apply`
673
+ * can consume verbatim.
674
+ *
675
+ * Never throws — this always runs immediately before teardown and must
676
+ * never block branch integration/cleanup or change a job's verdict. Writes
677
+ * nothing (and returns `{ ok: false }`) when the worktree is clean or on
678
+ * any git failure, so a successful run's teardown never leaves a 0-byte
679
+ * artifact behind.
680
+ */
681
+ async function salvageWorktreeDiff({ cwd, outFile }) {
682
+ try {
683
+ const status = await execGit(['status', '--porcelain'], { cwd, timeout: 15_000 });
684
+ if (!status.trim()) return { ok: false };
685
+ try {
686
+ await execGit(['add', '-A', '--intent-to-add'], { cwd, timeout: 30_000 });
687
+ } catch {
688
+ // Best-effort — the diff below still captures whatever staged/tracked
689
+ // changes exist even if intent-to-add partially failed.
690
+ }
691
+ const patch = await execGit(['diff', 'HEAD', '--binary'], { cwd, timeout: 30_000 });
692
+ if (!patch || !patch.trim()) return { ok: false };
693
+ const { writeTextAtomic } = require('../config.cjs');
694
+ await writeTextAtomic(outFile, patch);
695
+ return { ok: true, bytes: Buffer.byteLength(patch, 'utf8') };
696
+ } catch {
697
+ return { ok: false };
698
+ }
699
+ }
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
+
319
778
  /** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
320
779
  function parseWorktreeListPorcelain(text) {
321
780
  const entries = [];
@@ -358,6 +817,14 @@ function keyFromBranch(kind, branch) {
358
817
  * equivalent concept (a job worktree found at boot is, by definition, from a
359
818
  * run that didn't finish) and typically omits `isLive`.
360
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
+ *
361
828
  * Never throws — a project that isn't a git repo, or has no worktrees, is a
362
829
  * silent no-op.
363
830
  */
@@ -366,6 +833,7 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
366
833
  const isLive = typeof opts.isLive === 'function' ? opts.isLive : null;
367
834
  const root = worktreeRootFor(kind);
368
835
  const list = Array.isArray(cwds) ? cwds.filter(Boolean) : [];
836
+ let checkoutsRemoved = 0;
369
837
  for (const cwd of list) {
370
838
  if (!(await isGitRepo(cwd))) continue;
371
839
  let out = '';
@@ -380,12 +848,29 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
380
848
  const key = keyFromBranch(kind, entry.branch);
381
849
  if (isLive && key && (await isLive(key, entry))) continue;
382
850
  await removeWorktreeDir(cwd, entry.worktree);
851
+ checkoutsRemoved++;
383
852
  if (entry.branch) {
384
853
  try { await execGit(['branch', '-D', entry.branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
385
854
  }
386
855
  }
387
856
  try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
388
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;
389
874
  }
390
875
 
391
876
  // ──────────────────────────────────────────── kind-scoped convenience wrappers
@@ -393,12 +878,18 @@ async function reconcileWorktreesOnBoot(cwds, opts = {}) {
393
878
  async function createJobWorktree({ cwd, slug }) {
394
879
  return createWorktree({ kind: 'job', cwd, key: slug });
395
880
  }
396
- async function integrateJobBranch({ cwd, branch, slug }) {
397
- 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 });
398
883
  }
399
884
  async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
400
885
  return cleanupWorktree({ kind: 'job', cwd, dir, branch, keepBranch });
401
886
  }
887
+ async function salvageJobWorktreeDiff({ dir, outFile }) {
888
+ return salvageWorktreeDiff({ cwd: dir, outFile });
889
+ }
890
+ async function salvageJobDirtyDelta({ cwd, paths, outFile }) {
891
+ return salvageDirtyDelta({ cwd, paths, outFile });
892
+ }
402
893
 
403
894
  async function createEpicWorktree({ cwd, epicId }) {
404
895
  return createWorktree({ kind: 'epic', cwd, key: epicId });
@@ -415,21 +906,29 @@ module.exports = {
415
906
  worktreeRootFor,
416
907
  isWorktreeDisabled,
417
908
  getMaxConcurrentWorktrees,
909
+ getStaleSweepAgeMs,
418
910
  isGitRepo,
419
911
  isBaseTreeClean,
420
912
  worktreeDirFor,
421
913
  branchNameFor,
422
914
  keyFromBranch,
423
915
  createWorktree,
916
+ captureAndCarryBaseDiff,
424
917
  integrateBranch,
425
918
  cleanupWorktree,
919
+ salvageWorktreeDiff,
920
+ salvageDirtyDelta,
426
921
  parseWorktreeListPorcelain,
427
922
  reconcileWorktreesOnBoot,
923
+ sweepStaleWorktreeCheckouts,
924
+ mainTreeFromWorktreeGitFile,
428
925
  // Job-kind convenience wrappers — same call shape jobWorktree.cjs has
429
926
  // always exposed.
430
927
  createJobWorktree,
431
928
  integrateJobBranch,
432
929
  cleanupJobWorktree,
930
+ salvageJobWorktreeDiff,
931
+ salvageJobDirtyDelta,
433
932
  // Epic-kind convenience wrappers.
434
933
  createEpicWorktree,
435
934
  integrateEpicBranch,