claude-code-session-manager 0.75.2 → 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 (102) hide show
  1. package/dist/assets/{AgentLibrary-s1jRGOji.js → AgentLibrary-CBx9l4zN.js} +1 -1
  2. package/dist/assets/{DataModel-DNefcWbk.js → DataModel-Bf0EIE_t.js} +1 -1
  3. package/dist/assets/{History-4sMk80yI.js → History-CpdtWhC8.js} +1 -1
  4. package/dist/assets/{Hooks-CNUVwH5P.js → Hooks-DyUbMDmg.js} +1 -1
  5. package/dist/assets/{HostBilko-BhkyvIhw.js → HostBilko-By-wIpry.js} +1 -1
  6. package/dist/assets/{Library-BpockanA.js → Library-CQmo4QVC.js} +1 -1
  7. package/dist/assets/{ListDetail-Cre7h3U0.js → ListDetail-BQMd6NOm.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-x-FzvaXm.js → MarkdownEditor-DEp43FXX.js} +1 -1
  9. package/dist/assets/{McpServers-CXAcQLMB.js → McpServers-CLarzwqA.js} +1 -1
  10. package/dist/assets/{Memory-CGBv1Rco.js → Memory-B0sCdIy1.js} +1 -1
  11. package/dist/assets/{Panel-czEs21N2.js → Panel-BhWPVOCD.js} +1 -1
  12. package/dist/assets/{Permissions-rGkLOpqI.js → Permissions-Ddlq8T_O.js} +1 -1
  13. package/dist/assets/{Plugins-BTmB_vgU.js → Plugins-D2oA_2Jl.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-dmCivDq5.js → ProvenanceBadge-DgAgavUM.js} +1 -1
  15. package/dist/assets/{SaveBar-D80TNkAx.js → SaveBar-Qvc4Ek-H.js} +1 -1
  16. package/dist/assets/{Scheduler-C6wHSZP7.js → Scheduler-BmYJvNzK.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-DsaiYTgl.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
  18. package/dist/assets/{Settings-m2fyU8Q5.js → Settings-2Vx3X5SI.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-yS-lgBFU.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
  20. package/dist/assets/{Skills-DtQgCs9-.js → Skills-Cmrz_LeN.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CLuVdoMA.js → SystemPrompt-DVA1eYDP.js} +1 -1
  22. package/dist/assets/{TagLibrary-DQXoI752.js → TagLibrary-DYJGAKZu.js} +1 -1
  23. package/dist/assets/{TiptapBody-BGKVXrvq.js → TiptapBody-DmPc3amD.js} +1 -1
  24. package/dist/assets/{Toggle-RFnEV71m.js → Toggle-zfd5LJkK.js} +1 -1
  25. package/dist/assets/{index-Ddrw7J7O.js → index-B_4PNh9T.js} +676 -676
  26. package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
  27. package/dist/assets/{settingsSchema-LwuJXKD1.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 +146 -4
  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 +202 -0
  59. package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +51 -0
  60. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  61. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  62. package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -0
  63. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  64. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  65. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  66. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  67. package/src/main/lib/activeIndexMerge.cjs +15 -0
  68. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  69. package/src/main/lib/buildTarget.cjs +3 -2
  70. package/src/main/lib/childWithLog.cjs +33 -1
  71. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  72. package/src/main/lib/delegationReadiness.cjs +408 -26
  73. package/src/main/lib/ephemeralCwd.cjs +78 -0
  74. package/src/main/lib/epicDelegationStats.cjs +2 -1
  75. package/src/main/lib/epicMint.cjs +17 -1
  76. package/src/main/lib/epicStatusMirror.cjs +95 -0
  77. package/src/main/lib/epicValidationHook.cjs +2 -1
  78. package/src/main/lib/gitWorktree.cjs +56 -2
  79. package/src/main/lib/jobWorktree.cjs +1 -0
  80. package/src/main/lib/loadGate.cjs +134 -0
  81. package/src/main/lib/mcpToolCatalog.cjs +285 -0
  82. package/src/main/lib/opsErrorLog.cjs +12 -1
  83. package/src/main/lib/opsOwnership.cjs +94 -0
  84. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  85. package/src/main/lib/prdCreate.cjs +46 -14
  86. package/src/main/lib/prdLocations.cjs +13 -6
  87. package/src/main/lib/projectRootResolve.cjs +134 -0
  88. package/src/main/lib/promptSessionSchema.cjs +7 -0
  89. package/src/main/lib/queueStore.cjs +70 -8
  90. package/src/main/lib/rcaReport.cjs +1 -1
  91. package/src/main/lib/reaperHelpers.cjs +47 -1
  92. package/src/main/lib/schedulerBatch.cjs +171 -29
  93. package/src/main/lib/schedulerConfig.cjs +80 -0
  94. package/src/main/projectBrief.cjs +3 -2
  95. package/src/main/projectPages.cjs +2 -1
  96. package/src/main/promptSessionTranscript.cjs +0 -0
  97. package/src/main/pty.cjs +5 -0
  98. package/src/main/queueOps.cjs +15 -8
  99. package/src/main/scheduler.cjs +346 -49
  100. package/src/main/seedSchedulerMcp.cjs +58 -4
  101. package/src/preload/api.d.ts +69 -1
  102. package/src/preload/index.cjs +2 -0
@@ -158,6 +158,97 @@ function checkOpsWrite(absPath, writer) {
158
158
  function assertOpsWrite(absPath, writer) {
159
159
  const verdict = checkOpsWrite(absPath, writer);
160
160
  if (!verdict.ok) throw new Error(verdict.error);
161
+ // Ownership is one question; WHERE the ops root lives is another. A writer
162
+ // that bypasses the path helpers below and builds its own absolute path can
163
+ // still aim at an ephemeral project root (a worktree, os.tmpdir()) — refuse
164
+ // that here too so the single-writer gate is also the last line against the
165
+ // worktree-cwd hazard (PRD 1082; incidents 2026-08-30, 2026-09-01).
166
+ const { inOps } = parseOpsPath(absPath);
167
+ if (inOps) {
168
+ const segs = absPath.split(path.sep);
169
+ const idx = segs.lastIndexOf(OPS_ROOT_DIR);
170
+ const projectRoot = segs.slice(0, idx).join(path.sep) || path.sep;
171
+ const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
172
+ if (isEphemeralCwd(projectRoot)) {
173
+ const err = new Error(
174
+ `refusing to write ${OPS_ROOT_DIR}/ state under an ephemeral project root `
175
+ + `(tmpdir or linked git worktree): "${projectRoot}" — see lib/opsOwnership.cjs resolveProjectRoot`,
176
+ );
177
+ err.ephemeral = true;
178
+ throw err;
179
+ }
180
+ }
181
+ }
182
+
183
+ // ─── THE ONE ops-root resolver (PRD 1082) ─────────────────────────────────
184
+ //
185
+ // Every namespace used to compute its own path with a bare
186
+ // `path.join(cwd, 'session-manager-operations', ...)`, each one independently
187
+ // trusting its `cwd`. The worktree / ops-internal cwd hazard therefore had to
188
+ // be fixed one call site at a time (activeSessions 2026-08-30, PRDs 1073,
189
+ // 1074, 1081). This is the single choke point every reader and writer of the
190
+ // ops root goes through instead — normalize what can be normalized, refuse
191
+ // what cannot — so the next stray cwd is caught by construction, not by
192
+ // incident. `scripts/ops-sweep.cjs` lints that no other file in src/main/
193
+ // spells the ops-root literal.
194
+ //
195
+ // Normalization is `activeSessions.projectRootOf`: an ops-internal path is
196
+ // truncated to the project above it, a linked git worktree is mapped to its
197
+ // main tree via the `.git` file's `gitdir:` pointer, and a subdirectory of a
198
+ // git repo resolves to that repo's root. A plain directory with no enclosing
199
+ // `.git` is returned unchanged (byte-identical to the old join). A nested
200
+ // project that IS its own git repo (e.g. Apple/01-Shapes-Foundation) keeps its
201
+ // own root — the walk stops at the first `.git` it meets.
202
+
203
+ /**
204
+ * resolveProjectRoot(cwd, { opsInternal }) → absolute project root.
205
+ *
206
+ * Throws on a missing/non-string/relative cwd (a caller that cannot name an
207
+ * absolute project has no business touching that project's state), and
208
+ * throws a tagged `err.ephemeral = true` when the normalized root is still
209
+ * inside os.tmpdir() or is a linked worktree root (ephemeralCwd.cjs).
210
+ *
211
+ * `opsInternal` — 'normalize' (default): a cwd inside session-manager-
212
+ * operations/ is truncated up to its project, the reader-friendly behaviour
213
+ * activeSessions already has. 'refuse': throw instead — the fail-closed
214
+ * posture a WRITER wants (queueStore.projectStateDir), since silently
215
+ * redirecting a write target is riskier than refusing it.
216
+ */
217
+ function resolveProjectRoot(cwd, { opsInternal = 'normalize' } = {}) {
218
+ if (!cwd || typeof cwd !== 'string') throw new Error('resolveProjectRoot: cwd is required');
219
+ if (!path.isAbsolute(cwd)) {
220
+ throw new Error(`resolveProjectRoot: cwd must be an absolute path, got "${cwd}"`);
221
+ }
222
+ if (opsInternal === 'refuse' && cwd.split(path.sep).includes(OPS_ROOT_DIR)) {
223
+ throw new Error(
224
+ `resolveProjectRoot: cwd must be a project root, not a path inside ${OPS_ROOT_DIR}/, got "${cwd}"`,
225
+ );
226
+ }
227
+ // Lazy: activeSessions → gitWorktree → (lazily) config.cjs → this module.
228
+ const { projectRootOf } = require('../../../scripts/lib/activeSessions.cjs');
229
+ const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
230
+ const root = projectRootOf(cwd);
231
+ if (isEphemeralCwd(root)) {
232
+ const err = new Error(
233
+ `resolveProjectRoot: refusing ephemeral cwd (tmpdir or linked git worktree), got "${cwd}"`,
234
+ );
235
+ err.ephemeral = true;
236
+ throw err;
237
+ }
238
+ return root;
239
+ }
240
+
241
+ /** resolveOpsRoot(cwd, opts) → `<projectRoot>/session-manager-operations`. */
242
+ function resolveOpsRoot(cwd, opts) {
243
+ return path.join(resolveProjectRoot(cwd, opts), OPS_ROOT_DIR);
244
+ }
245
+
246
+ /**
247
+ * opsPath(cwd, ...segments) → `<projectRoot>/session-manager-operations/<segments>`.
248
+ * The only sanctioned way to build a path inside a project's ops root.
249
+ */
250
+ function opsPath(cwd, ...segments) {
251
+ return path.join(resolveOpsRoot(cwd), ...segments);
161
252
  }
162
253
 
163
254
  module.exports = {
@@ -167,4 +258,7 @@ module.exports = {
167
258
  parseOpsPath,
168
259
  checkOpsWrite,
169
260
  assertOpsWrite,
261
+ resolveProjectRoot,
262
+ resolveOpsRoot,
263
+ opsPath,
170
264
  };
@@ -29,6 +29,9 @@ const { schemas } = require('../ipcSchemas.cjs');
29
29
  const { readBody, sendJson } = require('./localAdminHttp.cjs');
30
30
  const { appendAuditEvent } = require('./auditLog.cjs');
31
31
  const queueOps = require('../queueOps.cjs');
32
+ const { MCP_TOOL_CATALOG, MCP_RECIPES } = require('./mcpToolCatalog.cjs');
33
+ const { checkDelegationReadiness } = require('./delegationReadiness.cjs');
34
+ const { resolveProjectContext } = require('./projectRootResolve.cjs');
32
35
 
33
36
  function parseJsonBody(raw) {
34
37
  try {
@@ -45,6 +48,41 @@ function parseJsonBody(raw) {
45
48
  * scheduler.cjs's own registerAdminRoute(s) pattern.
46
49
  */
47
50
  function registerAdminRoute(adminHttp, remote) {
51
+ // GET /admin/mcp/catalog — read-only surface for mcpToolCatalog.cjs, the
52
+ // single source of truth scheduler-mcp-server.cjs's tool descriptions are
53
+ // now composed from (PRD: agent-facing help + Home-tab surface). No
54
+ // `remote` dependency; kept in this file only because it registers
55
+ // "alongside the existing PRD admin routes".
56
+ adminHttp.registerRoute('GET', '/admin/mcp/catalog', async (req, res) => {
57
+ sendJson(res, 200, { ok: true, tools: MCP_TOOL_CATALOG, recipes: MCP_RECIPES });
58
+ });
59
+
60
+ // GET /admin/mcp/readiness?cwd=... — session_manager_help's live half: the
61
+ // catalog above is static and always answerable in-process, but "is this
62
+ // MCP server actually wired up for this project" needs the same four
63
+ // checks delegationReadiness.cjs already runs for the New Epic readiness
64
+ // banner. `cwd` is a required query param (this server has no notion of
65
+ // "the" project — one admin API instance serves every open tab/project).
66
+ adminHttp.registerRoute('GET', '/admin/mcp/readiness', async (req, res, query) => {
67
+ let input;
68
+ try {
69
+ input = schemas.delegationReadinessCwd.parse(Object.fromEntries(query ?? []));
70
+ } catch (e) {
71
+ sendJson(res, 400, { ok: false, error: 'invalid query', details: e?.issues ?? e?.message });
72
+ return;
73
+ }
74
+ // Normalize a worktree/ops-internal cwd to its real project root first —
75
+ // session_manager_help called from inside an Epic's worktree must report
76
+ // THAT project's readiness, not a worktree checkout's own (nonexistent)
77
+ // config state. See projectRootResolve.cjs's header.
78
+ const resolved = resolveProjectContext({ cwd: input.cwd });
79
+ const result = await checkDelegationReadiness({ cwd: resolved.cwd || input.cwd });
80
+ // `ok: true` means "the route answered"; `ready`/`checks` carry the
81
+ // actual delegation-readiness verdict — kept distinct so a caller can't
82
+ // mistake "readiness is false" for "the request itself failed".
83
+ sendJson(res, 200, { ok: true, ready: result.ok, checks: result.checks });
84
+ });
85
+
48
86
  // GET /admin/scheduler/prds — paginated PRD listing (PRD: 353KB/120s
49
87
  // fix). Sort order is stable and explicit: slug ascending via
50
88
  // String.localeCompare(..., { numeric: true }) (listPrdsInternal in
@@ -121,7 +159,7 @@ function registerAdminRoute(adminHttp, remote) {
121
159
  sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
122
160
  return;
123
161
  }
124
- const result = await queueOps.archiveMany(input.slugs);
162
+ const result = await queueOps.archiveMany(input.slugs, input.cwd);
125
163
  // Audit only the slugs that actually archived — archiveMany's `results`
126
164
  // array carries the real per-slug outcome; logging every requested slug
127
165
  // regardless of whether it succeeded would misrepresent a partial-failure
@@ -147,9 +185,11 @@ function registerAdminRoute(adminHttp, remote) {
147
185
  sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
148
186
  return;
149
187
  }
150
- const result = await remote.cancelJob(input.slug);
188
+ const result = await remote.cancelJob(input.slug, { cwd: input.cwd });
151
189
  if (!result.ok) {
152
- const status = /not found/i.test(result.error ?? '') ? 404 : 409;
190
+ const status = result.error === 'invalid slug' ? 400
191
+ : /unknown slug|not found/i.test(result.error ?? '') ? 404
192
+ : 409;
153
193
  sendJson(res, status, result);
154
194
  return;
155
195
  }
@@ -25,6 +25,7 @@ const { expandHome } = require('./expandHome.cjs');
25
25
  const { readBody, sendJson } = require('./localAdminHttp.cjs');
26
26
  const { readActiveIndex } = require('./epicMint.cjs');
27
27
  const { appendAuditEvent } = require('./auditLog.cjs');
28
+ const { resolveProjectContext } = require('./projectRootResolve.cjs');
28
29
 
29
30
  const STANDARDS_PATH = path.join(
30
31
  __dirname, '..', '..', '..',
@@ -138,17 +139,41 @@ function resolveSourcePromptIdFromClaudeSession(cwd, claudeSessionId) {
138
139
  * (400/409/500), left to the caller since only the HTTP route needs it.
139
140
  */
140
141
  async function createPrd(input, remote) {
141
- // cwd is untrusted: this is the first *creating* mutation on a
142
- // token-authed API whose product is "a command that will later run
143
- // with --dangerously-skip-permissions in a chosen cwd". Route it
144
- // through config.cjs's validatePath (allowedRoots = home dir) —
145
- // same boundary every other fs-touching IPC handler uses — never a
146
- // bespoke check here.
147
- // Normalize a `~`-prefixed cwd BEFORE it reaches allocateParallelGroup/
148
- // readPrd/writePrd — those pass cwd through to safeSlugPathIn-adjacent path
149
- // joins that treat it as a literal path segment, so an unexpanded `~/...`
150
- // fails downstream as a bare "invalid slug" instead of a clear cwd error.
151
- const cwd = expandHome(input.cwd);
142
+ // cwd is untrusted AND, since this PRD, optional — a caller inside an
143
+ // Epic's git worktree naturally passes its worktree pwd (or omits cwd
144
+ // entirely and relies on originClaudeSessionId/sourcePromptId). Resolve the
145
+ // real project + owning Epic server-side BEFORE any validation: a worktree
146
+ // cwd currently PASSES config.validatePath and then reads the WRONG
147
+ // active-index.json (a git-tracked snapshot frozen at branch time), which
148
+ // is why "the Epic got lost" reports keep recurring even though the Epic
149
+ // never left main's index. See projectRootResolve.cjs's header.
150
+ const rawCwd = input.cwd ? expandHome(input.cwd) : null;
151
+ const originProjectRoot = input.originProjectRoot ? expandHome(input.originProjectRoot) : null;
152
+ const resolved = resolveProjectContext({
153
+ cwd: rawCwd,
154
+ originClaudeSessionId: input.originClaudeSessionId,
155
+ // Only ask the resolver to hunt for the project BY Epic id when no cwd
156
+ // (nor a trusted SM_PROJECT_ROOT hint) was supplied at all — an explicit
157
+ // cwd that merely needs worktree/ops-path normalization is already fixed
158
+ // by projectRootOf above, so the common already-correct-cwd call (cwd +
159
+ // sourcePromptId both supplied) never pays for a cross-project scan.
160
+ epicId: (rawCwd || originProjectRoot) ? undefined : input.sourcePromptId,
161
+ originProjectRoot,
162
+ });
163
+
164
+ if (!resolved.cwd) {
165
+ return {
166
+ ok: false,
167
+ status: 400,
168
+ error: 'cwd is required — pass cwd explicitly, or originClaudeSessionId/sourcePromptId that resolves '
169
+ + 'to an existing, already-approved Epic (create/approve an Epic first)',
170
+ };
171
+ }
172
+
173
+ // A resolved Epic's own project cwd always wins over the supplied/hinted
174
+ // one — see resolveProjectContext's header. `cwd` below is that final,
175
+ // already-normalized (worktree -> main tree) value.
176
+ const cwd = resolved.cwd;
152
177
  let realCwd;
153
178
  try {
154
179
  realCwd = config.validatePath(cwd);
@@ -164,9 +189,16 @@ async function createPrd(input, remote) {
164
189
  config.addAllowedRoot(realCwd);
165
190
  input = { ...input, cwd };
166
191
 
167
- if (!input.sourcePromptId && input.originClaudeSessionId) {
168
- const resolved = resolveSourcePromptIdFromClaudeSession(cwd, input.originClaudeSessionId);
169
- if (resolved) input = { ...input, sourcePromptId: resolved };
192
+ if (resolved.epicId) {
193
+ input = { ...input, sourcePromptId: resolved.epicId };
194
+ } else if (!input.sourcePromptId && input.originClaudeSessionId) {
195
+ // Fallback kept for the narrow case a caller passes originClaudeSessionId
196
+ // but resolveProjectContext (which also scans cross-project) somehow
197
+ // missed it — resolveSourcePromptIdFromClaudeSession is the single-cwd
198
+ // primitive resolveProjectContext itself is built from, so this can only
199
+ // ever be a no-op in practice; kept for defense-in-depth, not duplicated logic.
200
+ const fallback = resolveSourcePromptIdFromClaudeSession(cwd, input.originClaudeSessionId);
201
+ if (fallback) input = { ...input, sourcePromptId: fallback };
170
202
  }
171
203
 
172
204
  const slug = input.slug || deriveSlugFromTitle(input.title);
@@ -19,8 +19,14 @@ const fs = require('node:fs');
19
19
  const path = require('node:path');
20
20
  const { activeProjectCwds, allProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
21
21
 
22
- const PRD_SUBPATH = ['session-manager-operations', 'scheduler', 'prds'];
23
- const EPICS_SUBPATH = ['session-manager-operations', 'scheduler', 'epics'];
22
+ const { opsPath, OPS_ROOT_DIR } = require('./opsOwnership.cjs');
23
+
24
+ // Segments UNDER the ops root; the ops root itself is resolved by
25
+ // opsOwnership.opsPath (PRD 1082) — never joined onto a raw cwd here.
26
+ const PRD_SUBPATH = ['scheduler', 'prds'];
27
+ const EPICS_SUBPATH = ['scheduler', 'epics'];
28
+ // Segment count from a project root to an epic's own dir: <ops-root> + EPICS_SUBPATH.
29
+ const EPICS_DEPTH_FROM_PROJECT = 1 + EPICS_SUBPATH.length;
24
30
 
25
31
  /**
26
32
  * resolveEpicsRoot(cwd) → `<cwd>/session-manager-operations/scheduler/epics`
@@ -30,7 +36,7 @@ function resolveEpicsRoot(cwd) {
30
36
  if (!cwd || typeof cwd !== 'string') {
31
37
  throw new Error('resolveEpicsRoot: cwd is required');
32
38
  }
33
- return path.join(cwd, ...EPICS_SUBPATH);
39
+ return opsPath(cwd, ...EPICS_SUBPATH);
34
40
  }
35
41
 
36
42
  /**
@@ -69,7 +75,8 @@ function listEpicPrdDirs(cwd) {
69
75
  * its Epic (the layout every new PRD uses) is still found.
70
76
  */
71
77
  function listArchivedPrdDirs(cwd) {
72
- const dirs = [path.join(cwd, ...PRD_SUBPATH, '..', 'prds-archived')];
78
+ let dirs;
79
+ try { dirs = [opsPath(cwd, ...PRD_SUBPATH, '..', 'prds-archived')]; } catch { return []; }
73
80
  let root;
74
81
  try { root = resolveEpicsRoot(cwd); } catch { return dirs; }
75
82
  let entries;
@@ -91,7 +98,7 @@ function resolvePrdWriteDir(cwd) {
91
98
  if (!cwd || typeof cwd !== 'string') {
92
99
  throw new Error('resolvePrdWriteDir: cwd is required');
93
100
  }
94
- return path.join(cwd, ...PRD_SUBPATH);
101
+ return opsPath(cwd, ...PRD_SUBPATH);
95
102
  }
96
103
 
97
104
  /**
@@ -202,7 +209,7 @@ function deriveEpicIdFromPrdPath(filePath) {
202
209
  const epicId = path.basename(epicDir);
203
210
  if (!epicId || epicId === '.' || epicId === path.sep) return null;
204
211
  let projectCwd = path.dirname(epicDir); // epicsRoot (.../scheduler/epics)
205
- for (let i = 0; i < EPICS_SUBPATH.length; i++) projectCwd = path.dirname(projectCwd);
212
+ for (let i = 0; i < EPICS_DEPTH_FROM_PROJECT; i++) projectCwd = path.dirname(projectCwd);
206
213
  let epicsRoot;
207
214
  try { epicsRoot = resolveEpicsRoot(projectCwd); } catch { return null; }
208
215
  if (path.join(epicsRoot, epicId, dirName) !== prdsDir) return null;
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * projectRootResolve.cjs — server-side project/Epic resolution for every
5
+ * cwd-accepting ops-root entry point (PRD: worktree-cwd Epic-lookup hazard).
6
+ *
7
+ * Problem this closes. An agent running inside an Epic's git worktree has
8
+ * `pwd` = `/tmp/session-manager-epic-worktrees/<hash>/<epicId>` and naturally
9
+ * passes that as `cwd` to `scheduler_create_prd` / `feedback_open_session`.
10
+ * `active-index.json` (the Epic registry) is git-tracked, so the worktree's
11
+ * copy is a snapshot frozen at branch time — it usually does NOT contain the
12
+ * very Epic the agent is running inside. Verified live 2026-09-01 on
13
+ * starry-night-ships: main's index had 5 rows including the running Epic; the
14
+ * worktree's copy had 1 row and did not contain it, so a naive per-cwd lookup
15
+ * returns null and the write is refused with "create/approve an Epic first" —
16
+ * reading to the human as "the Epic got lost", when it never did.
17
+ *
18
+ * `resolveProjectContext` fixes this at the API boundary, not by trying to
19
+ * keep every cwd-accepting caller worktree-aware individually:
20
+ * 1. Normalizes any supplied cwd through `activeSessions.projectRootOf`
21
+ * (worktree -> main tree, ops-internal path -> project root).
22
+ * 2. When an `originClaudeSessionId` (or explicit `epicId`) is known,
23
+ * searches for the owning Epic first in the normalized cwd's own
24
+ * active-index, then across every project this machine has ever opened
25
+ * (`activeSessions.allProjectCwds`) — because the Epic's OWN project may
26
+ * not even be the cwd the agent guessed (e.g. a worktree whose gitdir
27
+ * pointer is stale/unresolvable, or a cross-project mixup).
28
+ * 3. A resolved Epic's own project cwd (`epicCwd`) always wins over the
29
+ * supplied/hinted cwd when they differ — the Epic is the ground truth.
30
+ *
31
+ * Pure, synchronous, fs-only — no Electron deps — so it can be required from
32
+ * the standalone scheduler-mcp-server.cjs process and from every ops-root
33
+ * path helper a sibling PRD (ops-root-single-resolver) folds in later.
34
+ */
35
+
36
+ const path = require('node:path');
37
+ const { projectRootOf, allProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
38
+ const { readActiveIndex } = require('./epicMint.cjs');
39
+ const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
40
+
41
+ /**
42
+ * resolveProjectContext({ cwd, originClaudeSessionId, epicId, originProjectRoot }, deps?)
43
+ * → { cwd, epicId, epicCwd, source }
44
+ *
45
+ * `cwd` — the caller-supplied cwd (may be inside a worktree/ops-internal path,
46
+ * or absent entirely when the caller is relying purely on Epic resolution).
47
+ * `originClaudeSessionId` — the calling session's own claude session id
48
+ * (chatRunner.cjs's SM_CHAT_SESSION_ID / pty.cjs's tabId) — matched against
49
+ * each Epic's `claudeSessionId`.
50
+ * `epicId` — an explicit already-known Epic id (e.g. a caller-supplied
51
+ * `sourcePromptId`) — matched against each Epic's `id`. Either identifier
52
+ * (or both) may be supplied; a match on either counts.
53
+ * `originProjectRoot` — a trusted hint (e.g. SM_PROJECT_ROOT env, stamped by
54
+ * the process that actually knows the real project root) — normalized the
55
+ * same way as `cwd` and preferred over it when both are given, but still
56
+ * loses to a resolved Epic's own `epicCwd`.
57
+ *
58
+ * `source` records which path produced the returned `cwd`/`epicId`:
59
+ * 'cwd' — no Epic identifier given, or none resolved; cwd is
60
+ * just the normalized supplied/hinted value.
61
+ * 'epic-index' — the Epic was found in the (single) normalized
62
+ * base cwd's own active-index.
63
+ * 'cross-project-scan' — the Epic was found only by scanning other known
64
+ * projects (or a duplicate id required tie-breaking).
65
+ *
66
+ * Never throws: an unresolvable cwd/hint/Epic id just yields a smaller
67
+ * result, never an error — refusal is the caller's decision, not this
68
+ * resolver's.
69
+ */
70
+ function resolveProjectContext({ cwd, originClaudeSessionId, epicId: wantedEpicId, originProjectRoot } = {}, deps = {}) {
71
+ const projectRootOfFn = deps.projectRootOf || projectRootOf;
72
+ const allProjectCwdsFn = deps.allProjectCwds || allProjectCwds;
73
+ const readActiveIndexFn = deps.readActiveIndex || readActiveIndex;
74
+ const isEphemeralCwdFn = deps.isEphemeralCwd || isEphemeralCwd;
75
+ const warn = deps.warn || console.warn;
76
+
77
+ let baseCwd = null;
78
+ if (cwd && typeof cwd === 'string') {
79
+ baseCwd = projectRootOfFn(path.resolve(cwd));
80
+ }
81
+ if (originProjectRoot && typeof originProjectRoot === 'string') {
82
+ const hintRoot = projectRootOfFn(path.resolve(originProjectRoot));
83
+ // A trusted hint outranks the agent-supplied cwd, but a resolved Epic
84
+ // (below) still outranks both.
85
+ if (hintRoot) baseCwd = hintRoot;
86
+ }
87
+
88
+ const result = { cwd: baseCwd, epicId: null, epicCwd: null, source: 'cwd' };
89
+
90
+ if (!originClaudeSessionId && !wantedEpicId) return result;
91
+
92
+ const candidates = [];
93
+ if (baseCwd) candidates.push(baseCwd);
94
+ for (const p of allProjectCwdsFn()) {
95
+ if (!candidates.includes(p)) candidates.push(p);
96
+ }
97
+
98
+ const matches = [];
99
+ for (const projectCwd of candidates) {
100
+ const { sessions } = readActiveIndexFn(projectCwd);
101
+ for (const session of Object.values(sessions)) {
102
+ if (!session) continue;
103
+ const idHit = wantedEpicId && session.id === wantedEpicId;
104
+ const claudeHit = originClaudeSessionId && session.claudeSessionId === originClaudeSessionId;
105
+ if (idHit || claudeHit) {
106
+ matches.push({ projectCwd, epicId: session.id, status: session.status ?? null });
107
+ break;
108
+ }
109
+ }
110
+ }
111
+
112
+ if (matches.length === 0) return result;
113
+
114
+ let chosen = matches[0];
115
+ if (matches.length > 1) {
116
+ const score = (m) => (isEphemeralCwdFn(m.projectCwd) ? 0 : 2) + (m.status === 'active' ? 1 : 0);
117
+ chosen = matches.reduce((best, m) => (score(m) > score(best) ? m : best), matches[0]);
118
+ warn(
119
+ `[projectRootResolve] Epic id ${chosen.epicId} matched in multiple projects: `
120
+ + `${matches.map((m) => m.projectCwd).join(', ')} — choosing ${chosen.projectCwd} `
121
+ + '(prefers a non-worktree/tmp project with status=active)',
122
+ );
123
+ }
124
+
125
+ result.epicId = chosen.epicId;
126
+ result.epicCwd = chosen.projectCwd;
127
+ result.cwd = chosen.projectCwd;
128
+ result.source = matches.length > 1
129
+ ? 'cross-project-scan'
130
+ : (chosen.projectCwd === baseCwd ? 'epic-index' : 'cross-project-scan');
131
+ return result;
132
+ }
133
+
134
+ module.exports = { resolveProjectContext };
@@ -79,6 +79,13 @@ const PromptSessionSchema = z.object({
79
79
  // Passthrough only as of PRD 1032 — nothing populates this yet; the next
80
80
  // PRD in this chain wires Epic worktree minting.
81
81
  worktree: EpicWorktreeSchema.optional(),
82
+ // Status-mirror fields (epicStatusMirror.cjs) written onto prompt-sessions/
83
+ // <id>.json alongside the active-index.json row — never onto the index row
84
+ // itself, so a plain PromptSession read from active-index.json normally
85
+ // omits them. Optional here only so activeIndexRebuild.cjs can validate a
86
+ // mirrored file's reconstructed row against this same schema.
87
+ archivedAt: z.string().nullable().optional(),
88
+ indexedAt: z.string().optional(),
82
89
  });
83
90
 
84
91
  /**
@@ -33,12 +33,13 @@ const fsp = require('node:fs/promises');
33
33
  const path = require('node:path');
34
34
  const os = require('node:os');
35
35
  const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
36
- const { assertOpsWrite } = require('./opsOwnership.cjs');
36
+ const { assertOpsWrite, resolveOpsRoot, OPS_ROOT_DIR } = require('./opsOwnership.cjs');
37
37
  const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
38
38
 
39
39
  const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
40
40
  const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
41
- const STATE_SUBPATH = ['session-manager-operations', 'scheduler', 'state'];
41
+ const OPS_DIRNAME = OPS_ROOT_DIR;
42
+ const STATE_SUBPATH = ['scheduler', 'state'];
42
43
 
43
44
  /**
44
45
  * A project's `session-manager-operations/scheduler/state/` directory.
@@ -60,7 +61,43 @@ function projectStateDir(cwd) {
60
61
  if (!path.isAbsolute(cwd)) {
61
62
  throw new Error(`projectStateDir: cwd must be an absolute path, got "${cwd}"`);
62
63
  }
63
- return path.join(cwd, ...STATE_SUBPATH);
64
+ // ...and it must not itself sit INSIDE an ops root. Absolute-and-existing
65
+ // was not enough: a transcript records an agent's `cd` into a PRD folder,
66
+ // activeSessions hands that subdirectory over as a project, and this join
67
+ // materializes a second ops root under the first — the doubled
68
+ // `.../prds/session-manager-operations/scheduler/state/queue.json` stubs
69
+ // found in starry-night-ships on 2026-08-30 (14 of them, 8 days' worth).
70
+ // activeSessions.projectRootOf normalizes such a cwd away upstream; this is
71
+ // the fail-closed backstop for every other caller. A queue file below an
72
+ // ops root is always a bug, never a project.
73
+ if (cwd.split(path.sep).includes(OPS_DIRNAME)) {
74
+ throw new Error(
75
+ `projectStateDir: cwd must be a project root, not a path inside ${OPS_DIRNAME}/, got "${cwd}"`,
76
+ );
77
+ }
78
+ // ...and it must not be an EPHEMERAL cwd (inside os.tmpdir(), or a linked
79
+ // git worktree root) — a worktree is torn down when its Epic/job ends, so
80
+ // any state written there is silently destroyed (verified live 2026-09-01
81
+ // as a scheduler shard rewritten every reconcile pass inside a since-deleted
82
+ // epic worktree). That check, and the worktree → main-tree normalization,
83
+ // live in opsOwnership.resolveOpsRoot (PRD 1082) — the one resolver every
84
+ // ops-root reader/writer goes through. `opsInternal: 'refuse'` keeps this
85
+ // WRITER fail-closed on an ops-internal cwd (the guard above shapes the
86
+ // message; the resolver is the backstop).
87
+ let opsRoot;
88
+ try {
89
+ opsRoot = resolveOpsRoot(cwd, { opsInternal: 'refuse' });
90
+ } catch (e) {
91
+ if (e?.ephemeral) {
92
+ const err = new Error(
93
+ `projectStateDir: refusing ephemeral cwd (tmpdir or linked git worktree), got "${cwd}"`,
94
+ );
95
+ err.ephemeral = true;
96
+ throw err;
97
+ }
98
+ throw e;
99
+ }
100
+ return path.join(opsRoot, ...STATE_SUBPATH);
64
101
  }
65
102
 
66
103
  function projectQueuePath(cwd) {
@@ -71,6 +108,26 @@ function projectHistoryPath(cwd) {
71
108
  return path.join(projectStateDir(cwd), 'history.jsonl');
72
109
  }
73
110
 
111
+ /**
112
+ * queuePathOrSkip(cwd, context) → the shard path, or null when `cwd` is not a
113
+ * usable project root. projectStateDir fails closed on a relative or
114
+ * ops-internal cwd; a single such job row (or a stale cached cwd) must not
115
+ * abort a whole read or write of every OTHER project's queue, so callers that
116
+ * loop over many cwds log-and-skip instead of throwing.
117
+ */
118
+ function queuePathOrSkip(cwd, context) {
119
+ try {
120
+ return projectQueuePath(cwd);
121
+ } catch (e) {
122
+ if (e?.ephemeral) {
123
+ console.warn(`[queueStore] ${context}: refusing ephemeral cwd "${cwd}" — ${e.message}`);
124
+ } else {
125
+ console.error(`[queueStore] ${context}: skipping invalid project cwd — ${e?.message}`);
126
+ }
127
+ return null;
128
+ }
129
+ }
130
+
74
131
  function writeJsonAtomicSync(file, value) {
75
132
  assertOpsWrite(file, 'scheduler');
76
133
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -183,7 +240,8 @@ function readMergedSync(opts) {
183
240
  }
184
241
  }
185
242
  for (const cwd of stateCwds(opts)) {
186
- const file = projectQueuePath(cwd);
243
+ const file = queuePathOrSkip(cwd, 'read');
244
+ if (!file) continue;
187
245
  try {
188
246
  const { jobs, invalid } = shapeJobs(fs.readFileSync(file, 'utf8'), file);
189
247
  out.jobs.push(...jobs);
@@ -212,7 +270,8 @@ async function readMerged(opts) {
212
270
  }
213
271
  }
214
272
  for (const cwd of stateCwds(opts)) {
215
- const file = projectQueuePath(cwd);
273
+ const file = queuePathOrSkip(cwd, 'read');
274
+ if (!file) continue;
216
275
  try {
217
276
  const { jobs, invalid } = shapeJobs(await fsp.readFile(file, 'utf8'), file);
218
277
  out.jobs.push(...jobs);
@@ -261,12 +320,14 @@ async function writeSplit(state, defaultCwd) {
261
320
  byCwd.get(cwd).push(job);
262
321
  }
263
322
  for (const [cwd, jobs] of byCwd) {
323
+ const file = queuePathOrSkip(cwd, 'writeSplit');
324
+ if (!file) continue;
264
325
  try {
265
- await writeJsonAtomic(projectQueuePath(cwd), { jobs });
326
+ await writeJsonAtomic(file, { jobs });
266
327
  } catch (e) {
267
328
  // A single unwritable project (deleted repo dir, permissions) must not
268
329
  // lose every other project's write.
269
- console.error(`[queueStore] failed to write ${projectQueuePath(cwd)}: ${e?.message}`);
330
+ console.error(`[queueStore] failed to write ${file}: ${e?.message}`);
270
331
  }
271
332
  }
272
333
  bustCwdCache();
@@ -316,7 +377,8 @@ async function migrateLegacyGlobalQueue(defaultCwd) {
316
377
  }
317
378
  let moved = 0;
318
379
  for (const [cwd, jobs] of byCwd) {
319
- const file = projectQueuePath(cwd);
380
+ const file = queuePathOrSkip(cwd, 'legacy split');
381
+ if (!file) continue;
320
382
  let existing = [];
321
383
  try { existing = shapeJobs(await fsp.readFile(file, 'utf8'), file).jobs; } catch { /* fresh shard */ }
322
384
  const have = new Set(existing.map((j) => j.slug));
@@ -74,7 +74,7 @@ const PREVENTION_HINTS = {
74
74
  [FAILURE_CLASSES.ALREADY_SHIPPED]:
75
75
  "This run found its acceptance criteria already satisfied by a prior commit and correctly made no change, so no commit landed and the verifier returned `pass_no_commit`. This is a stale re-run, not an execution failure — the PRD's `.md` was never moved out of `session-manager-operations/scheduler/prds/` after the work shipped. Archive the PRD into `session-manager-operations/scheduler/prds-archived/` instead of re-queuing or re-running it.",
76
76
  [FAILURE_CLASSES.SELF_QUEUE]:
77
- "This run either invoked /develop or /process-feedback from inside its own headless execution, or backgrounded a long-running command and called ScheduleWakeup to check back later — both are the 'you ARE the executor — never re-queue or self-schedule' anti-pattern. A headless PRD run must perform its own acceptance criteria directly and has no next turn to resume it (standards.md → Execution discipline).",
77
+ "This run either invoked /develop or /process-feedback from inside its own headless execution, or backgrounded a long-running command (e.g. a verification/test run) and called Monitor, TaskOutput, or ScheduleWakeup to check back later — all are the 'you ARE the executor — never re-queue or self-schedule' anti-pattern. A headless PRD run must perform its own acceptance criteria directly and has no next turn to resume it (standards.md → Execution discipline).",
78
78
  [FAILURE_CLASSES.STUCK_LOOP]:
79
79
  'Bound every command with `timeout <N> <cmd>` — never leave an unbounded `until`/`while true`/`sleep` poll in a PRD body (PRD_AUTHORING.md loop-hang guidance).',
80
80
  [FAILURE_CLASSES.POST_AC_OVERRUN]: