claude-code-session-manager 0.58.0 → 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 (38) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{TiptapBody-BEBLdJl_.js → TiptapBody-PUx4oZTh.js} +1 -1
  3. package/dist/assets/{index-DEQzGYa6.js → index-BPnfPdLW.js} +1076 -1081
  4. package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
  8. package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
  9. package/src/main/__tests__/prdMigration.test.cjs +160 -0
  10. package/src/main/__tests__/projectPages.test.cjs +151 -0
  11. package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
  12. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
  13. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
  14. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
  15. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
  16. package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
  17. package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
  18. package/src/main/health.cjs +5 -1
  19. package/src/main/index.cjs +1 -1
  20. package/src/main/ipcSchemas.cjs +26 -3
  21. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
  22. package/src/main/lib/classifyTranscriptLine.cjs +131 -48
  23. package/src/main/lib/epicMint.cjs +6 -1
  24. package/src/main/lib/epicValidationHook.cjs +192 -0
  25. package/src/main/lib/prdMigration.cjs +76 -5
  26. package/src/main/lib/promptSessionSchema.cjs +16 -0
  27. package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
  28. package/src/main/lib/schedulerBatch.cjs +70 -111
  29. package/src/main/lib/schedulerConfig.cjs +0 -1
  30. package/src/main/otel.cjs +3 -1
  31. package/src/main/projectPages.cjs +60 -14
  32. package/src/main/promptSessionEvents.cjs +16 -1
  33. package/src/main/scheduler.cjs +226 -47
  34. package/src/main/templates/project-pages-default-home.html +123 -0
  35. package/src/main/transcripts.cjs +191 -32
  36. package/src/main/webRemote.cjs +8 -7
  37. package/src/preload/api.d.ts +75 -9
  38. package/src/preload/index.cjs +3 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * epicValidationHook.test.cjs — PRD 986: a PRD check-in triggers validation
