claude-code-session-manager 0.39.3 → 0.39.4

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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * epicMint.cjs — auto-mint an Epic (PromptSession) for a PRD dispatch.
3
+ *
4
+ * Domain rule (CLAUDE.md "Domain model"): every PRD belongs to an Epic; the
5
+ * hierarchy TAB → operations-root → EPIC → PRD is total. Dispatch paths that
6
+ * have no Epic in hand (feedback sweep, ad-hoc /develop, admin/MCP create)
7
+ * call ensureEpic() to create-or-join one instead of writing epicless PRDs.
8
+ *
9
+ * The Epic registry is the renderer's own store: the per-cwd
10
+ * `session-manager-operations/prompt-sessions/active-index.json`
11
+ * ({ sessions, events } — shape defined in state/promptSessions.ts). Writing
12
+ * the same file the renderer reads keeps a single source of truth: an
13
+ * auto-minted Epic shows up in the Epics nav like a hand-created one.
14
+ *
15
+ * Plain Node module (no Electron deps) so the external watchdog scripts can
16
+ * require it. Atomic writes: tmp + rename, same pattern as config.cjs.
17
+ */
18
+ 'use strict';
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const crypto = require('node:crypto');
23
+ const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
24
+
25
+ function activeIndexPath(cwd) {
26
+ return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
27
+ }
28
+
29
+ function readActiveIndex(cwd) {
30
+ try {
31
+ const parsed = JSON.parse(fs.readFileSync(activeIndexPath(cwd), 'utf8'));
32
+ return {
33
+ sessions: parsed && typeof parsed.sessions === 'object' && parsed.sessions ? parsed.sessions : {},
34
+ events: parsed && typeof parsed.events === 'object' && parsed.events ? parsed.events : {},
35
+ };
36
+ } catch {
37
+ return { sessions: {}, events: {} };
38
+ }
39
+ }
40
+
41
+ function writeActiveIndex(cwd, index) {
42
+ const file = activeIndexPath(cwd);
43
+ fs.mkdirSync(path.dirname(file), { recursive: true });
44
+ const tmp = `${file}.tmp-${process.pid}`;
45
+ fs.writeFileSync(tmp, JSON.stringify(index, null, 2));
46
+ fs.renameSync(tmp, file);
47
+ }
48
+
49
+ function slugify(text) {
50
+ return String(text || 'epic')
51
+ .toLowerCase()
52
+ .replace(/[^a-z0-9]+/g, '-')
53
+ .replace(/^-+|-+$/g, '')
54
+ .slice(0, 48) || 'epic';
55
+ }
56
+
57
+ /**
58
+ * ensureEpic(cwd, { goalText, tag?, reuseByGoal? }) → { epicId, prdDir, created }
59
+ *
60
+ * Mints a new Epic — or, with `reuseByGoal`, joins the existing ACTIVE Epic
61
+ * whose goalText matches (used by the recurring feedback sweep so successive
62
+ * sweeps chain into one Epic instead of minting one per tick).
63
+ *
64
+ * The Epic's id doubles as its directory name under scheduler/epics/, so the
65
+ * PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
66
+ */
67
+ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId } = {}) {
68
+ if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
69
+ const index = readActiveIndex(cwd);
70
+
71
+ // A dispatch that already knows its Epic (sourcePromptId frontmatter from
72
+ // an Epic-conversation dispatch) joins it rather than minting a sibling.
73
+ if (explicitEpicId && index.sessions[explicitEpicId]) {
74
+ const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
75
+ fs.mkdirSync(prdDir, { recursive: true });
76
+ return { epicId: explicitEpicId, prdDir, created: false };
77
+ }
78
+
79
+ if (reuseByGoal) {
80
+ for (const s of Object.values(index.sessions)) {
81
+ if (s && s.status === 'active' && s.goalText === goalText) {
82
+ const prdDir = resolveEpicPrdWriteDir(cwd, s.id);
83
+ fs.mkdirSync(prdDir, { recursive: true });
84
+ return { epicId: s.id, prdDir, created: false };
85
+ }
86
+ }
87
+ }
88
+
89
+ const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
90
+ const now = new Date().toISOString();
91
+ const session = {
92
+ id: epicId,
93
+ cwd,
94
+ goalText: String(goalText || ''),
95
+ // Independently minted, never shared with a SessionTab — same invariant
96
+ // as renderer-created PromptSessions (state/promptSessions.ts).
97
+ claudeSessionId: crypto.randomUUID(),
98
+ status: 'active',
99
+ createdAt: now,
100
+ completedAt: null,
101
+ ...(tag ? { tag } : {}),
102
+ };
103
+ const firstEvent = {
104
+ id: crypto.randomUUID(),
105
+ promptSessionId: epicId,
106
+ kind: 'prompt',
107
+ causedByEventId: null,
108
+ at: now,
109
+ text: String(goalText || ''),
110
+ };
111
+ index.sessions[epicId] = session;
112
+ index.events[epicId] = [firstEvent];
113
+ writeActiveIndex(cwd, index);
114
+
115
+ const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
116
+ fs.mkdirSync(prdDir, { recursive: true });
117
+ return { epicId, prdDir, created: true };
118
+ }
119
+
120
+ /**
121
+ * appendPrdCreatedEvent(cwd, epicId, prdSlug) — record a PRD dispatch on the
122
+ * Epic's event chain, FK-linked to the current tail (chain, not tree — the
123
+ * referential-integrity requirement from promptSessions.ts).
124
+ */
125
+ function appendPrdCreatedEvent(cwd, epicId, prdSlug, text) {
126
+ const index = readActiveIndex(cwd);
127
+ if (!index.sessions[epicId]) return false;
128
+ const chain = Array.isArray(index.events[epicId]) ? index.events[epicId] : [];
129
+ const tail = chain.length ? chain[chain.length - 1] : null;
130
+ chain.push({
131
+ id: crypto.randomUUID(),
132
+ promptSessionId: epicId,
133
+ kind: 'prd_created',
134
+ causedByEventId: tail ? tail.id : null,
135
+ at: new Date().toISOString(),
136
+ prdSlug,
137
+ ...(text ? { text } : {}),
138
+ });
139
+ index.events[epicId] = chain;
140
+ writeActiveIndex(cwd, index);
141
+ return true;
142
+ }
143
+
144
+ module.exports = { ensureEpic, appendPrdCreatedEvent, activeIndexPath };
@@ -20,6 +20,47 @@ const path = require('node:path');
20
20
  const { activeProjectCwds, allProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
21
21
 
22
22
  const PRD_SUBPATH = ['session-manager-operations', 'scheduler', 'prds'];
23
+ const EPICS_SUBPATH = ['session-manager-operations', 'scheduler', 'epics'];
24
+
25
+ /**
26
+ * resolveEpicsRoot(cwd) → `<cwd>/session-manager-operations/scheduler/epics`
27
+ * The per-project root under which every Epic keeps its own prds/ dir.
28
+ */
29
+ function resolveEpicsRoot(cwd) {
30
+ if (!cwd || typeof cwd !== 'string') {
31
+ throw new Error('resolveEpicsRoot: cwd is required');
32
+ }
33
+ return path.join(cwd, ...EPICS_SUBPATH);
34
+ }
35
+
36
+ /**
37
+ * resolveEpicPrdWriteDir(cwd, epicId) →
38
+ * `<cwd>/session-manager-operations/scheduler/epics/<epicId>/prds`
39
+ * The write destination for every NEW PRD. The flat legacy dir
40
+ * (resolvePrdWriteDir) remains read-only during the transition — new PRDs
41
+ * always belong to an Epic (auto-minted when the dispatch has none).
42
+ */
43
+ function resolveEpicPrdWriteDir(cwd, epicId) {
44
+ if (!epicId || typeof epicId !== 'string' || epicId.includes('/') || epicId.includes('..')) {
45
+ throw new Error('resolveEpicPrdWriteDir: epicId must be a plain directory name');
46
+ }
47
+ return path.join(resolveEpicsRoot(cwd), epicId, 'prds');
48
+ }
49
+
50
+ /** Enumerate every existing `epics/<id>/prds` dir under one project cwd. */
51
+ function listEpicPrdDirs(cwd) {
52
+ let root;
53
+ try { root = resolveEpicsRoot(cwd); } catch { return []; }
54
+ let entries;
55
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return []; }
56
+ const dirs = [];
57
+ for (const ent of entries) {
58
+ if (!ent.isDirectory()) continue;
59
+ const prdDir = path.join(root, ent.name, 'prds');
60
+ if (fs.existsSync(prdDir)) dirs.push(prdDir);
61
+ }
62
+ return dirs;
63
+ }
23
64
 
