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,299 @@
1
+ /**
2
+ * queueStore.cjs — federated scheduler state (2026-07-31 domain-model
3
+ * decision: "retire the global-aware scheduler").
4
+ *
5
+ * The old system of record was one global
6
+ * `~/.claude/session-manager/scheduled-plans/queue.json`. It is retired.
7
+ * State now lives where it belongs in the TAB → EPIC → PRD hierarchy:
8
+ *
9
+ * - Per-project job rows: `<cwd>/session-manager-operations/scheduler/state/queue.json`
10
+ * ({ jobs: [...] } — only that project's jobs)
11
+ * - Per-project history: `<cwd>/session-manager-operations/scheduler/state/history.jsonl`
12
+ * (owned by queueHistory.cjs, path resolved here)
13
+ * - Machine runtime state: `~/.claude/session-manager/scheduler-machine.json`
14
+ * (config, paused/rate-limit, scheduledFor, lastRunAt — these are
15
+ * Session-Manager runtime concerns, like the sessionSlots pool, not any
16
+ * one project's data. Run logs under scheduled-plans/runs/ stay
17
+ * machine-local for the same reason: they're execution artifacts of this
18
+ * machine's runner.)
19
+ *
20
+ * scheduler.cjs's 4k lines keep operating on ONE merged in-memory state
21
+ * object (jobs across all projects + machine fields); this module is the
22
+ * read-merge / write-split shim underneath readQueue/writeQueue. Jobs are
23
+ * split by `job.cwd` (fallback: the provided defaultCwd).
24
+ *
25
+ * Plain Node (no Electron deps) so watchdog scripts can require it; atomic
26
+ * writes are tmp+rename here rather than config.cjs's writeJson because
27
+ * config.cjs requires electron/chokidar and this must load outside the app.
28
+ */
29
+ 'use strict';
30
+
31
+ const fs = require('node:fs');
32
+ const fsp = require('node:fs/promises');
33
+ const path = require('node:path');
34
+ const os = require('node:os');
35
+ const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
36
+
37
+ const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
38
+ const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
39
+ const STATE_SUBPATH = ['session-manager-operations', 'scheduler', 'state'];
40
+
41
+ function projectStateDir(cwd) {
42
+ if (!cwd || typeof cwd !== 'string') throw new Error('projectStateDir: cwd is required');
43
+ return path.join(cwd, ...STATE_SUBPATH);
44
+ }
45
+
46
+ function projectQueuePath(cwd) {
47
+ return path.join(projectStateDir(cwd), 'queue.json');
48
+ }
49
+
50
+ function projectHistoryPath(cwd) {
51
+ return path.join(projectStateDir(cwd), 'history.jsonl');
52
+ }
53
+
54
+ function writeJsonAtomicSync(file, value) {
55
+ fs.mkdirSync(path.dirname(file), { recursive: true });
56
+ const tmp = `${file}.tmp-${process.pid}`;
57
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
58
+ fs.renameSync(tmp, file);
59
+ }
60
+
61
+ async function writeJsonAtomic(file, value) {
62
+ await fsp.mkdir(path.dirname(file), { recursive: true });
63
+ const tmp = `${file}.tmp-${process.pid}`;
64
+ await fsp.writeFile(tmp, JSON.stringify(value, null, 2));
65
+ await fsp.rename(tmp, file);
66
+ }
67
+
68
+ // ---------- project-cwd enumeration (cached) ----------
69
+
70
+ // allProjectCwds scans ~/.claude/projects; on the readQueue hot path (every
71
+ // IPC status call) that's too much stat traffic, so cache the resolved cwd
72
+ // list briefly. Correctness fallback: a brand-new project appears at worst
73
+ // CACHE_MS late, and its first write goes through writeSplit which busts the
74
+ // cache.
75
+ const CACHE_MS = 30_000;
76
+ let cwdCache = { at: 0, cwds: [] };
77
+
78
+ function stateCwds(opts) {
79
+ const now = Date.now();
80
+ if (!opts && now - cwdCache.at < CACHE_MS) return cwdCache.cwds;
81
+ const seen = new Set();
82
+ const cwds = [];
83
+ const add = (cwd) => { if (cwd && !seen.has(cwd)) { seen.add(cwd); cwds.push(cwd); } };
84
+ // Projects that already have a state file are authoritative sources...
85
+ for (const cwd of allProjectCwds(opts)) {
86
+ try { if (fs.existsSync(projectQueuePath(cwd))) add(cwd); } catch { /* skip */ }
87
+ }
88
+ // ...and active projects are included even before their first write.
89
+ for (const cwd of activeProjectCwds(undefined, opts)) add(cwd);
90
+ if (!opts) cwdCache = { at: now, cwds };
91
+ return cwds;
92
+ }
93
+
94
+ function bustCwdCache() {
95
+ cwdCache = { at: 0, cwds: [] };
96
+ }
97
+
98
+ // ---------- merged read ----------
99
+
100
+ function shapeMachine(raw) {
101
+ const data = raw ? JSON.parse(raw) : {};
102
+ return {
103
+ config: data.config || {},
104
+ scheduledFor: data.scheduledFor ?? null,
105
+ lastRunAt: data.lastRunAt ?? null,
106
+ paused: data.paused ?? null,
107
+ };
108
+ }
109
+
110
+ function shapeJobs(raw) {
111
+ const data = JSON.parse(raw);
112
+ return Array.isArray(data.jobs) ? data.jobs : [];
113
+ }
114
+
115
+ /**
116
+ * readMergedSync(opts?) → { config, jobs, scheduledFor, lastRunAt, paused,
117
+ * unreadable?, unreadablePath?, sourceCwds }.
118
+ *
119
+ * `unreadable` mirrors the old single-file semantics: ANY source file that
120
+ * exists but fails to parse halts scheduling (never treat a project's queue
121
+ * as empty because it read corrupt). `sourceCwds` records every project file
122
+ * consulted so writeSplit can persist "this project now has zero jobs".
123
+ */
124
+ function readMergedSync(opts) {
125
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
126
+ const sourceCwds = [];
127
+ try {
128
+ Object.assign(out, shapeMachine(fs.readFileSync(MACHINE_STATE_PATH, 'utf8')));
129
+ } catch (e) {
130
+ if (e?.code !== 'ENOENT') {
131
+ out.unreadable = `machine state unreadable: ${e?.message}`;
132
+ out.unreadablePath = MACHINE_STATE_PATH;
133
+ }
134
+ }
135
+ for (const cwd of stateCwds(opts)) {
136
+ const file = projectQueuePath(cwd);
137
+ try {
138
+ out.jobs.push(...shapeJobs(fs.readFileSync(file, 'utf8')));
139
+ sourceCwds.push(cwd);
140
+ } catch (e) {
141
+ if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
142
+ out.unreadable = out.unreadable || `project queue unreadable (${file}): ${e?.message}`;
143
+ out.unreadablePath = out.unreadablePath || file;
144
+ }
145
+ }
146
+ defineSources(out, sourceCwds);
147
+ return out;
148
+ }
149
+
150
+ /** Async twin of readMergedSync for IPC hot paths. */
151
+ async function readMerged(opts) {
152
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
153
+ const sourceCwds = [];
154
+ try {
155
+ Object.assign(out, shapeMachine(await fsp.readFile(MACHINE_STATE_PATH, 'utf8')));
156
+ } catch (e) {
157
+ if (e?.code !== 'ENOENT') {
158
+ out.unreadable = `machine state unreadable: ${e?.message}`;
159
+ out.unreadablePath = MACHINE_STATE_PATH;
160
+ }
161
+ }
162
+ for (const cwd of stateCwds(opts)) {
163
+ const file = projectQueuePath(cwd);
164
+ try {
165
+ out.jobs.push(...shapeJobs(await fsp.readFile(file, 'utf8')));
166
+ sourceCwds.push(cwd);
167
+ } catch (e) {
168
+ if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
169
+ out.unreadable = out.unreadable || `project queue unreadable (${file}): ${e?.message}`;
170
+ out.unreadablePath = out.unreadablePath || file;
171
+ }
172
+ }
173
+ defineSources(out, sourceCwds);
174
+ return out;
175
+ }
176
+
177
+ // Non-enumerable so broadcast/JSON payloads of the state never carry it.
178
+ function defineSources(state, sourceCwds) {
179
+ Object.defineProperty(state, 'sourceCwds', {
180
+ value: sourceCwds, enumerable: false, configurable: true, writable: true,
181
+ });
182
+ }
183
+
184
+ // ---------- split write ----------
185
+
186
+ /**
187
+ * writeSplit(state, defaultCwd) — persist a merged state back to its shards:
188
+ * machine fields → MACHINE_STATE_PATH; jobs grouped by job.cwd (fallback
189
+ * defaultCwd) → each project's state/queue.json. Every cwd the read consulted
190
+ * (state.sourceCwds) is written even when it now holds zero jobs, so
191
+ * deletions stick.
192
+ */
193
+ async function writeSplit(state, defaultCwd) {
194
+ await writeJsonAtomic(MACHINE_STATE_PATH, {
195
+ config: state.config,
196
+ scheduledFor: state.scheduledFor ?? null,
197
+ lastRunAt: state.lastRunAt ?? null,
198
+ paused: state.paused ?? null,
199
+ });
200
+
201
+ const byCwd = new Map();
202
+ for (const cwd of state.sourceCwds ?? []) byCwd.set(cwd, []);
203
+ for (const job of state.jobs ?? []) {
204
+ const cwd = job.cwd || defaultCwd;
205
+ if (!cwd) continue; // nowhere to put it; job is dropped from persistence rather than crashing
206
+ if (!byCwd.has(cwd)) byCwd.set(cwd, []);
207
+ byCwd.get(cwd).push(job);
208
+ }
209
+ for (const [cwd, jobs] of byCwd) {
210
+ try {
211
+ await writeJsonAtomic(projectQueuePath(cwd), { jobs });
212
+ } catch (e) {
213
+ // A single unwritable project (deleted repo dir, permissions) must not
214
+ // lose every other project's write.
215
+ console.error(`[queueStore] failed to write ${projectQueuePath(cwd)}: ${e?.message}`);
216
+ }
217
+ }
218
+ bustCwdCache();
219
+ }
220
+
221
+ // ---------- legacy migration ----------
222
+
223
+ /**
224
+ * migrateLegacyGlobalQueue(defaultCwd) — one-time boot split of the retired
225
+ * global queue.json into per-project shards. Shard rows win over legacy rows
226
+ * with the same slug (the shard is newer by construction). The legacy file is
227
+ * renamed to `queue.json.retired-<epoch>` so a rollback can recover it but no
228
+ * reader ever consults it again. Machine fields (config/paused/...) migrate
229
+ * only when no machine file exists yet. Idempotent: no legacy file → no-op.
230
+ */
231
+ async function migrateLegacyGlobalQueue(defaultCwd) {
232
+ let raw;
233
+ try {
234
+ raw = await fsp.readFile(LEGACY_QUEUE_PATH, 'utf8');
235
+ } catch {
236
+ return { migrated: false };
237
+ }
238
+ let legacy;
239
+ try {
240
+ legacy = JSON.parse(raw);
241
+ } catch (e) {
242
+ console.error(`[queueStore] legacy queue.json unparseable — leaving in place: ${e?.message}`);
243
+ return { migrated: false, error: e?.message };
244
+ }
245
+
246
+ if (!fs.existsSync(MACHINE_STATE_PATH)) {
247
+ await writeJsonAtomic(MACHINE_STATE_PATH, {
248
+ config: legacy.config || {},
249
+ scheduledFor: legacy.scheduledFor ?? null,
250
+ lastRunAt: legacy.lastRunAt ?? null,
251
+ paused: legacy.paused ?? null,
252
+ });
253
+ }
254
+
255
+ const legacyJobs = Array.isArray(legacy.jobs) ? legacy.jobs : [];
256
+ const byCwd = new Map();
257
+ for (const job of legacyJobs) {
258
+ const cwd = job.cwd || defaultCwd;
259
+ if (!cwd) continue;
260
+ if (!byCwd.has(cwd)) byCwd.set(cwd, []);
261
+ byCwd.get(cwd).push(job);
262
+ }
263
+ let moved = 0;
264
+ for (const [cwd, jobs] of byCwd) {
265
+ const file = projectQueuePath(cwd);
266
+ let existing = [];
267
+ try { existing = shapeJobs(await fsp.readFile(file, 'utf8')); } catch { /* fresh shard */ }
268
+ const have = new Set(existing.map((j) => j.slug));
269
+ const merged = [...existing, ...jobs.filter((j) => !have.has(j.slug))];
270
+ try {
271
+ await writeJsonAtomic(file, { jobs: merged });
272
+ moved += merged.length - existing.length;
273
+ } catch (e) {
274
+ console.error(`[queueStore] legacy split: failed to write ${file}: ${e?.message}`);
275
+ return { migrated: false, error: e?.message };
276
+ }
277
+ }
278
+
279
+ await fsp.rename(LEGACY_QUEUE_PATH, `${LEGACY_QUEUE_PATH}.retired-${Date.now()}`);
280
+ bustCwdCache();
281
+ return { migrated: true, moved, projects: byCwd.size };
282
+ }
283
+
284
+ module.exports = {
285
+ MACHINE_STATE_PATH,
286
+ LEGACY_QUEUE_PATH,
287
+ STATE_SUBPATH,
288
+ projectStateDir,
289
+ projectQueuePath,
290
+ projectHistoryPath,
291
+ stateCwds,
292
+ bustCwdCache,
293
+ readMerged,
294
+ readMergedSync,
295
+ writeSplit,
296
+ migrateLegacyGlobalQueue,
297
+ writeJsonAtomic,
298
+ writeJsonAtomicSync,
299
+ };
@@ -38,12 +38,44 @@ const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager
38
38
  * human-readable reason text that would otherwise only reach console.log.
