claude-code-session-manager 0.75.3 → 0.76.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 (101) hide show
  1. package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-CBx9l4zN.js} +1 -1
  2. package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-Bf0EIE_t.js} +1 -1
  3. package/dist/assets/{History-DnSi_OHm.js → History-CpdtWhC8.js} +1 -1
  4. package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-DyUbMDmg.js} +1 -1
  5. package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-By-wIpry.js} +1 -1
  6. package/dist/assets/{Library-CaJVqVvi.js → Library-CQmo4QVC.js} +1 -1
  7. package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-BQMd6NOm.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-DEp43FXX.js} +1 -1
  9. package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-CLarzwqA.js} +1 -1
  10. package/dist/assets/{Memory-BDeqlqwH.js → Memory-B0sCdIy1.js} +1 -1
  11. package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-BhWPVOCD.js} +1 -1
  12. package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-Ddlq8T_O.js} +1 -1
  13. package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-D2oA_2Jl.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-DgAgavUM.js} +1 -1
  15. package/dist/assets/{SaveBar-D-gCUx4n.js → SaveBar-Qvc4Ek-H.js} +1 -1
  16. package/dist/assets/{Scheduler-Bpd4OGju.js → Scheduler-BmYJvNzK.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
  18. package/dist/assets/{Settings-DRRozLyT.js → Settings-2Vx3X5SI.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
  20. package/dist/assets/{Skills-D8L66eiX.js → Skills-Cmrz_LeN.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-DVA1eYDP.js} +1 -1
  22. package/dist/assets/{TagLibrary-E5CLeuVk.js → TagLibrary-DYJGAKZu.js} +1 -1
  23. package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-DmPc3amD.js} +1 -1
  24. package/dist/assets/{Toggle-BTwsbxam.js → Toggle-zfd5LJkK.js} +1 -1
  25. package/dist/assets/{index-DijufvkJ.js → index-B_4PNh9T.js} +676 -676
  26. package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
  27. package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-B9es6fdA.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/lib/activeSessions.cjs +116 -6
  31. package/scripts/scheduler-mcp-server.cjs +154 -95
  32. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  33. package/src/main/__tests__/health-delegation-chain.test.cjs +105 -0
  34. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  35. package/src/main/__tests__/prdCreate.test.cjs +109 -0
  36. package/src/main/__tests__/scheduler-autofix-select.test.cjs +15 -3
  37. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  38. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +60 -1
  39. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  40. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  41. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  42. package/src/main/bilkoHost.cjs +4 -3
  43. package/src/main/chatRunner.cjs +6 -1
  44. package/src/main/config.cjs +22 -33
  45. package/src/main/health.cjs +153 -2
  46. package/src/main/index.cjs +56 -4
  47. package/src/main/ipcSchemas.cjs +18 -1
  48. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  49. package/src/main/lib/__tests__/childWithLog.test.cjs +63 -0
  50. package/src/main/lib/__tests__/delegationReadiness.test.cjs +241 -42
  51. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  52. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +1 -1
  53. package/src/main/lib/__tests__/gitWorktree.test.cjs +14 -2
  54. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  55. package/src/main/lib/__tests__/jobWorktree.test.cjs +2 -2
  56. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  57. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +101 -0
  58. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
  59. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  60. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  61. package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -0
  62. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  63. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  64. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  65. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  66. package/src/main/lib/activeIndexMerge.cjs +15 -0
  67. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  68. package/src/main/lib/buildTarget.cjs +3 -2
  69. package/src/main/lib/childWithLog.cjs +33 -1
  70. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  71. package/src/main/lib/delegationReadiness.cjs +408 -26
  72. package/src/main/lib/ephemeralCwd.cjs +78 -0
  73. package/src/main/lib/epicDelegationStats.cjs +2 -1
  74. package/src/main/lib/epicMint.cjs +17 -1
  75. package/src/main/lib/epicStatusMirror.cjs +95 -0
  76. package/src/main/lib/epicValidationHook.cjs +2 -1
  77. package/src/main/lib/gitWorktree.cjs +56 -2
  78. package/src/main/lib/jobWorktree.cjs +1 -0
  79. package/src/main/lib/loadGate.cjs +134 -0
  80. package/src/main/lib/mcpToolCatalog.cjs +285 -0
  81. package/src/main/lib/opsErrorLog.cjs +12 -1
  82. package/src/main/lib/opsOwnership.cjs +94 -0
  83. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  84. package/src/main/lib/prdCreate.cjs +46 -14
  85. package/src/main/lib/prdLocations.cjs +13 -6
  86. package/src/main/lib/projectRootResolve.cjs +134 -0
  87. package/src/main/lib/promptSessionSchema.cjs +7 -0
  88. package/src/main/lib/queueStore.cjs +31 -5
  89. package/src/main/lib/rcaReport.cjs +1 -1
  90. package/src/main/lib/reaperHelpers.cjs +47 -1
  91. package/src/main/lib/schedulerBatch.cjs +171 -29
  92. package/src/main/lib/schedulerConfig.cjs +80 -0
  93. package/src/main/projectBrief.cjs +3 -2
  94. package/src/main/projectPages.cjs +2 -1
  95. package/src/main/promptSessionTranscript.cjs +0 -0
  96. package/src/main/pty.cjs +5 -0
  97. package/src/main/queueOps.cjs +15 -8
  98. package/src/main/scheduler.cjs +346 -49
  99. package/src/main/seedSchedulerMcp.cjs +58 -4
  100. package/src/preload/api.d.ts +69 -1
  101. package/src/preload/index.cjs +2 -0
@@ -6,15 +6,28 @@
6
6
  * Run: timeout 300 npx vitest run src/main/lib/__tests__/delegationReadiness.test.cjs
7
7
  */
8
8
 
9
- import { test, expect, afterEach } from 'vitest';
9
+ import { test, expect, afterEach, beforeEach } from 'vitest';
10
10
  const fs = require('node:fs');
11
11
  const fsp = require('node:fs/promises');
12
12
  const os = require('node:os');
13
13
  const path = require('node:path');
14
- const { checkDelegationReadiness } = require('../delegationReadiness.cjs');
14
+ const {
15
+ checkDelegationReadiness,
16
+ installPrdWriteGuard,
17
+ probeSchedulerMcpLive,
18
+ clearLiveProbeCache,
19
+ PRD_WRITE_GUARD_SCRIPT,
20
+ } = require('../delegationReadiness.cjs');
21
+
22
+ const REQUIRED_TOOLS = ['scheduler_create_prd', 'session_manager_help'];
23
+
24
+ beforeEach(() => {
25
+ clearLiveProbeCache();
26
+ });
15
27
 
16
28
  const tmpDirs = [];
17
29
  afterEach(async () => {
30
+ clearLiveProbeCache();
18
31
  while (tmpDirs.length) {
19
32
  const d = tmpDirs.pop();
20
33
  await fsp.rm(d, { recursive: true, force: true });
@@ -32,46 +45,81 @@ async function writeJson(absPath, value) {
32
45
  await fsp.writeFile(absPath, JSON.stringify(value), 'utf8');
33
46
  }
34
47
 
48
+ /** A stub stdio MCP server: answers initialize + tools/list over stdin/stdout. */
49
+ async function writeAnsweringStub(dir, { toolNames = REQUIRED_TOOLS, countFile = null } = {}) {
50
+ const scriptPath = path.join(dir, 'stub-mcp-server.cjs');
51
+ const body = `
52
+ 'use strict';
53
+ const readline = require('node:readline');
54
+ ${countFile ? `require('node:fs').appendFileSync(${JSON.stringify(countFile)}, 'x\\n');` : ''}
55
+ const rl = readline.createInterface({ input: process.stdin });
56
+ rl.on('line', (line) => {
57
+ let msg;
58
+ try { msg = JSON.parse(line); } catch { return; }
59
+ if (msg.id === 1) {
60
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: 'stub', version: '1' } } }) + '\\n');
61
+ } else if (msg.id === 2) {
62
+ const tools = ${JSON.stringify(toolNames)}.map((name) => ({ name }));
63
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 2, result: { tools } }) + '\\n');
64
+ }
65
+ });
66
+ `;
67
+ await fsp.writeFile(scriptPath, body, 'utf8');
68
+ return scriptPath;
69
+ }
70
+
71
+ /** A stub that never responds to anything — exercises the timeout path. */
72
+ async function writeSilentStub(dir) {
73
+ const scriptPath = path.join(dir, 'silent-mcp-server.cjs');
74
+ await fsp.writeFile(scriptPath, 'process.stdin.resume();\n', 'utf8');
75
+ return scriptPath;
76
+ }
77
+
35
78
  async function makeGreenFixtures() {
36
79
  const homeDir = await mkTmp('sm-delegation-home-');
37
80
  const cwd = await mkTmp('sm-delegation-cwd-');
38
81
 
82
+ const scriptPath = await writeAnsweringStub(homeDir);
39
83
  await writeJson(path.join(homeDir, '.claude.json'), {
40
- mcpServers: { 'session-manager-scheduler': { type: 'stdio', command: 'node', args: [] } },
84
+ mcpServers: { 'session-manager-scheduler': { type: 'stdio', command: 'node', args: [scriptPath] } },
41
85
  });
42
86
  await writeJson(path.join(homeDir, '.claude', 'settings.json'), {
43
87
  enabledPlugins: { 'session-manager-dev@session-manager': true },
44
88
  });
45
89
  await fsp.mkdir(path.join(homeDir, '.claude', 'agents'), { recursive: true });
46
90
  await fsp.writeFile(path.join(homeDir, '.claude', 'agents', 'dev-lead.md'), '# dev-lead', 'utf8');
91
+ // The canonical, sanctioned form: an ABSOLUTE path to session-manager's own
92
+ // guard script (installPrdWriteGuard's "reference, never vendor" decision).
47
93
  await writeJson(path.join(cwd, '.claude', 'settings.json'), {
48
94
  hooks: {
49
95
  PreToolUse: [
50
96
  {
51
97
  matcher: 'Write|Edit|NotebookEdit',
52
- hooks: [{ type: 'command', command: 'node scripts/hooks/guard-prd-writes.cjs' }],
98
+ hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }],
53
99
  },
54
100
  ],
55
101
  },
56
102
  });
57
103
 
58
- return { homeDir, cwd };
104
+ return { homeDir, cwd, scriptPath };
59
105
  }
60
106
 
61
- test('all four checks pass on a fully-configured project', async () => {
107
+ test('all six checks pass on a fully-configured project', async () => {
62
108
  const { homeDir, cwd } = await makeGreenFixtures();
63
- const result = checkDelegationReadiness({ cwd, homeDir });
109
+ const result = await checkDelegationReadiness({ cwd, homeDir });
64
110
 
65
111
  expect(result.ok).toBe(true);
66
- expect(result.checks).toHaveLength(4);
112
+ expect(result.checks).toHaveLength(6);
67
113
  expect(result.checks.every((c) => c.ok)).toBe(true);
68
114
  expect(result.checks.map((c) => c.id)).toEqual([
69
115
  'scheduler-mcp',
116
+ 'scheduler-mcp-live',
117
+ 'scheduler-mcp-project-duplicate',
70
118
  'dev-plugin',
71
119
  'agent-personas',
72
120
  'prd-write-guard',
73
121
  ]);
74
- });
122
+ }, 15_000);
75
123
 
76
124
  test('scheduler-mcp: passes via project-scope .mcp.json even without user scope', async () => {
77
125
  const { homeDir, cwd } = await makeGreenFixtures();
@@ -80,47 +128,72 @@ test('scheduler-mcp: passes via project-scope .mcp.json even without user scope'
80
128
  mcpServers: { 'session-manager-scheduler': { command: 'node', args: [] } },
81
129
  });
82
130
 
83
- const result = checkDelegationReadiness({ cwd, homeDir });
131
+ const result = await checkDelegationReadiness({ cwd, homeDir });
84
132
  const check = result.checks.find((c) => c.id === 'scheduler-mcp');
85
133
  expect(check.ok).toBe(true);
86
- expect(result.ok).toBe(true);
87
- });
134
+ }, 15_000);
88
135
 
