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.
- package/dist/assets/{TiptapBody-B90xy18x.js → TiptapBody-CNr1qGoi.js} +1 -1
- package/dist/assets/{index-DVlD8N1X.css → index-CR-WdMgm.css} +1 -1
- package/dist/assets/index-oMNYp73N.js +3194 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +34 -27
- package/scripts/lib/watchdogHelpers.cjs +57 -21
- package/src/main/__tests__/health-prd-migration.test.cjs +37 -0
- package/src/main/__tests__/prdCreate.test.cjs +54 -15
- package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +118 -0
- package/src/main/__tests__/queueHistory.test.cjs +9 -4
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +12 -0
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +92 -0
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +1 -1
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +119 -0
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/config.cjs +10 -0
- package/src/main/health.cjs +46 -10
- package/src/main/index.cjs +28 -9
- package/src/main/ipcSchemas.cjs +10 -0
- package/src/main/lib/__tests__/instanceLock.test.cjs +87 -0
- package/src/main/lib/__tests__/sessionSlots.test.cjs +55 -0
- package/src/main/lib/epicMint.cjs +144 -0
- package/src/main/lib/instanceLock.cjs +100 -0
- package/src/main/lib/prdCreate.cjs +19 -6
- package/src/main/lib/prdLocations.cjs +82 -1
- package/src/main/lib/prdMigration.cjs +47 -1
- package/src/main/lib/queueHistory.cjs +85 -37
- package/src/main/lib/queueStore.cjs +299 -0
- package/src/main/lib/schedulerBatch.cjs +36 -4
- package/src/main/lib/sessionSlots.cjs +85 -0
- package/src/main/pty.cjs +9 -0
- package/src/main/queueOps.cjs +20 -1
- package/src/main/scheduler/prdParser.cjs +28 -4
- package/src/main/scheduler.cjs +333 -52
- package/src/main/templates/PRD_AUTHORING.md +9 -5
- package/src/preload/api.d.ts +18 -3
- package/src/preload/index.cjs +2 -0
- 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(
|
|
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
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
99
|
-
|
|
132
|
+
const fresh = group.filter((e) => !existingKeys.has(jobKey(e)));
|
|
133
|
+
if (fresh.length === 0) continue;
|
|
100
134
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
253
|
+
historyCacheKey = cacheKey;
|
|
206
254
|
historyCacheMap = map;
|
|
207
255
|
return map;
|
|
208
256
|
}
|