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
@@ -8,17 +8,29 @@
8
8
  * implements inline instead. See scripts/install-scheduler-mcp-user-scope.sh's
9
9
  * header for the incident this repeat-guards against (PRD 1024-1030).
10
10
  *
11
- * This module only computes the four checks below; surfacing them in the UI
12
- * or the Epic's grounding line is a dependent PRD (delegation-readiness-ui).
11
+ * This module only computes the checks below; surfacing them in the UI or
12
+ * the Epic's grounding line is a dependent PRD (delegation-readiness-ui).
13
+ * A registration key existing in ~/.claude.json is necessary but NOT
14
+ * sufficient — scheduler-mcp-live actually spawns the registered server and
15
+ * confirms it answers tools/list, closing the gap where a moved repo, a
16
+ * missing `node`, or a syntax error in the server showed GREEN forever.
13
17
  */
14
18
 
15
19
  const fs = require('node:fs');
16
20
  const path = require('node:path');
17
21
  const os = require('node:os');
22
+ const { spawn } = require('node:child_process');
23
+ const { writeJson, addAllowedRoot } = require('../config.cjs');
24
+ const { cleanChildEnv, pathWithUserBins } = require('./cleanEnv.cjs');
18
25
 
19
26
  const SCHEDULER_MCP_NAME = 'session-manager-scheduler';
20
27
  const DEV_PLUGIN_ENABLED_KEY = 'session-manager-dev@session-manager';
21
28
  const SCHEDULER_MCP_SERVER_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'scripts', 'scheduler-mcp-server.cjs');
29
+ const PRD_WRITE_GUARD_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'scripts', 'hooks', 'guard-prd-writes.cjs');
30
+ const PRD_WRITE_GUARD_MATCHER = 'Write|Edit|NotebookEdit';
31
+ const LIVE_PROBE_TIMEOUT_MS = 10_000;
32
+ const LIVE_PROBE_TTL_MS = 60_000;
33
+ const REQUIRED_LIVE_TOOLS = ['scheduler_create_prd', 'session_manager_help'];
22
34
 
23
35
  /** Read + JSON.parse a file, returning `fallback` on any read/parse failure. */
24
36
  function readJsonSafe(absPath, fallback) {
@@ -29,27 +41,257 @@ function readJsonSafe(absPath, fallback) {
29
41
  }
30
42
  }
31
43
 