89
136
  test('scheduler-mcp: fails with a runnable fix when absent from both scopes', async () => {
90
137
  const { homeDir, cwd } = await makeGreenFixtures();
91
138
  await writeJson(path.join(homeDir, '.claude.json'), { mcpServers: {} });
92
139
 
93
- const result = checkDelegationReadiness({ cwd, homeDir });
140
+ const result = await checkDelegationReadiness({ cwd, homeDir });
94
141
  const check = result.checks.find((c) => c.id === 'scheduler-mcp');
95
142
  expect(check.ok).toBe(false);
96
143
  expect(result.ok).toBe(false);
97
144
  expect(check.fix).toMatch(/^claude mcp add session-manager-scheduler --scope user/);
98
145
  expect(check.fix).toContain('scheduler-mcp-server.cjs');
99
- });
146
+ }, 15_000);
147
+
148
+ test('scheduler-mcp: fails when the user-scope script path is RELATIVE', async () => {
149
+ const { homeDir, cwd } = await makeGreenFixtures();
150
+ await writeJson(path.join(homeDir, '.claude.json'), {
151
+ mcpServers: { 'session-manager-scheduler': { command: 'node', args: ['scripts/scheduler-mcp-server.cjs'] } },
152
+ });
153
+
154
+ const result = await checkDelegationReadiness({ cwd, homeDir });
155
+ const check = result.checks.find((c) => c.id === 'scheduler-mcp');
156
+ expect(check.ok).toBe(false);
157
+ expect(check.detail).toMatch(/non-absolute/);
158
+ expect(check.fix).toMatch(/^claude mcp add session-manager-scheduler --scope user/);
159
+ // Live probe has nothing runnable to trust, so it must not report ready either.
160
+ expect(result.checks.find((c) => c.id === 'scheduler-mcp-live').skipped).toBe(true);
161
+ }, 15_000);
162
+
163
+ test('scheduler-mcp: fails when the user-scope script path is absolute but MISSING', async () => {
164
+ const { homeDir, cwd } = await makeGreenFixtures();
165
+ await writeJson(path.join(homeDir, '.claude.json'), {
166
+ mcpServers: { 'session-manager-scheduler': { command: 'node', args: [path.join(homeDir, 'does-not-exist.cjs')] } },
167
+ });
168
+
169
+ const result = await checkDelegationReadiness({ cwd, homeDir });
170
+ const check = result.checks.find((c) => c.id === 'scheduler-mcp');
171
+ expect(check.ok).toBe(false);
172
+ expect(check.detail).toMatch(/no longer exists/);
173
+ }, 15_000);
100
174
 
