claude-code-session-manager 0.57.1 → 0.59.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 (68) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{TiptapBody-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
  3. package/dist/assets/index-BPnfPdLW.js +3222 -0
  4. package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -2
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
  8. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  9. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  11. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
  12. package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
  13. package/scripts/lib/watchdogHelpers.cjs +0 -434
  14. package/scripts/mint-epic.cjs +6 -6
  15. package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
  16. package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
  17. package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
  18. package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
  19. package/src/main/__tests__/epicMint.test.cjs +165 -207
  20. package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
  21. package/src/main/__tests__/prdMigration.test.cjs +160 -0
  22. package/src/main/__tests__/projectPages.test.cjs +151 -0
  23. package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
  24. package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
  25. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
  26. package/src/main/__tests__/rcaReport.test.cjs +188 -0
  27. package/src/main/__tests__/runVerify.test.cjs +109 -6
  28. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
  29. package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
  30. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
  31. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
  32. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
  33. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
  34. package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
  35. package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
  36. package/src/main/chatRunner.cjs +10 -1
  37. package/src/main/health.cjs +5 -1
  38. package/src/main/index.cjs +3 -5
  39. package/src/main/ipcSchemas.cjs +72 -4
  40. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
  41. package/src/main/lib/agentModelResolve.cjs +97 -0
  42. package/src/main/lib/auditLog.cjs +2 -2
  43. package/src/main/lib/classifyTranscriptLine.cjs +131 -48
  44. package/src/main/lib/epicContextDigest.cjs +78 -0
  45. package/src/main/lib/epicMint.cjs +86 -164
  46. package/src/main/lib/epicValidationHook.cjs +192 -0
  47. package/src/main/lib/prdMigration.cjs +76 -5
  48. package/src/main/lib/promptSessionSchema.cjs +95 -0
  49. package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
  50. package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
  51. package/src/main/lib/schedulerBatch.cjs +70 -111
  52. package/src/main/lib/schedulerConfig.cjs +0 -1
  53. package/src/main/otel.cjs +3 -1
  54. package/src/main/projectPages.cjs +60 -14
  55. package/src/main/promptSessionEvents.cjs +16 -1
  56. package/src/main/runVerify.cjs +17 -2
  57. package/src/main/scheduler.cjs +302 -68
  58. package/src/main/templates/project-pages-default-home.html +123 -0
  59. package/src/main/transcripts.cjs +191 -32
  60. package/src/main/webRemote.cjs +8 -7
  61. package/src/preload/api.d.ts +100 -57
  62. package/src/preload/index.cjs +9 -7
  63. package/dist/assets/index-DCa3QvAi.js +0 -3227
  64. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
  65. package/scripts/propose-epic.cjs +0 -56
  66. package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
  67. package/src/main/repoAnalyzer.cjs +0 -346
  68. package/src/main/search.cjs +0 -332
@@ -0,0 +1,214 @@
1
+ /**
2
+ * scheduler-epic-digest.test.cjs — PRD 958: prepend the Epic context digest
3
+ * (PRD 950's epicContextDigest.cjs) to a job's prompt when job.epicId
4
+ * resolves to a known Epic (same resolution resolveOriginSessionId already
5
+ * performs). Additive only: the PRD's own body + FINISH_PROTOCOL must remain
6
+ * byte-for-byte unchanged, and a digest build failure must never block
7
+ * dispatch.
8
+ *
9
+ * Drives the REAL spawn path via SM_CLAUDE_BIN pointing at a stub process
10
+ * that captures the '-p' prompt argv to a file, so the assertions inspect
11
+ * exactly what would have been sent to `claude -p`.
12
+ *
13
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-epic-digest.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ let tmpHome;
24
+ let originalHome;
25
+ let originalClaudeBin;
26
+ let executeJob;
27
+ let FINISH_PROTOCOL;
28
+ let buildContextDigest;
29
+
30
+ beforeAll(() => {
31
+ originalHome = process.env.HOME;
32
+ originalClaudeBin = process.env.SM_CLAUDE_BIN;
33
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-epic-digest-'));
34
+ process.env.HOME = tmpHome;
35
+
36
+ ({ executeJob, FINISH_PROTOCOL } = require('../scheduler.cjs'));
37
+ ({ buildContextDigest } = require('../lib/epicContextDigest.cjs'));
38
+ });
39
+
40
+ afterAll(() => {
41
+ process.env.HOME = originalHome;
42
+ if (originalClaudeBin === undefined) delete process.env.SM_CLAUDE_BIN;
43
+ else process.env.SM_CLAUDE_BIN = originalClaudeBin;
44
+ fs.rmSync(tmpHome, { recursive: true, force: true });
45
+ });
46
+
47
+ afterEach(() => {
48
+ delete process.env.SM_CLAUDE_BIN;
49
+ });
50
+
51
+ // Stub `claude` binary: captures the '-p' prompt argv to `capturePath`, then
52
+ // emits one stream-json result line and exits 0.
53
+ function writeClaudeStub(capturePath) {
54
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
55
+ const body = `
56
+ const fs = require('fs');
57
+ const idx = process.argv.indexOf('-p');
58
+ const prompt = idx >= 0 ? process.argv[idx + 1] : '';
59
+ fs.writeFileSync(${JSON.stringify(capturePath)}, prompt, 'utf8');
60
+ process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
61
+ process.exit(0);
62
+ `;
63
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
64
+ return stubPath;
65
+ }
66
+
67
+ function setupProject(prefix) {
68
+ const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-project-`));
69
+ fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
70
+ const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', `${prefix}-run-`));
71
+ return { projectCwd, runDir };
72
+ }
73
+
74
+ function writeLegacyPrd(projectCwd, slug, bodyText) {
75
+ const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
76
+ fs.mkdirSync(prdsDir, { recursive: true });
77
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), bodyText, 'utf8');
78
+ return path.join(prdsDir, `${slug}.md`);
79
+ }
80
+
81
+ function writeActiveIndex(projectCwd, sessions) {
82
+ const idxPath = path.join(projectCwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
83
+ fs.mkdirSync(path.dirname(idxPath), { recursive: true });
84
+ fs.writeFileSync(idxPath, JSON.stringify({ sessions, events: {} }, null, 2), 'utf8');
85
+ }
86
+
87
+ test('prepends the Epic context digest when job.epicId resolves to a known Epic', async () => {
88
+ const { projectCwd, runDir } = setupProject('sm-epic-digest-hit');
89
+ try {
90
+ const epicId = 'epic-hit-1';
91
+ writeActiveIndex(projectCwd, {
92
+ [epicId]: { claudeSessionId: 'sess-abc-123', goalText: 'Ship the digest feature.' },
93
+ });
94
+
95
+ const slug = `958-test-hit-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
96
+ const bodyText = 'Do the thing described in this PRD body.';
97
+ writeLegacyPrd(projectCwd, slug, bodyText);
98
+
99
+ const capturePath = path.join(runDir, 'captured-prompt.txt');
100
+ process.env.SM_CLAUDE_BIN = writeClaudeStub(capturePath);
101
+
102
+ const job = { slug, cwd: projectCwd, epicId };
103
+ const result = await executeJob(job, runDir, projectCwd, () => Promise.resolve());
104
+
105
+ expect(result.exitCode).toBe(0);
106
+ const capturedPrompt = fs.readFileSync(capturePath, 'utf8');
107
+
108
+ const digestText = await buildContextDigest({ cwd: projectCwd, epicId });
109
+ expect(digestText.length).toBeGreaterThan(0);
110
+ expect(capturedPrompt.startsWith(digestText)).toBe(true);
111
+ expect(capturedPrompt.endsWith(bodyText + FINISH_PROTOCOL)).toBe(true);
112
+
113
+ // The PRD source on disk must never be rewritten with the digest merged in.
114
+ const onDiskPrd = fs.readFileSync(path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds', `${slug}.md`), 'utf8');
115
+ expect(onDiskPrd).toBe(bodyText);
116
+
117
+ const metaPath = path.join(runDir, `${slug}.meta.json`);
118
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
119
+ expect(meta.contextDigestApplied).toBe(true);
120
+ expect(meta.originSessionId).toBe('sess-abc-123');
121
+ } finally {
122
+ fs.rmSync(projectCwd, { recursive: true, force: true });
123
+ fs.rmSync(runDir, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test('is a silent no-op when job.epicId does not resolve to a known Epic', async () => {
128
+ const { projectCwd, runDir } = setupProject('sm-epic-digest-miss');
129
+ try {
130
+ const slug = `958-test-miss-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
131
+ const bodyText = 'Do the thing described in this PRD body, unaccompanied.';
132
+ writeLegacyPrd(projectCwd, slug, bodyText);
133
+
134
+ const capturePath = path.join(runDir, 'captured-prompt.txt');
135
+ process.env.SM_CLAUDE_BIN = writeClaudeStub(capturePath);
136
+
137
+ // epicId is absent entirely — no active-index.json even exists for this project.
138
+ const job = { slug, cwd: projectCwd };
139
+ const result = await executeJob(job, runDir, projectCwd, () => Promise.resolve());
140
+
141
+ expect(result.exitCode).toBe(0);
142
+ const capturedPrompt = fs.readFileSync(capturePath, 'utf8');
143
+ expect(capturedPrompt).toBe(bodyText + FINISH_PROTOCOL);
144
+
145
+ const metaPath = path.join(runDir, `${slug}.meta.json`);
146
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
147
+ expect(meta.contextDigestApplied).toBe(false);
148
+ expect(meta.originSessionId).toBe(null);
149
+ } finally {
150
+ fs.rmSync(projectCwd, { recursive: true, force: true });
151
+ fs.rmSync(runDir, { recursive: true, force: true });
152
+ }
153
+ });
154
+
155
+ test('a digest build failure is caught and never blocks dispatch', async () => {
156
+ const { projectCwd, runDir } = setupProject('sm-epic-digest-fail');
157
+ try {
158
+ const epicId = 'epic-fail-1';
159
+ writeActiveIndex(projectCwd, {
160
+ [epicId]: { claudeSessionId: 'sess-def-456', goalText: 'This Epic exists and resolves fine.' },
161
+ });
162
+
163
+ const slug = `958-test-fail-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
164
+ const bodyText = 'Do the thing even if the digest build blows up.';
165
+ writeLegacyPrd(projectCwd, slug, bodyText);
166
+
167
+ const capturePath = path.join(runDir, 'captured-prompt.txt');
168
+ process.env.SM_CLAUDE_BIN = writeClaudeStub(capturePath);
169
+
170
+ // Stub epicContextDigest.cjs's export to reject, then force scheduler.cjs
171
+ // to re-require it so it picks up the stub (mirrors exchanges.test.cjs's
172
+ // summarize.cjs stubbing pattern) — only scheduler.cjs's own cache entry
173
+ // is cleared, not its whole dependency tree.
174
+ const digestPath = require.resolve('../lib/epicContextDigest.cjs');
175
+ const realDigestModule = require.cache[digestPath];
176
+ require.cache[digestPath] = {
177
+ id: digestPath,
178
+ filename: digestPath,
179
+ loaded: true,
180
+ exports: {
181
+ buildContextDigest: async () => {
182
+ throw new Error('boom: digest build exploded');
183
+ },
184
+ },
185
+ };
186
+ const schedulerPath = require.resolve('../scheduler.cjs');
187
+ delete require.cache[schedulerPath];
188
+ const freshScheduler = require('../scheduler.cjs');
189
+
190
+ try {
191
+ const job = { slug, cwd: projectCwd, epicId };
192
+ const result = await freshScheduler.executeJob(job, runDir, projectCwd, () => Promise.resolve());
193
+
194
+ expect(result.exitCode).toBe(0);
195
+ const capturedPrompt = fs.readFileSync(capturePath, 'utf8');
196
+ // Digest failed to build — prompt falls back to the un-prefixed PRD body.
197
+ expect(capturedPrompt).toBe(bodyText + FINISH_PROTOCOL);
198
+
199
+ const metaPath = path.join(runDir, `${slug}.meta.json`);
200
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
201
+ expect(meta.contextDigestApplied).toBe(false);
202
+ expect(meta.originSessionId).toBe('sess-def-456');
203
+ } finally {
204
+ // Restore real modules for any later test in this file/process.
205
+ if (realDigestModule) require.cache[digestPath] = realDigestModule;
206
+ else delete require.cache[digestPath];
207
+ delete require.cache[schedulerPath];
208
+ ({ executeJob, FINISH_PROTOCOL } = require('../scheduler.cjs'));
209
+ }
210
+ } finally {
211
+ fs.rmSync(projectCwd, { recursive: true, force: true });
212
+ fs.rmSync(runDir, { recursive: true, force: true });
213
+ }
214
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * scheduler-heal-refusal.test.cjs — PRD 983.
3
+ *
4
+ * Guards the needs_review → completed self-heal in reverifyNeedsReview.
5
+ *
6
+ * Incident being regression-tested: job 972 (2026-08-03) ran 34 s, edited zero
7
+ * files, exited 0. verifyRun correctly raised `no_verdict_sentinel` /
8
+ * downgradeTo 'needs_review'. One minute later the self-heal pass promoted it
9
+ * to `completed` and the false green shipped. Cause: the rescan recomputes
10
+ * `committedDuringRun` from `committedInWindow`, a repo-wide
11
+ * `git log --all --since --until` with no author/message/slug filter — a
12
+ * CONCURRENT job's commit inside the window was credited to this job, which
13
+ * suppressed the no_verdict_sentinel check on re-verify and let the verdict
14
+ * conclude `clean`.
15
+ *
16
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-heal-refusal.test.cjs
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ import { test, expect } from 'vitest';
22
+ const { healRefusalReason } = require('../scheduler.cjs');
23
+
24
+ const CLEAN = { verdict: 'clean', reason: 'no issues detected' };
25
+
26
+ test('REGRESSION (PRD 983): a no_verdict_sentinel job with no landedCommit is NOT healed, even on a clean re-verify', () => {
27
+ const job = { slug: '972-notify', verifierVerdict: 'no_verdict_sentinel', landedCommit: null };
28
+ const refusal = healRefusalReason(job, CLEAN, true);
29
+ expect(refusal).toBeTruthy();
30
+ expect(refusal).toContain('no job-attributable commit');
31
+ });
32
+
33
+ test('a no_verdict_sentinel job WITH its own landedCommit is allowed to heal', () => {
34
+ const job = { slug: '900-real', verifierVerdict: 'no_verdict_sentinel', landedCommit: 'abc1234' };
35
+ expect(healRefusalReason(job, CLEAN, true)).toBeNull();
36
+ });
37
+
38
+ test('other verdict classes are untouched — transcript_errors still heals on a clean re-verify', () => {
39
+ const job = { slug: '901-transcript', verifierVerdict: 'transcript_errors', landedCommit: null };
40
+ expect(healRefusalReason(job, CLEAN, true)).toBeNull();
41
+ });
42
+
43
+ test('verify_unavailable (the other pre-sentinel-heal class) is untouched', () => {
44
+ const job = { slug: '902-unavailable', verifierVerdict: 'verify_unavailable', landedCommit: null };
45
+ expect(healRefusalReason(job, CLEAN, true)).toBeNull();
46
+ });
47
+
48
+ test('a non-completed-equivalent re-verify verdict is left to the existing path, not refused here', () => {
49
+ const job = { slug: '903-still-bad', verifierVerdict: 'no_verdict_sentinel', landedCommit: null };
50
+ expect(healRefusalReason(job, { verdict: 'transcript_errors', reason: 'x' }, false)).toBeNull();
51
+ });
52
+
53
+ test('an honest no-op exemption verdict still heals when the job recorded its own commit', () => {
54
+ const job = { slug: '904-shipped', verifierVerdict: 'no_verdict_sentinel', landedCommit: 'deadbee' };
55
+ expect(healRefusalReason(job, { verdict: 'pass_no_commit_already_shipped', reason: 'x' }, false)).toBeNull();
56
+ });
57
+
58
+ test('null/missing inputs never throw and never refuse', () => {
59
+ expect(healRefusalReason(null, CLEAN, true)).toBeNull();
60
+ expect(healRefusalReason({ slug: 'x' }, null, true)).toBeNull();
61
+ });
@@ -116,6 +116,7 @@ test('routes into the known PromptSession event chain and never falls back to a
116
116
  '/some/cwd',
117
117
  'psess-1',
118
118
  expect.stringContaining('814-notify'),
119
+ { prdSlug: '814-notify', outcome: 'completed', validation: 'unvalidated' },
119
120
  );
120
121
  expect(sendPrompt).not.toHaveBeenCalled();
121
122
  });
@@ -186,3 +187,76 @@ test('falls back to the tab-external-ticket path when appendResponseEvent itself
186
187
 
187
188
  expect(sendPrompt).toHaveBeenCalledWith('tab-x', expect.stringContaining('814-notify'));
188
189
  });
190
+
191
+ // ─── PRD 985: real path resolution ───────────────────────────────────────────
192
+ // Every test above injects a stubbed `parsePrdRaw`, which is exactly why the
193
+ // bug PRD 985 fixes shipped green: notifyOriginatingTab resolved its PRD via
194
+ // prdPathForJob → the RETIRED flat `scheduler/prds/` dir (today only
195
+ // `.reserved-NNN` stubs, no PRDs), and archiveCompletedPrd renames the file
196
+ // into `prds-archived/` immediately BEFORE notify runs anyway. `prd` was
197
+ // therefore always null on the completed path and no response event was ever
198
+ // appended. These two tests use the REAL parsePrdRaw against a temp tree so
199
+ // the resolution logic itself is covered, not mocked away.
200
+
201
+ const fs = require('node:fs');
202
+ const os = require('node:os');
203
+ const nodePath = require('node:path');
204
+
205
+ function makeEpicTree({ epicId, slug, archived }) {
206
+ const cwd = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'sm-notify-985-'));
207
+ const dir = nodePath.join(
208
+ cwd, 'session-manager-operations', 'scheduler', 'epics', epicId,
209
+ archived ? 'prds-archived' : 'prds',
210
+ );
211
+ fs.mkdirSync(dir, { recursive: true });
212
+ fs.writeFileSync(
213
+ nodePath.join(dir, `${slug}.md`),
214
+ `---\ntitle: notify 985\ncwd: ${cwd}\nsourcePromptId: ${epicId}\n---\n\nbody\n`,
215
+ 'utf8',
216
+ );
217
+ return cwd;
218
+ }
219
+
220
+ test('PRD 985: an ARCHIVED-only PRD still routes its check-in to the authoring Epic', async () => {
221
+ const epicId = 'psess-985-archived';
222
+ const slug = '985-archived-twin';
223
+ const cwd = makeEpicTree({ epicId, slug, archived: true });
224
+
225
+ const appendResponseEvent = vi.fn(async () => true);
226
+ const appendTranscriptTurn = vi.fn(async () => {});
227
+ const sendPrompt = vi.fn();
228
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
229
+
230
+ // NOTE: no parsePrdRaw override — the real one runs against the temp tree.
231
+ await notifyOriginatingTab(
232
+ { slug, status: 'completed', cwd, epicId: null },
233
+ { loadSessions, sendPrompt, appendResponseEvent, appendTranscriptTurn, readResultFromLog: () => null },
234
+ );
235
+
236
+ expect(appendResponseEvent).toHaveBeenCalledTimes(1);
237
+ expect(appendResponseEvent).toHaveBeenCalledWith(
238
+ cwd, epicId, expect.stringContaining(slug),
239
+ expect.objectContaining({ prdSlug: slug, outcome: 'completed' }),
240
+ );
241
+ expect(appendTranscriptTurn).toHaveBeenCalledWith(cwd, epicId, expect.anything());
242
+ expect(sendPrompt).not.toHaveBeenCalled();
243
+ });
244
+
245
+ test('PRD 985: a PRD missing entirely still routes via the job row\'s own epicId', async () => {
246
+ const cwd = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'sm-notify-985-none-'));
247
+ const appendResponseEvent = vi.fn(async () => true);
248
+ const appendTranscriptTurn = vi.fn(async () => {});
249
+ const sendPrompt = vi.fn();
250
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
251
+
252
+ await notifyOriginatingTab(
253
+ { slug: '985-no-prd-on-disk', status: 'failed', cwd, epicId: 'psess-985-from-queue-row' },
254
+ { loadSessions, sendPrompt, appendResponseEvent, appendTranscriptTurn, readResultFromLog: () => null },
255
+ );
256
+
257
+ expect(appendResponseEvent).toHaveBeenCalledWith(
258
+ cwd, 'psess-985-from-queue-row', expect.stringContaining('985-no-prd-on-disk'),
259
+ expect.objectContaining({ prdSlug: '985-no-prd-on-disk', outcome: 'failed' }),
260
+ );
261
+ expect(appendTranscriptTurn).toHaveBeenCalledWith(cwd, 'psess-985-from-queue-row', expect.anything());
262
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * scheduler-writeprd-epic-rollback.test.cjs — remote.writePrd may only JOIN
3
3
  * an existing, already-human-approved Epic (via `sourcePromptId`); it must
4
- * never mint a new one (mintIfMissing:false, see epicMint.cjs's ensureEpic).
4
+ * never mint a new one (no mintAuthority, see epicMint.cjs's ensureEpic).
5
5
  * A write with no resolvable Epic fails outright with zero residual
6
6
  * active-index.json entries — no orphaned Epic can ever be left behind by
7
7
  * this path because none is ever created by it.
@@ -19,7 +19,7 @@ const fs = require('node:fs');
19
19
  const os = require('node:os');
20
20
  const path = require('node:path');
21
21
  const { remote } = require('../scheduler.cjs');
22
- const { ensureEpic, readActiveIndex } = require('../lib/epicMint.cjs');
22
+ const { ensureEpic, readActiveIndex, MINT_AUTHORITY_NEW_EPIC_UI } = require('../lib/epicMint.cjs');
23
23
  const config = require('../config.cjs');
24
24
 
25
25
  const tmpDirs = [];
@@ -61,7 +61,7 @@ test('writePrd: no sourcePromptId refuses to write (no Epic to join) and mints n
61
61
  test('writePrd: invalid-slug failure when joining a pre-existing Epic leaves that Epic untouched', async () => {
62
62
  const cwd = makeFixtureCwd();
63
63
 
64
- const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal' });
64
+ const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal', mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI });
65
65
  expect(preexisting.created).toBe(true);
66
66
 
67
67
  const beforeIndex = readActiveIndex(cwd);
@@ -81,7 +81,7 @@ test('writePrd: invalid-slug failure when joining a pre-existing Epic leaves tha
81
81
 
82
82
  // Provenance (PRD 905): writePrd's ensureEpic call passes
83
83
  // source: { producer: 'scheduler-dispatch', prdSlug }, but this call is
84
- // join-only (mintIfMissing:false, explicit epicId) — ensureEpic's
84
+ // join-only (no mintAuthority, explicit epicId) — ensureEpic's
85
85
  // explicitEpicId branch returns before ever reading `source`, so a
86
86
  // successful join must NOT write/alter the joined Epic's `source` field.
87
87
  test('writePrd: successful join does not write a source field onto the pre-existing Epic (join-only no-op)', async () => {
@@ -92,7 +92,7 @@ test('writePrd: successful join does not write a source field onto the pre-exist
92
92
  tmpDirs.push(cwd);
93
93
  config.addAllowedRoot(cwd);
94
94
 
95
- const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal for join' });
95
+ const preexisting = await ensureEpic(cwd, { goalText: 'pre-existing epic goal for join', mintAuthority: MINT_AUTHORITY_NEW_EPIC_UI });
96
96
  expect(preexisting.created).toBe(true);
97
97
 
98
98
  const body = `---\ntitle: Joins the existing epic\nsourcePromptId: ${preexisting.epicId}\n---\n\nbody text\n`;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * transcripts-doFlush-array.test.cjs — unit tests for transcripts.cjs's
3
+ * doFlush()/readDelta() handling of classifyLine's array-of-events shape.
4
+ *
5
+ * Covers: one line producing several events all land in the getBuffer()
6
+ * drain flattened (not nested per line), a session with far more than the
7
+ * old 500-entry ring cap still returns every event via the line-offset
8
+ * index (the cap is gone), and each event's byte `ref` correctly points
9
+ * back at its own line in the transcript file on disk.
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/__tests__/transcripts-doFlush-array.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect, beforeEach, afterEach } from 'vitest';
17
+
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+
22
+ const { subscribe, getBuffer, closeTab, transcriptPath } = require('../transcripts.cjs');
23
+
24
+ let cwd;
25
+
26
+ beforeEach(() => {
27
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-doflush-test-'));
28
+ });
29
+
30
+ afterEach(() => {
31
+ fs.rmSync(cwd, { recursive: true, force: true });
32
+ });
33
+
34
+ function writeTranscript(cwdArg, sessionId, lines) {
35
+ const filePath = transcriptPath(cwdArg, sessionId);
36
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
37
+ fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
38
+ return filePath;
39
+ }
40
+
41
+ test('a single multi-block line flattens into multiple buffered events', async () => {
42
+ const sessionId = 'sess-multi';
43
+ const tabId = 'tab-multi';
44
+ writeTranscript(cwd, sessionId, [
45
+ {
46
+ type: 'assistant',
47
+ message: {
48
+ content: [
49
+ { type: 'text', text: 'doing two things' },
50
+ { type: 'tool_use', name: 'Bash', id: 'tu-1', input: { command: 'ls' } },
51
+ { type: 'tool_use', name: 'Bash', id: 'tu-2', input: { command: 'pwd' } },
52
+ ],
53
+ },
54
+ },
55
+ ]);
56
+
57
+ const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
58
+ expect(res.ok).toBe(true);
59
+ const buf = await getBuffer(tabId);
60
+ expect(buf).toHaveLength(3);
61
+ expect(buf.map((e) => e.kind)).toEqual(['assistant', 'tool_use', 'tool_use']);
62
+ closeTab(tabId);
63
+ });
64
+
65
+ test('a session with far more than the old 500-entry cap still returns every event — the ring cap is gone', async () => {
66
+ const sessionId = 'sess-cap';
67
+ const tabId = 'tab-cap';
68
+ // 200 lines * 3 events each = 600 events > the old 500 cap.
69
+ const lines = Array.from({ length: 200 }, (_, i) => ({
70
+ type: 'assistant',
71
+ message: {
72
+ content: [
73
+ { type: 'text', text: `line ${i}` },
74
+ { type: 'tool_use', name: 'Bash', id: `tu-${i}-a`, input: { command: 'ls' } },
75
+ { type: 'tool_use', name: 'Bash', id: `tu-${i}-b`, input: { command: 'pwd' } },
76
+ ],
77
+ },
78
+ }));
79
+ writeTranscript(cwd, sessionId, lines);
80
+
81
+ const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
82
+ expect(res.ok).toBe(true);
83
+ const buf = await getBuffer(tabId);
84
+ expect(buf).toHaveLength(600);
85
+ // Nothing evicted — both the genuine first and last events survive.
86
+ expect(buf[0].data).toBe('line 0');
87
+ expect(buf[buf.length - 1].data.id).toBe('tu-199-b');
88
+ closeTab(tabId);
89
+ });
90
+
91
+ test('each event ref points at its own line — re-reading those bytes reproduces that exact line', async () => {
92
+ const sessionId = 'sess-ref';
93
+ const tabId = 'tab-ref';
94
+ const filePath = writeTranscript(cwd, sessionId, [
95
+ { type: 'user', message: { content: 'first line — some unicode: héllo 🎉' } },
96
+ { type: 'assistant', message: { content: 'second line' } },
97
+ ]);
98
+
99
+ const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
100
+ expect(res.ok).toBe(true);
101
+ const buf = await getBuffer(tabId);
102
+ expect(buf).toHaveLength(2);
103
+
104
+ const fd = fs.openSync(filePath, 'r');
105
+ try {
106
+ for (const ev of buf) {
107
+ expect(ev.ref).toBeTruthy();
108
+ expect(ev.ref.filePath).toBe(filePath);
109
+ const readBuf = Buffer.alloc(ev.ref.byteLength);
110
+ fs.readSync(fd, readBuf, 0, ev.ref.byteLength, ev.ref.byteOffset);
111
+ const parsed = JSON.parse(readBuf.toString('utf8'));
112
+ expect(parsed.type).toBe(ev.kind);
113
+ }
114
+ } finally {
115
+ fs.closeSync(fd);
116
+ }
117
+ closeTab(tabId);
118
+ });