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
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * epicStatusMirror.cjs — mirrors an Epic's lifecycle `status` onto its own
5
+ * durable file, `prompt-sessions/<epicId>.json`, every time a write path
6
+ * changes that status in `active-index.json`.
7
+ *
8
+ * Why this exists: `active-index.json` is the ONLY place an Epic's status
9
+ * lives today. A lost, clobbered, git-reverted, or worktree-snapshotted index
10
+ * silently erases every open Epic it held, with no way to recover them —
11
+ * `activeIndexRebuild.cjs`'s `rebuildActiveIndex` is that recovery path, and
12
+ * it reconstructs rows from exactly the mirror this module writes. The
13
+ * archive file `markCompleted()` already writes at completion (session/
14
+ * events/transcript/archivedAt — see prompt-sessions/README.md) is the same
15
+ * file this module targets; a live (proposed/active) Epic gets a sparse
16
+ * mirror here instead, later overwritten wholesale by the real archive.
17
+ *
18
+ * Raw fs tmp+rename — NOT config.cjs's `writeJson` — so this module stays
19
+ * requirable from epicMint.cjs, which is deliberately Electron-free (its own
20
+ * header: "so the external watchdog scripts can require it"; config.cjs
21
+ * requires 'electron' at module load). The write shape matches config.cjs's
22
+ * writeJson exactly (`JSON.stringify(data, null, 2) + '\n'`, `<path>.tmp-
23
+ * <pid>` then rename) so main-process callers (activeIndexMerge.cjs) and
24
+ * epicMint.cjs never diverge in format despite using different write paths.
25
+ */
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const { assertOpsWrite, opsPath } = require('./opsOwnership.cjs');
30
+
31
+ function epicMirrorPath(cwd, epicId) {
32
+ return opsPath(cwd, 'prompt-sessions', `${epicId}.json`);
33
+ }
34
+
35
+ function readExistingMirror(file) {
36
+ try {
37
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
38
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ /**
45
+ * mirrorEpicStatus(cwd, epicId, { session?, status?, archivedAt?, writer? })
46
+ * — read-merge-write onto `prompt-sessions/<epicId>.json`, preserving
47
+ * whatever else is already there (e.g. a full `PromptSessionArchive` written
48
+ * by `markCompleted`, or a prior mirror).
49
+ *
50
+ * When `session` (the full PromptSession record) is given, it is spread onto
51
+ * the file too — not just the four mirror fields — so a live (proposed/
52
+ * active) Epic's file alone is enough for `activeIndexRebuild.cjs` to
53
+ * reconstruct a complete `active-index.json` row, not just its status.
54
+ * `status`/`archivedAt` may be passed independently of `session` (or to
55
+ * override `session.status`) for callers that only know the status.
56
+ *
57
+ * Never throws on a read failure (a missing/corrupt existing file is treated
58
+ * as "start fresh"); a write failure propagates, matching every other atomic
59
+ * writer in this codebase (config.cjs's writeTextAtomic, epicMint.cjs's own
60
+ * writeActiveIndex).
61
+ */
62
+ function mirrorEpicStatus(cwd, epicId, { session = null, status = null, archivedAt = null, writer = 'epics' } = {}) {
63
+ const resolvedStatus = status || (session && session.status) || null;
64
+ if (!cwd || !epicId || !resolvedStatus) return;
65
+ const file = epicMirrorPath(cwd, epicId);
66
+ assertOpsWrite(file, writer);
67
+ const existing = readExistingMirror(file);
68
+ const merged = {
69
+ ...existing,
70
+ ...(session || {}),
71
+ id: epicId,
72
+ cwd,
73
+ status: resolvedStatus,
74
+ archivedAt: archivedAt ?? existing.archivedAt ?? null,
75
+ indexedAt: new Date().toISOString(),
76
+ };
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ const tmp = `${file}.tmp-${process.pid}`;
79
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n');
80
+ fs.renameSync(tmp, file);
81
+ }
82
+
83
+ /**
84
+ * removeEpicMirror(cwd, epicId) — best-effort delete of the mirror file, used
85
+ * only by epicMint.cjs's removeEpic() rollback (undoing a mint whose caller
86
+ * failed to complete its own work). Never throws — a missing file is a no-op.
87
+ */
88
+ function removeEpicMirror(cwd, epicId) {
89
+ if (!cwd || !epicId) return;
90
+ try {
91
+ fs.unlinkSync(epicMirrorPath(cwd, epicId));
92
+ } catch { /* never existed, or already gone */ }
93
+ }
94
+
95
+ module.exports = { mirrorEpicStatus, removeEpicMirror, epicMirrorPath };
@@ -62,7 +62,8 @@ function pairKey(epicId, prdSlug) {
62
62
  */
63
63
  function defaultReadActiveIndex(cwd) {
64
64
  try {
65
- const p = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
65
+ const { opsPath } = require('./opsOwnership.cjs');
66
+ const p = opsPath(cwd, 'prompt-sessions', 'active-index.json');
66
67
  return JSON.parse(fs.readFileSync(p, 'utf8'));
67
68
  } catch {
68
69
  return null;
@@ -157,10 +157,21 @@ async function isGitRepo(cwd) {
157
157
  }
158
158
  }
159
159
 
160
- /** True when `cwd`'s own working tree (not any worktree) has zero pending changes. */
160
+ /**
161
+ * True when `cwd`'s own working tree (not any worktree) has zero pending
162
+ * changes to TRACKED files (modified or staged). Untracked files are
163
+ * deliberately excluded (`--untracked-files=no`): an untracked file is never
164
+ * tracked WIP a job depends on, and `git worktree add` checks out only
165
+ * committed HEAD content into the new worktree — it never touches, copies, or
166
+ * is affected by the base tree's untracked files either way. Counting them as
167
+ * "dirty" here bought no safety: one stray scratch file in a project silently
168
+ * disabled isolation for every job in that project, permanently, since a
169
+ * failed-to-commit job leaves the tree dirty and re-triggers the same
170
+ * fallback for every subsequent job (RCA: PRD 1064, starry-night-ships).
171
+ */
161
172
  async function isBaseTreeClean(cwd) {
162
173
  try {
163
- const out = await execGit(['status', '--porcelain'], { cwd, timeout: 10_000 });
174
+ const out = await execGit(['status', '--porcelain', '--untracked-files=no'], { cwd, timeout: 10_000 });
164
175
  return out.trim().length === 0;
165
176
  } catch {
166
177
  return false;
@@ -316,6 +327,44 @@ async function cleanupWorktree({ kind, cwd, dir, branch, keepBranch }) {
316
327
  activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
317
328
  }
318
329
 
330
+ /**
331
+ * Best-effort dump of a worktree's full outstanding diff (tracked
332
+ * modifications AND untracked files) to `outFile`, for a caller about to
333
+ * tear the worktree down. A job killed before its finish-protocol commit
334
+ * (rate limit, timeout, crash, hard kill) otherwise loses that work
335
+ * outright the moment `cleanupWorktree` removes the checkout — no branch,
336
+ * no stash, no patch survives it. `git add -A --intent-to-add` stages
337
+ * untracked paths (empty blobs) without touching their content, so the
338
+ * subsequent `git diff HEAD --binary` includes their full contents exactly
339
+ * like a tracked modification; the result is a patch a later `git apply`
340
+ * can consume verbatim.
341
+ *
342
+ * Never throws — this always runs immediately before teardown and must
343
+ * never block branch integration/cleanup or change a job's verdict. Writes
344
+ * nothing (and returns `{ ok: false }`) when the worktree is clean or on
345
+ * any git failure, so a successful run's teardown never leaves a 0-byte
346
+ * artifact behind.
347
+ */
348
+ async function salvageWorktreeDiff({ cwd, outFile }) {
349
+ try {
350
+ const status = await execGit(['status', '--porcelain'], { cwd, timeout: 15_000 });
351
+ if (!status.trim()) return { ok: false };
352
+ try {
353
+ await execGit(['add', '-A', '--intent-to-add'], { cwd, timeout: 30_000 });
354
+ } catch {
355
+ // Best-effort — the diff below still captures whatever staged/tracked
356
+ // changes exist even if intent-to-add partially failed.
357
+ }
358
+ const patch = await execGit(['diff', 'HEAD', '--binary'], { cwd, timeout: 30_000 });
359
+ if (!patch || !patch.trim()) return { ok: false };
360
+ const { writeTextAtomic } = require('../config.cjs');
361
+ await writeTextAtomic(outFile, patch);
362
+ return { ok: true, bytes: Buffer.byteLength(patch, 'utf8') };
363
+ } catch {
364
+ return { ok: false };
365
+ }
366
+ }
367
+
319
368
  /** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
320
369
  function parseWorktreeListPorcelain(text) {
321
370
  const entries = [];
@@ -399,6 +448,9 @@ async function integrateJobBranch({ cwd, branch, slug }) {
399
448
  async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
400
449
  return cleanupWorktree({ kind: 'job', cwd, dir, branch, keepBranch });
401
450
  }
451
+ async function salvageJobWorktreeDiff({ dir, outFile }) {
452
+ return salvageWorktreeDiff({ cwd: dir, outFile });
453
+ }
402
454
 
403
455
  async function createEpicWorktree({ cwd, epicId }) {
404
456
  return createWorktree({ kind: 'epic', cwd, key: epicId });
@@ -423,6 +475,7 @@ module.exports = {
423
475
  createWorktree,
424
476
  integrateBranch,
425
477
  cleanupWorktree,
478
+ salvageWorktreeDiff,
426
479
  parseWorktreeListPorcelain,
427
480
  reconcileWorktreesOnBoot,
428
481
  // Job-kind convenience wrappers — same call shape jobWorktree.cjs has
@@ -430,6 +483,7 @@ module.exports = {
430
483
  createJobWorktree,
431
484
  integrateJobBranch,
432
485
  cleanupJobWorktree,
486
+ salvageJobWorktreeDiff,
433
487
  // Epic-kind convenience wrappers.
434
488
  createEpicWorktree,
435
489
  integrateEpicBranch,
@@ -62,6 +62,7 @@ module.exports = {
62
62
  createJobWorktree: gitWorktree.createJobWorktree,
63
63
  integrateJobBranch: gitWorktree.integrateJobBranch,
64
64
  cleanupJobWorktree: gitWorktree.cleanupJobWorktree,
65
+ salvageJobWorktreeDiff: gitWorktree.salvageJobWorktreeDiff,
65
66
  parseWorktreeListPorcelain: gitWorktree.parseWorktreeListPorcelain,
66
67
  reconcileWorktreesOnBoot: (cwds) => gitWorktree.reconcileWorktreesOnBoot(cwds, { kind: KIND }),
67
68
  // Test-only escape hatch for the in-memory concurrency counter.
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * loadGate.cjs — CPU-load launch gate for the scheduler (PRD 1085).
5
+ *
6
+ * The scheduler already gates launches on free memory (scheduler.cjs's
7
+ * memoryLimitedBatchSize) and on a static per-project cap
8
+ * (schedulerConfig.projectJobCap). Neither sees CPU. Observed 2026-09-01 on
9
+ * starry-night-ships: 4 concurrent executors each running a Godot test
10
+ * battery under its own Xvfb, loadavg 12.95 on 14 cores. Under that
11
+ * contention every 8-minute battery stretches, executors hit their own
12
+ * `timeout`, the verifier files FAIL/FATAL → needs_review → an auto-fix
13
+ * chain with inflated estimates launches MORE batteries. A static cap cannot
14
+ * see that feedback loop; the 1-minute load average can.
15
+ *
16
+ * Gate ordering at the call site (scheduler.cjs tickQueue):
17
+ * global sessionSlots pool → per-project cap → memory → LOAD (innermost).
18
+ * This is one more predicate inside the existing pick path — never a second
19
+ * pool, and it only WITHHOLDS launches; running jobs are never touched.
20
+ *
21
+ * Everything here is pure and injectable (`loadavg`, `cores`, `now`) so the
22
+ * decision, the audit rate-limit and the escalation are unit-testable without
23
+ * real load or a fake clock hack on `Date`.
24
+ */
25
+
26
+ const os = require('node:os');
27
+ const { execFileSync } = require('node:child_process');
28
+ const { loadGateThreshold, JOB_OVERRUN_FLOOR_MS } = require('./schedulerConfig.cjs');
29
+
30
+ // One audit row per this interval while continuously gated — the poll loop
31
+ // ticks every 60 s and a saturated box stays saturated for a while; auditing
32
+ // every tick would bury the signal in its own noise.
33
+ const AUDIT_INTERVAL_MS = 10 * 60_000;
34
+
35
+ /**
36
+ * isLoadGated(loadavg1, cores, threshold) → boolean
37
+ *
38
+ * True when the 1-minute load average per core exceeds `threshold`. The 5-
39
+ * and 15-minute averages are deliberately NOT consulted: a finished battery
40
+ * should free launches within a minute or two, not a quarter hour. A
41
+ * threshold of 0 (or anything non-positive) disables the gate. Zero/unknown
42
+ * cores never gates (os.cpus() can be empty in some containers).
43
+ */
44
+ function isLoadGated(loadavg1, cores, threshold) {
45
+ if (!(threshold > 0)) return false;
46
+ if (!(cores > 0)) return false;
47
+ if (!Number.isFinite(loadavg1) || loadavg1 <= 0) return false; // [0,0,0] on Windows/unsupported
48
+ return loadavg1 / cores > threshold;
49
+ }
50
+
51
+ /** Best-effort top-N CPU consumers (Linux only). Returns [] anywhere else or on error. */
52
+ function topCpuConsumers(n = 3) {
53
+ if (process.platform !== 'linux') return [];
54
+ try {
55
+ const out = execFileSync('ps', ['-eo', 'pid,pcpu,comm', '--sort=-pcpu'], { encoding: 'utf8', timeout: 2000 });
56
+ return out.split('\n').slice(1, 1 + n).map((l) => l.trim()).filter(Boolean);
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ /**
63
+ * createLoadGate(opts) → { evaluate, snapshot }
64
+ *
65
+ * Holds the small amount of state the gate needs across ticks (when it was
66
+ * last audited, when the current gated stretch began). `evaluate({ bypass })`
67
+ * returns the decision for THIS tick:
68
+ * { gated, ratio, threshold, loadavg1, cores, bypassed,
69
+ * shouldAudit, escalate, gatedSinceMs }
70
+ * - shouldAudit: true at most once per AUDIT_INTERVAL_MS while gated.
71
+ * - escalate: true once the current gated stretch exceeds JOB_OVERRUN_FLOOR_MS
72
+ * (45 min by default) — the caller warn-logs with topCpuConsumers().
73
+ * - bypassed: `bypass` was set (an explicit human Run now) and the gate would
74
+ * otherwise have held; the caller launches anyway and logs that it did.
75
+ *
76
+ * `snapshot()` is what buildScheduleStatePayload exposes as `loadGate`.
77
+ */
78
+ function createLoadGate({
79
+ loadavg = () => os.loadavg(),
80
+ cores = () => (os.cpus() || []).length,
81
+ now = () => Date.now(),
82
+ threshold = loadGateThreshold,
83
+ auditIntervalMs = AUDIT_INTERVAL_MS,
84
+ escalateAfterMs = JOB_OVERRUN_FLOOR_MS,
85
+ } = {}) {
86
+ let lastAuditAt = null; // null = never audited; the first gated tick always audits
87
+ let gatedSince = null;
88
+ let last = null;
89
+
90
+ function evaluate({ bypass = false } = {}) {
91
+ const t = now();
92
+ const [l1] = loadavg();
93
+ const c = cores();
94
+ const th = typeof threshold === 'function' ? threshold() : threshold;
95
+ const ratio = c > 0 && Number.isFinite(l1) ? l1 / c : 0;
96
+ const wouldGate = isLoadGated(l1, c, th);
97
+
98
+ if (wouldGate) {
99
+ if (gatedSince === null) gatedSince = t;
100
+ } else {
101
+ gatedSince = null;
102
+ }
103
+ const gated = wouldGate && !bypass;
104
+ const bypassed = wouldGate && bypass;
105
+
106
+ let shouldAudit = false;
107
+ if (gated && (lastAuditAt === null || t - lastAuditAt >= auditIntervalMs)) {
108
+ shouldAudit = true;
109
+ lastAuditAt = t;
110
+ }
111
+ const gatedSinceMs = gatedSince === null ? 0 : t - gatedSince;
112
+ const escalate = gated && gatedSinceMs >= escalateAfterMs;
113
+
114
+ last = {
115
+ gated,
116
+ bypassed,
117
+ ratio: Number(ratio.toFixed(3)),
118
+ threshold: th,
119
+ loadavg1: Number.isFinite(l1) ? Number(l1.toFixed(2)) : null,
120
+ cores: c,
121
+ gatedSinceMs,
122
+ at: new Date(t).toISOString(),
123
+ };
124
+ return { ...last, shouldAudit, escalate };
125
+ }
126
+
127
+ function snapshot() {
128
+ return last;
129
+ }
130
+
131
+ return { evaluate, snapshot };
132
+ }
133
+
134
+ module.exports = { isLoadGated, createLoadGate, topCpuConsumers, AUDIT_INTERVAL_MS };
@@ -0,0 +1,285 @@
1
+ /**
2
+ * mcpToolCatalog.cjs — single source of truth for what every
3
+ * session-manager-scheduler MCP tool does, when to reach for it, and when
4
+ * NOT to. Before this file, that knowledge lived only inside hand-written
5
+ * `description` strings in scripts/scheduler-mcp-server.cjs's TOOLS array —
6
+ * invisible to the app, the Home tab, the manual, and to a human debugging
7
+ * why a session went off-piste. scheduler-mcp-server.cjs now BUILDS each
8
+ * tool's live `description` from this catalog via composeDescription(), so
9
+ * the two can never drift.
10
+ *
11
+ * Plain CJS, no Electron dependency — requirable from both the main process
12
+ * and the standalone `node scripts/scheduler-mcp-server.cjs` process, same
13
+ * constraint workTypeLibrary.cjs already satisfies (see that file's header).
14
+ *
15
+ * Composition rule (also asserted in mcpToolCatalog.test.cjs): a tool's live
16
+ * description is `[purpose, whenToUse, whenNotToUse, notes].filter(Boolean)
17
+ * .join(' ')` — deterministic, so a catalog edit provably reaches the live
18
+ * MCP tool list with no separate hand-edit required.
19
+ */
20
+ 'use strict';
21
+
22
+ const { z } = require('zod');
23
+
24
+ const CatalogEntrySchema = z.object({
25
+ name: z.string().min(1),
26
+ group: z.enum(['scheduler', 'chat', 'feedback', 'help']),
27
+ purpose: z.string().min(1),
28
+ whenToUse: z.string().min(1),
29
+ whenNotToUse: z.string().min(1),
30
+ exampleArgs: z.record(z.string(), z.unknown()),
31
+ notes: z.string().nullable(),
32
+ });
33
+
34
+ const RecipeSchema = z.object({
35
+ id: z.string().min(1),
36
+ title: z.string().min(1),
37
+ steps: z.array(z.string().min(1)).min(1),
38
+ });
39
+
40
+ const MCP_TOOL_CATALOG = [
41
+ {
42
+ name: 'scheduler_reset_job',
43
+ group: 'scheduler',
44
+ purpose: "Reset a stuck scheduler job by slug via the session-manager app's admin API.",
45
+ whenToUse: 'Use after diagnosing why a job is stuck (e.g. via scheduler_get_prd/scheduler_list_jobs) and deciding it should re-run from pending.',
46
+ whenNotToUse: 'Do not use as a first move on a "needs_review" job without reading it first — reset just clears status, it does not answer the question the job raised.',
47
+ exampleArgs: { slug: 'add-mcp-tool-catalog', force: false },
48
+ notes: 'Refuses a job whose status is already "completed" unless force:true is passed — resetting a completed job re-executes already-shipped work.',
49
+ },
50
+ {
51
+ name: 'scheduler_list_jobs',
52
+ group: 'scheduler',
53
+ purpose: "List scheduler jobs via the session-manager app's admin API.",
54
+ whenToUse: 'Use for a quick overview of live queue-row status (pending/running/completed/failed/needs_review) across the machine.',
55
+ whenNotToUse: 'For PRD content (frontmatter/body) rather than just job status, or for filtering by cwd/Epic, use scheduler_list_prds instead.',
56
+ exampleArgs: {},
57
+ notes: null,
58
+ },
59
+ {
60
+ name: 'scheduler_create_prd',
61
+ group: 'scheduler',
62
+ purpose: 'THE ONLY SANCTIONED WAY to author a PRD. Write a new PRD file via the '
63
+ + "session-manager app's admin API. Server-side validates the frontmatter, "
64
+ + 'atomically allocates the NN parallel-group number, appends the engineering '
65
+ + 'standards, and writes the PRD file to disk. This tool ONLY writes the file — '
66
+ + 'it does not create a scheduler queue row. The queue row is derived '
67
+ + "automatically by the scheduler's next reconcile pass (typically within ~1 "
68
+ + 'minute); the response has `enqueued: false` for exactly this reason. Every PRD '
69
+ + 'must join an EXISTING, already-human-approved Epic (pass sourcePromptId) — this '
70
+ + 'tool never mints a new one, and refuses the write if no Epic can be resolved. '
71
+ + '`cwd` is OPTIONAL when called from inside an Epic session (chat or terminal, '
72
+ + 'including from inside that Epic\'s own git worktree pwd): the server resolves '
73
+ + 'the real project from the calling session\'s sourcePromptId/originClaudeSessionId, '
74
+ + 'never from a worktree\'s own possibly-stale active-index.json snapshot.',
75
+ whenToUse: 'Use whenever new work should be queued into an already-approved Epic — this is the /develop path.',
76
+ whenNotToUse: 'TWO DISTINCT FAILURE MODES if this tool is not usable — do not conflate them: '
77
+ + '(a) this tool call is PRESENT in your tool list but ERRORS as app-not-running / admin '
78
+ + 'API unreachable — that is the ONLY case where hand-authoring the PRD file directly on '
79
+ + 'disk is an acceptable DEGRADED, LAST-RESORT fallback; the caller MUST say so explicitly '
80
+ + 'and visibly in its report (which file, why the tool was unreachable, that it needs '
81
+ + 'verification) since the server-side validation, atomic NN allocation, and '
82
+ + 'Epic-existence check this tool performs did not run for that file. '
83
+ + '(b) this tool is ABSENT from your tool list entirely — you were never offered it, so '
84
+ + 'there is no error to catch. That means the session-manager-scheduler MCP server is not '
85
+ + 'registered for this project: a MISCONFIGURATION, not an offline app. In that case DO '
86
+ + 'NOT hand-write any PRD file — stop and tell the human the MCP server is not registered '
87
+ + '(fix: `claude mcp add session-manager-scheduler --scope user -- node '
88
+ + '<session-manager-repo>/scripts/scheduler-mcp-server.cjs`, once at user scope covers '
89
+ + 'every project).',
90
+ exampleArgs: {
91
+ title: 'Add unit tests for the retry backoff helper',
92
+ cwd: '/home/bilko/Projects/session-manager',
93
+ estimateMinutes: 30,
94
+ goal: 'Cover retryWithBackoff.cjs edge cases (zero retries, max-delay clamp) that currently have no test.',
95
+ acceptanceCriteria: ['New test file exercises zero-retry and max-delay-clamp cases', 'timeout 300 npm run typecheck passes', 'timeout 600 npm run test:unit passes'],
96
+ implementationNotes: 'See src/main/lib/retryWithBackoff.cjs and its existing __tests__ sibling for the pattern to extend.',
97
+ sourcePromptId: 'epic-id-of-an-already-approved-session',
98
+ },
99
+ notes: 'See /develop.',
100
+ },
101
+ {
102
+ name: 'scheduler_list_prds',
103
+ group: 'scheduler',
104
+ purpose: "THE ONLY SUPPORTED WAY to list scheduled PRDs (live + archived) via the session-manager app's admin API. "
105
+ + 'Each entry includes its real job status (pending/running/completed/failed/needs_review, or null if not yet '
106
+ + 'queued/reconciled). Optionally filter by project cwd, Epic id, and/or status. Results are paginated (default '
107
+ + 'limit 100, max 500) sorted by slug ascending — check `hasMore`/`total` in the response before assuming you '
108
+ + "received every PRD; page further with `offset`. Default fields are compact (no parallelGroup/estimateMinutes/"
109
+ + 'sourcePromptId/epicId/archivedStatus) — pass fields:"full" to restore them.',
110
+ whenToUse: 'Use to survey PRDs by project/Epic/status before deciding which one to read, update, reset, or archive.',
111
+ whenNotToUse: 'Do not assume a page without `hasMore` is the full result set without checking `total` — page further with `offset` first.',
112
+ exampleArgs: { cwd: '/home/bilko/Projects/session-manager', status: 'needs_review' },
113
+ notes: null,
114
+ },
115
+ {
116
+ name: 'scheduler_get_prd',
117
+ group: 'scheduler',
118
+ purpose: "THE ONLY SUPPORTED WAY to read one PRD's full body + parsed frontmatter (live or archived) via the "
119
+ + "session-manager app's admin API.",
120
+ whenToUse: 'Use before editing/resetting a PRD, or to answer what a needs_review job actually asked.',
121
+ whenNotToUse: 'For a fleet-wide overview instead of one PRD, use scheduler_list_prds.',
122
+ exampleArgs: { slug: 'add-mcp-tool-catalog' },
123
+ notes: null,
124
+ },
125
+ {
126
+ name: 'scheduler_update_prd',
127
+ group: 'scheduler',
128
+ purpose: "THE ONLY SUPPORTED WAY to edit a NOT-yet-running PRD's frontmatter and/or body via the session-manager "
129
+ + 'app\'s admin API. Refuses once a queue row exists for the slug and its status is anything but "pending" '
130
+ + '(running/completed/failed/needs_review) — editing the spec under a live or already-finished executor is refused, '
131
+ + 'not silently applied. Only recognized frontmatter keys (title, cwd, estimateMinutes, parallelGroup, '
132
+ + 'sourcePromptId, sourceTabId, tag) may be patched; unrecognized keys (e.g. dependsOn) round-trip unchanged.',
133
+ whenToUse: 'Use to correct a PRD scope/estimate/tag before it starts running — e.g. before resetting a needs_review job whose spec needs to change.',
134
+ whenNotToUse: 'Do not use once the job is running or terminal (completed/failed/needs_review) without first resetting it back to pending — the route refuses the edit.',
135
+ exampleArgs: { slug: 'add-mcp-tool-catalog', frontmatter: { estimateMinutes: 45 } },
136
+ notes: null,
137
+ },
138
+ {
139
+ name: 'scheduler_archive_prd',
140
+ group: 'scheduler',
141
+ purpose: "THE ONLY SUPPORTED WAY to archive one or more PRDs (move to prds-archived/) via the session-manager "
142
+ + "app's admin API.",
143
+ whenToUse: 'Use once a batch of PRDs is done and should stop showing up in default (live) listings.',
144
+ whenNotToUse: 'Do not archive a PRD you still expect to reset/re-run — archived PRDs are not part of the live scheduling loop.',
145
+ exampleArgs: { slugs: ['add-mcp-tool-catalog'] },
146
+ notes: null,
147
+ },
148
+ {
149
+ name: 'scheduler_cancel_job',
150
+ group: 'scheduler',
151
+ purpose: "THE ONLY SUPPORTED WAY to cancel a not-yet-terminal scheduler job via the session-manager app's admin "
152
+ + 'API. A running job is SIGTERM\'d; a pending job is simply retired. There is no "cancelled" job status, so a '
153
+ + 'cancelled job lands as "failed" with an error naming the cause. Refuses a slug whose job is already terminal '
154
+ + '(completed/failed/needs_review) — nothing to cancel.',
155
+ whenToUse: 'Use to stop a running or pending job that should not continue (e.g. it was queued in error, or scope changed underneath it).',
156
+ whenNotToUse: 'Do not use on an already-terminal job (completed/failed/needs_review) — use scheduler_reset_job if it should run again.',
157
+ exampleArgs: { slug: 'add-mcp-tool-catalog' },
158
+ notes: null,
159
+ },
160
+ {
161
+ name: 'scheduler_retag_prd',
162
+ group: 'scheduler',
163
+ purpose: "THE ONLY SUPPORTED WAY to rewrite a PRD's parallelGroup and/or estimateMinutes frontmatter (and, if "
164
+ + "parallelGroup changes, its NN- filename prefix) via the session-manager app's admin API.",
165
+ whenToUse: 'Use to correct an estimate or renumber a PRD file after it was created.',
166
+ whenNotToUse: 'Do not use parallelGroup as an ordering/dependency barrier — it is only a unique-per-PRD display hint; use dependsOn on scheduler_create_prd/scheduler_update_prd for real ordering.',
167
+ exampleArgs: { items: [{ slug: 'add-mcp-tool-catalog', estimateMinutes: 30 }] },
168
+ notes: null,
169
+ },
170
+ {
171
+ name: 'chat_send_prompt',
172
+ group: 'chat',
173
+ purpose: "Push a prompt into an already-open tab's chat queue via the session-manager app's admin "
174
+ + 'API. The renderer resolves the tab (must currently be open) and runs the prompt through '
175
+ + 'the same queued-vs-immediate path as a manual send.',
176
+ whenToUse: 'Use to programmatically continue a conversation in a tab that is already open in the running app.',
177
+ whenNotToUse: 'Do not use to start new work in a project you are not already chatting in — that is /develop (scheduler_create_prd) or feedback_open_session.',
178
+ exampleArgs: { tabId: 'a1b2c3d4-tab-id', prompt: 'Please continue with the next step.' },
179
+ notes: 'No-ops if the tab is unknown/closed.',
180
+ },
181
+ {
182
+ name: 'feedback_list_projects',
183
+ group: 'feedback',
184
+ purpose: 'List the OTHER projects on this machine that can receive feedback (i.e. that Session Manager '
185
+ + 'already manages — they have a session-manager-operations/ directory).',
186
+ whenToUse: "Call this FIRST when you need the exact `toCwd` for feedback_open_session and are not certain of it — never guess a path.",
187
+ whenNotToUse: 'A project missing from this list has simply never been opened in Session Manager; ask the human to open it once rather than inventing a path.',
188
+ exampleArgs: {},
189
+ notes: null,
190
+ },
191
+ {
192
+ name: 'feedback_open_session',
193
+ group: 'feedback',
194
+ purpose: 'THE ONLY SANCTIONED WAY to hand a finding from THIS project to a DIFFERENT project. Opens a new '
195
+ + "PROPOSED session in the receiving project's own Sessions queue, carrying your report as its "
196
+ + 'opening prompt and stamped with where it came from. Session Manager performs the cross-folder '
197
+ + 'write; you never write another project\'s files yourself.',
198
+ whenToUse: 'Use when work in this project surfaces something that is genuinely another project\'s to fix. '
199
+ + 'Write the report for a reader who has never seen your project: state the symptom, where you '
200
+ + 'observed it, what you expected, and (if you know) the file in THEIR repo that looks responsible. '
201
+ + 'Never assume they can see your code.',
202
+ whenNotToUse: 'WHEN NOT TO USE IT: for work in the project you are ALREADY in, run /develop inside the Epic you '
203
+ + 'are already in — this tool refuses toCwd === fromCwd outright.',
204
+ exampleArgs: {
205
+ toCwd: '/home/bilko/Projects/other-project',
206
+ fromCwd: '/home/bilko/Projects/session-manager',
207
+ title: 'Cross-project contract mismatch in the shared admin API',
208
+ body: 'Symptom: X. Observed while doing Y in session-manager. Expected: Z. Suspected file: <path in their repo>.',
209
+ },
210
+ notes: 'WHAT THIS DOES NOT DO: it does not start anything, queue a PRD, or spend a token. The session '
211
+ + 'lands as `proposed` and runs only if a human in the RECEIVING project presses "Approve & start". '
212
+ + 'There is no callback and no reply channel — do not wait for an answer, and do not tell the user '
213
+ + 'the other project has "been fixed" or "is working on it". Report only that the proposal was '
214
+ + 'delivered.',
215
+ },
216
+ {
217
+ name: 'session_manager_help',
218
+ group: 'help',
219
+ purpose: 'THE ENTRY POINT — call this tool FIRST whenever you are unsure which session-manager-scheduler '
220
+ + 'tool to reach for, or a call to this server just failed. Returns the same tool catalog every other '
221
+ + "tool's description is composed from, this project's multi-tool recipes, and this machine's "
222
+ + 'delegation-readiness state (tells apart "not registered", "registered but dead" — the process '
223
+ + 'won\'t even answer tools/list — and "registered and answering").',
224
+ whenToUse: 'Call with no arguments for the full grouped tool list plus recipe titles. Pass `tool` (a tool '
225
+ + 'name) for that one tool\'s full entry, including its exampleArgs. Pass `topic` (a recipe id) for that '
226
+ + 'recipe\'s step-by-step instructions. Pass both to get both sections in one call.',
227
+ whenNotToUse: 'Do not use this to actually perform scheduler/chat/feedback work — it is read-only '
228
+ + "documentation and never a substitute for calling the tool it describes.",
229
+ exampleArgs: {},
230
+ notes: 'Always returns the static catalog even when the session-manager app is not running — only the '
231
+ + '`readiness` section of the response depends on the admin API being reachable.',
232
+ },
233
+ ];
234
+
235
+ const MCP_RECIPES = [
236
+ {
237
+ id: 'queue-work-via-develop',
238
+ title: 'Queue work into an already-approved Epic (/develop)',
239
+ steps: [
240
+ 'Confirm an Epic already exists for this work (proposed or active) — scheduler_create_prd never mints one.',
241
+ 'Call scheduler_create_prd with sourcePromptId set to that Epic id, plus title/cwd/estimateMinutes/goal/acceptanceCriteria/implementationNotes.',
242
+ 'The response has enqueued:false — the queue row is derived by the scheduler\'s next reconcile pass (~1 minute), not by this call.',
243
+ 'Optionally call scheduler_list_prds with cwd/status to confirm the PRD picked up a job row.',
244
+ ],
245
+ },
246
+ {
247
+ id: 'unstick-needs-review-job',
248
+ title: 'Unstick a job stuck in needs_review',
249
+ steps: [
250
+ 'Call scheduler_list_prds with status:"needs_review" (or scheduler_list_jobs) to find the stuck slug.',
251
+ 'Call scheduler_get_prd with that slug to read its full frontmatter + body and understand the question it raised.',
252
+ 'If the PRD spec needs to change, call scheduler_update_prd with the slug and a frontmatter/body patch — this is only accepted while the job is not yet running or terminal.',
253
+ 'Call scheduler_reset_job with { slug } to clear the needs_review status back to pending — force is only required if the job had already reached "completed".',
254
+ 'The next scheduler reconcile pass re-queues the job; confirm with scheduler_list_jobs or scheduler_list_prds.',
255
+ ],
256
+ },
257
+ {
258
+ id: 'hand-finding-to-another-project',
259
+ title: 'Hand a finding to another project',
260
+ steps: [
261
+ 'Call feedback_list_projects to get the exact toCwd for the receiving project — never guess a path.',
262
+ 'Call feedback_open_session with toCwd, fromCwd, a one-line title, and a self-contained body (symptom, where observed, expected behavior, suspected cause).',
263
+ 'The call only delivers a PROPOSED session in the receiving project — nothing runs until a human there presses Approve & start; there is no reply channel.',
264
+ ],
265
+ },
266
+ ];
267
+
268
+ for (const entry of MCP_TOOL_CATALOG) {
269
+ CatalogEntrySchema.parse(entry);
270
+ }
271
+ for (const recipe of MCP_RECIPES) {
272
+ RecipeSchema.parse(recipe);
273
+ }
274
+
275
+ function composeDescription(entry) {
276
+ return [entry.purpose, entry.whenToUse, entry.whenNotToUse, entry.notes].filter(Boolean).join(' ');
277
+ }
278
+
279
+ module.exports = {
280
+ MCP_TOOL_CATALOG,
281
+ MCP_RECIPES,
282
+ CatalogEntrySchema,
283
+ RecipeSchema,
284
+ composeDescription,
285
+ };
@@ -14,6 +14,7 @@
14
14
  const fs = require('node:fs');
15
15
  const path = require('node:path');
16
16
  const { assertOpsWrite } = require('./opsOwnership.cjs');
17
+ const { isEphemeralCwd } = require('./ephemeralCwd.cjs');
17
18
 
18
19
  // Same redaction policy as logs.cjs's sanitizeMeta — kept independent (not
19
20
  // shared/required) so this module has no dependency on the Electron `app`
@@ -31,7 +32,8 @@ function sanitizeMeta(meta) {
31
32
  }
32
33
 
33
34
  function logsDir(cwd) {
34
- return path.join(cwd, 'session-manager-operations', 'logs');
35
+ const { opsPath } = require('./opsOwnership.cjs');
36
+ return opsPath(cwd, 'logs');
35
37
  }
36
38
 
37
39
  function todayFile(cwd) {
@@ -58,6 +60,15 @@ function todayFile(cwd) {
58
60
  */
59
61
  function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta }) {
60
62
  if (!cwd || typeof cwd !== 'string') return; // no project to attribute this line to — skip
63
+ if (isEphemeralCwd(cwd)) {
64
+ // A worktree is torn down when its Epic/job ends and os.tmpdir() is
65
+ // scratch space either way — never materialize logs there. See
66
+ // ephemeralCwd.cjs / queueStore.cjs's projectStateDir for the sibling
67
+ // refusal on the scheduler namespace (verified live 2026-09-01 as a
68
+ // recreated /tmp/session-manager-operations/logs/ tree).
69
+ console.warn(`[opsErrorLog] appendError: refusing ephemeral cwd "${cwd}" (scope=${scope || 'unknown'})`);
70
+ return;
71
+ }
61
72
  const file = todayFile(cwd);
62
73
  try {
63
74
  assertOpsWrite(file, 'logs');