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.
- package/dist/assets/{AgentLibrary-s1jRGOji.js → AgentLibrary-CBx9l4zN.js} +1 -1
- package/dist/assets/{DataModel-DNefcWbk.js → DataModel-Bf0EIE_t.js} +1 -1
- package/dist/assets/{History-4sMk80yI.js → History-CpdtWhC8.js} +1 -1
- package/dist/assets/{Hooks-CNUVwH5P.js → Hooks-DyUbMDmg.js} +1 -1
- package/dist/assets/{HostBilko-BhkyvIhw.js → HostBilko-By-wIpry.js} +1 -1
- package/dist/assets/{Library-BpockanA.js → Library-CQmo4QVC.js} +1 -1
- package/dist/assets/{ListDetail-Cre7h3U0.js → ListDetail-BQMd6NOm.js} +1 -1
- package/dist/assets/{MarkdownEditor-x-FzvaXm.js → MarkdownEditor-DEp43FXX.js} +1 -1
- package/dist/assets/{McpServers-CXAcQLMB.js → McpServers-CLarzwqA.js} +1 -1
- package/dist/assets/{Memory-CGBv1Rco.js → Memory-B0sCdIy1.js} +1 -1
- package/dist/assets/{Panel-czEs21N2.js → Panel-BhWPVOCD.js} +1 -1
- package/dist/assets/{Permissions-rGkLOpqI.js → Permissions-Ddlq8T_O.js} +1 -1
- package/dist/assets/{Plugins-BTmB_vgU.js → Plugins-D2oA_2Jl.js} +2 -2
- package/dist/assets/{ProvenanceBadge-dmCivDq5.js → ProvenanceBadge-DgAgavUM.js} +1 -1
- package/dist/assets/{SaveBar-D80TNkAx.js → SaveBar-Qvc4Ek-H.js} +1 -1
- package/dist/assets/{Scheduler-C6wHSZP7.js → Scheduler-BmYJvNzK.js} +1 -1
- package/dist/assets/{ScopeSwitcher-DsaiYTgl.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
- package/dist/assets/{Settings-m2fyU8Q5.js → Settings-2Vx3X5SI.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-yS-lgBFU.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
- package/dist/assets/{Skills-DtQgCs9-.js → Skills-Cmrz_LeN.js} +1 -1
- package/dist/assets/{SystemPrompt-CLuVdoMA.js → SystemPrompt-DVA1eYDP.js} +1 -1
- package/dist/assets/{TagLibrary-DQXoI752.js → TagLibrary-DYJGAKZu.js} +1 -1
- package/dist/assets/{TiptapBody-BGKVXrvq.js → TiptapBody-DmPc3amD.js} +1 -1
- package/dist/assets/{Toggle-RFnEV71m.js → Toggle-zfd5LJkK.js} +1 -1
- package/dist/assets/{index-Ddrw7J7O.js → index-B_4PNh9T.js} +676 -676
- package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
- package/dist/assets/{settingsSchema-LwuJXKD1.js → settingsSchema-B9es6fdA.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +146 -4
- package/scripts/scheduler-mcp-server.cjs +154 -95
- package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +105 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
- package/src/main/__tests__/prdCreate.test.cjs +109 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +15 -3
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +60 -1
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
- package/src/main/bilkoHost.cjs +4 -3
- package/src/main/chatRunner.cjs +6 -1
- package/src/main/config.cjs +22 -33
- package/src/main/health.cjs +153 -2
- package/src/main/index.cjs +56 -4
- package/src/main/ipcSchemas.cjs +18 -1
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +63 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +241 -42
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +1 -1
- package/src/main/lib/__tests__/gitWorktree.test.cjs +14 -2
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +2 -2
- package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +101 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +202 -0
- package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +51 -0
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -0
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
- package/src/main/lib/activeIndexMerge.cjs +15 -0
- package/src/main/lib/activeIndexRebuild.cjs +133 -0
- package/src/main/lib/buildTarget.cjs +3 -2
- package/src/main/lib/childWithLog.cjs +33 -1
- package/src/main/lib/crossProjectFeedback.cjs +8 -1
- package/src/main/lib/delegationReadiness.cjs +408 -26
- package/src/main/lib/ephemeralCwd.cjs +78 -0
- package/src/main/lib/epicDelegationStats.cjs +2 -1
- package/src/main/lib/epicMint.cjs +17 -1
- package/src/main/lib/epicStatusMirror.cjs +95 -0
- package/src/main/lib/epicValidationHook.cjs +2 -1
- package/src/main/lib/gitWorktree.cjs +56 -2
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/loadGate.cjs +134 -0
- package/src/main/lib/mcpToolCatalog.cjs +285 -0
- package/src/main/lib/opsErrorLog.cjs +12 -1
- package/src/main/lib/opsOwnership.cjs +94 -0
- package/src/main/lib/prdAdminRoutes.cjs +43 -3
- package/src/main/lib/prdCreate.cjs +46 -14
- package/src/main/lib/prdLocations.cjs +13 -6
- package/src/main/lib/projectRootResolve.cjs +134 -0
- package/src/main/lib/promptSessionSchema.cjs +7 -0
- package/src/main/lib/queueStore.cjs +70 -8
- package/src/main/lib/rcaReport.cjs +1 -1
- package/src/main/lib/reaperHelpers.cjs +47 -1
- package/src/main/lib/schedulerBatch.cjs +171 -29
- package/src/main/lib/schedulerConfig.cjs +80 -0
- package/src/main/projectBrief.cjs +3 -2
- package/src/main/projectPages.cjs +2 -1
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +5 -0
- package/src/main/queueOps.cjs +15 -8
- package/src/main/scheduler.cjs +346 -49
- package/src/main/seedSchedulerMcp.cjs +58 -4
- package/src/preload/api.d.ts +69 -1
- 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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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');
|