claude-code-session-manager 0.52.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.
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-hNso_ykJ.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-D1v4VU6k.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-CKP60_3d.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-BoOiJJOr.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.52.0",
3
+ "version": "0.53.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",
@@ -200,6 +200,116 @@ test('findJoinableEpic falls through to the similarity check when preferEpicId p
200
200
  expect(joined).toEqual({ epicId: sibling.epicId, matchedBy: 'similarity', score: expect.any(Number) });
201
201
  });
202
202
 
203
+ test('ensureEpic mints with status "proposed" by default', async () => {
204
+ const cwd = await mkCwd();
205
+
206
+ const minted = await ensureEpic(cwd, { goalText: 'default status epic' });
207
+
208
+ const index = readActiveIndex(cwd);
209
+ expect(index.sessions[minted.epicId].status).toBe('proposed');
210
+ });
211
+
212
+ test('ensureEpic throws when a mint is requested with status "active" (born-proposed law), and appends an audit event', async () => {
213
+ const cwd = await mkCwd();
214
+ const auditLog = require('../lib/auditLog.cjs');
215
+
216
+ await expect(
217
+ ensureEpic(cwd, { goalText: 'should never mint active', status: 'active' }),
218
+ ).rejects.toThrow(/born 'proposed'/);
219
+
220
+ const index = readActiveIndex(cwd);
221
+ expect(Object.keys(index.sessions)).toHaveLength(0);
222
+
223
+ const records = fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
224
+ .split('\n')
225
+ .filter(Boolean)
226
+ .map((line) => JSON.parse(line))
227
+ .filter((r) => r.cwd === cwd);
228
+ expect(records).toHaveLength(1);
229
+ expect(records[0].kind).toBe('epic_mint_refused');
230
+ expect(records[0].status).toBe('active');
231
+ });
232
+
233
+ test('ensureEpic still joins an existing active Epic via preferEpicId even when status:"active" is requested', async () => {
234
+ const cwd = await mkCwd();
235
+ const first = await ensureEpic(cwd, { goalText: 'already running epic' });
236
+ const index = readActiveIndex(cwd);
237
+ index.sessions[first.epicId].status = 'active';
238
+ fs.writeFileSync(
239
+ path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
240
+ JSON.stringify(index, null, 2),
241
+ );
242
+
243
+ const second = await ensureEpic(cwd, { goalText: 'unrelated text', epicId: first.epicId, status: 'active' });
244
+
245
+ expect(second.created).toBe(false);
246
+ expect(second.epicId).toBe(first.epicId);
247
+ });
248
+
249
+ test('ensureEpic falls through to a "proposed" mint when explicit epicId is archived/completed, never throwing', async () => {
250
+ const cwd = await mkCwd();
251
+ const first = await ensureEpic(cwd, { goalText: 'completed epic' });
252
+ const index = readActiveIndex(cwd);
253
+ index.sessions[first.epicId].status = 'completed';
254
+ fs.writeFileSync(
255
+ path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
256
+ JSON.stringify(index, null, 2),
257
+ );
258
+
259
+ const second = await ensureEpic(cwd, { goalText: 'totally unrelated goal text here', epicId: first.epicId });
260
+
261
+ expect(second.created).toBe(true);
262
+ expect(second.epicId).not.toBe(first.epicId);
263
+ const after = readActiveIndex(cwd);
264
+ expect(after.sessions[second.epicId].status).toBe('proposed');
265
+ });
266
+
267
+ function readAuditRecordsForCwd(auditLog, cwd) {
268
+ if (!fs.existsSync(auditLog.AUDIT_LOG_PATH)) return [];
269
+ return fs.readFileSync(auditLog.AUDIT_LOG_PATH, 'utf8')
270
+ .split('\n')
271
+ .filter(Boolean)
272
+ .map((line) => {
273
+ try {
274
+ return JSON.parse(line);
275
+ } catch {
276
+ return null;
277
+ }
278
+ })
279
+ .filter((record) => record && record.cwd === cwd);
280
+ }
281
+
282
+ test('mintIfMissing:false refusal appends an epic_mint_refused audit event', async () => {
283
+ const cwd = await mkCwd();
284
+ const auditLog = require('../lib/auditLog.cjs');
285
+
286
+ await expect(ensureEpic(cwd, { goalText: 'no epic yet', mintIfMissing: false })).rejects.toThrow();
287
+
288
+ const records = readAuditRecordsForCwd(auditLog, cwd);
289
+ expect(records).toHaveLength(1);
290
+ expect(records[0].kind).toBe('epic_mint_refused');
291
+ expect(records[0].cwd).toBe(cwd);
292
+ });
293
+
294
+ test('not-open explicit-epicId fall-through appends an epic_mint_refused audit event', async () => {
295
+ const cwd = await mkCwd();
296
+ const first = await ensureEpic(cwd, { goalText: 'to be completed' });
297
+ const index = readActiveIndex(cwd);
298
+ index.sessions[first.epicId].status = 'completed';
299
+ fs.writeFileSync(
300
+ path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
301
+ JSON.stringify(index, null, 2),
302
+ );
303
+ const auditLog = require('../lib/auditLog.cjs');
304
+
305
+ await ensureEpic(cwd, { goalText: 'unrelated fresh goal xyz', epicId: first.epicId });
306
+
307
+ const records = readAuditRecordsForCwd(auditLog, cwd);
308
+ const refusal = records.find((r) => r.kind === 'epic_mint_refused');
309
+ expect(refusal).toBeDefined();
310
+ expect(refusal.epicId).toBe(first.epicId);
311
+ });
312
+
203
313
  test('ensureEpic with forceNewEpic:true mints even when a near-identical open Epic exists', async () => {
204
314
  const cwd = await mkCwd();
205
315
 
@@ -532,6 +532,11 @@ test('resolveSourcePromptIdFromClaudeSession finds the Epic whose claudeSessionI
532
532
  }
533
533
  });