44
+ const SCHEDULER_MCP_FIX_CMD = `claude mcp add ${SCHEDULER_MCP_NAME} --scope user -- node ${SCHEDULER_MCP_SERVER_SCRIPT}`;
45
+
46
+ /**
47
+ * `ok` must mean "an agent in this project can actually reach a working
48
+ * server", not "some entry named session-manager-scheduler exists somewhere".
49
+ * A user-scope entry whose script path is relative or points at a file that
50
+ * no longer exists (repo moved, worktree deleted) is exactly as dead as no
51
+ * entry at all — the difference is it shows GREEN under a substring check,
52
+ * which is the incident this PRD closes.
53
+ */
32
54
  function checkSchedulerMcp({ cwd, homeDir }) {
33
55
  const userConfig = readJsonSafe(path.join(homeDir, '.claude.json'), null);
34
- const userHas = !!userConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
56
+ const userEntry = userConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
35
57
 
36
58
  const projectConfig = readJsonSafe(path.join(cwd, '.mcp.json'), null);
37
59
  const projectHas = !!projectConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
38
60
 
39
- const ok = userHas || projectHas;
40
- let detail;
41
- if (userHas) detail = `${SCHEDULER_MCP_NAME} registered at user scope (~/.claude.json)`;
42
- else if (projectHas) detail = `${SCHEDULER_MCP_NAME} registered at project scope (${cwd}/.mcp.json)`;
43
- else detail = `no ${SCHEDULER_MCP_NAME} entry in ~/.claude.json or ${cwd}/.mcp.json`;
61
+ if (userEntry) {
62
+ const args = Array.isArray(userEntry.args) ? userEntry.args : [];
63
+ const scriptArg = args[args.length - 1];
64
+ const isAbsolute = typeof scriptArg === 'string' && path.isAbsolute(scriptArg);
65
+ const exists = isAbsolute && fs.existsSync(scriptArg);
66
+ if (!isAbsolute || !exists) {
67
+ return {
68
+ id: 'scheduler-mcp',
69
+ label: 'Scheduler MCP server registered',
70
+ ok: false,
71
+ detail: !isAbsolute
72
+ ? `user-scope ${SCHEDULER_MCP_NAME} registration points at a non-absolute script path (${scriptArg})`
73
+ : `user-scope ${SCHEDULER_MCP_NAME} registration points at a script that no longer exists (${scriptArg})`,
74
+ fix: SCHEDULER_MCP_FIX_CMD,
75
+ };
76
+ }
77
+ return {
78
+ id: 'scheduler-mcp',
79
+ label: 'Scheduler MCP server registered',
80
+ ok: true,
81
+ detail: `${SCHEDULER_MCP_NAME} registered at user scope (~/.claude.json)`,
82
+ fix: null,
83
+ };
84
+ }
85
+
86
+ if (projectHas) {
87
+ return {
88
+ id: 'scheduler-mcp',
89
+ label: 'Scheduler MCP server registered',
90
+ ok: true,
91
+ detail: `${SCHEDULER_MCP_NAME} registered at project scope (${cwd}/.mcp.json)`,
92
+ fix: null,
93
+ };
94
+ }
44
95
 
45
96
  return {
46
97
  id: 'scheduler-mcp',
47
98
  label: 'Scheduler MCP server registered',
48
- ok,
49
- detail,
50
- fix: ok
99
+ ok: false,
100
+ detail: `no ${SCHEDULER_MCP_NAME} entry in ~/.claude.json or ${cwd}/.mcp.json`,
101
+ fix: SCHEDULER_MCP_FIX_CMD,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * A project-scope `.mcp.json` entry is never wrong by itself, but once a
107
+ * user-scope entry also exists it is pure risk: `.mcp.json` commonly carries
108
+ * a RELATIVE script path (this repo's own copy did, until this PRD), and a
109
+ * relative path resolves against the process's cwd — which is the worktree
110
+ * root for an Epic, not this repo root — silently shadowing the working
111
+ * user-scope registration with a broken one. Reported as a warning, never a
112
+ * failure: enabling/disabling project-scope servers is the human's call
113
+ * (enableAllProjectMcpServers), not something this probe decides.
114
+ */
115
+ function checkSchedulerMcpProjectDuplicate({ cwd, homeDir }) {
116
+ const userConfig = readJsonSafe(path.join(homeDir, '.claude.json'), null);
117
+ const userHas = !!userConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
118
+
119
+ const projectConfig = readJsonSafe(path.join(cwd, '.mcp.json'), null);
120
+ const projectHas = !!projectConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
121
+
122
+ const warn = userHas && projectHas;
123
+ return {
124
+ id: 'scheduler-mcp-project-duplicate',
125
+ label: 'No project-scope duplicate of the scheduler MCP',
126
+ ok: true,
127
+ warn,
128
+ detail: warn
129
+ ? `${cwd}/.mcp.json ALSO registers ${SCHEDULER_MCP_NAME} — user scope is canonical; this project entry can shadow it (e.g. via a relative script path resolved against a worktree cwd) instead of adding coverage`
130
+ : `no project-scope duplicate of ${SCHEDULER_MCP_NAME} in ${cwd}/.mcp.json`,
131
+ fix: warn
132
+ ? `Remove the ${SCHEDULER_MCP_NAME} entry from ${cwd}/.mcp.json — the user-scope registration already covers every project`
133
+ : null,
134
+ };
135
+ }
136
+
137
+ /** Cache key: a fresh registration signature (command+args+env) always misses. */
138
+ function liveProbeSignature({ command, args, env }) {
139
+ return JSON.stringify({ command, args, env: env || {} });
140
+ }
141
+
142
+ const liveProbeCache = new Map(); // signature -> { result: Promise, expiresAt }
143
+
144
+ /** Test-only: forces the next probe for every signature to actually spawn. */
145
+ function clearLiveProbeCache() {
146
+ liveProbeCache.clear();
147
+ }
148
+
149
+ /**
150
+ * Spawns the registered command+args with the registered env (via
151
+ * cleanChildEnv/pathWithUserBins — the same env the scheduler gives
152
+ * `claude -p`, so this fails the way a real session would fail, not the way
153
+ * the Electron process would), sends an initialize + tools/list JSON-RPC
154
+ * pair over stdin, and resolves once a tools/list result naming every entry
155
+ * in REQUIRED_LIVE_TOOLS arrives, or the 10s bound is hit. Never rejects,
156
+ * never leaves the child running — every path clears the timer and kills it.
157
+ */
158
+ function probeSchedulerMcpLive({ command, args, env }) {
159
+ return new Promise((resolve) => {
160
+ let child;
161
+ try {
162
+ child = spawn(command, args, {
163
+ cwd: os.homedir(),
164
+ env: cleanChildEnv({ PATH: pathWithUserBins(), ...(env || {}) }),
165
+ stdio: ['pipe', 'pipe', 'pipe'],
166
+ });
167
+ } catch (err) {
168
+ resolve({ ok: false, detail: err?.message ?? String(err) });
169
+ return;
170
+ }
171
+
172
+ let settled = false;
173
+ let stdoutBuf = '';
174
+ let firstStderrLine = null;
175
+
176
+ const finish = (result) => {
177
+ if (settled) return;
178
+ settled = true;
179
+ clearTimeout(timer);
180
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
181
+ resolve(result);
182
+ };
183
+
184
+ const timer = setTimeout(() => finish({ ok: false, detail: 'timeout' }), LIVE_PROBE_TIMEOUT_MS);
185
+
186
+ child.on('error', (err) => finish({ ok: false, detail: err?.message ?? String(err) }));
187
+
188
+ child.stderr?.on('data', (chunk) => {
189
+ if (firstStderrLine != null) return;
190
+ const line = chunk.toString('utf8').split('\n').find((l) => l.trim());
191
+ if (line) firstStderrLine = line.trim();
192
+ });
193
+
194
+ child.stdout?.on('data', (chunk) => {
195
+ stdoutBuf += chunk.toString('utf8');
196
+ let idx;
197
+ while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
198
+ const line = stdoutBuf.slice(0, idx).trim();
199
+ stdoutBuf = stdoutBuf.slice(idx + 1);
200
+ if (!line) continue;
201
+ let msg;
202
+ try { msg = JSON.parse(line); } catch { continue; }
203
+ if (msg && msg.id === 2) {
204
+ const tools = Array.isArray(msg.result?.tools) ? msg.result.tools.map((t) => t?.name) : null;
205
+ if (!tools) {
206
+ finish({ ok: false, detail: msg.error?.message ? `tools/list error: ${msg.error.message}` : 'tools/list returned no tools array' });
207
+ return;
208
+ }
209
+ const missing = REQUIRED_LIVE_TOOLS.filter((t) => !tools.includes(t));
210
+ if (missing.length > 0) {
211
+ finish({ ok: false, detail: `tools/list did not include: ${missing.join(', ')}` });
212
+ } else {
213
+ finish({ ok: true, detail: `tools/list returned ${tools.length} tool(s) including ${REQUIRED_LIVE_TOOLS.join(', ')}` });
214
+ }
215
+ return;
216
+ }
217
+ }
218
+ });
219
+
220
+ child.on('close', () => {
221
+ finish({ ok: false, detail: firstStderrLine || 'process exited before answering tools/list' });
222
+ });
223
+
224
+ try {
225
+ const initialize = {
226
+ jsonrpc: '2.0',
227
+ id: 1,
228
+ method: 'initialize',
229
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'sm-readiness', version: '1' } },
230
+ };
231
+ const toolsList = { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} };
232
+ child.stdin.write(`${JSON.stringify(initialize)}\n`);
233
+ child.stdin.write(`${JSON.stringify(toolsList)}\n`);
234
+ } catch (err) {
235
+ finish({ ok: false, detail: err?.message ?? String(err) });
236
+ }
237
+ });
238
+ }
239
+
240
+ function probeSchedulerMcpLiveMemoized({ command, args, env }) {
241
+ const sig = liveProbeSignature({ command, args, env });
242
+ const now = Date.now();
243
+ const cached = liveProbeCache.get(sig);
244
+ if (cached && cached.expiresAt > now) return cached.result;
245
+
246
+ const result = probeSchedulerMcpLive({ command, args, env });
247
+ liveProbeCache.set(sig, { result, expiresAt: now + LIVE_PROBE_TTL_MS });
248
+ return result;
249
+ }
250
+
251
+ /**
252
+ * Answers "does the server process actually answer", which
253
+ * checkSchedulerMcp's static-config check cannot — a registration can point
254
+ * at a script that spawns and dies on a syntax error, or at `node` when
255
+ * `node` isn't on PATH for a real session's env, and both show GREEN there.
256
+ * Skipped (not failed) when the registration check already failed, so a
257
+ * missing entry produces one red row, not two.
258
+ */
259
+ async function checkSchedulerMcpLive({ cwd, homeDir, schedulerMcpCheck }) {
260
+ const base = { id: 'scheduler-mcp-live', label: 'Scheduler MCP server answers tools/list' };
261
+
262
+ if (!schedulerMcpCheck.ok) {
263
+ return { ...base, ok: true, skipped: true, detail: 'skipped — scheduler-mcp registration check already failed', fix: null };
264
+ }
265
+
266
+ // ONLY ever spawn the USER-scope entry. A project-scope `.mcp.json` is
267
+ // attacker-controllable (any repo a human points Session Manager at) and
268
+ // Claude Code deliberately gates running it behind the human's own
269
+ // enabledMcpjsonServers/enableAllProjectMcpServers approval — a gate this
270
+ // probe has no way to check. checkDelegationReadiness runs unattended
271
+ // (New Epic banner on mount, session_manager_help), so falling back to a
272
+ // project-scope command here would let a malicious repo's .mcp.json get
273
+ // executed the moment its cwd is merely probed, bypassing that approval
274
+ // entirely. Auto-approving project-scope servers is explicitly out of
275
+ // scope for this probe — see this PRD's "Out of scope".
276
+ const userConfig = readJsonSafe(path.join(homeDir, '.claude.json'), null);
277
+ const entry = userConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
278
+
279
+ if (!entry?.command) {
280
+ return { ...base, ok: true, skipped: true, detail: 'skipped — no user-scope registration to safely run (a project-scope-only .mcp.json entry is not auto-executed)', fix: null };
281
+ }
282
+
283
+ const command = entry.command;
284
+ const args = Array.isArray(entry.args) ? entry.args : [];
285
+ const env = entry.env && typeof entry.env === 'object' ? entry.env : {};
286
+
287
+ const result = await probeSchedulerMcpLiveMemoized({ command, args, env });
288
+ return {
289
+ ...base,
290
+ ok: result.ok,
291
+ detail: result.detail,
292
+ fix: result.ok
51
293
  ? null
52
- : `claude mcp add ${SCHEDULER_MCP_NAME} --scope user -- node ${SCHEDULER_MCP_SERVER_SCRIPT}`,
294
+ : `Run \`${command} ${args.join(' ')}\` by hand in a fresh shell to see the startup error directly, or \`claude mcp get ${SCHEDULER_MCP_NAME}\``,
53
295
  };
54
296
  }
