claude-code-session-manager 0.41.0 → 0.42.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-H7rwsoKV.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-D4ga1iZ5.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-CKY3mHgV.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-CxGY-V5G.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.41.0",
3
+ "version": "0.42.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",
@@ -313,9 +313,16 @@ async function emitFeedbackPRD(cwd, {
313
313
  // Every PRD belongs to an Epic (CLAUDE.md domain model). The sweep joins
314
314
  // one standing "Inbound feedback" Epic per project (reuseByGoal) so
315
315
  // recurring ticks chain into a single Epic rather than minting one each.
316
+ // This is an unattended automated sweep, not a human action — status
317
+ // MUST be 'proposed' (same as lib/rcaFeedbackHook.cjs) so the Epic lands
318
+ // behind the Approve & start human gate instead of running immediately;
319
+ // an 'active' default here would let an unattended process mint and
320
+ // execute PRDs with zero human approval, exactly the hole the 'proposed'
321
+ // gate exists to close.
316
322
  const epic = await ensureEpic(cwd, {
317
323
  goalText: 'Inbound feedback processing',
318
324
  tag: 'discussion',
325
+ status: 'proposed',
319
326
  reuseByGoal: true,
320
327
  });
321
328
  epicId = epic.epicId;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * opsErrorLog.test.cjs — appendError() is the sole writer of the `logs` ops
3
+ * namespace (single-writer law, opsOwnership.cjs). Verifies: writes a tagged
4
+ * JSONL line to <cwd>/session-manager-operations/logs/errors-<date>.jsonl,
5
+ * redacts sensitive meta keys, and no-ops (never throws) with no cwd.
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/opsErrorLog.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test } from 'vitest';
13
+ import assert from 'node:assert/strict';
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+
18
+ const opsErrorLog = require('../lib/opsErrorLog.cjs');
19
+
20
+ function mkTmpProject() {
21
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'sm-opslog-test-'));
22
+ }
23
+
24
+ function readLines(cwd) {
25
+ const dir = opsErrorLog.logsDir(cwd);
26
+ const file = opsErrorLog.todayFile(cwd);
27
+ if (!fs.existsSync(file)) return [];
28
+ return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
29
+ }
30
+
31
+ test('appendError writes a tagged JSONL line', () => {
32
+ const cwd = mkTmpProject();
33
+ opsErrorLog.appendError({
34
+ cwd,
35
+ scope: 'pty',
36
+ tabId: 'tab-123',
37
+ tags: ['spawn-failed'],
38
+ message: 'pty.spawn threw: boom',
39
+ });
40
+ const lines = readLines(cwd);
41
+ assert.equal(lines.length, 1);
42
+ const [line] = lines;
43
+ assert.equal(line.level, 'error');
44
+ assert.equal(line.scope, 'pty');
45
+ assert.equal(line.tabId, 'tab-123');
46
+ assert.equal(line.message, 'pty.spawn threw: boom');
47
+ assert.ok(line.tags.includes('level:error'));
48
+ assert.ok(line.tags.includes('scope:pty'));
49
+ assert.ok(line.tags.includes('tab:tab-123'));
50
+ assert.ok(line.tags.includes('spawn-failed'));
51
+ assert.ok(typeof line.ts === 'string' && !Number.isNaN(Date.parse(line.ts)));
52
+ });
53
+
54
+ test('appendError redacts sensitive meta keys', () => {
55
+ const cwd = mkTmpProject();
56
+ opsErrorLog.appendError({
57
+ cwd,
58
+ scope: 'chatRunner',
59
+ message: 'run failed',
60
+ meta: { token: 'sk-secret', sessionId: 'abc', password: 'hunter2' },
61
+ });
62
+ const [line] = readLines(cwd);
63
+ assert.equal(line.meta.token, '[redacted]');
64
+ assert.equal(line.meta.password, '[redacted]');
65
+ assert.equal(line.meta.sessionId, 'abc');
66
+ });
67
+
68
+ test('appendError is a no-op with no cwd', () => {
69
+ // Must not throw, and there is nowhere to assert a file was NOT written
70
+ // since no cwd means no path — this just proves it never throws.
71
+ assert.doesNotThrow(() => opsErrorLog.appendError({ scope: 'x', message: 'y' }));
72
+ });
73
+
74
+ test('appendError merges caller tags without duplicating auto-derived ones', () => {
75
+ const cwd = mkTmpProject();
76
+ opsErrorLog.appendError({
77
+ cwd,
78
+ scope: 'chatRunner',
79
+ tabId: 'tab-9',
80
+ tags: ['level:error', 'silent-probe'], // deliberately duplicates an auto tag
81
+ message: 'probe failed',
82
+ });
83
+ const [line] = readLines(cwd);
84
+ const count = line.tags.filter((t) => t === 'level:error').length;
85
+ assert.equal(count, 1);
86
+ assert.ok(line.tags.includes('silent-probe'));
87
+ });
@@ -23,6 +23,7 @@ const {
23
23
  PRD_CREATE_SLUG_RE,
24
24
  registerAdminRoute,
25
25
  createPrd,
26
+ resolveSourcePromptIdFromClaudeSession,
26
27
  } = require('../lib/prdCreate.cjs');
