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
@@ -14,9 +14,11 @@ const path = require('node:path');
14
14
  const {
15
15
  checkDelegationReadiness,
16
16
  installPrdWriteGuard,
17
+ installDestructiveGitGuard,
17
18
  probeSchedulerMcpLive,
18
19
  clearLiveProbeCache,
19
20
  PRD_WRITE_GUARD_SCRIPT,
21
+ DESTRUCTIVE_GIT_GUARD_SCRIPT,
20
22
  } = require('../delegationReadiness.cjs');
21
23
 
22
24
  const REQUIRED_TOOLS = ['scheduler_create_prd', 'session_manager_help'];
@@ -97,6 +99,10 @@ async function makeGreenFixtures() {
97
99
  matcher: 'Write|Edit|NotebookEdit',
98
100
  hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
99
101
  },
102
+ {
103
+ matcher: 'Bash',
104
+ hooks: [{ type: 'command', command: `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}` }],
105
+ },
100
106
  ],
101
107
  },
102
108
  });
@@ -104,12 +110,12 @@ async function makeGreenFixtures() {
104
110
  return { homeDir, cwd, scriptPath };
105
111
  }
106
112
 
107
- test('all six checks pass on a fully-configured project', async () => {
113
+ test('all seven checks pass on a fully-configured project', async () => {
108
114
  const { homeDir, cwd } = await makeGreenFixtures();
109
115
  const result = await checkDelegationReadiness({ cwd, homeDir });
110
116
 
111
117
  expect(result.ok).toBe(true);
112
- expect(result.checks).toHaveLength(6);
118
+ expect(result.checks).toHaveLength(7);
113
119
  expect(result.checks.every((c) => c.ok)).toBe(true);
114
120
  expect(result.checks.map((c) => c.id)).toEqual([
115
121
  'scheduler-mcp',
@@ -118,6 +124,7 @@ test('all six checks pass on a fully-configured project', async () => {
118
124
  'dev-plugin',
119
125
  'agent-personas',
120
126
  'prd-write-guard',
127
+ 'destructive-git-guard',
121
128
  ]);
122
129
  }, 15_000);
123
130
 
@@ -382,3 +389,146 @@ test('the installed command actually DENIES a scheduler PRD write and allows a n
382
389
  const allowed = run({ file_path: path.join(cwd, 'src', 'x.ts'), content: 'x' });
383
390
  expect(allowed.hookSpecificOutput?.permissionDecision).not.toBe('deny');
384
391
  });
392
+
393
+ // ─────────────────────────────── destructive-git-guard / installDestructiveGitGuard
394
+
395
+ test('destructive-git-guard: fails independently when the hook is missing', async () => {
396
+ const { homeDir, cwd } = await makeGreenFixtures();
397
+ await writeJson(path.join(cwd, '.claude', 'settings.json'), {
398
+ hooks: {
399
+ PreToolUse: [
400
+ {
401
+ matcher: 'Write|Edit|NotebookEdit',
402
+ hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
403
+ },
404
+ ],
405
+ },
406
+ });
407
+
408
+ const result = await checkDelegationReadiness({ cwd, homeDir });
409
+ const check = result.checks.find((c) => c.id === 'destructive-git-guard');
410
+ expect(check.ok).toBe(false);
411
+ expect(result.ok).toBe(false);
412
+ // the other checks still pass independently
413
+ expect(result.checks.filter((c) => c.id !== 'destructive-git-guard').every((c) => c.ok)).toBe(true);
414
+ }, 15_000);
415
+
416
+ test('destructive-git-guard: fails when the hook names a script that does not exist', async () => {
417
+ const { homeDir, cwd } = await makeGreenFixtures();
418
+ await writeJson(path.join(cwd, '.claude', 'settings.json'), {
419
+ hooks: {
420
+ PreToolUse: [
421
+ {
422
+ matcher: 'Write|Edit|NotebookEdit',
423
+ hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
424
+ },
425
+ {
426
+ matcher: 'Bash',
427
+ hooks: [{ type: 'command', command: 'node /does/not/exist/guard-destructive-git.cjs' }],
428
+ },
429
+ ],
430
+ },
431
+ });
432
+
433
+ const result = await checkDelegationReadiness({ cwd, homeDir });
434
+ const check = result.checks.find((c) => c.id === 'destructive-git-guard');
435
+ expect(check.ok).toBe(false);
436
+ expect(check.detail).toMatch(/does not exist/);
437
+ }, 15_000);
438
+
439
+ test('installDestructiveGitGuard: writes the canonical ABSOLUTE-path entry and turns the check green', async () => {
440
+ const { homeDir, cwd } = await makeGreenFixtures();
441
+ await writeJson(path.join(cwd, '.claude', 'settings.json'), {
442
+ hooks: {
443
+ PreToolUse: [
444
+ {
445
+ matcher: 'Write|Edit|NotebookEdit',
446
+ hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
447
+ },
448
+ ],
449
+ },
450
+ });
451
+
452
+ const r = await installDestructiveGitGuard({ cwd });
453
+ expect(r.ok).toBe(true);
454
+ expect(r.action).toBe('installed');
455
+ expect(r.command).toBe(`node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}`);
456
+
457
+ const written = JSON.parse(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8'));
458
+ // The pre-existing Write|Edit|NotebookEdit matcher (and its hook) survives untouched.
459
+ expect(written.hooks.PreToolUse).toContainEqual(
460
+ { matcher: 'Write|Edit|NotebookEdit', hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }] },
461
+ );
462
+ expect(written.hooks.PreToolUse).toContainEqual(
463
+ { matcher: 'Bash', hooks: [{ type: 'command', command: `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}` }] },
464
+ );
465
+
466
+ const result = await checkDelegationReadiness({ cwd, homeDir });
467
+ expect(result.checks.find((c) => c.id === 'destructive-git-guard').ok).toBe(true);
468
+ expect(result.checks.find((c) => c.id === 'prd-write-guard').ok).toBe(true);
469
+ }, 15_000);
470
+
471
+ test('installDestructiveGitGuard: is idempotent — a healthy guard is a no-op', async () => {
472
+ const { cwd } = await makeGreenFixtures();
473
+ const before = fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8');
474
+
475
+ const again = await installDestructiveGitGuard({ cwd });
476
+ expect(again.action).toBe('already-installed');
477
+ expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe(before);
478
+ });
479
+
480
+ test('installDestructiveGitGuard: repairs a broken entry in place rather than duplicating it', async () => {
481
+ const { cwd } = await makeGreenFixtures();
482
+ await writeJson(path.join(cwd, '.claude', 'settings.json'), {
483
+ hooks: {
484
+ PreToolUse: [
485
+ {
486
+ matcher: 'Write|Edit|NotebookEdit',
487
+ hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
488
+ },
489
+ {
490
+ matcher: 'Bash',
491
+ hooks: [{ type: 'command', command: 'node /does/not/exist/guard-destructive-git.cjs' }],
492
+ },
493
+ ],
494
+ },
495
+ });
496
+
497
+ const r = await installDestructiveGitGuard({ cwd });
498
+ expect(r.action).toBe('repaired');
499
+
500
+ const written = JSON.parse(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8'));
501
+ const bashMatchers = written.hooks.PreToolUse.filter((m) => m.matcher === 'Bash');
502
+ expect(bashMatchers).toHaveLength(1);
503
+ expect(bashMatchers[0].hooks).toEqual([{ type: 'command', command: `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}` }]);
504
+ });
505
+
506
+ test('installDestructiveGitGuard: refuses on unparseable settings rather than discarding them', async () => {
507
+ const { cwd } = await makeGreenFixtures();
508
+ await fsp.writeFile(path.join(cwd, '.claude', 'settings.json'), '{ not valid json', 'utf8');
509
+
510
+ const r = await installDestructiveGitGuard({ cwd });
511
+ expect(r.ok).toBe(false);
512
+ expect(r.action).toBe('error');
513
+ expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe('{ not valid json');
514
+ });
515
+
516
+ // The guard script is EXERCISED here too, same rationale as the
517
+ // installPrdWriteGuard end-to-end test above.
518
+ test('the installed destructive-git command actually DENIES a shared-tree stash and allows it inside an sm-job/ worktree', async () => {
519
+ const { cwd } = await makeGreenFixtures();
520
+ const { execFileSync } = require('node:child_process');
521
+ const os2 = require('node:os');
522
+
523
+ const run = (command, runCwd) => JSON.parse(execFileSync('node', [DESTRUCTIVE_GIT_GUARD_SCRIPT], {
524
+ input: JSON.stringify({ tool_name: 'Bash', tool_input: { command }, cwd: runCwd }),
525
+ encoding: 'utf8',
526
+ }));
527
+
528
+ const denied = run('git stash', cwd);
529
+ expect(denied.hookSpecificOutput?.permissionDecision).toBe('deny');
530
+
531
+ const worktreeCwd = path.join(os2.tmpdir(), 'session-manager-job-worktrees', 'somehash', 'some-slug');
532
+ const allowed = run('git stash', worktreeCwd);
533
+ expect(allowed.hookSpecificOutput?.permissionDecision).not.toBe('deny');
534
+ });
@@ -80,12 +80,14 @@ test('a non-git cwd resolves to null rather than throwing (Epic proceeds unisola
80
80
  expect(result).toBeNull();
81
81
  });
82
82
 
83
- test('a dirty base tree resolves to null rather than throwing', async () => {
83
+ test('a dirty base tree still creates the worktree, carrying the uncommitted edit in rather than falling back unisolated', async () => {
84
84
  const cwd = await mkRepoCwd();
85
85
  fs.writeFileSync(path.join(cwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
86
86
 
87
87
  const result = await handler(null, { cwd, epicId: 'epic-mint-3' });
88
- expect(result).toBeNull();
88
+ expect(result).not.toBeNull();
89
+ expect(fs.readFileSync(path.join(result.dir, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
90
+ expect(fs.readFileSync(path.join(cwd, 'README.md'), 'utf8')).toBe('uncommitted tracked edit\n');
89
91
  });
90
92
 
91
93
  test('SM_EPIC_WORKTREE_DISABLE=1 resolves to null rather than throwing', async () => {
@@ -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 with a modified TRACKED file (never silently drops uncommitted human WIP)', async () => {
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 () => {
172
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' });
@@ -410,3 +570,116 @@ test('isBaseTreeClean still reports dirty for a modified TRACKED file', async ()
410
570
  fs.writeFileSync(path.join(repoCwd, 'README.md'), 'modified tracked content\n', 'utf8');
411
571
  expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(false);
412
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
+ });