claude-code-session-manager 0.65.0 → 0.66.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 (60) hide show
  1. package/dist/assets/AgentLibrary-Bzg89D5Y.js +3 -0
  2. package/dist/assets/{History-DB-9-zwc.js → History-CNH9vA0A.js} +2 -2
  3. package/dist/assets/{Hooks-DnxqMRrR.js → Hooks-CyTksPza.js} +3 -3
  4. package/dist/assets/{HostBilko-Cw6JocV7.js → HostBilko-DXQVDNHn.js} +1 -1
  5. package/dist/assets/{Library-BYSB0dmY.js → Library-9E2UOIsm.js} +1 -1
  6. package/dist/assets/{ListDetail-BfKqnL0r.js → ListDetail-CQWU_Yn5.js} +1 -1
  7. package/dist/assets/{MarkdownEditor-Cs-l5JOv.js → MarkdownEditor-DQgfpSef.js} +1 -1
  8. package/dist/assets/{McpServers-bBqj3hGg.js → McpServers-r3qwDIj2.js} +2 -2
  9. package/dist/assets/{Memory-DzPGXT5J.js → Memory-BaxOpj-3.js} +6 -6
  10. package/dist/assets/{Panel-BX6UZ-W0.js → Panel-BhFD8Lqo.js} +1 -1
  11. package/dist/assets/{Permissions-Db7S4T3Z.js → Permissions-Cj-mODQQ.js} +3 -3
  12. package/dist/assets/{Plugins-CIuey9R3.js → Plugins-DEL3Fqng.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-FPWNWc_-.js → ProvenanceBadge-CIAg6-JQ.js} +1 -1
  14. package/dist/assets/Scheduler-YOuZKkES.js +14 -0
  15. package/dist/assets/{ScopeSwitcher-DU7M_q5-.js → ScopeSwitcher-DZ_3gEus.js} +1 -1
  16. package/dist/assets/{Settings-DEptXcEY.js → Settings-B0x4oflz.js} +3 -3
  17. package/dist/assets/{SkillReferenceGraph-CprLcOCe.js → SkillReferenceGraph-CoIwsol8.js} +1 -1
  18. package/dist/assets/{Skills-CGN56X1i.js → Skills-CN8R6AWn.js} +2 -2
  19. package/dist/assets/{SystemPrompt-DIEK7FpJ.js → SystemPrompt-DceChpAi.js} +1 -1
  20. package/dist/assets/{TagLibrary-q66s4N3i.js → TagLibrary-BKmz2W7B.js} +1 -1
  21. package/dist/assets/{TiptapBody-I22aArc2.js → TiptapBody-W7n5SwPM.js} +1 -1
  22. package/dist/assets/{Toggle-D9sapSAv.js → Toggle-QuxlVHGI.js} +1 -1
  23. package/dist/assets/{index-LlWpj2VJ.css → index-14dBLqE_.css} +1 -1
  24. package/dist/assets/{index-BRwaw_1W.js → index-TejhHSzN.js} +526 -525
  25. package/dist/assets/{settingsSchema-CTc4qelV.js → settingsSchema-DNqx6BKJ.js} +1 -1
  26. package/dist/index.html +2 -2
  27. package/package.json +1 -1
  28. package/plugins/session-manager-dev/skills/develop/SKILL.md +41 -13
  29. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +10 -0
  30. package/src/main/__tests__/agentLibrary.test.cjs +40 -0
  31. package/src/main/__tests__/flatPrdTickSweep.test.cjs +110 -0
  32. package/src/main/__tests__/prdAdminRouteParity.test.cjs +68 -0
  33. package/src/main/__tests__/prdAdminRoutes.test.cjs +311 -0
  34. package/src/main/__tests__/prdCreate.test.cjs +7 -2
  35. package/src/main/__tests__/prdMigration.test.cjs +17 -0
  36. package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +91 -0
  37. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +109 -0
  38. package/src/main/__tests__/scheduleJobSchema.test.cjs +127 -0
  39. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +65 -0
  40. package/src/main/__tests__/scheduleJobTransitions.test.cjs +152 -0
  41. package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +59 -0
  42. package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +203 -0
  43. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +196 -0
  44. package/src/main/agentLibrary.cjs +40 -2
  45. package/src/main/index.cjs +2 -0
  46. package/src/main/ipcSchemas.cjs +60 -0
  47. package/src/main/lib/localAdminHttp.cjs +10 -3
  48. package/src/main/lib/prdAdminRoutes.cjs +175 -0
  49. package/src/main/lib/prdCreate.cjs +37 -2
  50. package/src/main/lib/prdFrontmatter.cjs +179 -1
  51. package/src/main/lib/prdMigration.cjs +82 -5
  52. package/src/main/lib/queueStore.cjs +41 -7
  53. package/src/main/lib/scheduleJobSchema.cjs +114 -0
  54. package/src/main/lib/scheduleJobTransitions.cjs +164 -0
  55. package/src/main/scheduler/prdParser.cjs +7 -0
  56. package/src/main/scheduler.cjs +649 -137
  57. package/src/preload/api.d.ts +54 -2
  58. package/src/preload/index.cjs +12 -0
  59. package/dist/assets/AgentLibrary-DYriNDGf.js +0 -1
  60. package/dist/assets/Scheduler-X5y252Qw.js +0 -14