27
28
  const { createAdminHttp } = require('../lib/localAdminHttp.cjs');
28
29
  const config = require('../config.cjs');
@@ -505,3 +506,72 @@ test('POST /admin/scheduler/create-prd with sourcePromptId writes it into the cr
505
506
  await admin.stop();
506
507
  }
507
508
  });
509
+
510
+ // ──────────────────────────────────────────── originClaudeSessionId fallback
511
+ // (2026-08-01 fix: a PRD queued via the scheduler_create_prd MCP tool from
512
+ // inside an Epic's own chat session, without an explicit sourcePromptId,
513
+ // must still join that Epic rather than mint an unrelated sibling one.)
514
+
515
+ async function writeActiveIndexFixture(cwd, sessions) {
516
+ const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
517
+ await fsp.mkdir(dir, { recursive: true });
518
+ await fsp.writeFile(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events: {} }, null, 2));
519
+ }
520
+
521
+ test('resolveSourcePromptIdFromClaudeSession finds the Epic whose claudeSessionId matches', async () => {
522
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-resolve-epic-'));
523
+ try {
524
+ await writeActiveIndexFixture(root, {
525
+ 'psess-abc123': { id: 'psess-abc123', claudeSessionId: 'claude-sess-1', status: 'active' },
526
+ });
527
+ expect(resolveSourcePromptIdFromClaudeSession(root, 'claude-sess-1')).toBe('psess-abc123');
528
+ expect(resolveSourcePromptIdFromClaudeSession(root, 'claude-sess-unknown')).toBeNull();
529
+ expect(resolveSourcePromptIdFromClaudeSession(root, null)).toBeNull();
530
+ } finally {
531
+ await fsp.rm(root, { recursive: true, force: true });
532
+ }
533
+ });
534
+
535
+ test('createPrd resolves sourcePromptId from originClaudeSessionId when the caller omitted sourcePromptId', async () => {
536
+ const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-epic-'));
537
+ config.addAllowedRoot(root);
538
+ try {
539
+ await writeActiveIndexFixture(root, {
540
+ 'psess-always-select-tab': { id: 'psess-always-select-tab', claudeSessionId: 'claude-sess-epic-1', status: 'active' },
541
+ });
542
+ const prdsDir = path.join(root, 'session-manager-operations', 'scheduler', 'epics', 'psess-always-select-tab', 'prds');
543
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
544
+
545
+ const result = await createPrd(
546
+ validCreateBody({ cwd: root, slug: 'joins-the-epic', originClaudeSessionId: 'claude-sess-epic-1' }),
547
+ remote,
548
+ );
549
+
550
+ expect(result.ok).toBe(true);
551
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
552
+ expect(written).toMatch(/sourcePromptId: psess-always-select-tab/);
553
+ } finally {
554
+ await fsp.rm(root, { recursive: true, force: true });
555
+ }
556
+ });
557
+
558
+ test('createPrd leaves sourcePromptId unset when originClaudeSessionId matches no known Epic (plain SessionTab chat)', async () => {
559
+ const root = await fsp.mkdtemp(path.join(os.homedir(), '.sm-origin-session-no-match-'));
560
+ config.addAllowedRoot(root);
561
+ try {
562
+ await writeActiveIndexFixture(root, {});
563
+ const prdsDir = path.join(root, 'session-manager-operations', 'scheduler', 'prds');
564
+ const remote = makeFakeRemoteWithPrdsDir(prdsDir);
565
+
566
+ const result = await createPrd(
567
+ validCreateBody({ cwd: root, slug: 'no-epic-match', originClaudeSessionId: 'claude-sess-not-an-epic' }),
568
+ remote,
569
+ );
570
+
571
+ expect(result.ok).toBe(true);
572
+ const written = await fsp.readFile(path.join(prdsDir, result.filename), 'utf8');
573
+ expect(!/sourcePromptId:/.test(written)).toBeTruthy();
574
+ } finally {
575
+ await fsp.rm(root, { recursive: true, force: true });
576
+ }
577
+ });
@@ -51,6 +51,7 @@ const { classifyToolUse } = require('./lib/toolUseClassify.cjs');
51
51
  const { extractJson } = require('./lib/extractJson.cjs');
