claude-code-session-manager 0.43.0 → 0.44.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,7 +7,7 @@
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-6w1gIeyB.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-BGEZVIeo.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
13
  <link rel="stylesheet" crossorigin href="./assets/index-CxGY-V5G.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.43.0",
3
+ "version": "0.44.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",
@@ -19,9 +19,23 @@ description: >-
19
19
 
20
20
  **Role:** `/develop` owns the *pipeline*: it turns a development request into one or more
21
21
  self-contained PRDs, queues them, and tracks them to completion. It is the convergence point
22
- for both entry paths — an interactive human prompt comes straight here; an agent feedback file
23
- arrives as an approved Epic proposal (`/propose-epic`), which the user approves and then calls this skill. Everything from
24
- here on is identical regardless of who asked.
22
+ for both entry paths — an interactive human prompt inside an Epic's own conversation comes
23
+ straight here; an agent feedback file arrives as an approved Epic proposal (`/propose-epic`),
24
+ which the user approves and then calls this skill. Everything from here on is identical
25
+ regardless of who asked.
26
+
27
+ **Epic-gated — non-negotiable.** `/develop` may only run inside an already-existing,
28
+ already-human-approved Epic's own conversation (Chat or Terminal — the two views over one Epic
29
+ session, per the domain model). It never creates an Epic itself and never writes a PRD against
30
+ one it had to guess at. Before doing anything else:
31
+ 1. Resolve `<epic-id>`: this conversation's own claudeSessionId must already match an Epic's
32
+ `claudeSessionId` in `<cwd>/session-manager-operations/prompt-sessions/active-index.json` —
33
+ that Epic's `id` is `<epic-id>` for every PRD authored in this pass.
34
+ 2. If it doesn't match any Epic — this was invoked as a bare standalone command outside any
35
+ Epic context — **stop and say so**. Tell the human to create the Epic first (the New Epic
36
+ card in the app, or `/propose-epic` + **Approve & start**), then re-run `/develop` from
37
+ inside that Epic. Do not mint one yourself, do not fall back to writing an epicless PRD, and
38
+ do not proceed with authoring.
25
39
 
26
40
  **Never** hand-implement the work inline in chat, and never restate rules that live elsewhere:
27
41
  the engineering rules belong to `standards.md`. Reference it; don't fork it.
@@ -184,7 +198,11 @@ can't load skills.
184
198
  **Prefer creating each PRD via the `scheduler_create_prd` MCP tool**
185
199
  (`mcp__session-manager-scheduler__scheduler_create_prd`) over hand-writing the file. Its input
186
200
  (`title`, `cwd`, `estimateMinutes`, `goal`, `acceptanceCriteria[]`, `implementationNotes`,
187
- `outOfScope[]`) maps directly onto the sections below — pass them straight through. It
201
+ `outOfScope[]`) maps directly onto the sections below — pass them straight through. **Always
202
+ pass `sourcePromptId` explicitly, set to the `<epic-id>` resolved in the Epic-gated step
203
+ above** — never omit it and rely on the server-side session-id fallback; that fallback exists
204
+ only to cover a model that forgot, not as this skill's normal path, and the server refuses to
205
+ write the PRD at all if no existing Epic resolves. It
188
206
  allocates a strictly-unique `NN` atomically (no read-then-write race against another
189
207
  concurrent `/develop` invocation, never reused across the project — PRD
190
208
  832), derives and collision-checks the slug, and embeds the standards pointer for you.
@@ -220,17 +238,21 @@ can't load skills.
220
238
  token-window resets, with auto-pause on rate-limit and auto-resume.
221
239
 
222
240
  **Canonical location — non-negotiable.** Every PRD belongs to an **Epic** (the TAB → EPIC →
223
- PRD domain model in the project CLAUDE.md). PRDs MUST be written to, inside the target repo:
241
+ PRD domain model in the project CLAUDE.md) — specifically the `<epic-id>` resolved in the
242
+ Epic-gated step above. PRDs MUST be written to, inside the target repo:
224
243
  ```
225
244
  <cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/<NN>-<kebab-slug>.md
226
245
  ```
