claude-code-session-manager 0.57.0 → 0.58.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.
Files changed (45) hide show
  1. package/dist/assets/{TiptapBody-B1llI4h-.js → TiptapBody-BEBLdJl_.js} +1 -1
  2. package/dist/assets/index-DEQzGYa6.js +3227 -0
  3. package/dist/assets/{index-WUc_GHMf.css → index-DwUffaDq.css} +1 -1
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -2
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
  7. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  8. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
  9. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
  11. package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
  12. package/scripts/lib/watchdogHelpers.cjs +0 -434
  13. package/scripts/mint-epic.cjs +6 -6
  14. package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
  15. package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
  16. package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
  17. package/src/main/__tests__/epicMint.test.cjs +165 -207
  18. package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
  19. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
  20. package/src/main/__tests__/queueHistory.test.cjs +14 -0
  21. package/src/main/__tests__/rcaReport.test.cjs +188 -0
  22. package/src/main/__tests__/runVerify.test.cjs +109 -6
  23. package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
  24. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
  25. package/src/main/chatRunner.cjs +10 -1
  26. package/src/main/index.cjs +2 -4
  27. package/src/main/ipcSchemas.cjs +47 -2
  28. package/src/main/lib/agentModelResolve.cjs +97 -0
  29. package/src/main/lib/auditLog.cjs +2 -2
  30. package/src/main/lib/epicContextDigest.cjs +78 -0
  31. package/src/main/lib/epicMint.cjs +81 -164
  32. package/src/main/lib/promptSessionSchema.cjs +79 -0
  33. package/src/main/lib/promptSessionsCreateEpic.cjs +68 -0
  34. package/src/main/lib/queueHistory.cjs +14 -2
  35. package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
  36. package/src/main/runVerify.cjs +17 -2
  37. package/src/main/scheduler.cjs +79 -24
  38. package/src/preload/api.d.ts +25 -48
  39. package/src/preload/index.cjs +6 -7
  40. package/dist/assets/index-DdVGnaCd.js +0 -3227
  41. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
  42. package/scripts/propose-epic.cjs +0 -56
  43. package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
  44. package/src/main/repoAnalyzer.cjs +0 -346
  45. package/src/main/search.cjs +0 -332
