claude-code-session-manager 0.51.0 → 0.53.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.
@@ -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,
@@ -486,6 +493,25 @@ const promptSessionTranscriptRead = z.object({
486
493
  limit: z.number().int().positive().max(10_000).optional(),
487
494
  }).strict();
488
495
 
496
+ // ──────────────────────────────────────────── Audit log (renderer Epic
497
+ // lifecycle trace, PRD 940). Kind allowlist is enforced HERE, main-side — the
498
+ // renderer cannot write arbitrary kinds through this channel, only the six
499
+ // Epic lifecycle transitions it emits from promptSessions.ts.
500
+ const AUDIT_LOG_KIND = z.enum([
501
+ 'epic_create',
502
+ 'epic_approve',
503
+ 'epic_complete',
504
+ 'epic_delete',
505
+ 'epic_resume',
506
+ 'epic_duplicate',
507
+ ]);
508
+ const auditLogAppend = z.object({
509
+ kind: AUDIT_LOG_KIND,
510
+ cwd: z.string().min(1).max(4096),
511
+ epicId: z.string().min(1).max(256),
512
+ source: z.string().min(1).max(256),
513
+ }).strict();
514
+
489
515
  // ──────────────────────────────────────────── Per-subagent memory
490
516
  // Distinct from the workspace-scoped Memory tool: agentMemory is keyed by
491
517
  // subagent name (the .md filename in ~/.claude/agents/, e.g. "code-reviewer"),
