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
@@ -11,8 +11,28 @@
11
11
  const fs = require('node:fs');
12
12
  const os = require('node:os');
13
13
  const path = require('node:path');
14
+ const { KIND_CONFIG: WORKTREE_KIND_CONFIG } = require('../../src/main/lib/gitWorktree.cjs');
14
15
 
15
16
  const HOME = os.homedir();
17
+ const TMPDIR = os.tmpdir();
18
+
19
+ // The last-resort drop targets for addCwd's tmp guard.
20
+ // - exactDropCwds: dropped only on an EXACT match — os.tmpdir() itself
21
+ // (e.g. `/tmp`). NOT a prefix match: this codebase's own test suite
22
+ // routinely stubs HOME to a tmp dir and nests a fake project cwd
23
+ // underneath it, a legitimate, unrelated use of os.tmpdir() that a
24
+ // blanket prefix match would wrongly drop.
25
+ // - prefixDropRoots: dropped on an exact match OR any nested path — the two
26
+ // managed worktree roots (both live under os.tmpdir() per gitWorktree
27
+ // .cjs's KIND_CONFIG). Nothing but managed worktree checkouts is ever
28
+ // created under these roots, so a prefix match here is safe. A resolvable
29
+ // worktree (managed or user-made, anywhere on disk) is already handled by
30
+ // worktreeMainRootOf above; this guard only catches what that resolution
31
+ // could not — e.g. a bare, .git-less worktree-root directory.
32
+ const TMP_DROP_ROOTS = {
33
+ exactDropCwds: [TMPDIR],
34
+ prefixDropRoots: [WORKTREE_KIND_CONFIG.job.root, WORKTREE_KIND_CONFIG.epic.root],
35
+ };
16
36
 
17
37
  // Transcript reads only need the last line with a `cwd` field — a few dozen
18
38
  // lines is always enough. 64 KB keeps peak RSS proportionate.
@@ -37,16 +57,93 @@ const MAX_CWDS = 50;
37
57
  // signal survives instead of being silently lost.
38
58
  const OPS_DIRNAME = 'session-manager-operations';
39
59
 
60
+ // Bound on the ancestor walk in worktreeMainRootOf — well past any real
61
+ // filesystem depth, purely to guarantee termination without relying on
62
+ // reaching '/' (e.g. a symlink loop or an unusually deep path).
63
+ const MAX_WORKTREE_WALK = 40;
64
+
65
+ const WORKTREES_MARKER = `${path.sep}.git${path.sep}worktrees${path.sep}`;
66
+
67
+ /**
68
+ * worktreeMainRootOf(cwd) → the main tree's root when cwd sits inside a
69
+ * linked `git worktree` (job/epic worktrees under os.tmpdir(), or a
70
+ * user-made one anywhere). Walks UP from cwd looking for a `.git` entry:
71
+ * - `.git` is a FILE (linked worktree) whose first line is
72
+ * `gitdir: <main>/.git/worktrees/<name>` → returns <main>.
73
+ * - `.git` is a DIRECTORY (already the main tree) → returns that ancestor.
74
+ * - nothing found within MAX_WORKTREE_WALK levels → returns null.
75
+ * Pure fs, synchronous, never throws — any unexpected shape (garbled file,
76
+ * unreadable entry, gitdir path without the worktrees marker) yields null so
77
+ * the caller leaves the cwd unchanged rather than guessing.
78
+ */
79
+ function worktreeMainRootOf(cwd) {
80
+ let dir = cwd;
81
+ for (let i = 0; i < MAX_WORKTREE_WALK; i++) {
82
+ const gitPath = path.join(dir, '.git');
83
+ let stat;
84
+ try {
85
+ stat = fs.statSync(gitPath);
86
+ } catch {
87
+ stat = null;
88
+ }
89
+ if (stat) {
90
+ if (stat.isDirectory()) return dir;
91
+ if (stat.isFile()) {
92
+ let body;
93
+ try {
94
+ body = fs.readFileSync(gitPath, 'utf8');
95
+ } catch {
96
+ return null;
97
+ }
98
+ const firstLine = body.split('\n', 1)[0].trim();
99
+ const prefix = 'gitdir:';
100
+ if (!firstLine.startsWith(prefix)) return null;
101
+ const gitdir = firstLine.slice(prefix.length).trim();
102
+ const markerIdx = gitdir.indexOf(WORKTREES_MARKER);
103
+ if (markerIdx <= 0) return null;
104
+ const candidateMain = gitdir.slice(0, markerIdx);
105
+ const worktreeName = gitdir.slice(markerIdx + WORKTREES_MARKER.length).split(path.sep)[0];
106
+ // Round-trip verification: gitdir's content is a plain file that
107
+ // anything on disk could have written (e.g. a nested `.git` file
108
+ // tracked inside an untrusted repo), so it must not be trusted to
109
+ // redirect callers to an arbitrary attacker-chosen absolute path.
110
+ // A genuine linked worktree's admin dir back-references the exact
111
+ // `.git` FILE we are resolving via its own `gitdir` pointer file —
112
+ // require that round trip before accepting candidateMain.
113
+ if (!worktreeName) return null;
114
+ const adminGitdirFile = path.join(candidateMain, '.git', 'worktrees', worktreeName, 'gitdir');
115
+ let backRef;
116
+ try {
117
+ backRef = fs.readFileSync(adminGitdirFile, 'utf8').trim();
118
+ } catch {
119
+ return null;
120
+ }
121
+ if (path.resolve(backRef) !== path.resolve(gitPath)) return null;
122
+ return candidateMain;
123
+ }
124
+ return null;
125
+ }
126
+ const parent = path.dirname(dir);
127
+ if (parent === dir) return null;
128
+ dir = parent;
129
+ }
130
+ return null;
131
+ }
132
+
40
133
  /**
41
134
  * projectRootOf(cwd) → the project root for a cwd that may sit inside an ops
42
- * tree. Returns cwd unchanged when it does not. Only the FIRST occurrence
43
- * matters — a nested stray ops root is itself the bug, never a project.
135
+ * tree and/or a linked git worktree. Returns cwd unchanged when neither
136
+ * applies. Only the FIRST ops-dir occurrence matters — a nested stray ops
137
+ * root is itself the bug, never a project. The ops-dir truncation runs
138
+ * first, then the worktree resolution, so a cwd deep inside a worktree's OWN
139
+ * ops tree still lands on the real project's main root.
44
140
  */