24
65
  /**
25
66
  * resolvePrdWriteDir(cwd) → `<cwd>/session-manager-operations/scheduler/prds`
@@ -68,6 +109,9 @@ function resolvePrdsDirs(maxAgeMin, opts) {
68
109
  let dir;
69
110
  try { dir = resolvePrdWriteDir(cwd); } catch { continue; }
70
111
  if (fs.existsSync(dir)) add(dir);
112
+ // Epic-scoped dirs (the write layout for all new PRDs) are first-class
113
+ // scan sources alongside the legacy flat dir.
114
+ for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
71
115
  }
72
116
 
73
117
  // Active projects are added unconditionally: a brand-new project has no
@@ -75,9 +119,18 @@ function resolvePrdsDirs(maxAgeMin, opts) {
75
119
  // find it. Scans over a non-existent dir are a harmless ENOENT no-op.
76
120
  for (const cwd of activeProjectCwds(maxAgeMin, opts)) {
77
121
  try { add(resolvePrdWriteDir(cwd)); } catch { /* unusable cwd */ }
122
+ for (const epicDir of listEpicPrdDirs(cwd)) add(epicDir);
78
123
  }
79
124
 
80
125
  return dirs;
81
126
  }
82
127
 
83
- module.exports = { resolvePrdWriteDir, resolvePrdsDirs, PRD_SUBPATH };
128
+ module.exports = {
129
+ resolvePrdWriteDir,
130
+ resolvePrdsDirs,
131
+ resolveEpicsRoot,
132
+ resolveEpicPrdWriteDir,
133
+ listEpicPrdDirs,
134
+ PRD_SUBPATH,
135
+ EPICS_SUBPATH,
136
+ };
@@ -82,4 +82,50 @@ async function migratePrds(legacyPrdsDir) {
82
82
  return { moved, skipped, unresolved };
83
83
  }