534
534
 
535
+ // Real-homedir-rooted like the chat-only-epic test above (see its comment at
536
+ // line ~449): validatePath's read boundary is home-dir-wide, and this
537
+ // exercises createPrd's real config.addAllowedRoot/writeTextAtomic path. Safe
538
+ // because the random suffix is never a registered project the live scheduler
539
+ // scans for PRDs, and it's removed in `finally`.
535
540
  test('createPrd resolves sourcePromptId from originClaudeSessionId when the caller omitted sourcePromptId', async () => {
536
541
  const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-epic-'));
537
542
  config.addAllowedRoot(root);
@@ -555,6 +560,7 @@ test('createPrd resolves sourcePromptId from originClaudeSessionId when the call
555
560
  }
556
561
  });
557
562
 
563
+ // Same real-homedir rationale as the two tests above.
558
564
  test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
559
565
  const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-no-match-'));
560
566
  config.addAllowedRoot(root);
@@ -105,6 +105,11 @@ test('migratePrds ignores non-.md and dotfiles, and no-ops on a missing legacy d
105
105
 
106
106
  test('migratePrds expands a tilde-prefixed cwd and moves the file', async () => {
107
107
  const legacyDir = await mkLegacyDir();
108
+ // Deliberately rooted under the REAL os.homedir() (not os.tmpdir()) — the
109
+ // assertion under test is tilde-expansion relative to the actual home dir,
110
+ // so a real homedir path is load-bearing here, not incidental. Still safe:
111
+ // the random suffix is never a registered project the live scheduler scans
112
+ // for PRDs, and it's removed in afterEach via tmpDirs/fsp.rm.
108
113
  const projectCwd = await fsp.mkdtemp(path.join(os.homedir(), '.sm-prdmigration-tilde-'));
109
114
  tmpDirs.push(projectCwd);
110
115
  const tildeCwd = path.join('~', path.relative(os.homedir(), projectCwd));
@@ -6,20 +6,53 @@
6
6
  * `<cwd>/session-manager-operations/scheduler/prds/`) must still resolve,
7
7
  * since findPrdDir's candidatePrdsDirs() searches the legacy dir first.
8
8
  *
9
- * Touches the real legacy PRDS_DIR (scheduler.cjs hard-codes it from
10
- * os.homedir(), same as other scheduler unit tests that exercise
11
- * global-path-backed exports) with a unique slug, and cleans up after itself.
9
+ * HOME-isolated (PRD 812 incident fix): scheduler.cjs derives PRDS_DIR from
10
+ * os.homedir() at module load. This test used to write straight into the
11
+ * REAL `~/.claude/session-manager/scheduled-plans/prds/` — the live
12
+ * scheduler's `reconcile()` scans that dir every 60s and enqueued the
13
+ * fixture as a phantom job (job 812-test-legacy-only-1975929-233060) before
14
+ * this test's own cleanup ran. Now HOME is stubbed to a mkdtemp dir BEFORE
15
+ * scheduler.cjs is first required, so PRDS_DIR resolves under the temp dir
16
+ * and nothing here can reach the real scheduler root. The
17
+ * `PRDS_DIR.startsWith(tmpHome)` guard is the regression test for that
18
+ * incident: if module-load ordering ever changes so the real homedir gets
19
+ * snapshotted first, this throws loudly instead of silently poisoning the
20
+ * live queue again.
12
21
  *
13
22
  * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-find-prd-dir.test.cjs
14
23
  */
15
24
 
16
25
  'use strict';
17
26
 
18
- import { test, expect } from 'vitest';
27
+ import { test, expect, beforeAll, afterAll } from 'vitest';
19
28
  const fs = require('node:fs');
20
29
  const os = require('node:os');
21
30
  const path = require('node:path');
22
- const { findPrdDir, PRDS_DIR } = require('../scheduler.cjs');
31
+
32
+ let tmpHome;
33
+ let originalHome;
34
+ let findPrdDir;
35
+ let PRDS_DIR;
36
+
37
+ beforeAll(() => {
38
+ originalHome = process.env.HOME;
39
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-find-prd-dir-'));
40
+ process.env.HOME = tmpHome;
41
+
42
+ // Load AFTER HOME is stubbed — scheduler.cjs snapshots os.homedir() into
43
+ // PRDS_DIR at module load, not lazily.
44
+ ({ findPrdDir, PRDS_DIR } = require('../scheduler.cjs'));
45
+
46
+ // Hard guard: never let this test write into the real scheduler root.
47
+ if (!PRDS_DIR.startsWith(tmpHome)) {
48
+ throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
49
+ }
50
+ });
51
+
52
+ afterAll(() => {
53
+ process.env.HOME = originalHome;
54
+ fs.rmSync(tmpHome, { recursive: true, force: true });
55
+ });
23
56
 
24
57
  test('findPrdDir resolves a slug that exists only in the legacy global PRDs dir', async () => {
25
58
  const slug = `812-test-legacy-only-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * scheduler-prd-missing-skip.test.cjs — regression test for PRD 812's
3
+ * failure-classification fix: a queued job whose PRD source has disappeared
4
+ * between enqueue and dispatch (e.g. a test fixture that leaked into the
5
+ * live queue and was cleaned up before the job ran) must be retired as a
6
+ * stale row (exitCode: 0, skipped: 'prd-missing'), not treated as a hard
7
+ * job failure (exitCode: -1). A genuinely unreadable/unparseable PRD (a
8
+ * real, non-ENOENT error) must still fail with exitCode: -1.
9
+ *
10
+ * HOME-isolated per the same incident: stub HOME to a mkdtemp dir before
11
+ * requiring scheduler.cjs, and never write into the real scheduler root.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-prd-missing-skip.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, beforeAll, afterAll } from 'vitest';
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ let tmpHome;
24
+ let originalHome;
25
+ let executeJob;
26
+ let PRDS_DIR;
27
+
28
+ beforeAll(() => {
29
+ originalHome = process.env.HOME;
30
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-skip-'));
31
+ process.env.HOME = tmpHome;
32
+
33
+ ({ executeJob, PRDS_DIR } = require('../scheduler.cjs'));
34
+
35
+ if (!PRDS_DIR.startsWith(tmpHome)) {
36
+ throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
37
+ }
38
+ });
39
+
40
+ afterAll(() => {
41
+ process.env.HOME = originalHome;
42
+ fs.rmSync(tmpHome, { recursive: true, force: true });
43
+ });
44
+
45
+ test('a job whose PRD source is gone everywhere is retired, not failed', async () => {
46
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-project-'));
47
+ // config.cjs's writeJsonSync (used for the job's meta.json) restricts
48
+ // writes to WRITE_PREFIXES, which includes `<home>/.claude` — so runDir
49
+ // must live under the stubbed tmpHome's .claude/, not the system tmpdir.
50
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
51
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-missing-run-'));
52
+ try {
53
+ const slug = `812-test-missing-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
54
+ const job = { slug, cwd: projectCwd };
55
+
56
+ const result = await executeJob(job, runDir, projectCwd, () => {});
57
+
58
+ expect(result.exitCode).toBe(0);
59
+ expect(result.skipped).toBe('prd-missing');
60
+ } finally {
61
+ fs.rmSync(projectCwd, { recursive: true, force: true });
62
+ fs.rmSync(runDir, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ test('a job whose PRD source exists but is unreadable for a non-ENOENT reason still fails', async () => {
67
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-toobig-project-'));
68
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
69
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-toobig-run-'));
70
+ try {
71
+ const slug = `812-test-toobig-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
72
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
73
+ fs.mkdirSync(prdsDir, { recursive: true });
74
+ // parsePrd rejects any PRD over 1MB with a plain Error (no .code) — this
75
+ // is the "found but unparseable" case, distinct from ENOENT, and must
76
+ // stay a real failure.
77
+ const oversized = '---\ntitle: too big\n---\n\n' + 'x'.repeat(1024 * 1024 + 1);
78
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), oversized, 'utf8');
79
+
80
+ const job = { slug, cwd: projectCwd };
81
+ const result = await executeJob(job, runDir, projectCwd, () => {});
82
+
83
+ expect(result.exitCode).toBe(-1);
84
+ expect(result.skipped).toBeUndefined();
85
+ } finally {
86
+ fs.rmSync(projectCwd, { recursive: true, force: true });
87
+ fs.rmSync(runDir, { recursive: true, force: true });
88
+ }
89
+ });
@@ -6,6 +6,14 @@ import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
8
 
9
+ // HOME stubbed BEFORE requiring scheduler.cjs (PRD 812 fix): the last
10
+ // describe block below calls scheduler.reconcile(), which internally calls
11
+ // ensureDirs() → fs.mkdirSync on PRDS_DIR/RUNS_DIR — paths baked from
12
+ // os.homedir() at require time. Without this stub that mkdirSync lands in
13
+ // the real `~/.claude/session-manager/scheduled-plans/` tree.
14
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-832-home-'));
15
+ process.env.HOME = tmpHome;
16
+
9
17
  const prdParser = require('../scheduler/prdParser.cjs');
10
18
  const { pickForProject } = require('../lib/schedulerBatch.cjs');
11
19
 
@@ -28,6 +28,7 @@ const voiceWizard = require('./voiceWizard.cjs');
28
28
  const scheduler = require('./scheduler.cjs');
29
29
  const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
30
30
  const prdCreate = require('./lib/prdCreate.cjs');
31
+ const { appendAuditEvent } = require('./lib/auditLog.cjs');
31
32
  const chatRunner = require('./chatRunner.cjs');
32
33
  const promptSessionEvents = require('./promptSessionEvents.cjs');
33
34
  const agentLibrary = require('./agentLibrary.cjs');
@@ -799,6 +800,12 @@ ipcMain.handle('promptSessionTranscript:read', validated(schemas.promptSessionTr
799
800
  const turns = await promptSessionTranscript.readTurns(cwd, epicId, { limit });
800
801
  return { turns };
801
802
  }));
803
+ // Renderer Epic lifecycle trace (PRD 940) — append-only, no read/list IPC.
804
+ // Kind allowlist enforced by schemas.auditLogAppend, not here.
805
+ ipcMain.handle('auditLog:append', validated(schemas.auditLogAppend, async ({ kind, cwd, epicId, source }) => {
806
+ appendAuditEvent(kind, { cwd, epicId, source });
807
+ return { ok: true };
808
+ }));
802
809
  agentMemory.registerAgentMemoryHandlers();
803
810
  git.register(ipcMain);
804
811
  filesIpc.registerFilesHandlers();
@@ -493,6 +493,25 @@ const promptSessionTranscriptRead = z.object({
493
493
  limit: z.number().int().positive().max(10_000).optional(),
494
494
  }).strict();
495
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
+
496
515
  // ──────────────────────────────────────────── Per-subagent memory
497
516
  // Distinct from the workspace-scoped Memory tool: agentMemory is keyed by
498
517
  // subagent name (the .md filename in ~/.claude/agents/, e.g. "code-reviewer"),
@@ -889,6 +908,7 @@ module.exports = {
889
908
  projectBriefUpdate,
890
909
  promptSessionTranscriptAppend,
891
910
  promptSessionTranscriptRead,
911
+ auditLogAppend,
892
912
  agentMemoryList,
893
913
  agentMemoryGet,
894
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
@@ -223,6 +227,12 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
223
227
  return { epicId: explicitEpicId, prdDir, created: false };
224
228
  }
225
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
+ });
226
236
  }
227
237
 
228
238
  if (reuseByGoal) {
@@ -251,11 +261,11 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
251
261
  }
252
262
 
253
263
  if (!mintIfMissing) {
254
- throw new Error(
255
- `ensureEpic: no existing Epic found (epicId=${explicitEpicId ?? 'none'}) and mintIfMissing is false — `
256
- + 'a new Epic can only be created by explicit human intent (New Epic UI, or /propose-epic + Approve & start), '
257
- + 'never implicitly by a PRD-authoring path',
258
- );
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'})`);
259
269
  }
260
270
 
261
271
  if (!forceNewEpic) {
@@ -267,6 +277,20 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
267
277
  }
268
278
  }
269
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
+
270
294
  const epicId = `${slugify(goalText)}-${crypto.randomUUID().slice(0, 8)}`;
271
295
  const now = new Date().toISOString();
272
296
  const session = {
@@ -565,22 +565,33 @@ async function archivedTwinExists(job) {
565
565
  }
566
566
 
567
567
  /**
568
- * Build the non-failure result + run meta for a job whose PRD has already
569
- * been archived (work shipped, queue entry is stale). Shared by both
570
- * PRD-read failure exits in executeJob so the stale-skip logic isn't
571
- * 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.
572
579
  */
573
- function prdArchivedSkipResult(job, cwd, sessionId, startedAt, safeLog, closeFd, metaPath) {
574
- const archivedTwin = archivedPrdPathForJob(job);
575
- 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
+ })();
576
587
  safeLog(`[scheduler] ${msg}\n`);
577
588
  closeFd();
578
589
  const finishedAt = Date.now();
579
590
  config.writeJsonSync(metaPath, {
580
- slug: job.slug, cwd, sessionId, exitCode: 0, skipped: 'prd-archived',
591
+ slug: job.slug, cwd, sessionId, exitCode: 0, skipped: reason,
581
592
  note: msg, startedAt, finishedAt, durationMs: 0,
582
593
  });
583
- return { exitCode: 0, durationMs: 0, skipped: 'prd-archived', note: msg, sessionId };
594
+ return { exitCode: 0, durationMs: 0, skipped: reason, note: msg, sessionId };
584
595
  }
585
596
 
586
597
  /**
@@ -2032,16 +2043,34 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
2032
2043
  prompt = parsed.body + FINISH_PROTOCOL;
2033
2044
  prdPath = fallbackPath;
2034
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.
2035
2052
  if (await archivedTwinExists(job)) {
2036
- 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');
2037
2057
  }
2038
2058
  safeLog(`[scheduler] failed to read PRD: ${e2?.message}\n`);
2039
2059
  closeFd();
2040
2060
  return { exitCode: -1, durationMs: 0, error: e2?.message };
2041
2061
  }
2042
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.
2043
2069
  if (await archivedTwinExists(job)) {
2044
- 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');
2045
2074
  }
2046
2075
  safeLog(`[scheduler] failed to read PRD: ${e?.message}\n`);
2047
2076
  closeFd();
@@ -4480,4 +4509,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
4480
4509
  });
4481
4510
  }
4482
4511
 
4483
- 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 };
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 };
@@ -1556,6 +1556,15 @@ export interface SessionManagerAPI {
1556
1556
  /** Read back an Epic's full-text turns (optionally capped to the last `limit`). Skips corrupt lines rather than throwing. */
1557
1557
  read: (cwd: string, epicId: string, limit?: number) => Promise<{ turns: PromptSessionTranscriptTurn[] }>;
1558
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
+ };
1559
1568
  agentMemory: {
1560
1569
  /** List all memory entries for one subagent. Sorted newest first. */
1561
1570
  list: (agentId: string) => Promise<AgentMemoryListResult>;
@@ -339,6 +339,9 @@ contextBridge.exposeInMainWorld('api', {
339
339
  read: (cwd, epicId, limit) =>
340
340
  ipcRenderer.invoke('promptSessionTranscript:read', { cwd, epicId, ...(limit ? { limit } : {}) }),
341
341
  },
342
+ auditLog: {
343
+ append: (kind, fields) => ipcRenderer.invoke('auditLog:append', { kind, ...fields }),
344
+ },
342
345
  agentMemory: {
343
346
  list: (agentId) => ipcRenderer.invoke('agent-memory:list', { agentId }),
344
347
  get: (agentId, entryId) => ipcRenderer.invoke('agent-memory:get', { agentId, entryId }),