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
package/dist/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-BUuhV6vT.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-oMNYp73N.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
- <link rel="stylesheet" crossorigin href="./assets/index-DVlD8N1X.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-CR-WdMgm.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.39.3",
3
+ "version": "0.40.0",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -77,7 +77,7 @@ can't load skills.
77
77
  do they actually share the same shape, or only look similar — confirm by reading, don't
78
78
  assume: a wrong assumption here means an inaccurate PRD, discovered only after the executor
79
79
  runs it), check existing tests for the area, and check whether a prior PRD already touched
80
- this subsystem (`ls <cwd>/session-manager-operations/scheduler/prds/` for related slugs, in
80
+ this subsystem (`ls <cwd>/session-manager-operations/scheduler/epics/*/prds/` for related slugs, in
81
81
  the target repo) — duplicating or contradicting a still-queued PRD is a real failure mode,
82
82
  not a hypothetical one.
83
83
 
@@ -161,8 +161,9 @@ can't load skills.
161
161
  - **Evolving chain (3-5 PRDs).** Use this shape when the request is naturally a sequence of
162
162
  stages that build on each other rather than independent units — e.g. scaffold → core
163
163
  behavior → edge-case/interaction hardening → integration wiring → UI validation pass. Each
164
- PRD in the chain: gets the **next sequential `NN`** (never the same `NN` as the PRD it
165
- depends on — same-`NN` means "can run in parallel," which a chain link cannot), and its
164
+ PRD in the chain: gets its own unique `NN` (numbers are strictly unique per project —
165
+ PRD 832, user decision 2026-07-31) plus a `dependsOn: [<previous-link-slug>]`
166
+ frontmatter line expressing the chain edge explicitly, and its
166
167
  `# Implementation notes` states in one line what the *previous* link actually delivered
167
168
  (file paths/functions it added, referencing its real landed state — not the plan for it,
168
169
  since PRDs 1..k-1 may have adjusted scope during execution) and what this link is expected
@@ -184,11 +185,11 @@ can't load skills.
184
185
  (`mcp__session-manager-scheduler__scheduler_create_prd`) over hand-writing the file. Its input
185
186
  (`title`, `cwd`, `estimateMinutes`, `goal`, `acceptanceCriteria[]`, `implementationNotes`,
186
187
  `outOfScope[]`) maps directly onto the sections below — pass them straight through. It
187
- allocates the parallel-group `NN` atomically (no read-then-write race against another
188
- concurrent `/develop`/`/process-feedback` invocation), derives and collision-checks the slug,
189
- and embeds the standards pointer for you. Set `parallelGroup` explicitly only when this PRD
190
- must share an existing sibling's `NN` (a logically independent PRD that can run in parallel
191
- with one already queued); omit it to get the next free `NN` atomically.
188
+ allocates a strictly-unique `NN` atomically (no read-then-write race against another
189
+ concurrent `/develop`/`/process-feedback` invocation, never reused across the project — PRD
190
+ 832), derives and collision-checks the slug, and embeds the standards pointer for you.
191
+ `parallelGroup` is DEPRECATED and ignored — express ordering with the `dependsOn` input
192
+ (slugs that must complete first); independent PRDs simply omit it and may run in parallel.
192
193
 
193
194
  **Fallback — only when the tool errors with "app not running" / admin API unreachable**
194
195
  (the session-manager Electron app must be running for this MCP tool to work; if it isn't,
@@ -197,12 +198,12 @@ can't load skills.
197
198
  and collides). PRDs are stored per-project, so `NN` allocation for a given PRD only needs
198
199
  *that project's own* prds directory scanned — not every project's:
199
200
  ```bash
200
- ls <cwd>/session-manager-operations/scheduler/prds/ | grep -oE '^[0-9]+' | sort -n | uniq | tail -5
201
+ ls <cwd>/session-manager-operations/scheduler/epics/*/prds/ <cwd>/session-manager-operations/scheduler/prds-archived/ 2>/dev/null | grep -oE '^[0-9]+' | sort -n | uniq | tail -5
201
202
  ```
202
203
  (`<cwd>` is the target repo's absolute path — the same one this PRD's `cwd` field will use.)