@@ -0,0 +1,311 @@
1
+ /**
2
+ * prdAdminRoutes.test.cjs — unit tests for PRD 1024's admin HTTP routes
3
+ * (list/get/update/archive/cancel/retag) and the scheduler.cjs `remote`
4
+ * methods they delegate to. Exercises real HTTP round-trips against a real
5
+ * (temp-HOME) filesystem, matching prdCreate.test.cjs's pattern for the
6
+ * create-prd route.
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdAdminRoutes.test.cjs
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import { test, expect, beforeAll, afterAll, beforeEach } from 'vitest';
14
+ const fs = require('node:fs');
15
+ const fsp = require('node:fs/promises');
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+ const http = require('node:http');
19
+
20
+ let tmpHome;
21
+ let originalHome;
22
+ let scheduler;
23
+ let prdAdminRoutes;
24
+ let config;
25
+ let createAdminHttp;
26
+
27
+ beforeAll(() => {
28
+ originalHome = process.env.HOME;
29
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-admin-prd-routes-'));
30
+ process.env.HOME = tmpHome;
31
+
32
+ // Load AFTER HOME is stubbed — scheduler.cjs/config.cjs snapshot
33
+ // os.homedir() into module-level constants at load time, not lazily.
34
+ scheduler = require('../scheduler.cjs');
35
+ prdAdminRoutes = require('../lib/prdAdminRoutes.cjs');
36
+ config = require('../config.cjs');
37
+ ({ createAdminHttp } = require('../lib/localAdminHttp.cjs'));
38
+
39
+ if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
40
+ throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
41
+ }
42
+
43
+ // allProjectCwds()/activeProjectCwds() (queueStore.cjs's stateCwds, and
44
+ // prdLocations.cjs's resolvePrdsDirs) discover project cwds by scanning
45
+ // ~/.claude/projects/*/*.jsonl for a `cwd` field — fake one project
46
+ // transcript pointing at the real on-disk fixture cwd this file uses,
47
+ // same pattern as scheduler-verify-prd-path.test.cjs.
48
+ const projDir = path.join(tmpHome, '.claude', 'projects', 'fake-project-slug');
49
+ fs.mkdirSync(projDir, { recursive: true });
50
+ fs.writeFileSync(
51
+ path.join(projDir, 'session.jsonl'),
52
+ `${JSON.stringify({ cwd: path.join(tmpHome, 'fake-project-cwd') })}\n`,
53
+ 'utf8',
54
+ );
55
+ });
56
+
57
+ afterAll(() => {
58
+ process.env.HOME = originalHome;
59
+ fs.rmSync(tmpHome, { recursive: true, force: true });
60
+ });
61
+
62
+ function projectCwd() {
63
+ const cwd = path.join(tmpHome, 'fake-project-cwd');
64
+ fs.mkdirSync(cwd, { recursive: true });
65
+ config.addAllowedRoot(cwd);
66
+ return cwd;
67
+ }
68
+
69
+ function uniqueSlug(prefix) {
70
+ return `${prefix}-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
71
+ }
72
+
73
+ async function writePrdFixture({ cwd, epicId, slug, extra = '' }) {
74
+ const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
75
+ await fsp.mkdir(prdsDir, { recursive: true });
76
+ const filePath = path.join(prdsDir, `${slug}.md`);
77
+ const body = `---\ntitle: fixture title\ncwd: ${cwd}\nestimateMinutes: 10\n${extra}---\n\n# Goal\n\ndo the thing\n`;
78
+ await fsp.writeFile(filePath, body, 'utf8');
79
+ return filePath;
80
+ }
81
+
82
+ function request(port, { method = 'GET', path: reqPath, token, body }) {
83
+ return new Promise((resolve, reject) => {
84
+ const headers = {};
85
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
86
+ let payload;
87
+ if (body !== undefined) {
88
+ payload = JSON.stringify(body);
89
+ headers['Content-Type'] = 'application/json';
90
+ headers['Content-Length'] = Buffer.byteLength(payload);
91
+ }
92
+ const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
93
+ const chunks = [];
94
+ res.on('data', (c) => chunks.push(c));
95
+ res.on('end', () => {
96
+ const text = Buffer.concat(chunks).toString('utf8');
97
+ let json = null;
98
+ try { json = JSON.parse(text); } catch { /* leave null */ }
99
+ resolve({ status: res.statusCode, json, text });
100
+ });
101
+ });
102
+ req.on('error', reject);
103
+ if (payload) req.write(payload);
104
+ req.end();
105
+ });
106
+ }
107
+
108
+ async function startAdmin() {
109
+ const prevE2e = process.env.SM_E2E;
110
+ process.env.SM_E2E = '1';
111
+ const admin = createAdminHttp();
112
+ prdAdminRoutes.registerAdminRoute(admin, scheduler.remote);
113
+ const { port, token } = await admin.start();
114
+ if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
115
+ return { admin, port, token };
116
+ }
117
+
118
+ test('GET /admin/scheduler/prds without token returns 401', async () => {
119
+ const { admin, port } = await startAdmin();
120
+ try {
121
+ const res = await request(port, { path: '/admin/scheduler/prds' });
122
+ expect(res.status).toBe(401);
123
+ } finally {
124
+ await admin.stop();
125
+ }
126
+ });
127
+
128
+ test('GET /admin/scheduler/prds lists a live PRD with status null (no queue row yet), filterable by cwd', async () => {
129
+ const cwd = projectCwd();
130
+ const epicId = 'epic-list';
131
+ const slug = uniqueSlug('list-prds');
132
+ await writePrdFixture({ cwd, epicId, slug });
133
+
134
+ const { admin, port, token } = await startAdmin();
135
+ try {
136
+ const res = await request(port, { path: `/admin/scheduler/prds?cwd=${encodeURIComponent(cwd)}`, token });
137
+ expect(res.status).toBe(200);
138
+ expect(res.json.ok).toBe(true);
139
+ const entry = res.json.prds.find((p) => p.slug === slug);
140
+ expect(entry).toBeTruthy();
141
+ expect(entry.status).toBe(null);
142
+ expect(entry.epicId).toBe(epicId);
143
+ } finally {
144
+ await admin.stop();
145
+ }
146
+ });
147
+
148
+ test('GET /admin/scheduler/prd?slug=&cwd= returns parsed frontmatter + body', async () => {
149
+ const cwd = projectCwd();
150
+ const epicId = 'epic-get';
151
+ const slug = uniqueSlug('get-prd');
152
+ await writePrdFixture({ cwd, epicId, slug });
153
+
154
+ const { admin, port, token } = await startAdmin();
155
+ try {
156
+ const res = await request(port, { path: `/admin/scheduler/prd?slug=${slug}&cwd=${encodeURIComponent(cwd)}`, token });
157
+ expect(res.status).toBe(200);
158
+ expect(res.json.ok).toBe(true);
159
+ expect(res.json.frontmatter.title).toBe('fixture title');
160
+ expect(res.json.body).toMatch(/do the thing/);
161
+ } finally {
162
+ await admin.stop();
163
+ }
164
+ });
165
+
166
+ test('GET /admin/scheduler/prd for a nonexistent slug returns 404', async () => {
167
+ const cwd = projectCwd();
168
+ const { admin, port, token } = await startAdmin();
169
+ try {
170
+ const res = await request(port, { path: `/admin/scheduler/prd?slug=${uniqueSlug('nope')}&cwd=${encodeURIComponent(cwd)}`, token });
171
+ expect(res.status).toBe(404);
172
+ expect(res.json.ok).toBe(false);
173
+ } finally {
174
+ await admin.stop();
175
+ }
176
+ });
177
+
178
+ test('POST /admin/scheduler/update-prd edits a not-yet-queued PRD and preserves unrecognized frontmatter keys', async () => {
179
+ const cwd = projectCwd();
180
+ const epicId = 'epic-update';
181
+ const slug = uniqueSlug('update-prd');
182
+ await writePrdFixture({ cwd, epicId, slug, extra: 'dependsOn: [some-other-slug]\n' });
183
+
184
+ const { admin, port, token } = await startAdmin();
185
+ try {
186
+ const res = await request(port, {
187
+ method: 'POST', path: '/admin/scheduler/update-prd', token,
188
+ body: { slug, cwd, frontmatter: { title: 'edited title', estimateMinutes: 25 } },
189
+ });
190
+ expect(res.status).toBe(200);
191
+ expect(res.json.ok).toBe(true);
192
+
193
+ const raw = await fsp.readFile(path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds', `${slug}.md`), 'utf8');
194
+ expect(raw).toMatch(/title: edited title/);
195
+ expect(raw).toMatch(/estimateMinutes: 25/);
196
+ // Unrecognized key round-trips verbatim.
197
+ expect(raw).toMatch(/dependsOn: \[some-other-slug\]/);
198
+ } finally {
199
+ await admin.stop();
200
+ }
201
+ });
202
+
203
+ test('POST /admin/scheduler/update-prd refuses a slug whose job is running, naming the current status', async () => {
204
+ const cwd = projectCwd();
205
+ const epicId = 'epic-update-running';
206
+ const slug = uniqueSlug('update-prd-running');
207
+ await writePrdFixture({ cwd, epicId, slug });
208
+
209
+ await scheduler.writeQueue({
210
+ jobs: [{ slug, title: 'x', cwd, status: 'running', runtime: {} }],
211
+ config: {},
212
+ paused: null,
213
+ });
214
+
215
+ const { admin, port, token } = await startAdmin();
216
+ try {
217
+ const res = await request(port, {
218
+ method: 'POST', path: '/admin/scheduler/update-prd', token,
219
+ body: { slug, cwd, frontmatter: { title: 'should not land' } },
220
+ });
221
+ expect(res.status).toBe(409);
222
+ expect(res.json.ok).toBe(false);
223
+ expect(res.json.error).toMatch(/running/);
224
+ } finally {
225
+ await admin.stop();
226
+ await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
227
+ }
228
+ });
229
+
230
+ test('POST /admin/scheduler/cancel-job cancels a pending job (no cancelled status exists — lands as failed)', async () => {
231
+ const cwd = projectCwd();
232
+ const slug = uniqueSlug('cancel-pending');
233
+ await scheduler.writeQueue({
234
+ jobs: [{ slug, title: 'x', cwd, status: 'pending' }],
235
+ config: {},
236
+ paused: null,
237
+ });
238
+
239
+ const { admin, port, token } = await startAdmin();
240
+ try {
241
+ const res = await request(port, { method: 'POST', path: '/admin/scheduler/cancel-job', token, body: { slug } });
242
+ expect(res.status).toBe(200);
243
+ expect(res.json.ok).toBe(true);
244
+ expect(res.json.status).toBe('failed');
245
+ expect(res.json.wasRunning).toBe(false);
246
+ } finally {
247
+ await admin.stop();
248
+ await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
249
+ }
250
+ });
251
+
252
+ test('POST /admin/scheduler/cancel-job refuses an already-terminal job', async () => {
253
+ const cwd = projectCwd();
254
+ const slug = uniqueSlug('cancel-terminal');
255
+ await scheduler.writeQueue({
256
+ jobs: [{ slug, title: 'x', cwd, status: 'completed' }],
257
+ config: {},
258
+ paused: null,
259
+ });
260
+
261
+ const { admin, port, token } = await startAdmin();
262
+ try {
263
+ const res = await request(port, { method: 'POST', path: '/admin/scheduler/cancel-job', token, body: { slug } });
264
+ expect(res.status).toBe(409);
265
+ expect(res.json.ok).toBe(false);
266
+ expect(res.json.error).toMatch(/terminal/);
267
+ } finally {
268
+ await admin.stop();
269
+ await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
270
+ }
271
+ });
272
+
273
+ test('POST /admin/scheduler/archive-prd moves a PRD to prds-archived/', async () => {
274
+ const cwd = projectCwd();
275
+ const epicId = 'epic-archive';
276
+ const slug = uniqueSlug('archive-prd');
277
+ const filePath = await writePrdFixture({ cwd, epicId, slug });
278
+
279
+ const { admin, port, token } = await startAdmin();
280
+ try {
281
+ const res = await request(port, { method: 'POST', path: '/admin/scheduler/archive-prd', token, body: { slugs: [slug] } });
282
+ expect(res.status).toBe(200);
283
+ expect(res.json.ok).toBe(true);
284
+ expect(res.json.archived).toBe(1);
285
+ expect(fs.existsSync(filePath)).toBe(false);
286
+ } finally {
287
+ await admin.stop();
288
+ }
289
+ });
290
+
291
+ test('POST /admin/scheduler/retag-prd rewrites estimateMinutes frontmatter', async () => {
292
+ const cwd = projectCwd();
293
+ const epicId = 'epic-retag';
294
+ const slug = uniqueSlug('retag-prd');
295
+ const filePath = await writePrdFixture({ cwd, epicId, slug });
296
+
297
+ const { admin, port, token } = await startAdmin();
298
+ try {
299
+ const res = await request(port, {
300
+ method: 'POST', path: '/admin/scheduler/retag-prd', token,
301
+ body: { items: [{ slug, estimateMinutes: 42 }] },
302
+ });
303
+ expect(res.status).toBe(200);
304
+ expect(res.json.ok).toBe(true);
305
+ expect(res.json.retagged).toBe(1);
306
+ const raw = await fsp.readFile(filePath, 'utf8');
307
+ expect(raw).toMatch(/estimateMinutes: 42/);
308
+ } finally {
309
+ await admin.stop();
310
+ }
311
+ });
@@ -221,7 +221,7 @@ test('POST /admin/scheduler/create-prd without token returns 401', async () => {
221
221
  }
222
222
  });
