claude-code-session-manager 0.75.3 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-B2ie8bbw.js} +2 -2
  2. package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-BIJPYw32.js} +1 -1
  3. package/dist/assets/{History-DnSi_OHm.js → History-CeY6dk9S.js} +2 -2
  4. package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-BFH2ocKg.js} +2 -2
  5. package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-36gj9wLz.js} +1 -1
  6. package/dist/assets/{Library-CaJVqVvi.js → Library-C-hBct39.js} +1 -1
  7. package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-CNq64VWV.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
  9. package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-DpGN0oyz.js} +1 -1
  10. package/dist/assets/{Memory-BDeqlqwH.js → Memory-D59hUjC4.js} +6 -6
  11. package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-DCgbaoci.js} +1 -1
  12. package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-DAmQ0DYV.js} +2 -2
  13. package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-Dyfgn6Is.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
  15. package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
  16. package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
  18. package/dist/assets/{Settings-DRRozLyT.js → Settings-BL4vf5aX.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
  20. package/dist/assets/{Skills-D8L66eiX.js → Skills-BV08gDUH.js} +2 -2
  21. package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-CLftSsDw.js} +1 -1
  22. package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
  23. package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-jCpuB6E5.js} +1 -1
  24. package/dist/assets/{Toggle-BTwsbxam.js → Toggle-D2paA1xf.js} +1 -1
  25. package/dist/assets/{index-DijufvkJ.js → index-BDRSqBl3.js} +704 -704
  26. package/dist/assets/{index-CMLnzdZC.css → index-CYhdtisq.css} +1 -1
  27. package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-6IOLjZZN.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +8 -2
  30. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  31. package/scripts/lib/activeSessions.cjs +116 -6
  32. package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
  33. package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
  34. package/scripts/render-project-pages.cjs +70 -0
  35. package/scripts/scheduler-mcp-server.cjs +269 -96
  36. package/scripts/validate-project-pages-summary.cjs +62 -0
  37. package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
  38. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  39. package/src/main/__tests__/health-delegation-chain.test.cjs +106 -0
  40. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  41. package/src/main/__tests__/prdAgentType.test.cjs +103 -0
  42. package/src/main/__tests__/prdCreate.test.cjs +247 -0
  43. package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
  44. package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
  45. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
  46. package/src/main/__tests__/projectPages.test.cjs +73 -1
  47. package/src/main/__tests__/rcaReport.test.cjs +54 -0
  48. package/src/main/__tests__/runVerify.test.cjs +94 -0
  49. package/src/main/__tests__/scheduler-autofix-select.test.cjs +58 -3
  50. package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
  51. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  52. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
  54. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
  55. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
  56. package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
  57. package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
  58. package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
  59. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
  60. package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
  61. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +207 -1
  62. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
  63. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  64. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
  65. package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
  66. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  67. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  68. package/src/main/bilkoHost.cjs +4 -3
  69. package/src/main/chatRunner.cjs +6 -1
  70. package/src/main/config.cjs +25 -33
  71. package/src/main/health.cjs +153 -2
  72. package/src/main/index.cjs +64 -5
  73. package/src/main/ipcSchemas.cjs +69 -1
  74. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  75. package/src/main/lib/__tests__/childWithLog.test.cjs +141 -0
  76. package/src/main/lib/__tests__/delegationReadiness.test.cjs +391 -42
  77. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  78. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +5 -3
  79. package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
  80. package/src/main/lib/__tests__/gitWorktree.test.cjs +290 -5
  81. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  82. package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
  83. package/src/main/lib/__tests__/jobWorktree.test.cjs +6 -4
  84. package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
  85. package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
  86. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  87. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +102 -0
  88. package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
  89. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
  90. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  91. package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
  92. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  93. package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
  94. package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
  95. package/src/main/lib/__tests__/reaperHelpers.test.cjs +133 -0
  96. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  97. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  98. package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
  99. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  100. package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
  101. package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
  102. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  103. package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
  104. package/src/main/lib/activeIndexMerge.cjs +15 -0
  105. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  106. package/src/main/lib/agentModelResolve.cjs +58 -0
  107. package/src/main/lib/buildTarget.cjs +3 -2
  108. package/src/main/lib/childWithLog.cjs +69 -2
  109. package/src/main/lib/claudeBin.cjs +54 -1
  110. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  111. package/src/main/lib/definitionOfDone.cjs +3 -2
  112. package/src/main/lib/delegationReadiness.cjs +514 -26
  113. package/src/main/lib/ephemeralCwd.cjs +78 -0
  114. package/src/main/lib/epicDelegationStats.cjs +2 -1
  115. package/src/main/lib/epicMint.cjs +17 -1
  116. package/src/main/lib/epicStatusMirror.cjs +95 -0
  117. package/src/main/lib/epicValidationHook.cjs +2 -1
  118. package/src/main/lib/epicWorktreeMint.cjs +5 -2
  119. package/src/main/lib/fixChainDepth.cjs +45 -0
  120. package/src/main/lib/gitWorktree.cjs +520 -21
  121. package/src/main/lib/jobWorktree.cjs +2 -0
  122. package/src/main/lib/landedSinceRun.cjs +55 -0
  123. package/src/main/lib/launchFailure.cjs +357 -0
  124. package/src/main/lib/loadGate.cjs +134 -0
  125. package/src/main/lib/mcpToolCatalog.cjs +370 -0
  126. package/src/main/lib/opsErrorLog.cjs +12 -1
  127. package/src/main/lib/opsOwnership.cjs +106 -0
  128. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  129. package/src/main/lib/prdAgentType.cjs +84 -0
  130. package/src/main/lib/prdCreate.cjs +103 -15
  131. package/src/main/lib/prdDeclaredPaths.cjs +70 -0
  132. package/src/main/lib/prdFrontmatter.cjs +17 -3
  133. package/src/main/lib/prdLocations.cjs +13 -6
  134. package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
  135. package/src/main/lib/projectPageSummarySchema.cjs +181 -0
  136. package/src/main/lib/projectRootResolve.cjs +134 -0
  137. package/src/main/lib/promptSessionSchema.cjs +7 -0
  138. package/src/main/lib/queueHealth.cjs +38 -0
  139. package/src/main/lib/queueStore.cjs +40 -7
  140. package/src/main/lib/quietMachineLease.cjs +48 -0
  141. package/src/main/lib/rcaReport.cjs +54 -4
  142. package/src/main/lib/reaperHelpers.cjs +64 -1
  143. package/src/main/lib/scheduleJobSchema.cjs +31 -0
  144. package/src/main/lib/scheduleJobTransitions.cjs +6 -2
  145. package/src/main/lib/schedulerBatch.cjs +301 -55
  146. package/src/main/lib/schedulerConfig.cjs +99 -0
  147. package/src/main/projectBrief.cjs +3 -2
  148. package/src/main/projectPages.cjs +162 -3
  149. package/src/main/promptSessionTranscript.cjs +0 -0
  150. package/src/main/pty.cjs +5 -0
  151. package/src/main/queueOps.cjs +15 -8
  152. package/src/main/runVerify.cjs +50 -9
  153. package/src/main/scheduler/prdParser.cjs +18 -1
  154. package/src/main/scheduler.cjs +1701 -130
  155. package/src/main/seedAgentPersonas.cjs +62 -21
  156. package/src/main/seedSchedulerMcp.cjs +58 -4
  157. package/src/main/templates/project-pages-catalog.json +741 -0
  158. package/src/main/templates/project-pages-pipeline.md +417 -0
  159. package/src/preload/api.d.ts +187 -3
  160. package/src/preload/index.cjs +9 -0
  161. package/src/seed/agents/project-home-builder.md +59 -0
  162. package/dist/assets/SaveBar-D-gCUx4n.js +0 -1
  163. package/dist/assets/Scheduler-Bpd4OGju.js +0 -14
  164. package/dist/assets/TagLibrary-E5CLeuVk.js +0 -1