203
- The last line is the current max within that project. Then: same `NN` as a logically
204
- independent sibling that can run in parallel; **next free `NN` = max+1** when this PRD
205
- hard-depends on prior work or is unrelated to every existing group. This manual path has a
204
+ The last line is the current max within that project. Then: **always next free `NN` =
205
+ max+1** — never reuse a sibling's number (unique-per-project rule, PRD 832); express
206
+ ordering with `dependsOn: [<slug>]` frontmatter instead. This manual path has a
206
207
  small, accepted race (two concurrent authors could compute the same "next free" `NN`) —
207
208
  cosmetic (two unrelated groups end up sharing a number) rather than destructive, and only
208
209
  reachable when the atomic tool path above isn't available. Record each cross-PRD dependency
@@ -218,23 +219,29 @@ can't load skills.
218
219
  system that runs `claude -p <prd-body> --dangerously-skip-permissions` jobs around 5-hour
219
220
  token-window resets, with auto-pause on rate-limit and auto-resume.
220
221
 
221
- **Canonical location — non-negotiable.** PRDs MUST be written to, inside the target repo:
222
+ **Canonical location — non-negotiable.** Every PRD belongs to an **Epic** (the TAB → EPIC →
223
+ PRD domain model in the project CLAUDE.md). PRDs MUST be written to, inside the target repo:
222
224
  ```
223
- <cwd>/session-manager-operations/scheduler/prds/<NN>-<kebab-slug>.md
225
+ <cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/<NN>-<kebab-slug>.md
224
226
  ```
225
- **Anywhere else doesn't get scheduled.** If you write to `data/prds/`, `docs/prds/`, or the
226
- old, now-retired global `prds/` dir under `~/.claude/session-manager/scheduled-plans/`,
227
- the scheduler will not see it and the user loses their token-budget-managed execution. PRD
228
- *source* files are per-project — each active project's own
229
- `session-manager-operations/scheduler/prds/` directory, resolved at runtime from active
230
- session cwds (`src/main/lib/prdLocations.cjs`) — but there is still exactly one **queue**
231
- across all projects (`queue.json`/`history.jsonl`/`runs/` under
232
- `~/.claude/session-manager/scheduled-plans/` remain global bookkeeping), because the 5-hour
233
- token budget is global across all of the user's Claude work.
234
-
235
- **Filename rules.** `NN` is the 2-digit zero-padded parallel group (picked per the `ls`
236
- command above — same `NN` as an independent sibling, or next free `NN` = max+1 when this PRD
237
- hard-depends on prior work). `<kebab-slug>` is a short, descriptive kebab-case identifier
227
+ Get `<epic-id>`'s prds/ directory by minting (or joining) an Epic first:
228
+ ```bash
229
+ node <session-manager-repo>/scripts/mint-epic.cjs <cwd> "<one-line goal for this work>" [feature|bug|discussion]
230
+ ```
231
+ The last stdout line is the exact directory to write into. Reuse-by-goal makes it idempotent —
232
+ all PRDs of one /develop ask share one Epic. (The MCP `scheduler_create_prd` tool does all of
233
+ this automatically; the mint step is only for the manual-write fallback.)
234
+
235
+ **Anywhere else doesn't get scheduled or gets retired.** `data/prds/`, `docs/prds/`, and the
236
+ old global `prds/` dir under `~/.claude/session-manager/scheduled-plans/` are invisible to
237
+ the scheduler; the legacy flat `session-manager-operations/scheduler/prds/` dir is RETIRED —
238
+ anything written there is auto-consolidated into `prds-archived/` at the next app boot
239
+ without being executed. PRD *source* files are per-project and per-Epic, resolved at runtime
240
+ via `src/main/lib/prdLocations.cjs`.
241
+
242
+ **Filename rules.** `NN` is the PRD's unique per-project number (always next free =
243
+ max+1 per the `ls` command above; ordering via `dependsOn` frontmatter, never via shared
244
+ numbers). `<kebab-slug>` is a short, descriptive kebab-case identifier
238
245
  (e.g. `voice-commands-send-cancel`, `ticker-velocity-mcp`), kept under 60 chars. Verify your
239
246
  chosen filename doesn't already exist before writing.
240
247
 
@@ -8,7 +8,9 @@ const os = require('node:os');
8
8
  const path = require('node:path');