101
175
  test('dev-plugin: fails independently when enabledPlugins is missing the key', async () => {
102
176
  const { homeDir, cwd } = await makeGreenFixtures();
103
177
  await writeJson(path.join(homeDir, '.claude', 'settings.json'), { enabledPlugins: {} });
104
178
 
105
- const result = checkDelegationReadiness({ cwd, homeDir });
179
+ const result = await checkDelegationReadiness({ cwd, homeDir });
106
180
  const check = result.checks.find((c) => c.id === 'dev-plugin');
107
181
  expect(check.ok).toBe(false);
108
182
  expect(result.ok).toBe(false);
109
183
  expect(check.fix).toBeTruthy();
110
- // the other three checks still pass independently
184
+ // the other checks still pass independently
111
185
  expect(result.checks.filter((c) => c.id !== 'dev-plugin').every((c) => c.ok)).toBe(true);
112
- });
186
+ }, 15_000);
113
187
 
114
188
  test('agent-personas: fails independently when ~/.claude/agents has no personas', async () => {
115
189
  const { homeDir, cwd } = await makeGreenFixtures();
116
190
  await fsp.rm(path.join(homeDir, '.claude', 'agents', 'dev-lead.md'));
117
191
 
118
- const result = checkDelegationReadiness({ cwd, homeDir });
192
+ const result = await checkDelegationReadiness({ cwd, homeDir });
119
193
  const check = result.checks.find((c) => c.id === 'agent-personas');
120
194
  expect(check.ok).toBe(false);
121
195
  expect(result.ok).toBe(false);
122
- expect(result.checks.filter((c) => c.id !== 'agent-personas').every((c) => c.ok)).toBe(true);
123
- });
196
+ }, 15_000);
124
197
 