55
297
 
@@ -98,39 +340,172 @@ function checkAgentPersonas({ cwd, homeDir }) {
98
340
  };
99
341
  }
100
342
 
343
+ /**
344
+ * Pull the guard script's path out of a hook `command` string.
345
+ *
346
+ * Commands are `node <script>` (optionally quoted). We only care about the
347
+ * token naming guard-prd-writes — anything else is not this hook.
348
+ */
349
+ function extractGuardScriptPath(command) {
350
+ const match = String(command).match(/(?:"([^"]*guard-prd-writes\.cjs)"|'([^']*guard-prd-writes\.cjs)'|(\S*guard-prd-writes\.cjs))/);
351
+ if (!match) return null;
352
+ return match[1] || match[2] || match[3] || null;
353
+ }
354
+
355
+ /**
356
+ * `ok` must mean "a hook that will actually run", not "the settings file
357
+ * mentions guard-prd-writes somewhere".
358
+ *
359
+ * The failure mode this guards against: a PreToolUse command that exits
360
+ * non-zero WITHOUT exit code 2 is a non-blocking error, so a hook pointing at
361
+ * a script that doesn't exist in this project silently guards nothing — while
362
+ * a substring match would flip this check green. That is strictly worse than
363
+ * an honest red. So we resolve the command's script path (relative resolves
364
+ * against the project cwd, which is what the harness runs the hook with) and
365
+ * require the file to exist on disk.
366
+ */
101
367
  function checkPrdWriteGuard({ cwd }) {
102
368
  const settings = readJsonSafe(path.join(cwd, '.claude', 'settings.json'), null);
103
369
  const preToolUse = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
104
- const ok = preToolUse.some((matcher) =>
105
- Array.isArray(matcher?.hooks) &&
106
- matcher.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('guard-prd-writes'))
107
- );
370
+
371
+ let mentioned = false;
372
+ let resolvedScript = null;
373
+ for (const matcher of preToolUse) {
374
+ if (!Array.isArray(matcher?.hooks)) continue;
375
+ for (const h of matcher.hooks) {
376
+ if (typeof h?.command !== 'string' || !h.command.includes('guard-prd-writes')) continue;
377
+ mentioned = true;
378
+ const raw = extractGuardScriptPath(h.command);
379
+ if (!raw) continue;
380
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
381
+ if (fs.existsSync(abs)) { resolvedScript = abs; break; }
382
+ }
383
+ if (resolvedScript) break;
384
+ }
385
+
386
+ const ok = !!resolvedScript;
387
+ let detail;
388
+ if (ok) detail = `guard-prd-writes PreToolUse hook found in ${cwd}/.claude/settings.json, resolving to ${resolvedScript}`;
389
+ else if (mentioned) detail = `guard-prd-writes PreToolUse hook in ${cwd}/.claude/settings.json names a script that does not exist — it would silently guard nothing`;
390
+ else detail = `no guard-prd-writes PreToolUse hook in ${cwd}/.claude/settings.json`;
108
391
 