227
- Get `<epic-id>`'s prds/ directory by minting (or joining) an Epic first:
246
+ Always pass that `<epic-id>` as `sourcePromptId` when creating each PRD — via the MCP
247
+ `scheduler_create_prd` tool's `sourcePromptId` input, or, for the manual-write fallback, by
248
+ resolving the directory with:
228
249
  ```bash
229
- node <session-manager-repo>/scripts/mint-epic.cjs <cwd> "<one-line goal for this work>" [feature|bug|discussion]
250
+ node <session-manager-repo>/scripts/mint-epic.cjs <cwd> <epic-id>
230
251
  ```
231
- The last stdout line is the exact directory to write into. Reuse-by-goal makes it idempotent —
232
- all PRDs of one /develop ask share one Epic. (The MCP `scheduler_create_prd` tool does all of
233
- this automatically; the mint step is only for the manual-write fallback.)
252
+ This only JOINS an existing Epic and prints its prds/ directory on the last stdout line — it
253
+ never creates one. If `<epic-id>` doesn't exist yet, that means the Epic-gated step above
254
+ wasn't satisfied; go back and get a human to create/approve the Epic first, don't work around
255
+ this by minting one.
234
256
 
235
257
  **Anywhere else doesn't get scheduled or gets retired.** `data/prds/`, `docs/prds/`, and the
236
258
  old global `prds/` dir under `~/.claude/session-manager/scheduled-plans/` are invisible to
@@ -324,6 +324,7 @@ async function emitFeedbackPRD(cwd, {
324
324
  tag: 'discussion',
325
325
  status: 'proposed',
326
326
  reuseByGoal: true,
327
+ source: { producer: 'feedback-sweep' },
327
328
  });
328
329
  epicId = epic.epicId;
329
330
  prdsDir = epic.prdDir;