223
223
 
224
- test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename, status}', async () => {
224
+ test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename, enqueued:false}', async () => {
225
225
  const prdsDir = await mkTmpPrdsDir();
226
226
  const remote = makeFakeRemoteWithPrdsDir(prdsDir);
227
227
  const { admin, port, token } = await startWithRemote(remote);
@@ -230,7 +230,12 @@ test('POST /admin/scheduler/create-prd with a valid payload writes a file with f
230
230
  method: 'POST', path: '/admin/scheduler/create-prd', token, body: validCreateBody(),
231
231
  });
232
232
  expect(res.status).toBe(200);
233
- expect(res.json.status).toBe('queued');
233
+ // Never a 'status' field, and never the literal 'queued' — this route
234
+ // only writes the PRD file; the queue row is derived by the next
235
+ // reconcile() pass, not created here. See prdCreate.cjs's createPrd.
236
+ expect(res.json.status).toBeUndefined();
237
+ expect(res.json.enqueued).toBe(false);
238
+ expect(typeof res.json.note).toBe('string');
234
239
  expect(typeof res.json.nn).toBe('number');
235
240
  expect(res.json.filename).toMatch(/^\d+-add-widget-frobnication\.md$/);
236
241
 
@@ -250,6 +250,23 @@ test('consolidateFlatPrds archives a file with no job at all', async () => {
250
250
  expect(fs.existsSync(path.join(opsRoot(root), 'prds-archived', '701-orphan.md'))).toBe(true);
251
251
  });