@@ -8,17 +8,31 @@
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 DESTRUCTIVE_GIT_GUARD_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'scripts', 'hooks', 'guard-destructive-git.cjs');
32
+ const DESTRUCTIVE_GIT_GUARD_MATCHER = 'Bash';
33
+ const LIVE_PROBE_TIMEOUT_MS = 10_000;
34
+ const LIVE_PROBE_TTL_MS = 60_000;
35
+ const REQUIRED_LIVE_TOOLS = ['scheduler_create_prd', 'session_manager_help'];
22
36
 
23
37
  /** Read + JSON.parse a file, returning `fallback` on any read/parse failure. */
24
38
  function readJsonSafe(absPath, fallback) {
@@ -29,27 +43,257 @@ function readJsonSafe(absPath, fallback) {
29
43
  }
30
44
  }
31
45
 
46
+ const SCHEDULER_MCP_FIX_CMD = `claude mcp add ${SCHEDULER_MCP_NAME} --scope user -- node ${SCHEDULER_MCP_SERVER_SCRIPT}`;
47
+
48
+ /**
49
+ * `ok` must mean "an agent in this project can actually reach a working
50
+ * server", not "some entry named session-manager-scheduler exists somewhere".
51
+ * A user-scope entry whose script path is relative or points at a file that
52
+ * no longer exists (repo moved, worktree deleted) is exactly as dead as no
53
+ * entry at all — the difference is it shows GREEN under a substring check,
54
+ * which is the incident this PRD closes.
55
+ */
32
56
  function checkSchedulerMcp({ cwd, homeDir }) {
33
57
  const userConfig = readJsonSafe(path.join(homeDir, '.claude.json'), null);
34
- const userHas = !!userConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
58
+ const userEntry = userConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
35
59
 
36
60
  const projectConfig = readJsonSafe(path.join(cwd, '.mcp.json'), null);
37
61
  const projectHas = !!projectConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
38
62
 
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`;
63
+ if (userEntry) {
64
+ const args = Array.isArray(userEntry.args) ? userEntry.args : [];
65
+ const scriptArg = args[args.length - 1];
66
+ const isAbsolute = typeof scriptArg === 'string' && path.isAbsolute(scriptArg);
67
+ const exists = isAbsolute && fs.existsSync(scriptArg);
68
+ if (!isAbsolute || !exists) {
69
+ return {
70
+ id: 'scheduler-mcp',
71
+ label: 'Scheduler MCP server registered',
72
+ ok: false,
73
+ detail: !isAbsolute
74
+ ? `user-scope ${SCHEDULER_MCP_NAME} registration points at a non-absolute script path (${scriptArg})`
75
+ : `user-scope ${SCHEDULER_MCP_NAME} registration points at a script that no longer exists (${scriptArg})`,
76
+ fix: SCHEDULER_MCP_FIX_CMD,
77
+ };
78
+ }
79
+ return {
80
+ id: 'scheduler-mcp',
81
+ label: 'Scheduler MCP server registered',
82
+ ok: true,
83
+ detail: `${SCHEDULER_MCP_NAME} registered at user scope (~/.claude.json)`,
84
+ fix: null,
85
+ };
86
+ }
87
+
88
+ if (projectHas) {
89
+ return {
90
+ id: 'scheduler-mcp',
91
+ label: 'Scheduler MCP server registered',
92
+ ok: true,
93
+ detail: `${SCHEDULER_MCP_NAME} registered at project scope (${cwd}/.mcp.json)`,
94
+ fix: null,
95
+ };
96
+ }
44
97
 
45
98
  return {
46
99
  id: 'scheduler-mcp',
47
100
  label: 'Scheduler MCP server registered',
48
- ok,
49
- detail,
50
- fix: ok
101
+ ok: false,
102
+ detail: `no ${SCHEDULER_MCP_NAME} entry in ~/.claude.json or ${cwd}/.mcp.json`,
103
+ fix: SCHEDULER_MCP_FIX_CMD,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * A project-scope `.mcp.json` entry is never wrong by itself, but once a
109
+ * user-scope entry also exists it is pure risk: `.mcp.json` commonly carries
110
+ * a RELATIVE script path (this repo's own copy did, until this PRD), and a
111
+ * relative path resolves against the process's cwd — which is the worktree
112
+ * root for an Epic, not this repo root — silently shadowing the working
113
+ * user-scope registration with a broken one. Reported as a warning, never a
114
+ * failure: enabling/disabling project-scope servers is the human's call
115
+ * (enableAllProjectMcpServers), not something this probe decides.
116
+ */
117
+ function checkSchedulerMcpProjectDuplicate({ cwd, homeDir }) {
118
+ const userConfig = readJsonSafe(path.join(homeDir, '.claude.json'), null);
119
+ const userHas = !!userConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
120
+
121
+ const projectConfig = readJsonSafe(path.join(cwd, '.mcp.json'), null);
122
+ const projectHas = !!projectConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
123
+
124
+ const warn = userHas && projectHas;
125
+ return {
126
+ id: 'scheduler-mcp-project-duplicate',
127
+ label: 'No project-scope duplicate of the scheduler MCP',
128
+ ok: true,
129
+ warn,
130
+ detail: warn
131
+ ? `${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`
132
+ : `no project-scope duplicate of ${SCHEDULER_MCP_NAME} in ${cwd}/.mcp.json`,
133
+ fix: warn
134
+ ? `Remove the ${SCHEDULER_MCP_NAME} entry from ${cwd}/.mcp.json — the user-scope registration already covers every project`
135
+ : null,
136
+ };
137
+ }
138
+
139
+ /** Cache key: a fresh registration signature (command+args+env) always misses. */
140
+ function liveProbeSignature({ command, args, env }) {
141
+ return JSON.stringify({ command, args, env: env || {} });
142
+ }
143
+
144
+ const liveProbeCache = new Map(); // signature -> { result: Promise, expiresAt }
145
+
146
+ /** Test-only: forces the next probe for every signature to actually spawn. */
147
+ function clearLiveProbeCache() {
148
+ liveProbeCache.clear();
149
+ }
150
+
151
+ /**
152
+ * Spawns the registered command+args with the registered env (via
153
+ * cleanChildEnv/pathWithUserBins — the same env the scheduler gives
154
+ * `claude -p`, so this fails the way a real session would fail, not the way
155
+ * the Electron process would), sends an initialize + tools/list JSON-RPC
156
+ * pair over stdin, and resolves once a tools/list result naming every entry
157
+ * in REQUIRED_LIVE_TOOLS arrives, or the 10s bound is hit. Never rejects,
158
+ * never leaves the child running — every path clears the timer and kills it.
159
+ */
160
+ function probeSchedulerMcpLive({ command, args, env }) {
161
+ return new Promise((resolve) => {
162
+ let child;
163
+ try {
164
+ child = spawn(command, args, {
165
+ cwd: os.homedir(),
166
+ env: cleanChildEnv({ PATH: pathWithUserBins(), ...(env || {}) }),
167
+ stdio: ['pipe', 'pipe', 'pipe'],
168
+ });
169
+ } catch (err) {
170
+ resolve({ ok: false, detail: err?.message ?? String(err) });
171
+ return;
172
+ }
173
+
174
+ let settled = false;
175
+ let stdoutBuf = '';
176
+ let firstStderrLine = null;
177
+
178
+ const finish = (result) => {
179
+ if (settled) return;
180
+ settled = true;
181
+ clearTimeout(timer);
182
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
183
+ resolve(result);
184
+ };
185
+
186
+ const timer = setTimeout(() => finish({ ok: false, detail: 'timeout' }), LIVE_PROBE_TIMEOUT_MS);
187
+
188
+ child.on('error', (err) => finish({ ok: false, detail: err?.message ?? String(err) }));
189
+
190
+ child.stderr?.on('data', (chunk) => {
191
+ if (firstStderrLine != null) return;
192
+ const line = chunk.toString('utf8').split('\n').find((l) => l.trim());
193
+ if (line) firstStderrLine = line.trim();
194
+ });
195
+
196
+ child.stdout?.on('data', (chunk) => {
197
+ stdoutBuf += chunk.toString('utf8');
198
+ let idx;
199
+ while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
200
+ const line = stdoutBuf.slice(0, idx).trim();
201
+ stdoutBuf = stdoutBuf.slice(idx + 1);
202
+ if (!line) continue;
203
+ let msg;
204
+ try { msg = JSON.parse(line); } catch { continue; }
205
+ if (msg && msg.id === 2) {
206
+ const tools = Array.isArray(msg.result?.tools) ? msg.result.tools.map((t) => t?.name) : null;
207
+ if (!tools) {
208
+ finish({ ok: false, detail: msg.error?.message ? `tools/list error: ${msg.error.message}` : 'tools/list returned no tools array' });
209
+ return;
210
+ }
211
+ const missing = REQUIRED_LIVE_TOOLS.filter((t) => !tools.includes(t));
212
+ if (missing.length > 0) {
213
+ finish({ ok: false, detail: `tools/list did not include: ${missing.join(', ')}` });
214
+ } else {
215
+ finish({ ok: true, detail: `tools/list returned ${tools.length} tool(s) including ${REQUIRED_LIVE_TOOLS.join(', ')}` });
216
+ }
217
+ return;
218
+ }
219
+ }
220
+ });
221
+
222
+ child.on('close', () => {
223
+ finish({ ok: false, detail: firstStderrLine || 'process exited before answering tools/list' });
224
+ });
225
+
226
+ try {
227
+ const initialize = {
228
+ jsonrpc: '2.0',
229
+ id: 1,
230
+ method: 'initialize',
231
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'sm-readiness', version: '1' } },
232
+ };
233
+ const toolsList = { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} };
234
+ child.stdin.write(`${JSON.stringify(initialize)}\n`);
235
+ child.stdin.write(`${JSON.stringify(toolsList)}\n`);
236
+ } catch (err) {
237
+ finish({ ok: false, detail: err?.message ?? String(err) });
238
+ }
239
+ });
240
+ }
241
+
242
+ function probeSchedulerMcpLiveMemoized({ command, args, env }) {
243
+ const sig = liveProbeSignature({ command, args, env });
244
+ const now = Date.now();
245
+ const cached = liveProbeCache.get(sig);
246
+ if (cached && cached.expiresAt > now) return cached.result;
247
+
248
+ const result = probeSchedulerMcpLive({ command, args, env });
249
+ liveProbeCache.set(sig, { result, expiresAt: now + LIVE_PROBE_TTL_MS });
250
+ return result;
251
+ }
252
+
253
+ /**
254
+ * Answers "does the server process actually answer", which
255
+ * checkSchedulerMcp's static-config check cannot — a registration can point
256
+ * at a script that spawns and dies on a syntax error, or at `node` when
257
+ * `node` isn't on PATH for a real session's env, and both show GREEN there.
258
+ * Skipped (not failed) when the registration check already failed, so a
259
+ * missing entry produces one red row, not two.
260
+ */
261
+ async function checkSchedulerMcpLive({ cwd, homeDir, schedulerMcpCheck }) {
262
+ const base = { id: 'scheduler-mcp-live', label: 'Scheduler MCP server answers tools/list' };
263
+
264
+ if (!schedulerMcpCheck.ok) {
265
+ return { ...base, ok: true, skipped: true, detail: 'skipped — scheduler-mcp registration check already failed', fix: null };
266
+ }
267
+
268
+ // ONLY ever spawn the USER-scope entry. A project-scope `.mcp.json` is
269
+ // attacker-controllable (any repo a human points Session Manager at) and
270
+ // Claude Code deliberately gates running it behind the human's own
271
+ // enabledMcpjsonServers/enableAllProjectMcpServers approval — a gate this
272
+ // probe has no way to check. checkDelegationReadiness runs unattended
273
+ // (New Epic banner on mount, session_manager_help), so falling back to a
274
+ // project-scope command here would let a malicious repo's .mcp.json get
275
+ // executed the moment its cwd is merely probed, bypassing that approval
276
+ // entirely. Auto-approving project-scope servers is explicitly out of
277
+ // scope for this probe — see this PRD's "Out of scope".
278
+ const userConfig = readJsonSafe(path.join(homeDir, '.claude.json'), null);
279
+ const entry = userConfig?.mcpServers?.[SCHEDULER_MCP_NAME];
280
+
281
+ if (!entry?.command) {
282
+ 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 };
283
+ }
284
+
285
+ const command = entry.command;
286
+ const args = Array.isArray(entry.args) ? entry.args : [];
287
+ const env = entry.env && typeof entry.env === 'object' ? entry.env : {};
288
+
289
+ const result = await probeSchedulerMcpLiveMemoized({ command, args, env });
290
+ return {
291
+ ...base,
292
+ ok: result.ok,
293
+ detail: result.detail,
294
+ fix: result.ok
51
295
  ? null
52
- : `claude mcp add ${SCHEDULER_MCP_NAME} --scope user -- node ${SCHEDULER_MCP_SERVER_SCRIPT}`,
296
+ : `Run \`${command} ${args.join(' ')}\` by hand in a fresh shell to see the startup error directly, or \`claude mcp get ${SCHEDULER_MCP_NAME}\``,
53
297
  };
54
298
  }
