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.
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-DPSmbyU_.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-CefVSjmm.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-BGTU12rl.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-B6JNrbpD.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.38.7",
3
+ "version": "0.39.1",
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,8 +77,9 @@ 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 ~/.claude/session-manager/scheduled-plans/prds/` for related slugs) —
81
- duplicating or contradicting a still-queued PRD is a real failure mode, not a hypothetical one.
80
+ this subsystem (`ls <cwd>/session-manager-operations/scheduler/prds/` for related slugs, in
81
+ the target repo) — duplicating or contradicting a still-queued PRD is a real failure mode,
82
+ not a hypothetical one.
82
83
 
83
84
  3. **Analyze the request through five named lenses, draft a candidate decomposition, then run
84
85
  a completeness pass before finalizing it.** This step exists because small, bounded individual
@@ -193,17 +194,19 @@ can't load skills.
193
194
  (the session-manager Electron app must be running for this MCP tool to work; if it isn't,
194
195
  don't block on it): compute the highest in-use number deterministically yourself — never
195
196
  eyeball or narrow-grep the `ls` (a narrowed pattern like `'^10[0-9]'` silently misses `110+`
196
- and collides):
197
+ and collides). PRDs are stored per-project, so `NN` allocation for a given PRD only needs
198
+ *that project's own* prds directory scanned — not every project's:
197
199
  ```bash
198
- ls ~/.claude/session-manager/scheduled-plans/prds/ | grep -oE '^[0-9]+' | sort -n | uniq | tail -5
200
+ ls <cwd>/session-manager-operations/scheduler/prds/ | grep -oE '^[0-9]+' | sort -n | uniq | tail -5
199
201
  ```
200
- The last line is the current max. Then: same `NN` as a logically independent sibling that
201
- can run in parallel; **next free `NN` = max+1** when this PRD hard-depends on prior work or
202
- is unrelated to every existing group. This manual path has a small, accepted race (two
203
- concurrent authors could compute the same "next free" `NN`) — cosmetic (two unrelated groups
204
- end up sharing a number) rather than destructive, and only reachable when the atomic tool
205
- path above isn't available. Record each cross-PRD dependency in the dependent PRD's notes
206
- either way.
202
+ (`<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
206
+ small, accepted race (two concurrent authors could compute the same "next free" `NN`) —
207
+ cosmetic (two unrelated groups end up sharing a number) rather than destructive, and only
208
+ reachable when the atomic tool path above isn't available. Record each cross-PRD dependency
209
+ in the dependent PRD's notes either way.
207
210
 
208
211
  ### PRD structure and location
209
212
 
@@ -215,15 +218,19 @@ can't load skills.
215
218
  system that runs `claude -p <prd-body> --dangerously-skip-permissions` jobs around 5-hour
216
219
  token-window resets, with auto-pause on rate-limit and auto-resume.
217
220
 
218
- **Canonical location — non-negotiable.** PRDs MUST be written to:
221
+ **Canonical location — non-negotiable.** PRDs MUST be written to, inside the target repo:
219
222
  ```
220
- ~/.claude/session-manager/scheduled-plans/prds/<NN>-<kebab-slug>.md
223
+ <cwd>/session-manager-operations/scheduler/prds/<NN>-<kebab-slug>.md
221
224
  ```
222
225
  **Anywhere else doesn't get scheduled.** If you write to `data/prds/`, `docs/prds/`, or the
223
- project root, the scheduler will not see it and the user loses their token-budget-managed
224
- execution. There is exactly one queue for all projects — that's intentional, because the
225
- 5-hour token budget is global across all of the user's Claude work. The `~` expands to
226
- `os.homedir()` so the same convention works for any user on any machine.
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.
227
234
 
