claude-code-session-manager 0.40.2 → 0.41.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 (51) hide show
  1. package/dist/assets/{TiptapBody-CFCp4Mz9.js → TiptapBody-BnRle0iw.js} +1 -1
  2. package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
  3. package/dist/assets/{index-bdqOSyxG.js → index-H7rwsoKV.js} +650 -643
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -5
  7. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  8. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +2 -2
  9. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/project-status/SKILL.md +20 -34
  11. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +68 -0
  12. package/scripts/lib/watchdogHelpers.cjs +5 -5
  13. package/scripts/mint-epic.cjs +28 -0
  14. package/scripts/propose-epic.cjs +55 -0
  15. package/src/main/__tests__/epicMint.test.cjs +85 -0
  16. package/src/main/__tests__/prdCreate.test.cjs +54 -2
  17. package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
  18. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  19. package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
  20. package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
  21. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
  22. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
  23. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
  24. package/src/main/browserView.cjs +1 -1
  25. package/src/main/chatRunner.cjs +68 -55
  26. package/src/main/config.cjs +12 -6
  27. package/src/main/docEdit.cjs +3 -4
  28. package/src/main/health.cjs +5 -0
  29. package/src/main/index.cjs +12 -0
  30. package/src/main/ipcSchemas.cjs +51 -11
  31. package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
  32. package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
  33. package/src/main/lib/epicMint.cjs +133 -59
  34. package/src/main/lib/opsOwnership.cjs +166 -0
  35. package/src/main/lib/prdCreate.cjs +9 -1
  36. package/src/main/lib/prdLocations.cjs +21 -0
  37. package/src/main/lib/projectBriefCore.cjs +70 -4
  38. package/src/main/lib/queueStore.cjs +3 -0
  39. package/src/main/lib/rcaFeedbackHook.cjs +41 -55
  40. package/src/main/projectBrief.cjs +36 -2
  41. package/src/main/promptSessionEvents.cjs +24 -2
  42. package/src/main/promptSessionTranscript.cjs +0 -0
  43. package/src/main/queueOps.cjs +1 -1
  44. package/src/main/scheduler/prdParser.cjs +8 -0
  45. package/src/main/scheduler.cjs +281 -164
  46. package/src/main/templates/PRD_AUTHORING.md +1 -1
  47. package/src/preload/api.d.ts +76 -3
  48. package/src/preload/index.cjs +21 -3
  49. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
  50. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
  51. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +0 -265
@@ -78,44 +78,37 @@ function baseJob(overrides = {}) {
78
78
 
79
79
  // ─── dedupe / new-runId ───────────────────────────────────────────────────────
80
80
 
81
- test('fileRcaFeedback: second call with same (slug, runId) is a no-op', async () => {
82
- const cwd = makeProjectWithInbox();
83
- const runDir = path.join(tmpHome, 'run1');
84
- writeRun(runDir, 'testslug');
85
- writePrd(cwd, 'testslug');
86
- const job = baseJob({ cwd });
81
+ // ─── proposal filing (replaced the feedback-file inbox) ──────────────────────
87
82
 
88
- const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
89
- expect(first.filed).toBe(true);
90
-
91
- const second = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
92
- expect(second.filed).toBe(false);
93
- expect(second.reason).toBe('duplicate');
94
-
95
- const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
96
- expect(files.length).toBe(1);
97
- });
83
+ function readSessions(cwd) {
84
+ const p = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
85
+ if (!fs.existsSync(p)) return {};
86
+ return JSON.parse(fs.readFileSync(p, 'utf8')).sessions ?? {};
87
+ }
98
88
 
99
- test('fileRcaFeedback: new runId for the same slug files a new RCA', async () => {
89
+ test('fileRcaFeedback: files a PROPOSED Epic carrying the RCA as its opening prompt', async () => {
100
90
  const cwd = makeProjectWithInbox();
101
- const runDir1 = path.join(tmpHome, 'run1');
102
- const runDir2 = path.join(tmpHome, 'run2');
103
- writeRun(runDir1, 'testslug');
104
- writeRun(runDir2, 'testslug');
91
+ const runDir = path.join(tmpHome, 'run1');
92
+ writeRun(runDir, 'testslug');
105
93
  writePrd(cwd, 'testslug');
106
94
 
107
- const r1 = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, runId: 'run-aaa' }), runDir: runDir1, verdict: 'transcript_errors' });
108
- const r2 = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, runId: 'run-bbb' }), runDir: runDir2, verdict: 'transcript_errors' });
109
-
110
- expect(r1.filed).toBe(true);
111
- expect(r2.filed).toBe(true);
112
- expect(r1.path).not.toBe(r2.path);
95
+ const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
96
+ expect(res.filed).toBe(true);
97
+ expect(res.created).toBe(true);
113
98
 
