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.
- package/dist/assets/{TiptapBody-B1llI4h-.js → TiptapBody-BEBLdJl_.js} +1 -1
- package/dist/assets/index-DEQzGYa6.js +3227 -0
- package/dist/assets/{index-WUc_GHMf.css → index-DwUffaDq.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -2
- package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
- package/scripts/lib/watchdogHelpers.cjs +0 -434
- package/scripts/mint-epic.cjs +6 -6
- package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
- package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
- package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
- package/src/main/__tests__/epicMint.test.cjs +165 -207
- package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
- package/src/main/__tests__/queueHistory.test.cjs +14 -0
- package/src/main/__tests__/rcaReport.test.cjs +188 -0
- package/src/main/__tests__/runVerify.test.cjs +109 -6
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
- package/src/main/chatRunner.cjs +10 -1
- package/src/main/index.cjs +2 -4
- package/src/main/ipcSchemas.cjs +47 -2
- package/src/main/lib/agentModelResolve.cjs +97 -0
- package/src/main/lib/auditLog.cjs +2 -2
- package/src/main/lib/epicContextDigest.cjs +78 -0
- package/src/main/lib/epicMint.cjs +81 -164
- package/src/main/lib/promptSessionSchema.cjs +79 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +68 -0
- package/src/main/lib/queueHistory.cjs +14 -2
- package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
- package/src/main/runVerify.cjs +17 -2
- package/src/main/scheduler.cjs +79 -24
- package/src/preload/api.d.ts +25 -48
- package/src/preload/index.cjs +6 -7
- package/dist/assets/index-DdVGnaCd.js +0 -3227
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
- package/scripts/propose-epic.cjs +0 -56
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
- package/src/main/repoAnalyzer.cjs +0 -346
- package/src/main/search.cjs +0 -332
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* epicMint.cjs —
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
//
|
|
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?,
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
-
* `
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
174
|
+
reason: `explicit epicId exists but is not open (status=${preferred?.status ?? 'unknown'}) — refusing to join`,
|
|
248
175
|
});
|
|
249
176
|
}
|
|
250
177
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
317
|
-
// human
|
|
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
|
|
327
|
-
//
|
|
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
|
|
343
|
-
//
|
|
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 };
|
|
@@ -21,10 +21,22 @@ const { projectHistoryPath, stateCwds } = require('./queueStore.cjs');
|
|
|
21
21
|
// resolved via queueStore.cjs). Old rows stay readable from here; new appends
|
|
22
22
|
// go to the owning project's shard (entries without a cwd still land here so
|
|
23
23
|
// nothing is ever dropped).
|
|
24
|
-
|
|
24
|
+
//
|
|
25
|
+
// Overridable via SM_HISTORY_PATH_OVERRIDE so queueHistory.test.cjs can point
|
|
26
|
+
// this at an isolated tmpdir path instead of racing a live Electron
|
|
27
|
+
// instance's scheduler.cjs, which writes this same machine-global file.
|
|
28
|
+
const HISTORY_PATH = process.env.SM_HISTORY_PATH_OVERRIDE
|
|
29
|
+
|| path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'history.jsonl');
|
|
25
30
|
|
|
26
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Every history file currently in play: each project's shard + the legacy
|
|
33
|
+
* global. Under SM_HISTORY_PATH_OVERRIDE (queueHistory.test.cjs), skip the
|
|
34
|
+
* federated per-project shards entirely — they're real files under whatever
|
|
35
|
+
* projects exist on this machine's ~/.claude/projects, so including them
|
|
36
|
+
* would leak real production history rows into an otherwise-isolated test.
|
|
37
|
+
*/
|
|
27
38
|
function historyPaths() {
|
|
39
|
+
if (process.env.SM_HISTORY_PATH_OVERRIDE) return [HISTORY_PATH];
|
|
28
40
|
const paths = [];
|
|
29
41
|
for (const cwd of stateCwds()) {
|
|
30
42
|
try { paths.push(projectHistoryPath(cwd)); } catch { /* unusable cwd */ }
|