claude-code-session-manager 0.43.0 → 0.45.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.
@@ -51,6 +51,17 @@ function writeActiveIndex(cwd, index) {
51
51
  fs.renameSync(tmp, file);
52
52
  }
53
53
 
54
+ // index.sessions/index.events are plain objects parsed from JSON — bracket
55
+ // lookup with an attacker/agent-supplied key like "__proto__" or
56
+ // "constructor" resolves through the prototype chain to a truthy
57
+ // Object.prototype member even though no such Epic was ever written,
58
+ // bypassing every "does this Epic exist" gate below (including the
59
+ // mintIfMissing:false join-only check that PRD-authoring paths rely on).
60
+ // Always use this instead of `obj[key]`/`!obj[key]` for existence checks.
61
+ function hasOwn(obj, key) {
62
+ return Object.prototype.hasOwnProperty.call(obj, key);
63
+ }
64
+
54
65
  function slugify(text) {
55
66
  return String(text || 'epic')
56
67
  .toLowerCase()
@@ -59,6 +70,85 @@ function slugify(text) {
59
70
  .slice(0, 48) || 'epic';
60
71
  }
61
72
 
73
+ const STOPWORDS = new Set([
74
+ 'the', 'a', 'an', 'is', 'of', 'to', 'for', 'and', 'in', 'on', 'at', 'this', 'that',
75
+ ]);
76
+
77
+ // Lowercase, strip punctuation, split on whitespace, drop stopwords — shared
78
+ // by findJoinableEpic's similarity check. Kept standalone so its behavior is
79
+ // independently testable rather than inlined into the Jaccard computation.
80
+ function tokenize(text) {
81
+ return String(text || '')
82
+ .toLowerCase()
83
+ .replace(/[^a-z0-9\s]+/g, ' ')
84
+ .split(/\s+/)
85
+ .filter((token) => token && !STOPWORDS.has(token));
86
+ }
87
+
88
+ function jaccardSimilarity(tokensA, tokensB) {
89
+ const setA = new Set(tokensA);
90
+ const setB = new Set(tokensB);
91
+ if (setA.size === 0 && setB.size === 0) return 0;
92
+ let intersection = 0;
93
+ for (const token of setA) {
94
+ if (setB.has(token)) intersection += 1;
95
+ }
96
+ const union = setA.size + setB.size - intersection;
97
+ return union === 0 ? 0 : intersection / union;
98
+ }
99
+
100
+ const JOIN_SIMILARITY_THRESHOLD = 0.35;
101
+
102
+ /**
103
+ * findJoinableEpicInIndex(index, { goalText, preferEpicId, status }) → { epicId, matchedBy, score? } | null
104
+ *
105
+ * Same contract as findJoinableEpic() but takes an already-loaded index —
106
+ * lets ensureEpic() consult this without a second readActiveIndex() call
107
+ * inside its own withPathLock critical section. Two strategies, in order:
108
+ * 1. `preferEpicId` — an explicitly-known origin Epic the caller already has
109
+ * in hand. Joined immediately (no similarity check) as long as it exists
110
+ * and is still open ('proposed' or 'active') — a 'completed' or unknown
111
+ * preferEpicId falls through to strategy 2 rather than joining a dead Epic.
112
+ * 2. Keyword-similarity — Jaccard token-set overlap between `goalText` and
113
+ * every other OPEN Epic's goalText in the same cwd (open = 'proposed' or
114
+ * 'active', regardless of the specific requested `status` — a proposed
115
+ * RCA report and an active one about the same topic are still the same
116
+ * underlying issue). Highest score wins if it clears
117
+ * JOIN_SIMILARITY_THRESHOLD; otherwise no join. `status` is accepted for
118
+ * signature symmetry with ensureEpic()/reuseByGoal but only participates
119
+ * in strategy 1's preferEpicId open-check, not the similarity filter.
120
+ */
121
+ function findJoinableEpicInIndex(index, { goalText, preferEpicId = null, status: _status = 'proposed' } = {}) {
122
+ if (preferEpicId && hasOwn(index.sessions, preferEpicId)) {
123
+ const preferred = index.sessions[preferEpicId];
124
+ if (preferred && (preferred.status === 'proposed' || preferred.status === 'active')) {
125
+ return { epicId: preferEpicId, matchedBy: 'preferEpicId' };
126
+ }
127
+ }
128
+
129
+ const candidateTokens = tokenize(goalText);
130
+ let best = null;
131
+ for (const s of Object.values(index.sessions)) {
132
+ if (!s || (s.status !== 'proposed' && s.status !== 'active')) continue;
133
+ const score = jaccardSimilarity(candidateTokens, tokenize(s.goalText));
134
+ if (score >= JOIN_SIMILARITY_THRESHOLD && (!best || score > best.score)) {
135
+ best = { epicId: s.id, matchedBy: 'similarity', score };
136
+ }
137
+ }
138
+ return best;
139
+ }
140
+
141
+ /**
142
+ * findJoinableEpic(cwd, { goalText, preferEpicId, status }) → { epicId, matchedBy, score? } | null
143
+ *
144
+ * Public entry point for callers outside ensureEpic()'s own critical section
145
+ * (tests, future PRD 899/900 wiring) — loads the index itself. See
146
+ * findJoinableEpicInIndex() for the matching logic.
147
+ */
148
+ function findJoinableEpic(cwd, opts = {}) {
149
+ return findJoinableEpicInIndex(readActiveIndex(cwd), opts);
150
+ }
151
+
62
152
  // Serializes read-modify-write cycles per active-index.json path, mirroring
63
153
  // promptSessionEvents.cjs's own pendingWritesByPath/withPathLock. The current
64
154
  // read/write pair below is synchronous (fs.readFileSync/writeFileSync), so
@@ -83,26 +173,43 @@ function withPathLock(lockPath, task) {
83
173
  }
84
174
 
85
175
  /**
86
- * ensureEpic(cwd, { goalText, tag?, reuseByGoal?, status? }) → Promise<{ epicId, prdDir, created }>
176
+ * ensureEpic(cwd, { goalText, tag?, reuseByGoal?, status?, openingPrompt?, mintIfMissing?, source?, forceNewEpic? }) → Promise<{ epicId, prdDir, created }>
177
+ *
178
+ * Before minting brand-new, the mint branch consults findJoinableEpic() —
179
+ * minting is the LAST resort, not the default, for automated callers. Pass
180
+ * `forceNewEpic: true` to skip that check and always mint (the one
181
+ * legitimate case being explicit human-authored creation).
87
182
  *
88
- * `status` defaults to 'active'. Pass 'proposed' to file an Epic that waits
89
- * for human approval before anything runs.
183
+ * `status` defaults to 'proposed' — a fail-safe default so any caller that
184
+ * forgets to pass it files an Epic that waits for human approval rather than
185
+ * one that starts running immediately. Pass 'active' explicitly only for the
186
+ * one legitimate immediate-start path (the New Epic UI's own proposed→active
187
+ * transition, `promptSessions.ts`'s `approveProposed`).
90
188
  *
91
- * Mints a new Epic — or, with `reuseByGoal`, joins the existing ACTIVE Epic
92
- * whose goalText matches (used by the recurring feedback sweep so successive
93
- * sweeps chain into one Epic instead of minting one per tick).
189
+ * `mintIfMissing` defaults to true for the small set of callers that are
190
+ * themselves the human-intent gate (propose-epic, the RCA hook, the feedback
191
+ * sweep — all of which pass `status: 'proposed'`, so "minting" here still
192
+ * never starts anything without a human's Approve & start). Callers that are
193
+ * NOT themselves a human-intent gate (an automated PRD-write path acting on
194
+ * a session's behalf) must pass `mintIfMissing: false` — that path may only
195
+ * JOIN an Epic that already exists; it throws instead of silently creating a
196
+ * new one, so no PRD-authoring surface can conjure Epics on its own.
197
+ *
198
+ * Mints a new Epic — or, with `reuseByGoal`, joins the existing Epic (of the
199
+ * same `status`) whose goalText matches (used by the recurring feedback sweep
200
+ * so successive sweeps chain into one Epic instead of minting one per tick).
94
201
  *
95
202
  * The Epic's id doubles as its directory name under scheduler/epics/, so the
96
203
  * PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
97
204
  */
98
- function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId, status = 'active', openingPrompt = null } = {}) {
205
+ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, mintIfMissing = true, source = null, forceNewEpic = false } = {}) {
99
206
  if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
100
207
  return withPathLock(activeIndexPath(cwd), () => {
101
208
  const index = readActiveIndex(cwd);
102
209
 
103
210
  // A dispatch that already knows its Epic (sourcePromptId frontmatter from
104
211
  // an Epic-conversation dispatch) joins it rather than minting a sibling.
105
- if (explicitEpicId && index.sessions[explicitEpicId]) {
212
+ if (explicitEpicId && hasOwn(index.sessions, explicitEpicId)) {
106
213
  const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
107
214
  fs.mkdirSync(prdDir, { recursive: true });
108
215
  return { epicId: explicitEpicId, prdDir, created: false };
@@ -133,6 +240,23 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
133
240
  }
134
241
  }
135
242
 
243
+ if (!mintIfMissing) {
244
+ throw new Error(
245
+ `ensureEpic: no existing Epic found (epicId=${explicitEpicId ?? 'none'}) and mintIfMissing is false — `
246
+ + 'a new Epic can only be created by explicit human intent (New Epic UI, or /propose-epic + Approve & start), '
247
+ + 'never implicitly by a PRD-authoring path',
248
+ );
249
+ }
250
+
251
+ if (!forceNewEpic) {
252
+ const joinable = findJoinableEpicInIndex(index, { goalText, preferEpicId: explicitEpicId, status });
253
+ if (joinable) {
254
+ const prdDir = resolveEpicPrdWriteDir(cwd, joinable.epicId);
255
+ fs.mkdirSync(prdDir, { recursive: true });
256
+ return { epicId: joinable.epicId, prdDir, created: false };
257
+ }
258
+ }
259
+
136
260
  const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
137
261
  const now = new Date().toISOString();
138
262
  const session = {
@@ -152,6 +276,9 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
152
276
  // Full body for a proposal whose goalText is only a one-line title;
153
277
  // sent verbatim as the first prompt when a human approves it.
154
278
  ...(openingPrompt ? { openingPrompt: String(openingPrompt) } : {}),
279
+ // Structured trace of which automated producer minted this Epic — see
280
+ // EpicSource in state/promptSessions.ts.
281
+ ...(source ? { source } : {}),
155
282
  };
156
283
  const firstEvent = {
157
284
  id: crypto.randomUUID(),
@@ -168,7 +295,7 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
168
295
  // Every mint is logged, whether the Epic starts 'proposed' (human gate
169
296
  // ahead) or 'active' (started immediately) — this is the trace-back point
170
297
  // for "who/what created this Epic" (see auditLog.cjs).
171
- appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText });
298
+ appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText, source: source ?? null });
172
299
 