228
235
  **Filename rules.** `NN` is the 2-digit zero-padded parallel group (picked per the `ls`
229
236
  command above — same `NN` as an independent sibling, or next free `NN` = max+1 when this PRD
@@ -85,6 +85,7 @@ Data-driven from 400+ scheduler runs: long hangs (not bad code) are the dominant
85
85
  - **You ARE the executor — never re-queue or self-schedule.** A headless PRD run must perform its own acceptance criteria directly. Do NOT invoke `/develop`, `/process-feedback`, or any queue-authoring skill from inside a run — those are interactive main-loop skills that author a *new* PRD and return, so the run exits 0 having done nothing (no commit, no sentinel → `needs_review` with `no_verdict_sentinel`). Do NOT call `ScheduleWakeup`/set a tracking loop either — the process exits when the run ends and nothing re-invokes it. This applies just as much to spawning your own review agents and waiting on them: do NOT invoke `/code-review`, `/security-review`, `requesting-code-review`, or any other skill/subagent as a background/async step and then end your turn with something like "I'll wait for the review agents to complete" — a headless run has no next turn, so that line is the run's last output, no verdict sentinel prints, and the job parks in `needs_review` even though the actual work already landed. If a PRD's acceptance criteria call for a second review pass, run it **synchronously, inline, before the finish protocol** — call the reviewer and read its result in the same turn, don't fire-and-wait. If the PRD's work looks large, decompose and execute it inline within this run; never delegate it back to the queue. (Incidents: PRD 460 invoked `/develop`, spawned a duplicate PRD 461, and exited 0 with no work. PRD 479 landed its commit correctly but then backgrounded `/code-review --fix` + `/security-review` and called `ScheduleWakeup` to "wait" for them — same class of failure, different entry point.)
86
86
  - **A shared-repo `cwd` can be occupied by a concurrent job — check before you touch shared state.** When a PRD's `cwd` is a repo other headless runs may also target (a shared team repo like sigma, not a private single-purpose project), a `git checkout`/`gh pr checkout` can land you in another job's live worktree with its own uncommitted WIP. Before running `git stash`, `git reset`, or any command that discards or hides working-tree state, check `git stash list` and `git status` first, and if you must set aside pre-existing uncommitted changes that aren't yours, **stash with a descriptive message** (`git stash push -m "pre-existing WIP found by PRD <NN>, not mine"`) and **restore it before your run ends** (or, if you can't safely restore because your own commit depends on that worktree state, leave it stashed with the message and say so explicitly in your finish output — never let the run end silently dropping someone else's stash). Never `git stash drop`/`git clean -fd` on state you didn't create. (Incident: PRD 477 stashed a concurrent job's rAF-throttle-revert WIP to get its own checkout, finished, and exited without restoring it — orphaning the other job's uncommitted work in `stash@{0}` with no record of whose it was.)
87
87
  - **`gh pr edit --body` can fail on repos with legacy GitHub Projects (classic) boards** — the underlying GraphQL query fetches `repository.pullRequest.projectCards`, a field GitHub is sunsetting, and errors with `GraphQL: Projects (classic) is being deprecated ... (repository.pullRequest.projectCards)` even though the edit itself would otherwise succeed. This is a known `gh` CLI quirk, not a defect in your work. Prefer `gh api -X PATCH repos/<owner>/<repo>/pulls/<n> -f body="$(cat body.md)"` for updating a PR description headlessly — it doesn't touch the deprecated field. If you do use `gh pr edit` and it fails this way, don't leave the bare GraphQL error as the last thing in that step (it reads as an unrecovered error in the final-20%-of-transcript verifier heuristic): immediately retry with the `gh api` form and print one line noting the known-bug fallback, so the recovery is adjacent to the error.
88
+ - **`gh pr checks`/`gh run watch` exit non-zero while CI is merely *pending*, not failed — don't let that surface as a bare error.** Polling `gh pr checks <n>` before checks finish returns a non-zero exit (e.g. 8) with output like `check pending 0 <url>` — this is normal, documented `gh` CLI behavior, not a failure. If you retry with a *differently-worded* command (e.g. dropping a `sleep N &&` prefix, or switching to `gh run watch <id> --exit-status`), the verifier's self-recovery detector pairs retries by exact command-description match and may not recognize the differently-worded retry as the same recovery, leaving the original pending-state error looking unrecovered in the transcript (incident: `745-pr188-ci-lint-docs-integrity`, a fully green, committed, pushed run flagged `needs_review` over exactly this). Prefer polling with the *same* command/description each time (e.g. loop `gh pr checks <n>` unchanged, or use `gh run watch <id> --exit-status` from the start rather than switching mid-poll) so a later success is recognized as recovering the earlier pending-state failure.
88
89
  - **Negative-assertion checks must exit 0 when clean.** A check that verifies the *absence* of something (a `grep` that should find nothing, "no leftover X", `diff` expecting no change) must return exit 0 on the clean case. A bare `grep` exits **1 on no-match** — so the *success* path surfaces as `is_error=true` and the verifier downgrades a perfect run to `needs_review`. Always invert: `if <detector>; then echo "HALT: <what was found>"; exit 1; fi; echo clean`. Never let the no-match/empty path carry the non-zero exit.
89
90
  - **Recover or annotate every error — don't strand a Traceback in the transcript.** The verifier downgrades an otherwise-perfect run to `needs_review` when a `Traceback`/`Error` appears with *no visible recovery within ~10 lines* (the `transcript_errors` heuristic — the single most common false-positive on green deliverables). Two executor habits cause it: (1) **throwaway probes that error** — an inline `python -c` with a quoting/f-string slip, a wrong kwarg, a bad path. When a probe errors, immediately re-run the corrected version *or* print one line `# expected/handled: <why>` right after, so recovery is adjacent. Don't move on leaving a bare error as the last thing in that step. Prefer a small temp `.py` file over a fragile multi-quote `python -c` one-liner (inline f-string errors are the top source of stranded tracebacks). (2) See the timeout rule below.
90
91
  - **An *expected* bounded-timeout (exit 124) must be annotated, not bare.** `timeout`-capping a genuinely long task you expect to hit the cap (a full-universe ingest, a long scan) is correct — but a bare `Exit code 124` reads as a failure to the verifier. Wrap it so the cap is a success-with-note: `timeout 120 <cmd> || { rc=$?; [ $rc -eq 124 ] && echo "hit time cap — idempotent/partial, rows persist incrementally; OK" || { echo "HALT: <cmd> failed rc=$rc"; exit 1; }; }`. (Distinguish 124 = expected cap from a real non-zero.) For work that legitimately needs longer than a safe cap, run it in the background and poll a bounded number of times rather than capping the foreground command.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * prdLocations.test.cjs — unit tests for prdLocations.cjs's per-project PRD
3
+ * directory resolution (PRD 808).
4
+ *
5
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdLocations.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test, expect, afterEach } from 'vitest';
11
+ const fs = require('node:fs');
12
+ const fsp = require('node:fs/promises');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const { resolvePrdWriteDir, resolvePrdsDirs } = require('../lib/prdLocations.cjs');
16
+
17
+ const tmpDirs = [];
18
+ afterEach(async () => {
19
+ while (tmpDirs.length) {
20
+ const d = tmpDirs.pop();
21
+ await fsp.rm(d, { recursive: true, force: true });
22
+ }
23
+ });
24
+
25
+ test('resolvePrdWriteDir joins the project-scoped PRDs subpath onto cwd', () => {
26
+ expect(resolvePrdWriteDir('/home/user/Projects/foo')).toBe(
27
+ path.join('/home/user/Projects/foo', 'session-manager-operations', 'scheduler', 'prds'),
28
+ );
29
+ });
30
+
31
+ test('resolvePrdWriteDir throws on a missing cwd', () => {
32
+ expect(() => resolvePrdWriteDir()).toThrow();
33
+ expect(() => resolvePrdWriteDir('')).toThrow();
34
+ });
35
+
36
+ test('resolvePrdsDirs maps each active project cwd to its own PRDs dir', async () => {
37
+ const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdlocations-projects-'));
38
+ tmpDirs.push(projectsDir);
39
+ const projectCwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdlocations-cwd-'));
40
+ tmpDirs.push(projectCwd);
41
+
42
+ const slug = 'abc123';
43
+ const projDir = path.join(projectsDir, slug);
44
+ fs.mkdirSync(projDir, { recursive: true });
45
+ const transcript = path.join(projDir, 'session1.jsonl');
46
+ fs.writeFileSync(transcript, `${JSON.stringify({ cwd: projectCwd })}\n`);
47
+
48
+ const dirs = resolvePrdsDirs(90, { projectsDir });
49
+ expect(dirs).toEqual([resolvePrdWriteDir(projectCwd)]);
50
+ expect(dirs[0].endsWith(path.join('session-manager-operations', 'scheduler', 'prds'))).toBe(true);
51
+ });
52
+
53
+ test('resolvePrdsDirs returns [] when no project has a recent transcript', () => {
54
+ const dirs = resolvePrdsDirs(90, { projectsDir: '/nonexistent-projects-dir' });
55
+ expect(dirs).toEqual([]);
56
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * prdMigration.test.cjs — unit tests for the one-time PRD-relocation
3
+ * migration (PRD 808): moves legacy-global PRD .md files into their own
4
+ * project's `<cwd>/session-manager-operations/scheduler/prds/`, based on
5
+ * each file's frontmatter `cwd`.
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdMigration.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, afterEach } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const fsp = require('node:fs/promises');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const { migratePrds } = require('../lib/prdMigration.cjs');
18
+ const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
19
+
20
+ const tmpDirs = [];
21
+ afterEach(async () => {
22
+ while (tmpDirs.length) {
23
+ const d = tmpDirs.pop();
24
+ await fsp.rm(d, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ async function mkLegacyDir() {
29
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdmigration-legacy-'));
30
+ tmpDirs.push(dir);
31
+ return dir;
32
+ }
33
+
34
+ async function mkProjectCwd() {
35
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdmigration-cwd-'));
36
+ tmpDirs.push(dir);
37
+ return dir;
38
+ }
39
+
40
+ test('migratePrds moves a PRD whose frontmatter cwd resolves to an on-disk project', async () => {
41
+ const legacyDir = await mkLegacyDir();
42
+ const projectCwd = await mkProjectCwd();
43
+ const body = `---\ntitle: Do the thing\ncwd: ${projectCwd}\nestimateMinutes: 15\n---\n\n# Goal\n\nDo it.\n`;
44
+ await fsp.writeFile(path.join(legacyDir, '01-do-thing.md'), body, 'utf8');
45
+
46
+ const result = await migratePrds(legacyDir);
47
+
48
+ expect(result.moved).toBe(1);
49
+ expect(result.unresolved).toEqual([]);
50
+ expect(fs.existsSync(path.join(legacyDir, '01-do-thing.md'))).toBe(false);
51
+ const dest = path.join(resolvePrdWriteDir(projectCwd), '01-do-thing.md');
52
+ expect(fs.existsSync(dest)).toBe(true);
53
+ expect(await fsp.readFile(dest, 'utf8')).toBe(body);
54
+ });
55
+
56
+ test('migratePrds is idempotent — a second run over an already-migrated dir is a no-op', async () => {
57
+ const legacyDir = await mkLegacyDir();
58
+ const projectCwd = await mkProjectCwd();
59
+ const body = `---\ntitle: Do the thing\ncwd: ${projectCwd}\nestimateMinutes: 15\n---\n\nBody.\n`;
60
+ await fsp.writeFile(path.join(legacyDir, '02-do-thing.md'), body, 'utf8');
61
+
62
+ await migratePrds(legacyDir);
63
+ const second = await migratePrds(legacyDir);
64
+
65
+ expect(second.moved).toBe(0);
66
+ expect(second.unresolved).toEqual([]);
67
+ });
68
+
69
+ test('migratePrds leaves a file with no cwd in frontmatter in place and reports it unresolved', async () => {
70
+ const legacyDir = await mkLegacyDir();
71
+ const body = `---\ntitle: No cwd here\nestimateMinutes: 15\n---\n\nBody.\n`;
72
+ await fsp.writeFile(path.join(legacyDir, '03-no-cwd.md'), body, 'utf8');
73
+
74
+ const result = await migratePrds(legacyDir);
75
+
76
+ expect(result.moved).toBe(0);
77
+ expect(result.unresolved).toEqual([{ file: '03-no-cwd.md', reason: 'no cwd in frontmatter' }]);
78
+ expect(fs.existsSync(path.join(legacyDir, '03-no-cwd.md'))).toBe(true);
79
+ });
80
+
81
+ test('migratePrds leaves a file whose cwd does not exist on disk in place and reports it unresolved', async () => {
82
+ const legacyDir = await mkLegacyDir();
83
+ const body = `---\ntitle: Bad cwd\ncwd: /this/path/does/not/exist/anywhere\nestimateMinutes: 15\n---\n\nBody.\n`;
84
+ await fsp.writeFile(path.join(legacyDir, '04-bad-cwd.md'), body, 'utf8');
85
+
86
+ const result = await migratePrds(legacyDir);
87
+
88
+ expect(result.moved).toBe(0);
89
+ expect(result.unresolved).toHaveLength(1);
90
+ expect(result.unresolved[0].file).toBe('04-bad-cwd.md');
91
+ expect(fs.existsSync(path.join(legacyDir, '04-bad-cwd.md'))).toBe(true);
92
+ });
93
+
94
+ test('migratePrds ignores non-.md and dotfiles, and no-ops on a missing legacy dir', async () => {
95
+ const legacyDir = await mkLegacyDir();
96
+ await fsp.writeFile(path.join(legacyDir, 'notes.txt'), 'not a prd', 'utf8');
97
+ await fsp.writeFile(path.join(legacyDir, '.hidden.md'), 'ignored', 'utf8');
98
+
99
+ const result = await migratePrds(legacyDir);
100
+ expect(result).toEqual({ moved: 0, skipped: 0, unresolved: [] });
101
+
102
+ const missing = await migratePrds(path.join(legacyDir, 'nope'));
103
+ expect(missing).toEqual({ moved: 0, skipped: 0, unresolved: [] });
104
+ });
105
+
106
+ test('migratePrds moves multiple PRDs to different projects independently', async () => {
107
+ const legacyDir = await mkLegacyDir();
108
+ const cwdA = await mkProjectCwd();
109
+ const cwdB = await mkProjectCwd();
110
+ await fsp.writeFile(
111
+ path.join(legacyDir, '05-a.md'),
112
+ `---\ntitle: A\ncwd: ${cwdA}\nestimateMinutes: 5\n---\n\nA.\n`,
113
+ 'utf8',
114
+ );
115
+ await fsp.writeFile(
116
+ path.join(legacyDir, '06-b.md'),
117
+ `---\ntitle: B\ncwd: ${cwdB}\nestimateMinutes: 5\n---\n\nB.\n`,
118
+ 'utf8',
119
+ );
120
+
121
+ const result = await migratePrds(legacyDir);
122
+
123
+ expect(result.moved).toBe(2);
124
+ expect(fs.existsSync(path.join(resolvePrdWriteDir(cwdA), '05-a.md'))).toBe(true);
125
+ expect(fs.existsSync(path.join(resolvePrdWriteDir(cwdB), '06-b.md'))).toBe(true);
126
+ });
@@ -16,7 +16,7 @@ let realHome;
16
16
  let rcaFeedbackHook;
17
17
 
18
18
  // config.cjs and rcaFeedbackHook.cjs both bake os.homedir() into top-level
19
- // consts at first require (allowedRoots/WRITE_PREFIXES, PRDS_DIR/SM_REPO_ROOT).
19
+ // consts at first require (allowedRoots/WRITE_PREFIXES, SM_REPO_ROOT).
20
20
  // Node's require cache must be purged per test so each test's tmpHome takes
21
21
  // effect — vi.resetModules() only resets vitest's ESM graph, not require().
22
22
  const MODULES_TO_RELOAD = ['../lib/rcaFeedbackHook.cjs', '../config.cjs'];
@@ -65,8 +65,8 @@ function writeRun(runDir, slug, { logLines = ['line1', 'line2'], exitCode = 1, d
65
65
  fs.writeFileSync(path.join(runDir, `${slug}.meta.json`), JSON.stringify({ exitCode, durationMs }));
66
66
  }
67
67
 
68
- function writePrd(slug, { acBody = '- [ ] `timeout 10 echo ok` passes.' } = {}) {
69
- const prdsDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'prds');
68
+ function writePrd(cwd, slug, { acBody = '- [ ] `timeout 10 echo ok` passes.' } = {}) {
69
+ const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'prds');
70
70
  fs.mkdirSync(prdsDir, { recursive: true });
71
71
  const body = ['---', `title: ${slug}`, '---', '', '# Goal', '', 'Do the thing.', '', '# Acceptance criteria', '', acBody, '', '# Out of scope', '', '- N/A'].join('\n');
72
72
  fs.writeFileSync(path.join(prdsDir, `${slug}.md`), body);
@@ -82,7 +82,7 @@ test('fileRcaFeedback: second call with same (slug, runId) is a no-op', async ()
82
82
  const cwd = makeProjectWithInbox();
83
83
  const runDir = path.join(tmpHome, 'run1');
84
84
  writeRun(runDir, 'testslug');
85
- writePrd('testslug');
85
+ writePrd(cwd, 'testslug');
86
86
  const job = baseJob({ cwd });
87
87
 
88
88
  const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
@@ -102,7 +102,7 @@ test('fileRcaFeedback: new runId for the same slug files a new RCA', async () =>
102
102
  const runDir2 = path.join(tmpHome, 'run2');
103
103
  writeRun(runDir1, 'testslug');
104
104
  writeRun(runDir2, 'testslug');
105
- writePrd('testslug');
105
+ writePrd(cwd, 'testslug');
106
106
 
107
107
  const r1 = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, runId: 'run-aaa' }), runDir: runDir1, verdict: 'transcript_errors' });
108
108
  const r2 = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, runId: 'run-bbb' }), runDir: runDir2, verdict: 'transcript_errors' });