@@ -1,28 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * mint-epic.cjs — CLI wrapper over lib/epicMint.cjs for PRD authors working
4
- * without the app's admin API (the /develop skill's manual-write fallback).
3
+ * mint-epic.cjs — CLI wrapper over lib/epicMint.cjs's JOIN path, for PRD
4
+ * authors working without the app's admin API (the /develop skill's
5
+ * manual-write fallback).
6
+ *
7
+ * This script never creates an Epic. It only resolves an EXISTING Epic's
8
+ * prds/ write directory. An Epic may only come into existence through
9
+ * explicit human intent — the New Epic UI, or /propose-epic followed by a
10
+ * human pressing Approve & start — never implicitly from a PRD-authoring
11
+ * path. /develop must already be running inside that Epic's own
12
+ * conversation (or have the approved Epic's id in hand) before it calls this.
5
13
  *
6
14
  * Usage:
7
- * node scripts/mint-epic.cjs <cwd> "<goal text>" [feature|bug|discussion]
15
+ * node scripts/mint-epic.cjs <cwd> <epic-id>
8
16
  *
9
- * Prints the Epic's prds/ write directory on stdout (last line), minting the
10
- * Epic (and registering it in prompt-sessions/active-index.json) if no active
11
- * Epic with the same goal text exists — reuse-by-goal, so re-running for the
12
- * same goal is idempotent and returns the same directory.
17
+ * Prints the Epic's prds/ write directory on stdout (last line). Exits 1
18
+ * with no stdout output if <epic-id> does not exist in
19
+ * <cwd>/session-manager-operations/prompt-sessions/active-index.json.
13
20
  */
14
21
  'use strict';
15
22
 
16
23
  const { ensureEpic } = require('../src/main/lib/epicMint.cjs');
17
24
 
18
- const [cwd, goalText, tag] = process.argv.slice(2);
19
- if (!cwd || !goalText) {
20
- process.stderr.write('usage: mint-epic.cjs <cwd> "<goal text>" [feature|bug|discussion]\n');
25
+ const [cwd, epicId] = process.argv.slice(2);
26
+ if (!cwd || !epicId) {
27
+ process.stderr.write('usage: mint-epic.cjs <cwd> <epic-id>\n');
28
+ process.stderr.write('(epic-id must already exist — this script only joins, never creates)\n');
21
29
  process.exit(1);
22
30
  }
23
31
 
24
32
  (async () => {
25
- const { epicId, prdDir, created } = await ensureEpic(cwd, { goalText, tag, reuseByGoal: true });
26
- process.stderr.write(`${created ? 'minted' : 'joined'} epic ${epicId}\n`);
27
- process.stdout.write(`${prdDir}\n`);
33
+ try {
34
+ const { prdDir } = await ensureEpic(cwd, { epicId, mintIfMissing: false });
35
+ process.stderr.write(`joined epic ${epicId}\n`);
36
+ process.stdout.write(`${prdDir}\n`);
37
+ } catch (e) {
38
+ process.stderr.write(`${e?.message ?? 'no such Epic'}\n`);
39
+ process.exit(1);
40
+ }
28
41
  })();
@@ -49,6 +49,7 @@ function readStdin() {
49
49
  status: 'proposed',
50
50
  reuseByGoal: true,
51
51
  openingPrompt: body || title,
52
+ source: { producer: 'propose-epic' },
52
53
  });
53
54
  process.stderr.write(`${created ? 'proposed' : 'joined existing proposal'} epic ${epicId}\n`);
54
55
  process.stdout.write(`${epicId}\n`);
@@ -0,0 +1,63 @@
1
+ /**
2
+ * chat-dead-channels.test.cjs — regression guard for the removed
3
+ * chat:probe-context / chat:context-usage channels (PRD 897).
4
+ *
5
+ * These IPC channels were deliberately deleted (handler, broadcast, preload
6
+ * and zod schema all removed) rather than left half-wired. This test walks
7
+ * the source tree and fails if either literal string reappears anywhere
8
+ * under src/main, src/preload, or src/renderer — such a reintroduction must
9
+ * be wired end-to-end (handler + preload + zod schema + broadcast) or not
10
+ * added at all.
11
+ *
12
+ * Run: timeout 120 npx vitest run src/main/__tests__/chat-dead-channels.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ import { test, expect } from 'vitest';
21
+
22
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
23
+ const SCAN_DIRS = ['src/main', 'src/preload', 'src/renderer'];
24
+ const FORBIDDEN_STRINGS = ['chat:probe-context', 'chat:context-usage'];
25
+ const SELF_PATH = path.resolve(__filename);
26
+
27
+ function walk(dir, files) {
28
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
29
+ if (entry.name === 'node_modules') continue;
30
+ if (entry.isSymbolicLink()) continue;
31
+ const full = path.join(dir, entry.name);
32
+ if (entry.isDirectory()) {
33
+ walk(full, files);
34
+ } else {
35
+ files.push(full);
36
+ }
37
+ }
38
+ }
39
+
40
+ test('chat:probe-context / chat:context-usage channels stay removed', () => {
41
+ const files = [];
42
+ for (const dir of SCAN_DIRS) {
43
+ walk(path.join(REPO_ROOT, dir), files);
44
+ }
45
+
46
+ const hits = [];
47
+ for (const file of files) {
48
+ if (path.resolve(file) === SELF_PATH) continue;
49
+ const contents = fs.readFileSync(file, 'utf8');
50
+ for (const needle of FORBIDDEN_STRINGS) {
51
+ if (contents.includes(needle)) {
52
+ hits.push(`${path.relative(REPO_ROOT, file)}: contains "${needle}"`);
53
+ }
54
+ }
55
+ }
56
+
57
+ expect(
58
+ hits,
59
+ 'chat:probe-context / chat:context-usage were deliberately removed as a dead/half-wired ' +
60
+ 'channel (PRD 897). If reintroducing this feature, wire it end-to-end (handler, preload, ' +
61
+ 'zod schema, broadcast) in the same change — do not add just one side.\n' + hits.join('\n'),
62
+ ).toEqual([]);
63
+ });
@@ -14,7 +14,7 @@ const fs = require('node:fs');
14
14
  const fsp = require('node:fs/promises');
15
15
  const os = require('node:os');
16
16
  const path = require('node:path');
17
- const { ensureEpic, removeEpic, readActiveIndex } = require('../lib/epicMint.cjs');
17
+ const { ensureEpic, removeEpic, readActiveIndex, findJoinableEpic } = require('../lib/epicMint.cjs');
18
18
 
19
19
  const tmpDirs = [];
20
20
  afterEach(async () => {
@@ -83,3 +83,134 @@ test('removeEpic is a no-op (returns false) for an unknown epicId', async () =>
83
83
  expect(removed).toBe(false);
84
84
  expect(Object.keys(readActiveIndex(cwd).sessions)).toHaveLength(1);
85
85
  });
86
+
87
+ // A plain object's bracket lookup with a prototype-chain key like
88
+ // "__proto__"/"constructor" returns a truthy Object.prototype member even
89
+ // when no such Epic was ever written — without an own-property check, this
90
+ // would make mintIfMissing:false's "join an existing Epic" gate falsely
91
+ // report a match, defeating the restriction that a PRD write may only join
92
+ // a real, already-approved Epic.
93
+ for (const pollutedKey of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
94
+ test(`ensureEpic refuses to "join" the prototype-chain key "${pollutedKey}" (mintIfMissing:false)`, async () => {
95
+ const cwd = await mkCwd();
96
+
97
+ await expect(
98
+ ensureEpic(cwd, { epicId: pollutedKey, mintIfMissing: false }),
99
+ ).rejects.toThrow(/no existing Epic found/);
100
+
101
+ const index = readActiveIndex(cwd);
102
+ expect(Object.keys(index.sessions)).toHaveLength(0);
103
+ });
104
+ }
105
+
106
+ test('removeEpic refuses to report success for the prototype-chain key "__proto__"', async () => {
107
+ const cwd = await mkCwd();
108
+ const removed = removeEpic(cwd, '__proto__');
109
+ expect(removed).toBe(false);
110
+ });
111
+
112
+ test('ensureEpic persists the passed source onto the written active-index.json session record', async () => {
113
+ const cwd = await mkCwd();
114
+ const source = { producer: 'rca-hook', prdSlug: 'x', runId: 'y' };
115
+
116
+ const minted = await ensureEpic(cwd, { goalText: 'auto-filed epic', source });
117
+
118
+ const index = readActiveIndex(cwd);
119
+ expect(index.sessions[minted.epicId].source).toEqual(source);
120
+ });
121
+
122
+ test('ensureEpic omits source from the written record when not passed', async () => {
123
+ const cwd = await mkCwd();
124
+
125
+ const minted = await ensureEpic(cwd, { goalText: 'human-filed epic' });
126
+
127
+ const index = readActiveIndex(cwd);
128
+ expect('source' in index.sessions[minted.epicId]).toBe(false);
129
+ });
130
+
131
+ test('ensureEpic joins an existing open Epic when goalText is a near-identical paraphrase (no preferEpicId)', async () => {
132
+ const cwd = await mkCwd();
133
+
134
+ const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
135
+ expect(first.created).toBe(true);
136
+
137
+ const second = await ensureEpic(cwd, { goalText: 'Fix login button colour issue' });
138
+
139
+ expect(second.created).toBe(false);
140
+ expect(second.epicId).toBe(first.epicId);
141
+
142
+ const index = readActiveIndex(cwd);
143
+ expect(Object.keys(index.sessions)).toHaveLength(1);
144
+ });
145
+
146
+ test('ensureEpic mints a distinct Epic when goalText is genuinely unrelated', async () => {
147
+ const cwd = await mkCwd();
148
+
149
+ const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
150
+ expect(first.created).toBe(true);
151
+
152
+ const second = await ensureEpic(cwd, { goalText: 'Add CSV export to History tab' });
153
+
154
+ expect(second.created).toBe(true);
155
+ expect(second.epicId).not.toBe(first.epicId);
156
+
157
+ const index = readActiveIndex(cwd);
158
+ expect(Object.keys(index.sessions)).toHaveLength(2);
159
+ });
160
+
161
+ test('findJoinableEpic joins the preferEpicId Epic even when goalText similarity is near-zero', async () => {
162
+ const cwd = await mkCwd();
163
+
164
+ const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
165
+ expect(first.created).toBe(true);
166
+
167
+ const joined = findJoinableEpic(cwd, {
168
+ goalText: 'Completely unrelated topic about database migrations',
169
+ preferEpicId: first.epicId,
170
+ });
171
+
172
+ expect(joined).toEqual({ epicId: first.epicId, matchedBy: 'preferEpicId' });
173
+ });
174
+
175
+ test('findJoinableEpic falls through to the similarity check when preferEpicId points at a completed Epic', async () => {
176
+ const cwd = await mkCwd();
177
+
178
+ const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
179
+ const index = readActiveIndex(cwd);
180
+ index.sessions[first.epicId].status = 'completed';
181
+ fs.writeFileSync(
182
+ path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'),
183
+ JSON.stringify(index, null, 2),
184
+ );
185
+
186
+ // No other open Epic shares tokens with the completed one, so the
187
+ // fall-through similarity check finds nothing rather than joining the
188
+ // dead (completed) preferEpicId Epic.
189
+ const joined = findJoinableEpic(cwd, { goalText: 'Fix login button colour issue', preferEpicId: first.epicId });
190
+
191
+ expect(joined).toBeNull();
192
+ });
193
+
194
+ test('findJoinableEpic falls through to the similarity check when preferEpicId points at a nonexistent Epic, and can still find a match there', async () => {
195
+ const cwd = await mkCwd();
196
+ const sibling = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
197
+
198
+ const joined = findJoinableEpic(cwd, { goalText: 'Fix login button colour issue', preferEpicId: 'nonexistent-epic-id' });
199
+
200
+ expect(joined).toEqual({ epicId: sibling.epicId, matchedBy: 'similarity', score: expect.any(Number) });
201
+ });
202
+
203
+ test('ensureEpic with forceNewEpic:true mints even when a near-identical open Epic exists', async () => {
204
+ const cwd = await mkCwd();
205
+
206
+ const first = await ensureEpic(cwd, { goalText: 'Fix the login button color' });
207
+ expect(first.created).toBe(true);
208
+
209
+ const second = await ensureEpic(cwd, { goalText: 'Fix login button colour issue', forceNewEpic: true });
210
+
211
+ expect(second.created).toBe(true);
212
+ expect(second.epicId).not.toBe(first.epicId);
213
+
214
+ const index = readActiveIndex(cwd);
215
+ expect(Object.keys(index.sessions)).toHaveLength(2);
216
+ });
@@ -147,6 +147,87 @@ test('fileRcaFeedback: files into the job\'s own project, no feedback dir requir
147
147
  expect(readSessions(cwd)[res.epicId].status).toBe('proposed');
148
148
  });
149
149
 
150
+ // ─── epic targeting (job.epicId → preferred join target) ───────────────────
151
+
152
+ function writeActiveIndex(cwd, sessions) {
153
+ const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
154
+ fs.mkdirSync(dir, { recursive: true });
155
+ const events = {};
156
+ for (const id of Object.keys(sessions)) {
157
+ events[id] = [{ id: `${id}-e0`, promptSessionId: id, kind: 'prompt', causedByEventId: null, at: new Date(0).toISOString(), text: sessions[id].goalText }];
158
+ }
159
+ fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events }));
160
+ }
161
+
162
+ test('fileRcaFeedback: joins the job\'s known origin Epic when it is still open, and tags source', async () => {
163
+ const cwd = makeProjectWithInbox();
164
+ const runDir = path.join(tmpHome, 'run1');
165
+ writeRun(runDir, 'testslug');
166
+ writePrd(cwd, 'testslug');
167
+
168
+ writeActiveIndex(cwd, {
169
+ 'origin-epic-1': { id: 'origin-epic-1', cwd, goalText: 'Unrelated title text', status: 'proposed', createdAt: new Date(0).toISOString(), completedAt: null },
170
+ });
171
+
172
+ const res = await rcaFeedbackHook.fileRcaFeedback({
173
+ job: baseJob({ cwd, epicId: 'origin-epic-1' }), runDir, verdict: 'transcript_errors',
174
+ });
175
+
176
+ expect(res.filed).toBe(true);
177
+ expect(res.epicId).toBe('origin-epic-1');
178
+ expect(res.created).toBe(false);
179
+ expect(Object.keys(readSessions(cwd)).length).toBe(1);
180
+ });
181
+
182
+ test('fileRcaFeedback: does not join a completed origin Epic, falls through to mint', async () => {
183
+ const cwd = makeProjectWithInbox();
184
+ const runDir = path.join(tmpHome, 'run1');
185
+ writeRun(runDir, 'testslug');
186
+ writePrd(cwd, 'testslug');
187
+
188
+ writeActiveIndex(cwd, {
189
+ 'origin-epic-1': { id: 'origin-epic-1', cwd, goalText: 'Unrelated title text', status: 'completed', createdAt: new Date(0).toISOString(), completedAt: new Date(0).toISOString() },
190
+ });
191
+
192
+ const res = await rcaFeedbackHook.fileRcaFeedback({
193
+ job: baseJob({ cwd, epicId: 'origin-epic-1' }), runDir, verdict: 'transcript_errors',
194
+ });
195
+
196
+ expect(res.filed).toBe(true);
197
+ expect(res.epicId).not.toBe('origin-epic-1');
198
+ expect(res.created).toBe(true);
199
+ const sessions = readSessions(cwd);
200
+ expect(sessions[res.epicId].source).toEqual({ producer: 'rca-hook', prdSlug: 'testslug', runId: baseJob().runId });
201
+ });
202
+
203
+ test('fileRcaFeedback: a nonexistent job.epicId falls through to mint without erroring', async () => {
204
+ const cwd = makeProjectWithInbox();
205
+ const runDir = path.join(tmpHome, 'run1');
206
+ writeRun(runDir, 'testslug');
207
+ writePrd(cwd, 'testslug');
208
+
209
+ const res = await rcaFeedbackHook.fileRcaFeedback({
210
+ job: baseJob({ cwd, epicId: 'does-not-exist' }), runDir, verdict: 'transcript_errors',
211
+ });
212
+
213
+ expect(res.filed).toBe(true);
214
+ expect(res.epicId).not.toBe('does-not-exist');
215
+ expect(res.created).toBe(true);
216
+ });
217
+
218
+ test('fileRcaFeedback: no job.epicId preserves current mint-or-reuseByGoal behavior', async () => {
219
+ const cwd = makeProjectWithInbox();
220
+ const runDir = path.join(tmpHome, 'run1');
221
+ writeRun(runDir, 'testslug');
222
+ writePrd(cwd, 'testslug');
223
+
224
+ const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
225
+ expect(res.filed).toBe(true);
226
+ expect(res.created).toBe(true);
227
+ const sessions = readSessions(cwd);
228
+ expect(sessions[res.epicId].source).toEqual({ producer: 'rca-hook', prdSlug: 'testslug', runId: baseJob().runId });
229
+ });
230
+
150
231
  test('fileRcaFeedback: rejects a slug containing path-traversal segments', async () => {
151
232
  const cwd = makeProjectWithInbox();
152
233
  const runDir = path.join(tmpHome, 'run1');
@@ -1,14 +1,13 @@
1
1
  /**
2
- * scheduler-writeprd-epic-rollback.test.cjs — PRD 851: when remote.writePrd
3
- * mints a brand-new Epic via ensureEpic (epicMint.cjs) but the PRD file write
4
- * itself then fails, cleanupEmptyMintedEpic must roll back BOTH the on-disk
5
- * empty epic dir AND the Epic's active-index.json entry (sessions/events) —
6
- * otherwise an orphaned seed-prompt-only Epic lingers forever in the Epics
7
- * nav with no PRDs and no cleanup path.
2
+ * scheduler-writeprd-epic-rollback.test.cjs — remote.writePrd may only JOIN
3
+ * an existing, already-human-approved Epic (via `sourcePromptId`); it must
4
+ * never mint a new one (mintIfMissing:false, see epicMint.cjs's ensureEpic).
5
+ * A write with no resolvable Epic fails outright with zero residual
6
+ * active-index.json entries — no orphaned Epic can ever be left behind by
7
+ * this path because none is ever created by it.
8
8
  *
9
- * The rollback must only fire for a call that minted a FRESH Epic
10
- * (ensureEpic's `created: true`); a PRD write that joins an existing Epic
11
- * and then fails must leave that Epic's history untouched.
9
+ * A PRD write that joins a pre-existing Epic and then fails (e.g. an invalid
10
+ * slug) must leave that Epic's history untouched.
12
11
  *
13
12
  * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs
14
13
  */
@@ -21,6 +20,7 @@ const os = require('node:os');
21
20
  const path = require('node:path');
22
21
  const { remote } = require('../scheduler.cjs');
23
22
  const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
23
+ const config = require('../config.cjs');
24
24
 
25
25
  const tmpDirs = [];
26
26
 
@@ -38,18 +38,20 @@ afterEach(() => {
38
38
  });
39
39
 
40
40
  // A slug that escapes safeSlugPathIn's containment check, tripping the
41
- // "invalid slug" branch of writePrd (scheduler.cjs ~4188-4191) after
42
- // ensureEpic has already minted+persisted the Epic.
41
+ // "invalid slug" branch of writePrd (scheduler.cjs's writePrd). Only the
42
+ // second test below (joining a pre-existing Epic) actually reaches this
43
+ // check — the first test fails earlier, at Epic resolution, before the slug
44
+ // is ever considered.
43
45
  const ESCAPING_SLUG = '../escape-attempt';
44
46
 
45
- test('writePrd: invalid-slug failure after a fresh Epic mint leaves zero residual active-index.json entries', async () => {
47
+ test('writePrd: no sourcePromptId refuses to write (no Epic to join) and mints nothing', async () => {
46
48
  const cwd = makeFixtureCwd();
47
- const body = '---\ntitle: Freshly minted epic goal\n---\n\nbody text\n';
49
+ const body = '---\ntitle: No epic given\n---\n\nbody text\n';
48
50
 
49
51
  const result = await remote.writePrd(ESCAPING_SLUG, body, cwd);
50
52
 
51
53
  expect(result.ok).toBe(false);
52
- expect(result.error).toBe('invalid slug');
54
+ expect(result.error).toMatch(/no existing Epic to join/);
53
55
 
54
56
  const index = readActiveIndex(cwd);
55
57
  expect(Object.keys(index.sessions)).toHaveLength(0);
@@ -76,3 +78,28 @@ test('writePrd: invalid-slug failure when joining a pre-existing Epic leaves tha
76
78
  expect(afterIndex.sessions[preexisting.epicId]).toEqual(beforeIndex.sessions[preexisting.epicId]);
77
79
  expect(afterIndex.events[preexisting.epicId]).toEqual(beforeIndex.events[preexisting.epicId]);
78
80
  });
81
+
82
+ // Provenance (PRD 905): writePrd's ensureEpic call passes
83
+ // source: { producer: 'scheduler-dispatch', prdSlug }, but this call is
84
+ // join-only (mintIfMissing:false, explicit epicId) — ensureEpic's
85
+ // explicitEpicId branch returns before ever reading `source`, so a
86
+ // successful join must NOT write/alter the joined Epic's `source` field.
87
+ test('writePrd: successful join does not write a source field onto the pre-existing Epic (join-only no-op)', async () => {
88
+ // Must live under the home dir — config.cjs's writeTextAtomic validates
89
+ // paths against allowedRoots=home dir, unlike the failure-path tests above
90
+ // (which never reach the write) os.tmpdir() is not sufficient here.
91
+ const cwd = fs.mkdtempSync(path.join(os.homedir(), '.sm-writeprd-epic-source-'));
92
+ tmpDirs.push(cwd);
93
+ config.addAllowedRoot(cwd);
94
+
95
+ const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal for join' });
96
+ expect(preexisting.created).toBe(true);
97
+
98
+ const body = `---\ntitle: Joins the existing epic\nsourcePromptId: ${preexisting.epicId}\n---\n\nbody text\n`;
99
+ const result = await remote.writePrd('valid-slug', body, cwd);
100
+
101
+ expect(result.ok).toBe(true);
102
+
103
+ const index = readActiveIndex(cwd);
104
+ expect('source' in index.sessions[preexisting.epicId]).toBe(false);
105
+ });
@@ -377,6 +377,14 @@ const setConfigSchema = z.object({
377
377
  }).optional(),
378
378
  }).strict();
379
379
 
380
+ // Machine-wide claude -p slot pool cap (sessionSlots.cjs) — deliberately its own
381
+ // schema/channel rather than folded into setConfigSchema: it governs the shared
382
+ // process pool, not any one project's scheduler config, and its range (0-10, 0
383
+ // pauses new launches) differs from concurrencyCap's (1-20).
384
+ const setSessionSlotsSchema = z.object({
385
+ cap: z.number().int().min(0).max(10),
386
+ }).strict();
387
+
380
388
  // ──────────────────────────────────────────── Memory tool (Bundle C, cycle 3)
381
389
  // Workspace-scoped markdown store at ~/.claude/projects/<ws>/memory/.
382
390
  // Slug regex is shared with memoryTool.cjs/memoryAggregate.cjs via lib/memorySlug.cjs;
@@ -754,9 +762,6 @@ const READ_COMMANDS = new Set([
754
762
  // Sensitive reads — return user data; require SAS confirmation same as MUTATE.
755
763
  const SAS_GATED_READS = new Set([
756
764
  'cmd:sessions:load',
757
- 'cmd:schedule:state',
758
- 'cmd:schedule:read-prd',
759
- 'cmd:schedule:read-log',
760
765
  'cmd:history:aggregate',
761
766
  // subscribe initiates a live stream of session state/summary — sensitive.
762
767
  'cmd:session:subscribe',
@@ -764,6 +769,12 @@ const SAS_GATED_READS = new Set([
764
769
  // no dispatch handler for it, so an allowlist entry would only fail closed with
765
770
  // an opaque reject. Re-add here together with the handler when remote exchanges
766
771
  // are wired, so the allowlist always mirrors an actual capability.
772
+ //
773
+ // Scheduler/Epics commands (cmd:schedule:*) were removed here deliberately
774
+ // (core scheduler/Epics redesign) — Remote no longer reaches into scheduler
775
+ // internals at all; a future Remote rebuild will talk to Scheduler/Epics
776
+ // through whatever higher-level surface that redesign lands on, not this
777
+ // per-command allowlist.
767
778
  ]);
768
779
 
769
780
  const MUTATE_COMMANDS = new Set([
@@ -776,10 +787,6 @@ const MUTATE_COMMANDS = new Set([
776
787
  // mobile mirror has no business killing or resizing the desktop's session.
777
788
  'cmd:pty:kill',
778
789
  'cmd:pty:resize',
779
- 'cmd:schedule:write-prd',
780
- 'cmd:schedule:reset-job',
781
- 'cmd:schedule:run-now',
782
- 'cmd:schedule:set-config',
783
790
  // Pushes a prompt into an open tab's chat queue — a real mutation of that
784
791
  // tab's session, same tier as pty:write.
785
792
  'cmd:chat:send',
@@ -839,6 +846,7 @@ module.exports = {
839
846
  scheduleArchivePrd,
840
847
  scheduleRetagPrd,
841
848
  setConfigSchema,
849
+ setSessionSlotsSchema,
842
850
  shellOpen,
843
851
  archiveProject,
844
852
  historyAggregate,