3
+ * in the authoring Epic; it never asserts the PRD is done.
4
+ *
5
+ * Covers the PRD's five required cases:
6
+ * (a) a completed check-in enqueues exactly one validation prompt naming
7
+ * the PRD slug;
8
+ * (b) a check-in for a non-active Epic enqueues nothing;
9
+ * (c) a second check-in for the same (epicId, prdSlug) enqueues nothing;
10
+ * (d) the enqueued prompt contains the PRD's absolute path and the
11
+ * VERIFIED/REFUTED instruction (plus the not-evidence warning and the
12
+ * git diff --stat / empty-diff-is-REFUTED rule);
13
+ * (e) LOOP GUARD — an appended event that is a validation result never
14
+ * enqueues a further prompt.
15
+ * Plus: the SM_EPIC_VALIDATION_DISABLE kill-switch, the born-'unvalidated'
16
+ * stamp, and end-to-end wiring through notifyOriginatingTab.
17
+ *
18
+ * Run: timeout 300 npx vitest run src/main/__tests__/epicValidationHook.test.cjs
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ import { test, expect, vi, beforeEach, afterEach } from 'vitest';
24
+ const {
25
+ maybeEnqueueValidationPrompt,
26
+ buildValidationPrompt,
27
+ __resetForTests,
28
+ } = require('../lib/epicValidationHook.cjs');
29
+ const { notifyOriginatingTab } = require('../scheduler.cjs');
30
+
31
+ const EPIC = 'epic-986';
32
+ const SLUG = '986-example-prd';
33
+ const PRD_PATH = '/abs/path/to/session-manager-operations/scheduler/epics/epic-986/prds-archived/986-example-prd.md';
34
+
35
+ /** An active-index snapshot AS IT LOOKS RIGHT AFTER the scheduler's check-in
36
+ * append: exactly one validation-stamped response event for this prdSlug. */
37
+ function indexAfterCheckin({ status = 'active', extraEvents = [] } = {}) {
38
+ return {
39
+ sessions: { [EPIC]: { id: EPIC, status } },
40
+ events: {
41
+ [EPIC]: [
42
+ { id: 'e1', promptSessionId: EPIC, kind: 'prompt', causedByEventId: null, at: '2026-08-02T00:00:00Z', text: 'goal' },
43
+ {
44
+ id: 'e2', promptSessionId: EPIC, kind: 'response', causedByEventId: 'e1',
45
+ at: '2026-08-02T01:00:00Z', text: `PRD ${SLUG} finished: completed.`,
46
+ prdSlug: SLUG, outcome: 'completed', validation: 'unvalidated',
47
+ },
48
+ ...extraEvents,
49
+ ],
50
+ },
51
+ };
52
+ }
53
+
54
+ function callArgs(overrides = {}) {
55
+ return {
56
+ cwd: '/some/cwd',
57
+ epicId: EPIC,
58
+ prdSlug: SLUG,
59
+ prdPath: PRD_PATH,
60
+ outcome: 'completed',
61
+ eventValidation: 'unvalidated',
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ beforeEach(() => {
67
+ __resetForTests();
68
+ delete process.env.SM_EPIC_VALIDATION_DISABLE;
69
+ });
70
+
71
+ afterEach(() => {
72
+ delete process.env.SM_EPIC_VALIDATION_DISABLE;
73
+ });
74
+
75
+ // ─── (a) completed check-in → exactly one prompt naming the slug ────────────
76
+
77
+ test('(a) a completed check-in enqueues exactly one validation prompt naming the PRD slug', () => {
78
+ const sendPrompt = vi.fn();
79
+ const readActiveIndex = vi.fn(() => indexAfterCheckin());
80
+
81
+ const res = maybeEnqueueValidationPrompt(callArgs(), { sendPrompt, readActiveIndex });
82
+
83
+ expect(res.enqueued).toBe(true);
84
+ expect(sendPrompt).toHaveBeenCalledTimes(1);
85
+ expect(sendPrompt).toHaveBeenCalledWith(EPIC, expect.stringContaining(SLUG));
86
+ });
87
+
88
+ // ─── (b) non-active Epic → nothing ──────────────────────────────────────────
89
+
90
+ test('(b) a check-in for a completed (non-active) Epic enqueues nothing', () => {
91
+ const sendPrompt = vi.fn();
92
+ const readActiveIndex = vi.fn(() => indexAfterCheckin({ status: 'completed' }));
93
+
94
+ const res = maybeEnqueueValidationPrompt(callArgs(), { sendPrompt, readActiveIndex });
95
+
96
+ expect(res).toEqual({ enqueued: false, reason: 'epic-not-active' });
97
+ expect(sendPrompt).not.toHaveBeenCalled();
98
+ });
99
+
100
+ test('(b) an unknown Epic (no active-index entry) enqueues nothing — join-only, never creates an Epic', () => {
101
+ const sendPrompt = vi.fn();
102
+ const readActiveIndex = vi.fn(() => ({ sessions: {}, events: {} }));
103
+
104
+ const res = maybeEnqueueValidationPrompt(callArgs(), { sendPrompt, readActiveIndex });
105
+
106
+ expect(res).toEqual({ enqueued: false, reason: 'epic-not-active' });
107
+ expect(sendPrompt).not.toHaveBeenCalled();
108
+ });
109
+
110
+ // ─── (c) second check-in for the same (epicId, prdSlug) → nothing ───────────
111
+
112
+ test('(c) a second check-in for the same (epicId, prdSlug) enqueues nothing (in-memory guard)', () => {
113
+ const sendPrompt = vi.fn();
114
+ const readActiveIndex = vi.fn(() => indexAfterCheckin());
115
+
116
+ expect(maybeEnqueueValidationPrompt(callArgs(), { sendPrompt, readActiveIndex }).enqueued).toBe(true);
117
+ const second = maybeEnqueueValidationPrompt(callArgs(), { sendPrompt, readActiveIndex });
118
+
119
+ expect(second).toEqual({ enqueued: false, reason: 'already-fired' });
120
+ expect(sendPrompt).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ test('(c) durable guard: two validation-stamped check-in events already on the chain skip even after a process restart emptied the in-memory set', () => {
124
+ const sendPrompt = vi.fn();
125
+ // Chain carries the current check-in PLUS an earlier one for the same slug
126
+ // (a re-notify after restart appends a second response event).
127
+ const readActiveIndex = vi.fn(() => indexAfterCheckin({
128
+ extraEvents: [{
129
+ id: 'e3', promptSessionId: EPIC, kind: 'response', causedByEventId: 'e2',
130
+ at: '2026-08-02T02:00:00Z', text: `PRD ${SLUG} finished: completed.`,
131
+ prdSlug: SLUG, outcome: 'completed', validation: 'unvalidated',
132
+ }],
133
+ }));
134
+
135
+ __resetForTests(); // simulate the restart: in-memory set is empty
136
+ const res = maybeEnqueueValidationPrompt(callArgs(), { sendPrompt, readActiveIndex });
137
+
138
+ expect(res).toEqual({ enqueued: false, reason: 'already-fired-durable' });
139
+ expect(sendPrompt).not.toHaveBeenCalled();
140
+ });
141
+
142
+ test('(c) a different PRD slug for the same Epic still fires — the guard is per (epicId, prdSlug) pair, not per Epic', () => {
143
+ const sendPrompt = vi.fn();
144
+ const readActiveIndex = vi.fn(() => indexAfterCheckin());
145
+
146
+ expect(maybeEnqueueValidationPrompt(callArgs(), { sendPrompt, readActiveIndex }).enqueued).toBe(true);
147
+ const other = maybeEnqueueValidationPrompt(
148
+ callArgs({ prdSlug: '987-other-prd', prdPath: '/abs/987-other-prd.md' }),
149
+ { sendPrompt, readActiveIndex: vi.fn(() => ({ sessions: { [EPIC]: { id: EPIC, status: 'active' } }, events: { [EPIC]: [{ id: 'x', kind: 'response', prdSlug: '987-other-prd', validation: 'unvalidated' }] } })) },
150
+ );
151
+
152
+ expect(other.enqueued).toBe(true);
153
+ expect(sendPrompt).toHaveBeenCalledTimes(2);
154
+ });
155
+
156
+ // ─── (d) prompt content ─────────────────────────────────────────────────────
157
+
158
+ test('(d) the enqueued prompt contains the PRD absolute path, the unverified-claim label, and the VERIFIED/REFUTED instruction', () => {
159
+ const sendPrompt = vi.fn();
160
+ const readActiveIndex = vi.fn(() => indexAfterCheckin());
161
+
162
+ maybeEnqueueValidationPrompt(callArgs(), { sendPrompt, readActiveIndex });
163
+
164
+ const prompt = sendPrompt.mock.calls[0][1];
165
+ expect(prompt).toContain(PRD_PATH);
166
+ expect(prompt).toContain(SLUG);
167
+ expect(prompt).toContain('UNVERIFIED CLAIM');
168
+ expect(prompt).toContain('"completed"');
169
+ expect(prompt).toContain('VERIFIED or REFUTED');
170
+ expect(prompt).toMatch(/file:line|command output/);
171
+ // AC #3: the not-evidence warning + the git diff --stat / empty-diff rule.
172
+ expect(prompt).toContain('exit code of 0');
173
+ expect(prompt).toContain('NOT evidence');
174
+ expect(prompt).toContain('git diff --stat');
175
+ expect(prompt).toMatch(/empty diff.*REFUTED/i);
176
+ // Acceptance-criteria-against-working-tree instruction.
177
+ expect(prompt).toMatch(/Acceptance criteria/i);
178
+ expect(prompt).toMatch(/working tree/i);
179
+ });
180
+
181
+ test('(d) buildValidationPrompt degrades gracefully when the PRD path could not be resolved', () => {
182
+ const prompt = buildValidationPrompt({ prdSlug: SLUG, prdPath: null, outcome: 'failed' });
183
+ expect(prompt).toContain(SLUG);
184
+ expect(prompt).toContain('path could not be resolved');
185
+ expect(prompt).toContain('"failed"');
186
+ });
187
+
188
+ // ─── (e) LOOP GUARD ─────────────────────────────────────────────────────────
189
+
190
+ test('(e) LOOP GUARD: an appended event that is a validation result never enqueues a further prompt', () => {
191
+ const sendPrompt = vi.fn();
192
+ const readActiveIndex = vi.fn(() => indexAfterCheckin());
193
+
194
+ for (const eventValidation of ['verified', 'refuted', 'validating', undefined]) {
195
+ const res = maybeEnqueueValidationPrompt(callArgs({ eventValidation }), { sendPrompt, readActiveIndex });
196
+ expect(res).toEqual({ enqueued: false, reason: 'not-a-checkin' });
197
+ }
198
+ expect(sendPrompt).not.toHaveBeenCalled();
199
+ });
200
+
201
+ // ─── kill-switch ────────────────────────────────────────────────────────────
202
+
203
+ test('SM_EPIC_VALIDATION_DISABLE=1 turns the hook off entirely', () => {
204
+ process.env.SM_EPIC_VALIDATION_DISABLE = '1';
205
+ const sendPrompt = vi.fn();
206
+ const readActiveIndex = vi.fn(() => indexAfterCheckin());
207
+
208
+ const res = maybeEnqueueValidationPrompt(callArgs(), { sendPrompt, readActiveIndex });
209
+
210
+ expect(res).toEqual({ enqueued: false, reason: 'disabled' });
211
+ expect(sendPrompt).not.toHaveBeenCalled();
212
+ expect(readActiveIndex).not.toHaveBeenCalled();
213
+ });
214
+
215
+ // ─── never throws ───────────────────────────────────────────────────────────
216
+
217
+ test('a sendPrompt that throws is swallowed and logged, never propagated (fire-and-forget contract)', () => {
218
+ const sendPrompt = vi.fn(() => { throw new Error('IPC gone'); });
219
+ const readActiveIndex = vi.fn(() => indexAfterCheckin());
220
+ const log = { error: vi.fn() };
221
+
222
+ let res;
223
+ expect(() => {
224
+ res = maybeEnqueueValidationPrompt(callArgs(), { sendPrompt, readActiveIndex, log });
225
+ }).not.toThrow();
226
+ expect(res).toEqual({ enqueued: false, reason: 'error' });
227
+ expect(log.error).toHaveBeenCalled();
228
+ });
229
+
230
+ // ─── wiring through notifyOriginatingTab ────────────────────────────────────
231
+
232
+ test('notifyOriginatingTab: a routed check-in stamps validation:unvalidated and fires the validation hook once with the Epic id + slug', async () => {
233
+ const sendPrompt = vi.fn();
234
+ const appendResponseEvent = vi.fn(async () => true);
235
+ const enqueueValidation = vi.fn(() => ({ enqueued: true }));
236
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: EPIC, path: PRD_PATH }));
237
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
238
+
239
+ await notifyOriginatingTab(
240
+ { slug: SLUG, status: 'completed', cwd: '/some/cwd' },
241
+ { parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent, enqueueValidation },
242
+ );
243
+
244
+ expect(appendResponseEvent).toHaveBeenCalledWith(
245
+ '/some/cwd', EPIC, expect.stringContaining(SLUG),
246
+ expect.objectContaining({ prdSlug: SLUG, outcome: 'completed', validation: 'unvalidated' }),
247
+ );
248
+ expect(enqueueValidation).toHaveBeenCalledTimes(1);
249
+ expect(enqueueValidation).toHaveBeenCalledWith(
250
+ expect.objectContaining({
251
+ cwd: '/some/cwd',
252
+ epicId: EPIC,
253
+ prdSlug: SLUG,
254
+ outcome: 'completed',
255
+ eventValidation: 'unvalidated',
256
+ }),
257
+ expect.objectContaining({ sendPrompt }),
258
+ );
259
+ // The check-in routed into the Epic chain — no tab-prompt fallback fires.
260
+ expect(sendPrompt).not.toHaveBeenCalled();
261
+ });
262
+
263
+ test('notifyOriginatingTab: a REFUSED append (unknown/completed Epic) never fires the validation hook', async () => {
264
+ const sendPrompt = vi.fn();
265
+ const appendResponseEvent = vi.fn(async () => false);
266
+ const enqueueValidation = vi.fn();
267
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: EPIC, sourceTabId: 'tab-x' }));
268
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
269
+
270
+ await notifyOriginatingTab(
271
+ { slug: SLUG, status: 'completed', cwd: '/some/cwd' },
272
+ { parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent, enqueueValidation },
273
+ );
274
+
275
+ expect(enqueueValidation).not.toHaveBeenCalled();
276
+ });
277
+
278
+ test('notifyOriginatingTab: a validation hook that throws never blocks the notification path', async () => {
279
+ const sendPrompt = vi.fn();
280
+ const appendResponseEvent = vi.fn(async () => true);
281
+ const enqueueValidation = vi.fn(() => { throw new Error('boom'); });
282
+ const parsePrdRaw = vi.fn(async () => ({ sourcePromptId: EPIC, path: PRD_PATH }));
283
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
284
+
285
+ await expect(
286
+ notifyOriginatingTab(
287
+ { slug: SLUG, status: 'failed', cwd: '/some/cwd' },
288
+ { parsePrdRaw, loadSessions, sendPrompt, appendResponseEvent, enqueueValidation },
289
+ ),
290
+ ).resolves.not.toThrow();
291
+ });
@@ -170,3 +170,163 @@ test('migratePrds moves multiple PRDs to different projects independently', asyn
170
170
  expect(fs.existsSync(path.join(resolvePrdWriteDir(cwdA), '05-a.md'))).toBe(true);
171
171
  expect(fs.existsSync(path.join(resolvePrdWriteDir(cwdB), '06-b.md'))).toBe(true);
172
172
  });