55
299
 
@@ -98,39 +342,273 @@ function checkAgentPersonas({ cwd, homeDir }) {
98
342
  };
99
343
  }
100
344
 
345
+ /**
346
+ * Pull a guard script's path out of a hook `command` string.
347
+ *
348
+ * Commands are `node <script>` (optionally quoted). `scriptBasename` names
349
+ * the exact guard file we're looking for — anything else is not that hook.
350
+ */
351
+ function extractGuardScriptPath(command, scriptBasename) {
352
+ const esc = scriptBasename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
353
+ const re = new RegExp(`(?:"([^"]*${esc})"|'([^']*${esc})'|(\\S*${esc}))`);
354
+ const match = String(command).match(re);
355
+ if (!match) return null;
356
+ return match[1] || match[2] || match[3] || null;
357
+ }
358
+
359
+ /**
360
+ * `ok` must mean "a hook that will actually run", not "the settings file
361
+ * mentions guard-prd-writes somewhere".
362
+ *
363
+ * The failure mode this guards against: a PreToolUse command that exits
364
+ * non-zero WITHOUT exit code 2 is a non-blocking error, so a hook pointing at
365
+ * a script that doesn't exist in this project silently guards nothing — while
366
+ * a substring match would flip this check green. That is strictly worse than
367
+ * an honest red. So we resolve the command's script path (relative resolves
368
+ * against the project cwd, which is what the harness runs the hook with) and
369
+ * require the file to exist on disk.
370
+ */
101
371
  function checkPrdWriteGuard({ cwd }) {
102
372
  const settings = readJsonSafe(path.join(cwd, '.claude', 'settings.json'), null);
103
373
  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
- );
374
+
375
+ let mentioned = false;
376
+ let resolvedScript = null;
377
+ for (const matcher of preToolUse) {
378
+ if (!Array.isArray(matcher?.hooks)) continue;
379
+ for (const h of matcher.hooks) {
380
+ if (typeof h?.command !== 'string' || !h.command.includes('guard-prd-writes')) continue;
381
+ mentioned = true;
382
+ const raw = extractGuardScriptPath(h.command, 'guard-prd-writes.cjs');
383
+ if (!raw) continue;
384
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
385
+ if (fs.existsSync(abs)) { resolvedScript = abs; break; }
386
+ }
387
+ if (resolvedScript) break;
388
+ }
389
+
390
+ const ok = !!resolvedScript;
391
+ let detail;
392
+ if (ok) detail = `guard-prd-writes PreToolUse hook found in ${cwd}/.claude/settings.json, resolving to ${resolvedScript}`;
393
+ 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`;
394
+ else detail = `no guard-prd-writes PreToolUse hook in ${cwd}/.claude/settings.json`;
108
395
 
109
396
  return {
110
397
  id: 'prd-write-guard',
111
398
  label: 'PRD-write guard hook installed',
112
399
  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`,
