claude-code-session-manager 0.39.3 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/assets/{TiptapBody-B90xy18x.js → TiptapBody-CNr1qGoi.js} +1 -1
  2. package/dist/assets/{index-DVlD8N1X.css → index-CR-WdMgm.css} +1 -1
  3. package/dist/assets/index-oMNYp73N.js +3194 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +34 -27
  7. package/scripts/lib/watchdogHelpers.cjs +57 -21
  8. package/src/main/__tests__/health-prd-migration.test.cjs +37 -0
  9. package/src/main/__tests__/prdCreate.test.cjs +54 -15
  10. package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +118 -0
  11. package/src/main/__tests__/queueHistory.test.cjs +9 -4
  12. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +12 -0
  13. package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +92 -0
  14. package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +1 -1
  15. package/src/main/__tests__/uniquePrdNumbers.test.cjs +119 -0
  16. package/src/main/chatRunner.cjs +11 -1
  17. package/src/main/config.cjs +10 -0
  18. package/src/main/health.cjs +46 -10
  19. package/src/main/index.cjs +28 -9
  20. package/src/main/ipcSchemas.cjs +10 -0
  21. package/src/main/lib/__tests__/instanceLock.test.cjs +87 -0
  22. package/src/main/lib/__tests__/sessionSlots.test.cjs +55 -0
  23. package/src/main/lib/epicMint.cjs +144 -0
  24. package/src/main/lib/instanceLock.cjs +100 -0
  25. package/src/main/lib/prdCreate.cjs +19 -6
  26. package/src/main/lib/prdLocations.cjs +82 -1
  27. package/src/main/lib/prdMigration.cjs +47 -1
  28. package/src/main/lib/queueHistory.cjs +85 -37
  29. package/src/main/lib/queueStore.cjs +299 -0
  30. package/src/main/lib/schedulerBatch.cjs +36 -4
  31. package/src/main/lib/sessionSlots.cjs +85 -0
  32. package/src/main/pty.cjs +9 -0
  33. package/src/main/queueOps.cjs +20 -1
  34. package/src/main/scheduler/prdParser.cjs +28 -4
  35. package/src/main/scheduler.cjs +333 -52
  36. package/src/main/templates/PRD_AUTHORING.md +9 -5
  37. package/src/preload/api.d.ts +18 -3
  38. package/src/preload/index.cjs +2 -0
  39. package/dist/assets/index-BUuhV6vT.js +0 -3180