125
198
  test('agent-personas: passes via project-scope .claude/agents/ even without global personas', async () => {
126
199
  const { homeDir, cwd } = await makeGreenFixtures();
@@ -128,31 +201,20 @@ test('agent-personas: passes via project-scope .claude/agents/ even without glob
128
201
  await fsp.mkdir(path.join(cwd, '.claude', 'agents'), { recursive: true });
129
202
  await fsp.writeFile(path.join(cwd, '.claude', 'agents', 'builder.md'), '# builder', 'utf8');
130
203
 
131
- const result = checkDelegationReadiness({ cwd, homeDir });
204
+ const result = await checkDelegationReadiness({ cwd, homeDir });
132
205
  const check = result.checks.find((c) => c.id === 'agent-personas');
133
206
  expect(check.ok).toBe(true);
134
- expect(result.ok).toBe(true);
135
- });
136
-
137
- test('agent-personas: fails when the directory does not exist at all', async () => {
138
- const { homeDir, cwd } = await makeGreenFixtures();
139
- await fsp.rm(path.join(homeDir, '.claude', 'agents'), { recursive: true, force: true });
140
-
141
- const result = checkDelegationReadiness({ cwd, homeDir });
142
- const check = result.checks.find((c) => c.id === 'agent-personas');
143
- expect(check.ok).toBe(false);
144
- });
207
+ }, 15_000);
145
208
 
