claude-code-session-manager 0.51.0 → 0.52.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.
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-DPY5vv68.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-hNso_ykJ.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-BXzDjHNE.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-CKP60_3d.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.51.0",
3
+ "version": "0.52.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",
@@ -28,6 +28,8 @@
28
28
  "dev:renderer": "vite",
29
29
  "dev:electron": "wait-on http://localhost:5173 && SM_DEV=1 electron .",
30
30
  "build": "vite build",
31
+ "build:project-pages": "node scripts/build-project-pages-renderer.mjs",
32
+ "build:project-pages-logic": "node scripts/build-project-pages-logic.mjs",
31
33
  "typecheck": "tsc --noEmit",
32
34
  "start": "electron .",
33
35
  "postinstall": "node scripts/postinstall.cjs",
@@ -124,6 +126,7 @@
124
126
  "@vitejs/plugin-react": "^4.3.3",
125
127
  "autoprefixer": "^10.4.20",
126
128
  "concurrently": "^9.1.0",
129
+ "esbuild": "^0.25.12",
127
130
  "jsdom": "^29.1.1",
128
131
  "postcss": "^8.4.49",
129
132
  "react": "^18.3.1",
@@ -0,0 +1,152 @@
1
+ /**
2
+ * prdLocationsArchived.test.cjs — unit tests for the archived-PRD discovery
3
+ * that backs `schedule:list-prds` including archived PRDs (PRD 926): an
4
+ * Epic's "PRDs" tab count must reflect its REAL historical PRD count, not
5
+ * just whatever is still sitting in its live `prds/` dir — a completed
6
+ * PRD's source .md moves to the sibling `prds-archived/` dir the instant its
7
+ * scheduler job finishes (scheduler.cjs's archiveCompletedPrd), which the
8
+ * old list-prds handler never read.
9
+ *
10
+ * Covers:
11
+ * - resolveArchivedPrdsDirs finds an Epic's own `prds-archived/` dir
12
+ * alongside the project's flat sibling one.
13
+ * - deriveEpicIdFromPrdPath resolves an epicId for a file under
14
+ * `prds-archived/`, not just the live `prds/` dir.
15
+ * - scheduler.cjs's candidateArchivedPrdsDirs + candidatePrdsDirs
16
+ * together surface both a still-pending PRD and an already-archived one
17
+ * for the same Epic, without double-counting.
18
+ *
19
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdLocationsArchived.test.cjs
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ import { test, expect, afterEach } from 'vitest';
25
+ const fs = require('node:fs');
26
+ const fsp = require('node:fs/promises');
27
+ const os = require('node:os');
28
+ const path = require('node:path');
29
+ const {
30
+ resolveArchivedPrdsDirs,
31
+ resolvePrdsDirs,
32
+ resolveEpicPrdWriteDir,
33
+ deriveEpicIdFromPrdPath,
34
+ } = require('../lib/prdLocations.cjs');
35
+ const { candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus } = require('../scheduler.cjs');
36
+
37
+ const tmpDirs = [];
38
+ afterEach(async () => {
39
+ while (tmpDirs.length) {
40
+ const d = tmpDirs.pop();
41
+ await fsp.rm(d, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ function mkEpicProject() {
46
+ return fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-archived-cwd-'));
47
+ }
48
+
49
+ test('resolveArchivedPrdsDirs finds an Epic\'s own prds-archived/ dir for an active project', async () => {
50
+ const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-archived-projects-'));
51
+ tmpDirs.push(projectsDir);
52
+ const projectCwd = await mkEpicProject();
53
+ tmpDirs.push(projectCwd);
54
+
55
+ const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, 'epic-1');
56
+ const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
57
+ fs.mkdirSync(epicArchiveDir, { recursive: true });
58
+
59
+ const projDir = path.join(projectsDir, 'epic-project');
60
+ fs.mkdirSync(projDir, { recursive: true });
61
+ const transcript = path.join(projDir, 'session1.jsonl');
62
+ fs.writeFileSync(transcript, `${JSON.stringify({ cwd: projectCwd })}\n`);
63
+
64
+ const dirs = resolveArchivedPrdsDirs(90, { projectsDir });
65
+ expect(dirs).toContain(path.resolve(epicArchiveDir));
66
+ });
67
+
68
+ test('deriveEpicIdFromPrdPath resolves epicId for a file under prds-archived/, not just prds/', async () => {
69
+ const projectCwd = await mkEpicProject();
70
+ tmpDirs.push(projectCwd);
71
+
72
+ const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, 'epic-42');
73
+ const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
74
+ fs.mkdirSync(epicArchiveDir, { recursive: true });
75
+
76
+ const archivedFile = path.join(epicArchiveDir, '917-example.md');
77
+ fs.writeFileSync(archivedFile, '---\ntitle: example\n---\n\n# Goal\n');
78
+
79
+ expect(deriveEpicIdFromPrdPath(archivedFile)).toBe('epic-42');
80
+ // Still works for the live dir too — no regression.
81
+ const liveFile = path.join(epicPrdsDir, '918-example.md');
82
+ fs.mkdirSync(epicPrdsDir, { recursive: true });
83
+ fs.writeFileSync(liveFile, '---\ntitle: example\n---\n\n# Goal\n');
84
+ expect(deriveEpicIdFromPrdPath(liveFile)).toBe('epic-42');
85
+ });
86
+
87
+ test('deriveEpicIdFromPrdPath returns null for an unrelated dir name', () => {
88
+ expect(deriveEpicIdFromPrdPath('/some/random/dir/917-example.md')).toBeNull();
89
+ });
90
+
91
+ test('resolvePrdsDirs + resolveArchivedPrdsDirs together surface a pending PRD dir and an archived PRD dir for the same Epic, with no overlap', async () => {
92
+ const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-both-projects-'));
93
+ tmpDirs.push(projectsDir);
94
+ const projectCwd = await mkEpicProject();
95
+ tmpDirs.push(projectCwd);
96
+
97
+ const epicId = `test-epic-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
98
+ const epicPrdsDir = resolveEpicPrdWriteDir(projectCwd, epicId);
99
+ const epicArchiveDir = path.join(epicPrdsDir, '..', 'prds-archived');
100
+ fs.mkdirSync(epicPrdsDir, { recursive: true });
101
+ fs.mkdirSync(epicArchiveDir, { recursive: true });
102
+
103
+ const pendingSlug = `926-pending-${process.pid}`;
104
+ const archivedSlug = `917-archived-${process.pid}`;
105
+ fs.writeFileSync(path.join(epicPrdsDir, `${pendingSlug}.md`), `---\ntitle: pending\ncwd: ${projectCwd}\n---\n\n# Goal\n`);
106
+ fs.writeFileSync(path.join(epicArchiveDir, `${archivedSlug}.md`), `---\ntitle: archived\ncwd: ${projectCwd}\n---\n\n# Goal\n`);
107
+
108
+ const projDir = path.join(projectsDir, 'both-project');
109
+ fs.mkdirSync(projDir, { recursive: true });
110
+ fs.writeFileSync(path.join(projDir, 'session1.jsonl'), `${JSON.stringify({ cwd: projectCwd })}\n`);
111
+
112
+ const liveDirs = resolvePrdsDirs(90, { projectsDir });
113
+ const archivedDirs = resolveArchivedPrdsDirs(90, { projectsDir });
114
+ expect(liveDirs).toContain(epicPrdsDir);
115
+ expect(archivedDirs.map((d) => path.resolve(d))).toContain(path.resolve(epicArchiveDir));
116
+
117
+ // No overlap between the two dir sets for this Epic — an archived PRD's
118
+ // dir is never also scanned as a live one, and vice versa.
119
+ expect(liveDirs).not.toContain(epicArchiveDir);
120
+ expect(archivedDirs).not.toContain(epicPrdsDir);
121
+ });
122
+
123
+ test('resolveArchivedPrdStatus prefers the live queue.json row over history', () => {
124
+ const liveStatusBySlug = new Map([['917-slug', 'failed']]);
125
+ const histBySlug = new Map([['917-slug', { status: 'completed' }]]);
126
+ expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, histBySlug)).toBe('failed');
127
+ });
128
+
129
+ test('resolveArchivedPrdStatus falls back to history.jsonl when there is no live row', () => {
130
+ const liveStatusBySlug = new Map();
131
+ const histBySlug = new Map([['917-slug', { status: 'failed' }]]);
132
+ expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, histBySlug)).toBe('failed');
133
+ });
134
+
135
+ test('resolveArchivedPrdStatus defaults to completed when neither source has the slug', () => {
136
+ expect(resolveArchivedPrdStatus('917-slug', new Map(), new Map())).toBe('completed');
137
+ });
138
+
139
+ test('resolveArchivedPrdStatus normalizes any non-failed status (e.g. needs_review) to completed', () => {
140
+ const liveStatusBySlug = new Map([['917-slug', 'needs_review']]);
141
+ expect(resolveArchivedPrdStatus('917-slug', liveStatusBySlug, new Map())).toBe('completed');
142
+ });
143
+
144
+ test('scheduler.cjs exports candidateArchivedPrdsDirs alongside candidatePrdsDirs', () => {
145
+ expect(typeof candidatePrdsDirs).toBe('function');
146
+ expect(typeof candidateArchivedPrdsDirs).toBe('function');
147
+ // Both scan the real machine's projects — just assert they run without
148
+ // throwing and return arrays; per-Epic coverage is exercised above via
149
+ // the opts-injectable prdLocations functions directly.
150
+ expect(Array.isArray(candidatePrdsDirs())).toBe(true);
151
+ expect(Array.isArray(candidateArchivedPrdsDirs())).toBe(true);
152
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * scheduler-reap-dead-running-jobs.test.cjs — regression cover for PRD 935
3
+ * (reapDeadRunningJobs runningSet desync).
4
+ *
5
+ * spawnJob()'s completion path can write terminal run artifacts to disk and
6
+ * have its child process fully exit, yet still throw between executeJob()
7
+ * resolving and the completion mutate() finishing (e.g. writeQueue's
8
+ * unreadable guard). That throw is swallowed by spawnJob()'s catch, but its
9
+ * `finally` unconditionally deletes the job's slug from the in-memory
10
+ * runningSet — leaving queue.json stuck at status:"running" with a dead pid
11
+ * while runningSet no longer names it. reapDeadRunningJobs() used to gate its
12
+ * entire body on `runningSet.size === 0`, so this exact state made it return
13
+ * before ever reading queue.json — permanently invisible to reconciliation.
14
+ *
15
+ * This test reproduces that desync directly: a project queue.json job row
16
+ * with status:"running" and a dead pid, while runningSet (freshly loaded,
17
+ * never populated) does not contain its slug. It asserts the job still gets
18
+ * reconciled to a terminal status.
19
+ *
20
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs, since every
21
+ * path this code touches (queueStore's MACHINE_STATE_PATH, activeSessions'
22
+ * project scan root, scheduler.cjs's ROOT/RUNS_DIR, schedulerBatch's
23
+ * DEFAULT_PROJECT_CWD) is baked into a top-level const from os.homedir() at
24
+ * require time — this test must never be able to read or write real state.
25
+ *
26
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const assert = require('node:assert/strict');
32
+ const fs = require('node:fs');
33
+ const os = require('node:os');
34
+ const path = require('node:path');
35
+
36
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-dead-running-jobs-test-'));
37
+ process.env.HOME = tmpHome;
38
+
39
+ const { reapDeadRunningJobs } = require('../scheduler.cjs');
40
+
41
+ function registerActiveProject(cwd) {
42
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
43
+ const slugDir = path.join(projectsDir, 'fake-project-slug');
44
+ fs.mkdirSync(slugDir, { recursive: true });
45
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
46
+ }
47
+
48
+ function writeProjectQueue(cwd, jobs) {
49
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
50
+ fs.mkdirSync(stateDir, { recursive: true });
51
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
52
+ return path.join(stateDir, 'queue.json');
53
+ }
54
+
55
+ function writeRunLog(runId, slug, lines) {
56
+ const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
57
+ fs.mkdirSync(runDir, { recursive: true });
58
+ fs.writeFileSync(path.join(runDir, `${slug}.log`), lines.join('\n') + '\n');
59
+ }
60
+
61
+ test('reapDeadRunningJobs reconciles a queue.json row stuck at status:running with a dead pid, even when runningSet does not name its slug (desync repro)', async () => {
62
+ const projectCwd = path.join(tmpHome, 'a-project');
63
+ fs.mkdirSync(projectCwd, { recursive: true });
64
+ registerActiveProject(projectCwd);
65
+
66
+ const queuePath = writeProjectQueue(projectCwd, [
67
+ {
68
+ slug: 'desynced-job',
69
+ status: 'running',
70
+ cwd: projectCwd,
71
+ runId: 'run-desynced',
72
+ runtime: { pid: 999999 }, // guaranteed-dead pid (see reaperHelpers tests)
73
+ },
74
+ ]);
75
+ writeRunLog('run-desynced', 'desynced-job', [
76
+ '{"type":"result","subtype":"success","result":"done","is_error":false}',
77
+ ]);
78
+
79
+ // runningSet is a fresh module-level Set with nothing in it — this is the
80
+ // exact desynced state spawnJob()'s finally block can leave behind.
81
+ await reapDeadRunningJobs();
82
+
83
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
84
+ assert.equal(jobs.length, 1);
85
+ assert.equal(jobs[0].status, 'completed', 'job must be reconciled from disk state, not skipped via the runningSet gate');
86
+ assert.equal(jobs[0].exitCode, 0);
87
+ assert.equal(jobs[0].runtime, undefined);
88
+ });
89
+
90
+ test('reapDeadRunningJobs is a no-op when no job is actually running in queue.json', async () => {
91
+ const projectCwd = path.join(tmpHome, 'b-project');
92
+ fs.mkdirSync(projectCwd, { recursive: true });
93
+ registerActiveProject(projectCwd);
94
+ const queuePath = writeProjectQueue(projectCwd, [
95
+ { slug: 'already-done', status: 'completed', cwd: projectCwd, exitCode: 0 },
96
+ ]);
97
+
98
+ await assert.doesNotReject(() => reapDeadRunningJobs());
99
+
100
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
101
+ assert.equal(jobs.length, 1);
102
+ assert.equal(jobs[0].status, 'completed');
103
+ });
@@ -58,6 +58,7 @@ const schedulerConfig = require('./lib/schedulerConfig.cjs');
58
58
  const memoryTool = require('./memoryTool.cjs');
59
59
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
60
60
  const { registerProjectBriefIpc } = require('./projectBrief.cjs');
61
+ const { registerProjectPagesIpc } = require('./projectPages.cjs');
61
62
  const promptSessionTranscript = require('./promptSessionTranscript.cjs');
62
63
  const agentMemory = require('./agentMemory.cjs');
63
64
  const git = require('./git.cjs');
@@ -789,6 +790,7 @@ pluginInstall.registerPluginInstallHandlers();
789
790
  memoryTool.registerMemoryHandlers();
790
791
  registerMemoryAggregateIpc();
791
792
  registerProjectBriefIpc();
793
+ registerProjectPagesIpc();
792
794
  ipcMain.handle('promptSessionTranscript:append', validated(schemas.promptSessionTranscriptAppend, async ({ cwd, epicId, role, text, at, eventId }) => {
793
795
  const ok = await promptSessionTranscript.appendTurn(cwd, epicId, { role, text, at, eventId });
794
796
  return { ok };
@@ -313,7 +313,7 @@ const schedulerCreatePrd = z.object({
313
313
  originClaudeSessionId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
314
314
  // User-selected Feature/Bug tag (PRD 774) carried from the originating
315
315
  // PromptTicket — deterministic, never LLM-classified.
316
- tag: z.enum(['feature', 'bug', 'discussion', 'build']).optional(),
316
+ tag: z.enum(['feature', 'bug', 'discussion', 'build', 'project-home-builder']).optional(),
317
317
  });
318
318
 
319
319
  // Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
@@ -446,6 +446,13 @@ const PROJECT_BRIEF_BLOCK = z.enum(['what', 'conventions']);
446
446
  const projectBriefCwd = z.object({
447
447
  cwd: z.string().min(1).max(4096),
448
448
  }).strict();
449
+
450
+ // ──────────────────────────────────────────── Project Pages (PRD 932)
451
+ // Same validation split as projectBriefCwd above: real path validation is
452
+ // config.cjs's validatePath at first fs access in projectPages.cjs.
453
+ const projectPagesCwd = z.object({
454
+ cwd: z.string().min(1).max(4096),
455
+ }).strict();
449
456
  const projectBriefSetPin = z.object({
450
457
  cwd: z.string().min(1).max(4096),
451
458
  block: PROJECT_BRIEF_BLOCK,
@@ -877,6 +884,7 @@ module.exports = {
877
884
  memoryAggregate,
878
885
  memoryStale,
879
886
  projectBriefCwd,
887
+ projectPagesCwd,
880
888
  projectBriefSetPin,
881
889
  projectBriefUpdate,
882
890
  promptSessionTranscriptAppend,
@@ -208,11 +208,21 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
208
208
  const index = readActiveIndex(cwd);
209
209
 
210
210
  // A dispatch that already knows its Epic (sourcePromptId frontmatter from
211
- // an Epic-conversation dispatch) joins it rather than minting a sibling.
211
+ // an Epic-conversation dispatch) joins it rather than minting a sibling —
212
+ // but only while that Epic is still open. Joining unconditionally here
213
+ // let a stale/hallucinated sourcePromptId silently attach a PRD (and its
214
+ // follow-on events/chat activity) to an unrelated or even completed
215
+ // Epic — the "this session ran again without my knowledge, and it was
216
+ // really another Epic's prompt" cross-contamination bug. Mirrors
217
+ // findJoinableEpicInIndex()'s preferEpicId open-check (line ~124).
212
218
  if (explicitEpicId && hasOwn(index.sessions, explicitEpicId)) {
213
- const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
214
- fs.mkdirSync(prdDir, { recursive: true });
215
- return { epicId: explicitEpicId, prdDir, created: false };
219
+ const preferred = index.sessions[explicitEpicId];
220
+ if (preferred && (preferred.status === 'proposed' || preferred.status === 'active')) {
221
+ const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
222
+ fs.mkdirSync(prdDir, { recursive: true });
223
+ return { epicId: explicitEpicId, prdDir, created: false };
224
+ }
225
+ console.warn(`[epicMint] ensureEpic: explicit epicId ${explicitEpicId} exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join, falling through`);
216
226
  }
217
227
 
218
228
  if (reuseByGoal) {
@@ -145,6 +145,39 @@ function resolvePrdsDirs(maxAgeMin, opts) {
145
145
  return dirs;
146
146
  }
147
147
 
148
+ /**
149
+ * resolveArchivedPrdsDirs(maxAgeMin?, opts?) → string[]
150
+ *
151
+ * The `prds-archived/` counterpart of resolvePrdsDirs — every archive dir
152
+ * (the flat per-project sibling plus each Epic's own sibling, per
153
+ * listArchivedPrdDirs) that exists on disk, across every historical AND
154
+ * currently-active project. Same "not recency-filtered" rationale as
155
+ * resolvePrdsDirs: a quiet project's completed-PRD history is still real
156
+ * history, not something list-prds should stop counting.
157
+ */
158
+ function resolveArchivedPrdsDirs(maxAgeMin, opts) {
159
+ const dirs = [];
160
+ const seen = new Set();
161
+ const add = (dir) => {
162
+ if (seen.has(dir)) return;
163
+ seen.add(dir);
164
+ dirs.push(dir);
165
+ };
166
+
167
+ for (const cwd of allProjectCwds(opts)) {
168
+ for (const dir of listArchivedPrdDirs(cwd)) {
169
+ if (fs.existsSync(dir)) add(dir);
170
+ }
171
+ }
172
+ for (const cwd of activeProjectCwds(maxAgeMin, opts)) {
173
+ for (const dir of listArchivedPrdDirs(cwd)) {
174
+ if (fs.existsSync(dir)) add(dir);
175
+ }
176
+ }
177
+
178
+ return dirs;
179
+ }
180
+
148
181
  /**
149
182
  * deriveEpicIdFromPrdPath(filePath) → epicId | null
150
183
  *
@@ -160,7 +193,11 @@ function resolvePrdsDirs(maxAgeMin, opts) {
160
193
  function deriveEpicIdFromPrdPath(filePath) {
161
194
  if (!filePath || typeof filePath !== 'string') return null;
162
195
  const prdsDir = path.dirname(filePath);
163
- if (path.basename(prdsDir) !== 'prds') return null;
196
+ // Accept both an Epic's live `prds/` dir and its sibling `prds-archived/`
197
+ // dir — archived PRDs still belong to the Epic they were dispatched from,
198
+ // and callers (parsePrdRaw, list-prds) need epicId for archived files too.
199
+ const dirName = path.basename(prdsDir);
200
+ if (dirName !== 'prds' && dirName !== 'prds-archived') return null;
164
201
  const epicDir = path.dirname(prdsDir);
165
202
  const epicId = path.basename(epicDir);
166
203
  if (!epicId || epicId === '.' || epicId === path.sep) return null;
@@ -168,13 +205,14 @@ function deriveEpicIdFromPrdPath(filePath) {
168
205
  for (let i = 0; i < EPICS_SUBPATH.length; i++) projectCwd = path.dirname(projectCwd);
169
206
  let epicsRoot;
170
207
  try { epicsRoot = resolveEpicsRoot(projectCwd); } catch { return null; }
171
- if (path.join(epicsRoot, epicId, 'prds') !== prdsDir) return null;
208
+ if (path.join(epicsRoot, epicId, dirName) !== prdsDir) return null;
172
209
  return epicId;
173
210
  }
174
211
 
175
212
  module.exports = {
176
213
  resolvePrdWriteDir,
177
214
  resolvePrdsDirs,
215
+ resolveArchivedPrdsDirs,
178
216
  resolveEpicsRoot,
179
217
  resolveEpicPrdWriteDir,
180
218
  listEpicPrdDirs,
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * projectPages.cjs — read-only backend for Project Home's Project Pages
5
+ * display (PRD 932). Reads what the `project-home-builder` Epic's own
6
+ * session writes directly with its Write tool
7
+ * (`session-manager-operations/project-pages/output/*.html` + manifest.json)
8
+ * — this module never writes anything, matching the corrected "not an
9
+ * OWNERS namespace" storage/ownership note in
10
+ * session-manager-operations/architecture/project-pages-pipeline.md and
11
+ * session-manager-operations/project-pages/README.md. Structure mirrors
12
+ * projectBrief.cjs's `get()`: validatePath first, config.cjs read helpers,
13
+ * one ipcMain.handle registered from index.cjs's registerXHandlers() pattern.
14
+ */
15
+
16
+ const { ipcMain } = require('electron');
17
+ const path = require('node:path');
18
+ const config = require('./config.cjs');
19
+
20
+ function outputDir(cwd) {
21
+ return path.join(cwd, 'session-manager-operations', 'project-pages', 'output');
22
+ }
23
+
24
+ const LENSES = ['marketing', 'feature', 'architecture'];
25
+
26
+ async function get({ cwd }) {
27
+ const realCwd = config.validatePath(cwd);
28
+ const dir = outputDir(realCwd);
29
+ const manifestResult = await config.readJson(path.join(dir, 'manifest.json'));
30
+ if (!manifestResult.exists || !manifestResult.data || manifestResult.parseError) {
31
+ return { output: null };
32
+ }
33
+
34
+ const htmlResults = await Promise.all(
35
+ LENSES.map((lens) => config.readText(path.join(dir, `${lens}.html`))),
36
+ );
37
+ if (htmlResults.some((r) => !r.exists)) {
38
+ return { output: null };
39
+ }
40
+
41
+ const [marketing, feature, architecture] = htmlResults;
42
+ const generatedAt = typeof manifestResult.data.generatedAt === 'string' ? manifestResult.data.generatedAt : null;
43
+ if (!generatedAt) {
44
+ return { output: null };
45
+ }
46
+
47
+ return {
48
+ output: {
49
+ marketing: marketing.text,
50
+ feature: feature.text,
51
+ architecture: architecture.text,
52
+ generatedAt,
53
+ },
54
+ };
55
+ }
56
+
57
+ function registerProjectPagesIpc() {
58
+ const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
59
+ ipcMain.handle('project-pages:get', v(s.projectPagesCwd, get));
60
+ }
61
+
62
+ module.exports = {
63
+ registerProjectPagesIpc,
64
+ get,
65
+ };