claude-code-session-manager 0.38.7 → 0.39.1

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.
@@ -83,6 +83,8 @@ const queueOps = require('./queueOps.cjs');
83
83
  // match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
84
84
  // home-dir layout.
85
85
  const { sweep: sweepFeedback } = require('../../scripts/lib/watchdogHelpers.cjs');
86
+ const { resolvePrdsDirs, resolvePrdWriteDir } = require('./lib/prdLocations.cjs');
87
+ const { migratePrds } = require('./lib/prdMigration.cjs');
86
88
 
87
89
  const MAX_INVESTIGATION_DURATION_MS = 30 * 60_000;
88
90
 
@@ -407,19 +409,73 @@ function biasJobOomScore(pid) {
407
409
 
408
410
  // ---------- fs helpers ----------
409
411
 
412
+ // ---------- per-project PRD dir resolution (PRD 808) ----------
413
+ //
414
+ // PRDS_DIR above is now the LEGACY global dir — kept only as a migration
415
+ // source and a search fallback. Live PRDs resolve per-job to
416
+ // `<job.cwd>/session-manager-operations/scheduler/prds/` via prdLocations.cjs.
417
+
418
+ /**
419
+ * Every PRD-source directory currently in play: the legacy global dir (still
420
+ * searched so a not-yet-migrated file is never invisible) plus every active
421
+ * project's own PRDs dir. Used by scans that enumerate PRDs across projects
422
+ * (reconcile, list-prds, lint, rescan).
423
+ */
424
+ function candidatePrdsDirs() {
425
+ return [PRDS_DIR, ...resolvePrdsDirs()];
426
+ }
427
+
428
+ /** The PRD-source directory for a given job cwd (falls back to DEFAULT_PROJECT_CWD). */
429
+ function prdDirForCwd(cwd) {
430
+ return resolvePrdWriteDir(cwd || DEFAULT_PROJECT_CWD);
431
+ }
432
+
433
+ /** Absolute path to `<job's project PRDs dir>/<job.slug>.md`. */
434
+ function prdPathForJob(job) {
435
+ return path.join(prdDirForCwd(job && job.cwd), `${job && job.slug}.md`);
436
+ }
437
+
438
+ /**
439
+ * Search every candidate PRD dir for `<slug>.md` (legacy dir first, then
440
+ * each active project's dir). Returns the containing dir, or null if the
441
+ * slug isn't found anywhere. Used by slug-only callers (IPC handlers) that
442
+ * don't have a job's cwd on hand.
443
+ */
444
+ async function findPrdDir(slug) {
445
+ for (const dir of candidatePrdsDirs()) {
446
+ try {
447
+ await fsp.access(path.join(dir, `${slug}.md`));
448
+ return dir;
449
+ } catch { /* not here — try the next candidate dir */ }
450
+ }
451
+ return null;
452
+ }
453
+
410
454
  /**
411
- * Resolve PRDS_DIR/<slug>.md and enforce path containment. Returns the
455
+ * Resolve `<dir>/<slug>.md` for a directory already known to contain (or be
456
+ * about to receive) the slug, and enforce path containment. Returns the
412
457
  * absolute path on success, null on slug-escape attempts. The zod schema
413
458
  * for slugs already blocks `..` because the SLUG_RE excludes `/`, but
414
459
  * defense-in-depth: a second containment check after path.resolve costs
415
460
  * nothing and catches future regex laxity.
416
461
  */
417
- function safeSlugPath(slug) {
418
- const resolved = path.resolve(path.join(PRDS_DIR, `${slug}.md`));
419
- if (!resolved.startsWith(PRDS_DIR + path.sep)) return null;
462
+ function safeSlugPathIn(dir, slug) {
463
+ const resolved = path.resolve(path.join(dir, `${slug}.md`));
464
+ if (!resolved.startsWith(dir + path.sep)) return null;
420
465
  return resolved;
421
466
  }
422
467
 
468
+ /**
469
+ * Locate an EXISTING `<slug>.md` across every candidate PRD dir and return
470
+ * its safe, containment-checked absolute path — or null if the slug isn't
471
+ * found anywhere (or would escape its containing dir).
472
+ */
473
+ async function safeSlugPath(slug) {
474
+ const dir = await findPrdDir(slug);
475
+ if (!dir) return null;
476
+ return safeSlugPathIn(dir, slug);
477
+ }
478
+
423
479
  // Bundled authoring guide seeded into the scheduler dir so the session-manager-dev
424
480
  // plugin's /develop and /prd skills — which reference this stable `~`-absolute
425
481
  // path — work on any user's machine, not just the author's.
@@ -437,6 +493,37 @@ function ensureDirs() {
437
493
  } catch { /* non-fatal: the guide is a convenience, not load-bearing for a run */ }
438
494
  }
439
495
 
496
+ /**
497
+ * One-time, idempotent migration (PRD 808): move every PRD source .md file
498
+ * still sitting in the legacy global PRDS_DIR into its own project's
499
+ * `<cwd>/session-manager-operations/scheduler/prds/`, based on that PRD's
500
+ * own frontmatter `cwd`. Runs at every boot — a no-op fs.readdir once
501
+ * nothing is left to move. Files migratePrds couldn't resolve (missing/
502
+ * unparseable cwd, cwd not on disk) are left in place and logged as a
503
+ * warning — never silently dropped — so a human can fix the frontmatter.
504
+ */
505
+ async function runPrdMigration() {
506
+ let result;
507
+ try {
508
+ result = await migratePrds(PRDS_DIR);
509
+ } catch (e) {
510
+ logs.writeLine({ level: 'error', scope: 'scheduler', message: 'PRD migration failed', meta: { error: e?.message } });
511
+ return;
512
+ }
513
+ console.log(`[scheduler] PRD migration: moved ${result.moved}, skipped ${result.skipped}`);
514
+ if (result.unresolved.length > 0) {
515
+ logs.writeLine({
516
+ level: 'warn',
517
+ scope: 'scheduler',
518
+ message: `PRD migration: ${result.unresolved.length} file(s) left in legacy dir`,
519
+ meta: { legacyDir: PRDS_DIR, unresolved: result.unresolved },
520
+ });
521
+ for (const u of result.unresolved) {
522
+ console.warn(`[scheduler] PRD migration: left ${u.file} in legacy dir (${u.reason})`);
523
+ }
524
+ }
525
+ }
526
+
440
527
  // Matches only numbered timestamp backups (queue.json.bak-<epoch>), not the
441
528
  // bare `queue.json.bak` single-shot copy some older code paths left behind.
442
529
  const QUEUE_BAK_RE = /^queue\.json\.bak-\d+$/;
@@ -611,20 +698,31 @@ function mutate(fn) {
611
698
  // preserve the existing call shape (callers don't need to thread PRDS_DIR).
612
699
  const parsePrdRaw = prdParser.parsePrdRaw;
613
700
  const parsePrd = prdParser.parsePrd;
701
+ // Aggregates every candidate PRD dir's files into one flat, sorted list.
702
+ // prdParser's dir-mtime cache is keyed per-dir internally by its own single
703
+ // mtime var, so scanning N dirs here costs N stat+readdir passes rather than
704
+ // one — acceptable: PRD counts per project are bounded (hundreds, not
705
+ // millions), and correctness across multiple project dirs matters more than
706
+ // preserving the single-dir cache's steady-state zero-read fast path.
614
707
  async function listPrdFiles() {
615
708
  ensureDirs();
616
- return prdParser.listPrdFiles(PRDS_DIR);
709
+ const dirs = candidatePrdsDirs();
710
+ const perDir = await Promise.all(dirs.map((dir) => prdParser.listPrdFiles(dir)));
711
+ return perDir.flat().sort();
617
712
  }
618
713
 
619
714
  /**
620
715
  * Atomically mint a brand-new parallel-group NN for a PRD about to be
621
- * authored under PRDS_DIR. See prdParser.allocateParallelGroup for the
622
- * collision-proof mechanics. Callers wanting to join an EXISTING group
623
- * (deliberate parallel siblings) skip this and just reuse the NN prefix.
716
+ * authored under the given project's own PRDs dir (falls back to
717
+ * DEFAULT_PROJECT_CWD's dir when no cwd is supplied). See
718
+ * prdParser.allocateParallelGroup for the collision-proof mechanics. Callers
719
+ * wanting to join an EXISTING group (deliberate parallel siblings) skip this
720
+ * and just reuse the NN prefix.
624
721
  */
625
- async function allocateParallelGroup() {
626
- ensureDirs();
627
- return prdParser.allocateParallelGroup(PRDS_DIR);
722
+ async function allocateParallelGroup(cwd) {
723
+ const dir = prdDirForCwd(cwd);
724
+ await fsp.mkdir(dir, { recursive: true });
725
+ return prdParser.allocateParallelGroup(dir);
628
726
  }
629
727
 
630
728
  /**
@@ -739,6 +837,7 @@ async function reconcile(state) {
739
837
  parallelGroup: p.parallelGroup,
740
838
  estimateMinutes: p.estimateMinutes,
741
839
  sourcePromptId: p.sourcePromptId,
840
+ sourceTabId: p.sourceTabId,
742
841
  bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
743
842
  });
744
843
  }
@@ -784,6 +883,7 @@ async function reconcile(state) {
784
883
  parallelGroup: p.parallelGroup,
785
884
  estimateMinutes: p.estimateMinutes,
786
885
  sourcePromptId: p.sourcePromptId,
886
+ sourceTabId: p.sourceTabId,
787
887
  bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
788
888
  status: 'pending',
789
889
  runId: null,
@@ -1241,7 +1341,7 @@ async function notifyOriginatingTab(job, {
1241
1341
  sendPrompt = enqueueExternalPrompt,
1242
1342
  } = {}) {
1243
1343
  try {
1244
- const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
1344
+ const prdPath = prdPathForJob(job);
1245
1345
  const prd = await parsePrdRaw(prdPath).catch(() => null);
1246
1346
 
1247
1347
  let targetTabId = prd?.sourceTabId || null;
@@ -1390,7 +1490,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
1390
1490
 
1391
1491
  // Read full PRD body fresh from disk (queue stored only the preview).
1392
1492
  let prompt;
1393
- const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
1493
+ const prdPath = prdPathForJob(job);
1394
1494
  try {
1395
1495
  const parsed = await parsePrd(prdPath);
1396
1496
  // Centrally enforce the review → security-review → verify → commit finish
@@ -1757,7 +1857,7 @@ async function spawnInvestigation(failedJob, runDir) {
1757
1857
 
1758
1858
  let originalBody = '';
1759
1859
  try {
1760
- originalBody = (await parsePrd(path.join(PRDS_DIR, `${failedJob.slug}.md`))).body;
1860
+ originalBody = (await parsePrd(prdPathForJob(failedJob))).body;
1761
1861
  } catch {
1762
1862
  originalBody = failedJob.bodyPreview || '(original PRD missing from disk)';
1763
1863
  }
@@ -1767,7 +1867,7 @@ async function spawnInvestigation(failedJob, runDir) {
1767
1867
  const baseSlug = failedJob.slug.replace(/^\d+-/, '');
1768
1868
  const group = failedJob.parallelGroup ?? 99;
1769
1869
  const fixSlug = `${String(group).padStart(2, '0')}-fix-${baseSlug}`;
1770
- const fixPath = path.join(PRDS_DIR, `${fixSlug}.md`);
1870
+ const fixPath = path.join(prdDirForCwd(failedJob.cwd), `${fixSlug}.md`);
1771
1871
 
1772
1872
  if (fs.existsSync(fixPath)) {
1773
1873
  console.log(`[scheduler] skip investigation: fix plan already exists at ${fixPath}`);
@@ -1943,7 +2043,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1943
2043
  new Date().toISOString(),
1944
2044
  );
1945
2045
 
1946
- const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
2046
+ const prdPath = prdPathForJob(job);
1947
2047
  const stateForDeps = await readQueue();
1948
2048
  verifyResult = await verifyRun({
1949
2049
  runDir,
@@ -2119,7 +2219,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
2119
2219
  // investigation fires (mirrors reverifyNeedsReview's auto-fix section)
2120
2220
  // so the periodic pass 10 min later sees it already attempted and
2121
2221
  // does not spawn a duplicate.
2122
- const fixSlugExists = (slug) => fs.existsSync(path.join(PRDS_DIR, `${slug}.md`));
2222
+ const fixSlugExists = (slug) => candidatePrdsDirs().some((dir) => fs.existsSync(path.join(dir, `${slug}.md`)));
2123
2223
  if (
2124
2224
  process.env.SM_AUTOFIX_DISABLE !== '1' &&
2125
2225
  isEligibleForImmediateAutoFix(s.jobs[i2], s.jobs, fixSlugExists)
@@ -2777,7 +2877,7 @@ async function reverifyNeedsReview() {
2777
2877
  const leftForReview = [];
2778
2878
  for (const job of candidates) {
2779
2879
  const runDir = path.join(RUNS_DIR, job.runId || resolveRunId(job));
2780
- const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
2880
+ const prdPath = prdPathForJob(job);
2781
2881
  // Derive committedDuringRun from the recorded run window. The live
2782
2882
  // commit-guard uses gitHead() (before/after HEAD diff); here the run is
2783
2883
  // already over so we query git log filtered to [startedAt, finishedAt+60s].
@@ -2897,7 +2997,7 @@ async function reverifyNeedsReview() {
2897
2997
  if (process.env.SM_AUTOFIX_DISABLE !== '1') {
2898
2998
  const afterHeal = await readQueue();
2899
2999
  const targets = selectAutoFixTargets(afterHeal.jobs, {
2900
- fixSlugExists: (s) => fs.existsSync(path.join(PRDS_DIR, `${s}.md`)),
3000
+ fixSlugExists: (s) => candidatePrdsDirs().some((dir) => fs.existsSync(path.join(dir, `${s}.md`))),
2901
3001
  });
2902
3002
  for (const job of targets) {
2903
3003
  const runId = job.runId || resolveRunId(job);
@@ -2993,7 +3093,7 @@ function registerScheduleHandlers() {
2993
3093
  }));
2994
3094
 
2995
3095
  ipcMain.handle('schedule:reset-job', validated(schemas.scheduleSlug, async ({ slug }) => {
2996
- if (!safeSlugPath(slug)) return { ok: false, error: 'invalid slug' };
3096
+ if (!(await safeSlugPath(slug))) return { ok: false, error: 'invalid slug' };
2997
3097
  const found = await mutate((state) => {
2998
3098
  const idx = state.jobs.findIndex((j) => j.slug === slug);
2999
3099
  if (idx < 0) return false;
@@ -3054,8 +3154,9 @@ function registerScheduleHandlers() {
3054
3154
  await fsp.mkdir(archiveDir, { recursive: true });
3055
3155
  let archived = 0;
3056
3156
  for (const job of victims) {
3057
- const src = path.resolve(path.join(PRDS_DIR, `${job.slug}.md`));
3058
- if (!src.startsWith(PRDS_DIR + path.sep)) continue;
3157
+ const srcDir = await findPrdDir(job.slug) ?? prdDirForCwd(job.cwd);
3158
+ const src = path.resolve(path.join(srcDir, `${job.slug}.md`));
3159
+ if (!src.startsWith(srcDir + path.sep)) continue;
3059
3160
  const dst = path.join(archiveDir, `${job.slug}.md`);
3060
3161
  try {
3061
3162
  await fsp.rename(src, dst);
@@ -3085,7 +3186,7 @@ function registerScheduleHandlers() {
3085
3186
  });
3086
3187
 
3087
3188
  ipcMain.handle('schedule:read-prd', validated(schemas.scheduleSlug, async ({ slug }) => {
3088
- const filePath = safeSlugPath(slug);
3189
+ const filePath = await safeSlugPath(slug);
3089
3190
  if (!filePath) return { ok: false, error: 'invalid slug' };
3090
3191
  try {
3091
3192
  const text = await fsp.readFile(filePath, 'utf8');
@@ -3111,7 +3212,14 @@ function registerScheduleHandlers() {
3111
3212
  }));
3112
3213
 
3113
3214
  ipcMain.handle('schedule:write-prd', validated(schemas.scheduleWritePrd, async (data) => {
3114
- const resolved = safeSlugPath(data.slug);
3215
+ // Editing an existing PRD writes back to whichever dir it already lives
3216
+ // in; a brand-new slug (no cwd known yet — e.g. the renderer's "New PRD"
3217
+ // template, authored before the user fills in `cwd`) falls back to the
3218
+ // legacy global dir until it's re-saved with a real cwd and migrated by
3219
+ // the next reconcile-driven scan.
3220
+ const dir = (await findPrdDir(data.slug)) ?? PRDS_DIR;
3221
+ if (dir === PRDS_DIR) ensureDirs();
3222
+ const resolved = safeSlugPathIn(dir, data.slug);
3115
3223
  if (!resolved) return { ok: false, error: 'invalid slug' };
3116
3224
  try {
3117
3225
  await config.writeTextAtomic(resolved, data.body);
@@ -3128,31 +3236,35 @@ function registerScheduleHandlers() {
3128
3236
 
3129
3237
  ipcMain.handle('schedule:list-prds', async () => {
3130
3238
  ensureDirs();
3131
- let entries;
3132
- try {
3133
- entries = await fsp.readdir(PRDS_DIR);
3134
- } catch (e) {
3135
- logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: readdir failed', meta: { error: e?.message } });
3136
- return [];
3137
- }
3138
3239
  const out = [];
3139
- for (const name of entries) {
3140
- if (!name.endsWith('.md') || name.startsWith('.')) continue;
3141
- const filePath = path.join(PRDS_DIR, name);
3240
+ for (const dir of candidatePrdsDirs()) {
3241
+ let entries;
3142
3242
  try {
3143
- const parsed = await parsePrd(filePath);
3144
- const stat = await fsp.stat(filePath);
3145
- out.push({
3146
- slug: parsed.slug,
3147
- parallelGroup: parsed.parallelGroup,
3148
- title: parsed.title,
3149
- cwd: parsed.cwd || '',
3150
- estimateMinutes: parsed.estimateMinutes,
3151
- sourcePromptId: parsed.sourcePromptId,
3152
- mtimeMs: stat.mtimeMs,
3153
- });
3243
+ entries = await fsp.readdir(dir);
3154
3244
  } catch (e) {
3155
- logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: skipping unparseable file', meta: { name, error: e?.message } });
3245
+ if (e?.code !== 'ENOENT') {
3246
+ logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: readdir failed', meta: { dir, error: e?.message } });
3247
+ }
3248
+ continue;
3249
+ }
3250
+ for (const name of entries) {
3251
+ if (!name.endsWith('.md') || name.startsWith('.')) continue;
3252
+ const filePath = path.join(dir, name);
3253
+ try {
3254
+ const parsed = await parsePrd(filePath);
3255
+ const stat = await fsp.stat(filePath);
3256
+ out.push({
3257
+ slug: parsed.slug,
3258
+ parallelGroup: parsed.parallelGroup,
3259
+ title: parsed.title,
3260
+ cwd: parsed.cwd || '',
3261
+ estimateMinutes: parsed.estimateMinutes,
3262
+ sourcePromptId: parsed.sourcePromptId,
3263
+ mtimeMs: stat.mtimeMs,
3264
+ });
3265
+ } catch (e) {
3266
+ logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: skipping unparseable file', meta: { name, error: e?.message } });
3267
+ }
3156
3268
  }
3157
3269
  }
3158
3270
  out.sort((a, b) => a.slug.localeCompare(b.slug, undefined, { numeric: true }));
@@ -3175,6 +3287,7 @@ function registerScheduleHandlers() {
3175
3287
 
3176
3288
  async function init() {
3177
3289
  ensureDirs();
3290
+ await runPrdMigration();
3178
3291
  sweepQueueBackups().catch((e) => console.warn('[scheduler] backup sweep failed', e?.message));
3179
3292
 
3180
3293
  // Hydrate cached state from the sidecar before any scheduling decisions.
@@ -3359,14 +3472,20 @@ const remote = {
3359
3472
  return buildScheduleStatePayload(state, { withPaths: true });
3360
3473
  },
3361
3474
 
3362
- async readPrd(slug) {
3363
- const filePath = safeSlugPath(slug);
3475
+ // `cwd` is optional: prdCreate.cjs's create flow passes the target
3476
+ // project's cwd explicitly (the file may not exist yet, so there's
3477
+ // nothing for findPrdDir to search for); the renderer's slug-only IPC
3478
+ // path (editing an already-queued PRD) omits it and relies on findPrdDir.
3479
+ async readPrd(slug, cwd) {
3480
+ const dir = cwd ? prdDirForCwd(cwd) : await findPrdDir(slug);
3481
+ if (!dir) return { ok: false, error: 'invalid slug' };
3482
+ const filePath = safeSlugPathIn(dir, slug);
3364
3483
  if (!filePath) return { ok: false, error: 'invalid slug' };
3365
3484
  try {
3366
3485
  // realpath resolves symlinks; re-check boundary to block a rogue agent job
3367
- // that places a symlink inside PRDS_DIR pointing outside the safe root.
3486
+ // that places a symlink inside the PRDs dir pointing outside the safe root.
3368
3487
  const real = await fsp.realpath(filePath);
3369
- if (!real.startsWith(PRDS_DIR + path.sep)) {
3488
+ if (!real.startsWith(dir + path.sep)) {
3370
3489
  return { ok: false, error: 'invalid slug' };
3371
3490
  }
3372
3491
  const text = await fsp.readFile(real, 'utf8');
@@ -3395,17 +3514,29 @@ const remote = {
3395
3514
  }
3396
3515
  },
3397
3516
 
3398
- async writePrd(slug, body) {
3399
- const resolved = safeSlugPath(slug);
3517
+ // `cwd` optional — see readPrd's comment above; prdCreate.cjs's create
3518
+ // flow supplies it (the destination project dir for a brand-new file that
3519
+ // doesn't exist yet, so findPrdDir would return nothing to write into).
3520
+ async writePrd(slug, body, cwd) {
3521
+ let dir;
3522
+ if (cwd) {
3523
+ dir = prdDirForCwd(cwd);
3524
+ await fsp.mkdir(dir, { recursive: true });
3525
+ } else {
3526
+ dir = (await findPrdDir(slug)) ?? PRDS_DIR;
3527
+ if (dir === PRDS_DIR) ensureDirs();
3528
+ }
3529
+ const resolved = safeSlugPathIn(dir, slug);
3400
3530
  if (!resolved) return { ok: false, error: 'invalid slug' };
3401
3531
  try {
3402
- // Symlink defense, matching readPrd/readLog: safeSlugPath is lexical and
3403
- // does NOT resolve symlinks, so a rogue job could plant prds/x.md → an
3404
- // arbitrary $HOME path and have writeTextAtomic clobber it. Resolve the
3405
- // real parent dir (the file itself may not exist yet) and re-assert
3406
- // containment; also reject the target if it is already a symlink.
3532
+ // Symlink defense, matching readPrd/readLog: safeSlugPathIn is lexical
3533
+ // and does NOT resolve symlinks, so a rogue job could plant a PRDs-dir
3534
+ // entry → an arbitrary $HOME path and have writeTextAtomic clobber it.
3535
+ // Resolve the real parent dir (the file itself may not exist yet) and
3536
+ // re-assert containment; also reject the target if it is already a
3537
+ // symlink.
3407
3538
  const realParent = await fsp.realpath(path.dirname(resolved));
3408
- if (realParent !== PRDS_DIR && !realParent.startsWith(PRDS_DIR + path.sep)) {
3539
+ if (realParent !== dir && !realParent.startsWith(dir + path.sep)) {
3409
3540
  return { ok: false, error: 'invalid slug' };
3410
3541
  }
3411
3542
  const existing = await fsp.lstat(resolved).catch(() => null);
@@ -3421,7 +3552,7 @@ const remote = {
3421
3552
  },
3422
3553
 
3423
3554
  async resetJob(slug) {
3424
- if (!safeSlugPath(slug)) return { ok: false, error: 'invalid slug' };
3555
+ if (!(await safeSlugPath(slug))) return { ok: false, error: 'invalid slug' };
3425
3556
  const found = await mutate((state) => {
3426
3557
  const idx = state.jobs.findIndex((j) => j.slug === slug);
3427
3558
  if (idx < 0) return false;
@@ -3499,4 +3630,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
3499
3630
  });
3500
3631
  }
3501
3632
 
3502
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, feedbackSweepDue, FEEDBACK_SWEEP_TICK_INTERVAL, sweepFeedback, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus };
3633
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, feedbackSweepDue, FEEDBACK_SWEEP_TICK_INTERVAL, sweepFeedback, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, candidatePrdsDirs, prdDirForCwd, prdPathForJob, findPrdDir, runPrdMigration };
@@ -392,8 +392,8 @@ the user may or may not have open.
392
392
 
393
393
  ### Fallback: writing the PRD file directly
394
394
 
395
- When the app is not running, write `<NN>-<slug>.md` by hand into
396
- `~/.claude/session-manager/scheduled-plans/prds/` following the frontmatter rules in §6 and the
395
+ When the app is not running, write `<NN>-<slug>.md` by hand into the target repo's own
396
+ `<cwd>/session-manager-operations/scheduler/prds/` following the frontmatter rules in §6 and the
397
397
  body conventions the rest of this guide describes (`# Goal`, `# Acceptance criteria`,
398
398
  `# Implementation notes`, `## Engineering standards` inlined verbatim — see `/develop`'s output
399
399
  for the exact shape). **Trade-off:** this path has no atomic `NN` allocation. The tool's
@@ -113,6 +113,15 @@ export interface ReadTextResult {
113
113
  error: string | null;
114
114
  }
115
115
 
116
+ /** One resolved node in a CLAUDE.md-like file's `@path` import chain. */
117
+ export interface ImportRef {
118
+ path: string;
119
+ exists: boolean;
120
+ sizeBytes: number;
121
+ tokenEstimate: number;
122
+ ok: boolean;
123
+ }
124
+
116
125
  export interface WriteResult {
117
126
  ok: boolean;
118
127
  mtimeMs: number;
@@ -390,6 +399,12 @@ export interface ScheduleJob {
390
399
  verifierVerdict?: string;
391
400
  /** Per-job values carried in queue.json for dependency checking. */
392
401
  dependsOn?: string[];
402
+ /** Originating tab id (PRD frontmatter `sourceTabId`), refreshed from the
403
+ * PRD file on every reconcile. When the PRD was authored from inside a
404
+ * PromptSessionConversation, this equals that PromptSession's own id
405
+ * (its chat key) — lets the Scheduler UI trace a job back to the
406
+ * PromptSession that spawned it. */
407
+ sourceTabId?: string | null;
393
408
  }
394
409
 
395
410
  export interface SchedulePaths {
@@ -1172,6 +1187,7 @@ export interface SessionManagerAPI {
1172
1187
  watch: (paths: string[]) => void;
1173
1188
  unwatch: (paths: string[]) => void;
1174
1189
  onChanged: (handler: (info: ConfigChangedEvent) => void) => () => void;
1190
+ parseImports: (path: string) => Promise<{ ok: true; imports: ImportRef[] } | { ok: false; error: string }>;
1175
1191
  };
1176
1192
  voice: {
1177
1193
  onHotkey: (handler: (event: VoiceHotkeyEvent) => void) => () => void;
@@ -156,6 +156,7 @@ contextBridge.exposeInMainWorld('api', {
156
156
  exists: (path) => ipcRenderer.invoke('config:exists', { path }),
157
157
  watch: (paths) => ipcRenderer.send('config:watch', { paths }),
158
158
  unwatch: (paths) => ipcRenderer.send('config:unwatch', { paths }),
159
+ parseImports: (path) => ipcRenderer.invoke('config:parse-imports', { path }),
159
160
  onChanged: (handler) => {
160
161
  const listener = (_e, info) => handler(info);
161
162
  ipcRenderer.on('config:changed', listener);