84
84
 
85
- module.exports = { migratePrds };
85
+ /**
86
+ * consolidateFlatPrds(cwd) — one-time, idempotent retirement of the legacy
87
+ * FLAT per-project PRD dir (`scheduler/prds/`) now that new PRDs are always
88
+ * epic-scoped (`scheduler/epics/<id>/prds/`, CLAUDE.md domain model).
89
+ *
90
+ * Every `.md` in the flat dir moves to the sibling `prds-archived/` for later
91
+ * special processing (per 2026-07-31 decision: "new PRDs only; consolidate
92
+ * existing into prds-archived"). Queue rows pointing at moved files are
93
+ * reaped by the existing archived-twin retirement (11ad3d8). Dotfiles
94
+ * (.max-allocated-group, .reserved-*) stay — NN allocation still uses them.
95
+ * Name collisions in prds-archived/ get a `-legacy-<n>` suffix, never an
96
+ * overwrite. Idempotent: an emptied flat dir is a no-op readdir.
97
+ *
98
+ * Returns { moved, failed: [{ file, reason }] }.
99
+ */
100
+ async function consolidateFlatPrds(cwd) {
101
+ const flatDir = resolvePrdWriteDir(cwd);
102
+ const archiveDir = path.join(path.dirname(flatDir), 'prds-archived');
103
+ let entries;
104
+ try {
105
+ entries = await fsp.readdir(flatDir);
106
+ } catch {
107
+ return { moved: 0, failed: [] };
108
+ }
109
+
110
+ let moved = 0;
111
+ const failed = [];
112
+ for (const name of entries) {
113
+ if (!name.endsWith('.md') || name.startsWith('.')) continue;
114
+ const src = path.join(flatDir, name);
115
+ try {
116
+ await fsp.mkdir(archiveDir, { recursive: true });
117
+ let dst = path.join(archiveDir, name);
118
+ for (let n = 1; fs.existsSync(dst); n++) {
119
+ dst = path.join(archiveDir, `${name.slice(0, -3)}-legacy-${n}.md`);
120
+ }
121
+ await fsp.rename(src, dst);
122
+ moved++;
123
+ } catch (e) {
124
+ if (e?.code === 'ENOENT') continue; // raced with a concurrent mover
125
+ failed.push({ file: name, reason: e?.message ?? 'move failed' });
126
+ }
127
+ }
128
+ return { moved, failed };
129
+ }
130
+
131
+ module.exports = { migratePrds, consolidateFlatPrds };
@@ -14,9 +14,32 @@ const os = require('os');
14
14
  const path = require('path');