@@ -0,0 +1,214 @@
1
+ /**
2
+ * scheduler-epic-digest.test.cjs — PRD 958: prepend the Epic context digest
3
+ * (PRD 950's epicContextDigest.cjs) to a job's prompt when job.epicId
4
+ * resolves to a known Epic (same resolution resolveOriginSessionId already
5
+ * performs). Additive only: the PRD's own body + FINISH_PROTOCOL must remain
6
+ * byte-for-byte unchanged, and a digest build failure must never block
7
+ * dispatch.
8
+ *
9
+ * Drives the REAL spawn path via SM_CLAUDE_BIN pointing at a stub process
10
+ * that captures the '-p' prompt argv to a file, so the assertions inspect
11
+ * exactly what would have been sent to `claude -p`.
12
+ *
13
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-epic-digest.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, beforeAll, afterAll, afterEach } 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 originalClaudeBin;
26
+ let executeJob;
27
+ let FINISH_PROTOCOL;
28
+ let buildContextDigest;
29
+
30
+ beforeAll(() => {
31
+ originalHome = process.env.HOME;
32
+ originalClaudeBin = process.env.SM_CLAUDE_BIN;
33
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-epic-digest-'));
34
+ process.env.HOME = tmpHome;
35
+
36
+ ({ executeJob, FINISH_PROTOCOL } = require('../scheduler.cjs'));
37
+ ({ buildContextDigest } = require('../lib/epicContextDigest.cjs'));
38
+ });
39
+
40
+ afterAll(() => {
41
+ process.env.HOME = originalHome;
42
+ if (originalClaudeBin === undefined) delete process.env.SM_CLAUDE_BIN;
43
+ else process.env.SM_CLAUDE_BIN = originalClaudeBin;
44
+ fs.rmSync(tmpHome, { recursive: true, force: true });
45
+ });
46
+
47
+ afterEach(() => {
48
+ delete process.env.SM_CLAUDE_BIN;
49
+ });
50
+
51
+ // Stub `claude` binary: captures the '-p' prompt argv to `capturePath`, then
52
+ // emits one stream-json result line and exits 0.
53
+ function writeClaudeStub(capturePath) {
54
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
55
+ const body = `
56
+ const fs = require('fs');
57
+ const idx = process.argv.indexOf('-p');
58
+ const prompt = idx >= 0 ? process.argv[idx + 1] : '';
59
+ fs.writeFileSync(${JSON.stringify(capturePath)}, prompt, 'utf8');
60
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
61
+ process.exit(0);
62
+ `;
63
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
64
+ return stubPath;
65
+ }
66
+
67
+ function setupProject(prefix) {
68
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-project-`));
69
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
70
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', `${prefix}-run-`));
71
+ return { projectCwd, runDir };
72
+ }
73
+
74
+ function writeLegacyPrd(projectCwd, slug, bodyText) {
75
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
76
+ fs.mkdirSync(prdsDir, { recursive: true });
77
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), bodyText, 'utf8');
78
+ return path.join(prdsDir, `${slug}.md`);
79
+ }
80
+
81
+ function writeActiveIndex(projectCwd, sessions) {
82
+ const idxPath = path.join(projectCwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
83
+ fs.mkdirSync(path.dirname(idxPath), { recursive: true });
84
+ fs.writeFileSync(idxPath, JSON.stringify({ sessions, events: {} }, null, 2), 'utf8');
85
+ }
86
+
87
+ test('prepends the Epic context digest when job.epicId resolves to a known Epic', async () => {
88
+ const { projectCwd, runDir } = setupProject('sm-epic-digest-hit');
89
+ try {
90
+ const epicId = 'epic-hit-1';
91
+ writeActiveIndex(projectCwd, {
92
+ [epicId]: { claudeSessionId: 'sess-abc-123', goalText: 'Ship the digest feature.' },
93
+ });
94
+
95
+ const slug = `958-test-hit-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
96
+ const bodyText = 'Do the thing described in this PRD body.';
97
+ writeLegacyPrd(projectCwd, slug, bodyText);
98
+
99
+ const capturePath = path.join(runDir, 'captured-prompt.txt');
100
+ process.env.SM_CLAUDE_BIN = writeClaudeStub(capturePath);
101
+
102
+ const job = { slug, cwd: projectCwd, epicId };
103
+ const result = await executeJob(job, runDir, projectCwd, () => Promise.resolve());
104
+
105
+ expect(result.exitCode).toBe(0);
106
+ const capturedPrompt = fs.readFileSync(capturePath, 'utf8');
107
+
108
+ const digestText = await buildContextDigest({ cwd: projectCwd, epicId });
109
+ expect(digestText.length).toBeGreaterThan(0);
110
+ expect(capturedPrompt.startsWith(digestText)).toBe(true);
111
+ expect(capturedPrompt.endsWith(bodyText + FINISH_PROTOCOL)).toBe(true);
112
+
113
+ // The PRD source on disk must never be rewritten with the digest merged in.
114
+ const onDiskPrd = fs.readFileSync(path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds', `${slug}.md`), 'utf8');
115
+ expect(onDiskPrd).toBe(bodyText);
116
+
117
+ const metaPath = path.join(runDir, `${slug}.meta.json`);
118
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
119
+ expect(meta.contextDigestApplied).toBe(true);
120
+ expect(meta.originSessionId).toBe('sess-abc-123');
121
+ } finally {
122
+ fs.rmSync(projectCwd, { recursive: true, force: true });
123
+ fs.rmSync(runDir, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test('is a silent no-op when job.epicId does not resolve to a known Epic', async () => {
128
+ const { projectCwd, runDir } = setupProject('sm-epic-digest-miss');
129
+ try {
130
+ const slug = `958-test-miss-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
131
+ const bodyText = 'Do the thing described in this PRD body, unaccompanied.';
132
+ writeLegacyPrd(projectCwd, slug, bodyText);
133
+
134
+ const capturePath = path.join(runDir, 'captured-prompt.txt');
135
+ process.env.SM_CLAUDE_BIN = writeClaudeStub(capturePath);
136
+
137
+ // epicId is absent entirely — no active-index.json even exists for this project.
138
+ const job = { slug, cwd: projectCwd };
139
+ const result = await executeJob(job, runDir, projectCwd, () => Promise.resolve());
140
+
141
+ expect(result.exitCode).toBe(0);
142
+ const capturedPrompt = fs.readFileSync(capturePath, 'utf8');
143
+ expect(capturedPrompt).toBe(bodyText + FINISH_PROTOCOL);
144
+
145
+ const metaPath = path.join(runDir, `${slug}.meta.json`);
146
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
147
+ expect(meta.contextDigestApplied).toBe(false);
148
+ expect(meta.originSessionId).toBe(null);
149
+ } finally {
150
+ fs.rmSync(projectCwd, { recursive: true, force: true });
151
+ fs.rmSync(runDir, { recursive: true, force: true });
152
+ }
153
+ });
154
+
155
+ test('a digest build failure is caught and never blocks dispatch', async () => {
156
+ const { projectCwd, runDir } = setupProject('sm-epic-digest-fail');
157
+ try {
158
+ const epicId = 'epic-fail-1';
159
+ writeActiveIndex(projectCwd, {
160
+ [epicId]: { claudeSessionId: 'sess-def-456', goalText: 'This Epic exists and resolves fine.' },
161
+ });
162
+
163
+ const slug = `958-test-fail-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
164
+ const bodyText = 'Do the thing even if the digest build blows up.';
165
+ writeLegacyPrd(projectCwd, slug, bodyText);
166
+
167
+ const capturePath = path.join(runDir, 'captured-prompt.txt');
168
+ process.env.SM_CLAUDE_BIN = writeClaudeStub(capturePath);
169
+
170
+ // Stub epicContextDigest.cjs's export to reject, then force scheduler.cjs
171
+ // to re-require it so it picks up the stub (mirrors exchanges.test.cjs's
172
+ // summarize.cjs stubbing pattern) — only scheduler.cjs's own cache entry
173
+ // is cleared, not its whole dependency tree.
174
+ const digestPath = require.resolve('../lib/epicContextDigest.cjs');
175
+ const realDigestModule = require.cache[digestPath];
176
+ require.cache[digestPath] = {
177
+ id: digestPath,
178
+ filename: digestPath,
179
+ loaded: true,
180
+ exports: {
181
+ buildContextDigest: async () => {
182
+ throw new Error('boom: digest build exploded');
183
+ },
184
+ },
185
+ };
186
+ const schedulerPath = require.resolve('../scheduler.cjs');
187
+ delete require.cache[schedulerPath];
188
+ const freshScheduler = require('../scheduler.cjs');
189
+
190
+ try {
191
+ const job = { slug, cwd: projectCwd, epicId };
192
+ const result = await freshScheduler.executeJob(job, runDir, projectCwd, () => Promise.resolve());
193
+
194
+ expect(result.exitCode).toBe(0);
195
+ const capturedPrompt = fs.readFileSync(capturePath, 'utf8');
196
+ // Digest failed to build — prompt falls back to the un-prefixed PRD body.
197
+ expect(capturedPrompt).toBe(bodyText + FINISH_PROTOCOL);
198
+
199
+ const metaPath = path.join(runDir, `${slug}.meta.json`);
200
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
201
+ expect(meta.contextDigestApplied).toBe(false);
202
+ expect(meta.originSessionId).toBe('sess-def-456');
203
+ } finally {
204
+ // Restore real modules for any later test in this file/process.
205
+ if (realDigestModule) require.cache[digestPath] = realDigestModule;
206
+ else delete require.cache[digestPath];
207
+ delete require.cache[schedulerPath];
208
+ ({ executeJob, FINISH_PROTOCOL } = require('../scheduler.cjs'));
209
+ }
210
+ } finally {
211
+ fs.rmSync(projectCwd, { recursive: true, force: true });
212
+ fs.rmSync(runDir, { recursive: true, force: true });
213
+ }
214
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * scheduler-writeprd-epic-rollback.test.cjs — remote.writePrd may only JOIN
3
3
  * an existing, already-human-approved Epic (via `sourcePromptId`); it must
4
- * never mint a new one (mintIfMissing:false, see epicMint.cjs's ensureEpic).
4
+ * never mint a new one (no mintAuthority, see epicMint.cjs's ensureEpic).
5
5
  * A write with no resolvable Epic fails outright with zero residual
6
6
  * active-index.json entries — no orphaned Epic can ever be left behind by
7
7
  * this path because none is ever created by it.
@@ -19,7 +19,7 @@ const fs = require('node:fs');
19
19
  const os = require('node:os');
20
20
  const path = require('node:path');
21
21
  const { remote } = require('../scheduler.cjs');
22
- const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
22
+ const { ensureEpic, readActiveIndex, MINT_AUTHORITY_NEW_EPIC_UI } = require('../lib/epicMint.cjs');
23
23
  const config = require('../config.cjs');
24
24
 
25
25
  const tmpDirs = [];
@@ -61,7 +61,7 @@ test('writePrd: no sourcePromptId refuses to write (no Epic to join) and mints n
61
61
  test('writePrd: invalid-slug failure when joining a pre-existing Epic leaves that Epic untouched', async () => {
62
62
  const cwd = makeFixtureCwd();
63
63
 
64
- const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal' });
64
+ const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal', mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI });
65
65
  expect(preexisting.created).toBe(true);
66
66
 
67
67
  const beforeIndex = readActiveIndex(cwd);
@@ -81,7 +81,7 @@ test('writePrd: invalid-slug failure when joining a pre-existing Epic leaves tha
81
81
 
82
82
  // Provenance (PRD 905): writePrd's ensureEpic call passes
83
83
  // source: { producer: 'scheduler-dispatch', prdSlug }, but this call is
84
- // join-only (mintIfMissing:false, explicit epicId) — ensureEpic's
84
+ // join-only (no mintAuthority, explicit epicId) — ensureEpic's
85
85
  // explicitEpicId branch returns before ever reading `source`, so a
86
86
  // successful join must NOT write/alter the joined Epic's `source` field.
87
87
  test('writePrd: successful join does not write a source field onto the pre-existing Epic (join-only no-op)', async () => {
@@ -92,7 +92,7 @@ test('writePrd: successful join does not write a source field onto the pre-exist
92
92
  tmpDirs.push(cwd);
93
93
  config.addAllowedRoot(cwd);
94
94
 
95
- const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal for join' });
95
+ const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal for join', mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI });
96
96
  expect(preexisting.created).toBe(true);
97
97
 
98
98
  const body = `---\ntitle: Joins the existing epic\nsourcePromptId: ${preexisting.epicId}\n---\n\nbody text\n`;
@@ -52,6 +52,7 @@ const { extractJson } = require('./lib/extractJson.cjs');
52
52
  const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
53
53
  const sessionSlots = require('./lib/sessionSlots.cjs');
54
54
  const opsErrorLog = require('./lib/opsErrorLog.cjs');
55
+ const agentModelResolve = require('./lib/agentModelResolve.cjs');
55
56
 
56
57
  // ─── Stop-signal protocol ──────────────────────────────────────────────────
57
58
  // Single source of truth for the sentinel and parser. The renderer (PRD 320)
@@ -522,10 +523,18 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
522
523
  // instruction to every prompt
523
524
  const fullPrompt = STOP_SIGNAL_INSTRUCTION + CHAT_MODE_TRUTH_INSTRUCTION + prompt;
524
525
 
526
+ // sessionId is the Epic's claudeSessionId for an Epic-backed tab (see
527
+ // comment above childEnv) — resolve that Epic's agentType persona model
528
+ // so Chat and Terminal views of the same Epic launch with the same
529
+ // model. Never throws; falls back to the hardcoded 'sonnet' literal when
530
+ // there's no Epic, no agentType, or the persona has no model/'inherit' —
531
+ // --model must never be left unpinned (CLAUDE.md model-pinning rule).
532
+ const model = agentModelResolve.resolveEpicModel({ cwd, claudeSessionId: sessionId });
533
+
525
534
  // Build argv as an array — no shell: true, no string interpolation
526
535
  const args = [
527
536
  '-p', fullPrompt,
528
- '--model', 'sonnet', // pinned per "claude -p model pinning" rule
537
+ '--model', model,
529
538
  '--dangerously-skip-permissions',
530
539
  '--output-format', 'stream-json',
531
540
  '--verbose',
@@ -32,6 +32,7 @@ const { appendAuditEvent } = require('./lib/auditLog.cjs');
32
32
  const chatRunner = require('./chatRunner.cjs');
33
33
  const promptSessionEvents = require('./promptSessionEvents.cjs');
34
34
  const activeIndexMerge = require('./lib/activeIndexMerge.cjs');
35
+ const promptSessionsCreateEpic = require('./lib/promptSessionsCreateEpic.cjs');
35
36
  const agentLibrary = require('./agentLibrary.cjs');
36
37
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
37
38
  const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
@@ -68,8 +69,6 @@ const agentMemory = require('./agentMemory.cjs');
68
69
  const git = require('./git.cjs');
69
70
  const filesIpc = require('./files.cjs');
70
71
  const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
71
- const searchIpc = require('./search.cjs');
72
- const repoAnalyzer = require('./repoAnalyzer.cjs');
73
72
  const webRemote = require('./webRemote.cjs');
74
73
  const { listExchanges } = require('./exchanges.cjs');
75
74
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
@@ -819,6 +818,7 @@ registerHistoryDashboardHandlers();
819
818
  pluginInstall.registerPluginInstallHandlers();
820
819
  memoryTool.registerMemoryHandlers();
821
820
  activeIndexMerge.registerActiveIndexMergeHandlers();
821
+ promptSessionsCreateEpic.registerPromptSessionsCreateEpicHandlers();
822
822
  registerMemoryAggregateIpc();
823
823
  registerProjectBriefIpc();
824
824
  registerProjectPagesIpc();
@@ -841,8 +841,6 @@ agentMemory.registerAgentMemoryHandlers();
841
841
  git.register(ipcMain);
842
842
  filesIpc.registerFilesHandlers();
843
843
  registerDocEditHandlers();
844
- searchIpc.registerSearchHandlers();
845
- repoAnalyzer.register(ipcMain);
846
844
  webRemote.registerRemoteHandlers();
847
845
  chatRunner.registerChatHandlers();
848
846
 
@@ -8,6 +8,7 @@
8
8
  const { z } = require('zod');
9
9
  const os = require('node:os');
10
10
  const path = require('node:path');
11
+ const { PromptSessionSchema, EpicTagSchema, EpicSourceSchema } = require('./lib/promptSessionSchema.cjs');
11
12
 
12
13
  // ──────────────────────────────────────────── PTY
13
14
  const ptySpawn = z.object({
@@ -229,9 +230,35 @@ const configParseImports = z.object({ path: z.string().min(1).max(4096) });
229
230
  // Real epic ids are `${slugify(goalText)}-${uuid8}` (epicMint.cjs) — max 64
230
231
  // mirrors slugify's 48-char cap plus the "-" + 8 hex chars suffix, rounded up.
231
232
  const PROMPT_SESSION_ID_RE = /^(?!__proto__$|constructor$|prototype$)[A-Za-z0-9_-]{1,64}$/;
233
+
234
+ // Per-ROW validation, not per-BATCH. persistActiveIndex (state/promptSessions.ts)
235
+ // sends every open Epic for a cwd in ONE call, so validating the map with a
236
+ // single parse meant one malformed/legacy row rejected the whole payload —
237
+ // every other legitimate Epic for that cwd silently stopped persisting (the
238
+ // renderer only logs the failure). Dropping just the offending row matches how
239
+ // the rest of this pipeline already fails: per-id (activeIndexMerge.cjs's
240
+ // tombstone/resurrection handling), never per-batch.
241
+ const dropInvalidSessions = (raw) => {
242
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw;
243
+ const kept = Object.create(null);
244
+ for (const id of Object.keys(raw)) {
245
+ if (!PROMPT_SESSION_ID_RE.test(id)) {
246
+ console.warn(`[ipc] merge-active-index: dropping session with invalid id ${JSON.stringify(id)}`);
247
+ continue;
248
+ }
249
+ const parsed = PromptSessionSchema.safeParse(raw[id]);
250
+ if (!parsed.success) {
251
+ console.warn(`[ipc] merge-active-index: dropping malformed session ${id} — ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
252
+ continue;
253
+ }
254
+ kept[id] = raw[id];
255
+ }
256
+ return kept;
257
+ };
258
+
232
259
  const promptSessionsMergeActiveIndex = z.object({
233
260
  cwd: z.string().min(1).max(4096),
234
- sessions: z.record(z.string().regex(PROMPT_SESSION_ID_RE), z.unknown()).refine(
261
+ sessions: z.preprocess(dropInvalidSessions, z.record(z.string().regex(PROMPT_SESSION_ID_RE), PromptSessionSchema)).refine(
235
262
  (v) => Object.keys(v).length <= 2000,
236
263
  { message: 'too many sessions in one merge payload' },
237
264
  ),
@@ -247,6 +274,19 @@ const promptSessionsMergeActiveIndex = z.object({
247
274
  source: z.literal('epics'),
248
275
  });
249
276
 
277
+ // Renderer entry point for main's ensureEpic() (lib/promptSessionsCreateEpic.cjs)
278
+ // — additive step 1 of a two-PRD chain; unused by the app until the next PRD
279
+ // routes state/promptSessions.ts's createPromptSession through it. `tag`/
280
+ // `source` reuse promptSessionSchema.cjs's own enums rather than retyping
281
+ // the value lists a third time.
282
+ const promptSessionsCreateEpic = z.object({
283
+ cwd: z.string().min(1).max(4096),
284
+ goalText: z.string().min(1).max(20000),
285
+ tag: EpicTagSchema.optional(),
286
+ agentType: z.string().min(1).max(256).optional(),
287
+ source: EpicSourceSchema.optional(),
288
+ });
289
+
250
290
  // ──────────────────────────────────────────── Sessions
251
291
  const sessionsPayload = z.object({
252
292
  tabs: z.array(z.object({
@@ -345,7 +385,11 @@ const schedulerCreatePrd = z.object({
345
385
  // forgot to pass sourcePromptId explicitly.
346
386
  originClaudeSessionId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
347
387
  // User-selected Feature/Bug tag (PRD 774) carried from the originating
348
- // PromptTicket — deterministic, never LLM-classified.
388
+ // PromptTicket — deterministic, never LLM-classified. NOT the same value
389
+ // set as PromptSession's Epic-mission `tag` (lib/promptSessionSchema.cjs's
390
+ // EpicTagSchema) — that one also includes 'bilko-host-publisher'. Keep
391
+ // these two enums in sync deliberately if their meanings ever converge;
392
+ // don't assume editing one updates the other.
349
393
  tag: z.enum(['feature', 'bug', 'discussion', 'build', 'project-home-builder']).optional(),
350
394
  });
351
395
 
@@ -971,6 +1015,7 @@ module.exports = {
971
1015
  promptSessionTranscriptAppend,
972
1016
  promptSessionTranscriptRead,
973
1017
  promptSessionsMergeActiveIndex,
1018
+ promptSessionsCreateEpic,
974
1019
  auditLogAppend,
975
1020
  agentMemoryList,
976
1021
  agentMemoryGet,
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * agentModelResolve.cjs — resolves the `--model` an Epic's session should
5
+ * launch a headless `claude -p` (or Terminal `claude`) process with: its
6
+ * agentType persona's own `model` field (Agent Library) when the Epic has
7
+ * one set and it isn't 'inherit', else `fallbackModel`.
8
+ *
9
+ * Extracted from PRD agent-model-default-terminal (which wired this for the
10
+ * Terminal-view launch in EpicTerminalPane.tsx) so chatRunner.cjs's headless
11
+ * Chat-view launch resolves the SAME persona-derived model — Chat and
12
+ * Terminal are two VIEWS over one Epic session (CLAUDE.md) and must agree on
13
+ * which model that session launches with.
14
+ *
15
+ * Plain Node module (sync fs) — no Electron deps, mirrors epicMint.cjs's
16
+ * readActiveIndex so this can be called from chatRunner.cjs's synchronous
17
+ * executeRun() without restructuring it into an async flow.
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const os = require('node:os');
23
+ const { splitFrontmatter } = require('./prdFrontmatter.cjs');
24
+ const { readActiveIndex } = require('./epicMint.cjs');
25
+ const configMgr = require('../config.cjs');
26
+
27
+ /** The hardcoded floor every branch of resolveEpicModel falls back to — --model must never be omitted (CLAUDE.md "Automation model pinning"). */
28
+ const FALLBACK_MODEL = 'sonnet';
29
+
30
+ /**
31
+ * Looks up the agentType an Epic (PromptSession) was created with, by the
32
+ * Epic's own claudeSessionId — the same domain-model join `prdCreate.cjs`'s
33
+ * resolveSourcePromptIdFromClaudeSession uses (Epic:claude-session is 1:1).
34
+ * Returns null if no matching Epic is found or it has no agentType set.
35
+ */
36
+ function findAgentTypeByClaudeSessionId(cwd, claudeSessionId, deps = {}) {
37
+ if (!cwd || !claudeSessionId) return null;
38
+ const load = deps.readActiveIndex || readActiveIndex;
39
+ const { sessions } = load(cwd);
40
+ for (const session of Object.values(sessions)) {
41
+ if (session && session.claudeSessionId === claudeSessionId) return session.agentType || null;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Reads a global agent persona's `model` frontmatter field by name
48
+ * (`~/.claude/agents/<agentType>.md`), sync. Returns null on any miss
49
+ * (no such file, unreadable, no `model` key) — never throws.
50
+ */
51
+ function readPersonaModel(agentType, deps = {}) {
52
+ if (!agentType) return null;
53
+ const globalDir = deps.globalDir || path.join(os.homedir(), '.claude', 'agents');
54
+ const validatePath = deps.validatePath || configMgr.validatePath;
55
+ let real;
56
+ try {
57
+ real = validatePath(path.join(globalDir, `${agentType}.md`));
58
+ } catch {
59
+ return null;
60
+ }
61
+ let text;
62
+ try {
63
+ text = fs.readFileSync(real, 'utf8');
64
+ } catch {
65
+ return null;
66
+ }
67
+ const { fm } = splitFrontmatter(text);
68
+ return fm.model || null;
69
+ }
70
+
71
+ /**
72
+ * Resolves the `--model` value for an Epic-backed session launch. Never
73
+ * throws — any lookup failure (missing Epic, missing persona, unreadable
74
+ * file) falls back to `fallbackModel` (default: 'sonnet', the same literal
75
+ * every claude -p call site pins per CLAUDE.md's model-pinning rule).
76
+ *
77
+ * @param {{ cwd: string, claudeSessionId: string, fallbackModel?: string, deps?: object }} opts
78
+ * @returns {string}
79
+ */
80
+ function resolveEpicModel({ cwd, claudeSessionId, fallbackModel = FALLBACK_MODEL, deps = {} } = {}) {
81
+ try {
82
+ const agentType = findAgentTypeByClaudeSessionId(cwd, claudeSessionId, deps);
83
+ if (!agentType) return fallbackModel;
84
+ const model = readPersonaModel(agentType, deps);
85
+ if (!model || model === 'inherit') return fallbackModel;
86
+ return model;
87
+ } catch {
88
+ return fallbackModel;
89
+ }
90
+ }
91
+
92
+ module.exports = {
93
+ FALLBACK_MODEL,
94
+ resolveEpicModel,
95
+ findAgentTypeByClaudeSessionId,
96
+ readPersonaModel,
97
+ };
@@ -8,10 +8,10 @@
8
8
  * survive even if the project's ops root doesn't exist yet.
9
9
  *
10
10
  * Best-effort only: a logging failure must never block the mint/create it is
11
- * observing (same posture as rcaFeedbackHook.cjs / dodDrainHook.cjs).
11
+ * observing (same posture as rcaReport.cjs / dodDrainHook.cjs).
12
12
  *
13
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.
14
+ * scripts/mint-epic.cjs (which runs outside Electron) can require it too.
15
15
  */
16
16
  'use strict';
17
17
 
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * epicContextDigest.cjs — condensed, real (never fabricated) digest of an
5
+ * Epic's own session, for later injection into a scheduled PRD job's opening
6
+ * prompt (wiring is a separate, dependent PRD — this module is groundwork
7
+ * only). Composes the Epic's goalText (active-index.json, via epicMint.cjs's
8
+ * readActiveIndex) with its most-recent turns (the JSONL transcript, via
9
+ * promptSessionTranscript.cjs's readTurns) — never reimplements either read.
10
+ *
11
+ * Plain Node module (no Electron deps), matching sessionSlots.cjs's and
12
+ * promptSessionTranscript.cjs's own posture, so it is callable from
13
+ * scheduler.cjs.
14
+ */
15
+
16
+ const { readTurns } = require('../promptSessionTranscript.cjs');
17
+ const { readActiveIndex } = require('./epicMint.cjs');
18
+
19
+ const DEFAULT_MAX_CHARS = 4000;
20
+ const TURN_LIMIT = 40;
21
+
22
+ function formatTurn(turn) {
23
+ return `[${turn.role}] ${turn.text}`;
24
+ }
25
+
26
+ /**
27
+ * buildContextDigest({ cwd, epicId, maxChars }) → Promise<string>
28
+ *
29
+ * Best-effort: every internal call is wrapped so a failure here can never
30
+ * propagate to a caller — returns '' on any error, missing epicId, an epicId
31
+ * absent from the cwd's active-index.json, or an empty transcript.
32
+ */
33
+ async function buildContextDigest({ cwd, epicId, maxChars } = {}) {
34
+ try {
35
+ if (!cwd || !epicId) return '';
36
+ const limit = typeof maxChars === 'number' && maxChars > 0 ? maxChars : DEFAULT_MAX_CHARS;
37
+
38
+ const index = readActiveIndex(cwd);
39
+ if (!Object.prototype.hasOwnProperty.call(index.sessions, epicId)) return '';
40
+ const session = index.sessions[epicId];
41
+ const goalText = session && typeof session.goalText === 'string' ? session.goalText.trim() : '';
42
+
43
+ const turns = await readTurns(cwd, epicId, { limit: TURN_LIMIT });
44
+
45
+ const header = goalText;
46
+ const headerChars = header ? header.length + 1 : 0; // +1 for trailing newline before turns
47
+
48
+ // Drop whole turns from the oldest first until the joined digest fits —
49
+ // never a hard mid-string slice that could cut a turn's text in half.
50
+ let kept = turns.slice();
51
+ while (kept.length > 0) {
52
+ const lines = kept.map(formatTurn);
53
+ const bodyChars = lines.length > 0 ? lines.join('\n').length : 0;
54
+ const total = headerChars + bodyChars;
55
+ if (total <= limit) break;
56
+ kept = kept.slice(1);
57
+ }
58
+
59
+ const parts = [];
60
+ if (header) parts.push(header);
61
+ if (kept.length > 0) parts.push(kept.map(formatTurn).join('\n'));
62
+
63
+ let digest = parts.join('\n');
64
+ if (digest.length > limit) {
65
+ // Header alone (no turns fit) can still exceed maxChars — clip the
66
+ // header itself rather than exceeding the caller's bound.
67
+ digest = digest.slice(0, limit);
68
+ }
69
+ return digest;
70
+ } catch (e) {
71
+ console.error('[epicContextDigest] buildContextDigest error', cwd, epicId, e);
72
+ return '';
73
+ }
74
+ }
75
+
76
+ module.exports = {
77
+ buildContextDigest,
78
+ };