claude-code-session-manager 0.57.1 → 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 (43) hide show
  1. package/dist/assets/{TiptapBody-CJDxVF7X.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__/rcaReport.test.cjs +188 -0
  21. package/src/main/__tests__/runVerify.test.cjs +109 -6
  22. package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
  23. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
  24. package/src/main/chatRunner.cjs +10 -1
  25. package/src/main/index.cjs +2 -4
  26. package/src/main/ipcSchemas.cjs +47 -2
  27. package/src/main/lib/agentModelResolve.cjs +97 -0
  28. package/src/main/lib/auditLog.cjs +2 -2
  29. package/src/main/lib/epicContextDigest.cjs +78 -0
  30. package/src/main/lib/epicMint.cjs +81 -164
  31. package/src/main/lib/promptSessionSchema.cjs +79 -0
  32. package/src/main/lib/promptSessionsCreateEpic.cjs +68 -0
  33. package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
  34. package/src/main/runVerify.cjs +17 -2
  35. package/src/main/scheduler.cjs +79 -24
  36. package/src/preload/api.d.ts +25 -48
  37. package/src/preload/index.cjs +6 -7
  38. package/dist/assets/index-DCa3QvAi.js +0 -3227
  39. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
  40. package/scripts/propose-epic.cjs +0 -56
  41. package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
  42. package/src/main/repoAnalyzer.cjs +0 -346
  43. package/src/main/search.cjs +0 -332
@@ -1,10 +1,20 @@
1
1
  /**
2
- * epicMint.cjs — auto-mint an Epic (PromptSession) for a PRD dispatch.
2
+ * epicMint.cjs — resolve the Epic (PromptSession) a PRD dispatch belongs to,
3
+ * and mint the one Epic-creation path there is.
3
4
  *
4
5
  * Domain rule (CLAUDE.md "Domain model"): every PRD belongs to an Epic; the
5
- * hierarchy TAB → operations-root → EPIC → PRD is total. Dispatch paths that
6
- * have no Epic in hand (feedback sweep, ad-hoc /develop, admin/MCP create)
7
- * call ensureEpic() to create-or-join one instead of writing epicless PRDs.
6
+ * hierarchy TAB → operations-root → EPIC → PRD is total. Dispatch paths call
7
+ * ensureEpic() to JOIN the Epic they already belong to instead of writing
8
+ * epicless PRDs — they can never create one.
9
+ *
10
+ * SINGLE-CREATOR LAW (fail-closed, mirrors opsOwnership.cjs's assertOpsWrite):
11
+ * an Epic comes into existence in exactly ONE place — the human pressing
12
+ * "New Epic" in the app. That is the only caller allowed to pass
13
+ * `mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI` (today: lib/
14
+ * promptSessionsCreateEpic.cjs, the New Epic card's IPC handler). Every other
15
+ * caller is join-only and throws rather than conjuring an Epic. Agents that
16
+ * are sure work is needed run /develop inside the Epic they are already in;
17
+ * agents that are not sure say so and let the human open an Epic.
8
18
  *
9
19
  * The Epic registry is the renderer's own store: the per-cwd
10
20
  * `session-manager-operations/prompt-sessions/active-index.json`
@@ -23,6 +33,17 @@ const crypto = require('node:crypto');
23
33
  const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
24
34
  const { assertOpsWrite } = require('./opsOwnership.cjs');
25
35
  const { appendAuditEvent } = require('./auditLog.cjs');
36
+ // Required as the module object (not destructured) so a test can
37
+ // monkeypatch promptSessionSchema.assertValidPromptSession in place to
38
+ // simulate a corrupted construction — the real construction below is
39
+ // hardcoded and always valid.
40
+ const promptSessionSchema = require('./promptSessionSchema.cjs');
41
+
42
+ // The one token that unlocks ensureEpic's mint branch. Held by
43
+ // lib/promptSessionsCreateEpic.cjs (the New Epic card's IPC handler) and
44
+ // nothing else — grep for it before adding a second holder, that is a
45
+ // deliberate domain-model change, not a convenience.
46
+ const MINT_AUTHORITY_NEW_EPIC_UI = 'new-epic-ui';
26
47
 
27
48
  function activeIndexPath(cwd) {
28
49
  return path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
@@ -63,7 +84,7 @@ function writeActiveIndex(cwd, index) {
63
84
  // "constructor" resolves through the prototype chain to a truthy
64
85
  // Object.prototype member even though no such Epic was ever written,
65
86
  // bypassing every "does this Epic exist" gate below (including the
66
- // mintIfMissing:false join-only check that PRD-authoring paths rely on).
87
+ // join-only existence check that PRD-authoring paths rely on).
67
88
  // Always use this instead of `obj[key]`/`!obj[key]` for existence checks.
68
89
  function hasOwn(obj, key) {
69
90
  return Object.prototype.hasOwnProperty.call(obj, key);
@@ -77,85 +98,6 @@ function slugify(text) {
77
98
  .slice(0, 48) || 'epic';
78
99
  }
79
100
 
80
- const STOPWORDS = new Set([
81
- 'the', 'a', 'an', 'is', 'of', 'to', 'for', 'and', 'in', 'on', 'at', 'this', 'that',
82
- ]);
83
-
84
- // Lowercase, strip punctuation, split on whitespace, drop stopwords — shared
85
- // by findJoinableEpic's similarity check. Kept standalone so its behavior is
86
- // independently testable rather than inlined into the Jaccard computation.
87
- function tokenize(text) {
88
- return String(text || '')
89
- .toLowerCase()
90
- .replace(/[^a-z0-9\s]+/g, ' ')
91
- .split(/\s+/)
92
- .filter((token) => token && !STOPWORDS.has(token));
93
- }
94
-
95
- function jaccardSimilarity(tokensA, tokensB) {
96
- const setA = new Set(tokensA);
97
- const setB = new Set(tokensB);
98
- if (setA.size === 0 && setB.size === 0) return 0;
99
- let intersection = 0;
100
- for (const token of setA) {
101
- if (setB.has(token)) intersection += 1;
102
- }
103
- const union = setA.size + setB.size - intersection;
104
- return union === 0 ? 0 : intersection / union;
105
- }
106
-
107
- const JOIN_SIMILARITY_THRESHOLD = 0.35;
108
-
109
- /**
110
- * findJoinableEpicInIndex(index, { goalText, preferEpicId, status }) → { epicId, matchedBy, score? } | null
111
- *
112
- * Same contract as findJoinableEpic() but takes an already-loaded index —
113
- * lets ensureEpic() consult this without a second readActiveIndex() call
114
- * inside its own withPathLock critical section. Two strategies, in order:
115
- * 1. `preferEpicId` — an explicitly-known origin Epic the caller already has
116
- * in hand. Joined immediately (no similarity check) as long as it exists
117
- * and is still open ('proposed' or 'active') — a 'completed' or unknown
118
- * preferEpicId falls through to strategy 2 rather than joining a dead Epic.
119
- * 2. Keyword-similarity — Jaccard token-set overlap between `goalText` and
120
- * every other OPEN Epic's goalText in the same cwd (open = 'proposed' or
121
- * 'active', regardless of the specific requested `status` — a proposed
122
- * RCA report and an active one about the same topic are still the same
123
- * underlying issue). Highest score wins if it clears
124
- * JOIN_SIMILARITY_THRESHOLD; otherwise no join. `status` is accepted for
125
- * signature symmetry with ensureEpic()/reuseByGoal but only participates
126
- * in strategy 1's preferEpicId open-check, not the similarity filter.
127
- */
128
- function findJoinableEpicInIndex(index, { goalText, preferEpicId = null, status: _status = 'proposed' } = {}) {
129
- if (preferEpicId && hasOwn(index.sessions, preferEpicId)) {
130
- const preferred = index.sessions[preferEpicId];
131
- if (preferred && (preferred.status === 'proposed' || preferred.status === 'active')) {
132
- return { epicId: preferEpicId, matchedBy: 'preferEpicId' };
133
- }
134
- }
135
-
136
- const candidateTokens = tokenize(goalText);
137
- let best = null;
138
- for (const s of Object.values(index.sessions)) {
139
- if (!s || (s.status !== 'proposed' && s.status !== 'active')) continue;
140
- const score = jaccardSimilarity(candidateTokens, tokenize(s.goalText));
141
- if (score >= JOIN_SIMILARITY_THRESHOLD && (!best || score > best.score)) {
142
- best = { epicId: s.id, matchedBy: 'similarity', score };
143
- }
144
- }
145
- return best;
146
- }
147
-
148
- /**
149
- * findJoinableEpic(cwd, { goalText, preferEpicId, status }) → { epicId, matchedBy, score? } | null
150
- *
151
- * Public entry point for callers outside ensureEpic()'s own critical section
152
- * (tests, future PRD 899/900 wiring) — loads the index itself. See
153
- * findJoinableEpicInIndex() for the matching logic.
154
- */
155
- function findJoinableEpic(cwd, opts = {}) {
156
- return findJoinableEpicInIndex(readActiveIndex(cwd), opts);
157
- }
158
-
159
101
  // Serializes read-modify-write cycles per active-index.json path, mirroring
160
102
  // promptSessionEvents.cjs's own pendingWritesByPath/withPathLock. The current
161
103
  // read/write pair below is synchronous (fs.readFileSync/writeFileSync), so
@@ -180,40 +122,26 @@ function withPathLock(lockPath, task) {
180
122
  }
181
123
 
182
124
  /**
183
- * ensureEpic(cwd, { goalText, tag?, reuseByGoal?, status?, openingPrompt?, mintIfMissing?, source?, forceNewEpic? }) → Promise<{ epicId, prdDir, created }>
184
- *
185
- * Before minting brand-new, the mint branch consults findJoinableEpic() —
186
- * minting is the LAST resort, not the default, for automated callers. Pass
187
- * `forceNewEpic: true` to skip that check and always mint (the one
188
- * legitimate case being explicit human-authored creation).
125
+ * ensureEpic(cwd, { epicId?, goalText, tag?, status?, openingPrompt?, source?, agentType?, mintAuthority? }) → Promise<{ epicId, prdDir, created }>
189
126
  *
190
- * `status` defaults to 'proposed' — a fail-safe default so any caller that
191
- * forgets to pass it files an Epic that waits for human approval rather than
192
- * one that starts running immediately. Every Epic is BORN 'proposed' — the
193
- * mint branch below ignores/rejects any other requested status; it is
194
- * fail-closed, mirroring opsOwnership.cjs's assertOpsWrite. Activation
195
- * ('proposed' → 'active') happens exactly once, entirely in the renderer
196
- * store's `approveProposed` (`state/promptSessions.ts`) — that code path
197
- * never calls ensureEpic(). Joining an already-'active' Epic (this function's
198
- * join branches, above the mint branch) remains legal and unchanged.
127
+ * Two behaviors, and only two:
128
+ * - JOIN (the default, for every automated caller): `epicId` names an Epic
129
+ * that already exists and is still open ('proposed'/'active') → its prds/
130
+ * directory is returned with `created: false`. Anything else throws.
131
+ * - MINT (the New Epic UI alone): `mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI`
132
+ * creates a brand-new Epic. No similarity/keyword matching runs first — the
133
+ * human asked for a new Epic, so they get a new Epic.
199
134
  *
200
- * `mintIfMissing` defaults to true for the small set of callers that are
201
- * themselves the human-intent gate (propose-epic, the RCA hook, the feedback
202
- * sweep — all of which pass `status: 'proposed'`, so "minting" here still
203
- * never starts anything without a human's Approve & start). Callers that are
204
- * NOT themselves a human-intent gate (an automated PRD-write path acting on
205
- * a session's behalf) must pass `mintIfMissing: false` — that path may only
206
- * JOIN an Epic that already exists; it throws instead of silently creating a
207
- * new one, so no PRD-authoring surface can conjure Epics on its own.
208
- *
209
- * Mints a new Epic — or, with `reuseByGoal`, joins the existing Epic (of the
210
- * same `status`) whose goalText matches (used by the recurring feedback sweep
211
- * so successive sweeps chain into one Epic instead of minting one per tick).
135
+ * Every Epic is BORN 'proposed'; `status` defaults to it and the mint branch
136
+ * rejects any other value outright (fail-closed, mirroring opsOwnership.cjs's
137
+ * assertOpsWrite). Activation ('proposed' → 'active') happens exactly once, in
138
+ * the renderer store's `approveProposed` (`state/promptSessions.ts`) — a path
139
+ * that never calls ensureEpic().
212
140
  *
213
141
  * The Epic's id doubles as its directory name under scheduler/epics/, so the
214
142
  * PromptSession ↔ on-disk Epic mapping is 1:1 with no lookup table.
215
143
  */
216
- function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, mintIfMissing = true, source = null, forceNewEpic = false } = {}) {
144
+ function ensureEpic(cwd, { goalText, tag, epicId: explicitEpicId, status = 'proposed', openingPrompt = null, source = null, agentType = null, mintAuthority = null } = {}) {
217
145
  if (!cwd || typeof cwd !== 'string') throw new Error('ensureEpic: cwd is required');
218
146
  // A relative cwd (e.g. a caller passing '.') would otherwise get stored
219
147
  // verbatim on the minted Epic's `cwd` field — the renderer's EpicsWorkspace
@@ -230,8 +158,7 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
230
158
  // let a stale/hallucinated sourcePromptId silently attach a PRD (and its
231
159
  // follow-on events/chat activity) to an unrelated or even completed
232
160
  // Epic — the "this session ran again without my knowledge, and it was
233
- // really another Epic's prompt" cross-contamination bug. Mirrors
234
- // findJoinableEpicInIndex()'s preferEpicId open-check (line ~124).
161
+ // really another Epic's prompt" cross-contamination bug.
235
162
  if (explicitEpicId && hasOwn(index.sessions, explicitEpicId)) {
236
163
  const preferred = index.sessions[explicitEpicId];
237
164
  if (preferred && (preferred.status === 'proposed' || preferred.status === 'active')) {
@@ -239,57 +166,26 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
239
166
  fs.mkdirSync(prdDir, { recursive: true });
240
167
  return { epicId: explicitEpicId, prdDir, created: false };
241
168
  }
242
- console.warn(`[epicMint] ensureEpic: explicit epicId ${explicitEpicId} exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join, falling through`);
169
+ console.warn(`[epicMint] ensureEpic: explicit epicId ${explicitEpicId} exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join`);
243
170
  appendAuditEvent('epic_mint_refused', {
244
171
  cwd,
245
172
  epicId: explicitEpicId,
246
173
  status,
247
- reason: `explicit epicId exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join, falling through to mint`,
174
+ reason: `explicit epicId exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join`,
248
175
  });
249
176
  }
250
177
 
251
- if (reuseByGoal) {
252
- for (const s of Object.values(index.sessions)) {
253
- // Match the status being requested so repeat proposals chain into
254
- // one proposal instead of spawning a duplicate per trigger.
255
- if (s && s.status === status && s.goalText === goalText) {
256
- // A PROPOSED Epic has not started, so its opening prompt is still
257
- // mutable: a re-trigger carrying richer detail (e.g. the RCA hook's
258
- // later investigation pass) enriches the pending proposal in place
259
- // rather than filing a duplicate. Never done for an active Epic —
260
- // its first turn is already history.
261
- if (s.status === 'proposed' && openingPrompt && openingPrompt !== s.openingPrompt) {
262
- s.openingPrompt = String(openingPrompt);
263
- const chain = index.events[s.id];
264
- if (Array.isArray(chain) && chain[0] && chain[0].kind === 'prompt') {
265
- chain[0].text = String(openingPrompt);
266
- }
267
- writeActiveIndex(cwd, index);
268
- }
269
- const prdDir = resolveEpicPrdWriteDir(cwd, s.id);
270
- fs.mkdirSync(prdDir, { recursive: true });
271
- return { epicId: s.id, prdDir, created: false };
272
- }
273
- }
274
- }
275
-
276
- if (!mintIfMissing) {
277
- const reason = 'no existing Epic found and mintIfMissing is false — a new Epic can only be created by '
278
- + 'explicit human intent (New Epic UI, or /propose-epic + Approve & start), never implicitly by a '
279
- + 'PRD-authoring path';
178
+ // SINGLE-CREATOR LAW (see this file's header). Only the New Epic UI may
179
+ // mint; every other caller reaching this line was trying to JOIN an Epic
180
+ // that isn't there, which is a bug in the caller, not a cue to create one.
181
+ if (mintAuthority !== MINT_AUTHORITY_NEW_EPIC_UI) {
182
+ const reason = 'no open Epic to join — an Epic is created in exactly one place, the New Epic UI '
183
+ + '(mintAuthority). Agents that are sure the work is needed run /develop inside the Epic they are '
184
+ + 'already in; a PRD-authoring path may never conjure one';
280
185
  appendAuditEvent('epic_mint_refused', { cwd, epicId: explicitEpicId ?? null, status, reason });
281
186
  throw new Error(`ensureEpic: ${reason} (epicId=${explicitEpicId ?? 'none'})`);
282
187
  }
283
188
 
284
- if (!forceNewEpic) {
285
- const joinable = findJoinableEpicInIndex(index, { goalText, preferEpicId: explicitEpicId, status });
286
- if (joinable) {
287
- const prdDir = resolveEpicPrdWriteDir(cwd, joinable.epicId);
288
- fs.mkdirSync(prdDir, { recursive: true });
289
- return { epicId: joinable.epicId, prdDir, created: false };
290
- }
291
- }
292
-
293
189
  // BORN-PROPOSED LAW (fail-closed, mirrors opsOwnership.cjs's
294
190
  // assertOpsWrite): a mint always writes 'proposed', regardless of what
295
191
  // status the caller requested. A caller explicitly asking to mint
@@ -313,9 +209,8 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
313
209
  // Independently minted, never shared with a SessionTab — same invariant
314
210
  // as renderer-created PromptSessions (state/promptSessions.ts).
315
211
  claudeSessionId: crypto.randomUUID(),
316
- // 'proposed' files the Epic WITHOUT starting it — nothing runs until a
317
- // human approves it in the Epics workspace. This is the sink that
318
- // replaced the feedback-folder intake (see lib/rcaFeedbackHook.cjs).
212
+ // 'proposed' files the Epic WITHOUT starting it — nothing runs until the
213
+ // human presses Approve & start in the Epics workspace.
319
214
  status,
320
215
  createdAt: now,
321
216
  completedAt: null,
@@ -323,10 +218,34 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
323
218
  // Full body for a proposal whose goalText is only a one-line title;
324
219
  // sent verbatim as the first prompt when a human approves it.
325
220
  ...(openingPrompt ? { openingPrompt: String(openingPrompt) } : {}),
326
- // Structured trace of which automated producer minted this Epic — see
327
- // EpicSource in state/promptSessions.ts.
221
+ // Structured trace of which surface minted this Epic — see EpicSource in
222
+ // state/promptSessions.ts.
328
223
  ...(source ? { source } : {}),
224
+ // Display-only "who is working" persona name (New Epic card's Agent
225
+ // picker). The renderer no longer constructs a PromptSession itself —
226
+ // PRD 955 / commit ba54269 routed createPromptSession through this same
227
+ // ensureEpic IPC path (state/promptSessions.ts). Never affects which
228
+ // claude CLI spawns.
229
+ ...(agentType ? { agentType } : {}),
329
230
  };
231
+
232
+ // Validate the constructed record against the canonical PromptSession
233
+ // schema (promptSessionSchema.cjs) before it ever reaches disk — closes
234
+ // the drift risk between this hand-constructed literal and the
235
+ // renderer's own createPromptSession, same fail-closed spirit as the
236
+ // BORN-PROPOSED LAW check above.
237
+ try {
238
+ promptSessionSchema.assertValidPromptSession(session);
239
+ } catch (err) {
240
+ appendAuditEvent('epic_mint_refused', {
241
+ cwd,
242
+ epicId,
243
+ status,
244
+ reason: `constructed session object failed PromptSession schema validation: ${err.message}`,
245
+ });
246
+ throw err;
247
+ }
248
+
330
249
  const firstEvent = {
331
250
  id: crypto.randomUUID(),
332
251
  promptSessionId: epicId,
@@ -339,9 +258,8 @@ function ensureEpic(cwd, { goalText, tag, reuseByGoal = false, epicId: explicitE
339
258
  index.events[epicId] = [firstEvent];
340
259
  writeActiveIndex(cwd, index);
341
260
 
342
- // Every mint is logged, whether the Epic starts 'proposed' (human gate
343
- // ahead) or 'active' (started immediately) — this is the trace-back point
344
- // for "who/what created this Epic" (see auditLog.cjs).
261
+ // Every mint is logged — the trace-back point for "who created this Epic"
262
+ // (see auditLog.cjs).
345
263
  appendAuditEvent('epic_mint', { cwd, epicId, status, tag: tag ?? null, goalText: session.goalText, source: source ?? null });
346
264
 
347
265
  const prdDir = resolveEpicPrdWriteDir(cwd, epicId);
@@ -399,12 +317,11 @@ function removeEpic(cwd, epicId) {
399
317
 
400
318
  module.exports = {
401
319
  ensureEpic,
320
+ MINT_AUTHORITY_NEW_EPIC_UI,
402
321
  appendPrdCreatedEvent,
403
322
  removeEpic,
404
323
  activeIndexPath,
405
324
  readActiveIndex,
406
- findJoinableEpic,
407
- tokenize,
408
325
  // Exported so lib/activeIndexMerge.cjs's renderer-facing merge IPC handler
409
326
  // serializes through the SAME lock instance as ensureEpic/
410
327
  // appendPrdCreatedEvent (module-level Map, shared via Node's require
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Canonical runtime schema for a PromptSession (Epic) record, mirroring the
3
+ * TS `PromptSession` interface at src/renderer/state/promptSessions.ts:29-76
4
+ * field-for-field. Two independent code paths hand-construct this same
5
+ * record shape — the renderer's createPromptSession and this main-process
6
+ * module's ensureEpic mint branch (epicMint.cjs) — kept in sync only by doc
7
+ * comments today. This schema is the single source of truth to validate
8
+ * against, closing that drift risk on the main-process/CLI/scheduler side.
9
+ *
10
+ * Plain CJS, no Electron dependency, so it's requirable from epicMint.cjs
11
+ * and (in a later PRD) ipcSchemas.cjs's IPC boundary.
12
+ */
13
+ 'use strict';
14
+
15
+ const { z } = require('zod');
16
+
17
+ // Mirrors EpicTag (src/renderer/lib/tagLibrary.ts:10) — the Epic-level
18
+ // mission tag, NOT the same value set as ipcSchemas.cjs's scheduleRetagPrd
19
+ // tag enum (~line 349), which only covers 5 of these 6 values ('feature',
20
+ // 'bug', 'discussion', 'build', 'project-home-builder') and is missing
21
+ // 'bilko-host-publisher' — the two enums describe related but distinct
22
+ // concepts (PRD-scheduling tag vs. Epic-mission tag), so this is its own
23
+ // definition rather than an import of that one.
24
+ const EpicTagSchema = z.enum([
25
+ 'feature',
26
+ 'bug',
27
+ 'discussion',
28
+ 'build',
29
+ 'project-home-builder',
30
+ 'bilko-host-publisher',
31
+ ]);
32
+
33
+ // Mirrors EpicSource (src/renderer/state/promptSessions.ts:22-27).
34
+ const EpicSourceSchema = z.object({
35
+ producer: z.enum(['new-epic-ui', 'scheduler-dispatch']),
36
+ prdSlug: z.string().optional(),
37
+ runId: z.string().optional(),
38
+ sourceTabId: z.string().optional(),
39
+ });
40
+
41
+ // Mirrors PromptSession (src/renderer/state/promptSessions.ts:29-76).
42
+ const PromptSessionSchema = z.object({
43
+ id: z.string(),
44
+ cwd: z.string(),
45
+ goalText: z.string(),
46
+ claudeSessionId: z.string(),
47
+ status: z.enum(['proposed', 'active', 'completed']),
48
+ createdAt: z.string(),
49
+ completedAt: z.string().nullable(),
50
+ resumedFromId: z.string().nullable().optional(),
51
+ tag: EpicTagSchema.optional(),
52
+ openingPrompt: z.string().nullable().optional(),
53
+ source: EpicSourceSchema.optional(),
54
+ agentType: z.string().optional(),
55
+ });
56
+
57
+ /**
58
+ * Throws a clear, descriptive error (not a raw ZodError dump) when `session`
59
+ * doesn't match PromptSessionSchema. Callers that want fail-closed
60
+ * enforcement before persisting a session object call this immediately
61
+ * before their write.
62
+ */
63
+ function assertValidPromptSession(session) {
64
+ const result = PromptSessionSchema.safeParse(session);
65
+ if (!result.success) {
66
+ const issues = result.error.issues
67
+ .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
68
+ .join('; ');
69
+ throw new Error(`assertValidPromptSession: invalid PromptSession shape — ${issues}`);
70
+ }
71
+ return result.data;
72
+ }
73
+
74
+ module.exports = {
75
+ PromptSessionSchema,
76
+ EpicSourceSchema,
77
+ EpicTagSchema,
78
+ assertValidPromptSession,
79
+ };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * promptSessionsCreateEpic.cjs — IPC entry point letting the renderer mint a
5
+ * new Epic through main's own ensureEpic() instead of hand-constructing a
6
+ * PromptSession record itself (state/promptSessions.ts's createPromptSession).
7
+ *
8
+ * Step 1 of a two-PRD chain (PRD 953 here; PRD 954 routes createPromptSession
9
+ * through it): purely additive today — nothing calls this handler yet. Kept
10
+ * as a sibling module (mirrors lib/activeIndexMerge.cjs, which does the same
11
+ * "own file, own register*Handlers export, wired from index.cjs" shape for
12
+ * the neighboring active-index merge IPC) rather than growing epicMint.cjs
13
+ * itself, since this is IPC-registration plumbing, not Epic-minting logic.
14
+ */
15
+
16
+ const { ensureEpic, readActiveIndex, MINT_AUTHORITY_NEW_EPIC_UI } = require('./epicMint.cjs');
17
+ const { validatePath } = require('../config.cjs');
18
+
19
+ /**
20
+ * createEpicViaIpc(cwd, { goalText, tag?, agentType?, source? }) → Promise<{ epicId, session }>
21
+ *
22
+ * THE one Epic-creation path (epicMint.cjs's SINGLE-CREATOR LAW): this handler
23
+ * holds `MINT_AUTHORITY_NEW_EPIC_UI`, the token that unlocks ensureEpic's mint
24
+ * branch, because it is what the New Epic card calls when a human presses it.
25
+ * Nothing else may hold it.
26
+ *
27
+ * Always mints a brand-new 'proposed' Epic — never joins an existing one — and
28
+ * returns the just-written record read back from the active-index.json on disk, so the
29
+ * response is guaranteed byte-identical to what a subsequent hydrate would
30
+ * see — never a second, independently re-derived copy of the session shape.
31
+ *
32
+ * `ensureEpic`'s writes go through epicMint.cjs's own raw `fs` calls, not
33
+ * config.cjs's writeJson/validateWrite — fine for its other callers
34
+ * (the scheduler's join-only dispatch), which derives `cwd` from a
35
+ * project the app already knows about, but this function is reachable from
36
+ * the renderer over IPC with an arbitrary `cwd` string. Explicitly running
37
+ * that `cwd` through config.cjs's own `validatePath` (allowedRoots = home dir
38
+ * + every cwd a TAB has opened) closes the gap so this handler can't be used
39
+ * to make main write session-manager-operations/ into an arbitrary
40
+ * filesystem location — the same boundary every other renderer-facing path
41
+ * IPC (config:read-json/write-json, lib/activeIndexMerge.cjs's own merge) is
42
+ * already required to pass through.
43
+ */
44
+ async function createEpicViaIpc(cwd, { goalText, tag, agentType, source } = {}) {
45
+ validatePath(cwd);
46
+ const { epicId } = await ensureEpic(cwd, {
47
+ goalText,
48
+ tag,
49
+ agentType,
50
+ source,
51
+ mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI,
52
+ status: 'proposed',
53
+ });
54
+ const index = readActiveIndex(cwd);
55
+ return { epicId, session: index.sessions[epicId] };
56
+ }
57
+
58
+ function registerPromptSessionsCreateEpicHandlers() {
59
+ const { ipcMain } = require('electron');
60
+ const { schemas: s, validated: v } = require('../ipcSchemas.cjs');
61
+ ipcMain.handle(
62
+ 'promptSessions:create-epic',
63
+ v(s.promptSessionsCreateEpic, ({ cwd, goalText, tag, agentType, source }) =>
64
+ createEpicViaIpc(cwd, { goalText, tag, agentType, source })),
65
+ );
66
+ }
67
+
68
+ module.exports = { createEpicViaIpc, registerPromptSessionsCreateEpicHandlers };