400
+ detail,
401
+ fix: ok
402
+ ? null
403
+ : `Add a PreToolUse hook to ${cwd}/.claude/settings.json matching Write|Edit|NotebookEdit that runs node ${PRD_WRITE_GUARD_SCRIPT}`,
404
+ // The one check with a sanctioned one-press install (installPrdWriteGuard).
405
+ fixAction: ok ? null : 'install-prd-write-guard',
406
+ };
407
+ }
408
+
409
+
410
+ /**
411
+ * Install the PRD-write guard into `<cwd>/.claude/settings.json`.
412
+ *
413
+ * ── The standardized approach is REFERENCE, not vendor ────────────────────
414
+ * The hook command points at session-manager's own copy of
415
+ * scripts/hooks/guard-prd-writes.cjs by ABSOLUTE path. We deliberately do NOT
416
+ * copy the script into the adopting repo:
417
+ *
418
+ * - Vendoring drifts. It already did, with exactly one adopter
419
+ * (social-signals-trader's copy is missing the 26-line adoption header).
420
+ * - The thing being guarded is `session-manager-operations/scheduler/` —
421
+ * Session Manager's own territory in someone else's repo. The enforcement
422
+ * logic belongs with the owner, not forked into every consumer.
423
+ * - Adopting repos need no Node toolchain of their own; `node` plus this
424
+ * absolute path is the entire dependency.
425
+ *
426
+ * The liability of reference — a silently-no-op hook if the script ever moves
427
+ * — is closed by checkPrdWriteGuard() above, which resolves the command's
428
+ * script path and fails the check when the file is gone.
429
+ *
430
+ * Merges into any existing hooks block rather than clobbering it: other
431
+ * PreToolUse matchers, other hooks under the same matcher, and every unrelated
432
+ * settings key are preserved. Idempotent — a healthy guard is a no-op, and a
433
+ * guard entry pointing at a missing script is repaired in place.
434
+ */
435
+ async function installPrdWriteGuard({ cwd }) {
436
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
437
+ // config.cjs's validateWrite only permits `<registered project root>/.claude/**`,
438
+ // and a project the user is merely *pointing* the New Epic dialog at may
439
+ // never have spawned a pty — same cold-boot hazard config.cjs:73-81 already
440
+ // documents for PromptSession archive writes. `cwd` here IS a project root.
441
+ addAllowedRoot(cwd);
442
+
443
+ const existing = fs.existsSync(settingsPath) ? readJsonSafe(settingsPath, undefined) : {};
444
+ if (existing === undefined) {
445
+ // Unparseable settings — refuse rather than silently discarding whatever
446
+ // the human has in there.
447
+ return { ok: false, action: 'error', error: `${settingsPath} is not valid JSON — fix it by hand before installing the guard` };
448
+ }
449
+ const settings = (existing && typeof existing === 'object' && !Array.isArray(existing)) ? existing : {};
450
+
451
+ const command = `node ${PRD_WRITE_GUARD_SCRIPT}`;
452
+ const hooks = (settings.hooks && typeof settings.hooks === 'object') ? settings.hooks : {};
453
+ const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse.slice() : [];
454
+
455
+ // Already healthy? Nothing to do.
456
+ if (checkPrdWriteGuard({ cwd }).ok) {
457
+ return { ok: true, action: 'already-installed', settingsPath, command };
458
+ }
459
+
460
+ // Repair a broken entry in place, wherever it sits, before appending.
461
+ let repaired = false;
462
+ for (const matcher of preToolUse) {
463
+ if (!Array.isArray(matcher?.hooks)) continue;
464
+ for (const h of matcher.hooks) {
465
+ if (typeof h?.command === 'string' && h.command.includes('guard-prd-writes')) {
466
+ h.command = command;
467
+ h.type = 'command';
468
+ repaired = true;
469
+ }
470
+ }
471
+ }
472
+
473
+ if (!repaired) {
474
+ const target = preToolUse.find((m) => m?.matcher === PRD_WRITE_GUARD_MATCHER);
475
+ if (target) {
476
+ target.hooks = Array.isArray(target.hooks) ? target.hooks : [];
477
+ target.hooks.push({ type: 'command', command });
478
+ } else {
479
+ preToolUse.push({ matcher: PRD_WRITE_GUARD_MATCHER, hooks: [{ type: 'command', command }] });
480
+ }
481
+ }
482
+
483
+ const next = { ...settings, hooks: { ...hooks, PreToolUse: preToolUse } };
484
+ await writeJson(settingsPath, next);
485
+
486
+ return { ok: true, action: repaired ? 'repaired' : 'installed', settingsPath, command };
487
+ }
488
+
489
+ /**
490
+ * `ok` must mean "a hook that will actually run", not "the settings file
491
+ * mentions guard-destructive-git somewhere" — same rationale as
492
+ * checkPrdWriteGuard above (a PreToolUse command pointing at a script that
493
+ * doesn't exist in this project exits non-zero WITHOUT code 2, a
494
+ * non-blocking error that silently guards nothing).
495
+ */
496
+ function checkDestructiveGitGuard({ cwd }) {
497
+ const settings = readJsonSafe(path.join(cwd, '.claude', 'settings.json'), null);
498
+ const preToolUse = Array.isArray(settings?.hooks?.PreToolUse) ? settings.hooks.PreToolUse : [];
499
+
500
+ let mentioned = false;
501
+ let resolvedScript = null;
502
+ for (const matcher of preToolUse) {
503
+ if (!Array.isArray(matcher?.hooks)) continue;
504
+ for (const h of matcher.hooks) {
505
+ if (typeof h?.command !== 'string' || !h.command.includes('guard-destructive-git')) continue;
506
+ mentioned = true;
507
+ const raw = extractGuardScriptPath(h.command, 'guard-destructive-git.cjs');
508
+ if (!raw) continue;
509
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
510
+ if (fs.existsSync(abs)) { resolvedScript = abs; break; }
511
+ }
512
+ if (resolvedScript) break;
513
+ }
514
+
515
+ const ok = !!resolvedScript;
516
+ let detail;
517
+ if (ok) detail = `guard-destructive-git PreToolUse hook found in ${cwd}/.claude/settings.json, resolving to ${resolvedScript}`;
518
+ else if (mentioned) detail = `guard-destructive-git PreToolUse hook in ${cwd}/.claude/settings.json names a script that does not exist — it would silently guard nothing`;
519
+ else detail = `no guard-destructive-git PreToolUse hook in ${cwd}/.claude/settings.json`;
520
+
521
+ return {
522
+ id: 'destructive-git-guard',
523
+ label: 'Destructive-git guard hook installed',
524
+ ok,
525
+ detail,
116
526
  fix: ok
117
527
  ? null
118
- : `Add a PreToolUse hook to ${cwd}/.claude/settings.json matching Write|Edit|NotebookEdit that runs node scripts/hooks/guard-prd-writes.cjs`,
528
+ : `Add a PreToolUse hook to ${cwd}/.claude/settings.json matching Bash that runs node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}`,
529
+ fixAction: ok ? null : 'install-destructive-git-guard',
119
530
  };
120
531
  }