45
141
  function projectRootOf(cwd) {
46
142
  const parts = cwd.split(path.sep);
47
143
  const i = parts.indexOf(OPS_DIRNAME);
48
- if (i <= 0) return cwd;
49
- return parts.slice(0, i).join(path.sep) || path.sep;
144
+ const truncated = i > 0 ? (parts.slice(0, i).join(path.sep) || path.sep) : cwd;
145
+ const mainRoot = worktreeMainRootOf(truncated);
146
+ return mainRoot || truncated;
50
147
  }
51
148
 
52
149
  /**
@@ -85,11 +182,13 @@ function readTailLines(filePath, maxBytes) {
85
182
  * Complexity: O(P × L) over P project dirs, each bounded-tail read (64 KB).
86
183
  *
87
184
  * opts (for testing):
88
- * projectsDir — override the default ~/.claude/projects path
185
+ * projectsDir — override the default ~/.claude/projects path
186
+ * tmpDropRoots — override the tmp guard's drop roots (default TMP_DROP_ROOTS)
89
187
  */
90
188
  function activeProjectCwds(maxAgeMin = 90, {
91
189
  projectsDir = path.join(HOME, '.claude', 'projects'),
92
190
  maxCwds = MAX_CWDS,
191
+ tmpDropRoots = TMP_DROP_ROOTS,
93
192
  } = {}) {
94
193
  // maxAgeMin === Infinity means "every project ever seen, no recency filter"
95
194
  // (allProjectCwds below). Date.now() - Infinity is -Infinity, which every
@@ -111,6 +210,17 @@ function activeProjectCwds(maxAgeMin = 90, {
111
210
  // whole per-project state off these strings — a project is a cwd, and a
112
211
  // cwd is an absolute path.
113
212
  if (!path.isAbsolute(cwd)) return;
213
+ // Drop-guard of last resort: a cwd that is (or sits inside) a known
214
+ // worktree-scratch root after projectRootOf's normalization is one
215
+ // worktreeMainRootOf could not resolve to a main tree — never a real
216
+ // project.
217
+ if (tmpDropRoots.exactDropCwds.includes(cwd)) return;
218
+ const isUnderPrefixDropRoot = tmpDropRoots.prefixDropRoots.some((root) => {
219
+ if (cwd === root) return true;
220
+ const rel = path.relative(root, cwd);
221
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
222
+ });
223
+ if (isUnderPrefixDropRoot) return;
114
224
  if (seen.has(cwd) || result.length >= maxCwds) return;
115
225
  // Must exist AND be a directory — a transcript naming a since-deleted
116
226
  // path, or a file, is not a project.
@@ -178,4 +288,4 @@ function allProjectCwds(opts = {}) {
178
288
  return activeProjectCwds(Infinity, { maxCwds: 500, ...opts });
179
289
  }
180
290
 
181
- module.exports = { activeProjectCwds, allProjectCwds, projectRootOf };
291
+ module.exports = { activeProjectCwds, allProjectCwds, projectRootOf, worktreeMainRootOf };
@@ -29,11 +29,33 @@ const {
29
29
  CallToolRequestSchema,
30
30
  } = require('@modelcontextprotocol/sdk/types.js');
31
31
  const { PRD_WORK_TYPES } = require('../src/main/lib/workTypeLibrary.cjs');
32
+ const { MCP_TOOL_CATALOG, MCP_RECIPES, composeDescription } = require('../src/main/lib/mcpToolCatalog.cjs');
33
+
34
+ const CATALOG_BY_NAME = new Map(MCP_TOOL_CATALOG.map((entry) => [entry.name, entry]));
35
+
36
+ function descriptionFor(toolName) {
37
+ const entry = CATALOG_BY_NAME.get(toolName);
38
+ if (!entry) throw new Error(`mcpToolCatalog.cjs has no entry for tool "${toolName}"`);
39
+ return composeDescription(entry);
40
+ }
32
41
 
33
42
  const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
34
43
 
44
+ // Defined once so every failure path points to the same next call, verbatim
45
+ // — never paste this sentence at each return site (PRD: session_manager_help).
46
+ const HELP_POINTER = ' — call session_manager_help for the correct usage';
47
+
48
+ function withPointer(text) {
49
+ const str = String(text);
50
+ return str.endsWith(HELP_POINTER) ? str : str + HELP_POINTER;
51
+ }
52
+
53
+ function errorResult(text) {
54
+ return { content: [{ type: 'text', text: withPointer(text) }], isError: true };
55
+ }
56
+
35
57
  const NOT_RUNNING_ERROR =
36
- 'session-manager app is not running (admin API unreachable) — start it first';
58
+ `session-manager app is not running (admin API unreachable) — start it first${HELP_POINTER}`;
37
59
 
38
60
  async function readAdminConfig() {
39
61
  const raw = await fsp.readFile(TOKEN_PATH, 'utf8');
@@ -68,56 +90,56 @@ async function adminRequest(method, urlPath, body) {
68
90
  return json;
69
91
  }
70
92
 
93
+ // session_manager_help's live half. The catalog/recipes below are static and
94
+ // answerable purely in-process (no admin API needed), but "is this MCP
95
+ // server actually wired up for THIS project" needs the admin API's
96
+ // checkDelegationReadiness result — that's genuinely unavailable when the
97
+ // app is down, so this must degrade to a reported-unavailable state rather
98
+ // than throwing NOT_RUNNING_ERROR and failing the whole help call.
99
+ async function fetchReadiness() {
100
+ // SM_PROJECT_ROOT (when present) is a trusted hint for the real project
101
+ // root — forwarded so a worktree/ops-internal process.cwd() still reports
102
+ // the real project's readiness. See projectRootResolve.cjs.
103
+ const cwd = process.env.SM_PROJECT_ROOT || process.cwd();
104
+ try {
105
+ const qs = new URLSearchParams({ cwd });
106
+ const result = await adminRequest('GET', `/admin/mcp/readiness?${qs.toString()}`);
107
+ if (result?.ok === false) {
108
+ return { available: false, cwd, reason: result.error ?? 'readiness check failed' };
109
+ }
110
+ return { available: true, cwd, ok: result.ready, checks: result.checks };
111
+ } catch (e) {
112
+ return { available: false, cwd, reason: e?.message ?? String(e) };
113
+ }
114
+ }
115
+
71
116
  const TOOLS = [
72
117
  {
73
118
  name: 'scheduler_reset_job',
74
- description: "Reset a stuck scheduler job by slug via the session-manager app's admin API. "
75
- + 'Refuses a job whose status is already "completed" unless force:true is passed — resetting '
76
- + 'a completed job re-executes already-shipped work.',
119
+ description: descriptionFor('scheduler_reset_job'),
77
120
  inputSchema: {
78
121
  type: 'object',
79
122
  properties: {
80
123
  slug: { type: 'string', description: 'PRD slug of the job to reset' },
81
124
  force: { type: 'boolean', description: 'Required to reset a job whose status is already "completed"' },
125
+ cwd: { type: 'string', description: 'Optional: the PRD project cwd, narrows/speeds the search' },
82
126
  },
83
127
  required: ['slug'],
84
128
  },
85
129
  },
86
130
  {
87
131
  name: 'scheduler_list_jobs',
88
- description: "List scheduler jobs via the session-manager app's admin API.",
132
+ description: descriptionFor('scheduler_list_jobs'),
89
133
  inputSchema: { type: 'object', properties: {} },
90
134
  },
91
135
  {
92
136
  name: 'scheduler_create_prd',
93
- description:
94
- "THE ONLY SANCTIONED WAY to author a PRD. Write a new PRD file via the session-manager "
95
- + "app's admin API. Server-side validates the frontmatter, atomically allocates the NN "
96
- + 'parallel-group number, appends the engineering standards, and writes the PRD file to '
97
- + "disk. This tool ONLY writes the file — it does not create a scheduler queue row. The "
98
- + 'queue row is derived automatically by the scheduler\'s next reconcile pass (typically '
99
- + 'within ~1 minute); the response has `enqueued: false` for exactly this reason. Every '
100
- + 'PRD must join an EXISTING, already-human-approved Epic (pass sourcePromptId) — this '
101
- + 'tool never mints a new one, and refuses the write if no Epic can be resolved. '
102
- + 'TWO DISTINCT FAILURE MODES if this tool is not usable — do not conflate them: '
103
- + '(a) this tool call is PRESENT in your tool list but ERRORS as app-not-running / admin '
104
- + 'API unreachable — that is the ONLY case where hand-authoring the PRD file directly on '
105
- + 'disk is an acceptable DEGRADED, LAST-RESORT fallback; the caller MUST say so explicitly '
106
- + 'and visibly in its report (which file, why the tool was unreachable, that it needs '
107
- + 'verification) since the server-side validation, atomic NN allocation, and '
108
- + 'Epic-existence check this tool performs did not run for that file. '
109
- + '(b) this tool is ABSENT from your tool list entirely — you were never offered it, so '
110
- + 'there is no error to catch. That means the session-manager-scheduler MCP server is not '
111
- + 'registered for this project: a MISCONFIGURATION, not an offline app. In that case DO '
112
- + 'NOT hand-write any PRD file — stop and tell the human the MCP server is not registered '
113
- + '(fix: `claude mcp add session-manager-scheduler --scope user -- node '
114
- + '<session-manager-repo>/scripts/scheduler-mcp-server.cjs`, once at user scope covers '
115
- + 'every project). See /develop.',
137
+ description: descriptionFor('scheduler_create_prd'),
116
138
  inputSchema: {
117
139
  type: 'object',
118
140
  properties: {
119
141
  title: { type: 'string', description: 'One-line human-readable title' },
120
- cwd: { type: 'string', description: 'Absolute path to the target project (where claude -p will run)' },
142
+ cwd: { type: 'string', description: 'Absolute path to the target project (where claude -p will run). Optional inside an Epic session — the server resolves the real project from the calling session (originClaudeSessionId/sourcePromptId) when omitted.' },
121
143
  estimateMinutes: { type: 'number', description: 'Integer wall-clock estimate in minutes' },
122
144
  goal: { type: 'string', description: '2-4 sentences: what the executor will build and why' },
123
145
  acceptanceCriteria: {
@@ -137,17 +159,12 @@ const TOOLS = [
137
159
  description: 'Optional: the work type of THIS PRD — independent of the parent Epic\'s own tag. An Epic is the plan; a PRD is one unit of work inside it, and a single plan may legitimately contain several different work types. Never derived or inherited from the Epic.',
138
160
  },
139
161
  },
140
- required: ['title', 'cwd', 'estimateMinutes', 'goal', 'acceptanceCriteria', 'implementationNotes'],
162
+ required: ['title', 'estimateMinutes', 'goal', 'acceptanceCriteria', 'implementationNotes'],
141
163
  },
142
164
  },
143
165
  {
144
166
  name: 'scheduler_list_prds',
145
- description: "THE ONLY SUPPORTED WAY to list scheduled PRDs (live + archived) via the session-manager app's admin API. "
146
- + 'Each entry includes its real job status (pending/running/completed/failed/needs_review, or null if not yet '
147
- + 'queued/reconciled). Optionally filter by project cwd, Epic id, and/or status. Results are paginated (default '
148
- + 'limit 100, max 500) sorted by slug ascending — check `hasMore`/`total` in the response before assuming you '
149
- + "received every PRD; page further with `offset`. Default fields are compact (no parallelGroup/estimateMinutes/"
150
- + 'sourcePromptId/epicId/archivedStatus) — pass fields:"full" to restore them.',
167
+ description: descriptionFor('scheduler_list_prds'),
151
168
  inputSchema: {
152
169
  type: 'object',
153
170
  properties: {
@@ -162,8 +179,7 @@ const TOOLS = [
162
179
  },
163
180
  {
164
181
  name: 'scheduler_get_prd',
165
- description: "THE ONLY SUPPORTED WAY to read one PRD's full body + parsed frontmatter (live or archived) via the "
166
- + "session-manager app's admin API.",
182
+ description: descriptionFor('scheduler_get_prd'),
167
183
  inputSchema: {
168
184
  type: 'object',
169
185
  properties: {
@@ -175,11 +191,7 @@ const TOOLS = [
175
191
  },
176
192
  {
177
193
  name: 'scheduler_update_prd',
178
- description: "THE ONLY SUPPORTED WAY to edit a NOT-yet-running PRD's frontmatter and/or body via the session-manager "
179
- + 'app\'s admin API. Refuses once a queue row exists for the slug and its status is anything but "pending" '
180
- + '(running/completed/failed/needs_review) — editing the spec under a live or already-finished executor is refused, '
181
- + 'not silently applied. Only recognized frontmatter keys (title, cwd, estimateMinutes, parallelGroup, '
182
- + 'sourcePromptId, sourceTabId, tag) may be patched; unrecognized keys (e.g. dependsOn) round-trip unchanged.',
194
+ description: descriptionFor('scheduler_update_prd'),
183
195
  inputSchema: {
184
196
  type: 'object',
185
197
  properties: {
@@ -205,34 +217,31 @@ const TOOLS = [
205
217
  },
206
218
  {
207
219
  name: 'scheduler_archive_prd',
208
- description: "THE ONLY SUPPORTED WAY to archive one or more PRDs (move to prds-archived/) via the session-manager "
209
- + "app's admin API.",
220
+ description: descriptionFor('scheduler_archive_prd'),
210
221
  inputSchema: {
211
222
  type: 'object',
212
223
  properties: {
213
224
  slugs: { type: 'array', items: { type: 'string' }, description: 'Slugs to archive' },
225
+ cwd: { type: 'string', description: 'Optional: the PRDs\' project cwd, narrows/speeds the search for every slug in this batch' },
214
226
  },
215
227
  required: ['slugs'],
216
228
  },
217
229
  },
218
230
  {
219
231
  name: 'scheduler_cancel_job',
220
- description: "THE ONLY SUPPORTED WAY to cancel a not-yet-terminal scheduler job via the session-manager app's admin "
221
- + 'API. A running job is SIGTERM\'d; a pending job is simply retired. There is no "cancelled" job status, so a '
222
- + 'cancelled job lands as "failed" with an error naming the cause. Refuses a slug whose job is already terminal '
223
- + '(completed/failed/needs_review) — nothing to cancel.',
232
+ description: descriptionFor('scheduler_cancel_job'),
224
233
  inputSchema: {
225
234
  type: 'object',
226
235
  properties: {
227
236
  slug: { type: 'string', description: 'PRD slug of the job to cancel' },
237
+ cwd: { type: 'string', description: 'Optional: the PRD project cwd, narrows/speeds the search' },
228
238
  },
229
239
  required: ['slug'],
230
240
  },
231
241
  },
232
242
  {
233
243
  name: 'scheduler_retag_prd',
234
- description: "THE ONLY SUPPORTED WAY to rewrite a PRD's parallelGroup and/or estimateMinutes frontmatter (and, if "
235
- + "parallelGroup changes, its NN- filename prefix) via the session-manager app's admin API.",
244
+ description: descriptionFor('scheduler_retag_prd'),
236
245
  inputSchema: {
237
246
  type: 'object',
238
247
  properties: {
@@ -255,10 +264,7 @@ const TOOLS = [
255
264
  },
256
265
  {
257
266
  name: 'chat_send_prompt',
258
- description:
259
- "Push a prompt into an already-open tab's chat queue via the session-manager app's admin "
260
- + 'API. The renderer resolves the tab (must currently be open) and runs the prompt through '
261
- + 'the same queued-vs-immediate path as a manual send. No-ops if the tab is unknown/closed.',
267
+ description: descriptionFor('chat_send_prompt'),
262
268
  inputSchema: {
263
269
  type: 'object',
264
270
  properties: {
@@ -270,31 +276,12 @@ const TOOLS = [
270
276
  },
271
277
  {
272
278
  name: 'feedback_list_projects',
273
- description:
274
- 'List the OTHER projects on this machine that can receive feedback (i.e. that Session Manager '
275
- + 'already manages — they have a session-manager-operations/ directory). Call this FIRST when you '
276
- + 'need the exact `toCwd` for feedback_open_session and are not certain of it — never guess a path. '
277
- + 'A project missing from this list has simply never been opened in Session Manager; ask the human '
278
- + 'to open it once rather than inventing a path.',
279
+ description: descriptionFor('feedback_list_projects'),
279
280
  inputSchema: { type: 'object', properties: {} },
280
281
  },
281
282
  {
282
283
  name: 'feedback_open_session',
283
- description:
284
- 'THE ONLY SANCTIONED WAY to hand a finding from THIS project to a DIFFERENT project. Opens a new '
285
- + "PROPOSED session in the receiving project's own Sessions queue, carrying your report as its "
286
- + 'opening prompt and stamped with where it came from. Session Manager performs the cross-folder '
287
- + 'write; you never write another project\'s files yourself. '
288
- + 'WHAT THIS DOES NOT DO: it does not start anything, queue a PRD, or spend a token. The session '
289
- + 'lands as `proposed` and runs only if a human in the RECEIVING project presses "Approve & start". '
290
- + 'There is no callback and no reply channel — do not wait for an answer, and do not tell the user '
291
- + 'the other project has "been fixed" or "is working on it". Report only that the proposal was '
292
- + 'delivered. '
293
- + 'WHEN NOT TO USE IT: for work in the project you are ALREADY in, run /develop inside the Epic you '
294
- + 'are already in — this tool refuses toCwd === fromCwd outright. '
295
- + 'Write the report for a reader who has never seen your project: state the symptom, where you '
296
- + 'observed it, what you expected, and (if you know) the file in THEIR repo that looks responsible. '
297
- + 'Never assume they can see your code.',
284
+ description: descriptionFor('feedback_open_session'),
298
285
  inputSchema: {
299
286
  type: 'object',
300
287
  properties: {
@@ -329,6 +316,17 @@ const TOOLS = [
329
316
  required: ['toCwd', 'fromCwd', 'title', 'body'],
330
317
  },
331
318
  },
319
+ {
320
+ name: 'session_manager_help',
321
+ description: descriptionFor('session_manager_help'),
322
+ inputSchema: {
323
+ type: 'object',
324
+ properties: {
325
+ tool: { type: 'string', description: 'Optional: a tool name — returns that one catalog entry, including exampleArgs' },
326
+ topic: { type: 'string', description: 'Optional: a recipe id — returns that recipe\'s step-by-step instructions' },
327
+ },
328
+ },
329
+ },
332
330
  ];
333
331
 
334
332
  const server = new Server(
@@ -338,16 +336,21 @@ const server = new Server(
338
336
 
339
337
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
340
338
 
341
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
339
+ // Named + exported (see module.exports below) so mcpToolCatalog.test.cjs's
340
+ // sibling can exercise session_manager_help's argument handling directly —
341
+ // mocking node:fs/promises + global.fetch to drive adminRequest — without
342
+ // booting a stdio transport.
343
+ async function handleCallTool(request) {
342
344
  const { name, arguments: args } = request.params;
343
345
  try {
344
346
  if (name === 'scheduler_reset_job') {
345
347
  const slug = args && typeof args.slug === 'string' ? args.slug : null;
346
348
  if (!slug) {
347
- return { content: [{ type: 'text', text: 'missing required argument: slug' }], isError: true };
349
+ return errorResult('missing required argument: slug');
348
350
  }
349
351
  const force = args && args.force === true;
350
- const result = await adminRequest('POST', '/admin/scheduler/reset-job', { slug, force });
352
+ const cwd = args && typeof args.cwd === 'string' ? args.cwd : undefined;
353
+ const result = await adminRequest('POST', '/admin/scheduler/reset-job', { slug, force, cwd });
351
354
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
352
355
  }
353
356
  if (name === 'scheduler_list_jobs') {
@@ -375,7 +378,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
375
378
  if (name === 'scheduler_get_prd') {
376
379
  const slug = args && typeof args.slug === 'string' ? args.slug : null;
377
380
  if (!slug) {
378
- return { content: [{ type: 'text', text: 'missing required argument: slug' }], isError: true };
381
+ return errorResult('missing required argument: slug');
379
382
  }
380
383
  const qs = new URLSearchParams({ slug });
381
384
  if (args?.cwd) qs.set('cwd', args.cwd);
@@ -385,7 +388,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
385
388
  if (name === 'scheduler_update_prd') {
386
389
  const slug = args && typeof args.slug === 'string' ? args.slug : null;
387
390
  if (!slug) {
388
- return { content: [{ type: 'text', text: 'missing required argument: slug' }], isError: true };
391
+ return errorResult('missing required argument: slug');
389
392
  }
390
393
  const result = await adminRequest('POST', '/admin/scheduler/update-prd', args);
391
394
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
@@ -393,23 +396,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
393
396
  if (name === 'scheduler_archive_prd') {
394
397
  const slugs = Array.isArray(args?.slugs) ? args.slugs : null;
395
398
  if (!slugs || slugs.length === 0) {
396
- return { content: [{ type: 'text', text: 'missing required argument: slugs' }], isError: true };
399
+ return errorResult('missing required argument: slugs');
397
400
  }
398
- const result = await adminRequest('POST', '/admin/scheduler/archive-prd', { slugs });
401
+ const cwd = args && typeof args.cwd === 'string' ? args.cwd : undefined;
402
+ const result = await adminRequest('POST', '/admin/scheduler/archive-prd', { slugs, cwd });
399
403
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
400
404
  }
401
405
  if (name === 'scheduler_cancel_job') {
402
406
  const slug = args && typeof args.slug === 'string' ? args.slug : null;
403
407
  if (!slug) {
404
- return { content: [{ type: 'text', text: 'missing required argument: slug' }], isError: true };
408
+ return errorResult('missing required argument: slug');
405
409
  }
406
- const result = await adminRequest('POST', '/admin/scheduler/cancel-job', { slug });
410
+ const cwd = args && typeof args.cwd === 'string' ? args.cwd : undefined;
411
+ const result = await adminRequest('POST', '/admin/scheduler/cancel-job', { slug, cwd });
407
412
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
408
413
  }
409
414
  if (name === 'scheduler_retag_prd') {
410
415
  const items = Array.isArray(args?.items) ? args.items : null;
411
416
  if (!items || items.length === 0) {
412
- return { content: [{ type: 'text', text: 'missing required argument: items' }], isError: true };
417
+ return errorResult('missing required argument: items');
413
418
  }
414
419
  const result = await adminRequest('POST', '/admin/scheduler/retag-prd', { items });
415
420
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
@@ -425,6 +430,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
425
430
  if (!payload.sourcePromptId && process.env.SM_CHAT_SESSION_ID) {
426
431
  payload.originClaudeSessionId = process.env.SM_CHAT_SESSION_ID;
427
432
  }
433
+ // SM_PROJECT_ROOT (chatRunner.cjs/pty.cjs/scheduler.cjs's job spawn) —
434
+ // a trusted hint for the real project cwd, forwarded so the admin route
435
+ // can resolve it even when the caller omitted cwd or passed a worktree
436
+ // pwd. See projectRootResolve.cjs's resolveProjectContext.
437
+ if (process.env.SM_PROJECT_ROOT) {
438
+ payload.originProjectRoot = process.env.SM_PROJECT_ROOT;
439
+ }
428
440
  const result = await adminRequest('POST', '/admin/scheduler/create-prd', payload);
429
441
  // Never say "queued" — this tool only writes the PRD file; the queue
430
442
  // row is derived by the scheduler's next reconcile pass, not by this
@@ -441,7 +453,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
441
453
  if (name === 'feedback_open_session') {
442
454
  for (const key of ['toCwd', 'fromCwd', 'title', 'body']) {
443
455
  if (!args || typeof args[key] !== 'string' || !args[key].trim()) {
444
- return { content: [{ type: 'text', text: `missing required argument: ${key}` }], isError: true };
456
+ return errorResult(`missing required argument: ${key}`);
445
457
  }
446
458
  }
447
459
  // Same fallback shape as scheduler_create_prd: forward this process's
@@ -452,6 +464,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
452
464
  if (!payload.fromEpicId && process.env.SM_CHAT_SESSION_ID) {
453
465
  payload.originClaudeSessionId = process.env.SM_CHAT_SESSION_ID;
454
466
  }
467
+ // See scheduler_create_prd's own SM_PROJECT_ROOT forwarding above.
468
+ if (process.env.SM_PROJECT_ROOT) {
469
+ payload.originProjectRoot = process.env.SM_PROJECT_ROOT;
470
+ }
455
471
  const result = await adminRequest('POST', '/admin/feedback/open-session', payload);
456
472
  // Never say "sent", "filed" or "fixed" — this call delivers a PROPOSAL
457
473
  // that a human in the other project must still approve.
@@ -464,23 +480,66 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
464
480
  const tabId = args && typeof args.tabId === 'string' ? args.tabId : null;
465
481
  const prompt = args && typeof args.prompt === 'string' ? args.prompt : null;
466
482
  if (!tabId || !prompt) {
467
- return { content: [{ type: 'text', text: 'missing required arguments: tabId, prompt' }], isError: true };
483
+ return errorResult('missing required arguments: tabId, prompt');
468
484
  }
469
485
  const result = await adminRequest('POST', '/admin/chat/send-prompt', { tabId, prompt });
470
486
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
471
487
  }
472
- return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true };
488
+ if (name === 'session_manager_help') {
489
+ const toolName = args && typeof args.tool === 'string' ? args.tool : null;
490
+ const topic = args && typeof args.topic === 'string' ? args.topic : null;
491
+
492
+ const response = {};
493
+
494
+ if (toolName) {
495
+ const entry = CATALOG_BY_NAME.get(toolName);
496
+ if (!entry) {
497
+ const valid = MCP_TOOL_CATALOG.map((e) => e.name);
498
+ return errorResult(`unknown tool "${toolName}" for session_manager_help — valid tool names: ${valid.join(', ')}`);
499
+ }
500
+ response.tool = entry;
501
+ }
502
+
503
+ if (topic) {
504
+ const recipe = MCP_RECIPES.find((r) => r.id === topic);
505
+ if (!recipe) {
506
+ const valid = MCP_RECIPES.map((r) => r.id);
507
+ return errorResult(`unknown topic "${topic}" for session_manager_help — valid topic ids: ${valid.join(', ')}`);
508
+ }
509
+ response.recipe = recipe;
510
+ }
511
+
512
+ if (!toolName && !topic) {
513
+ response.tools = MCP_TOOL_CATALOG.map((e) => ({ name: e.name, group: e.group, purpose: e.purpose }));
514
+ response.recipes = MCP_RECIPES.map((r) => ({ id: r.id, title: r.title }));
515
+ }
516
+
517
+ response.readiness = await fetchReadiness();
518
+
519
+ return { content: [{ type: 'text', text: JSON.stringify(response) }] };
520
+ }
521
+ return errorResult(`unknown tool: ${name}`);
473
522
  } catch (e) {
474
- return { content: [{ type: 'text', text: e?.message ?? String(e) }], isError: true };
523
+ return errorResult(e?.message ?? String(e));
475
524
  }
476
- });
525
+ }
526
+
527
+ server.setRequestHandler(CallToolRequestSchema, handleCallTool);
477
528
 
478
529
  async function main() {
479
530
  const transport = new StdioServerTransport();
480
531
  await server.connect(transport);
481
532
  }
482
533
 
483
- main().catch((e) => {
484
- process.stderr.write(`scheduler-mcp-server fatal: ${e?.stack ?? e}\n`);
485
- process.exit(1);
486
- });
534
+ // Guarded so mcpToolCatalog.test.cjs can `require()` this file to read TOOLS
535
+ // (for both-directions name-parity) without opening a stdio transport as a
536
+ // require-time side effect — only the real `node scripts/scheduler-mcp-server.cjs`
537
+ // invocation satisfies require.main === module.
538
+ if (require.main === module) {
539
+ main().catch((e) => {
540
+ process.stderr.write(`scheduler-mcp-server fatal: ${e?.stack ?? e}\n`);
541
+ process.exit(1);
542
+ });
543
+ }
544
+
545
+ module.exports = { TOOLS, handleCallTool, HELP_POINTER, NOT_RUNNING_ERROR };