@@ -195,7 +195,7 @@ test('fileRcaFeedback: actually writes into the fallback dir when the target inb
195
195
  const cwd = makeProjectWithoutInbox();
196
196
  const runDir = path.join(tmpHome, 'run1');
197
197
  writeRun(runDir, 'testslug');
198
- writePrd('testslug');
198
+ writePrd(cwd, 'testslug');
199
199
 
200
200
  const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
201
201
  expect(res.filed).toBe(true);
@@ -221,7 +221,7 @@ test('fileRcaFeedback: SM_RCA_DISABLE=1 skips without writing', async () => {
221
221
  const cwd = makeProjectWithInbox();
222
222
  const runDir = path.join(tmpHome, 'run1');
223
223
  writeRun(runDir, 'testslug');
224
- writePrd('testslug');
224
+ writePrd(cwd, 'testslug');
225
225
 
226
226
  const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
227
227
  expect(res.filed).toBe(false);
@@ -235,7 +235,7 @@ test('fileRcaFeedback: investigationText updates the existing RCA instead of dup
235
235
  const cwd = makeProjectWithInbox();
236
236
  const runDir = path.join(tmpHome, 'run1');
237
237
  writeRun(runDir, 'testslug');
238
- writePrd('testslug');
238
+ writePrd(cwd, 'testslug');
239
239
  const job = baseJob({ cwd });
240
240
 
241
241
  const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
@@ -23,6 +23,7 @@ const chokidar = require('chokidar');
23
23
  const logs = require('./logs.cjs');
24
24
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
25
25
  const { expandHome } = require('./lib/expandHome.cjs');
26
+ const { listReferencedFiles } = require('./lib/importReferences.cjs');
26
27
 
27
28
  /** Map<absPath, {watcher, refCount}> — one chokidar watcher per path. */
28
29
  const watchers = new Map();
@@ -409,6 +410,14 @@ function registerConfigHandlers() {
409
410
  ipcMain.handle('config:write-text', v(s.configWriteText, ({ path: p, text }) => writeTextAtomic(p, text)));
410
411
  ipcMain.handle('config:list-dir', v(s.configListDir, ({ path: p, opts }) => listDir(p, opts || {})));
411
412
  ipcMain.handle('config:exists', v(s.configPath, ({ path: p }) => exists(p)));
413
+ ipcMain.handle('config:parse-imports', v(s.configParseImports, async ({ path: p }) => {
414
+ try {
415
+ const real = validatePath(expandHome(p));
416
+ return { ok: true, imports: listReferencedFiles(real) };
417
+ } catch (e) {
418
+ return { ok: false, error: e.message };
419
+ }
420
+ }));
412
421
  ipcMain.on('config:watch', (e, { paths }) => { try { s.configWatch.parse(paths); watch(paths, e.sender.id); } catch (err) { logs.writeLine({ level: 'warn', scope: 'config', message: 'config:watch schema reject', meta: { error: err?.message } }); } });
413
422
  ipcMain.on('config:unwatch', (e, { paths }) => { try { s.configWatch.parse(paths); unwatch(paths, e.sender.id); } catch (err) { logs.writeLine({ level: 'warn', scope: 'config', message: 'config:unwatch schema reject', meta: { error: err?.message } }); } });
414
423
  }
@@ -11,6 +11,7 @@ const os = require('node:os');
11
11
  const { execFileSync } = require('node:child_process');
12
12
  const { POLL_INTERVAL_MS } = require('./lib/schedulerConfig.cjs');
13
13
  const { checkPersonaImports } = require('./lib/personaImportHealth.cjs');
14
+ const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
14
15
 
15
16
  const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
16
17
  const PROJECT_ROOT = path.resolve(__dirname, '../..');
@@ -237,28 +238,33 @@ async function check() {
237
238
  };
238
239
  }
239
240
 
240
- // 4. Check PRDs directory.
241
- const prdsDir = path.join(schedulerBaseDir, 'prds');
242
- try {
243
- await fsp.access(prdsDir, fs.constants.R_OK);
244
- const files = await fsp.readdir(prdsDir);
245
- const prdFiles = files.filter((f) => f.endsWith('.md'));
246
- status.components.scheduler_prds = {
247
- ok: true,
248
- path: prdsDir,
249
- count: prdFiles.length,
250
- };
251
- } catch (e) {
252
- if (e.code !== 'ENOENT') {
253
- status.issues.push(`PRDs directory not accessible: ${e.message}`);
241
+ // 4. Check PRDs directories — one per active project
242
+ // (session-manager-operations/scheduler/prds/), summed. PRD 809.
243
+ const prdsDirs = resolvePrdsDirs();
244
+ let totalPrdCount = 0;
245
+ let anyPrdsDirAccessible = false;
246
+ const prdsDirErrors = [];
247
+ for (const dir of prdsDirs) {
248
+ try {
249
+ await fsp.access(dir, fs.constants.R_OK);
250
+ const files = await fsp.readdir(dir);
251
+ totalPrdCount += files.filter((f) => f.endsWith('.md')).length;
252
+ anyPrdsDirAccessible = true;
253
+ } catch (e) {
254
+ if (e.code !== 'ENOENT') {
255
+ prdsDirErrors.push(`${dir}: ${e.message}`);
256
+ }
254
257
  }
255
- status.components.scheduler_prds = {
256
- ok: e.code === 'ENOENT', // ok if not yet created
257
- path: prdsDir,
258
- exists: false,
259
- error: e.code === 'ENOENT' ? 'not yet created' : e.message,
260
- };
261
258
  }
259
+ if (prdsDirErrors.length > 0) {
260
+ status.issues.push(`PRDs directory not accessible: ${prdsDirErrors.join('; ')}`);
261
+ }
262
+ status.components.scheduler_prds = {
263
+ ok: prdsDirErrors.length === 0,
264
+ dirs: prdsDirs,
265
+ count: totalPrdCount,
266
+ ...(prdsDirs.length === 0 || anyPrdsDirAccessible ? {} : { exists: false }),
267
+ };
262
268
 
263
269
  // 5. Check transcripts directory (where live session logs are tailed).
264
270
  const projectsDir = path.join(os.homedir(), '.claude/projects');
@@ -192,6 +192,8 @@ const configListDir = z.object({
192
192
 
193
193
  const configWatch = z.array(z.string().min(1).max(4096));
194
194
 
195
+ const configParseImports = z.object({ path: z.string().min(1).max(4096) });
196
+
195
197
  // ──────────────────────────────────────────── Sessions
196
198
  const sessionsPayload = z.object({
197
199
  tabs: z.array(z.object({
@@ -748,6 +750,7 @@ module.exports = {
748
750
  configWriteText,
749
751
  configListDir,
750
752
  configWatch,
753
+ configParseImports,
751
754
  sessionsPayload,
752
755
  layoutEnvelope,
753
756
  scheduleSlug,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * importReferences.spec.cjs — unit tests for listReferencedFiles, the IPC-facing
3
+ * wrapper around personaImportHealth's `@path` import-chain walker.
4
+ *
5
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/importReferences.spec.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ import { test, expect } from 'vitest';
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { listReferencedFiles } = require('../importReferences.cjs');
15
+
16
+ function mkTmpDir() {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'import-refs-test-'));
18
+ }
19
+
20
+ test('listReferencedFiles: 2 valid @imports returns 2 entries with correct sizeBytes/tokenEstimate/ok', () => {
21
+ const dir = mkTmpDir();
22
+ const a = path.join(dir, 'a.md');
23
+ const b = path.join(dir, 'b.md');
24
+ const root = path.join(dir, 'root.md');
25
+ fs.writeFileSync(a, 'x'.repeat(40));
26
+ fs.writeFileSync(b, 'y'.repeat(8));
27
+ fs.writeFileSync(root, `@${a}\n@${b}\n`);
28
+
29
+ const result = listReferencedFiles(root);
30
+
31
+ expect(result).toHaveLength(2);
32
+ const byPath = Object.fromEntries(result.map((r) => [r.path, r]));
33
+ expect(byPath[a]).toEqual({ path: a, exists: true, sizeBytes: 40, tokenEstimate: 10, ok: true });
34
+ expect(byPath[b]).toEqual({ path: b, exists: true, sizeBytes: 8, tokenEstimate: 2, ok: true });
35
+ });
36
+
37
+ test('listReferencedFiles: missing import returns ok=false with sizeBytes 0', () => {
38
+ const dir = mkTmpDir();
39
+ const missing = path.join(dir, 'does-not-exist.md');
40
+ const root = path.join(dir, 'root.md');
41
+ fs.writeFileSync(root, `@${missing}\n`);
42
+
43
+ const result = listReferencedFiles(root);
44
+
45
+ expect(result).toEqual([
46
+ { path: missing, exists: false, sizeBytes: 0, tokenEstimate: 0, ok: false },
47
+ ]);
48
+ });
49
+
50
+ test('listReferencedFiles: zero @import lines returns an empty array', () => {
51
+ const dir = mkTmpDir();
52
+ const root = path.join(dir, 'root.md');
53
+ fs.writeFileSync(root, 'no imports here, just prose.\n');
54
+
55
+ expect(listReferencedFiles(root)).toEqual([]);
56
+ });
@@ -13,6 +13,7 @@ const os = require('node:os');
13
13
  const path = require('node:path');
14
14
  const { spawn, spawnSync } = require('node:child_process');
15
15
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
16
+ const { resolvePrdWriteDir } = require('./prdLocations.cjs');
16
17
 
17
18
  // Regex identifying meta/dod slugs that must NOT influence the batchKey.
18
19
  // This is the load-bearing loop-avoidance filter: when the gate job itself
@@ -96,11 +97,6 @@ function reportExists(key, runsDir = RUNS_DIR) {
96
97
  // watchers.cjs and app:test-fire-hook only.
97
98
  const SHELL_META_RE = /[|>&;<`]|\$[({]/;
98
99
 
99
- const PRDS_DIR = path.join(
100
- os.homedir(),
101
- '.claude', 'session-manager', 'scheduled-plans', 'prds'
102
- );
103
-
104
100
  /**
105
101
  * Extract the first bounded AC test command from a PRD body.
106
102
  *
@@ -185,11 +181,12 @@ function extractAcCommand(prdBody) {
185
181
  * @param {{ slug: string, cwd: string }} job
186
182
  * @param {{ timeoutMs?: number, prdsDir?: string }} opts
187
183
  * timeoutMs Hard kill ceiling for the child (default 60s).
188
- * prdsDir Override PRD directory (for tests).
184
+ * prdsDir Override PRD directory (for tests). Defaults to the job's own
185
+ * per-project PRDs dir (resolvePrdWriteDir(job.cwd)).
189
186
  * @returns {Promise<{ slug: string, status: 'pass'|'fail'|'unverifiable', code: number|null, ms: number }>}
190
187
  */
191
188
  function reverifyAc(job, { timeoutMs = 60_000, prdsDir } = {}) {
192
- const resolvedPrdsDir = prdsDir ?? PRDS_DIR;
189
+ const resolvedPrdsDir = prdsDir ?? (job.cwd ? resolvePrdWriteDir(job.cwd) : null);
193
190
  const startNs = process.hrtime.bigint();
194
191
 
195
192
  function elapsedMs() {
@@ -371,14 +368,16 @@ function _extractSection(body, headingText) {
371
368
  *
372
369
  * @param {Array<{ slug: string, cwd: string, landedCommit?: string }>} jobs
373
370
  * @param {{ prdsDir?: string, gitTimeoutMs?: number }} opts
371
+ * prdsDir Override PRD directory for every job (for tests). Defaults per-job to
372
+ * the job's own per-project PRDs dir (resolvePrdWriteDir(job.cwd)).
374
373
  * @returns {Array<{ slug: string, surfaces: string[] }>} Only jobs with ≥1 hit.
375
374
  */
376
375
  function flagRiskySurfaces(jobs, { prdsDir, gitTimeoutMs = 10_000 } = {}) {
377
- const resolvedPrdsDir = prdsDir ?? PRDS_DIR;
378
376
  const results = [];
379
377
 
380
378
  for (const job of jobs) {
381
379
  let candidateText = '';
380
+ const resolvedPrdsDir = prdsDir ?? (job.cwd ? resolvePrdWriteDir(job.cwd) : null);
382
381
 
383
382
  // 1. Try git commit — bounded by gitTimeoutMs.
384
383
  if (job.landedCommit && job.cwd) {
@@ -395,7 +394,7 @@ function flagRiskySurfaces(jobs, { prdsDir, gitTimeoutMs = 10_000 } = {}) {
395
394
  // 2. Fallback: PRD # Implementation notes section.
396
395
  // Guard: slug must contain only safe chars to prevent path traversal via a
397
396
  // corrupted queue.json entry (e.g. slug: "../../.ssh/id_rsa").
398
- if (!candidateText && job.slug && /^[\w-]+$/.test(job.slug)) {
397
+ if (!candidateText && resolvedPrdsDir && job.slug && /^[\w-]+$/.test(job.slug)) {
399
398
  try {
400
399
  const raw = fs.readFileSync(path.join(resolvedPrdsDir, `${job.slug}.md`), 'utf8');
401
400
  const body = splitFrontmatter(raw).body;
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * importReferences.cjs — IPC-facing wrapper around personaImportHealth's
5
+ * import-chain walker, adding the size/token-estimate fields the "referenced
6
+ * files" panel needs. Reuses walkImports rather than reparsing `@path` syntax.
7
+ */
8
+
9
+ const fs = require('node:fs');
10
+ const { walkImports } = require('./personaImportHealth.cjs');
11
+
12
+ // Maps walkImports' result shape to what the renderer panel needs per file:
13
+ // resolved path, existence, size, a rough token estimate, and overall health.
14
+ function listReferencedFiles(rootPath) {
15
+ return walkImports(rootPath).map(({ importPath, exists, ok }) => {
16
+ let sizeBytes = 0;
17
+ if (exists) {
18
+ try {
19
+ sizeBytes = fs.statSync(importPath).size;
20
+ } catch {
21
+ sizeBytes = 0;
22
+ }
23
+ }
24
+ return {
25
+ path: importPath,
26
+ exists,
27
+ sizeBytes,
28
+ tokenEstimate: Math.round(sizeBytes / 4),
29
+ ok,
30
+ };
31
+ });
32
+ }
33
+
34
+ module.exports = { listReferencedFiles };
@@ -122,7 +122,7 @@ async function createPrd(input, remote) {
122
122
  // NN allocation is delegated to allocateParallelGroup() (PRD 548) via
123
123
  // the injected remote — never re-derived here — unless the caller
124
124
  // opted into an existing group explicitly.
125
- const nn = input.parallelGroup ?? await remote.allocateParallelGroup();
125
+ const nn = input.parallelGroup ?? await remote.allocateParallelGroup(input.cwd);
126
126
  const filenameSlug = `${nn}-${slug}`;
127
127
 
128
128
  // An explicit `parallelGroup` bypasses allocateParallelGroup()'s
@@ -131,13 +131,13 @@ async function createPrd(input, remote) {
131
131
  // no existence guard (by design, it doubles as the edit-in-place
132
132
  // path for Scheduler UI PRD edits), so "create" must not silently
133
133
  // clobber an existing job's PRD.
134
- const existing = await remote.readPrd(filenameSlug);
134
+ const existing = await remote.readPrd(filenameSlug, input.cwd);
135
135
  if (existing?.ok) {
136
136
  return { ok: false, status: 409, error: `PRD already exists: ${filenameSlug}.md` };
137
137
  }
138
138
 
139
139
  const body = buildPrdBody(input);
140
- const writeResult = await remote.writePrd(filenameSlug, body);
140
+ const writeResult = await remote.writePrd(filenameSlug, body, input.cwd);
141
141
  if (!writeResult?.ok) {
142
142
  return { ok: false, status: 500, error: writeResult?.error ?? 'write failed' };
143
143
  }