52
52
  const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
53
53
  const sessionSlots = require('./lib/sessionSlots.cjs');
54
+ const opsErrorLog = require('./lib/opsErrorLog.cjs');
54
55
 
55
56
  // ─── Stop-signal protocol ──────────────────────────────────────────────────
56
57
  // Single source of truth for the sentinel and parser. The renderer (PRD 320)
@@ -470,6 +471,19 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
470
471
  const emitTerminal = (channel, payload) => {
471
472
  if (terminalSent) return;
472
473
  terminalSent = true;
474
+ // Every terminal error for this tab, logged once, regardless of the
475
+ // `silent` gate below — a probe run's failure is still real signal for
476
+ // tracing even though it never reaches the visible chat UI.
477
+ if (channel === 'chat:run:error') {
478
+ opsErrorLog.appendError({
479
+ cwd,
480
+ scope: 'chatRunner',
481
+ tabId,
482
+ tags: silent ? ['silent-probe'] : [],
483
+ message: payload?.message ?? 'chat run failed',
484
+ meta: { sessionId, silent },
485
+ });
486
+ }
473
487
  // Silent (probe) runs still need this bookkeeping so the lane frees
474
488
  // correctly, but must never surface on the six turn-affecting channels.
475
489
  if (silent) return;
@@ -495,7 +509,14 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
495
509
  };
496
510
 
497
511
  const claudeBin = resolveClaudeBin();
498
- const childEnv = cleanChildEnv({ PATH: pathWithUserBins() });
512
+ // Carried down to any MCP server this run spawns (e.g.
513
+ // scripts/scheduler-mcp-server.cjs, inherited via the claude CLI's own
514
+ // child env) so scheduler_create_prd can auto-resolve sourcePromptId
515
+ // when the model forgets to pass it — see prdCreate.cjs's
516
+ // resolveSourcePromptIdFromClaudeSession. sessionId IS the Epic's
517
+ // claudeSessionId for an Epic-backed tab (domain model: Epic:claude-session
518
+ // 1:1); for a non-Epic tab, resolution below simply finds no match.
519
+ const childEnv = cleanChildEnv({ PATH: pathWithUserBins(), SM_CHAT_SESSION_ID: sessionId });
499
520
 
500
521
  // Prepend the stop-signal protocol instruction and the chat-mode truth
501
522
  // instruction to every prompt