39
39
  */
40
40
  function pickForProject(projectJobs, runningSlugsInProject, slots) {
41
- const pending = projectJobs.filter(
41
+ const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
42
+
43
+ // Explicit dependsOn eligibility (PRD 832). A dep slug is BLOCKING while a
44
+ // queue row for it exists in a non-completed state; a slug with no row is
45
+ // treated as already done (completed rows are retired to history shards,
46
+ // so absence is the normal end-state of a finished dep). A FAILED dep
47
+ // holds the dependent with an explicit reason, mirroring the failure gate.
48
+ // Legacy jobs without dependsOn keep the shared-NN group semantics below
49
+ // unchanged (lowest-number-first waves), so an in-flight mixed queue keeps
50
+ // its order without migration.
51
+ const rowBySlug = new Map(projectJobs.map((j) => [j.slug, j]));
52
+ const blockingDep = (j) => (j.dependsOn ?? []).find((slug) => {
53
+ const dep = rowBySlug.get(slug);
54
+ return dep && dep.status !== 'completed';
55
+ });
56
+
57
+ const allPending = projectJobs.filter(
42
58
  (j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
43
59
  );
44
- if (pending.length === 0) return { batch: [], reason: null };
45
-
46
- const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
60
+ if (allPending.length === 0) return { batch: [], reason: null };
61
+
62
+ const pending = [];
63
+ const heldByFailedDep = [];
64
+ for (const j of allPending) {
65
+ const dep = blockingDep(j);
66
+ if (!dep) { pending.push(j); continue; }
67
+ if (rowBySlug.get(dep)?.status === 'failed') heldByFailedDep.push({ job: j, dep });
68
+ // running/pending/needs_review dep — simply not eligible this tick.
69
+ }
70
+ if (pending.length === 0) {
71
+ if (heldByFailedDep.length > 0) {
72
+ const detail = heldByFailedDep.map(({ job, dep }) => `${job.slug} <- ${dep}`).join(', ');
73
+ const reason = `[scheduler] depends-gate [${projectCwd}]: holding ${heldByFailedDep.length} job(s) behind failed dependencies [${detail}]. Reset or archive the dep to unblock.`;
74
+ console.log(reason);
75
+ return { batch: [], reason };
76
+ }
77
+ return { batch: [], reason: null };
78
+ }
47
79
 
48
80
  // Lowest pending group (computed up-front for the failure-gate check).
49
81
  const lowestPendingGroup = pending.reduce(
@@ -0,0 +1,85 @@
1
+ /**
2
+ * sessionSlots.cjs — the Session-Manager-owned machine-wide `claude -p`
3
+ * concurrency pool (2026-07-31 domain-model decision).
4
+ *
5
+ * Caps and limits belong to Session-Manager, not to any one consumer: the
6
+ * scheduler and chatRunner previously each enforced a private cap (3 and 2),
7
+ * which combined could exceed the machine's real budget — the exact shape of
8
+ * the 2026-06-10 five-parallel-`claude -p` OOM. Now every subsystem that
9
+ * wants to launch a `claude -p` process REQUESTS a slot here first and
10
+ * 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]).
13
+ *
14
+ * Consumers keep their own scheduling policy (FIFO lanes, batch picking,
15
+ * 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
+ */
20
+ 'use strict';
21
+
22
+ const crypto = require('node:crypto');
23
+
24
+ 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));
27
+ }
28
+
29
+ // token → { owner, at }
30
+ const holders = new Map();
31
+
32
+ function inUse() {
33
+ return holders.size;
34
+ }
35
+
36
+ function available() {
37
+ return Math.max(0, totalSlots() - holders.size);
38
+ }
39
+
40
+ /**
41
+ * acquire(owner) → token string, or null when the pool is exhausted.
42
+ * `owner` is a diagnostic label ("scheduler:<slug>", "chat:<tabId>") shown in
43
+ * snapshot() so a stuck holder is attributable.
44
+ */
45
+ function acquire(owner) {
46
+ if (holders.size >= totalSlots()) return null;
47
+ const token = crypto.randomUUID();
48
+ holders.set(token, { owner: String(owner || 'unknown'), at: new Date().toISOString() });
49
+ return token;
50
+ }
51
+
52
+ // Release listeners: each consumer registers its own "a slot freed — try to
53
+ // start work" pump so a scheduler release wakes the chat lane and vice versa.
54
+ const listeners = new Set();
55
+ function subscribe(fn) {
56
+ listeners.add(fn);
57
+ return () => listeners.delete(fn);
58
+ }
59
+
60
+ /** release(token) — idempotent; releasing an unknown/already-released token is a no-op. */
61
+ function release(token) {
62
+ const had = holders.delete(token);
63
+ if (had) {
64
+ for (const fn of listeners) {
65
+ try { fn(); } catch { /* a consumer's pump error is its own problem */ }
66
+ }
67
+ }
68
+ return had;
69
+ }
70
+
71
+ /** Diagnostic view for status surfaces and tests. */
72
+ function snapshot() {
73
+ return {
74
+ total: totalSlots(),
75
+ inUse: holders.size,
76
+ holders: [...holders.values()],
77
+ };
78
+ }
79
+
80
+ /** Test hook: drop all held slots. */
81
+ function __resetForTests() {
82
+ holders.clear();
83
+ }
84
+
85
+ module.exports = { totalSlots, inUse, available, acquire, release, subscribe, snapshot, __resetForTests };
package/src/main/pty.cjs CHANGED
@@ -231,6 +231,14 @@ class PtyManager {
231
231
  killAll() {
232
232
  for (const tabId of [...this.sessions.keys()]) this.kill(tabId);
233
233
  }
234
+
235
+ /** Subset of `ids` that currently have a live PTY in the sessions map.
236
+ * Lets the Epics workspace reconcile Terminal-mode attachment after a
237
+ * renderer reload (the PTY survives; the renderer's in-memory attachment
238
+ * record does not — PRD 833 C1). */
239
+ aliveOf(ids) {
240
+ return ids.filter((id) => this.sessions.has(id));
241
+ }
234
242
  }
235
243
 
236
244
  const manager = new PtyManager();
@@ -244,6 +252,7 @@ function registerPtyHandlers() {
244
252
  if (typeof tabId !== 'string') return;
245
253
  manager.kill(tabId);
246
254
  });