121
532
 
122
533
  /**
123
- * Runs all four delegation-readiness checks for `cwd`. Every filesystem read
534
+ * Install the destructive-git guard into `<cwd>/.claude/settings.json`.
535
+ * Same reference-not-vendor rationale, and the same merge-into-existing-
536
+ * hooks-block / repair-in-place / idempotent behaviour as installPrdWriteGuard
537
+ * above — see that function's header. The one difference is the matcher
538
+ * (`Bash`, not `Write|Edit|NotebookEdit`), so this lands as its own
539
+ * `PreToolUse` entry rather than sharing installPrdWriteGuard's.
540
+ */
541
+ async function installDestructiveGitGuard({ cwd }) {
542
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
543
+ addAllowedRoot(cwd);
544
+
545
+ const existing = fs.existsSync(settingsPath) ? readJsonSafe(settingsPath, undefined) : {};
546
+ if (existing === undefined) {
547
+ return { ok: false, action: 'error', error: `${settingsPath} is not valid JSON — fix it by hand before installing the guard` };
548
+ }
549
+ const settings = (existing && typeof existing === 'object' && !Array.isArray(existing)) ? existing : {};
550
+
551
+ const command = `node ${DESTRUCTIVE_GIT_GUARD_SCRIPT}`;
552
+ const hooks = (settings.hooks && typeof settings.hooks === 'object') ? settings.hooks : {};
553
+ const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse.slice() : [];
554
+
555
+ if (checkDestructiveGitGuard({ cwd }).ok) {
556
+ return { ok: true, action: 'already-installed', settingsPath, command };
557
+ }
558
+
559
+ let repaired = false;
560
+ for (const matcher of preToolUse) {
561
+ if (!Array.isArray(matcher?.hooks)) continue;
562
+ for (const h of matcher.hooks) {
563
+ if (typeof h?.command === 'string' && h.command.includes('guard-destructive-git')) {
564
+ h.command = command;
565
+ h.type = 'command';
566
+ repaired = true;
567
+ }
568
+ }
569
+ }
570
+
571
+ if (!repaired) {
572
+ const target = preToolUse.find((m) => m?.matcher === DESTRUCTIVE_GIT_GUARD_MATCHER);
573
+ if (target) {
574
+ target.hooks = Array.isArray(target.hooks) ? target.hooks : [];
575
+ target.hooks.push({ type: 'command', command });
576
+ } else {
577
+ preToolUse.push({ matcher: DESTRUCTIVE_GIT_GUARD_MATCHER, hooks: [{ type: 'command', command }] });
578
+ }
579
+ }
580
+
581
+ const next = { ...settings, hooks: { ...hooks, PreToolUse: preToolUse } };
582
+ await writeJson(settingsPath, next);
583
+
584
+ return { ok: true, action: repaired ? 'repaired' : 'installed', settingsPath, command };
585
+ }
586
+
587
+ /**
588
+ * Runs all seven delegation-readiness checks for `cwd`. Every filesystem read
124
589
  * is wrapped (readJsonSafe / try-catch) so a missing or unparseable file
125
- * yields ok:false with a detail, never a thrown exception.
590
+ * yields ok:false with a detail, never a thrown exception. Async because
591
+ * scheduler-mcp-live genuinely spawns a process and waits on it (bounded by
592
+ * LIVE_PROBE_TIMEOUT_MS, and memoized per registration signature so repeat
593
+ * callers — the New Epic banner, session_manager_help — don't each pay for
594
+ * a fresh spawn).
126
595
  */