252
252
 
253
+ // PRD 1024: an UNRECOGNIZED/invalid status (the exact 2026-08-07 1021/1022
254
+ // incident shape) must protect the file, not archive it — reconcile() is
255
+ // what repairs such a row back to 'pending', and this consolidation must
256
+ // never race that repair by moving the row's only source out from under it.
257
+ for (const invalidStatus of ['queued', 'cancelled', 'bogus-status', '']) {
258
+ test(`consolidateFlatPrds does NOT archive a file whose job has the unrecognized status "${invalidStatus}"`, async () => {
259
+ const root = newRoot();
260
+ const flat = await makeFlatProject(root, ['950-invalid-status.md']);
261
+ await writeQueue(root, [{ slug: '950-invalid-status', status: invalidStatus }]);
262
+
263
+ const r = await consolidateFlatPrds(root);
264
+
265
+ expect(r.moved).toBe(0);
266
+ expect(fs.existsSync(path.join(flat, '950-invalid-status.md'))).toBe(true);
267
+ });
268
+ }
269
+
253
270
  test('consolidateFlatPrds mixed dir: only the live one survives', async () => {
254
271
  const root = newRoot();
255
272
  const flat = await makeFlatProject(root, ['800-live.md', '801-done.md', '802-none.md']);
@@ -0,0 +1,91 @@
1
+ /**
2
+ * prdMigrationLegacyAdopt.test.cjs — legacyAdoptExistingPrds() (PRD-authoring-
3
+ * lockdown rollout migration): every PRD .md already on disk when the
4
+ * provenance gate ships must be stamped `createdVia: legacy-adopted` before
5
+ * reconcile() ever runs its quarantine check against it, so the rollout
6
+ * itself never quarantines pre-existing work.
7
+ *
8
+ * HOME-isolated (mirrors scheduler-reconcile-quarantine.test.cjs): stamping
9
+ * relies on resolvePrdsDirs(), which discovers projects via
10
+ * ~/.claude/projects transcripts.
11
+ *
12
+ * Run: timeout 60 npx vitest run src/main/__tests__/prdMigrationLegacyAdopt.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ import { test, expect, beforeAll, afterAll } from 'vitest';
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+
22
+ let tmpHome;
23
+ let originalHome;
24
+ let legacyAdoptExistingPrds;
25
+ let parsePrdFile;
26
+
27
+ beforeAll(() => {
28
+ originalHome = process.env.HOME;
29
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-legacy-adopt-home-'));
30
+ process.env.HOME = tmpHome;
31
+ ({ legacyAdoptExistingPrds } = require('../lib/prdMigration.cjs'));
32
+ ({ parsePrdFile } = require('../lib/prdFrontmatter.cjs'));
33
+ });
34
+
35
+ afterAll(() => {
36
+ process.env.HOME = originalHome;
37
+ fs.rmSync(tmpHome, { recursive: true, force: true });
38
+ });
39
+
40
+ function registerActiveProject(cwd) {
41
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
42
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
43
+ fs.mkdirSync(slugDir, { recursive: true });
44
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
45
+ }
46
+
47
+ function makeFixtureProject(prefix) {
48
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
49
+ registerActiveProject(cwd);
50
+ const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'prds');
51
+ fs.mkdirSync(prdsDir, { recursive: true });
52
+ return { cwd, prdsDir };
53
+ }
54
+
55
+ test('stamps a pre-existing unstamped PRD as createdVia: legacy-adopted', async () => {
56
+ const { cwd, prdsDir } = makeFixtureProject('sm-legacy-adopt-proj-');
57
+ try {
58
+ const prdPath = path.join(prdsDir, '01-old.md');
59
+ fs.writeFileSync(prdPath, `---\ntitle: Old PRD\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nOld work.\n`, 'utf8');
60
+
61
+ const result = await legacyAdoptExistingPrds();
62
+ expect(result.stamped).toBeGreaterThanOrEqual(1);
63
+ expect(result.failed).toEqual([]);
64
+
65
+ const { frontmatter } = parsePrdFile(fs.readFileSync(prdPath, 'utf8'));
66
+ expect(frontmatter.createdVia).toBe('legacy-adopted');
67
+ expect(typeof frontmatter.issuedAt).toBe('string');
68
+ } finally {
69
+ fs.rmSync(cwd, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ test('is idempotent — never overwrites an already-stamped PRD', async () => {
74
+ const { cwd, prdsDir } = makeFixtureProject('sm-legacy-adopt-idempotent-');
75
+ try {
76
+ const prdPath = path.join(prdsDir, '02-already-stamped.md');
77
+ fs.writeFileSync(
78
+ prdPath,
79
+ `---\ntitle: Already stamped\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-01T00:00:00.000Z\n---\n\n# Goal\nDone.\n`,
80
+ 'utf8',
81
+ );
82
+
83
+ await legacyAdoptExistingPrds();
84
+
85
+ const { frontmatter } = parsePrdFile(fs.readFileSync(prdPath, 'utf8'));
86
+ expect(frontmatter.createdVia).toBe('scheduler-api'); // unchanged
87
+ expect(frontmatter.issuedAt).toBe('2026-08-01T00:00:00.000Z'); // unchanged
88
+ } finally {
89
+ fs.rmSync(cwd, { recursive: true, force: true });
90
+ }
91
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * reconcileFlatPrdSweep.test.cjs — end-to-end proof that `reconcile()` itself
3
+ * (not just `consolidateAllFlatPrds()` in isolation) never turns a
4
+ * hand-written flat-dir PRD into a queue job, regardless of which of
5
+ * reconcile's several callers (tickQueue's poll, job completion, the
6
+ * schedule:state/schedule:rescan IPC handlers, rescheduleTimer) triggered
7
+ * the pass.
8
+ *
9
+ * A code-review pass on this PRD flagged that gating only `tickQueue()` left
10
+ * every OTHER caller of `reconcile()` free to still turn a freshly
11
+ * hand-written flat-dir PRD into a `pending` job — the fix moved the sweep
12
+ * inside `reconcile()` itself instead. This test exercises the real
13
+ * `reconcile()` (not a stand-in) against a real on-disk flat-dir PRD with no
14
+ * pre-existing queue row (exactly the state of a file dropped there moments
15
+ * ago) and asserts it never appears in `state.jobs` afterward.
16
+ *
17
+ * HOME-isolated (mirrors scheduler-reconcile-invalid-repair.test.cjs): stub
18
+ * HOME to a mkdtemp dir before requiring scheduler.cjs, and register the
19
+ * fixture project as "active" via a fake ~/.claude/projects transcript —
20
+ * reconcile's PRD discovery (prdLocations.cjs) only scans cwds it can find
21
+ * that way.
22
+ *
23
+ * Run: timeout 120 npx vitest run src/main/__tests__/reconcileFlatPrdSweep.test.cjs
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ import { test, expect, beforeAll, afterAll } from 'vitest';
29
+ const fs = require('node:fs');
30
+ const os = require('node:os');
31
+ const path = require('node:path');
32
+
33
+ let tmpHome;
34
+ let originalHome;
35
+ let scheduler;
36
+
37
+ beforeAll(() => {
38
+ originalHome = process.env.HOME;
39
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-flatsweep-home-'));
40
+ process.env.HOME = tmpHome;
41
+
42
+ scheduler = require('../scheduler.cjs');
43
+
44
+ if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
45
+ throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
46
+ }
47
+ });
48
+
49
+ afterAll(() => {
50
+ process.env.HOME = originalHome;
51
+ fs.rmSync(tmpHome, { recursive: true, force: true });
52
+ });
53
+
54
+ function registerActiveProject(cwd) {
55
+ const projectsDir = path.join(tmpHome, '.claude', 'projects');
56
+ const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
57
+ fs.mkdirSync(slugDir, { recursive: true });
58
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
59
+ }
60
+
61
+ function makeFixtureProject(prefix) {
62
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
63
+ registerActiveProject(cwd);
64
+ const opsRoot = path.join(cwd, 'session-manager-operations');
65
+ const flatPrdsDir = path.join(opsRoot, 'scheduler', 'prds');
66
+ const stateDir = path.join(opsRoot, 'scheduler', 'state');
67
+ fs.mkdirSync(flatPrdsDir, { recursive: true });
68
+ fs.mkdirSync(stateDir, { recursive: true });
69
+ return { cwd, flatPrdsDir, stateDir, archiveDir: path.join(opsRoot, 'scheduler', 'prds-archived') };
70
+ }
71
+
72
+ test('reconcile() never turns a hand-written flat-dir PRD into a job, no matter which caller triggers it', async () => {
73
+ const { cwd, flatPrdsDir, archiveDir } = makeFixtureProject('sm-reconcile-flatsweep-proj-');
74
+ const filename = '1500-hand-written-bypass.md';
75
+ fs.writeFileSync(
76
+ path.join(flatPrdsDir, filename),
77
+ `---\ntitle: Hand-written bypass\ncwd: ${cwd}\nestimateMinutes: 10\n---\n\n# Goal\n\nSomething.\n`,
78
+ 'utf8',
79
+ );
80
+ // No queue.json at all — the exact state of a PRD dropped on disk moments
81
+ // ago, before any prior reconcile() pass has scanned it into a job.
82
+
83
+ const state = { jobs: [], invalidJobs: [], paused: null };
84
+ await scheduler.reconcile(state);
85
+
86
+ const found = state.jobs.find((j) => j.slug === '1500-hand-written-bypass');
87
+ expect(found).toBeUndefined();
88
+ expect(fs.existsSync(path.join(flatPrdsDir, filename))).toBe(false);
89
+ expect(fs.existsSync(path.join(archiveDir, filename))).toBe(true);
90
+ });
91
+
92
+ test('reconcile() still onboards a PRD written to the canonical Epic-scoped dir (the sweep only touches the flat dir)', async () => {
93
+ const { cwd } = makeFixtureProject('sm-reconcile-flatsweep-epic-proj-');
94
+ const epicId = 'epic-abc';
95
+ const epicPrdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
96
+ fs.mkdirSync(epicPrdsDir, { recursive: true });
97
+ fs.writeFileSync(
98
+ path.join(epicPrdsDir, '1501-canonical.md'),
99
+ `---\ntitle: Canonical\ncwd: ${cwd}\nestimateMinutes: 10\nsourcePromptId: ${epicId}\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\n\nSomething.\n`,
100
+ 'utf8',
101
+ );
102
+
103
+ const state = { jobs: [], invalidJobs: [], paused: null };
104
+ await scheduler.reconcile(state);
105
+
106
+ const found = state.jobs.find((j) => j.slug === '1501-canonical');
107
+ expect(found).toBeDefined();
108
+ expect(found.status).toBe('pending');
109
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * scheduleJobSchema.test.cjs — unit tests for the canonical ScheduleJob zod
3
+ * schema, its assertValidScheduleJob fail-closed helper, and queueStore.cjs's
4
+ * shapeJobs on-read quarantine gate.
5
+ *
6
+ * Reproduces the 2026-08-07 incident: PRDs 1021/1022 (social-signals-trader)
7
+ * sat unscheduled for 4+ hours because their queue.json rows carried
8
+ * `"status": "queued"` — a value absent from ScheduleJobStatus — which every
9
+ * picker silently skipped since none of them treat an unrecognized status as
10
+ * an error.
11
+ *
12
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduleJobSchema.test.cjs
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ import { test, expect, beforeEach, afterEach, vi } from 'vitest';
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+ const {
22
+ ScheduleJobSchema,
23
+ JOB_STATUSES,
24
+ assertValidScheduleJob,
25
+ } = require('../lib/scheduleJobSchema.cjs');
26
+ const { shapeJobs } = require('../lib/queueStore.cjs');
27
+
28
+ function validJob(overrides = {}) {
29
+ return {
30
+ slug: '1021-social-signals',
31
+ title: 'Wire up social signals trader',
32
+ cwd: '/home/bilko/Projects/social-signals-trader',
33
+ parallelGroup: 1021,
34
+ estimateMinutes: 30,
35
+ bodyPreview: 'Do the thing.',
36
+ status: 'pending',
37
+ runId: null,
38
+ startedAt: null,
39
+ finishedAt: null,
40
+ exitCode: null,
41
+ error: null,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ test('JOB_STATUSES matches ScheduleJobStatus', () => {
47
+ expect(JOB_STATUSES).toEqual(['pending', 'running', 'investigating', 'completed', 'failed', 'needs_review', 'quarantined']);
48
+ });
49
+
50
+ test('a valid full job record parses', () => {
51
+ const full = validJob({
52
+ sessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
53
+ runtime: { pid: 123, runId: 'run-1', startedAt: '2026-08-07T00:00:00.000Z' },
54
+ verifierVerdict: 'halt',
55
+ dependsOn: ['1020-prereq'],
56
+ originSessionId: 'orig-session',
57
+ sourceTabId: 'tab-1',
58
+ sourcePromptId: 'prompt-1',
59
+ epicId: 'epic-1',
60
+ });
61
+ expect(() => assertValidScheduleJob(full)).not.toThrow();
62
+ expect(ScheduleJobSchema.safeParse(full).success).toBe(true);
63
+ });
64
+
65
+ test('a job with an invalid status value fails', () => {
66
+ const badStatus = validJob({ status: 'queued' });
67
+ expect(() => assertValidScheduleJob(badStatus)).toThrow(/status/);
68
+ });
69
+
70
+ test('a job missing a required field fails', () => {
71
+ const { slug, ...missingSlug } = validJob();
72
+ expect(() => assertValidScheduleJob(missingSlug)).toThrow(/slug/);
73
+ });
74
+
75
+ // ---------- shapeJobs (the on-read quarantine gate) ----------
76
+
77
+ let tmpFile;
78
+ let errorSpy;
79
+
80
+ beforeEach(() => {
81
+ tmpFile = path.join(
82
+ os.tmpdir(),
83
+ `scheduleJobSchema-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`
84
+ );
85
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
86
+ });
87
+
88
+ afterEach(() => {
89
+ try { fs.unlinkSync(tmpFile); } catch { /* ok if absent */ }
90
+ errorSpy.mockRestore();
91
+ });
92
+
93
+ test('a row with status "queued" (the 1021/1022 incident) is quarantined, logged, and absent from jobs', () => {
94
+ fs.writeFileSync(tmpFile, JSON.stringify({
95
+ jobs: [
96
+ validJob({ slug: '1021-social-signals', status: 'queued' }),
97
+ validJob({ slug: '1022-social-signals', status: 'queued' }),
98
+ validJob({ slug: '1023-healthy', status: 'pending' }),
99
+ ],
100
+ }));
101
+
102
+ const raw = fs.readFileSync(tmpFile, 'utf8');
103
+ const { jobs, invalid } = shapeJobs(raw, tmpFile);
104
+
105
+ expect(jobs.map((j) => j.slug)).toEqual(['1023-healthy']);
106
+ expect(invalid.map((i) => i.slug)).toEqual(['1021-social-signals', '1022-social-signals']);
107
+ expect(invalid[0].issues).toMatch(/status/);
108
+ expect(invalid[0].file).toBe(tmpFile);
109
+
110
+ expect(errorSpy).toHaveBeenCalledTimes(2);
111
+ const firstCallArgs = errorSpy.mock.calls[0][0];
112
+ expect(firstCallArgs).toContain(tmpFile);
113
+ expect(firstCallArgs).toContain('1021-social-signals');
114
+ expect(firstCallArgs).toMatch(/status/);
115
+ });
116
+
117
+ test('a fully valid row round-trips through shapeJobs unchanged', () => {
118
+ const job = validJob({ slug: '1023-healthy' });
119
+ fs.writeFileSync(tmpFile, JSON.stringify({ jobs: [job] }));
120
+
121
+ const raw = fs.readFileSync(tmpFile, 'utf8');
122
+ const { jobs, invalid } = shapeJobs(raw, tmpFile);
123
+
124
+ expect(invalid).toEqual([]);
125
+ expect(jobs).toEqual([job]);
126
+ expect(errorSpy).not.toHaveBeenCalled();
127
+ });