@@ -877,10 +903,12 @@ module.exports = {
877
903
  memoryAggregate,
878
904
  memoryStale,
879
905
  projectBriefCwd,
906
+ projectPagesCwd,
880
907
  projectBriefSetPin,
881
908
  projectBriefUpdate,
882
909
  promptSessionTranscriptAppend,
883
910
  promptSessionTranscriptRead,
911
+ auditLogAppend,
884
912
  agentMemoryList,
885
913
  agentMemoryGet,
886
914
  agentMemorySet,
@@ -182,9 +182,13 @@ function withPathLock(lockPath, task) {
182
182
  *
183
183
  * `status` defaults to 'proposed' — a fail-safe default so any caller that
184
184
  * forgets to pass it files an Epic that waits for human approval rather than
185
- * one that starts running immediately. Pass 'active' explicitly only for the
186
- * one legitimate immediate-start path (the New Epic UI's own proposed→active
187
- * transition, `promptSessions.ts`'s `approveProposed`).
185
+ * one that starts running immediately. Every Epic is BORN 'proposed' — the
186
+ * mint branch below ignores/rejects any other requested status; it is
187
+ * fail-closed, mirroring opsOwnership.cjs's assertOpsWrite. Activation
188
+ * ('proposed' → 'active') happens exactly once, entirely in the renderer
189
+ * store's `approveProposed` (`state/promptSessions.ts`) — that code path
190
+ * never calls ensureEpic(). Joining an already-'active' Epic (this function's
191
+ * join branches, above the mint branch) remains legal and unchanged.
188
192
  *
189
193
  * `mintIfMissing` defaults to true for the small set of callers that are
190
194
  * themselves the human-intent gate (propose-epic, the RCA hook, the feedback
@@ -208,11 +212,27 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
208
212
  const index = readActiveIndex(cwd);
209
213
 
210
214
  // A dispatch that already knows its Epic (sourcePromptId frontmatter from
211
- // an Epic-conversation dispatch) joins it rather than minting a sibling.
215
+ // an Epic-conversation dispatch) joins it rather than minting a sibling —
216
+ // but only while that Epic is still open. Joining unconditionally here
217
+ // let a stale/hallucinated sourcePromptId silently attach a PRD (and its
218
+ // follow-on events/chat activity) to an unrelated or even completed
219
+ // Epic — the "this session ran again without my knowledge, and it was
220
+ // really another Epic's prompt" cross-contamination bug. Mirrors
221
+ // findJoinableEpicInIndex()'s preferEpicId open-check (line ~124).
212
222
  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 };
223
+ const preferred = index.sessions[explicitEpicId];
224
+ if (preferred && (preferred.status === 'proposed' || preferred.status === 'active')) {
225
+ const prdDir = resolveEpicPrdWriteDir(cwd, explicitEpicId);
226
+ fs.mkdirSync(prdDir, { recursive: true });
227
+ return { epicId: explicitEpicId, prdDir, created: false };
228
+ }
229
+ console.warn(`[epicMint] ensureEpic: explicit epicId ${explicitEpicId} exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join, falling through`);
230
+ appendAuditEvent('epic_mint_refused', {
231
+ cwd,
232
+ epicId: explicitEpicId,
233
+ status,
234
+ reason: `explicit epicId exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join, falling through to mint`,
235
+ });
216
236
  }
217
237
 
218
238
  if (reuseByGoal) {
@@ -241,11 +261,11 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
241
261
  }
242
262
 
243
263
  if (!mintIfMissing) {
244
- throw new Error(
245
- `ensureEpic: no existing Epic found (epicId=${explicitEpicId ?? 'none'}) and mintIfMissing is false — `
246
- + 'a new Epic can only be created by explicit human intent (New Epic UI, or /propose-epic + Approve & start), '
247
- + 'never implicitly by a PRD-authoring path',
248
- );
264
+ const reason = 'no existing Epic found and mintIfMissing is false — a new Epic can only be created by '
265
+ + 'explicit human intent (New Epic UI, or /propose-epic + Approve & start), never implicitly by a '
266
+ + 'PRD-authoring path';
267
+ appendAuditEvent('epic_mint_refused', { cwd, epicId: explicitEpicId ?? null, status, reason });
268
+ throw new Error(`ensureEpic: ${reason} (epicId=${explicitEpicId ?? 'none'})`);
249
269
  }
250
270
 
251
271
  if (!forceNewEpic) {
@@ -257,6 +277,20 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
257
277
  }
258
278
  }
259
279
 
280
+ // BORN-PROPOSED LAW (fail-closed, mirrors opsOwnership.cjs's
281
+ // assertOpsWrite): a mint always writes 'proposed', regardless of what
282
+ // status the caller requested. A caller explicitly asking to mint
283
+ // 'active' is refused outright rather than silently downgraded — that
284
+ // shape (mint + already-active) should never occur, so it is treated as
285
+ // a bug in the caller, not a normal fallback path.
286
+ if (status !== 'proposed') {
287
+ const reason = `ensureEpic: refusing to mint a new Epic with status '${status}' — every Epic is born `
288
+ + "'proposed' (CLAUDE.md domain model); activation happens only via the renderer store's "
289
+ + 'approveProposed, never through ensureEpic()';
290
+ appendAuditEvent('epic_mint_refused', { cwd, epicId: explicitEpicId ?? null, status, reason });
291
+ throw new Error(reason);
292
+ }
293
+
260
294
  const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
261
295
  const now = new Date().toISOString();
262
296
  const session = {
@@ -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
+ };
@@ -85,7 +85,7 @@ const queueOps = require('./queueOps.cjs');
85
85
  // Plain Node module, no Electron dependency; queuePath/prdsDir defaults already
86
86
  // match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
87
87
  // home-dir layout.
88
- const { resolvePrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
88
+ const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
89
89
  const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
90
90
 
91
91
  // ---------- origin session resolution (PRD 832) ----------
@@ -486,6 +486,35 @@ function candidatePrdsDirs() {
486
486
  return [PRDS_DIR, ...resolvePrdsDirs()];
487
487
  }
488
488
 
489
+ /**
490
+ * Every `prds-archived/` dir across every project (the sibling-of-source
491
+ * layout archiveCompletedPrd writes into — see that function's comment).
492
+ * Used only by list-prds so the PRDs/Runs tabs can show an Epic's REAL
493
+ * historical PRD count, not just its currently-pending one. Deliberately
494
+ * excludes the flat legacy PRDS_ARCHIVE_DIR (manual `schedule:archive-prd`
495
+ * timestamped-subdir layout) — that path is for a different, unused-by-
496
+ * any-renderer manual archive flow, not Epic-scoped completion archiving.
497
+ */
498
+ function candidateArchivedPrdsDirs() {
499
+ return resolveArchivedPrdsDirs();
500
+ }
501
+
502
+ /**
503
+ * Resolve an archived PRD's real terminal outcome for `schedule:list-prds`:
504
+ * its live queue.json row (if the job hasn't aged out yet), else its
505
+ * history.jsonl row, else 'completed' — archiveCompletedPrd only ever
506
+ * archives a job whose effective status is 'completed' (a 'failed' job's
507
+ * PRD source stays in the live prds/ dir), so 'completed' is a safe
508
+ * default, not a guess; the live/history lookups are defensive in case
509
+ * that archiving invariant ever changes. Exported as a pure function (no
510
+ * IPC/fs) so this fallback chain is unit-testable without an Electron
511
+ * harness.
512
+ */
513
+ function resolveArchivedPrdStatus(slug, liveStatusBySlug, histBySlug) {
514
+ const resolved = liveStatusBySlug.get(slug) ?? histBySlug.get(slug)?.status ?? 'completed';
515
+ return resolved === 'failed' ? 'failed' : 'completed';
516
+ }
517
+
489
518
  /** The PRD-source directory for a given job cwd (falls back to DEFAULT_PROJECT_CWD). */
490
519
  function prdDirForCwd(cwd) {
491
520
  return resolvePrdWriteDir(cwd || DEFAULT_PROJECT_CWD);
@@ -536,22 +565,33 @@ async function archivedTwinExists(job) {
536
565
  }
537
566
 
538
567
  /**
539
- * Build the non-failure result + run meta for a job whose PRD has already
540
- * been archived (work shipped, queue entry is stale). Shared by both
541
- * PRD-read failure exits in executeJob so the stale-skip logic isn't
542
- * duplicated.
568
+ * Build the non-failure result + run meta for a queued job whose PRD source
569
+ * is gone. Shared by both PRD-read failure exits in executeJob so the
570
+ * stale-skip logic isn't duplicated. Two reasons, both exitCode: 0 (no RCA
571
+ * feedback item, since neither is a real work failure):
572
+ * - 'prd-archived': the source was archived — archivedTwinExists found it
573
+ * under a prds-archived/ dir. The work already shipped.
574
+ * - 'prd-missing': the source is gone everywhere (ENOENT on every
575
+ * candidate dir, no archived twin either) — most commonly a PRD that
576
+ * existed at enqueue time and was deleted before dispatch (e.g. a test
577
+ * fixture that leaked into the live queue and was cleaned up by its own
578
+ * `finally` block). Retire the row rather than fail it.
543
579
  */
544
- function prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath) {
545
- const archivedTwin = archivedPrdPathForJob(job);
546
- const msg = `PRD already archived (${archivedTwin}) — work shipped; retiring stale queue entry`;
580
+ function prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, reason = 'prd-archived') {
581
+ const msg = reason === 'prd-missing'
582
+ ? 'PRD source no longer exists on disk — retiring stale queue entry'
583
+ : (() => {
584
+ const archivedTwin = archivedPrdPathForJob(job);
585
+ return `PRD already archived (${archivedTwin}) — work shipped; retiring stale queue entry`;
586
+ })();
547
587
  safeLog(`[scheduler] ${msg}\n`);
548
588
  closeFd();
549
589
  const finishedAt = Date.now();
550
590
  config.writeJsonSync(metaPath, {
551
- slug: job.slug, cwd, sessionId, exitCode: 0, skipped: 'prd-archived',
591
+ slug: job.slug, cwd, sessionId, exitCode: 0, skipped: reason,
552
592
  note: msg, startedAt, finishedAt, durationMs: 0,
553
593
  });
554
- return { exitCode: 0, durationMs: 0, skipped: 'prd-archived', note: msg, sessionId };
594
+ return { exitCode: 0, durationMs: 0, skipped: reason, note: msg, sessionId };
555
595
  }
556
596
 
557
597
  /**
@@ -2003,16 +2043,34 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
2003
2043
  prompt = parsed.body + FINISH_PROTOCOL;
2004
2044
  prdPath = fallbackPath;
2005
2045
  } catch (e2) {
2046
+ // Found the dir a moment ago but the read still failed. Case A: the
2047
+ // source has since been archived — stale-skip as usual. Case B: it
2048
+ // was deleted out from under us in the window between findPrdDir and
2049
+ // parsePrd (ENOENT) with no archived twin — also a stale row, not a
2050
+ // real failure. Anything else (malformed/unreadable PRD) is a real
2051
+ // failure and keeps exitCode: -1.
2006
2052
  if (await archivedTwinExists(job)) {
2007
- return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath);
2053
+ return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-archived');
2054
+ }
2055
+ if (e2 && e2.code === 'ENOENT') {
2056
+ return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-missing');
2008
2057
  }
2009
2058
  safeLog(`[scheduler] failed to read PRD: ${e2?.message}\n`);
2010
2059
  closeFd();
2011
2060
  return { exitCode: -1, durationMs: 0, error: e2?.message };
2012
2061
  }
2013
2062
  } else {
2063
+ // No candidate dir has the slug at all. Case A: it was archived —
2064
+ // stale-skip. Case B: the read error is ENOENT (the common case: a
2065
+ // source that existed at enqueue time and is gone by dispatch, e.g. a
2066
+ // leaked test fixture) with no archived twin — retire as stale rather
2067
+ // than a hard failure. Anything else (a real read/parse error) still
2068
+ // fails the job.
2014
2069
  if (await archivedTwinExists(job)) {
2015
- return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath);
2070
+ return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-archived');
2071
+ }
2072
+ if (e && e.code === 'ENOENT') {
2073
+ return prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath, 'prd-missing');
2016
2074
  }
2017
2075
  safeLog(`[scheduler] failed to read PRD: ${e?.message}\n`);
2018
2076
  closeFd();
@@ -2458,6 +2516,16 @@ async function spawnInvestigation(failedJob, runDir) {
2458
2516
  return { deferred: false };
2459
2517
  }
2460
2518
 
2519
+ // Mark the job 'investigating' so the Queue UI shows an active status for
2520
+ // the whole probe duration — previously this left the job's persisted
2521
+ // status frozen at 'failed'/'needs_review' the entire time, which read as
2522
+ // "nothing is happening" even though an Opus process was actively running.
2523
+ await mutate((s) => {
2524
+ const j = s.jobs.find((x) => x.slug === failedJob.slug);
2525
+ if (j) j.status = 'investigating';
2526
+ });
2527
+ await broadcast({ flush: true });
2528
+
2461
2529
  const claudeBin = resolveClaudeBin();
2462
2530
  const childEnv = cleanChildEnv({ PATH: pathWithUserBins() }); // Homebrew/user bins for macOS
2463
2531
 
@@ -2495,6 +2563,14 @@ async function spawnInvestigation(failedJob, runDir) {
2495
2563
  watchdogs: [deadmanWatchdog],
2496
2564
  onExit({ exitCode, error, spawnFailed, safeLog: sl }) {
2497
2565
  releaseSlot();
2566
+ // Restore the pre-investigation status now that the probe has exited —
2567
+ // 'investigating' must never be the job's resting state.
2568
+ mutate((s) => {
2569
+ const j = s.jobs.find((x) => x.slug === failedJob.slug);
2570
+ if (j && j.status === 'investigating') j.status = failedJob.status || 'failed';
2571
+ })
2572
+ .then(() => broadcast({ flush: true }))
2573
+ .catch(() => {});
2498
2574
  if (error) {
2499
2575
  const errMsg = spawnFailed
2500
2576
  ? `investigation spawn failed: ${error?.message ?? String(error)}`
@@ -2541,8 +2617,15 @@ async function spawnInvestigation(failedJob, runDir) {
2541
2617
  return { deferred: false };
2542
2618
  } catch (e) {
2543
2619
  // A synchronous throw before onExit is wired (e.g. resolveClaudeBin not found,
2544
- // openLog failure, spawn setup) must not strand the reserved slot.
2620
+ // openLog failure, spawn setup) must not strand the reserved slot, and must
2621
+ // not strand the job on the transient 'investigating' status either.
2545
2622
  releaseSlot();
2623
+ mutate((s) => {
2624
+ const j = s.jobs.find((x) => x.slug === failedJob.slug);
2625
+ if (j && j.status === 'investigating') j.status = failedJob.status || 'failed';
2626
+ })
2627
+ .then(() => broadcast({ flush: true }))
2628
+ .catch(() => {});
2546
2629
  throw e;
2547
2630
  }
2548
2631
  }
@@ -3167,7 +3250,11 @@ async function maybeLaunchWhenAvailable(state) {
3167
3250
  */
3168
3251
  async function reapDeadRunningJobs() {
3169
3252
  try {
3170
- if (runningSet.size === 0) return; // fast path: no in-flight jobs
3253
+ // Do NOT gate on runningSet: spawnJob()'s finally block unconditionally
3254
+ // deletes a job's slug from runningSet even when the preceding completion
3255
+ // mutate() threw and was swallowed, leaving queue.json stuck at
3256
+ // status:"running" with no slug left in runningSet to trigger reconciliation.
3257
+ // queue.json is the source of truth for which jobs are actually running.
3171
3258
  const state = await readQueue();
3172
3259
  const dead = [];
3173
3260
  for (const j of state.jobs) {
@@ -3925,7 +4012,9 @@ function registerScheduleHandlers() {
3925
4012
  ipcMain.handle('schedule:list-prds', async () => {
3926
4013
  ensureDirs();
3927
4014
  const out = [];
3928
- for (const dir of candidatePrdsDirs()) {
4015
+ const seenSlugs = new Set();
4016
+
4017
+ async function readDirInto(dir, { archived }) {
3929
4018
  let entries;
3930
4019
  try {
3931
4020
  entries = await fsp.readdir(dir);
@@ -3933,15 +4022,21 @@ function registerScheduleHandlers() {
3933
4022
  if (e?.code !== 'ENOENT') {
3934
4023
  logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: readdir failed', meta: { dir, error: e?.message } });
3935
4024
  }
3936
- continue;
4025
+ return;
3937
4026
  }
3938
4027
  for (const name of entries) {
3939
4028
  if (!name.endsWith('.md') || name.startsWith('.')) continue;
3940
4029
  const filePath = path.join(dir, name);
3941
4030
  try {
3942
4031
  const parsed = await parsePrd(filePath);
4032
+ // A slug can't be both live and archived at once, but a duplicate
4033
+ // slug found in two archive dirs (shouldn't happen — archiving is
4034
+ // a single rename — but is cheap to guard) is skipped rather than
4035
+ // double-counted.
4036
+ if (seenSlugs.has(parsed.slug)) continue;
4037
+ seenSlugs.add(parsed.slug);
3943
4038
  const stat = await fsp.stat(filePath);
3944
- out.push({
4039
+ const entry = {
3945
4040
  slug: parsed.slug,
3946
4041
  parallelGroup: parsed.parallelGroup,
3947
4042
  title: parsed.title,
@@ -3950,12 +4045,46 @@ function registerScheduleHandlers() {
3950
4045
  sourcePromptId: parsed.sourcePromptId,
3951
4046
  epicId: parsed.epicId ?? null,
3952
4047
  mtimeMs: stat.mtimeMs,
3953
- });
4048
+ archived,
4049
+ };
4050
+ out.push(entry);
3954
4051
  } catch (e) {
3955
4052
  logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: skipping unparseable file', meta: { name, error: e?.message } });
3956
4053
  }
3957
4054
  }
3958
4055
  }
4056
+
4057
+ // Live PRDs first, so an archived duplicate (shouldn't exist, but a
4058
+ // stale rename copy is possible) never shadows the still-runnable live
4059
+ // entry.
4060
+ for (const dir of candidatePrdsDirs()) {
4061
+ await readDirInto(dir, { archived: false });
4062
+ }
4063
+
4064
+ const archivedStart = out.length;
4065
+ for (const dir of candidateArchivedPrdsDirs()) {
4066
+ await readDirInto(dir, { archived: true });
4067
+ }
4068
+
4069
+ // Archived PRDs need a status: archiveCompletedPrd (scheduler.cjs) only
4070
+ // ever archives a job whose effective status is 'completed' — a 'failed'
4071
+ // job's PRD source stays in the live prds/ dir (still visible/countable
4072
+ // there already). Still resolve the real job status defensively (live
4073
+ // queue row, falling back to history.jsonl) rather than hard-coding
4074
+ // 'completed', so this stays correct if that archiving invariant ever
4075
+ // changes.
4076
+ if (out.length > archivedStart) {
4077
+ const [state, histBySlug] = await Promise.all([
4078
+ readQueue(),
4079
+ queueHistory.historyTerminalBySlug().catch(() => new Map()),
4080
+ ]);
4081
+ const liveStatusBySlug = new Map(state.jobs.map((j) => [j.slug, j.status]));
4082
+ for (let i = archivedStart; i < out.length; i++) {
4083
+ const entry = out[i];
4084
+ entry.archivedStatus = resolveArchivedPrdStatus(entry.slug, liveStatusBySlug, histBySlug);
4085
+ }
4086
+ }
4087
+
3959
4088
  out.sort((a, b) => a.slug.localeCompare(b.slug, undefined, { numeric: true }));
3960
4089
  return out;
3961
4090
  });
@@ -4380,4 +4509,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
4380
4509
  });
4381
4510
  }
4382
4511
 
4383
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, 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, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields };
4512
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, 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, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
@@ -385,7 +385,7 @@ export interface ScheduleConfig {
385
385
  schemaVersion: 1;
386
386
  }
387
387
 
388
- export type ScheduleJobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'needs_review';
388
+ export type ScheduleJobStatus = 'pending' | 'running' | 'investigating' | 'completed' | 'failed' | 'needs_review';
389
389
 
390
390
  export interface ScheduleJobRuntime {
391
391
  pid: number;
@@ -490,6 +490,15 @@ export interface PrdListItem {
490
490
  sourcePromptId?: string | null;
491
491
  /** Owning Epic id, derived from the PRD's directory. See ScheduleJob.epicId. */
492
492
  epicId?: string | null;
493
+ /** True when this PRD's source .md was found in a `prds-archived/` dir
494
+ * (its scheduler job already ran to completion) rather than the live
495
+ * `prds/` dir. Archived PRDs have no matching queue.json job row (the
496
+ * row may have aged out into history.jsonl), so consumers must read
497
+ * `archivedStatus` instead of joining on a job to know the outcome. */
498
+ archived?: boolean;
499
+ /** Only present when `archived` is true — the resolved terminal outcome
500
+ * ('completed' vs 'failed') of the job that produced this archived PRD. */
501
+ archivedStatus?: 'completed' | 'failed';
493
502
  }
494
503
 
495
504
  export interface SupervisorConfig {
@@ -959,6 +968,19 @@ export type ProjectBriefUpdateResult =
959
968
  | { ok: true; brief: ProjectBrief }
960
969
  | { ok: false; error: string };
961
970
 
971
+ // ────────────────────────────────────────────── Project Pages (PRD 929-932)
972
+ export interface ProjectPagesOutput {
973
+ marketing: string;
974
+ feature: string;
975
+ architecture: string;
976
+ generatedAt: string;
977
+ }
978
+
979
+ export interface ProjectPagesGetResult {
980
+ /** null when no manifest/output files exist yet — the empty-state signal. */
981
+ output: ProjectPagesOutput | null;
982
+ }
983
+
962
984
  // ────────────────────────────────────────────── Per-subagent memory
963
985
  // Stored at ~/.claude/session-manager/agent-memory/<agentId>.json. Keyed by
964
986
  // agent name (the .md filename in ~/.claude/agents/), not by workspace cwd.
@@ -1109,7 +1131,7 @@ export interface ChatCreatePrdPayload {
1109
1131
  /** Originating tab id — used at job completion to route a status prompt back into the tab. */
1110
1132
  sourceTabId?: string;
1111
1133
  /** User-selected Feature/Bug tag (PRD 774) carried from the originating PromptTicket. */
1112
- tag?: 'feature' | 'bug' | 'discussion' | 'build';
1134
+ tag?: 'feature' | 'bug' | 'discussion' | 'build' | 'project-home-builder';
1113
1135
  }
1114
1136
 
1115
1137
  export type ChatCreatePrdResult =
@@ -1520,6 +1542,10 @@ export interface SessionManagerAPI {
1520
1542
  /** Hand-edit brief.json in place — no LLM cost. Edited pinnable blocks are auto-pinned so the next refresh preserves them. */
1521
1543
  update: (cwd: string, patch: ProjectBriefPatch) => Promise<ProjectBriefUpdateResult>;
1522
1544
  };
1545
+ projectPages: {
1546
+ /** Read output/*.html + manifest.json (or `{output: null}` if none exist yet). Never fires an LLM call. */
1547
+ get: (cwd: string) => Promise<ProjectPagesGetResult>;
1548
+ };
1523
1549
  promptSessionTranscript: {
1524
1550
  /** Append one full-text turn to an Epic's durable JSONL transcript. Best-effort — resolves `{ok:false}` rather than throwing on failure. */
1525
1551
  append: (
@@ -1530,6 +1556,15 @@ export interface SessionManagerAPI {
1530
1556
  /** Read back an Epic's full-text turns (optionally capped to the last `limit`). Skips corrupt lines rather than throwing. */
1531
1557
  read: (cwd: string, epicId: string, limit?: number) => Promise<{ turns: PromptSessionTranscriptTurn[] }>;
1532
1558
  };
1559
+ auditLog: {
1560
+ /** Append one Epic-lifecycle audit event to ~/.claude/session-manager/audit-log.jsonl.
1561
+ * Kind allowlist is enforced main-side (ipcSchemas.cjs) — this channel is
1562
+ * append-only, no read/list IPC exists. */
1563
+ append: (
1564
+ kind: 'epic_create' | 'epic_approve' | 'epic_complete' | 'epic_delete' | 'epic_resume' | 'epic_duplicate',
1565
+ fields: { cwd: string; epicId: string; source: string },
1566
+ ) => Promise<{ ok: boolean }>;
1567
+ };
1533
1568
  agentMemory: {
1534
1569
  /** List all memory entries for one subagent. Sorted newest first. */
1535
1570
  list: (agentId: string) => Promise<AgentMemoryListResult>;
@@ -330,12 +330,18 @@ contextBridge.exposeInMainWorld('api', {
330
330
  setPin: (cwd, block, pinned) => ipcRenderer.invoke('project-brief:set-pin', { cwd, block, pinned }),
331
331
  update: (cwd, patch) => ipcRenderer.invoke('project-brief:update', { cwd, patch }),
332
332
  },
333
+ projectPages: {
334
+ get: (cwd) => ipcRenderer.invoke('project-pages:get', { cwd }),
335
+ },
333
336
  promptSessionTranscript: {
334
337
  append: (cwd, epicId, turn) =>
335
338
  ipcRenderer.invoke('promptSessionTranscript:append', { cwd, epicId, ...turn }),
336
339
  read: (cwd, epicId, limit) =>
337
340
  ipcRenderer.invoke('promptSessionTranscript:read', { cwd, epicId, ...(limit ? { limit } : {}) }),
338
341
  },
342
+ auditLog: {
343
+ append: (kind, fields) => ipcRenderer.invoke('auditLog:append', { kind, ...fields }),
344
+ },
339
345
  agentMemory: {
340
346
  list: (agentId) => ipcRenderer.invoke('agent-memory:list', { agentId }),
341
347
  get: (agentId, entryId) => ipcRenderer.invoke('agent-memory:get', { agentId, entryId }),