109
392
  return {
110
393
  id: 'prd-write-guard',
111
394
  label: 'PRD-write guard hook installed',
112
395
  ok,
113
- detail: ok
114
- ? `guard-prd-writes PreToolUse hook found in ${cwd}/.claude/settings.json`
115
- : `no guard-prd-writes PreToolUse hook in ${cwd}/.claude/settings.json`,
396
+ detail,
116
397
  fix: ok
117
398
  ? null
118
- : `Add a PreToolUse hook to ${cwd}/.claude/settings.json matching Write|Edit|NotebookEdit that runs node scripts/hooks/guard-prd-writes.cjs`,
399
+ : `Add a PreToolUse hook to ${cwd}/.claude/settings.json matching Write|Edit|NotebookEdit that runs node ${PRD_WRITE_GUARD_SCRIPT}`,
400
+ // The one check with a sanctioned one-press install (installPrdWriteGuard).
401
+ fixAction: ok ? null : 'install-prd-write-guard',
119
402
  };
120
403
  }
121
404
 
405
+
122
406
  /**
123
- * Runs all four delegation-readiness checks for `cwd`. Every filesystem read
407
+ * Install the PRD-write guard into `<cwd>/.claude/settings.json`.
408
+ *
409
+ * ── The standardized approach is REFERENCE, not vendor ────────────────────
410
+ * The hook command points at session-manager's own copy of
411
+ * scripts/hooks/guard-prd-writes.cjs by ABSOLUTE path. We deliberately do NOT
412
+ * copy the script into the adopting repo:
413
+ *
414
+ * - Vendoring drifts. It already did, with exactly one adopter
415
+ * (social-signals-trader's copy is missing the 26-line adoption header).
416
+ * - The thing being guarded is `session-manager-operations/scheduler/` —
417
+ * Session Manager's own territory in someone else's repo. The enforcement
418
+ * logic belongs with the owner, not forked into every consumer.
419
+ * - Adopting repos need no Node toolchain of their own; `node` plus this
420
+ * absolute path is the entire dependency.
421
+ *
422
+ * The liability of reference — a silently-no-op hook if the script ever moves
423
+ * — is closed by checkPrdWriteGuard() above, which resolves the command's
424
+ * script path and fails the check when the file is gone.
425
+ *
426
+ * Merges into any existing hooks block rather than clobbering it: other
427
+ * PreToolUse matchers, other hooks under the same matcher, and every unrelated
428
+ * settings key are preserved. Idempotent — a healthy guard is a no-op, and a
429
+ * guard entry pointing at a missing script is repaired in place.
430
+ */
431
+ async function installPrdWriteGuard({ cwd }) {
432
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
433
+ // config.cjs's validateWrite only permits `<registered project root>/.claude/**`,
434
+ // and a project the user is merely *pointing* the New Epic dialog at may
435
+ // never have spawned a pty — same cold-boot hazard config.cjs:73-81 already
436
+ // documents for PromptSession archive writes. `cwd` here IS a project root.
437
+ addAllowedRoot(cwd);
438
+
439
+ const existing = fs.existsSync(settingsPath) ? readJsonSafe(settingsPath, undefined) : {};
440
+ if (existing === undefined) {
441
+ // Unparseable settings — refuse rather than silently discarding whatever
442
+ // the human has in there.
443
+ return { ok: false, action: 'error', error: `${settingsPath} is not valid JSON — fix it by hand before installing the guard` };
444
+ }
445
+ const settings = (existing && typeof existing === 'object' && !Array.isArray(existing)) ? existing : {};
446
+
447
+ const command = `node ${PRD_WRITE_GUARD_SCRIPT}`;
448
+ const hooks = (settings.hooks && typeof settings.hooks === 'object') ? settings.hooks : {};
449
+ const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse.slice() : [];
450
+
451
+ // Already healthy? Nothing to do.
452
+ if (checkPrdWriteGuard({ cwd }).ok) {
453
+ return { ok: true, action: 'already-installed', settingsPath, command };
454
+ }
455
+
456
+ // Repair a broken entry in place, wherever it sits, before appending.
457
+ let repaired = false;
458
+ for (const matcher of preToolUse) {
459
+ if (!Array.isArray(matcher?.hooks)) continue;
460
+ for (const h of matcher.hooks) {
461
+ if (typeof h?.command === 'string' && h.command.includes('guard-prd-writes')) {
462
+ h.command = command;
463
+ h.type = 'command';
464
+ repaired = true;
465
+ }
466
+ }
467
+ }
468
+
469
+ if (!repaired) {
470
+ const target = preToolUse.find((m) => m?.matcher === PRD_WRITE_GUARD_MATCHER);
471
+ if (target) {
472
+ target.hooks = Array.isArray(target.hooks) ? target.hooks : [];
473
+ target.hooks.push({ type: 'command', command });
474
+ } else {
475
+ preToolUse.push({ matcher: PRD_WRITE_GUARD_MATCHER, hooks: [{ type: 'command', command }] });
476
+ }
477
+ }
478
+
479
+ const next = { ...settings, hooks: { ...hooks, PreToolUse: preToolUse } };
480
+ await writeJson(settingsPath, next);
481
+
482
+ return { ok: true, action: repaired ? 'repaired' : 'installed', settingsPath, command };
483
+ }
484
+
485
+ /**
486
+ * Runs all six delegation-readiness checks for `cwd`. Every filesystem read
124
487
  * is wrapped (readJsonSafe / try-catch) so a missing or unparseable file
125
- * yields ok:false with a detail, never a thrown exception.
488
+ * yields ok:false with a detail, never a thrown exception. Async because
489
+ * scheduler-mcp-live genuinely spawns a process and waits on it (bounded by
490
+ * LIVE_PROBE_TIMEOUT_MS, and memoized per registration signature so repeat
491
+ * callers — the New Epic banner, session_manager_help — don't each pay for
492
+ * a fresh spawn).
126
493
  */