15
15
  const fsp = require('fs').promises;
16
16
  const { HISTORY_RETENTION_MS } = require('./schedulerConfig.cjs');
17
+ const { projectHistoryPath, stateCwds } = require('./queueStore.cjs');
17
18
 
19
+ // Legacy global sidecar — READ-ONLY since 2026-07-31 (federated per-project
20
+ // history under <cwd>/session-manager-operations/scheduler/state/history.jsonl,
21
+ // resolved via queueStore.cjs). Old rows stay readable from here; new appends
22
+ // go to the owning project's shard (entries without a cwd still land here so
23
+ // nothing is ever dropped).
18
24
  const HISTORY_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'history.jsonl');
19
25
 
26
+ /** Every history file currently in play: each project's shard + the legacy global. */
27
+ function historyPaths() {
28
+ const paths = [];
29
+ for (const cwd of stateCwds()) {
30
+ try { paths.push(projectHistoryPath(cwd)); } catch { /* unusable cwd */ }
31
+ }
32
+ paths.push(HISTORY_PATH);
33
+ return paths;
34
+ }
35
+
36
+ function historyPathFor(entry) {
37
+ if (entry && entry.cwd) {
38
+ try { return projectHistoryPath(entry.cwd); } catch { /* fall through */ }
39
+ }
40
+ return HISTORY_PATH;
41
+ }
42
+
20
43
  function isFixPlanSlug(slug) {
21
44
  return /^\d+-fix-/.test(slug);
22
45
  }