@@ -303,6 +303,14 @@ const schedulerCreatePrd = z.object({
303
303
  // prompt back into the chat tab that queued this PRD, via
304
304
  // enqueueExternalPrompt (PRD 753). Same newline-injection guard.
305
305
  sourceTabId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
306
+ // The calling MCP process's own claude session id (scheduler-mcp-server.cjs
307
+ // forwards its inherited SM_CHAT_SESSION_ID env var here when the model
308
+ // omitted sourcePromptId) — never written to frontmatter itself; prdCreate.cjs's
309
+ // createPrd() resolves it against active-index.json to fill sourcePromptId
310
+ // when it matches an existing Epic's claudeSessionId, so a PRD queued from
311
+ // within an Epic's own chat session joins that Epic even if the tool call
312
+ // forgot to pass sourcePromptId explicitly.
313
+ originClaudeSessionId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
306
314
  // User-selected Feature/Bug tag (PRD 774) carried from the originating
307
315
  // PromptTicket — deterministic, never LLM-classified.
308
316
  tag: z.enum(['feature', 'bug', 'discussion']).optional(),
@@ -11,12 +11,24 @@
11
11
 
12
12
  'use strict';
13
13
 
14
- import { test, expect } from 'vitest';
14
+ import { test, expect, afterAll } from 'vitest';
15
15
  const http = require('node:http');
16
16
  const fs = require('node:fs');
17
17
  const path = require('node:path');
18
+ const os = require('node:os');
18
19
  const { createAdminHttp, TOKEN_PATH, resolveTokenPath } = require('../localAdminHttp.cjs');
19
20
 
21
+ // Redirect every start() in this file away from the PRODUCTION admin-api.json.
22
+ // Without this, these tests overwrote the running app's port+token with a
23
+ // throwaway server's, orphaning scheduler-mcp-server.cjs until an app restart
24
+ // (confirmed live 2026-08-01). Tests that specifically exercise path
25
+ // resolution unset this override themselves.
26
+ // Must live under the home dir: config.writeJson enforces allowedRoots.
27
+ const TEST_TOKEN_PATH = path.join(path.dirname(TOKEN_PATH), `admin-api.test-${process.pid}.json`);
28
+ process.env.SM_ADMIN_TOKEN_PATH = TEST_TOKEN_PATH;
29
+
30
+ afterAll(() => { fs.rmSync(TEST_TOKEN_PATH, { force: true }); });
31
+
20
32
  function request(port, { method = 'GET', path: reqPath, token, body }) {
21
33
  return new Promise((resolve, reject) => {
22
34
  const headers = {};
@@ -109,11 +121,11 @@ test('start() persists token file with port + token, mode 0600', async () => {
109
121
  const admin = createAdminHttp();
110
122
  const { port, token } = await admin.start();
111
123
  try {
112
- const raw = fs.readFileSync(TOKEN_PATH, 'utf8');
124
+ const raw = fs.readFileSync(TEST_TOKEN_PATH, 'utf8');
113
125
  const parsed = JSON.parse(raw);
114
126
  expect(parsed.port).toBe(port);
115
127
  expect(parsed.token).toBe(token);
116
- const mode = fs.statSync(TOKEN_PATH).mode & 0o777;
128
+ const mode = fs.statSync(TEST_TOKEN_PATH).mode & 0o777;
117
129
  expect(mode).toBe(0o600);
118
130
  } finally {
119
131
  await admin.stop();
@@ -124,6 +136,8 @@ test('SM_DEV=1 writes to a dev-suffixed path, never the production admin-api.jso
124
136
  const devPath = path.join(path.dirname(TOKEN_PATH), 'admin-api.dev.json');
125
137
  const prevMtime = fs.existsSync(TOKEN_PATH) ? fs.statSync(TOKEN_PATH).mtimeMs : null;
126
138
  const prevEnv = process.env.SM_DEV;
139
+ const prevOverride = process.env.SM_ADMIN_TOKEN_PATH;
140
+ delete process.env.SM_ADMIN_TOKEN_PATH;
127
141
  process.env.SM_DEV = '1';
128
142
  const admin = createAdminHttp();
129
143
  try {
@@ -141,32 +155,36 @@ test('SM_DEV=1 writes to a dev-suffixed path, never the production admin-api.jso
141
155
  } finally {
142
156
  await admin.stop();
143
157
  if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
158
+ if (prevOverride !== undefined) process.env.SM_ADMIN_TOKEN_PATH = prevOverride;
144
159
  fs.rmSync(devPath, { force: true });
145
160
  }
146
161
  });
147
162
 
148
- test('with neither SM_DEV nor SM_E2E set, writes to the original admin-api.json path', async () => {
163
+ test('with neither SM_DEV nor SM_E2E set, resolves to the original admin-api.json path', () => {
164
+ // Path RESOLUTION only — deliberately does not start() a server here. This
165
+ // test used to boot one with no override set, which wrote a throwaway
166
+ // port+token straight into the live app's admin-api.json and cut
167
+ // scheduler-mcp-server.cjs off from the running app. Persistence itself is
168
+ // covered by the 0600 test above, against the redirected test path.
149
169
  const prevDev = process.env.SM_DEV;
150
170
  const prevE2e = process.env.SM_E2E;
171
+ const prevOverride = process.env.SM_ADMIN_TOKEN_PATH;
151
172
  delete process.env.SM_DEV;
152
173
  delete process.env.SM_E2E;
153
- let admin;
174
+ delete process.env.SM_ADMIN_TOKEN_PATH;
154
175
  try {
155
176
  expect(resolveTokenPath()).toBe(TOKEN_PATH);
156
- admin = createAdminHttp();
157
- const { port, token } = await admin.start();
158
- const parsed = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
159
- expect(parsed.port).toBe(port);
160
- expect(parsed.token).toBe(token);
161
177
  } finally {
162
- if (admin) await admin.stop();
163
178
  if (prevDev === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevDev;
164
179
  if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
180
+ if (prevOverride !== undefined) process.env.SM_ADMIN_TOKEN_PATH = prevOverride;
165
181
  }
166
182
  });
167
183
 
168
184
  test('dev and production token paths never collide', () => {
169
185
  const prevEnv = process.env.SM_DEV;
186
+ const prevOverride = process.env.SM_ADMIN_TOKEN_PATH;
187
+ delete process.env.SM_ADMIN_TOKEN_PATH;
170
188
  try {
171
189
  process.env.SM_DEV = '1';
172
190
  const devPath = resolveTokenPath();
@@ -175,6 +193,7 @@ test('dev and production token paths never collide', () => {
175
193
  expect(devPath).not.toBe(prodPath);
176
194
  } finally {
177
195
  if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
196
+ if (prevOverride !== undefined) process.env.SM_ADMIN_TOKEN_PATH = prevOverride;
178
197
  }
179
198
  });
180
199
 
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ // Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerBatchDepends.test.cjs
4
+ //
5
+ // Regression coverage for dependsOn eligibility in pickForProject. The
6
+ // bare-slug cases below are the ones that shipped broken: a PRD's filename
7
+ // carries the auto-allocated `NN-` prefix, but `dependsOn:` is authored with
8
+ // the bare name (the author can't know the number the allocator will issue),
9
+ // so every such dep silently resolved to "no row -> already done" and the
10
+ // gate never held anything.
11
+
12
+ // vitest globals (test) — same convention as the other .cjs tests.
13
+ const assert = require('node:assert/strict');
14
+ const { pickForProject, DEFAULT_PROJECT_CWD } = require('../schedulerBatch.cjs');
15
+
16
+ const CWD = DEFAULT_PROJECT_CWD;
17
+
18
+ function job(slug, status, extra = {}) {
19
+ return {
20
+ slug,
21
+ status,
22
+ cwd: CWD,
23
+ parallelGroup: Number(String(slug).match(/^(\d+)-/)?.[1] ?? 99),
24
+ dependsOn: [],
25
+ ...extra,
26
+ };
27
+ }
28
+
29
+ function pick(jobs, running = new Set(), slots = 3) {
30
+ return pickForProject(jobs, running, slots);
31
+ }
32
+
33
+ test('holds a dependent whose bare-named dep is still running (NN- prefix stripped)', () => {
34
+ const jobs = [
35
+ job('873-leftnav-two-face-framework', 'running'),
36
+ job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
37
+ ];
38
+ const { batch } = pick(jobs, new Set(['873-leftnav-two-face-framework']));
39
+ assert.deepEqual(batch.map((j) => j.slug), []);
40
+ });
41
+
42
+ test('holds a dependent whose bare-named dep is still pending', () => {
43
+ const jobs = [
44
+ job('873-leftnav-two-face-framework', 'pending'),
45
+ job('895-home-global-behavior-settings', 'pending', {
46
+ dependsOn: ['leftnav-two-face-framework', 'nav-face-session-manager-config'],
47
+ }),
48
+ job('890-nav-face-session-manager-config', 'pending', {
49
+ dependsOn: ['leftnav-two-face-framework'],
50
+ }),
51
+ ];
52
+ const { batch } = pick(jobs);
53
+ // Only the framework PRD (no deps) is eligible; both dependents are held.
54
+ assert.deepEqual(batch.map((j) => j.slug), ['873-leftnav-two-face-framework']);
55
+ });
56
+
57
+ test('releases a dependent once its bare-named dep is completed', () => {
58
+ const jobs = [
59
+ job('873-leftnav-two-face-framework', 'completed'),
60
+ job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
61
+ ];
62
+ const { batch } = pick(jobs);
63
+ assert.deepEqual(batch.map((j) => j.slug), ['874-nav-face-project-home']);
64
+ });
65
+
66
+ test('still resolves an exact-slug dep (dep written WITH the NN- prefix)', () => {
67
+ const jobs = [
68
+ job('873-leftnav-two-face-framework', 'pending'),
69
+ job('874-nav-face-project-home', 'pending', {
70
+ dependsOn: ['873-leftnav-two-face-framework'],
71
+ }),
72
+ ];
73
+ const { batch } = pick(jobs);
74
+ assert.deepEqual(batch.map((j) => j.slug), ['873-leftnav-two-face-framework']);
75
+ });
76
+
77
+ test('a dep with no row at all is treated as already done (archived/retired)', () => {
78
+ const jobs = [
79
+ job('874-nav-face-project-home', 'pending', { dependsOn: ['some-long-archived-prd'] }),
80
+ ];
81
+ const { batch } = pick(jobs);
82
+ assert.deepEqual(batch.map((j) => j.slug), ['874-nav-face-project-home']);
83
+ });
84
+
85
+ test('a FAILED bare-named dep holds the dependent and reports an explicit reason', () => {
86
+ const jobs = [
87
+ job('873-leftnav-two-face-framework', 'failed'),
88
+ job('874-nav-face-project-home', 'pending', { dependsOn: ['leftnav-two-face-framework'] }),
89
+ ];
90
+ const { batch, reason } = pick(jobs);
91
+ assert.deepEqual(batch, []);
92
+ assert.match(reason, /depends-gate/);
93
+ assert.match(reason, /874-nav-face-project-home <- leftnav-two-face-framework/);
94
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * auditLog.cjs — append-only trace of every Epic/PRD creation, so a rogue or
3
+ * unexpected Epic/PRD can always be traced back to its origin.
4
+ *
5
+ * Deliberately machine-level (~/.claude/session-manager/), not under any
6
+ * project's session-manager-operations/ — this is a security trace, not app
7
+ * state a project's single-writer law needs to arbitrate, and it must
8
+ * survive even if the project's ops root doesn't exist yet.
9
+ *
10
+ * Best-effort only: a logging failure must never block the mint/create it is
11
+ * observing (same posture as rcaFeedbackHook.cjs / dodDrainHook.cjs).
12
+ *
13
+ * Plain Node module (no Electron deps) so scripts/mint-epic.cjs and
14
+ * scripts/propose-epic.cjs (which run outside Electron) can require it too.
15
+ */
16
+ 'use strict';
17
+
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+
22
+ const AUDIT_LOG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'audit-log.jsonl');
23
+
24
+ /**
25
+ * Best-effort identity of the process making this call — there is no
26
+ * cryptographic caller identity available across the admin HTTP API (bearer
27
+ * token proves "runs as this OS user", not "which agent/session"), so this
28
+ * captures every breadcrumb that IS available: this process's pid/ppid, and
29
+ * (when set) the claude session id chatRunner.cjs stamps on headless
30
+ * `claude -p` children and passes down to MCP server children.
31
+ */
32
+ function callerContext() {
33
+ return {
34
+ pid: process.pid,
35
+ ppid: process.ppid,
36
+ claudeSessionId: process.env.SM_CHAT_SESSION_ID || null,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * appendAuditEvent(kind, fields) — append one JSONL record. `kind` is a short
42
+ * label ('epic_mint' | 'prd_create' | ...); `fields` is event-specific detail
43
+ * (cwd, epicId, status, title, slug, etc). Never throws.
44
+ */
45
+ function appendAuditEvent(kind, fields = {}) {
46
+ try {
47
+ const record = {
48
+ at: new Date().toISOString(),
49
+ kind,
50
+ ...fields,
51
+ caller: callerContext(),
52
+ };
53
+ fs.mkdirSync(path.dirname(AUDIT_LOG_PATH), { recursive: true });
54
+ fs.appendFileSync(AUDIT_LOG_PATH, `${JSON.stringify(record)}\n`);
55
+ } catch (e) {
56
+ console.error('[auditLog] failed to append audit event', e?.message ?? String(e));
57
+ }
58
+ }
59
+
60
+ module.exports = { appendAuditEvent, AUDIT_LOG_PATH };
@@ -22,6 +22,7 @@ const path = require('node:path');
22
22
  const crypto = require('node:crypto');
23
23
  const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
24
24
  const { assertOpsWrite } = require('./opsOwnership.cjs');
25
+ const { appendAuditEvent } = require('./auditLog.cjs');
25
26
 
26
27
  function activeIndexPath(cwd) {
27
28
  return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
@@ -164,6 +165,11 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
164
165
  index.events[epicId] = [firstEvent];
165
166
  writeActiveIndex(cwd, index);
166
167
 
168
+ // Every mint is logged, whether the Epic starts 'proposed' (human gate
169
+ // ahead) or 'active' (started immediately) — this is the trace-back point
170
+ // for "who/what created this Epic" (see auditLog.cjs).
171
+ appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText });
172
+
167
173
  const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
168
174
  fs.mkdirSync(prdDir, { recursive: true });
169
175
  return { epicId, prdDir, created: true };
@@ -49,6 +49,15 @@ const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-
49
49
  * harmless tie-break, since real launches only ever set one or the other.
50
50
  */
51
51
  function resolveTokenPath() {
52
+ // Explicit override, highest priority. Exists because the unit tests call
53
+ // createAdminHttp().start() directly with neither SM_DEV nor SM_E2E set,
54
+ // which resolved to the PRODUCTION admin-api.json and overwrote the
55
+ // running app's port+token with a throwaway test server's — silently
56
+ // orphaning scheduler-mcp-server.cjs's access to the live app until the
57
+ // next app restart (confirmed live 2026-08-01: `npm run test:unit` made
58
+ // every scheduler_* MCP tool report "app is not running"). Tests point
59
+ // this at a tmp file; production never sets it.
60
+ if (process.env.SM_ADMIN_TOKEN_PATH) return process.env.SM_ADMIN_TOKEN_PATH;
52
61
  if (process.env.SM_DEV === '1') {
53
62
  return path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.dev.json');
54
63
  }
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * opsErrorLog.cjs — sole writer of the `logs` ops namespace
5
+ * (single-writer law, see opsOwnership.cjs). Appends one structured JSONL
6
+ * line per error to `<cwd>/session-manager-operations/logs/errors-<date>.jsonl`
7
+ * so errors from every tab/surface land in one common, per-project place
8
+ * instead of scattered across console.error calls and the machine-global
9
+ * <userData>/logs/ (which mixes every project together and isn't tagged for
10
+ * tracing). Every call site funnels through `appendError` — that is what
11
+ * makes this the sole writer in practice, not just in the OWNERS map.
12
+ */
13
+
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+ const { assertOpsWrite } = require('./opsOwnership.cjs');
17
+
18
+ // Same redaction policy as logs.cjs's sanitizeMeta — kept independent (not
19
+ // shared/required) so this module has no dependency on the Electron `app`
20
+ // module and can be unit-tested without a running app.
21
+ const REDACT_KEY = /^(transcript|interim|final|text|content|partial|userText|message|token|secret|password|authorization|cookie|api[_-]?key|access[_-]?token|refresh[_-]?token)$/i;
22
+
23
+ function sanitizeMeta(meta) {
24
+ if (!meta || typeof meta !== 'object') return meta;
25
+ if (Array.isArray(meta)) return meta;
26
+ const out = {};
27
+ for (const [k, v] of Object.entries(meta)) {
28
+ out[k] = REDACT_KEY.test(k) ? '[redacted]' : v;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function logsDir(cwd) {
34
+ return path.join(cwd, 'session-manager-operations', 'logs');
35
+ }
36
+
37
+ function todayFile(cwd) {
38
+ const d = new Date();
39
+ const yyyy = d.getFullYear();
40
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
41
+ const dd = String(d.getDate()).padStart(2, '0');
42
+ return path.join(logsDir(cwd), `errors-${yyyy}-${mm}-${dd}.jsonl`);
43
+ }
44
+
45
+ /**
46
+ * Append one structured error line, tagged for tracing/analysis.
47
+ *
48
+ * @param {{
49
+ * cwd: string, // required — which project's ops root owns this line
50
+ * scope: string, // subsystem name, e.g. 'pty', 'chatRunner', 'voice'
51
+ * level?: string, // default 'error'; 'warn' also accepted
52
+ * tabId?: string, // claudeSessionId of the tab this error belongs to, if any
53
+ * epicId?: string, // Epic id, if this error happened inside an Epic-backed run
54
+ * tags?: string[], // extra caller-supplied tags, merged with the auto-derived ones
55
+ * message: string,
56
+ * meta?: unknown,
57
+ * }} entry
58
+ */
59
+ function appendError({ cwd, scope, level = 'error', tabId, epicId, tags = [], message, meta }) {
60
+ if (!cwd || typeof cwd !== 'string') return; // no project to attribute this line to — skip
61
+ const file = todayFile(cwd);
62
+ try {
63
+ assertOpsWrite(file, 'logs');
64
+ } catch {
65
+ return; // fail-closed, same as every other ops write path — never throw from a log call
66
+ }
67
+
68
+ const autoTags = [
69
+ `level:${level}`,
70
+ `scope:${scope || 'unknown'}`,
71
+ ...(tabId ? [`tab:${tabId}`] : []),
72
+ ...(epicId ? [`epic:${epicId}`] : []),
73
+ ];
74
+ const allTags = Array.from(new Set([...autoTags, ...tags]));
75
+
76
+ const line = {
77
+ ts: new Date().toISOString(),
78
+ level,
79
+ scope: scope || 'unknown',
80
+ tabId: tabId || null,
81
+ epicId: epicId || null,
82
+ tags: allTags,
83
+ message: String(message ?? ''),
84
+ ...(meta !== undefined ? { meta: sanitizeMeta(meta) } : {}),
85
+ };
86
+
87
+ try {
88
+ fs.mkdirSync(logsDir(cwd), { recursive: true });
89
+ // 0o600: same rationale as logs.cjs — error meta can carry local paths /
90
+ // device labels, keep it off other local users' reach.
91
+ const fd = fs.openSync(file, 'a', 0o600);
92
+ try { fs.writeSync(fd, JSON.stringify(line) + '\n'); } finally { fs.closeSync(fd); }
93
+ } catch { /* best-effort — a logging failure must never break the caller */ }
94
+ }
95
+
96
+ module.exports = { appendError, logsDir, todayFile };