114
- const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
115
- expect(files.length).toBe(2);
99
+ const sessions = readSessions(cwd);
100
+ const epic = sessions[res.epicId];
101
+ // Nothing may auto-run: the whole point is a human gate.
102
+ expect(epic.status).toBe('proposed');
103
+ expect(epic.tag).toBe('bug');
104
+ expect(epic.goalText).toContain('testslug');
105
+ // Short title for the list, full RCA body for the first prompt.
106
+ expect(epic.goalText.split('\n').length).toBe(1);
107
+ expect(epic.openingPrompt).toContain('Root cause');
108
+ expect(epic.openingPrompt).toContain('prdSlug: testslug');
116
109
  });
117
110
 
118
- test('fileRcaFeedback: a duplicate is still detected after /process-feedback archives the file into processed/', async () => {
111
+ test('fileRcaFeedback: a re-trigger for the same failure joins the proposal instead of duplicating it', async () => {
119
112
  const cwd = makeProjectWithInbox();
120
113
  const runDir = path.join(tmpHome, 'run1');
121
114
  writeRun(runDir, 'testslug');
@@ -123,193 +116,35 @@ test('fileRcaFeedback: a duplicate is still detected after /process-feedback arc
123
116
  const job = baseJob({ cwd });
124
117
 
125
118
  const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
126
- expect(first.filed).toBe(true);
127
-
128
- const feedbackDir = path.join(cwd, 'session-manager-operations', 'feedback');
129
- const processedDir = path.join(feedbackDir, 'processed');
130
- fs.mkdirSync(processedDir, { recursive: true });
131
- const fileName = path.basename(first.path);
132
- fs.renameSync(first.path, path.join(processedDir, fileName));
133
-
134
119
  const second = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
135
- expect(second.filed).toBe(false);
136
- expect(second.reason).toBe('duplicate');
137
- expect(second.path).toBe(path.join(processedDir, fileName));
138
-
139
- expect(fs.existsSync(path.join(feedbackDir, fileName))).toBe(false);
140
- const liveFiles = fs.readdirSync(feedbackDir).filter((f) => f !== 'processed');
141
- expect(liveFiles.length).toBe(0);
142
- expect(fs.readdirSync(processedDir).length).toBe(1);
120
+
121
+ expect(first.created).toBe(true);
122
+ expect(second.created).toBe(false);
123
+ expect(second.epicId).toBe(first.epicId);
124
+ expect(Object.keys(readSessions(cwd)).length).toBe(1);
143
125
  });
144
126
 
145
- test('fileRcaFeedback: investigationText updates the file in processed/ in place instead of duplicating it in the live dir', async () => {
127
+ test('fileRcaFeedback: a different verdict for the same slug is its own proposal', async () => {
146
128
  const cwd = makeProjectWithInbox();
147
129
  const runDir = path.join(tmpHome, 'run1');
148
130
  writeRun(runDir, 'testslug');
149
131
  writePrd(cwd, 'testslug');
150
- const job = baseJob({ cwd });
151
-
152
- const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
153
- expect(first.filed).toBe(true);
154
-
155
- const feedbackDir = path.join(cwd, 'session-manager-operations', 'feedback');
156
- const processedDir = path.join(feedbackDir, 'processed');
157
- fs.mkdirSync(processedDir, { recursive: true });
158
- const fileName = path.basename(first.path);
159
- const processedPath = path.join(processedDir, fileName);
160
- fs.renameSync(first.path, processedPath);
161
-
162
- const second = await rcaFeedbackHook.fileRcaFeedback({
163
- job, runDir, verdict: 'transcript_errors', investigationText: 'Root cause: forgot to bound the poll loop.',
164
- });
165
- expect(second.filed).toBe(true);
166
- expect(second.updated).toBe(true);
167
- expect(second.path).toBe(processedPath);
168
132
 
169
- const after = fs.readFileSync(processedPath, 'utf8');
170
- expect(after).toContain('## Investigation analysis');
171
- expect(fs.existsSync(path.join(feedbackDir, fileName))).toBe(false);
172
- });
173
-
174
- // ─── failure-class matching ───────────────────────────────────────────────────
175
-
176
- test('classifyFailure: detects stuck-loop from an until/while-true/sleep tail', () => {
177
- const logTail = ['doing work', 'until curl -s http://x | jq .uptime; do sleep 5; done', 'still waiting'].join('\n');
178
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('stuck-loop');
179
- });
180
-
181
- test('classifyFailure: detects post-ac-overrun when work continues well past the last AC checkbox with no PASS', () => {
182
- const lines = ['- [x] first AC item done', '- [x] last AC item done'];
183
- for (let i = 0; i < 30; i++) lines.push(`extra post-AC work line ${i}`);
184
- const logTail = lines.join('\n');
185
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('post-ac-overrun');
186
- });
187
-
188
- test('classifyFailure: falls back to uncommitted-changes for that verdict', () => {
189
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'uncommitted_changes', logTail: 'no special markers here' })).toBe('uncommitted-changes');
190
- });
191
-
192
- test('classifyFailure: falls back to no-sentinel for no_verdict_sentinel/pass_no_commit', () => {
193
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail: 'clean tail, no loops' })).toBe('no-sentinel');
194
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail: 'clean tail, no loops' })).toBe('no-sentinel');
195
- });
133
+ const a = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
134
+ const b = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'pass_no_commit' });
196
135
 