9
9
 
10
10
  const { activeProjectCwds } = require('./activeSessions.cjs');
11
- const { resolvePrdWriteDir } = require('../../src/main/lib/prdLocations.cjs');
11
+ const { resolvePrdWriteDir, listEpicPrdDirs } = require('../../src/main/lib/prdLocations.cjs');
12
+ const { ensureEpic, appendPrdCreatedEvent } = require('../../src/main/lib/epicMint.cjs');
13
+ const { projectQueuePath } = require('../../src/main/lib/queueStore.cjs');
12
14
 
13
15
  // Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
14
16
  const DEFAULT_HEARTBEAT_PATH = path.join(
@@ -294,7 +296,7 @@ function hasOpenFeedback(cwd) {
294
296
  */
295
297
  function emitFeedbackPRD(cwd, {
296
298
  prdsDir,
297
- queuePath = DEFAULT_QUEUE_PATH,
299
+ queuePath,
298
300
  skillPath,
299
301
  standardsPath,
300
302
  pluginCacheRoot = PLUGIN_CACHE_ROOT,
@@ -306,8 +308,18 @@ function emitFeedbackPRD(cwd, {
306
308
  // PRD dir so the sweep writer agrees with scheduler.cjs's reader (see
307
309
  // prdLocations.cjs — PRD chain 808→811 moved PRD sources out of the legacy
308
310
  // global dir into `<cwd>/session-manager-operations/scheduler/prds`).
311
+ let epicId = null;
309
312
  if (prdsDir === undefined) {
310
- prdsDir = resolvePrdWriteDir(cwd);
313
+ // Every PRD belongs to an Epic (CLAUDE.md domain model). The sweep joins
314
+ // one standing "Inbound feedback" Epic per project (reuseByGoal) so
315
+ // recurring ticks chain into a single Epic rather than minting one each.
316
+ const epic = ensureEpic(cwd, {
317
+ goalText: 'Inbound feedback processing',
318
+ tag: 'discussion',
319
+ reuseByGoal: true,
320
+ });
321
+ epicId = epic.epicId;
322
+ prdsDir = epic.prdDir;
311
323
  }
312
324
  const skillPathExplicit = skillPath !== undefined;
313
325
  const standardsPathExplicit = standardsPath !== undefined;
@@ -319,11 +331,20 @@ function emitFeedbackPRD(cwd, {
319
331
  }
320
332
  const project = slugify(path.basename(cwd));
321
333
 
322
- // De-dup: check queue.json for pending/running feedback job for this project.
323
- let queueState = { jobs: [] };
324
- try {
325
- queueState = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
326
- } catch { /* queue.json may not exist */ }
334
+ // De-dup: check the queue for a pending/running feedback job for this
335
+ // project. Federated state (2026-07-31): the project's own shard is the
336
+ // system of record; the legacy global path is still consulted as a fallback
337
+ // during transition. An explicit queuePath override (tests) wins verbatim.
338
+ const queuePaths = queuePath !== undefined
339
+ ? [queuePath]
340
+ : [projectQueuePath(cwd), DEFAULT_QUEUE_PATH];
341
+ const queueState = { jobs: [] };
342
+ for (const qp of queuePaths) {
343
+ try {
344
+ const parsed = JSON.parse(fs.readFileSync(qp, 'utf8'));
345
+ queueState.jobs.push(...(Array.isArray(parsed.jobs) ? parsed.jobs : []));
346
+ } catch { /* file may not exist */ }
347
+ }
327
348
 
328
349
  // project only contains [a-z0-9-] after slugify — no regex metachar escaping needed.
329
350
  const dupRe = new RegExp(`^\\d+-feedback-${project}$`);
@@ -341,18 +362,28 @@ function emitFeedbackPRD(cwd, {
341
362
  // watchdog tick emits a fresh NN-feedback-<project>.md, unbounded.
342
363
  const prdDupRe = new RegExp(`^\\d+-feedback-${project}\\.md$`);
343
364
  let maxNN = 0;
344
- let prdFiles = [];
345
- try {
346
- prdFiles = fs.readdirSync(prdsDir);
347
- } catch { /* prdsDir may not exist yet */ }
348
- for (const f of prdFiles) {
349
- if (prdDupRe.test(f)) {
350
- return { emitted: false, reason: 'duplicate' };
351
- }
352
- const m = f.match(/^(\d+)-/);
353
- if (m) {
354
- const n = parseInt(m[1], 10);
355
- if (n > maxNN) maxNN = n;
365
+ // NN numbering and the on-disk dedup are project-global, so scan every PRD
366
+ // location the project has: the write dir, the legacy flat dir, and every
367
+ // Epic's prds/ dir. (An explicit prdsDir override — tests — scans only it.)
368
+ const scanDirs = new Set([prdsDir]);
369
+ if (epicId !== null) {
370
+ try { scanDirs.add(resolvePrdWriteDir(cwd)); } catch { /* unusable cwd */ }
371
+ for (const d of listEpicPrdDirs(cwd)) scanDirs.add(d);
372
+ }
373
+ for (const dir of scanDirs) {
374
+ let prdFiles = [];
375
+ try {
376
+ prdFiles = fs.readdirSync(dir);
377
+ } catch { continue; /* dir may not exist yet */ }
378
+ for (const f of prdFiles) {
379
+ if (prdDupRe.test(f)) {
380
+ return { emitted: false, reason: 'duplicate' };
381
+ }
382
+ const m = f.match(/^(\d+)-/);
383
+ if (m) {
384
+ const n = parseInt(m[1], 10);
385
+ if (n > maxNN) maxNN = n;
386
+ }
356
387
  }
357
388
  }
358
389
  const nn = String(maxNN + 1).padStart(2, '0');
@@ -440,6 +471,11 @@ function emitFeedbackPRD(cwd, {
440
471
  fs.mkdirSync(prdsDir, { recursive: true });
441
472
  fs.writeFileSync(tmpPath, body + '\n', 'utf8');
442
473
  fs.renameSync(tmpPath, prdPath);
474
+ if (epicId !== null) {
475
+ // Trace the dispatch on the Epic's event chain (prompt → prd_created →
476
+ // ...) so the Epic conversation shows the PRD it spawned.
477
+ try { appendPrdCreatedEvent(cwd, epicId, slug, `Feedback sweep emitted ${slug}`); } catch { /* trace is best-effort */ }
478
+ }
443
479
  } catch (e) {
444
480
  try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
445
481
  throw e;
@@ -622,7 +658,7 @@ function maybeRelaunchApp({
622
658
  function sweep({
623
659
  projectsDir,
624
660
  prdsDir,
625
- queuePath = DEFAULT_QUEUE_PATH,
661
+ queuePath,
626
662
  skillPath,
627
663
  standardsPath,
628
664
  } = {}) {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * health-prd-migration.test.cjs — stranded-PRD-migration health signal
3
+ * (behind two real 2026-07-31 incidents: a single stranded PRD causing an
4
+ * instant ENOENT job failure, and 223 stranded legacy PRDs + a missing
5
+ * history.jsonl resurrecting 189 completed jobs). Exercises
6
+ * evaluatePrdMigrationHealth() directly since it's pure — no need to spawn
7
+ * the full check() (which shells out to npm run typecheck) to cover this.
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/__tests__/health-prd-migration.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { test, expect } from 'vitest';
15
+ const { evaluatePrdMigrationHealth } = require('../health.cjs');
16
+
17
+ const LEGACY_DIR = '/home/user/.claude/session-manager/scheduled-plans/prds';
18
+
19
+ test('reports ok when nothing is stranded', () => {
20
+ const result = evaluatePrdMigrationHealth({ moved: 3, skipped: 0, unresolved: [] }, LEGACY_DIR);
21
+ expect(result.ok).toBe(true);
22
+ expect(result.strandedCount).toBe(0);
23
+ expect(result.legacyDir).toBe(LEGACY_DIR);
24
+ expect(result.unresolved).toBeUndefined();
25
+ });
26
+
27
+ test('reports RED with count and legacy dir path when files are stranded', () => {
28
+ const unresolved = [
29
+ { file: '01-stuck.md', reason: 'no cwd in frontmatter' },
30
+ { file: '02-stuck.md', reason: 'cwd does not exist on disk' },
31
+ ];
32
+ const result = evaluatePrdMigrationHealth({ moved: 0, skipped: 0, unresolved }, LEGACY_DIR);
33
+ expect(result.ok).toBe(false);
34
+ expect(result.strandedCount).toBe(2);
35
+ expect(result.legacyDir).toBe(LEGACY_DIR);
36
+ expect(result.unresolved).toEqual(unresolved);
37
+ });
@@ -307,7 +307,7 @@ test('POST /admin/scheduler/create-prd rejects a title containing a newline (fro
307
307
  }
308
308
  });
309
309
 
310
- test('POST /admin/scheduler/create-prd with an explicit parallelGroup+slug that already exists on disk returns 409 and does not clobber the file', async () => {
310
+ test('PRD 832: an explicit parallelGroup colliding with an existing file is ignored — a fresh unique NN is allocated and nothing is clobbered', async () => {
311
311
  const prdsDir = await mkTmpPrdsDir();
312
312
  const remote = makeFakeRemoteWithPrdsDir(prdsDir);
313
313
  const { admin, port, token } = await startWithRemote(remote);
@@ -317,17 +317,18 @@ test('POST /admin/scheduler/create-prd with an explicit parallelGroup+slug that
317
317
  method: 'POST', path: '/admin/scheduler/create-prd', token,
318
318
  body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
319
319
  });
320
- expect(res.status).toBe(409);
321
- expect(res.json.ok).toBe(false);
320
+ expect(res.status).toBe(200);
321
+ // Allocator scans past the existing 777 — unique per project, never reused.
322
+ expect(res.json.nn).toBe(778);
323
+ expect(res.json.filename).toBe('778-my-explicit-slug.md');
322
324
  const stillThere = await fsp.readFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'utf8');
323
- // existing PRD file must not be overwritten
324
325
  expect(stillThere).toBe('ORIGINAL CONTENT\n');
325
326
  } finally {
326
327
  await admin.stop();
327
328
  }
328
329
  });
329
330
 
330
- test('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup instead of deriving/allocating', async () => {
331
+ test('PRD 832: explicit parallelGroup input is deprecated and ignored — NN always comes from the allocator', async () => {
331
332
  const prdsDir = await mkTmpPrdsDir();
332
333
  const remote = makeFakeRemoteWithPrdsDir(prdsDir);
333
334
  const { admin, port, token } = await startWithRemote(remote);
@@ -337,9 +338,9 @@ test('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup i
337
338
  body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
338
339
  });
339
340
  expect(res.status).toBe(200);
340
- expect(res.json.nn).toBe(777);
341
- expect(res.json.filename).toBe('777-my-explicit-slug.md');
342
- expect(fs.existsSync(path.join(prdsDir, '777-my-explicit-slug.md'))).toBeTruthy();
341
+ expect(res.json.nn).toBe(1);
342
+ expect(res.json.filename).toBe('1-my-explicit-slug.md');
343
+ expect(fs.existsSync(path.join(prdsDir, '1-my-explicit-slug.md'))).toBeTruthy();
343
344
  } finally {
344
345
  await admin.stop();
345
346
  }
@@ -385,17 +386,55 @@ test('createPrd with a valid payload allocates a group, writes through remote.wr
385
386
  expect(written).toMatch(/# Goal/);
386
387
  });
387
388
 
388
- test('createPrd returns {ok:false, status:409} without clobbering an existing file at the same destination', async () => {
389
+ test('createPrd returns {ok:false, status:409} without clobbering when the destination already exists', async () => {
389
390
  const prdsDir = await mkTmpPrdsDir();
391
+ // The existence guard survives PRD 832: whatever NN the allocator picks,
392
+ // a pre-existing file at that exact destination must 409, never clobber.
390
393
  const remote = makeFakeRemoteWithPrdsDir(prdsDir);
391
- await fsp.writeFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'ORIGINAL CONTENT\n');
394
+ const realRead = remote.readPrd.bind(remote);
395
+ remote.readPrd = async (slug) => (slug.endsWith('my-explicit-slug') ? { ok: true, text: 'ORIGINAL' } : realRead(slug));
396
+ const writes = [];
397
+ const realWrite = remote.writePrd.bind(remote);
398
+ remote.writePrd = async (...a) => { writes.push(a[0]); return realWrite(...a); };
392
399
 
393
- const result = await createPrd(validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }), remote);
400
+ const result = await createPrd(validCreateBody({ slug: 'my-explicit-slug' }), remote);
394
401
 
395
402
  expect(result.ok).toBe(false);
396
403
  expect(result.status).toBe(409);
397
- const stillThere = await fsp.readFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'utf8');
398
- expect(stillThere).toBe('ORIGINAL CONTENT\n');
404
+ expect(writes).toEqual([]);
405
+ });
406
+
407
+ // ──────────────────────────────────────────── PRD 825: Epic PRD dir write grant + `~` cwd normalization
408
+
409
+ test('validateWrite allows writes under <root>/session-manager-operations/scheduler/epics/<id>/prds/', async () => {
410
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-scheduler-epics-'));
411
+ config.addAllowedRoot(root);
412
+ const prdsDir = path.join(root, 'session-manager-operations', 'scheduler', 'epics', 'epic-abc123', 'prds');
413
+ await fsp.mkdir(prdsDir, { recursive: true });
414
+ const filePath = path.join(prdsDir, '1-do-thing.md');
415
+
416
+ await config.writeTextAtomic(filePath, 'PRD body\n');
417
+
418
+ expect(fs.existsSync(filePath)).toBeTruthy();
419
+ expect(await fsp.readFile(filePath, 'utf8')).toBe('PRD body\n');
420
+ });
421
+
422
+ test('createPrd normalizes a `~`-prefixed cwd via expandHome before calling remote', async () => {
423
+ const receivedCwds = [];
424
+ const remote = {
425
+ async allocateParallelGroup(cwd) { receivedCwds.push(cwd); return 42; },
426
+ async readPrd(slug, cwd) { receivedCwds.push(cwd); return { ok: false }; },
427
+ async writePrd(slug, body, cwd) { receivedCwds.push(cwd); return { ok: true, bytesWritten: body.length }; },
428
+ };
429
+
430
+ const result = await createPrd(validCreateBody({ cwd: '~' }), remote);
431
+
432
+ expect(result.ok).toBe(true);
433
+ expect(receivedCwds.length).toBeGreaterThan(0);
434
+ for (const cwd of receivedCwds) {
435
+ expect(cwd.startsWith('~')).toBe(false);
436
+ expect(cwd).toBe(os.homedir());
437
+ }
399
438
  });
400
439
 
401
440
  test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the created PRD frontmatter', async () => {
@@ -405,10 +444,10 @@ test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the cr
405
444
  try {
406
445
  const res = await request(port, {
407
446
  method: 'POST', path: '/admin/scheduler/create-prd', token,
408
- body: validCreateBody({ slug: 'from-a-ticket', parallelGroup: 888, sourcePromptId: 'ticket-xyz-789' }),
447
+ body: validCreateBody({ slug: 'from-a-ticket', sourcePromptId: 'ticket-xyz-789' }),
409
448
  });
410
449
  expect(res.status).toBe(200);
411
- const written = fs.readFileSync(path.join(prdsDir, '888-from-a-ticket.md'), 'utf8');
450
+ const written = fs.readFileSync(path.join(prdsDir, `${res.json.nn}-from-a-ticket.md`), 'utf8');
412
451
  expect(written).toMatch(/sourcePromptId: ticket-xyz-789/);
413
452
  } finally {
414
453
  await admin.stop();
@@ -0,0 +1,118 @@
1
+ /**
2
+ * prdSourcePromptIdBackfill.test.cjs — PRD 830: a PRD file's location under
3
+ * `.../scheduler/epics/<epicId>/prds/<slug>.md` already IS its Epic
4
+ * membership, so ingest should stamp `sourcePromptId = <epicId>` whenever
5
+ * frontmatter doesn't supply one — instead of leaving it null forever.
6
+ *
7
+ * Covers:
8
+ * - deriveEpicIdFromPrdPath: pure path-shape extraction (prdLocations.cjs)
9
+ * - parsePrdRaw: dir-derived stamping + frontmatter-wins precedence
10
+ * - reconcileSourcePromptId: pending-only backfill (scheduler.cjs)
11
+ *
12
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdSourcePromptIdBackfill.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ import { test, expect } from 'vitest';
18
+ const fsp = require('node:fs/promises');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+
22
+ const { deriveEpicIdFromPrdPath, resolveEpicPrdWriteDir } = require('../lib/prdLocations.cjs');
23
+ const { parsePrdRaw, _resetCache } = require('../scheduler/prdParser.cjs');
24
+ const { reconcileSourcePromptId } = require('../scheduler.cjs');
25
+
26
+ async function mkProjectCwd() {
27
+ return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prd-backfill-'));
28
+ }
29
+
30
+ // ---------- deriveEpicIdFromPrdPath ----------
31
+
32
+ test('deriveEpicIdFromPrdPath extracts the epic id from a well-formed epic PRD path', async () => {
33
+ const cwd = await mkProjectCwd();
34
+ const epicId = 'some-epic-abc123';
35
+ const dir = resolveEpicPrdWriteDir(cwd, epicId);
36
+ await fsp.mkdir(dir, { recursive: true });
37
+ const filePath = path.join(dir, '900-example.md');
38
+ expect(deriveEpicIdFromPrdPath(filePath)).toBe(epicId);
39
+ });
40
+
41
+ test('deriveEpicIdFromPrdPath returns null for the retired flat prds/ layout', () => {
42
+ const filePath = '/home/user/Projects/foo/session-manager-operations/scheduler/prds/900-example.md';
43
+ expect(deriveEpicIdFromPrdPath(filePath)).toBe(null);
44
+ });
45
+
46
+ test('deriveEpicIdFromPrdPath returns null for an unrelated path', () => {
47
+ expect(deriveEpicIdFromPrdPath('/tmp/whatever/900-example.md')).toBe(null);
48
+ expect(deriveEpicIdFromPrdPath(null)).toBe(null);
49
+ });
50
+
51
+ // ---------- parsePrdRaw: dir-derived stamping + frontmatter-wins ----------
52
+
53
+ test('parsePrdRaw derives sourcePromptId from the parent epic dir when frontmatter omits it', async () => {
54
+ _resetCache();
55
+ const cwd = await mkProjectCwd();
56
+ const epicId = 'derive-me-epic-def456';
57
+ const dir = resolveEpicPrdWriteDir(cwd, epicId);
58
+ await fsp.mkdir(dir, { recursive: true });
59
+ const filePath = path.join(dir, '901-hand-authored.md');
60
+ await fsp.writeFile(filePath, [
61
+ '---',
62
+ 'title: Hand-authored, no sourcePromptId',
63
+ `cwd: ${cwd}`,
64
+ '---',
65
+ '# Goal',
66
+ '',
67
+ 'Do the thing.',
68
+ '',
69
+ ].join('\n'));
70
+
71
+ const parsed = await parsePrdRaw(filePath);
72
+ expect(parsed.sourcePromptId).toBe(epicId);
73
+ });
74
+
75
+ test('parsePrdRaw: explicit frontmatter sourcePromptId wins over the dir-derived epic id', async () => {
76
+ _resetCache();
77
+ const cwd = await mkProjectCwd();
78
+ const epicId = 'dir-epic-ghi789';
79
+ const dir = resolveEpicPrdWriteDir(cwd, epicId);
80
+ await fsp.mkdir(dir, { recursive: true });
81
+ const filePath = path.join(dir, '902-explicit.md');
82
+ await fsp.writeFile(filePath, [
83
+ '---',
84
+ 'title: Explicit sourcePromptId',
85
+ `cwd: ${cwd}`,
86
+ 'sourcePromptId: ticket-explicit-999',
87
+ '---',
88
+ '# Goal',
89
+ '',
90
+ 'Do the thing.',
91
+ '',
92
+ ].join('\n'));
93
+
94
+ const parsed = await parsePrdRaw(filePath);
95
+ expect(parsed.sourcePromptId).toBe('ticket-explicit-999');
96
+ });
97
+
98
+ // ---------- reconcileSourcePromptId: pending-only backfill ----------
99
+
100
+ test('reconcileSourcePromptId backfills a pending job whose sourcePromptId is null', () => {
101
+ const job = { slug: 'x', status: 'pending', sourcePromptId: null };
102
+ expect(reconcileSourcePromptId(job, 'epic-abc')).toBe('epic-abc');
103
+ });
104
+
105
+ test('reconcileSourcePromptId leaves a running job untouched even if its sourcePromptId is null', () => {
106
+ const job = { slug: 'x', status: 'running', sourcePromptId: null };
107
+ expect(reconcileSourcePromptId(job, 'epic-abc')).toBe(null);
108
+ });
109
+
110
+ test('reconcileSourcePromptId leaves a completed job untouched even if its sourcePromptId is null', () => {
111
+ const job = { slug: 'x', status: 'completed', sourcePromptId: null };
112
+ expect(reconcileSourcePromptId(job, 'epic-abc')).toBe(null);
113
+ });
114
+
115
+ test('reconcileSourcePromptId keeps a pending job\'s existing value if the fresh parse yields null', () => {
116
+ const job = { slug: 'x', status: 'pending', sourcePromptId: 'already-set' };
117
+ expect(reconcileSourcePromptId(job, null)).toBe('already-set');
118
+ });
@@ -146,11 +146,13 @@ test('readHistory respects limit and returns [] when file is absent', async () =
146
146
  const empty = await queueHistory.readHistory({ limit: 5 });
147
147
  expect(empty).toEqual([]);
148
148
 
149
- const entries = Array.from({ length: 8 }, (_, i) => old({ slug: `s${i}`, runId: `r${i}`, finishedAt: new Date(NOW - i * 60_000).toISOString() }));
149
+ // Realistic append order: oldest finishedAt first (append-only file grows
150
+ // forward in time), so s7 is both last-appended and newest.
151
+ const entries = Array.from({ length: 8 }, (_, i) => old({ slug: `s${i}`, runId: `r${i}`, finishedAt: new Date(NOW - (7 - i) * 60_000).toISOString() }));
150
152
  await queueHistory.appendHistory(entries);
151
153
  const read = await queueHistory.readHistory({ limit: 3 });
152
154
  expect(read.length).toBe(3);
153
- // newest-appended-first == last 3 entries reversed
155
+ // Federated readHistory (2026-07-31) orders by finishedAt newest-first.
154
156
  expect(read.map((j) => j.slug)).toEqual(['s7', 's6', 's5']);
155
157
  });
156
158
 
@@ -165,8 +167,11 @@ test('readHistory tail-reads correctly when the file is larger than one chunk',
165
167
  }));
166
168
  await queueHistory.appendHistory(bigEntries);
167
169
  const read = await queueHistory.readHistory({ limit: 5 });
168
- // appended in ascending index order; readHistory reverses to newest-appended-first
169
- expect(read.map((j) => j.slug)).toEqual(['bulk-1999', 'bulk-1998', 'bulk-1997', 'bulk-1996', 'bulk-1995']);
170
+ // The tail read only touches the file's end; whatever was read comes back
171
+ // ordered by finishedAt newest-first.
172
+ expect(read.length).toBe(5);
173
+ const t = (j) => Date.parse(j.finishedAt);
174
+ for (let i = 1; i < read.length; i++) expect(t(read[i - 1])).toBeGreaterThanOrEqual(t(read[i]));
170
175
  });
171
176
 
172
177
  // ---------- selectHistoryJobs merge (scheduler.cjs) ----------
@@ -140,3 +140,15 @@ test('autoArchiveCompleted: tolerates a missing/malformed state.jobs', async ()
140
140
  const result = await autoArchiveCompleted({}, { nowMs: NOW });
141
141
  expect(result.archived).toBe(0);
142
142
  });
143
+
144
+ // ---------- archiving never strands a runnable queue job (scheduler PRD:
145
+ // archived-prd-skip — job 822-epics-nav-rename fired ENOENT after its PRD
146
+ // was archived out from under a still-queued job) ----------
147
+
148
+ test('selectAutoArchivable never selects a slug whose job is still pending or running, so auto-archive can never leave a runnable job pointing at an archived PRD', () => {
149
+ const pending = old({ slug: '13-pending', status: 'pending', finishedAt: null });
150
+ const running = old({ slug: '14-running', status: 'running', finishedAt: null });
151
+ const completed = old({ slug: '15-done', status: 'completed' });
152
+ const slugs = selectAutoArchivable([pending, running, completed], { nowMs: NOW });
153
+ expect(slugs).toEqual(['15-done']);
154
+ });