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.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * prdLocations.cjs — resolves per-project PRD-source directories.
3
+ *
4
+ * PRD 808 (1 of the 808→809→810→811 chain moving PRD storage out of the
5
+ * single global `~/.claude/session-manager/scheduled-plans/prds/` into each
6
+ * active project's own `<cwd>/session-manager-operations/scheduler/prds/`,
7
+ * for consistency with how feedback and HUMAN_LEARN already scope
8
+ * per-project operational state under `session-manager-operations/`.
9
+ *
10
+ * Global scheduler bookkeeping (queue.json, history.jsonl, runs/) is
11
+ * untouched by this module — it only resolves where PRD *source* .md files
12
+ * live. Active-project discovery reuses activeSessions.cjs's
13
+ * activeProjectCwds (the same discovery watchdogHelpers.cjs's sweep() uses)
14
+ * rather than re-implementing transcript scanning here.
15
+ */
16
+ 'use strict';
17
+
18
+ const path = require('node:path');
19
+ const { activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
20
+
21
+ const PRD_SUBPATH = ['session-manager-operations', 'scheduler', 'prds'];
22
+
23
+ /**
24
+ * resolvePrdWriteDir(cwd) → `<cwd>/session-manager-operations/scheduler/prds`
25
+ * Pure path join, no I/O. Throws on a missing/non-string cwd so a caller
26
+ * never silently resolves a project-scoped path against `undefined`.
27
+ */
28
+ function resolvePrdWriteDir(cwd) {
29
+ if (!cwd || typeof cwd !== 'string') {
30
+ throw new Error('resolvePrdWriteDir: cwd is required');
31
+ }
32
+ return path.join(cwd, ...PRD_SUBPATH);
33
+ }
34
+
35
+ /**
36
+ * resolvePrdsDirs(maxAgeMin?, opts?) → string[]
37
+ * One `<cwd>/session-manager-operations/scheduler/prds` dir per active
38
+ * project cwd (activeProjectCwds' default window is 90 minutes). `opts` is
39
+ * forwarded to activeProjectCwds (e.g. `projectsDir` override for tests).
40
+ */
41
+ function resolvePrdsDirs(maxAgeMin, opts) {
42
+ const cwds = activeProjectCwds(maxAgeMin, opts);
43
+ return cwds.map(resolvePrdWriteDir);
44
+ }
45
+
46
+ module.exports = { resolvePrdWriteDir, resolvePrdsDirs, PRD_SUBPATH };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * prdMigration.cjs — one-time, idempotent move of PRD source .md files out
3
+ * of the legacy global `~/.claude/session-manager/scheduled-plans/prds/`
4
+ * into each PRD's own project's `<cwd>/session-manager-operations/scheduler/prds/`
5
+ * (PRD 808, first of the 808→809→810→811 chain).
6
+ *
7
+ * Idempotent: once a file has been moved out of legacyPrdsDir it is simply
8
+ * absent on the next run — nothing left to move, so a repeat run is a
9
+ * no-op fs.readdir + early return. Files whose frontmatter has no parseable
10
+ * `cwd` are left in place (never silently dropped) and reported back as
11
+ * `strandedCount` so the caller can log a warning — see `migratePrds`'s
12
+ * `unresolved` return field, surfaced by scheduler.cjs's boot safety-net.
13
+ */
14
+ 'use strict';
15
+
16
+ const fs = require('node:fs');
17
+ const fsp = require('node:fs/promises');
18
+ const path = require('node:path');
19
+ const { splitFrontmatter } = require('./prdFrontmatter.cjs');
20
+ const { resolvePrdWriteDir } = require('./prdLocations.cjs');
21
+
22
+ /**
23
+ * Move every `.md` file in legacyPrdsDir whose frontmatter `cwd` resolves to
24
+ * an on-disk directory into that project's PRDs dir (renamed, not copied).
25
+ * Files with missing/unparseable/non-existent `cwd` are left untouched.
26
+ *
27
+ * Returns { moved, skipped, unresolved } where:
28
+ * - moved: number of files successfully relocated
29
+ * - skipped: number of files already gone (race with a concurrent mover)
30
+ * - unresolved: [{ file, reason }] for every .md left behind in legacyPrdsDir
31
+ */
32
+ async function migratePrds(legacyPrdsDir) {
33
+ let entries;
34
+ try {
35
+ entries = await fsp.readdir(legacyPrdsDir);
36
+ } catch {
37
+ return { moved: 0, skipped: 0, unresolved: [] };
38
+ }
39
+
40
+ let moved = 0;
41
+ let skipped = 0;
42
+ const unresolved = [];
43
+
44
+ for (const name of entries) {
45
+ if (!name.endsWith('.md') || name.startsWith('.')) continue;
46
+ const src = path.join(legacyPrdsDir, name);
47
+
48
+ let raw;
49
+ try {
50
+ raw = await fsp.readFile(src, 'utf8');
51
+ } catch (e) {
52
+ if (e?.code === 'ENOENT') { skipped++; continue; }
53
+ unresolved.push({ file: name, reason: `read failed: ${e?.message}` });
54
+ continue;
55
+ }
56
+
57
+ const { fm } = splitFrontmatter(raw);
58
+ const cwd = fm.cwd && fm.cwd.trim();
59
+ if (!cwd) {
60
+ unresolved.push({ file: name, reason: 'no cwd in frontmatter' });
61
+ continue;
62
+ }
63
+ if (!fs.existsSync(cwd)) {
64
+ unresolved.push({ file: name, reason: `cwd does not exist on disk: ${cwd}` });
65
+ continue;
66
+ }
67
+
68
+ const destDir = resolvePrdWriteDir(cwd);
69
+ const dst = path.join(destDir, name);
70
+ try {
71
+ await fsp.mkdir(destDir, { recursive: true });
72
+ await fsp.rename(src, dst);
73
+ moved++;
74
+ } catch (e) {
75
+ if (e?.code === 'ENOENT') { skipped++; continue; }
76
+ unresolved.push({ file: name, reason: `move failed: ${e?.message}` });
77
+ }
78
+ }
79
+
80
+ return { moved, skipped, unresolved };
81
+ }
82
+
83
+ module.exports = { migratePrds };
@@ -30,8 +30,7 @@ const path = require('node:path');
30
30
  const config = require('../config.cjs');
31
31
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
32
32
  const { readTail } = require('./fileTail.cjs');
33
-
34
- const PRDS_DIR = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'prds');
33
+ const { resolvePrdWriteDir } = require('./prdLocations.cjs');
35
34
 
36
35
  // The one project this hook always knows how to reach, used as the fallback
37
36
  // destination when a job's own cwd has no feedback inbox.
@@ -214,9 +213,10 @@ function readRunMeta(runDir, slug) {
214
213
  }
215
214
  }
216
215
 
217
- function readPrdBody(slug) {
216
+ function readPrdBody(cwd, slug) {
217
+ if (!cwd) return '';
218
218
  try {
219
- const raw = fs.readFileSync(path.join(PRDS_DIR, `${slug}.md`), 'utf8');
219
+ const raw = fs.readFileSync(path.join(resolvePrdWriteDir(cwd), `${slug}.md`), 'utf8');
220
220
  return splitFrontmatter(raw).body;
221
221
  } catch {
222
222
  return '';
@@ -311,7 +311,7 @@ async function fileRcaFeedback({ job, runDir, verdict, annotations, investigatio
311
311
  const meta = readRunMeta(runDir, job.slug);
312
312
  const logPath = runDir ? path.join(runDir, `${job.slug}.log`) : null;
313
313
  const logTail = logPath ? readTail(logPath, 64 * 1024) : '';
314
- const acText = extractAcceptanceCriteria(readPrdBody(job.slug));
314
+ const acText = extractAcceptanceCriteria(readPrdBody(job.cwd, job.slug));
315
315
  const failureClass = classifyFailure({ verdict, logTail });
316
316
 
317
317
  const markdown = buildRcaMarkdown({
@@ -128,6 +128,27 @@ const INTERACTIVE_AC_PATTERNS = [
128
128
  ];
129
129
 
130
130
  const { splitFrontmatter } = require('./lib/prdFrontmatter.cjs');
131
+ const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
132
+
133
+ // PRD 808: PRDS_DIR is now the LEGACY global dir — kept as a migration
134
+ // source and search fallback for not-yet-migrated files. Live PRDs resolve
135
+ // per-project via prdLocations.cjs's resolvePrdsDirs(). candidatePrdsDirs()
136
+ // aggregates both so the linter/archive/retag operations still see every
137
+ // PRD regardless of which dir it currently lives in.
138
+ function candidatePrdsDirs() {
139
+ return [PRDS_DIR, ...resolvePrdsDirs()];
140
+ }
141
+
142
+ /** Search every candidate PRD dir for `<slug>.md`; returns the containing dir or null. */
143
+ async function findPrdDir(slug) {
144
+ for (const dir of candidatePrdsDirs()) {
145
+ try {
146
+ await fsp.access(path.join(dir, `${slug}.md`));
147
+ return dir;
148
+ } catch { /* not here — try the next candidate dir */ }
149
+ }
150
+ return null;
151
+ }
131
152
 
132
153
  /**
133
154
  * Lint a single PRD asynchronously. Returns { slug, findings: [...] }.
@@ -237,16 +258,18 @@ function lintParsed(slug, raw) {
237
258
  * so Promise.all never rejects under normal use.
238
259
  */
239
260
  async function lintAll() {
240
- let entries = [];
241
- try {
242
- entries = await fsp.readdir(PRDS_DIR);
243
- } catch {
244
- return { reports: [], scannedAt: Date.now() };
245
- }
246
261
  const paths = [];
247
- for (const name of entries) {
248
- if (!name.endsWith('.md') || name.startsWith('.')) continue;
249
- paths.push(path.join(PRDS_DIR, name));
262
+ for (const dir of candidatePrdsDirs()) {
263
+ let entries;
264
+ try {
265
+ entries = await fsp.readdir(dir);
266
+ } catch {
267
+ continue;
268
+ }
269
+ for (const name of entries) {
270
+ if (!name.endsWith('.md') || name.startsWith('.')) continue;
271
+ paths.push(path.join(dir, name));
272
+ }
250
273
  }
251
274
  const reports = await Promise.all(paths.map(async (filePath) => {
252
275
  try {
@@ -270,8 +293,10 @@ async function lintAll() {
270
293
  */
271
294
  async function archiveOne(slug, archiveDir) {
272
295
  if (!SLUG_RE.test(slug)) return { ok: false, slug, error: 'invalid slug' };
273
- const src = path.resolve(path.join(PRDS_DIR, `${slug}.md`));
274
- if (!src.startsWith(PRDS_DIR + path.sep)) return { ok: false, slug, error: 'path escape (src)' };
296
+ const srcDir = await findPrdDir(slug);
297
+ if (!srcDir) return { ok: false, slug, error: 'not found in any PRDs dir' };
298
+ const src = path.resolve(path.join(srcDir, `${slug}.md`));
299
+ if (!src.startsWith(srcDir + path.sep)) return { ok: false, slug, error: 'path escape (src)' };
275
300
  const dst = path.resolve(path.join(archiveDir, `${slug}.md`));
276
301
  if (!dst.startsWith(PRDS_ARCHIVE_DIR + path.sep)) return { ok: false, slug, error: 'path escape (dst)' };
277
302
  try {
@@ -407,8 +432,10 @@ async function autoArchiveCompleted(state, { nowMs } = {}) {
407
432
  */
408
433
  async function retagOne({ slug, parallelGroup, estimateMinutes }) {
409
434
  if (!SLUG_RE.test(slug)) return { ok: false, slug, error: 'invalid slug' };
410
- const src = path.resolve(path.join(PRDS_DIR, `${slug}.md`));
411
- if (!src.startsWith(PRDS_DIR + path.sep)) return { ok: false, slug, error: 'path escape' };
435
+ const dir = await findPrdDir(slug);
436
+ if (!dir) return { ok: false, slug, error: 'not found in any PRDs dir' };
437
+ const src = path.resolve(path.join(dir, `${slug}.md`));
438
+ if (!src.startsWith(dir + path.sep)) return { ok: false, slug, error: 'path escape' };
412
439
 
413
440
  let raw;
414
441
  try {
@@ -470,8 +497,8 @@ async function retagOne({ slug, parallelGroup, estimateMinutes }) {
470
497
  }
471
498
  if (!SLUG_RE.test(newSlug)) return { ok: false, slug, error: 'new slug would be invalid' };
472
499
  }
473
- const dst = path.resolve(path.join(PRDS_DIR, `${newSlug}.md`));
474
- if (!dst.startsWith(PRDS_DIR + path.sep)) return { ok: false, slug, error: 'new path escape' };
500
+ const dst = path.resolve(path.join(dir, `${newSlug}.md`));
501
+ if (!dst.startsWith(dir + path.sep)) return { ok: false, slug, error: 'new path escape' };
475
502
 
476
503
  // Atomic write via shared helper. If slug changed, write at the new path
477
504
  // and unlink the old slug.
@@ -551,4 +578,6 @@ module.exports = {
551
578
  retagMany,
552
579
  PRDS_DIR,
553
580
  PRDS_ARCHIVE_DIR,
581
+ candidatePrdsDirs,
582
+ findPrdDir,
554
583
  };