127
- function checkDelegationReadiness({ cwd, homeDir = os.homedir() }) {
494
+ async function checkDelegationReadiness({ cwd, homeDir = os.homedir() }) {
495
+ const schedulerMcpCheck = checkSchedulerMcp({ cwd, homeDir });
496
+ const schedulerMcpLiveCheck = await checkSchedulerMcpLive({ cwd, homeDir, schedulerMcpCheck });
497
+
498
+ // `fixAction: null` / `warn: false` are the defaults — only a check with a
499
+ // sanctioned one-press installer overrides fixAction (today: prd-write-guard),
500
+ // and only scheduler-mcp-project-duplicate ever sets warn.
128
501
  const checks = [
129
- checkSchedulerMcp({ cwd, homeDir }),
502
+ schedulerMcpCheck,
503
+ schedulerMcpLiveCheck,
504
+ checkSchedulerMcpProjectDuplicate({ cwd, homeDir }),
130
505
  checkDevPlugin({ homeDir }),
131
506
  checkAgentPersonas({ cwd, homeDir }),
132
507
  checkPrdWriteGuard({ cwd }),
133
- ];
508
+ ].map((c) => ({ fixAction: null, warn: false, ...c }));
134
509
 
135
510
  return {
136
511
  ok: checks.every((c) => c.ok),
@@ -138,4 +513,11 @@ function checkDelegationReadiness({ cwd, homeDir = os.homedir() }) {
138
513
  };
139
514
  }
140
515
 
141
- module.exports = { checkDelegationReadiness };
516
+ module.exports = {
517
+ checkDelegationReadiness,
518
+ installPrdWriteGuard,
519
+ probeSchedulerMcpLive,
520
+ clearLiveProbeCache,
521
+ PRD_WRITE_GUARD_SCRIPT,
522
+ PRD_WRITE_GUARD_MATCHER,
523
+ };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ephemeralCwd.cjs — fail-closed predicate for "this cwd must never receive a
5
+ * written ops-state tree."
6
+ *
7
+ * Verified live 2026-09-01: a worktree cwd slipped past project-discovery and
8
+ * queueStore.writeSplit materialized a per-project scheduler shard INSIDE an
9
+ * ephemeral epic/job worktree
10
+ * (/tmp/session-manager-epic-worktrees/<id>/<epic>/session-manager-operations/
11
+ * scheduler/state/queue.json, rewritten every reconcile pass) plus a stray
12
+ * /tmp/session-manager-operations/logs/ tree. A worktree is torn down when its
13
+ * Epic/job ends, so any state written there is silently destroyed — and while
14
+ * it exists it duplicate-answers "does this project have jobs" for a cwd that
15
+ * isn't really a distinct project.
16
+ *
17
+ * Three independent refusal conditions, any one is enough:
18
+ * - cwd IS os.tmpdir() itself (exact match — the bare-root case behind the
19
+ * stray /tmp/session-manager-operations/logs/ tree).
20
+ * - cwd resolves inside one of the two MANAGED worktree roots
21
+ * (gitWorktree.cjs's KIND_CONFIG job/epic roots under os.tmpdir()) — a
22
+ * prefix match here is safe because nothing but managed worktree
23
+ * checkouts is ever created under these two roots (same reasoning
24
+ * activeSessions.cjs's TMP_DROP_ROOTS already relies on).
25
+ * - cwd IS a linked git worktree's own root anywhere on disk —
26
+ * worktreeMainRootOf(cwd) resolves to a DIFFERENT path than cwd itself.
27
+ * (When cwd is itself an ordinary main-tree repo root, worktreeMainRootOf
28
+ * (cwd) resolves back to cwd unchanged, which is not a redirect and must
29
+ * not be refused.)
30
+ *
31
+ * Deliberately NOT a blanket "anywhere under os.tmpdir()" prefix match: this
32
+ * codebase's own scheduler test suite routinely mkdtemp()s a fake project cwd
33
+ * under os.tmpdir() (documented convention — see e.g.
34
+ * scheduler-clear-queue-history.test.cjs's header comment), a legitimate,
35
+ * unrelated use that a blanket match would wrongly refuse.
36
+ */
37
+
38
+ const os = require('node:os');
39
+ const path = require('node:path');
40
+ const { worktreeMainRootOf } = require('../../../scripts/lib/activeSessions.cjs');
41
+ const { KIND_CONFIG } = require('./gitWorktree.cjs');
42
+
43
+ const TMPDIR = path.resolve(os.tmpdir());
44
+ const MANAGED_WORKTREE_ROOTS = [KIND_CONFIG.job.root, KIND_CONFIG.epic.root].map((p) => path.resolve(p));
45
+
46
+ function isExactlyTmpdir(absCwd) {
47
+ return absCwd === TMPDIR;
48
+ }
49
+
50
+ function isUnderManagedWorktreeRoot(absCwd) {
51
+ return MANAGED_WORKTREE_ROOTS.some((root) => {
52
+ const rel = path.relative(root, absCwd);
53
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
54
+ });
55
+ }
56
+
57
+ function isLinkedWorktreeRoot(absCwd) {
58
+ const mainRoot = worktreeMainRootOf(absCwd);
59
+ return Boolean(mainRoot) && path.resolve(mainRoot) !== absCwd;
60
+ }
61
+
62
+ /**
63
+ * isEphemeralCwd(cwd) → true when `cwd` must never have ops state written
64
+ * into it (tmpdir-resident, or a linked git worktree root). Pure, synchronous,
65
+ * never throws — an unresolvable or non-absolute cwd is treated as NOT
66
+ * ephemeral so this predicate only ever adds refusals, never masks the
67
+ * existing "cwd must be absolute" checks callers already perform.
68
+ */
69
+ function isEphemeralCwd(cwd) {
70
+ if (!cwd || typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
71
+ const absCwd = path.resolve(cwd);
72
+ if (isExactlyTmpdir(absCwd)) return true;
73
+ if (isUnderManagedWorktreeRoot(absCwd)) return true;
74
+ if (isLinkedWorktreeRoot(absCwd)) return true;
75
+ return false;
76
+ }
77
+
78
+ module.exports = { isEphemeralCwd };
@@ -40,7 +40,8 @@ const APPLICATION_SOURCE_PREFIXES = ['src/', 'scripts/', 'plugins/', 'bin/'];
40
40
  function countPrdsQueued(cwd, epicId, deps = {}) {
41
41
  const readdirSync = deps.readdirSync || fs.readdirSync;
42
42
  try {
43
- const dir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
43
+ const { opsPath } = require('./opsOwnership.cjs');
44
+ const dir = opsPath(cwd, 'scheduler', 'epics', epicId, 'prds');
44
45
  return readdirSync(dir).filter((name) => name.endsWith('.md')).length;
45
46
  } catch {
46
47
  return 0;
@@ -34,6 +34,7 @@ const crypto = require('node:crypto');
34
34
  const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
35
35
  const { assertOpsWrite } = require('./opsOwnership.cjs');
36
36
  const { appendAuditEvent } = require('./auditLog.cjs');
37
+ const { mirrorEpicStatus, removeEpicMirror } = require('./epicStatusMirror.cjs');
37
38
  // Required as the module object (not destructured) so a test can
38
39
  // monkeypatch promptSessionSchema.assertValidPromptSession in place to
39
40
  // simulate a corrupted construction — the real construction below is
@@ -74,7 +75,10 @@ const MINT_AUTHORITIES = Object.freeze([
74
75
  ]);
75
76
 
76
77
  function activeIndexPath(cwd) {
77
- return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
78
+ // Lazy: opsOwnership → ephemeralCwd → activeSessions; this module is
79
+ // required early by several of those neighbours.
80
+ const { opsPath } = require('./opsOwnership.cjs');
81
+ return opsPath(cwd, 'prompt-sessions', 'active-index.json');
78
82
  }
79
83
 
80
84
  function readActiveIndex(cwd) {
@@ -362,6 +366,13 @@ function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'prop
362
366
  index.sessions[epicId] = session;
363
367
  index.events[epicId] = [firstEvent];
364
368
  writeActiveIndex(cwd, index);
369
+ // Status mirror (see epicStatusMirror.cjs's header) — every Epic is born
370
+ // 'proposed', so this is the FIRST mirror write for this id, inside the
371
+ // same withPathLock task the index write above just ran in. Carries the
372
+ // full validated `session` object (not just status) so a later rebuild
373
+ // (activeIndexRebuild.cjs) can reconstruct a complete row from this file
374
+ // alone.
375
+ mirrorEpicStatus(cwd, epicId, { session, status });
365
376
 
366
377
  // Every mint is logged — the trace-back point for "who created this Epic"
367
378
  // (see auditLog.cjs).
@@ -416,6 +427,10 @@ function removeEpic(cwd, epicId) {
416
427
  delete index.sessions[epicId];
417
428
  delete index.events[epicId];
418
429
  writeActiveIndex(cwd, index);
430
+ // Undo the mirror written by ensureEpic's mint branch too — otherwise
431
+ // the rolled-back id would linger as an orphan_file (a mirrored status
432
+ // with no index row and no tombstone) in health.cjs's epic_index check.
433
+ removeEpicMirror(cwd, epicId);
419
434
  return true;
420
435
  });
421
436
  }
@@ -429,6 +444,7 @@ module.exports = {
429
444
  removeEpic,
430
445
  activeIndexPath,
431
446
  readActiveIndex,
447
+ writeActiveIndex,
432
448
  personaFileExists,
433
449
  resolvePersonaPaths,
434
450
  // Exported so lib/activeIndexMerge.cjs's renderer-facing merge IPC handler