197
- test('classifyFailure: falls back to transcript-errors for transcript_errors/verify_unavailable', () => {
198
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'transcript_errors', logTail: 'Traceback (most recent call last)' })).toBe('transcript-errors');
199
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'verify_unavailable', logTail: 'verifier threw' })).toBe('transcript-errors');
136
+ expect(a.epicId).not.toBe(b.epicId);
137
+ expect(Object.keys(readSessions(cwd)).length).toBe(2);
200
138
  });
201
139
 
202
- test('classifyFailure: unknown verdict with no matching markers falls back to unknown', () => {
203
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'halt', logTail: 'nothing special' })).toBe('unknown');
204
- });
205
-
206
- test('classifyFailure: detects self-queue from a "Launching skill: session-manager-dev:develop" tail', () => {
207
- const logTail = ['doing setup', 'Launching skill: session-manager-dev:develop', 'skill running'].join('\n');
208
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('self-queue');
209
- });
210
-
211
- test('classifyFailure: detects self-queue from a "Launching skill: session-manager-dev:process-feedback" tail', () => {
212
- const logTail = ['doing setup', 'Launching skill: session-manager-dev:process-feedback', 'skill running'].join('\n');
213
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('self-queue');
214
- });
215
-
216
- test('classifyFailure: detects self-queue from a ScheduleWakeup tool_use even when the tail also contains "sleep" (PRD 771 regression)', () => {
217
- const logTail = [
218
- 'launching background build: eas-cli build --wait',
219
- 'the app will sleep between polls',
220
- '{"type":"tool_use","name":"ScheduleWakeup","input":{"delaySeconds":600}}',
221
- ].join('\n');
222
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('self-queue');
223
- });
224
-
225
- test('classifyFailure: detects already-shipped from a pass_no_commit tail citing a prior commit', () => {
226
- const logTail = ['re-verified acceptance criteria', 'this work was already committed in 9cf0384', 'nothing left to change'].join('\n');
227
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail })).toBe('already-shipped');
228
- });
229
-
230
- test('classifyFailure: detects already-shipped from a pass_no_commit tail with "nothing new to commit"', () => {
231
- const logTail = ['checked the diff', 'nothing new to commit for this PRD', 'exiting clean'].join('\n');
232
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail })).toBe('already-shipped');
233
- });
234
-
235
- test('classifyFailure: ALREADY_SHIPPED is checked before SELF_QUEUE, so a no_verdict_sentinel tail with both markers classifies as already-shipped', () => {
236
- const logTail = [
237
- 'Launching skill: session-manager-dev:develop',
238
- 'the acceptance criteria were already implemented',
239
- ].join('\n');
240
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('already-shipped');
241
- });
242
-
243
- test('classifyFailure: pass_no_commit tail with no already-shipped phrasing still falls back to no-sentinel (regression)', () => {
244
- const logTail = ['ran the tests', 'everything looked fine', 'printed PASS'].join('\n');
245
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail })).toBe('no-sentinel');
246
- });
247
-
248
- test('classifyFailure: STUCK_LOOP/POST_AC_OVERRUN reordering does not change classification when no self-queue marker is present', () => {
249
- const stuckLoopTail = ['doing work', 'until curl -s http://x | jq .uptime; do sleep 5; done', 'still waiting'].join('\n');
250
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail: stuckLoopTail })).toBe('stuck-loop');
251
-
252
- const postAcLines = ['- [x] first AC item done', '- [x] last AC item done'];
253
- for (let i = 0; i < 30; i++) postAcLines.push(`extra post-AC work line ${i}`);
254
- const postAcTail = postAcLines.join('\n');
255
- expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail: postAcTail })).toBe('post-ac-overrun');
256
- });
257
-
258
- // ─── AC extraction ─────────────────────────────────────────────────────────────
259
-
260
- test('extractAcceptanceCriteria: pulls the section body between the heading and the next heading', () => {
261
- const body = ['# Goal', '', 'Do the thing.', '', '# Acceptance criteria', '', '- [ ] one', '- [ ] two', '', '# Out of scope', '', '- N/A'].join('\n');
262
- expect(rcaFeedbackHook.extractAcceptanceCriteria(body)).toBe('- [ ] one\n- [ ] two');
263
- });
264
-
265
- test('extractAcceptanceCriteria: returns null when no such heading exists', () => {
266
- const body = ['# Goal', '', 'Do the thing.'].join('\n');
267
- expect(rcaFeedbackHook.extractAcceptanceCriteria(body)).toBeNull();
268
- });
269
-
270
- // ─── investigation <RCA> block extraction ──────────────────────────────────────
271
-
272
- test('extractRcaBlock: pulls plain-text block content', () => {
273
- const log = 'preamble\n<RCA>\nForgot to bound the poll loop.\n</RCA>\npostamble';
274
- expect(rcaFeedbackHook.extractRcaBlock(log)).toBe('Forgot to bound the poll loop.');
275
- });
276
-
277
- test('extractRcaBlock: unescapes JSON-escaped newlines from a stream-json transcript', () => {
278
- const log = '{"text":"<RCA>\\nForgot to bound the poll loop.\\nSee scheduler.cjs:1509.\\n</RCA>"}';
279
- expect(rcaFeedbackHook.extractRcaBlock(log)).toBe('Forgot to bound the poll loop.\nSee scheduler.cjs:1509.');
280
- });
281
-
282
- test('extractRcaBlock: returns null when no block is present', () => {
283
- expect(rcaFeedbackHook.extractRcaBlock('no block here')).toBeNull();
284
- });
285
-
286
- // ─── destination resolution ────────────────────────────────────────────────────
287
-
288
- test('resolveDestination: uses the job cwd inbox when it exists', () => {
289
- const cwd = makeProjectWithInbox();
290
- const dest = rcaFeedbackHook.resolveDestination({ cwd });
291
- expect(dest.dir).toBe(path.join(cwd, 'session-manager-operations', 'feedback'));
292
- expect(dest.targetProjectNote).toBeNull();
293
- });
294
-
295
- test('resolveDestination: falls back to the session-manager repo inbox when the target has none', () => {
296
- const cwd = makeProjectWithoutInbox();
297
- const dest = rcaFeedbackHook.resolveDestination({ cwd });
298
- expect(dest.dir).toBe(rcaFeedbackHook.SM_REPO_FEEDBACK_DIR);
299
- expect(dest.targetProjectNote).toContain(cwd);
300
- });
301
-
302
- test('fileRcaFeedback: actually writes into the fallback dir when the target inbox is missing', async () => {
140
+ test('fileRcaFeedback: files into the job\'s own project, no feedback dir required', async () => {
303
141
  const cwd = makeProjectWithoutInbox();
304
142
  const runDir = path.join(tmpHome, 'run1');
305
143
  writeRun(runDir, 'testslug');
306
- writePrd(cwd, 'testslug');
307
144
 
308
145
  const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
309
146
  expect(res.filed).toBe(true);
310
- expect(res.path.startsWith(rcaFeedbackHook.SM_REPO_FEEDBACK_DIR)).toBe(true);
311
- const content = fs.readFileSync(res.path, 'utf8');
312
- expect(content).toContain(cwd);
147
+ expect(readSessions(cwd)[res.epicId].status).toBe('proposed');
313
148
  });
314
149
 
315
150
  test('fileRcaFeedback: rejects a slug containing path-traversal segments', async () => {
@@ -339,7 +174,7 @@ test('fileRcaFeedback: SM_RCA_DISABLE=1 skips without writing', async () => {
339
174
 
340
175
  // ─── investigation-text enrichment ──────────────────────────────────────────────
341
176
 
342
- test('fileRcaFeedback: investigationText updates the existing RCA instead of duplicating it', async () => {
177
+ test('fileRcaFeedback: investigationText enriches the pending proposal in place', async () => {
343
178
  const cwd = makeProjectWithInbox();
344
179
  const runDir = path.join(tmpHome, 'run1');
345
180
  writeRun(runDir, 'testslug');
@@ -348,20 +183,17 @@ test('fileRcaFeedback: investigationText updates the existing RCA instead of dup
348
183
 
349
184
  const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
350
185
  expect(first.filed).toBe(true);
351
- const before = fs.readFileSync(first.path, 'utf8');
352
- expect(before).not.toContain('## Investigation analysis');
186
+ expect(readSessions(cwd)[first.epicId].openingPrompt).not.toContain('## Investigation analysis');
353
187
 
354
188
  const second = await rcaFeedbackHook.fileRcaFeedback({
355
189
  job, runDir, verdict: 'transcript_errors', investigationText: 'Root cause: forgot to bound the poll loop.',
356
190
  });
357
- expect(second.filed).toBe(true);
191
+ expect(second.epicId).toBe(first.epicId);
358
192
  expect(second.updated).toBe(true);
359
- expect(second.path).toBe(first.path);
360
-
361
- const after = fs.readFileSync(second.path, 'utf8');
362
- expect(after).toContain('## Investigation analysis');
363
- expect(after).toContain('forgot to bound the poll loop');
364
193
 
365
- const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
366
- expect(files.length).toBe(1);
194
+ const enriched = readSessions(cwd)[second.epicId];
195
+ expect(enriched.openingPrompt).toContain('## Investigation analysis');
196
+ expect(enriched.openingPrompt).toContain('forgot to bound the poll loop');
197
+ expect(enriched.status).toBe('proposed');
198
+ expect(Object.keys(readSessions(cwd)).length).toBe(1);
367
199
  });
@@ -18,6 +18,7 @@ const fs = require('node:fs');
18
18
  const os = require('node:os');
19
19
  const path = require('node:path');
20
20
  const { archivedTwinExists, archivedPrdPathForJob, prdDirForCwd } = require('../scheduler.cjs');
21
+ const { listArchivedPrdDirs } = require('../lib/prdLocations.cjs');
21
22
 
22
23
  const tmpDirs = [];
23
24
 
@@ -90,3 +91,65 @@ test('archivedPrdPathForJob does not escape prds-archived/ for a well-formed slu
90
91
  const archiveDir = path.resolve(path.join(prdDirForCwd(cwd), '..', 'prds-archived'));
91
92
  expect(resolved.startsWith(archiveDir + path.sep)).toBe(true);
92
93
  });
94
+
95
+ test('archivedTwinExists returns true when the twin lives in an EPIC\'s prds-archived dir', async () => {
96
+ // Regression cover for job 865: every new PRD is Epic-scoped, and
97
+ // archiveCompletedPrd archives into the Epic's own sibling prds-archived/,
98
+ // not the retired flat one. A queue row whose PRD moved there must still
99
+ // be recognized as stale.
100
+ const cwd = makeFixtureCwd();
101
+ const slug = '865-paste-image-in-chat-does-not-work';
102
+ const epicArchiveDir = path.join(
103
+ cwd, 'session-manager-operations', 'scheduler', 'epics', 'psess-fixture-1', 'prds-archived',
104
+ );
105
+ fs.mkdirSync(epicArchiveDir, { recursive: true });
106
+ fs.writeFileSync(path.join(epicArchiveDir, `${slug}.md`), '# Goal\n\nshipped\n', 'utf8');
107
+
108
+ await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(true);
109
+ });
110
+
111
+ test('archivedTwinExists still returns true for a flat-layout twin', async () => {
112
+ const cwd = makeFixtureCwd();
113
+ const slug = 'test-flat-layout-twin';
114
+ const archivedDir = path.join(prdDirForCwd(cwd), '..', 'prds-archived');
115
+ fs.mkdirSync(archivedDir, { recursive: true });
116
+ fs.writeFileSync(path.join(archivedDir, `${slug}.md`), '# Goal\n\nshipped\n', 'utf8');
117
+
118
+ await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(true);
119
+ });
120
+
121
+ test('archivedTwinExists returns false when no twin exists in any layout', async () => {
122
+ const cwd = makeFixtureCwd();
123
+ const slug = 'test-no-twin-anywhere';
124
+ fs.mkdirSync(prdDirForCwd(cwd), { recursive: true });
125
+
126
+ await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(false);
127
+ });
128
+
129
+ test('listArchivedPrdDirs returns the flat dir plus every existing epic archive dir', () => {
130
+ const cwd = makeFixtureCwd();
131
+ const epicArchiveDir1 = path.join(
132
+ cwd, 'session-manager-operations', 'scheduler', 'epics', 'psess-a', 'prds-archived',
133
+ );
134
+ const epicArchiveDir2 = path.join(
135
+ cwd, 'session-manager-operations', 'scheduler', 'epics', 'psess-b', 'prds-archived',
136
+ );
137
+ // psess-c has no prds-archived dir yet — should be excluded.
138
+ fs.mkdirSync(path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'psess-c', 'prds'), { recursive: true });
139
+ fs.mkdirSync(epicArchiveDir1, { recursive: true });
140
+ fs.mkdirSync(epicArchiveDir2, { recursive: true });
141
+
142
+ const dirs = listArchivedPrdDirs(cwd);
143
+ const flatDir = path.join(prdDirForCwd(cwd), '..', 'prds-archived');
144
+ expect(dirs).toContain(flatDir);
145
+ expect(dirs).toContain(epicArchiveDir1);
146
+ expect(dirs).toContain(epicArchiveDir2);
147
+ expect(dirs).not.toContain(path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'psess-c', 'prds-archived'));
148
+ });
149
+
150
+ test('listArchivedPrdDirs never throws for a cwd with no session-manager-operations/ at all', () => {
151
+ const cwd = makeFixtureCwd();
152
+ expect(() => listArchivedPrdDirs(cwd)).not.toThrow();
153
+ const dirs = listArchivedPrdDirs(cwd);
154
+ expect(dirs.length).toBe(1);
155
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * scheduler-notify-originating-tab-transcript.test.cjs — unit tests for the
3
+ * PRD 863 addition to notifyOriginatingTab: persisting the job's real
4
+ * result text (read from the run's log) to the durable per-Epic transcript
5
+ * store, independent of the existing short-status-chip notification.
6
+ *
7
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, vi } from 'vitest';
13
+ const { notifyOriginatingTab, extractResultTextFromLog } = require('../scheduler.cjs');
14
+
15
+ test('extractResultTextFromLog returns null for a missing log path', () => {
16
+ expect(extractResultTextFromLog(null)).toBeNull();
17
+ expect(extractResultTextFromLog('/does/not/exist.log')).toBeNull();
18
+ });
19
+
20
+ test('appends the job result text to the transcript store when sourcePromptId is known', async () => {
21
+ const appendTranscriptTurn = vi.fn(async () => true);
22
+ const readResultFromLog = vi.fn(() => 'the real agent result text');
23
+ const appendResponseEvent = vi.fn(async () => true);
24
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-abc' }));
25
+
26
+ await notifyOriginatingTab(
27
+ { slug: '863-transcript', status: 'completed', cwd: '/some/cwd', runId: 'run-1' },
28
+ { parsePrdRaw, appendResponseEvent, appendTranscriptTurn, readResultFromLog },
29
+ );
30
+
31
+ expect(appendTranscriptTurn).toHaveBeenCalledTimes(1);
32
+ expect(appendTranscriptTurn).toHaveBeenCalledWith('/some/cwd', 'psess-abc', {
33
+ role: 'assistant',
34
+ text: 'the real agent result text',
35
+ });
36
+ });
37
+
38
+ test('falls back to the short status message when the run log has no result text', async () => {
39
+ const appendTranscriptTurn = vi.fn(async () => true);
40
+ const readResultFromLog = vi.fn(() => null);
41
+ const appendResponseEvent = vi.fn(async () => true);
42
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-abc' }));
43
+
44
+ await notifyOriginatingTab(
45
+ { slug: '863-fallback', status: 'completed', cwd: '/some/cwd', runId: 'run-1' },
46
+ { parsePrdRaw, appendResponseEvent, appendTranscriptTurn, readResultFromLog },
47
+ );
48
+
49
+ expect(appendTranscriptTurn).toHaveBeenCalledWith(
50
+ '/some/cwd',
51
+ 'psess-abc',
52
+ expect.objectContaining({ text: expect.stringContaining('863-fallback') }),
53
+ );
54
+ });
55
+
56
+ test('a throwing transcript append never breaks the existing notification path', async () => {
57
+ const appendTranscriptTurn = vi.fn(async () => {
58
+ throw new Error('disk full');
59
+ });
60
+ const readResultFromLog = vi.fn(() => 'result text');
61
+ const appendResponseEvent = vi.fn(async () => true);
62
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-abc' }));
63
+
64
+ await expect(
65
+ notifyOriginatingTab(
66
+ { slug: '863-safe', status: 'completed', cwd: '/some/cwd', runId: 'run-1' },
67
+ { parsePrdRaw, appendResponseEvent, appendTranscriptTurn, readResultFromLog },
68
+ ),
69
+ ).resolves.toBeUndefined();
70
+
71
+ expect(appendResponseEvent).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ test('no cwd on the job skips the transcript append but does not throw', async () => {
75
+ const appendTranscriptTurn = vi.fn(async () => true);
76
+ const readResultFromLog = vi.fn(() => 'result text');
77
+ const appendResponseEvent = vi.fn(async () => true);
78
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'psess-abc' }));
79
+
80
+ await notifyOriginatingTab(
81
+ { slug: '863-nocwd', status: 'completed', cwd: null, runId: 'run-1' },
82
+ { parsePrdRaw, appendResponseEvent, appendTranscriptTurn, readResultFromLog },
83
+ );
84
+
85
+ expect(appendTranscriptTurn).not.toHaveBeenCalled();
86
+ });
@@ -150,6 +150,27 @@ test('falls back to the tab-external-ticket path when the PRD has no sourcePromp
150
150
  expect(sendPrompt).toHaveBeenCalledWith('tab-x', expect.stringContaining('814-notify'));
151
151
  });
152
152
 
153
+ // PRD 854: a PRD dispatched straight from an Epic's own composer
154
+ // (dispatchPromptSessionToPrd) never sets sourceTabId — only sourcePromptId.
155
+ // If the Epic has since gone completed (appendResponseEvent returns false),
156
+ // the fallback must still target sourcePromptId itself as the chat:external-
157
+ // send id, since the renderer now resolves that id against known Epics too.
158
+ test('falls back to sourcePromptId as the external-send target when sourceTabId is absent', async () => {
159
+ const sendPrompt = vi.fn();
160
+ const appendResponseEvent = vi.fn(async () => false);
161
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: 'epic-1', sourceTabId: null }));
162
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
163
+
164
+ await notifyOriginatingTab(
165
+ { slug: '854-notify', status: 'completed', cwd: '/some/cwd' },
166
+ { parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent },
167
+ );
168
+
169
+ expect(appendResponseEvent).toHaveBeenCalled();
170
+ expect(loadSessions).not.toHaveBeenCalled();
171
+ expect(sendPrompt).toHaveBeenCalledWith('epic-1', expect.stringContaining('854-notify'));
172
+ });
173
+
153
174
  test('falls back to the tab-external-ticket path when appendResponseEvent itself throws', async () => {
154
175
  const sendPrompt = vi.fn();
155
176
  const appendResponseEvent = vi.fn(async () => { throw new Error('disk error'); });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * scheduler-writeprd-epic-rollback.test.cjs — PRD 851: when remote.writePrd
3
+ * mints a brand-new Epic via ensureEpic (epicMint.cjs) but the PRD file write
4
+ * itself then fails, cleanupEmptyMintedEpic must roll back BOTH the on-disk
5
+ * empty epic dir AND the Epic's active-index.json entry (sessions/events) —
6
+ * otherwise an orphaned seed-prompt-only Epic lingers forever in the Epics
7
+ * nav with no PRDs and no cleanup path.
8
+ *
9
+ * The rollback must only fire for a call that minted a FRESH Epic
10
+ * (ensureEpic's `created: true`); a PRD write that joins an existing Epic
11
+ * and then fails must leave that Epic's history untouched.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, afterEach } from 'vitest';
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+ const { remote } = require('../scheduler.cjs');
23
+ const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
24
+
25
+ const tmpDirs = [];
26
+
27
+ function makeFixtureCwd() {
28
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-writeprd-epic-rollback-'));
29
+ tmpDirs.push(cwd);
30
+ return cwd;
31
+ }
32
+
33
+ afterEach(() => {
34
+ while (tmpDirs.length) {
35
+ const dir = tmpDirs.pop();
36
+ fs.rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ });
39
+
40
+ // A slug that escapes safeSlugPathIn's containment check, tripping the
41
+ // "invalid slug" branch of writePrd (scheduler.cjs ~4188-4191) after
42
+ // ensureEpic has already minted+persisted the Epic.
43
+ const ESCAPING_SLUG = '../escape-attempt';
44
+
45
+ test('writePrd: invalid-slug failure after a fresh Epic mint leaves zero residual active-index.json entries', async () => {
46
+ const cwd = makeFixtureCwd();
47
+ const body = '---\ntitle: Freshly minted epic goal\n---\n\nbody text\n';
48
+
49
+ const result = await remote.writePrd(ESCAPING_SLUG, body, cwd);
50
+
51
+ expect(result.ok).toBe(false);
52
+ expect(result.error).toBe('invalid slug');
53
+
54
+ const index = readActiveIndex(cwd);
55
+ expect(Object.keys(index.sessions)).toHaveLength(0);
56
+ expect(Object.keys(index.events)).toHaveLength(0);
57
+ });
58
+
59
+ test('writePrd: invalid-slug failure when joining a pre-existing Epic leaves that Epic untouched', async () => {
60
+ const cwd = makeFixtureCwd();
61
+
62
+ const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal' });
63
+ expect(preexisting.created).toBe(true);
64
+
65
+ const beforeIndex = readActiveIndex(cwd);
66
+ expect(Object.keys(beforeIndex.sessions)).toEqual([preexisting.epicId]);
67
+
68
+ const body = `---\ntitle: Joins the existing epic\nsourcePromptId: ${preexisting.epicId}\n---\n\nbody text\n`;
69
+ const result = await remote.writePrd(ESCAPING_SLUG, body, cwd);
70
+
71
+ expect(result.ok).toBe(false);
72
+ expect(result.error).toBe('invalid slug');
73
+
74
+ const afterIndex = readActiveIndex(cwd);
75
+ expect(Object.keys(afterIndex.sessions)).toEqual([preexisting.epicId]);
76
+ expect(afterIndex.sessions[preexisting.epicId]).toEqual(beforeIndex.sessions[preexisting.epicId]);
77
+ expect(afterIndex.events[preexisting.epicId]).toEqual(beforeIndex.events[preexisting.epicId]);
78
+ });
@@ -836,7 +836,7 @@ function registerBrowserView({ mainWindow, ipcMain }) {
836
836
  ipcMain.handle('browser:stop-find', validated(schemas.browserViewId, (payload) => stopFind(payload)));
837
837
  ipcMain.handle('browser:save-binary', validated(schemas.browserSaveBinary, (payload) => {
838
838
  const { writeBinaryAtomic } = require('./config.cjs');
839
- return writeBinaryAtomic(payload.path, Buffer.from(payload.base64, 'base64'))
839
+ return writeBinaryAtomic(payload.path, Buffer.from(payload.base64, 'base64'), { writer: payload.writer })
840
840
  .then(() => ({ ok: true }))
841
841
  .catch((e) => ({ ok: false, error: e && e.message ? e.message : String(e) }));
842
842
  }));