146
209
  test('prd-write-guard: fails independently when the hook is missing', async () => {
147
210
  const { homeDir, cwd } = await makeGreenFixtures();
148
211
  await writeJson(path.join(cwd, '.claude', 'settings.json'), { hooks: { PreToolUse: [] } });
149
212
 
150
- const result = checkDelegationReadiness({ cwd, homeDir });
213
+ const result = await checkDelegationReadiness({ cwd, homeDir });
151
214
  const check = result.checks.find((c) => c.id === 'prd-write-guard');
152
215
  expect(check.ok).toBe(false);
153
216
  expect(result.ok).toBe(false);
154
- expect(result.checks.filter((c) => c.id !== 'prd-write-guard').every((c) => c.ok)).toBe(true);
155
- });
217
+ }, 15_000);
156
218
 
157
219
  test('unparseable JSON files yield ok:false with a detail, never a throw', async () => {
158
220
  const { homeDir, cwd } = await makeGreenFixtures();
@@ -160,8 +222,8 @@ test('unparseable JSON files yield ok:false with a detail, never a throw', async
160
222
  await fs.promises.writeFile(path.join(homeDir, '.claude', 'settings.json'), '{ not valid json', 'utf8');
161
223
  await fs.promises.writeFile(path.join(cwd, '.claude', 'settings.json'), '{ not valid json', 'utf8');
162
224
 
163
- expect(() => checkDelegationReadiness({ cwd, homeDir })).not.toThrow();
164
- const result = checkDelegationReadiness({ cwd, homeDir });
225
+ await expect(checkDelegationReadiness({ cwd, homeDir })).resolves.toBeTruthy();
226
+ const result = await checkDelegationReadiness({ cwd, homeDir });
165
227
 
166
228
  const scheduler = result.checks.find((c) => c.id === 'scheduler-mcp');
167
229
  const devPlugin = result.checks.find((c) => c.id === 'dev-plugin');
@@ -173,13 +235,150 @@ test('unparseable JSON files yield ok:false with a detail, never a throw', async
173
235
  expect(guard.ok).toBe(false);
174
236
  expect(guard.detail).toBeTruthy();
175
237
  expect(result.ok).toBe(false);
176
- });
238
+ }, 15_000);
239
+
240
+ // ─────────────────────────────── scheduler-mcp-project-duplicate
241
+
242
+ test('scheduler-mcp-project-duplicate: warns when both scopes register the server', async () => {
243
+ const { homeDir, cwd } = await makeGreenFixtures();
244
+ await writeJson(path.join(cwd, '.mcp.json'), {
245
+ mcpServers: { 'session-manager-scheduler': { command: 'node', args: ['scripts/scheduler-mcp-server.cjs'] } },
246
+ });
247
+
248
+ const result = await checkDelegationReadiness({ cwd, homeDir });
249
+ const check = result.checks.find((c) => c.id === 'scheduler-mcp-project-duplicate');
250
+ expect(check.ok).toBe(true);
251
+ expect(check.warn).toBe(true);
252
+ expect(check.detail).toMatch(/user scope is canonical/);
253
+ // A warning never fails the overall gate.
254
+ expect(result.ok).toBe(true);
255
+ }, 15_000);
256
+
257
+ test('scheduler-mcp-project-duplicate: no warning when only user scope is registered', async () => {
258
+ const { homeDir, cwd } = await makeGreenFixtures();
259
+
260
+ const result = await checkDelegationReadiness({ cwd, homeDir });
261
+ const check = result.checks.find((c) => c.id === 'scheduler-mcp-project-duplicate');
262
+ expect(check.ok).toBe(true);
263
+ expect(check.warn).toBe(false);
264
+ }, 15_000);
265
+
266
+ // ─────────────────────────────── scheduler-mcp-live
267
+
268
+ test('scheduler-mcp-live: ok when the server answers tools/list with both required tools', async () => {
269
+ const dir = await mkTmp('sm-live-probe-');
270
+ const scriptPath = await writeAnsweringStub(dir);
271
+
272
+ const result = await probeSchedulerMcpLive({ command: 'node', args: [scriptPath], env: {} });
273
+ expect(result.ok).toBe(true);
274
+ expect(result.detail).toContain('scheduler_create_prd');
275
+ }, 15_000);
276
+
277
+ test('scheduler-mcp-live: fails naming the missing tool when the server answers WITHOUT it', async () => {
278
+ const dir = await mkTmp('sm-live-probe-');
279
+ const scriptPath = await writeAnsweringStub(dir, { toolNames: ['scheduler_create_prd'] });
280
+
281
+ const result = await probeSchedulerMcpLive({ command: 'node', args: [scriptPath], env: {} });
282
+ expect(result.ok).toBe(false);
283
+ expect(result.detail).toContain('session_manager_help');
284
+ }, 15_000);
285
+
286
+ test('scheduler-mcp-live: times out (bounded) against a server that never answers, and kills the child', async () => {
287
+ const dir = await mkTmp('sm-live-probe-');
288
+ const scriptPath = await writeSilentStub(dir);
289
+
290
+ const start = Date.now();
291
+ const result = await probeSchedulerMcpLive({ command: 'node', args: [scriptPath], env: {} });
292
+ const elapsed = Date.now() - start;
177
293
 
178
- test('missing cwd/.claude/settings.json entirely is treated as guard-absent, not a throw', async () => {
294
+ expect(result.ok).toBe(false);
295
+ expect(result.detail).toBe('timeout');
296
+ expect(elapsed).toBeLessThan(15_000);
297
+ }, 20_000);
298
+
299
+ test('scheduler-mcp-live: is skipped, not failed, when scheduler-mcp already failed', async () => {
300
+ const { homeDir, cwd } = await makeGreenFixtures();
301
+ await writeJson(path.join(homeDir, '.claude.json'), { mcpServers: {} });
302
+
303
+ const result = await checkDelegationReadiness({ cwd, homeDir });
304
+ const live = result.checks.find((c) => c.id === 'scheduler-mcp-live');
305
+ expect(live.ok).toBe(true);
306
+ expect(live.skipped).toBe(true);
307
+ // One red row for the registration failure, not two.
308
+ expect(result.checks.filter((c) => !c.ok)).toHaveLength(1);
309
+ }, 15_000);
310
+
311
+ test('scheduler-mcp-live: memoizes the probe per registration signature within the TTL', async () => {
312
+ const dir = await mkTmp('sm-live-probe-');
313
+ const countFile = path.join(dir, 'spawn-count.txt');
314
+ const scriptPath = await writeAnsweringStub(dir, { countFile });
315
+
316
+ const { homeDir, cwd } = await makeGreenFixtures();
317
+ await writeJson(path.join(homeDir, '.claude.json'), {
318
+ mcpServers: { 'session-manager-scheduler': { type: 'stdio', command: 'node', args: [scriptPath] } },
319
+ });
320
+
321
+ await checkDelegationReadiness({ cwd, homeDir });
322
+ await checkDelegationReadiness({ cwd, homeDir });
323
+
324
+ const spawnCount = (await fsp.readFile(countFile, 'utf8')).trim().split('\n').filter(Boolean).length;
325
+ expect(spawnCount).toBe(1);
326
+ }, 15_000);
327
+
328
+ // ─────────────────────────────── installPrdWriteGuard (unchanged behavior)
329
+
330
+ test('installPrdWriteGuard: writes the canonical ABSOLUTE-path entry and turns the check green', async () => {
179
331
  const { homeDir, cwd } = await makeGreenFixtures();
180
332
  await fsp.rm(path.join(cwd, '.claude', 'settings.json'));
181
333
 
182
- expect(() => checkDelegationReadiness({ cwd, homeDir })).not.toThrow();
183
- const result = checkDelegationReadiness({ cwd, homeDir });
184
- expect(result.checks.find((c) => c.id === 'prd-write-guard').ok).toBe(false);
334
+ const r = await installPrdWriteGuard({ cwd });
335
+ expect(r.ok).toBe(true);
336
+ expect(r.action).toBe('installed');
337
+ expect(r.command).toBe(`node ${PRD_WRITE_GUARD_SCRIPT}`);
338
+
339
+ const written = JSON.parse(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8'));
340
+ expect(written.hooks.PreToolUse).toEqual([
341
+ { matcher: 'Write|Edit|NotebookEdit', hooks: [{ type: 'command', command: `node ${PRD_WRITE_GUARD_SCRIPT}` }] },
342
+ ]);
343
+ const result = await checkDelegationReadiness({ cwd, homeDir });
344
+ expect(result.checks.find((c) => c.id === 'prd-write-guard').ok).toBe(true);
345
+ }, 15_000);
346
+
347
+ test('installPrdWriteGuard: is idempotent — a healthy guard is a no-op', async () => {
348
+ const { cwd } = await makeGreenFixtures();
349
+ await installPrdWriteGuard({ cwd });
350
+ const before = fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8');
351
+
352
+ const again = await installPrdWriteGuard({ cwd });
353
+ expect(again.action).toBe('already-installed');
354
+ expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe(before);
355
+ });
356
+
357
+ test('installPrdWriteGuard: refuses on unparseable settings rather than discarding them', async () => {
358
+ const { cwd } = await makeGreenFixtures();
359
+ await fsp.writeFile(path.join(cwd, '.claude', 'settings.json'), '{ not valid json', 'utf8');
360
+
361
+ const r = await installPrdWriteGuard({ cwd });
362
+ expect(r.ok).toBe(false);
363
+ expect(r.action).toBe('error');
364
+ expect(fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf8')).toBe('{ not valid json');
365
+ });
366
+
367
+ // The guard script is not just referenced — it is EXERCISED here, so the
368
+ // "certifies a config that has never been proven to run" gap is closed by a
369
+ // real end-to-end run of the exact command installPrdWriteGuard writes.
370
+ test('the installed command actually DENIES a scheduler PRD write and allows a normal one', async () => {
371
+ const { cwd } = await makeGreenFixtures();
372
+ const { execFileSync } = require('node:child_process');
373
+
374
+ const run = (toolInput) => JSON.parse(execFileSync('node', [PRD_WRITE_GUARD_SCRIPT], {
375
+ input: JSON.stringify({ tool_name: 'Write', tool_input: toolInput, cwd }),
376
+ encoding: 'utf8',
377
+ }));
378
+
379
+ const denied = run({ file_path: path.join(cwd, 'session-manager-operations', 'scheduler', 'prds', '1234-x.md'), content: '# x' });
380
+ expect(denied.hookSpecificOutput?.permissionDecision).toBe('deny');
381
+
382
+ const allowed = run({ file_path: path.join(cwd, 'src', 'x.ts'), content: 'x' });
383
+ expect(allowed.hookSpecificOutput?.permissionDecision).not.toBe('deny');
185
384
  });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * ephemeralCwd.test.cjs — isEphemeralCwd(cwd) must refuse a cwd that
3
+ * resolves to os.tmpdir() itself, into a managed worktree root, or onto a
4
+ * linked git worktree's own root — and must allow an ordinary project cwd.
5
+ *
6
+ * See ephemeralCwd.cjs's header for the live incident (2026-09-01) this
7
+ * predicate closes: queueStore.writeSplit materializing scheduler state
8
+ * inside an ephemeral epic worktree, torn down with the Epic and silently
9
+ * losing the write.
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/ephemeralCwd.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect, afterEach } from 'vitest';
17
+ const fs = require('node:fs');
18
+ const fsp = require('node:fs/promises');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+ const { isEphemeralCwd } = require('../ephemeralCwd.cjs');
22
+ const { KIND_CONFIG } = require('../gitWorktree.cjs');
23
+
24
+ const tmpDirs = [];
25
+
26
+ afterEach(async () => {
27
+ while (tmpDirs.length) {
28
+ await fsp.rm(tmpDirs.pop(), { recursive: true, force: true }).catch(() => {});
29
+ }
30
+ });
31
+
32
+ // A real project must never itself live under /tmp — only ephemeral fixtures
33
+ // (worktree checkouts) do. `test-results/` is already gitignored.
34
+ const NON_TMP_SCRATCH_ROOT = path.join(process.cwd(), 'test-results', 'ephemeral-cwd-fixtures');
35
+
36
+ async function mkNonTmpDir(prefix) {
37
+ fs.mkdirSync(NON_TMP_SCRATCH_ROOT, { recursive: true });
38
+ return fsp.mkdtemp(path.join(NON_TMP_SCRATCH_ROOT, prefix));
39
+ }
40
+
41
+ async function makeWorktreeFixture() {
42
+ const main = await mkNonTmpDir('sm-ephemeral-main-');
43
+ tmpDirs.push(main);
44
+ fs.mkdirSync(path.join(main, '.git'), { recursive: true });
45
+ const worktree = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-ephemeral-worktree-'));
46
+ tmpDirs.push(worktree);
47
+ const worktreeName = 'sm-epic-abc123';
48
+ const worktreeGitFile = path.join(worktree, '.git');
49
+ const adminDir = path.join(main, '.git', 'worktrees', worktreeName);
50
+ fs.mkdirSync(adminDir, { recursive: true });
51
+ fs.writeFileSync(path.join(adminDir, 'gitdir'), `${worktreeGitFile}\n`, 'utf8');
52
+ fs.writeFileSync(worktreeGitFile, `gitdir: ${adminDir}\n`, 'utf8');
53
+ return { main, worktree };
54
+ }
55
+
56
+ test('isEphemeralCwd refuses os.tmpdir() itself', () => {
57
+ expect(isEphemeralCwd(os.tmpdir())).toBe(true);
58
+ });
59
+
60
+ test('isEphemeralCwd refuses a cwd under the managed epic worktree root', () => {
61
+ const cwd = path.join(KIND_CONFIG.epic.root, 'some-hash', 'some-epic-slug');
62
+ expect(isEphemeralCwd(cwd)).toBe(true);
63
+ });
64
+
65
+ test('isEphemeralCwd refuses a cwd under the managed job worktree root', () => {
66
+ const cwd = path.join(KIND_CONFIG.job.root, 'some-job-id');
67
+ expect(isEphemeralCwd(cwd)).toBe(true);
68
+ });
69
+
70
+ test('isEphemeralCwd refuses a linked git worktree root anywhere on disk', async () => {
71
+ const { worktree } = await makeWorktreeFixture();
72
+ expect(isEphemeralCwd(worktree)).toBe(true);
73
+ });
74
+
75
+ test('isEphemeralCwd allows an ordinary project cwd (the worktree fixture\'s own main tree)', async () => {
76
+ const { main } = await makeWorktreeFixture();
77
+ expect(isEphemeralCwd(main)).toBe(false);
78
+ });
79
+
80
+ test('isEphemeralCwd allows an ordinary tmpdir-adjacent fixture used by other test suites (not os.tmpdir() itself, not a managed worktree root, not a linked worktree)', async () => {
81
+ const cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-some-other-test-fixture-'));
82
+ tmpDirs.push(cwd);
83
+ expect(isEphemeralCwd(cwd)).toBe(false);
84
+ });
85
+
86
+ test('isEphemeralCwd is false for non-absolute or missing input, never throws', () => {
87
+ expect(isEphemeralCwd('relative/path')).toBe(false);
88
+ expect(isEphemeralCwd('')).toBe(false);
89
+ expect(isEphemeralCwd(null)).toBe(false);
90
+ expect(isEphemeralCwd(undefined)).toBe(false);
91
+ });
@@ -82,7 +82,7 @@ test('a non-git cwd resolves to null rather than throwing (Epic proceeds unisola
82
82
 
83
83
  test('a dirty base tree resolves to null rather than throwing', async () => {
84
84
  const cwd = await mkRepoCwd();
85
- fs.writeFileSync(path.join(cwd, 'dirty.txt'), 'uncommitted\n', 'utf8');
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
88
  expect(result).toBeNull();
@@ -168,8 +168,8 @@ test('[job] falls back with a reason for a non-git cwd', async () => {
168
168
  expect(result.reason).toMatch(/not a git repository/);
169
169
  });
170
170
 
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');
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 () => {
172
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
173
173
  const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'x' });
174
174
  expect(result.ok).toBe(false);
175
175
  expect(result.reason).toMatch(/uncommitted changes/);
@@ -398,3 +398,15 @@ test('keyFromBranch strips the kind-specific prefix and returns null for a forei
398
398
  test('createWorktree throws on an unknown kind', async () => {
399
399
  await expect(gitWorktree.createWorktree({ kind: 'bogus', cwd: repoCwd, key: 'x' })).rejects.toThrow(/unknown kind/);
400
400
  });
401
+
402
+ // ──────────────────────────────────────────── isBaseTreeClean (PRD 1064)
403
+
404
+ test('isBaseTreeClean ignores untracked files — a single stray untracked file must not disable isolation', async () => {
405
+ fs.writeFileSync(path.join(repoCwd, 'scratch_untracked.txt'), 'not tracked\n', 'utf8');
406
+ expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(true);
407
+ });
408
+
409
+ test('isBaseTreeClean still reports dirty for a modified TRACKED file', async () => {
410
+ fs.writeFileSync(path.join(repoCwd, 'README.md'), 'modified tracked content\n', 'utf8');
411
+ expect(await gitWorktree.isBaseTreeClean(repoCwd)).toBe(false);
412
+ });
@@ -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
+ });
@@ -142,8 +142,8 @@ 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('falls back with a reason for a dirty base tree with a modified TRACKED file (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
148
  expect(result.ok).toBe(false);
149
149
  expect(result.reason).toMatch(/uncommitted changes/);