127
- function checkDelegationReadiness({ cwd, homeDir = os.homedir() }) {
596
+ async function checkDelegationReadiness({ cwd, homeDir = os.homedir() }) {
597
+ const schedulerMcpCheck = checkSchedulerMcp({ cwd, homeDir });
598
+ const schedulerMcpLiveCheck = await checkSchedulerMcpLive({ cwd, homeDir, schedulerMcpCheck });
599
+
600
+ // `fixAction: null` / `warn: false` are the defaults — only a check with a
601
+ // sanctioned one-press installer overrides fixAction (today: prd-write-guard,
602
+ // destructive-git-guard), and only scheduler-mcp-project-duplicate ever sets warn.
128
603
  const checks = [
129
- checkSchedulerMcp({ cwd, homeDir }),
604
+ schedulerMcpCheck,
605
+ schedulerMcpLiveCheck,
606
+ checkSchedulerMcpProjectDuplicate({ cwd, homeDir }),
130
607
  checkDevPlugin({ homeDir }),
131
608
  checkAgentPersonas({ cwd, homeDir }),
132
609
  checkPrdWriteGuard({ cwd }),
133
- ];
610
+ checkDestructiveGitGuard({ cwd }),
611
+ ].map((c) => ({ fixAction: null, warn: false, ...c }));
134
612
 
135
613
  return {
136
614
  ok: checks.every((c) => c.ok),
@@ -138,4 +616,14 @@ function checkDelegationReadiness({ cwd, homeDir = os.homedir() }) {
138
616
  };
139
617
  }
140
618
 
141
- module.exports = { checkDelegationReadiness };
619
+ module.exports = {
620
+ checkDelegationReadiness,
621
+ installPrdWriteGuard,
622
+ installDestructiveGitGuard,
623
+ probeSchedulerMcpLive,
624
+ clearLiveProbeCache,
625
+ PRD_WRITE_GUARD_SCRIPT,
626
+ PRD_WRITE_GUARD_MATCHER,
627
+ DESTRUCTIVE_GIT_GUARD_SCRIPT,
628
+ DESTRUCTIVE_GIT_GUARD_MATCHER,
629
+ };
@@ -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 };