173
+
174
+ /* ------------------------------------------------------------------------ *
175
+ * PRD 992 — consolidateFlatPrds must not archive a PRD with a live queue job.
176
+ *
177
+ * The scheduler still scans the retired flat `scheduler/prds/` dir as a PRD
178
+ * *source* (prdLocations.cjs's resolvePrdsDirs), so a file there can have a
179
+ * pending/running job. Consolidation used to move every .md unconditionally,
180
+ * stranding that job with no resolvable source. Observed live 2026-08-02:
181
+ * 980-fix-chat-typed-event-renderers.md sat there with status `running`.
182
+ * ------------------------------------------------------------------------ */
183
+
184
+ const { consolidateFlatPrds, LIVE_JOB_STATUSES } = require('../lib/prdMigration.cjs');
185
+
186
+ function opsRoot(root) {
187
+ return path.join(root, 'session-manager-operations', 'scheduler');
188
+ }
189
+
190
+ async function makeFlatProject(root, files) {
191
+ const flat = path.join(opsRoot(root), 'prds');
192
+ await fsp.mkdir(flat, { recursive: true });
193
+ for (const name of files) {
194
+ await fsp.writeFile(path.join(flat, name), `---\ntitle: ${name}\n---\n# Goal\n`, 'utf8');
195
+ }
196
+ return flat;
197
+ }
198
+
199
+ async function writeQueue(root, jobs) {
200
+ const stateDir = path.join(opsRoot(root), 'state');
201
+ await fsp.mkdir(stateDir, { recursive: true });
202
+ await fsp.writeFile(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }), 'utf8');
203
+ }
204
+
205
+ function newRoot() {
206
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-consolidate-'));
207
+ tmpDirs.push(d);
208
+ return d;
209
+ }
210
+
211
+ for (const status of LIVE_JOB_STATUSES) {
212
+ test(`consolidateFlatPrds leaves a file in place when its job is ${status}`, async () => {
213
+ const root = newRoot();
214
+ const flat = await makeFlatProject(root, ['980-fix-chat-typed-event-renderers.md']);
215
+ await writeQueue(root, [{ slug: '980-fix-chat-typed-event-renderers', status }]);
216
+
217
+ const r = await consolidateFlatPrds(root);
218
+
219
+ expect(r.moved).toBe(0);
220
+ expect(r.skipped).toEqual([
221
+ { file: '980-fix-chat-typed-event-renderers.md', reason: 'live queue job — source must survive' },
222
+ ]);
223
+ expect(fs.existsSync(path.join(flat, '980-fix-chat-typed-event-renderers.md'))).toBe(true);
224
+ });
225
+ }
226
+
227
+ for (const status of ['completed', 'failed']) {
228
+ test(`consolidateFlatPrds archives a file whose job is ${status}`, async () => {
229
+ const root = newRoot();
230
+ const flat = await makeFlatProject(root, ['700-done.md']);
231
+ await writeQueue(root, [{ slug: '700-done', status }]);
232
+
233
+ const r = await consolidateFlatPrds(root);
234
+
235
+ expect(r.moved).toBe(1);
236
+ expect(r.skipped).toEqual([]);
237
+ expect(fs.existsSync(path.join(flat, '700-done.md'))).toBe(false);
238
+ expect(fs.existsSync(path.join(opsRoot(root), 'prds-archived', '700-done.md'))).toBe(true);
239
+ });
240
+ }
241
+
242
+ test('consolidateFlatPrds archives a file with no job at all', async () => {
243
+ const root = newRoot();
244
+ await makeFlatProject(root, ['701-orphan.md']);
245
+ await writeQueue(root, []);
246
+
247
+ const r = await consolidateFlatPrds(root);
248
+
249
+ expect(r.moved).toBe(1);
250
+ expect(fs.existsSync(path.join(opsRoot(root), 'prds-archived', '701-orphan.md'))).toBe(true);
251
+ });
252
+
253
+ test('consolidateFlatPrds mixed dir: only the live one survives', async () => {
254
+ const root = newRoot();
255
+ const flat = await makeFlatProject(root, ['800-live.md', '801-done.md', '802-none.md']);
256
+ await writeQueue(root, [
257
+ { slug: '800-live', status: 'running' },
258
+ { slug: '801-done', status: 'completed' },
259
+ ]);
260
+
261
+ const r = await consolidateFlatPrds(root);
262
+
263
+ expect(r.moved).toBe(2);
264
+ expect(r.skipped.map((s) => s.file)).toEqual(['800-live.md']);
265
+ expect(fs.existsSync(path.join(flat, '800-live.md'))).toBe(true);
266
+ });
267
+
268
+ test('consolidateFlatPrds never touches dotfiles (NN allocation still uses them)', async () => {
269
+ const root = newRoot();
270
+ const flat = await makeFlatProject(root, ['900-x.md']);
271
+ await fsp.writeFile(path.join(flat, '.max-allocated-group'), '992', 'utf8');
272
+
273
+ await consolidateFlatPrds(root, { liveSlugs: new Set() });
274
+
275
+ expect(fs.existsSync(path.join(flat, '.max-allocated-group'))).toBe(true);
276
+ });
277
+
278
+ test('consolidateFlatPrds is idempotent and creates no -legacy- duplicate', async () => {
279
+ const root = newRoot();
280
+ await makeFlatProject(root, ['910-a.md']);
281
+ await writeQueue(root, []);
282
+
283
+ const first = await consolidateFlatPrds(root);
284
+ const second = await consolidateFlatPrds(root);
285
+
286
+ expect(first.moved).toBe(1);
287
+ expect(second.moved).toBe(0);
288
+ expect(fs.readdirSync(path.join(opsRoot(root), 'prds-archived'))).toEqual(['910-a.md']);
289
+ });
290
+
291
+ test('consolidateFlatPrds protects a live job across repeat runs, not just the first', async () => {
292
+ const root = newRoot();
293
+ await makeFlatProject(root, ['920-live.md']);
294
+ await writeQueue(root, [{ slug: '920-live', status: 'pending' }]);
295
+
296
+ await consolidateFlatPrds(root);
297
+ const second = await consolidateFlatPrds(root);
298
+
299
+ expect(second.moved).toBe(0);
300
+ expect(second.skipped).toHaveLength(1);
301
+ });
302
+
303
+ test('consolidateFlatPrds treats an absent queue.json as "no jobs", not an error', async () => {
304
+ const root = newRoot();
305
+ await makeFlatProject(root, ['930-fresh.md']);
306
+
307
+ const r = await consolidateFlatPrds(root);
308
+
309
+ expect(r.moved).toBe(1);
310
+ expect(r.skipped).toEqual([]);
311
+ });
312
+
313
+ test('consolidateFlatPrds FAILS CLOSED on an unparseable queue.json', async () => {
314
+ const root = newRoot();
315
+ const flat = await makeFlatProject(root, ['940-a.md', '941-b.md']);
316
+ const stateDir = path.join(opsRoot(root), 'state');
317
+ await fsp.mkdir(stateDir, { recursive: true });
318
+ await fsp.writeFile(path.join(stateDir, 'queue.json'), '{ not json', 'utf8');
319
+
320
+ const r = await consolidateFlatPrds(root);
321
+
322
+ expect(r.moved).toBe(0);
323
+ expect(r.skipped).toHaveLength(2);
324
+ expect(r.skipped[0].reason).toMatch(/queue state unreadable/);
325
+ expect(fs.existsSync(path.join(flat, '940-a.md'))).toBe(true);
326
+ expect(fs.existsSync(path.join(flat, '941-b.md'))).toBe(true);
327
+ });
328
+
329
+ test('consolidateFlatPrds returns the full shape when the flat dir is missing', async () => {
330
+ const r = await consolidateFlatPrds(newRoot());
331
+ expect(r).toEqual({ moved: 0, failed: [], skipped: [] });
332
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * projectPages.test.cjs — unit tests for projectPages.cjs's read-only get(),
3
+ * including the regression this PRD's AC calls out explicitly: adding the
4
+ * 5th 'brief' lens must not make every pre-existing 4-lens project suddenly
5
+ * read as having no output at all — and the shipped-default fallback added
6
+ * by the "project-home-hosted-html-spec" PRD: a project with no generated
7
+ * output gets the build-time default home.html (isDefault: true) instead of
8
+ * the bare `{ output: null }` empty-state signal.
9
+ *
10
+ * Run: timeout 120 npx vitest run src/main/__tests__/projectPages.test.cjs
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ import { test, expect, afterEach } from 'vitest';
16
+ const fs = require('node:fs');
17
+ const fsp = require('node:fs/promises');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+ const config = require('../config.cjs');
21
+ const { get, DEFAULT_HOME_PATH, readDefaultHomeHtml } = require('../projectPages.cjs');
22
+
23
+ const tmpDirs = [];
24
+ afterEach(async () => {
25
+ while (tmpDirs.length) {
26
+ const d = tmpDirs.pop();
27
+ await fsp.rm(d, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ async function mkTmpCwd() {
32
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-project-pages-'));
33
+ config.addAllowedRoot(dir);
34
+ tmpDirs.push(dir);
35
+ return dir;
36
+ }
37
+
38
+ function outputDir(cwd) {
39
+ return path.join(cwd, 'session-manager-operations', 'project-pages', 'output');
40
+ }
41
+
42
+ function writeOutput(cwd, lenses, manifest = { generatedAt: '2026-08-02T00:00:00.000Z' }) {
43
+ const dir = outputDir(cwd);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest));
46
+ for (const [lens, html] of Object.entries(lenses)) {
47
+ fs.writeFileSync(path.join(dir, `${lens}.html`), html);
48
+ }
49
+ }
50
+
51
+ test('the shipped default asset exists on disk at a fixed app-relative path', () => {
52
+ expect(fs.existsSync(DEFAULT_HOME_PATH)).toBe(true);
53
+ expect(path.isAbsolute(DEFAULT_HOME_PATH)).toBe(true);
54
+ });
55
+
56
+ test('(a) generated output present: get() returns it with isDefault false', async () => {
57
+ const cwd = await mkTmpCwd();
58
+ writeOutput(cwd, {
59
+ home: 'HOME',
60
+ marketing: 'MARKETING',
61
+ feature: 'FEATURE',
62
+ architecture: 'ARCHITECTURE',
63
+ brief: 'BRIEF',
64
+ });
65
+ const result = await get({ cwd });
66
+ expect(result.output).not.toBeNull();
67
+ expect(result.output.home).toBe('HOME');
68
+ expect(result.output.brief).toBe('BRIEF');
69
+ expect(result.output.generatedAt).toBe('2026-08-02T00:00:00.000Z');
70
+ expect(result.output.isDefault).toBe(false);
71
+ });
72
+
73
+ // The regression this PRD's AC explicitly guards against: a project that
74
+ // generated its output BEFORE the 'brief' lens existed has the original 4
75
+ // files but no brief.html — that must still read as "has output", not the
76
+ // shipped-default fallback, and `brief` must simply be absent from the payload.
77
+ test('4 original lenses present but brief.html missing still returns generated output, with brief omitted', async () => {
78
+ const cwd = await mkTmpCwd();
79
+ writeOutput(cwd, {
80
+ home: 'HOME',
81
+ marketing: 'MARKETING',
82
+ feature: 'FEATURE',
83
+ architecture: 'ARCHITECTURE',
84
+ });
85
+ const result = await get({ cwd });
86
+ expect(result.output).not.toBeNull();
87
+ expect(result.output.home).toBe('HOME');
88
+ expect(result.output.marketing).toBe('MARKETING');
89
+ expect(result.output.feature).toBe('FEATURE');
90
+ expect(result.output.architecture).toBe('ARCHITECTURE');
91
+ expect(result.output.brief).toBeUndefined();
92
+ expect(result.output.isDefault).toBe(false);
93
+ });
94
+
95
+ // (b) output absent → returns the shipped default with isDefault true.
96
+ test('(b) no manifest at all: get() returns the shipped default with isDefault true', async () => {
97
+ const cwd = await mkTmpCwd();
98
+ const result = await get({ cwd });
99
+ expect(result.output).not.toBeNull();
100
+ expect(result.output.isDefault).toBe(true);
101
+ expect(result.output.generatedAt).toBeNull();
102
+ expect(result.output.home).toBe(readDefaultHomeHtml());
103
+ expect(result.output.marketing).toBeUndefined();
104
+ expect(result.output.feature).toBeUndefined();
105
+ expect(result.output.architecture).toBeUndefined();
106
+ });
107
+
108
+ test('one of the required 4 lenses missing falls back to the shipped default, even if brief.html exists', async () => {
109
+ const cwd = await mkTmpCwd();
110
+ writeOutput(cwd, {
111
+ home: 'HOME',
112
+ marketing: 'MARKETING',
113
+ feature: 'FEATURE',
114
+ // architecture.html deliberately missing
115
+ brief: 'BRIEF',
116
+ });
117
+ const result = await get({ cwd });
118
+ expect(result.output).not.toBeNull();
119
+ expect(result.output.isDefault).toBe(true);
120
+ expect(result.output.home).toBe(readDefaultHomeHtml());
121
+ });
122
+
123
+ test('manifest with no generatedAt string falls back to the shipped default', async () => {
124
+ const cwd = await mkTmpCwd();
125
+ writeOutput(
126
+ cwd,
127
+ { home: 'HOME', marketing: 'MARKETING', feature: 'FEATURE', architecture: 'ARCHITECTURE', brief: 'BRIEF' },
128
+ {},
129
+ );
130
+ const result = await get({ cwd });
131
+ expect(result.output).not.toBeNull();
132
+ expect(result.output.isDefault).toBe(true);
133
+ });
134
+
135
+ // (c) the default path resolution does not depend on cwd.
136
+ test('(c) default fallback content is identical regardless of which project cwd requests it', async () => {
137
+ const cwdA = await mkTmpCwd();
138
+ const cwdB = await mkTmpCwd();
139
+ const resultA = await get({ cwd: cwdA });
140
+ const resultB = await get({ cwd: cwdB });
141
+ expect(resultA.output.isDefault).toBe(true);
142
+ expect(resultB.output.isDefault).toBe(true);
143
+ expect(resultA.output.home).toBe(resultB.output.home);
144
+ expect(resultA.output.home).toBe(readDefaultHomeHtml());
145
+ });
146
+
147
+ test('the shipped default is honest: no fabricated project-specific content, and prompts Generate My Project Home', () => {
148
+ const html = readDefaultHomeHtml();
149
+ expect(html).toContain('Generate My Project Home');
150
+ expect(html.toLowerCase()).not.toContain('lorem ipsum');
151
+ });
@@ -139,6 +139,80 @@ test('broadcasts EVENT_APPENDED_CHANNEL to the attached window on a successful a
139
139
  }
140
140
  });
141
141
 
142
+ test('appending with { prdSlug, outcome } persists both onto the event on disk', async () => {
143
+ await writeIndex({
144
+ sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
145
+ events: {
146
+ 'psess-1': [
147
+ { id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
148
+ ],
149
+ },
150
+ });
151
+
152
+ const routed = await appendResponseEventIfKnown(cwd, 'psess-1', 'PRD 976-foo finished: completed.', {
153
+ prdSlug: '976-foo',
154
+ outcome: 'completed',
155
+ });
156
+ expect(routed).toBe(true);
157
+
158
+ const onDisk = JSON.parse(await fsp.readFile(promptSessionActiveIndexPath(cwd), 'utf8'));
159
+ const event = onDisk.events['psess-1'][1];
160
+ expect(event.prdSlug).toBe('976-foo');
161
+ expect(event.outcome).toBe('completed');
162
+ });
163
+
164
+ // PRD 986: a scheduler check-in event is born 'unvalidated' — the stamp is
165
+ // persisted from meta, and events appended without it carry no validation
166
+ // key at all (pre-existing events keep rendering as they do today).
167
+ test('appending with { validation } persists the stamp; without it no validation key exists', async () => {
168
+ await writeIndex({
169
+ sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
170
+ events: {
171
+ 'psess-1': [
172
+ { id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
173
+ ],
174
+ },
175
+ });
176
+
177
+ const routed = await appendResponseEventIfKnown(cwd, 'psess-1', 'PRD 986-foo finished: completed.', {
178
+ prdSlug: '986-foo',
179
+ outcome: 'completed',
180
+ validation: 'unvalidated',
181
+ });
182
+ expect(routed).toBe(true);
183
+
184
+ await appendResponseEventIfKnown(cwd, 'psess-1', 'plain response, no meta');
185
+
186
+ const raw = await fsp.readFile(promptSessionActiveIndexPath(cwd), 'utf8');
187
+ const onDisk = JSON.parse(raw);
188
+ const [, checkin, plain] = onDisk.events['psess-1'];
189
+ expect(checkin.validation).toBe('unvalidated');
190
+ expect('validation' in plain).toBe(false);
191
+ });
192
+
193
+ test('appending without { prdSlug, outcome } persists an event with neither key set', async () => {
194
+ await writeIndex({
195
+ sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
196
+ events: {
197
+ 'psess-1': [
198
+ { id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
199
+ ],
200
+ },
201
+ });
202
+
203
+ const routed = await appendResponseEventIfKnown(cwd, 'psess-1', 'no meta here');
204
+ expect(routed).toBe(true);
205
+
206
+ const raw = await fsp.readFile(promptSessionActiveIndexPath(cwd), 'utf8');
207
+ const onDisk = JSON.parse(raw);
208
+ const event = onDisk.events['psess-1'][1];
209
+ expect('prdSlug' in event).toBe(false);
210
+ expect('outcome' in event).toBe(false);
211
+ // Also assert the raw JSON text has no undefined-valued keys serialized in.
212
+ expect(raw).not.toMatch(/"prdSlug"/);
213
+ expect(raw).not.toMatch(/"outcome"/);
214
+ });
215
+
142
216
  test('does not throw broadcasting before a window is attached, or after it is destroyed', async () => {
143
217
  await writeIndex({
144
218
  sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },