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.
- package/dist/assets/{TiptapBody-CFCp4Mz9.js → TiptapBody-BnRle0iw.js} +1 -1
- package/dist/assets/{index-BxVBtmjA.css → index-CKY3mHgV.css} +1 -1
- package/dist/assets/{index-bdqOSyxG.js → index-H7rwsoKV.js} +650 -643
- package/dist/index.html +2 -2
- package/package.json +3 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -5
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +2 -2
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +20 -34
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +68 -0
- package/scripts/lib/watchdogHelpers.cjs +5 -5
- package/scripts/mint-epic.cjs +28 -0
- package/scripts/propose-epic.cjs +55 -0
- package/src/main/__tests__/epicMint.test.cjs +85 -0
- package/src/main/__tests__/prdCreate.test.cjs +54 -2
- package/src/main/__tests__/promptSessionEvents.test.cjs +56 -2
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +43 -211
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +63 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +86 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +21 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +78 -0
- package/src/main/browserView.cjs +1 -1
- package/src/main/chatRunner.cjs +68 -55
- package/src/main/config.cjs +12 -6
- package/src/main/docEdit.cjs +3 -4
- package/src/main/health.cjs +5 -0
- package/src/main/index.cjs +12 -0
- package/src/main/ipcSchemas.cjs +51 -11
- package/src/main/lib/__tests__/opsOwnership.test.cjs +92 -0
- package/src/main/lib/__tests__/projectBriefCore.test.cjs +74 -0
- package/src/main/lib/epicMint.cjs +133 -59
- package/src/main/lib/opsOwnership.cjs +166 -0
- package/src/main/lib/prdCreate.cjs +9 -1
- package/src/main/lib/prdLocations.cjs +21 -0
- package/src/main/lib/projectBriefCore.cjs +70 -4
- package/src/main/lib/queueStore.cjs +3 -0
- package/src/main/lib/rcaFeedbackHook.cjs +41 -55
- package/src/main/projectBrief.cjs +36 -2
- package/src/main/promptSessionEvents.cjs +24 -2
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/queueOps.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +8 -0
- package/src/main/scheduler.cjs +281 -164
- package/src/main/templates/PRD_AUTHORING.md +1 -1
- package/src/preload/api.d.ts +76 -3
- package/src/preload/index.cjs +21 -3
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +0 -140
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +0 -290
- 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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
|
89
|
+
test('fileRcaFeedback: files a PROPOSED Epic carrying the RCA as its opening prompt', async () => {
|
|
100
90
|
const cwd = makeProjectWithInbox();
|
|
101
|
-
const
|
|
102
|
-
|
|
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
|
|
108
|
-
|
|
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
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
136
|
-
expect(
|
|
137
|
-
expect(second.
|
|
138
|
-
|
|
139
|
-
expect(
|
|
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:
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
198
|
-
expect(
|
|
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('
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
366
|
-
expect(
|
|
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
|
+
});
|
package/src/main/browserView.cjs
CHANGED
|
@@ -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
|
}));
|