173
300
  const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
174
301
  fs.mkdirSync(prdDir, { recursive: true });
@@ -185,7 +312,7 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
185
312
  function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
186
313
  return withPathLock(activeIndexPath(cwd), () => {
187
314
  const index = readActiveIndex(cwd);
188
- if (!index.sessions[epicId]) return false;
315
+ if (!hasOwn(index.sessions, epicId)) return false;
189
316
  const chain = Array.isArray(index.events[epicId]) ? index.events[epicId] : [];
190
317
  const tail = chain.length ? chain[chain.length - 1] : null;
191
318
  chain.push({
@@ -214,11 +341,11 @@ function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
214
341
  function removeEpic(cwd, epicId) {
215
342
  if (!cwd || !epicId) return false;
216
343
  const index = readActiveIndex(cwd);
217
- if (!index.sessions[epicId]) return false;
344
+ if (!hasOwn(index.sessions, epicId)) return false;
218
345
  delete index.sessions[epicId];
219
346
  delete index.events[epicId];
220
347
  writeActiveIndex(cwd, index);
221
348
  return true;
222
349
  }
223
350
 
224
- module.exports = { ensureEpic, appendPrdCreatedEvent, removeEpic, activeIndexPath, readActiveIndex };
351
+ module.exports = { ensureEpic, appendPrdCreatedEvent, removeEpic, activeIndexPath, readActiveIndex, findJoinableEpic, tokenize };
@@ -28,7 +28,7 @@ const fs = require('node:fs');
28
28
  const os = require('node:os');
29
29
  const path = require('node:path');
30
30
  const config = require('../config.cjs');
31
- const { ensureEpic } = require('./epicMint.cjs');
31
+ const { ensureEpic, findJoinableEpic } = require('./epicMint.cjs');
32
32
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
33
33
  const { readTail } = require('./fileTail.cjs');
34
34
  const { resolvePrdWriteDir } = require('./prdLocations.cjs');
@@ -294,7 +294,7 @@ function buildRcaMarkdown({ job, verdict, meta, logTail, acText, failureClass, i
294
294
  * duplicate. A different runId (new run of the same slug) files a new RCA.
295
295
  *
296
296
  * @param {object} opts
297
- * @param {{slug: string, cwd?: string, runId: string, exitCode?: number, error?: string}} opts.job
297
+ * @param {{slug: string, cwd?: string, runId: string, exitCode?: number, error?: string, epicId?: string}} opts.job
298
298
  * @param {string} [opts.runDir] Run directory containing <slug>.log / <slug>.meta.json.
299
299
  * @param {string} opts.verdict Verifier verdict string (e.g. 'uncommitted_changes').
300
300
  * @param {Array} [opts.annotations] Non-blocking verifier annotations (currently unused
@@ -351,12 +351,33 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
351
351
  // the old live/processed existence checks were for.
352
352
  config.addAllowedRoot(dest.allowlistRoot);
353
353
  const title = rcaProposalTitle(job, verdict);
354
+
355
+ // job.epicId (present on jobs authored after Epic-gating landed; absent
356
+ // on legacy PRDs) is the known origin Epic this failing job belonged to.
357
+ // Passing it straight through as ensureEpic's `epicId` would hit its
358
+ // explicitEpicId join-only branch, which joins unconditionally — even a
359
+ // 'completed' Epic. findJoinableEpic's preferEpicId strategy is the one
360
+ // that only joins when the Epic is still open ('proposed'/'active'), so
361
+ // resolve it here and only forward it when that strategy actually fires
362
+ // — otherwise fall through to the existing reuseByGoal/mint behavior.
363
+ // Once forwarded, ensureEpic joins it unconditionally by id (no second
364
+ // status check) — the status gate lives entirely in this resolve step.
365
+ let preferredEpicId;
366
+ if (typeof job.epicId === 'string' && job.epicId) {
367
+ const joinable = findJoinableEpic(dest.cwd, { goalText: title, preferEpicId: job.epicId, status: 'proposed' });
368
+ if (joinable && joinable.matchedBy === 'preferEpicId') {
369
+ preferredEpicId = joinable.epicId;
370
+ }
371
+ }
372
+
354
373
  const { epicId, created } = await ensureEpic(dest.cwd, {
355
374
  goalText: title,
356
375
  tag: 'bug',
357
376
  status: 'proposed',
358
377
  reuseByGoal: true,
359
378
  openingPrompt: markdown,
379
+ epicId: preferredEpicId,
380
+ source: { producer: 'rca-hook', prdSlug: job.slug, runId: job.runId },
360
381
  });
361
382
 
362
383
  console.log(`[rca] ${created ? 'proposed' : 'joined'} epic ${epicId} for ${job.slug}`);
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * sessionSlots.cjs — the Session-Manager-owned machine-wide `claude -p`
3
- * concurrency pool (2026-07-31 domain-model decision).
3
+ * concurrency pool (2026-07-31 domain-model decision; made user-configurable
4
+ * 2026-08-01).
4
5
  *
5
6
  * Caps and limits belong to Session-Manager, not to any one consumer: the
6
7
  * scheduler and chatRunner previously each enforced a private cap (3 and 2),
@@ -8,22 +9,68 @@
8
9
  * the 2026-06-10 five-parallel-`claude -p` OOM. Now every subsystem that
9
10
  * wants to launch a `claude -p` process REQUESTS a slot here first and
10
11
  * releases it when the process settles. There is one pool, sized to the
11
- * machine (default 3 — CLAUDE.md "Avoid" cap; SM_SESSION_SLOTS overrides,
12
- * clamped to [1, 3]).
12
+ * machine (default 5, user-adjustable [0, 10] from the Home tab; 0 pauses new
13
+ * launches without touching already-running processes — SM_SESSION_SLOTS
14
+ * still overrides everything for scripted/CI use, clamped to the same
15
+ * [0, 10] range).
13
16
  *
14
17
  * Consumers keep their own scheduling policy (FIFO lanes, batch picking,
15
18
  * memory gates); this module only answers "may one more process start right
16
- * now?". Plain Node, no Electron deps, process-local state — all consumers
17
- * live in the one Electron main process, which is exactly why it can be the
18
- * arbiter.
19
+ * now?". Plain Node, no Electron deps, process-local state (the persisted cap
20
+ * is the one exception — a tiny standalone JSON file, not routed through any
21
+ * other module's config store) — all consumers live in the one Electron main
22
+ * process, which is exactly why it can be the arbiter.
19
23
  */
20
24
  'use strict';
21
25
 
22
26
  const crypto = require('node:crypto');
27
+ const fs = require('node:fs');
28
+ const os = require('node:os');
29
+ const path = require('node:path');
30
+
31
+ const MIN_SLOTS = 0;
32
+ const MAX_SLOTS = 10;
33
+ const DEFAULT_SLOTS = 5;
34
+
35
+ const CONFIG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'session-slots-config.json');
36
+
37
+ function clamp(n) {
38
+ return Math.min(MAX_SLOTS, Math.max(MIN_SLOTS, n));
39
+ }
40
+
41
+ function readPersistedCap() {
42
+ try {
43
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
44
+ const parsed = JSON.parse(raw);
45
+ const cap = Number(parsed.cap);
46
+ return Number.isFinite(cap) ? clamp(Math.trunc(cap)) : DEFAULT_SLOTS;
47
+ } catch {
48
+ return DEFAULT_SLOTS;
49
+ }
50
+ }
51
+
52
+ /** Persist a new cap to disk (tmp + rename). Throws on an out-of-range value. */
53
+ function setCap(cap) {
54
+ const n = Number(cap);
55
+ if (!Number.isFinite(n) || Math.trunc(n) !== n || n < MIN_SLOTS || n > MAX_SLOTS) {
56
+ throw new Error(`sessionSlots.setCap: cap must be an integer in [${MIN_SLOTS}, ${MAX_SLOTS}]`);
57
+ }
58
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
59
+ const tmp = `${CONFIG_PATH}.${process.pid}.${Date.now()}.tmp`;
60
+ fs.writeFileSync(tmp, JSON.stringify({ cap: n }, null, 2));
61
+ fs.renameSync(tmp, CONFIG_PATH);
62
+ for (const fn of listeners) {
63
+ try { fn(); } catch { /* a consumer's pump error is its own problem */ }
64
+ }
65
+ return n;
66
+ }
23
67
 
24
68
  function totalSlots() {
25
- const parsed = parseInt(process.env.SM_SESSION_SLOTS || '3', 10);
26
- return Math.min(3, Math.max(1, Number.isFinite(parsed) ? parsed : 3));
69
+ if (process.env.SM_SESSION_SLOTS !== undefined) {
70
+ const parsed = parseInt(process.env.SM_SESSION_SLOTS, 10);
71
+ return Number.isFinite(parsed) ? clamp(parsed) : DEFAULT_SLOTS;
72
+ }
73
+ return readPersistedCap();
27
74
  }
28
75
 
29
76
  // token → { owner, at }
@@ -51,6 +98,7 @@ function acquire(owner) {
51
98
 
52
99
  // Release listeners: each consumer registers its own "a slot freed — try to
53
100
  // start work" pump so a scheduler release wakes the chat lane and vice versa.
101
+ // Also fired when the cap itself increases (setCap), for the same reason.
54
102
  const listeners = new Set();
55
103
  function subscribe(fn) {
56
104
  listeners.add(fn);
@@ -74,6 +122,10 @@ function snapshot() {
74
122
  total: totalSlots(),
75
123
  inUse: holders.size,
76
124
  holders: [...holders.values()],
125
+ min: MIN_SLOTS,
126
+ max: MAX_SLOTS,
127
+ default: DEFAULT_SLOTS,
128
+ envOverride: process.env.SM_SESSION_SLOTS !== undefined,
77
129
  };
78
130
  }
79
131
 
@@ -82,4 +134,17 @@ function __resetForTests() {
82
134
  holders.clear();
83
135
  }
84
136
 
85
- module.exports = { totalSlots, inUse, available, acquire, release, subscribe, snapshot, __resetForTests };
137
+ module.exports = {
138
+ MIN_SLOTS,
139
+ MAX_SLOTS,
140
+ DEFAULT_SLOTS,
141
+ totalSlots,
142
+ setCap,
143
+ inUse,
144
+ available,
145
+ acquire,
146
+ release,
147
+ subscribe,
148
+ snapshot,
149
+ __resetForTests,
150
+ };
@@ -86,7 +86,7 @@ const queueOps = require('./queueOps.cjs');
86
86
  // match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
87
87
  // home-dir layout.
88
88
  const { resolvePrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
89
- const { ensureEpic, appendPrdCreatedEvent, removeEpic, readActiveIndex } = require('./lib/epicMint.cjs');
89
+ const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
90
90
 
91
91
  // ---------- origin session resolution (PRD 832) ----------
92
92
  // An Epic IS a tagged claude session — job rows carry the originating
@@ -3710,6 +3710,15 @@ function registerScheduleHandlers() {
3710
3710
  // configuration tab.
3711
3711
  ipcMain.handle('schedule:session-slots', () => sessionSlots.snapshot());
3712
3712
 
3713
+ // Home-tab control for the same pool: user-set cap in [0, 10], default 5.
3714
+ // 0 pauses new claude -p launches machine-wide without killing anything
3715
+ // already running. SM_SESSION_SLOTS (if set) still overrides this at read
3716
+ // time — sessionSlots.snapshot().envOverride tells the UI to disable itself.
3717
+ ipcMain.handle('schedule:set-session-slots', validated(schemas.setSessionSlotsSchema, async (data) => {
3718
+ sessionSlots.setCap(data.cap);
3719
+ return sessionSlots.snapshot();
3720
+ }));
3721
+
3713
3722
  ipcMain.handle('schedule:health', async () => {
3714
3723
  const state = await readQueue();
3715
3724
  const runningJobs = [];
@@ -4178,13 +4187,6 @@ async function init() {
4178
4187
 
4179
4188
  // remote — callable from webRemote.cjs without going through IPC.
4180
4189
  const remote = {
4181
- async getState() {
4182
- const state = await readQueue();
4183
- await reconcile(state);
4184
- await writeQueue(state);
4185
- return buildScheduleStatePayload(state, { withPaths: true });
4186
- },
4187
-
4188
4190
  // `cwd` is optional: prdCreate.cjs's create flow passes the target
4189
4191
  // project's cwd explicitly (the file may not exist yet, so there's
4190
4192
  // nothing for findPrdDir to search for); the renderer's slug-only IPC
@@ -4220,33 +4222,12 @@ const remote = {
4220
4222
  }
4221
4223
  },
4222
4224
 
4223
- async readLog(slug, runId) {
4224
- const logPath = path.resolve(path.join(RUNS_DIR, runId, `${slug}.log`));
4225
- if (!logPath.startsWith(RUNS_DIR + path.sep)) {
4226
- return { ok: false, error: 'invalid slug or runId' };
4227
- }
4228
- try {
4229
- // realpath resolves symlinks; re-check boundary to block a rogue agent job
4230
- // that places a symlink inside RUNS_DIR pointing outside the safe root.
4231
- const real = await fsp.realpath(logPath);
4232
- if (!real.startsWith(RUNS_DIR + path.sep)) {
4233
- return { ok: false, error: 'invalid slug or runId' };
4234
- }
4235
- const text = await fsp.readFile(real, 'utf8');
4236
- return { ok: true, text };
4237
- } catch (e) {
4238
- return { ok: false, error: e?.message };
4239
- }
4240
- },
4241
-
4242
4225
  // `cwd` optional — see readPrd's comment above; prdCreate.cjs's create
4243
4226
  // flow supplies it (the destination project dir for a brand-new file that
4244
4227
  // doesn't exist yet, so findPrdDir would return nothing to write into).
4245
4228
  async writePrd(slug, body, cwd) {
4246
4229
  let dir;
4247
4230
  let epicTrace = null;
4248
- let epicCreated = false;
4249
- let epicId = null;
4250
4231
  if (cwd) {
4251
4232
  // Edit-in-place if this slug already lives anywhere under this project
4252
4233
  // (legacy flat dir or any Epic's prds/); otherwise this is a CREATE,
@@ -4258,29 +4239,37 @@ const remote = {
4258
4239
  if (candidate && fs.existsSync(candidate)) { dir = d; break; }
4259
4240
  }
4260
4241
  if (!dir) {
4242
+ // Every PRD must join an EXISTING, already-human-approved Epic —
4243
+ // mintIfMissing:false means this never conjures a new one. Only
4244
+ // Epics born from explicit human intent (New Epic UI, or
4245
+ // /propose-epic + Approve & start) may write PRDs; this write path
4246
+ // is not itself a human-intent gate, so it must not become one by
4247
+ // accident.
4248
+ const { fm } = splitFrontmatter(body);
4261
4249
  try {
4262
- const { fm } = splitFrontmatter(body);
4263
4250
  const epic = await ensureEpic(cwd, {
4264
- goalText: fm.title || slug,
4265
- tag: fm.tag,
4266
- // An Epic-conversation dispatch already has its Epic — join it.
4267
4251
  // fm.sourcePromptId must be an existing Epic's promptSessionId
4268
4252
  // (== its active-index.json sessions key), NOT a
4269
4253
  // PromptTicket.id — epicMint.cjs's ensureEpic looks it up via
4270
4254
  // `index.sessions[explicitEpicId]` (see epicMint.cjs ~line 73),
4271
4255
  // a literal-equality join. Any other id (e.g. a PromptTicket.id)
4272
- // simply won't match and mints a sibling Epic instead of joining.
4256
+ // simply won't match and this call throws below.
4273
4257
  epicId: fm.sourcePromptId,
4258
+ mintIfMissing: false,
4259
+ // Join-only call (mintIfMissing:false, explicit epicId): the
4260
+ // explicitEpicId branch in ensureEpic returns before `source` is
4261
+ // ever read, so this is a no-op today — kept for symmetry/audit
4262
+ // trail if that join path ever grows a source-touch (PRD 902/905).
4263
+ source: { producer: 'scheduler-dispatch', prdSlug: slug },
4274
4264
  });
4275
4265
  dir = epic.prdDir;
4276
4266
  epicTrace = epic.epicId;
4277
- epicCreated = epic.created === true;
4278
- epicId = epic.epicId;
4279
4267
  } catch (e) {
4280
- // Epic mint must never block a PRD write — fall back to the
4281
- // legacy flat dir and log loudly.
4282
- console.error(`[scheduler] ensureEpic failed for ${slug}: ${e?.message}`);
4283
- dir = prdDirForCwd(cwd);
4268
+ return {
4269
+ ok: false,
4270
+ error: `no existing Epic to join (sourcePromptId=${fm.sourcePromptId ?? 'none'}): ${e?.message ?? 'unknown error'}. `
4271
+ + 'Create the Epic first via the New Epic UI or /propose-epic + Approve & start, then pass its id as sourcePromptId.',
4272
+ };
4284
4273
  }
4285
4274
  }
4286
4275
  await fsp.mkdir(dir, { recursive: true });
@@ -4289,30 +4278,11 @@ const remote = {
4289
4278
  if (dir === PRDS_DIR) ensureDirs();
4290
4279
  }
4291
4280
 
4292
- // PRD 825: if this call minted a brand-new Epic (ensureEpic's `created`)
4293
- // and the write below never lands, don't strand an empty Epic dir —
4294
- // best-effort remove `<epic>/prds` then `<epic>` itself, only when empty.
4295
- // PRD 851: also drop the Epic's active-index.json entry (sessions/events)
4296
- // so an orphaned seed-prompt-only Epic doesn't linger in the Epics nav.
4297
- // Gated on epicCreated (never fires when ensureEpic joined an existing
4298
- // Epic) so a pre-existing Epic's history is never touched.
4299
- const cleanupEmptyMintedEpic = async () => {
4300
- if (!epicCreated || !epicId) return;
4301
- try {
4302
- const entries = await fsp.readdir(dir);
4303
- if (entries.length === 0) {
4304
- await fsp.rmdir(dir);
4305
- const epicRootDir = path.dirname(dir);
4306
- const epicRootEntries = await fsp.readdir(epicRootDir);
4307
- if (epicRootEntries.length === 0) await fsp.rmdir(epicRootDir);
4308
- }
4309
- } catch { /* best-effort only */ }
4310
- try { removeEpic(cwd, epicId); } catch { /* best-effort only */ }
4311
- };
4312
-
4281
+ // writePrd only ever JOINS an existing Epic now (mintIfMissing:false
4282
+ // above) — it can never mint one, so there is no orphaned-mint case left
4283
+ // to roll back here (contrast the old PRD 825/851 cleanup, removed).
4313
4284
  const resolved = safeSlugPathIn(dir, slug);
4314
4285
  if (!resolved) {
4315
- await cleanupEmptyMintedEpic();
4316
4286
  return { ok: false, error: 'invalid slug' };
4317
4287
  }
4318
4288
  try {
@@ -4324,23 +4294,20 @@ const remote = {
4324
4294
  // symlink.
4325
4295
  const realParent = await fsp.realpath(path.dirname(resolved));
4326
4296
  if (realParent !== dir && !realParent.startsWith(dir + path.sep)) {
4327
- await cleanupEmptyMintedEpic();
4328
4297
  return { ok: false, error: 'invalid slug' };
4329
4298
  }
4330
4299
  const existing = await fsp.lstat(resolved).catch(() => null);
4331
4300
  if (existing && existing.isSymbolicLink()) {
4332
- await cleanupEmptyMintedEpic();
4333
4301
  return { ok: false, error: 'invalid slug' };
4334
4302
  }
4335
4303
  await config.writeTextAtomic(resolved, body, { writer: 'scheduler' });
4336
4304
  const stat = await fsp.stat(resolved);
4337
4305
  if (epicTrace) {
4338
- // Best-effort: record the dispatch on the minted Epic's event chain.
4306
+ // Best-effort: record the dispatch on the Epic's event chain.
4339
4307
  try { await appendPrdCreatedEvent(cwd, epicTrace, slug); } catch { /* trace only */ }
4340
4308
  }
4341
4309
  return { ok: true, bytesWritten: stat.size };
4342
4310
  } catch (e) {
4343
- await cleanupEmptyMintedEpic();
4344
4311
  return { ok: false, error: e?.message ?? 'write failed' };
4345
4312
  }
4346
4313
  },
@@ -4378,28 +4345,6 @@ const remote = {
4378
4345
  // reuses the same allocator the file-based /develop authoring path relies
4379
4346
  // on implicitly, rather than re-deriving NN here.
4380
4347
  allocateParallelGroup,
4381
-
4382
- async runNow() {
4383
- await clearPause('run-now');
4384
- runDueJobs().catch((e) => logs.writeLine({
4385
- level: 'error', scope: 'scheduler',
4386
- message: 'runDueJobs error (remote:run-now)', meta: { error: e?.message },
4387
- }));
4388
- return { ok: true };
4389
- },
4390
-
4391
- async setConfig(partial) {
4392
- const cfg = await mutate((state) => {
4393
- const { supervisor: supPartial, ...rest } = partial;
4394
- state.config = { ...state.config, ...rest };
4395
- if (supPartial !== undefined) {
4396
- state.config.supervisor = { ...(state.config.supervisor ?? {}), ...supPartial };
4397
- }
4398
- return state.config;
4399
- });
4400
- await rescheduleTimer();
4401
- return { ok: true, config: cfg };
4402
- },
4403
4348
  };
4404
4349
 
4405
4350
  // Registers the two job-management admin HTTP routes (PRD 689 — moved
@@ -392,7 +392,11 @@ the user may or may not have open.
392
392
 
393
393
  ### Fallback: writing the PRD file directly
394
394
 
395
- When the app is not running, first mint (or join) an Epic — `node <session-manager-repo>/scripts/mint-epic.cjs <cwd> "<goal>" [feature|bug|discussion]`; its last stdout line is the prds dir — then write `<NN>-<slug>.md` by hand into that
395
+ When the app is not running, first join the EXISTING, already-human-approved Epic you're already
396
+ working inside — `node <session-manager-repo>/scripts/mint-epic.cjs <cwd> <epic-id>`; its last
397
+ stdout line is the prds dir. This only joins; it never creates an Epic, and errors out if
398
+ `<epic-id>` doesn't already exist — get a human to create/approve the Epic first (New Epic UI, or
399
+ `/propose-epic` + Approve & start) if it doesn't. Then write `<NN>-<slug>.md` by hand into that
396
400
  `<cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/` dir (the flat `scheduler/prds/` is RETIRED and auto-archived unexecuted at boot), add `sourcePromptId: <epic-id>` to the frontmatter so the job keeps its Epic linkage, following the frontmatter rules in §6 and the
397
401
  body conventions the rest of this guide describes (`# Goal`, `# Acceptance criteria`,
398
402
  `# Implementation notes`, `## Engineering standards` inlined verbatim — see `/develop`'s output
@@ -1054,7 +1054,6 @@ function getDispatchMap() {
1054
1054
 
1055
1055
  const { manager: ptyManager } = require('./pty.cjs');
1056
1056
  const sessionsStore = require('./sessionsStore.cjs');
1057
- const scheduler = require('./scheduler.cjs');
1058
1057
  const { remote: histRemote } = require('./historyAggregator.cjs');
1059
1058
  const chatRunner = require('./chatRunner.cjs');
1060
1059
 
@@ -1095,37 +1094,6 @@ function getDispatchMap() {
1095
1094
  return { ok: true };
1096
1095
  },
1097
1096
 
1098
- 'cmd:schedule:state': async () =>
1099
- scheduler.remote.getState(),
1100
-
1101
- 'cmd:schedule:read-prd': async (payload) => {
1102
- const parsed = schemas.scheduleSlug.parse(payload);
1103
- return scheduler.remote.readPrd(parsed.slug);
1104
- },
1105
-
1106
- 'cmd:schedule:read-log': async (payload) => {
1107
- const parsed = schemas.scheduleReadLog.parse(payload);
1108
- return scheduler.remote.readLog(parsed.slug, parsed.runId);
1109
- },
1110
-
1111
- 'cmd:schedule:write-prd': async (payload) => {
1112
- const parsed = schemas.scheduleWritePrd.parse(payload);
1113
- return scheduler.remote.writePrd(parsed.slug, parsed.body);
1114
- },
1115
-
1116
- 'cmd:schedule:reset-job': async (payload) => {
1117
- const parsed = schemas.scheduleSlug.parse(payload);
1118
- return scheduler.remote.resetJob(parsed.slug);
1119
- },
1120
-
1121
- 'cmd:schedule:run-now': async () =>
1122
- scheduler.remote.runNow(),
1123
-
1124
- 'cmd:schedule:set-config': async (payload) => {
1125
- const parsed = schemas.setConfigSchema.default({}).parse(payload ?? {});
1126
- return scheduler.remote.setConfig(parsed);
1127
- },
1128
-
1129
1097
  'cmd:history:aggregate': async (payload) => {
1130
1098
  const parsed = schemas.historyAggregate.parse(payload);
1131
1099
  return histRemote.aggregate(parsed);