@@ -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, readActiveIndex };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * instanceLock.cjs — machine-wide scheduler-ownership lock (PRD 834).
3
+ *
4
+ * Electron's app.requestSingleInstanceLock() is keyed by userData path, so an
5
+ * instance launched with a different identity bypasses it entirely — live
6
+ * incident 2026-07-31: a Playwright `_electron.launch(src/main/index.cjs)`
7
+ * runs as the "Electron" default app (userData ~/.config/Electron), won its
8
+ * own Electron lock, ran full scheduler boot reconciliation against the same
9
+ * per-project queue state as the production instance, SIGTERM'd the
10
+ * production instance's live job as an "orphan", and overwrote
11
+ * admin-api.json. Dev/e2e launches skip the Electron lock on purpose, with
12
+ * the same effect.
13
+ *
14
+ * This lock is identity-independent: a pid file at a FIXED path under
15
+ * ~/.claude/session-manager/. Exactly one process — whichever wins the
16
+ * atomic 'wx' create — owns scheduler mutation (boot reconciliation, queue
17
+ * ticking, feedback sweep, supervisor, heartbeat) and the admin API. Every
18
+ * other instance runs scheduler-passive: UI reads still work (IPC read
19
+ * handlers register unconditionally), but nothing that mutates queue state
20
+ * or kills processes runs.
21
+ *
22
+ * Deliberately plain sync fs (no config.cjs writeJson): this runs before
23
+ * app.whenReady and must not await; the file is tiny and single-writer.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const fs = require('node:fs');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+
32
+ // Env override is for unit tests only (isolates the lock from the real
33
+ // ~/.claude of the machine running the suite).
34
+ function lockPath() {
35
+ return process.env.SM_SCHEDULER_LOCK_PATH
36
+ || path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-owner.lock');
37
+ }
38
+
39
+ function pidAlive(pid) {
40
+ if (!Number.isInteger(pid) || pid <= 0) return false;
41
+ try {
42
+ process.kill(pid, 0);
43
+ return true;
44
+ } catch (e) {
45
+ // EPERM = alive but not ours; ESRCH = dead.
46
+ return e && e.code === 'EPERM';
47
+ }
48
+ }
49
+
50
+ function readLock() {
51
+ try {
52
+ const raw = fs.readFileSync(lockPath(), 'utf8');
53
+ const parsed = JSON.parse(raw);
54
+ return Number.isInteger(parsed?.pid) ? parsed : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function writeLockExclusive() {
61
+ const body = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
62
+ fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
63
+ // 'wx' = atomic create-or-fail — two racing instances cannot both win.
64
+ fs.writeFileSync(lockPath(), body, { flag: 'wx', mode: 0o600 });
65
+ }
66
+
67
+ /**
68
+ * Try to become the machine's scheduler owner.
69
+ * Returns { owner: true } or { owner: false, holderPid } — never throws.
70
+ */
71
+ function acquireSchedulerOwnership() {
72
+ for (let attempt = 0; attempt < 2; attempt++) {
73
+ const existing = readLock();
74
+ if (existing && existing.pid !== process.pid && pidAlive(existing.pid)) {
75
+ return { owner: false, holderPid: existing.pid };
76
+ }
77
+ // Missing, unreadable, our own, or stale (holder pid dead) — take it.
78
+ try {
79
+ if (existing || fs.existsSync(lockPath())) fs.unlinkSync(lockPath());
80
+ } catch { /* raced with another breaker — retry loop below settles it */ }
81
+ try {
82
+ writeLockExclusive();
83
+ return { owner: true };
84
+ } catch {
85
+ // Lost the create race — loop once more to report the winner's pid.
86
+ }
87
+ }
88
+ const winner = readLock();
89
+ return { owner: false, holderPid: winner?.pid };
90
+ }
91
+
92
+ /** Best-effort release on clean shutdown; stale-pid detection covers crashes. */
93
+ function releaseSchedulerOwnership() {
94
+ try {
95
+ const existing = readLock();
96
+ if (existing && existing.pid === process.pid) fs.unlinkSync(lockPath());
97
+ } catch { /* nothing to release */ }
98
+ }
99
+
100
+ module.exports = { acquireSchedulerOwnership, releaseSchedulerOwnership, pidAlive, lockPath };
@@ -48,7 +48,7 @@ function deriveSlugFromTitle(title) {
48
48
  function buildPrdBody(input) {
49
49
  const {
50
50
  title, cwd, estimateMinutes, goal, acceptanceCriteria,
51
- implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag,
51
+ implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, dependsOn,
52
52
  } = input;
53
53
 
54
54
  // No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
@@ -64,6 +64,8 @@ function buildPrdBody(input) {
64
64
  // Optional, additive: the user-selected Feature/Bug tag (PRD 774) carried
65
65
  // through from the originating PromptTicket — deterministic, never LLM-classified.
66
66
  if (tag) fmLines.push(`tag: ${tag}`);
67
+ // Explicit ordering (PRD 832): replaces the retired shared-NN convention.
68
+ if (dependsOn && dependsOn.length) fmLines.push(`dependsOn: [${dependsOn.join(', ')}]`);
67
69
  fmLines.push('---', '');
68
70
 
69
71
  const acLines = acceptanceCriteria.map((line) => `- [ ] ${line}`).join('\n');
@@ -108,21 +110,32 @@ async function createPrd(input, remote) {
108
110
  // through config.cjs's validatePath (allowedRoots = home dir) —
109
111
  // same boundary every other fs-touching IPC handler uses — never a
110
112
  // bespoke check here.
113
+ // Normalize a `~`-prefixed cwd BEFORE it reaches allocateParallelGroup/
114
+ // readPrd/writePrd — those pass cwd through to safeSlugPathIn-adjacent path
115
+ // joins that treat it as a literal path segment, so an unexpanded `~/...`
116
+ // fails downstream as a bare "invalid slug" instead of a clear cwd error.
117
+ const cwd = expandHome(input.cwd);
111
118
  try {
112
- config.validatePath(expandHome(input.cwd));
119
+ config.validatePath(cwd);
113
120
  } catch (e) {
114
121
  return { ok: false, status: 400, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` };
115
122
  }
123
+ input = { ...input, cwd };
116
124
 
117
125
  const slug = input.slug || deriveSlugFromTitle(input.title);
118
126
  if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
119
127
  return { ok: false, status: 400, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' };
120
128
  }
121
129
 
122
- // NN allocation is delegated to allocateParallelGroup() (PRD 548) via
123
- // the injected remote — never re-derived here — unless the caller
124
- // opted into an existing group explicitly.
125
- const nn = input.parallelGroup ?? await remote.allocateParallelGroup(input.cwd);
130
+ // NN allocation is delegated to allocateParallelGroup() (PRD 548) via the
131
+ // injected remote — never re-derived here. `parallelGroup` input is
132
+ // DEPRECATED (PRD 832, user decision 2026-07-31): numbers are strictly
133
+ // unique per project; ordering is expressed via `dependsOn` frontmatter,
134
+ // never by sharing a number. An explicit parallelGroup is ignored.
135
+ if (input.parallelGroup != null) {
136
+ console.warn(`[prdCreate] parallelGroup input is deprecated and ignored (got ${input.parallelGroup}) — numbers are unique per project; use dependsOn for ordering`);
137
+ }
138
+ const nn = await remote.allocateParallelGroup(input.cwd);
126
139
  const filenameSlug = `${nn}-${slug}`;
127
140
 
128
141
  // An explicit `parallelGroup` bypasses allocateParallelGroup()'s
@@ -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,46 @@ 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
+ /**
129
+ * deriveEpicIdFromPrdPath(filePath) → epicId | null
130
+ *
131
+ * A PRD's file location already IS its Epic membership (epic id == parent
132
+ * dir name, 1:1 by design). Given an absolute PRD file path, walk back up
133
+ * `EPICS_SUBPATH.length` segments from the epics root implied by the path's
134
+ * own `.../prds/<slug>.md` shape and confirm it round-trips through
135
+ * `resolveEpicsRoot` — i.e. the path really is
136
+ * `<projectCwd>/session-manager-operations/scheduler/epics/<epicId>/prds/<slug>.md`,
137
+ * not some other `prds/` dir (e.g. the retired flat layout). Returns null for
138
+ * any path that doesn't match this shape.
139
+ */
140
+ function deriveEpicIdFromPrdPath(filePath) {
141
+ if (!filePath || typeof filePath !== 'string') return null;
142
+ const prdsDir = path.dirname(filePath);
143
+ if (path.basename(prdsDir) !== 'prds') return null;
144
+ const epicDir = path.dirname(prdsDir);
145
+ const epicId = path.basename(epicDir);
146
+ if (!epicId || epicId === '.' || epicId === path.sep) return null;
147
+ let projectCwd = path.dirname(epicDir); // epicsRoot (.../scheduler/epics)
148
+ for (let i = 0; i < EPICS_SUBPATH.length; i++) projectCwd = path.dirname(projectCwd);
149
+ let epicsRoot;
150
+ try { epicsRoot = resolveEpicsRoot(projectCwd); } catch { return null; }
151
+ if (path.join(epicsRoot, epicId, 'prds') !== prdsDir) return null;
152
+ return epicId;
153
+ }
154
+
155
+ module.exports = {
156
+ resolvePrdWriteDir,
157
+ resolvePrdsDirs,
158
+ resolveEpicsRoot,
159
+ resolveEpicPrdWriteDir,
160
+ listEpicPrdDirs,
161
+ deriveEpicIdFromPrdPath,
162
+ PRD_SUBPATH,
163
+ EPICS_SUBPATH,
164
+ };
@@ -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
  }