@@ -76,32 +99,45 @@ async function appendHistory(entries) {
76
99
  const list = Array.isArray(entries) ? entries.filter(Boolean) : [];
77
100
  if (list.length === 0) return { appended: 0 };
78
101
 
79
- let existingText = '';
80
- try {
81
- existingText = await fsp.readFile(HISTORY_PATH, 'utf8');
82
- } catch (e) {
83
- if (e.code !== 'ENOENT') throw e;
102
+ // Federated: each entry appends to its own project's shard (fallback: the
103
+ // legacy global file for cwd-less rows). Dedupe stays per target file.
104
+ const byPath = new Map();
105
+ for (const e of list) {
106
+ const target = historyPathFor(e);
107
+ if (!byPath.has(target)) byPath.set(target, []);
108
+ byPath.get(target).push(e);
84
109
  }
85
110
 
86
- const existingKeys = new Set();
87
- if (existingText) {
88
- for (const line of existingText.split('\n')) {
89
- if (!line.trim()) continue;
90
- try {
91
- existingKeys.add(jobKey(JSON.parse(line)));
92
- } catch {
93
- // corrupt/partial line — ignore for dedupe purposes
111
+ let appended = 0;
112
+ for (const [target, group] of byPath) {
113
+ let existingText = '';
114
+ try {
115
+ existingText = await fsp.readFile(target, 'utf8');
116
+ } catch (e) {
117
+ if (e.code !== 'ENOENT') throw e;
118
+ }
119
+
120
+ const existingKeys = new Set();
121
+ if (existingText) {
122
+ for (const line of existingText.split('\n')) {
123
+ if (!line.trim()) continue;
124
+ try {
125
+ existingKeys.add(jobKey(JSON.parse(line)));
126
+ } catch {
127
+ // corrupt/partial line — ignore for dedupe purposes
128
+ }
94
129
  }
95
130
  }
96
- }
97
131
 
98
- const fresh = list.filter((e) => !existingKeys.has(jobKey(e)));
99
- if (fresh.length === 0) return { appended: 0 };
132
+ const fresh = group.filter((e) => !existingKeys.has(jobKey(e)));
133
+ if (fresh.length === 0) continue;
100
134
 
101
- await fsp.mkdir(path.dirname(HISTORY_PATH), { recursive: true });
102
- const text = fresh.map((e) => JSON.stringify(e)).join('\n') + '\n';
103
- await fsp.appendFile(HISTORY_PATH, text, 'utf8');
104
- return { appended: fresh.length };
135
+ await fsp.mkdir(path.dirname(target), { recursive: true });
136
+ const text = fresh.map((e) => JSON.stringify(e)).join('\n') + '\n';
137
+ await fsp.appendFile(target, text, 'utf8');
138
+ appended += fresh.length;
139
+ }
140
+ return { appended };
105
141
  }
106
142
 
107
143
  /**
@@ -112,10 +148,22 @@ async function appendHistory(entries) {
112
148
  */
113
149
  async function readHistory({ limit } = {}) {
114
150
  const cap = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 50));
151
+ // Federated: newest `cap` from EACH file, merged newest-first by finishedAt
152
+ // (fallback startedAt), then capped. Per-file tail reads keep this cheap.
153
+ const chunks = await Promise.all(historyPaths().map((p2) => readHistoryFile(p2, cap)));
154
+ const ts = (e) => Date.parse(e?.finishedAt ?? e?.startedAt ?? '') || 0;
155
+ // Each chunk is already newest-first; keep that order as the tiebreak so
156
+ // timestamp-less rows don't get shuffled by the cross-file sort.
157
+ const tagged = chunks.flatMap((chunk) => chunk.map((e, idx) => ({ e, ts: ts(e), idx })));
158
+ tagged.sort((a, b) => (b.ts - a.ts) || (a.idx - b.idx));
159
+ return tagged.slice(0, cap).map((t) => t.e);
160
+ }
115
161
 
162
+ /** Tail-read one history file: newest `cap` parsed rows, newest-first. */
163
+ async function readHistoryFile(target, cap) {
116
164
  let fh;
117
165
  try {
118
- fh = await fsp.open(HISTORY_PATH, 'r');
166
+ fh = await fsp.open(target, 'r');
119
167
  } catch (e) {
120
168
  if (e.code === 'ENOENT') return [];
121
169
  throw e;
@@ -162,7 +210,7 @@ async function readHistory({ limit } = {}) {
162
210
  // mtime-gated cache, same convention as scheduler/prdParser.cjs's dirCache —
163
211
  // repeated reconcile() calls between appends hit the cache instead of
164
212
  // re-reading/re-parsing a JSONL file that only grows over time.
165
- let historyCacheMtime = -1;
213
+ let historyCacheKey = null;
166
214
  let historyCacheMap = null;
167
215
 
168
216
  /**
@@ -176,22 +224,22 @@ let historyCacheMap = null;
176
224
  * from "this is a genuinely brand-new PRD nobody has ever queued."
177
225
  */
178
226
  async function historyTerminalBySlug() {
179
- let mtime;
180
- try {
181
- mtime = (await fsp.stat(HISTORY_PATH)).mtimeMs;
182
- } catch {
183
- mtime = -1;
184
- }
185
- if (mtime === historyCacheMtime && historyCacheMap) return historyCacheMap;
227
+ const paths = historyPaths();
228
+ const mtimes = await Promise.all(paths.map(async (p2) => {
229
+ try { return (await fsp.stat(p2)).mtimeMs; } catch { return -1; }
230
+ }));
231
+ const cacheKey = paths.map((p2, i) => `${p2}:${mtimes[i]}`).join('|');
232
+ if (cacheKey === historyCacheKey && historyCacheMap) return historyCacheMap;
186
233
 
187
234
  const map = new Map();
188
- let text = '';
189
- try {
190
- text = await fsp.readFile(HISTORY_PATH, 'utf8');
191
- } catch (e) {
192
- if (e.code !== 'ENOENT') throw e;
193
- }
194
- if (text) {
235
+ for (const p2 of paths) {
236
+ let text = '';
237
+ try {
238
+ text = await fsp.readFile(p2, 'utf8');
239
+ } catch (e) {
240
+ if (e.code !== 'ENOENT') throw e;
241
+ }
242
+ if (!text) continue;
195
243
  for (const line of text.split('\n')) {
196
244
  if (!line.trim()) continue;
197
245
  try {
@@ -202,7 +250,7 @@ async function historyTerminalBySlug() {
202
250
  }
203
251
  }
204
252
  }
205
- historyCacheMtime = mtime;
253
+ historyCacheKey = cacheKey;
206
254
  historyCacheMap = map;
207
255
  return map;
208
256
  }