255
+ ipcMain.handle('pty:alive', v(s.ptyAlive, ({ tabIds }) => manager.aliveOf(tabIds)));
247
256
  }
248
257
 
249
258
  module.exports = { manager, registerPtyHandlers };
@@ -307,6 +307,16 @@ async function archiveOne(slug, archiveDir) {
307
307
  }
308
308
  }
309
309
 
310
+ /**
311
+ * Injected by index.cjs at registration time (see registerQueueOpsHandlers)
312
+ * so a manual archive can retire any still-runnable queue job for the same
313
+ * slug without queueOps.cjs importing scheduler.cjs (circular — scheduler.cjs
314
+ * already requires queueOps.cjs). No-op until set; the auto-archive path
315
+ * never needs it (selectAutoArchivable only ever selects already-completed
316
+ * jobs).
317
+ */
318
+ let retireCompletedSlugsFn = async () => {};
319
+
310
320
  async function archiveMany(slugs) {
311
321
  if (!Array.isArray(slugs) || slugs.length === 0) {
312
322
  return { ok: true, archived: 0, archivedTo: null, results: [] };
@@ -324,6 +334,12 @@ async function archiveMany(slugs) {
324
334
  results.push(await archiveOne(slug, archiveDir));
325
335
  }
326
336
  const archived = results.filter((r) => r.ok).length;
337
+ const archivedSlugs = results.filter((r) => r.ok).map((r) => r.slug);
338
+ if (archivedSlugs.length > 0) {
339
+ await retireCompletedSlugsFn(archivedSlugs).catch((e) => {
340
+ logs.writeLine({ level: 'warn', scope: 'queueOps', message: 'archiveMany: retireCompletedSlugs failed', meta: { error: e?.message } });
341
+ });
342
+ }
327
343
  return { ok: true, archived, archivedTo: archiveDir, results };
328
344
  }
329
345
 
@@ -547,7 +563,10 @@ async function retagMany(items) {
547
563
 
548
564
  // ────────────────────────────────────────────── IPC registration
549
565
 
550
- function registerQueueOpsHandlers() {
566
+ function registerQueueOpsHandlers({ retireCompletedSlugs } = {}) {
567
+ if (typeof retireCompletedSlugs === 'function') {
568
+ retireCompletedSlugsFn = retireCompletedSlugs;
569
+ }
551
570
  ipcMain.handle('schedule:lint-queue', async () => {
552
571
  return lintAll();
553
572
  });
@@ -18,6 +18,7 @@ const fsp = require('node:fs/promises');
18
18
  const os = require('node:os');
19
19
  const path = require('node:path');
20
20
  const { splitFrontmatter } = require('../lib/prdFrontmatter.cjs');
21
+ const { deriveEpicIdFromPrdPath } = require('../lib/prdLocations.cjs');
21
22
 
22
23
  /**
23
24
  * Expand a PRD `cwd` value to an absolute path.
@@ -66,17 +67,37 @@ async function parsePrdRaw(filePath) {
66
67
  parallelGroup: (fm.parallelGroup ? Number(fm.parallelGroup) || null : null) ?? groupFromName ?? 99,
67
68
  // Optional traceability back to the PromptTicket.id (PRD 748) that was
68
69
  // classified 'develop' and spawned this PRD (PRD 749). Additive — absent
69
- // on every PRD authored before this field existed.
70
- sourcePromptId: fm.sourcePromptId || null,
70
+ // on every PRD authored before this field existed. When frontmatter
71
+ // omits it, fall back to the owning Epic dir name (PRD 830) — a PRD's
72
+ // file location already IS its Epic membership, so hand-authored PRDs
73
+ // dropped straight into an Epic's prds/ dir still get real linkage
74
+ // instead of null.
75
+ sourcePromptId: fm.sourcePromptId || deriveEpicIdFromPrdPath(filePath) || null,
71
76
  // Optional traceability back to the chat tab that queued this PRD (PRD
72
77
  // 761) — read back at job completion to route a status prompt via
73
78
  // enqueueExternalPrompt (PRD 753). Additive — absent on every PRD
74
79
  // authored before this field existed.
75
80
  sourceTabId: fm.sourceTabId || null,
81
+ // Explicit cross-PRD ordering (PRD 832): `dependsOn: [<slug>, <slug>]`
82
+ // or a comma-separated string. Replaces the retired shared-NN-means-
83
+ // parallel convention — a job is eligible only once every listed slug's
84
+ // queue row is completed (a slug with no row is treated as already
85
+ // done/archived, matching retireCompletedSlugs semantics).
86
+ dependsOn: parseDependsOn(fm.dependsOn),
76
87
  body: body.trim(),
77
88
  };
78
89
  }
79
90
 
91
+ /** `[a, b]` / `a, b` / `a` → ['a','b']; anything else → []. */
92
+ function parseDependsOn(raw) {
93
+ if (!raw || typeof raw !== 'string') return [];
94
+ const inner = raw.trim().replace(/^\[/, '').replace(/\]$/, '');
95
+ return inner
96
+ .split(',')
97
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
98
+ .filter((s) => /^[A-Za-z0-9][\w.-]*$/.test(s));
99
+ }
100
+
80
101
  /**
81
102
  * List `.md` PRD files under the given dir. Caches the result keyed by the
82
103
  * directory mtime so repeated reconcile() calls don't re-stat every entry.
@@ -257,11 +278,14 @@ async function maxParallelGroupInUse(prdsDir) {
257
278
  * number) and never wedges future callers, since each attempt only needs
258
279
  * the marker for its OWN candidate to not already exist.
259
280
  */
260
- async function allocateParallelGroup(prdsDir) {
281
+ async function allocateParallelGroup(prdsDir, { extraFloor = 0 } = {}) {
261
282
  await fsp.mkdir(prdsDir, { recursive: true });
262
283
  const scanMax = await maxParallelGroupInUse(prdsDir);
263
284
  const highWater = await readHighWaterMark(prdsDir);
264
- const floor = Math.max(scanMax, highWater);
285
+ // extraFloor (PRD 832): the caller's max across OTHER dirs sharing the
286
+ // project's number space (epic prds/ dirs, prds-archived) — numbers are
287
+ // unique per project, never merely per directory.
288
+ const floor = Math.max(scanMax, highWater, extraFloor);
265
289
  let candidate = floor + 1;
266
290
  for (let attempt = 0; attempt < MAX_RESERVE_ATTEMPTS; attempt += 1) {
267
291
  const markerPath = path.join(prdsDir, `.reserved-${candidate}`);