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
@@ -0,0 +1,40 @@
1
+ /**
2
+ * fixChainDepth.test.cjs — unit tests for the pure fix-chain depth counter
3
+ * (PRD 1113). Run: timeout 120 npx vitest run src/main/lib/__tests__/fixChainDepth.test.cjs
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ import { test, expect } from 'vitest';
9
+ const { fixChainDepthOf, baseSlugOf } = require('../fixChainDepth.cjs');
10
+
11
+ test('fixChainDepthOf counts consecutive -fix- segments after the leading NN- prefix', () => {
12
+ const cases = [
13
+ // [slug, expected depth]
14
+ ['113-foo', 0],
15
+ ['113-fix-foo', 1],
16
+ ['113-fix-fix-foo', 2],
17
+ ['113-fix-fix-fix-foo', 3],
18
+ // false-positive bait: a later segment merely reading 'fix' does not
19
+ // extend the run once a non-'fix' segment has broken it.
20
+ ['113-fix-loop-extraction-and-round-trip', 1],
21
+ ['22-fix-loop-galaxy-round-trip', 1],
22
+ // false-positive bait: a segment that only contains 'fix' as a substring
23
+ // (not an exact 'fix' segment) is never counted at all.
24
+ ['113-prefix-foo', 0],
25
+ ['113-prefix-fix-foo', 0],
26
+ // no fix chain, multi-digit NN, single-segment slug.
27
+ ['1-widget', 0],
28
+ ];
29
+ for (const [slug, expected] of cases) {
30
+ expect(fixChainDepthOf(slug)).toBe(expected);
31
+ }
32
+ });
33
+
34
+ test('baseSlugOf strips the leading fix chain, keeping the NN prefix', () => {
35
+ expect(baseSlugOf('113-foo')).toBe('113-foo');
36
+ expect(baseSlugOf('113-fix-foo')).toBe('113-foo');
37
+ expect(baseSlugOf('113-fix-fix-foo')).toBe('113-foo');
38
+ expect(baseSlugOf('113-fix-fix-fix-foo')).toBe('113-foo');
39
+ expect(baseSlugOf('113-fix-loop-extraction-and-round-trip')).toBe('113-loop-extraction-and-round-trip');
40
+ });
@@ -11,7 +11,7 @@
11
11
 
12
12
  'use strict';
13
13
 
14
- import { test, expect, beforeEach, afterEach } from 'vitest';
14
+ import { test, expect, beforeEach, afterEach, vi } from 'vitest';
15
15
  const fs = require('node:fs');
16
16
  const os = require('node:os');
17
17
  const path = require('node:path');
@@ -160,6 +160,90 @@ test('[job] merge-commit message text is byte-for-byte unchanged from the pre-ge
160
160
  await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
161
161
  });
162
162
 
163
+ test('[job] integration is skipped (carried-wip-only) when the branch only commits the carried base WIP, and the base tree stays untouched', async () => {
164
+ const slug = 'test-slug-carried-wip-only';
165
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
166
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
167
+ expect(worktree.ok).toBe(true);
168
+ expect(worktree.carriedPaths).toEqual(['README.md']);
169
+
170
+ // The job commits exactly the carried content, doing no other work.
171
+ git(['add', '-A'], worktree.dir);
172
+ git(['commit', '-q', '-m', 'commit carried README'], worktree.dir);
173
+
174
+ const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
175
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
176
+ expect(outcome.ok).toBe(true);
177
+ expect(outcome.integrated).toBe(false);
178
+ expect(outcome.reason).toBe('carried-wip-only');
179
+
180
+ const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
181
+ expect(headAfter).toBe(headBefore);
182
+ // The base tree's own uncommitted README edit is untouched — never
183
+ // discarded, never re-applied.
184
+ expect(git(['status', '--porcelain'], repoCwd)).toContain('README.md');
185
+
186
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: !outcome.ok });
187
+ });
188
+
189
+ test('[job] a branch that commits carried paths ALONGSIDE its own work still attempts integration normally (no silent drop)', async () => {
190
+ const slug = 'test-slug-carried-plus-own-work';
191
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
192
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
193
+ expect(worktree.ok).toBe(true);
194
+ expect(worktree.carriedPaths).toEqual(['README.md']);
195
+
196
+ fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'job output\n', 'utf8');
197
+ git(['add', '-A'], worktree.dir);
198
+ git(['commit', '-q', '-m', 'job commit touching carried path + own work'], worktree.dir);
199
+
200
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
201
+ // The base tree still holds README.md dirty, so a real merge attempt here
202
+ // is expected to fail exactly like the existing worktreeIntegrationFailure
203
+ // path — branch preserved, not merged, not silently discarded.
204
+ expect(outcome.ok).toBe(false);
205
+ expect(outcome.reason).toBeTruthy();
206
+
207
+ const branches = git(['branch', '--list', worktree.branch], repoCwd);
208
+ expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
209
+
210
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
211
+ });
212
+
213
+ test('[job] a branch that ADDS its own edit on top of the carried content on the SAME path is never classified carried-wip-only (content-verify, not path-only)', async () => {
214
+ const slug = 'test-slug-carried-same-path-plus-edit';
215
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
216
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
217
+ expect(worktree.ok).toBe(true);
218
+ expect(worktree.carriedPaths).toEqual(['README.md']);
219
+
220
+ // The job's real work lands on the SAME path the base tree had WIP on —
221
+ // committing MORE than just the carried content. A path-only check would
222
+ // wrongly see "only README.md changed, and README.md was carried" and
223
+ // classify this as carried-wip-only, causing the caller to delete this
224
+ // branch (and the job's real commit with it) instead of attempting a
225
+ // normal merge.
226
+ fs.writeFileSync(path.join(worktree.dir, 'README.md'), 'uncommitted tracked edit\njob added this line\n', 'utf8');
227
+ git(['add', '-A'], worktree.dir);
228
+ git(['commit', '-q', '-m', 'job extends the carried README further'], worktree.dir);
229
+
230
+ const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
231
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug, carriedPaths: worktree.carriedPaths });
232
+ // The base tree still holds README.md dirty, so a real merge attempt here
233
+ // is expected to fail exactly like the existing worktreeIntegrationFailure
234
+ // path — branch preserved, not merged, not silently discarded.
235
+ expect(outcome.ok).toBe(false);
236
+ expect(outcome.reason).toBeTruthy();
237
+ expect(outcome.reason).not.toBe('carried-wip-only');
238
+
239
+ const headAfter = git(['rev-parse', 'HEAD'], repoCwd).trim();
240
+ expect(headAfter).toBe(headBefore);
241
+ const branches = git(['branch', '--list', worktree.branch], repoCwd);
242
+ expect(branches).toContain(worktree.branch.replace('sm-job/', ''));
243
+
244
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
245
+ });
246
+
163
247
  test('[job] falls back with a reason for a non-git cwd', async () => {
164
248
  const nonGitDir = path.join(tmpRoot, 'not-a-repo');
165
249
  fs.mkdirSync(nonGitDir, { recursive: true });
@@ -168,11 +252,47 @@ test('[job] falls back with a reason for a non-git cwd', async () => {
168
252
  expect(result.reason).toMatch(/not a git repository/);
169
253
  });
170
254
 
171
- test('[job] falls back with a reason for a dirty base tree (never silently drops uncommitted human WIP)', async () => {
172
- fs.writeFileSync(path.join(repoCwd, 'dirty.txt'), 'uncommitted\n', 'utf8');
255
+ test('[job] a dirty tracked base file is carried into the worktree with identical content, and the base tree is never written to', async () => {
256
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
257
+ const statusBefore = git(['status', '--porcelain'], repoCwd);
258
+ const stashBefore = git(['stash', 'list'], repoCwd);
259
+
173
260
  const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
174
- expect(result.ok).toBe(false);
175
- expect(result.reason).toMatch(/uncommitted changes/);
261
+ expect(result.ok).toBe(true);
262
+ expect(result.carriedPaths).toEqual(['README.md']);
263
+ expect(fs.readFileSync(path.join(result.dir, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
264
+
265
+ const statusAfter = git(['status', '--porcelain'], repoCwd);
266
+ const stashAfter = git(['stash', 'list'], repoCwd);
267
+ expect(statusAfter).toBe(statusBefore);
268
+ expect(stashAfter).toBe(stashBefore);
269
+
270
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
271
+ });
272
+
273
+ test('[job] an untracked-only dirty base file is NOT carried and stays on the existing clean-create path', async () => {
274
+ fs.writeFileSync(path.join(repoCwd, 'scratch.txt'), 'stray scratch file\n', 'utf8');
275
+ const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
276
+ expect(result.ok).toBe(true);
277
+ expect(result.carriedPaths).toEqual([]);
278
+ expect(fs.existsSync(path.join(result.dir, 'scratch.txt'))).toBe(false);
279
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
280
+ });
281
+
282
+ test('[job] carry-over failure (git apply cannot apply) degrades cleanly: worktree torn down, ok:false, active count released', async () => {
283
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
284
+ const spy = vi.spyOn(gitWorktree, 'captureAndCarryBaseDiff').mockResolvedValue({ ok: false, reason: 'simulated git apply failure' });
285
+ try {
286
+ const before = gitWorktree._getActiveWorktreeCountForTests('job');
287
+ const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
288
+ expect(result.ok).toBe(false);
289
+ expect(result.reason).toMatch(/carry-over of base WIP failed/);
290
+ expect(gitWorktree._getActiveWorktreeCountForTests('job')).toBe(before);
291
+ const list = git(['worktree', 'list', '--porcelain'], repoCwd);
292
+ expect(list).not.toContain('sm-job/x');
293
+ } finally {
294
+ spy.mockRestore();
295
+ }
176
296
  });
177
297
 
178
298
  test('[job] falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
@@ -191,6 +311,46 @@ test('[job] falls back once the concurrency cap is reached, and recovers after c
191
311
  await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: third.dir, branch: third.branch });
192
312
  });
193
313
 
314
+ test('[job] getMaxConcurrentWorktrees floors the job cap at the sessionSlots pool size (PRD 1112)', () => {
315
+ const originalSlots = process.env.SM_SESSION_SLOTS;
316
+ try {
317
+ process.env.SM_SESSION_SLOTS = '10';
318
+ expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(10);
319
+
320
+ // The floor never drops BELOW the existing default (4) even when the
321
+ // slot pool is smaller — this cap only ever widens for isolation, never
322
+ // narrows it below what already shipped.
323
+ process.env.SM_SESSION_SLOTS = '3';
324
+ expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(4);
325
+ } finally {
326
+ if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
327
+ else process.env.SM_SESSION_SLOTS = originalSlots;
328
+ }
329
+ });
330
+
331
+ test('[job] an explicit SM_JOB_WORKTREE_MAX wins verbatim over the sessionSlots floor', () => {
332
+ const originalSlots = process.env.SM_SESSION_SLOTS;
333
+ try {
334
+ process.env.SM_SESSION_SLOTS = '10';
335
+ process.env.SM_JOB_WORKTREE_MAX = '2';
336
+ expect(gitWorktree.getMaxConcurrentWorktrees('job')).toBe(2);
337
+ } finally {
338
+ if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
339
+ else process.env.SM_SESSION_SLOTS = originalSlots;
340
+ }
341
+ });
342
+
343
+ test('[epic] getMaxConcurrentWorktrees is unaffected by the sessionSlots pool size', () => {
344
+ const originalSlots = process.env.SM_SESSION_SLOTS;
345
+ try {
346
+ process.env.SM_SESSION_SLOTS = '10';
347
+ expect(gitWorktree.getMaxConcurrentWorktrees('epic')).toBe(50);
348
+ } finally {
349
+ if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
350
+ else process.env.SM_SESSION_SLOTS = originalSlots;
351
+ }
352
+ });
353
+
194
354
  test('[job] SM_JOB_WORKTREE_DISABLE=1 restores in-place behaviour', async () => {
195
355
  process.env.SM_JOB_WORKTREE_DISABLE = '1';
196
356
  const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
@@ -398,3 +558,128 @@ test('keyFromBranch strips the kind-specific prefix and returns null for a forei
398
558
  test('createWorktree throws on an unknown kind', async () => {
399
559
  await expect(gitWorktree.createWorktree({ kind: 'bogus', cwd: repoCwd, key: 'x' })).rejects.toThrow(/unknown kind/);
400
560
  });
561
+
562
+ // ──────────────────────────────────────────── isBaseTreeClean (PRD 1064)
563
+
564
+ test('isBaseTreeClean ignores untracked files — a single stray untracked file must not disable isolation', async () => {
565
+ fs.writeFileSync(path.join(repoCwd, 'scratch_untracked.txt'), 'not tracked\n', 'utf8');
566
+ expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(true);
567
+ });
568
+
569
+ test('isBaseTreeClean still reports dirty for a modified TRACKED file', async () => {
570
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'modified tracked content\n', 'utf8');
571
+ expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(false);
572
+ });
573
+
574
+ // ──────────────────────────────────────────── orphaned on-disk checkouts (PRD 1108)
575
+
576
+ test('[job] cleanupWorktree rmdirs the now-empty parent hash dir, but a sibling checkout in the same hash dir survives', async () => {
577
+ const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'sibling-a' });
578
+ const second = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'sibling-b' });
579
+ expect(first.ok).toBe(true);
580
+ expect(second.ok).toBe(true);
581
+ const hashDir = path.dirname(first.dir);
582
+ expect(path.dirname(second.dir)).toBe(hashDir);
583
+
584
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: first.dir, branch: first.branch });
585
+ expect(fs.existsSync(first.dir)).toBe(false);
586
+ // The hash dir must survive — the sibling checkout still lives under it.
587
+ expect(fs.existsSync(hashDir)).toBe(true);
588
+ expect(fs.existsSync(second.dir)).toBe(true);
589
+
590
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: second.dir, branch: second.branch });
591
+ expect(fs.existsSync(second.dir)).toBe(false);
592
+ // Now that the last sibling is gone, the empty hash dir is reclaimed too.
593
+ expect(fs.existsSync(hashDir)).toBe(false);
594
+ });
595
+
596
+ test('[job] reconcileWorktreesOnBoot reclaims an orphaned on-disk checkout for a project cwd it was never handed', async () => {
597
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-old' });
598
+ expect(worktree.ok).toBe(true);
599
+ gitWorktree._resetActiveWorktreeCountForTests('job', 0);
600
+ // Simulate age: back-date the checkout dir's mtime well past the 1s
601
+ // threshold used below, without touching anything about its content.
602
+ const old = new Date(Date.now() - 60_000);
603
+ fs.utimesSync(worktree.dir, old, old);
604
+
605
+ // No cwd handed in at all — this is the "project no longer tracked" case
606
+ // the per-cwd loop can never reach on its own.
607
+ await gitWorktree.reconcileWorktreesOnBoot([], { kind: 'job', staleAgeMs: 1_000 });
608
+
609
+ expect(fs.existsSync(worktree.dir)).toBe(false);
610
+ // The hash dir it lived under is empty now and must be reclaimed too.
611
+ expect(fs.existsSync(path.dirname(worktree.dir))).toBe(false);
612
+ // The main tree's own worktree registration must not be left dangling.
613
+ const list = git(['worktree', 'list', '--porcelain'], repoCwd);
614
+ expect(list).not.toContain(worktree.dir);
615
+ });
616
+
617
+ test('[job] reconcileWorktreesOnBoot never reclaims a fresh, in-use checkout younger than the age threshold', async () => {
618
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'fresh-in-use' });
619
+ expect(worktree.ok).toBe(true);
620
+ gitWorktree._resetActiveWorktreeCountForTests('job', 0);
621
+
622
+ await gitWorktree.reconcileWorktreesOnBoot([], { kind: 'job', staleAgeMs: 24 * 60 * 60 * 1000 });
623
+
624
+ expect(fs.existsSync(worktree.dir)).toBe(true);
625
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
626
+ });
627
+
628
+ test('[epic] the orphan sweep age threshold defaults much higher (7 days) than the job kind (24h)', () => {
629
+ expect(gitWorktree.getStaleSweepAgeMs('job')).toBe(24 * 60 * 60 * 1000);
630
+ expect(gitWorktree.getStaleSweepAgeMs('epic')).toBe(7 * 24 * 60 * 60 * 1000);
631
+ });
632
+
633
+ test('[epic] SM_EPIC_WORKTREE_STALE_MS overrides the epic orphan-sweep age threshold independently of the job env var', () => {
634
+ const original = process.env.SM_EPIC_WORKTREE_STALE_MS;
635
+ try {
636
+ process.env.SM_EPIC_WORKTREE_STALE_MS = '12345';
637
+ expect(gitWorktree.getStaleSweepAgeMs('epic')).toBe(12345);
638
+ expect(gitWorktree.getStaleSweepAgeMs('job')).toBe(24 * 60 * 60 * 1000);
639
+ } finally {
640
+ if (original === undefined) delete process.env.SM_EPIC_WORKTREE_STALE_MS;
641
+ else process.env.SM_EPIC_WORKTREE_STALE_MS = original;
642
+ }
643
+ });
644
+
645
+ test('[job] sweepStaleWorktreeCheckouts treats staleAgeMs: 0 as "reclaim immediately", not as "unset — use the default"', async () => {
646
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'zero-age' });
647
+ expect(worktree.ok).toBe(true);
648
+ gitWorktree._resetActiveWorktreeCountForTests('job', 0);
649
+
650
+ const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0 });
651
+
652
+ expect(result.checkoutsRemoved).toBe(1);
653
+ expect(fs.existsSync(worktree.dir)).toBe(false);
654
+ });
655
+
656
+ test('[job] sweepStaleWorktreeCheckouts never deletes anything outside its own kind root, even via a symlink shaped like a traversal', async () => {
657
+ const outsideDir = path.join(tmpRoot, 'outside-root');
658
+ fs.mkdirSync(outsideDir, { recursive: true });
659
+ const markerFile = path.join(outsideDir, 'do-not-delete.txt');
660
+ fs.writeFileSync(markerFile, 'still here\n', 'utf8');
661
+
662
+ const root = gitWorktree.worktreeRootFor('job');
663
+ const hashDir = path.join(root, 'traversal-hash-dir');
664
+ fs.mkdirSync(hashDir, { recursive: true });
665
+ const escapePath = path.join(hashDir, 'escape');
666
+ fs.symlinkSync(outsideDir, escapePath, 'dir');
667
+
668
+ const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0 });
669
+
670
+ expect(fs.existsSync(markerFile)).toBe(true);
671
+ expect(fs.existsSync(outsideDir)).toBe(true);
672
+ expect(result.checkoutsRemoved).toBe(0);
673
+
674
+ fs.rmSync(hashDir, { recursive: true, force: true });
675
+ });
676
+
677
+ test('[job] reconcileWorktreesOnBoot logs a one-shot report line with reclaimed counts', async () => {
678
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
679
+ try {
680
+ await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' });
681
+ expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/boot sweep \(kind:job\): reclaimed \d+ checkout\(s\), \d+ empty root dir\(s\)/));
682
+ } finally {
683
+ logSpy.mockRestore();
684
+ }
685
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * gitWorktreeSalvage.test.cjs — PRD 1071: a job worktree's uncommitted diff
3
+ * (tracked + untracked) is salvaged to a patch file before teardown, so a
4
+ * job killed before its finish-protocol commit doesn't lose its work when
5
+ * cleanupWorktree removes the checkout right after.
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/gitWorktreeSalvage.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, beforeEach, afterEach } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const { execFileSync } = require('node:child_process');
17
+ const gitWorktree = require('../gitWorktree.cjs');
18
+
19
+ let tmpRoot;
20
+ let repoCwd;
21
+ // config.cjs's writeTextAtomic (used internally by salvageWorktreeDiff)
22
+ // restricts writes to a fixed allowlist — real prod call sites write under
23
+ // `~/.claude/session-manager/scheduled-plans/runs/...`, which is covered by
24
+ // config.cjs's WRITE_PREFIXES. Mirror that here instead of writing under
25
+ // os.tmpdir() (which validateWrite would reject) — same convention as
26
+ // coldBootPromptSessionsWrite.test.cjs.
27
+ let outDir;
28
+
29
+ function git(args, cwd) {
30
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
31
+ }
32
+
33
+ function initRepo(dir) {
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ git(['init', '-q'], dir);
36
+ git(['config', 'user.email', 'test@example.com'], dir);
37
+ git(['config', 'user.name', 'Test'], dir);
38
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
39
+ git(['add', '-A'], dir);
40
+ git(['commit', '-q', '-m', 'initial'], dir);
41
+ }
42
+
43
+ beforeEach(() => {
44
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-salvage-'));
45
+ repoCwd = path.join(tmpRoot, 'repo');
46
+ initRepo(repoCwd);
47
+ outDir = fs.mkdtempSync(path.join(os.homedir(), '.claude', 'sm-test-salvage-'));
48
+ gitWorktree._resetActiveWorktreeCountForTests('job', 0);
49
+ });
50
+
51
+ afterEach(() => {
52
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
53
+ fs.rmSync(outDir, { recursive: true, force: true });
54
+ });
55
+
56
+ test('dirty worktree (tracked edit + untracked file) produces a non-empty patch with the untracked file\'s contents', async () => {
57
+ const slug = 'salvage-dirty';
58
+ const created = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
59
+ expect(created.ok).toBe(true);
60
+
61
+ fs.writeFileSync(path.join(created.dir, 'README.md'), 'hello\nedited\n', 'utf8');
62
+ fs.writeFileSync(path.join(created.dir, 'new-file.txt'), 'brand new untracked content\n', 'utf8');
63
+
64
+ const outFile = path.join(outDir, `${slug}.uncommitted.patch`);
65
+ const result = await gitWorktree.salvageWorktreeDiff({ cwd: created.dir, outFile });
66
+
67
+ expect(result.ok).toBe(true);
68
+ expect(result.bytes).toBeGreaterThan(0);
69
+ expect(fs.existsSync(outFile)).toBe(true);
70
+ const patch = fs.readFileSync(outFile, 'utf8');
71
+ expect(patch).toContain('new-file.txt');
72
+ expect(patch).toContain('brand new untracked content');
73
+ expect(patch).toContain('README.md');
74
+ expect(patch).toContain('edited');
75
+
76
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: created.dir, branch: created.branch });
77
+ });
78
+
79
+ test('clean worktree produces no patch file (no 0-byte artifact)', async () => {
80
+ const slug = 'salvage-clean';
81
+ const created = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
82
+ expect(created.ok).toBe(true);
83
+
84
+ const outFile = path.join(outDir, `${slug}.uncommitted.patch`);
85
+ const result = await gitWorktree.salvageWorktreeDiff({ cwd: created.dir, outFile });
86
+
87
+ expect(result.ok).toBe(false);
88
+ expect(fs.existsSync(outFile)).toBe(false);
89
+
90
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: created.dir, branch: created.branch });
91
+ });
92
+
93
+ test('a git failure during salvage is swallowed (never throws) and cleanup still runs', async () => {
94
+ const notAGitRepo = path.join(tmpRoot, 'not-a-repo');
95
+ fs.mkdirSync(notAGitRepo, { recursive: true });
96
+ const outFile = path.join(outDir, 'salvage-failure.uncommitted.patch');
97
+
98
+ await expect(gitWorktree.salvageWorktreeDiff({ cwd: notAGitRepo, outFile })).resolves.toEqual({ ok: false });
99
+ expect(fs.existsSync(outFile)).toBe(false);
100
+
101
+ // Cleanup of an unrelated, never-created worktree is itself a no-op but
102
+ // must still resolve without throwing — mirrors the finally-block shape in
103
+ // scheduler.cjs where salvage failure must never block integration/cleanup.
104
+ await expect(
105
+ gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: path.join(tmpRoot, 'never-existed'), branch: 'sm-job/does-not-exist' }),
106
+ ).resolves.toBeUndefined();
107
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * gitWorktreeSalvageDelta.test.cjs — PRD 1098: `salvageDirtyDelta` is the
3
+ * shared-tree counterpart to `salvageWorktreeDiff` (gitWorktreeSalvage.test.cjs)
4
+ * — it salvages an IN-PLACE job's uncommitted work without a throwaway
5
+ * worktree checkout to diff and discard, so it must be strictly DELTA-scoped
6
+ * (only the caller-supplied paths, never a whole-tree dump) and must never
7
+ * mutate the shared tree (no add/stash/reset/checkout/clean) the way
8
+ * `salvageWorktreeDiff`'s `git add -A --intent-to-add` deliberately can
9
+ * against a disposable checkout.
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect, beforeEach, afterEach } from 'vitest';
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+ const { execFileSync } = require('node:child_process');
21
+ const gitWorktree = require('../gitWorktree.cjs');
22
+
23
+ let tmpRoot;
24
+ let repoCwd;
25
+ let outDir;
26
+
27
+ function git(args, cwd) {
28
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
29
+ }
30
+
31
+ function initRepo(dir) {
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ git(['init', '-q'], dir);
34
+ git(['config', 'user.email', 'test@example.com'], dir);
35
+ git(['config', 'user.name', 'Test'], dir);
36
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
37
+ fs.writeFileSync(path.join(dir, 'tracked-a.txt'), 'a\n', 'utf8');
38
+ fs.writeFileSync(path.join(dir, 'tracked-b.txt'), 'b\n', 'utf8');
39
+ git(['add', '-A'], dir);
40
+ git(['commit', '-q', '-m', 'initial'], dir);
41
+ }
42
+
43
+ beforeEach(() => {
44
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-salvage-delta-'));
45
+ repoCwd = path.join(tmpRoot, 'repo');
46
+ initRepo(repoCwd);
47
+ outDir = fs.mkdtempSync(path.join(os.homedir(), '.claude', 'sm-test-salvage-delta-'));
48
+ });
49
+
50
+ afterEach(() => {
51
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
52
+ fs.rmSync(outDir, { recursive: true, force: true });
53
+ });
54
+
55
+ test('salvages only the given delta paths (tracked edit + untracked new file), never a pre-existing dirty file outside the delta', async () => {
56
+ // Pre-existing WIP that must NEVER appear in the patch — the whole point
57
+ // of delta-scoping.
58
+ fs.writeFileSync(path.join(repoCwd, 'tracked-b.txt'), 'b\npre-existing human WIP\n', 'utf8');
59
+
60
+ // This job's own delta: an edit to tracked-a.txt and a new untracked file.
61
+ fs.writeFileSync(path.join(repoCwd, 'tracked-a.txt'), 'a\nedited by job\n', 'utf8');
62
+ fs.writeFileSync(path.join(repoCwd, 'job-new-file.txt'), 'brand new job output\n', 'utf8');
63
+
64
+ const outFile = path.join(outDir, 'delta.uncommitted.patch');
65
+ const result = await gitWorktree.salvageDirtyDelta({
66
+ cwd: repoCwd,
67
+ paths: ['tracked-a.txt', 'job-new-file.txt'],
68
+ outFile,
69
+ });
70
+
71
+ expect(result.ok).toBe(true);
72
+ expect(result.bytes).toBeGreaterThan(0);
73
+ const patch = fs.readFileSync(outFile, 'utf8');
74
+ expect(patch).toContain('tracked-a.txt');
75
+ expect(patch).toContain('edited by job');
76
+ expect(patch).toContain('job-new-file.txt');
77
+ expect(patch).toContain('brand new job output');
78
+ // The sibling/human's pre-existing dirty file is NOT in the delta list —
79
+ // it must never leak into this job's patch.
80
+ expect(patch).not.toContain('pre-existing human WIP');
81
+ });
82
+
83
+ test('applies cleanly with git apply --check against a fresh clone of the base tree', async () => {
84
+ fs.writeFileSync(path.join(repoCwd, 'tracked-a.txt'), 'a\nedited\n', 'utf8');
85
+ fs.writeFileSync(path.join(repoCwd, 'job-new-file.txt'), 'new content\n', 'utf8');
86
+
87
+ const outFile = path.join(outDir, 'apply-check.uncommitted.patch');
88
+ const result = await gitWorktree.salvageDirtyDelta({
89
+ cwd: repoCwd,
90
+ paths: ['tracked-a.txt', 'job-new-file.txt'],
91
+ outFile,
92
+ });
93
+ expect(result.ok).toBe(true);
94
+
95
+ // Fresh clone of the same commit, clean, to prove the patch is
96
+ // self-sufficient and applies against the pristine base state.
97
+ const cleanCheckout = path.join(tmpRoot, 'clean-checkout');
98
+ git(['clone', '-q', repoCwd, cleanCheckout], tmpRoot);
99
+ expect(() => git(['apply', '--check', outFile], cleanCheckout)).not.toThrow();
100
+ });
101
+
102
+ test('never mutates the shared tree: git status and stash list are byte-identical before and after', async () => {
103
+ // A human's own pre-existing WIP, deliberately outside the delta.
104
+ fs.writeFileSync(path.join(repoCwd, 'tracked-b.txt'), 'b\nhuman wip\n', 'utf8');
105
+ fs.writeFileSync(path.join(repoCwd, 'tracked-a.txt'), 'a\njob edit\n', 'utf8');
106
+ fs.writeFileSync(path.join(repoCwd, 'job-new-file.txt'), 'job output\n', 'utf8');
107
+
108
+ const statusBefore = git(['status', '--porcelain'], repoCwd);
109
+ const stashBefore = git(['stash', 'list'], repoCwd);
110
+
111
+ const outFile = path.join(outDir, 'no-mutate.uncommitted.patch');
112
+ const result = await gitWorktree.salvageDirtyDelta({
113
+ cwd: repoCwd,
114
+ paths: ['tracked-a.txt', 'job-new-file.txt'],
115
+ outFile,
116
+ });
117
+ expect(result.ok).toBe(true);
118
+
119
+ const statusAfter = git(['status', '--porcelain'], repoCwd);
120
+ const stashAfter = git(['stash', 'list'], repoCwd);
121
+ expect(statusAfter).toBe(statusBefore);
122
+ expect(stashAfter).toBe(stashBefore);
123
+ });
124
+
125
+ test('no delta paths dirty at call time produces no patch file (no 0-byte artifact)', async () => {
126
+ const outFile = path.join(outDir, 'clean.uncommitted.patch');
127
+ const result = await gitWorktree.salvageDirtyDelta({
128
+ cwd: repoCwd,
129
+ paths: ['tracked-a.txt'],
130
+ outFile,
131
+ });
132
+ expect(result.ok).toBe(false);
133
+ expect(fs.existsSync(outFile)).toBe(false);
134
+ });
135
+
136
+ test('empty paths list is a no-op', async () => {
137
+ const outFile = path.join(outDir, 'empty.uncommitted.patch');
138
+ const result = await gitWorktree.salvageDirtyDelta({ cwd: repoCwd, paths: [], outFile });
139
+ expect(result.ok).toBe(false);
140
+ expect(fs.existsSync(outFile)).toBe(false);
141
+ });
142
+
143
+ test('a git failure (not a repo) is swallowed and never throws', async () => {
144
+ const notAGitRepo = path.join(tmpRoot, 'not-a-repo');
145
+ fs.mkdirSync(notAGitRepo, { recursive: true });
146
+ fs.writeFileSync(path.join(notAGitRepo, 'x.txt'), 'x\n', 'utf8');
147
+ const outFile = path.join(outDir, 'failure.uncommitted.patch');
148
+
149
+ await expect(
150
+ gitWorktree.salvageDirtyDelta({ cwd: notAGitRepo, paths: ['x.txt'], outFile }),
151
+ ).resolves.toEqual({ ok: false });
152
+ expect(fs.existsSync(outFile)).toBe(false);
153
+ });
@@ -142,11 +142,13 @@ test('falls back with a reason for a non-git cwd', async () => {
142
142
  expect(result.reason).toMatch(/not a git repository/);
143
143
  });
144
144
 
145
- test('falls back with a reason for a dirty base tree (never silently drops uncommitted human WIP)', async () => {
146
- fs.writeFileSync(path.join(repoCwd, 'dirty.txt'), 'uncommitted\n', 'utf8');
145
+ test('a dirty tracked base file is carried into the worktree instead of disabling isolation (never silently drops uncommitted human WIP)', async () => {
146
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
147
147
  const result = await jobWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
148
- expect(result.ok).toBe(false);
149
- expect(result.reason).toMatch(/uncommitted changes/);
148
+ expect(result.ok).toBe(true);
149
+ expect(result.carriedPaths).toEqual(['README.md']);
150
+ expect(fs.readFileSync(path.join(result.dir, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
151
+ await jobWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
150
152
  });
151
153
 
152
154
  test('falls back once the concurrency cap is reached, and recovers after cleanup', async () => {