claude-code-session-manager 0.64.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 (63) hide show
  1. package/dist/assets/AgentLibrary-Bzg89D5Y.js +3 -0
  2. package/dist/assets/{History-Dp3-_bjQ.js → History-CNH9vA0A.js} +2 -2
  3. package/dist/assets/{Hooks-DMQFgGDd.js → Hooks-CyTksPza.js} +3 -3
  4. package/dist/assets/{HostBilko-zNU1NtBh.js → HostBilko-DXQVDNHn.js} +1 -1
  5. package/dist/assets/{Library-BrODBbeL.js → Library-9E2UOIsm.js} +1 -1
  6. package/dist/assets/{ListDetail-C5vbA1gC.js → ListDetail-CQWU_Yn5.js} +1 -1
  7. package/dist/assets/{MarkdownEditor-CpzAEiiG.js → MarkdownEditor-DQgfpSef.js} +1 -1
  8. package/dist/assets/{McpServers-DWuw-flj.js → McpServers-r3qwDIj2.js} +2 -2
  9. package/dist/assets/{Memory-B4TC9SsJ.js → Memory-BaxOpj-3.js} +6 -6
  10. package/dist/assets/{Panel-Bap7G-pI.js → Panel-BhFD8Lqo.js} +1 -1
  11. package/dist/assets/{Permissions-BIpNWItH.js → Permissions-Cj-mODQQ.js} +3 -3
  12. package/dist/assets/{Plugins-DrwklmW4.js → Plugins-DEL3Fqng.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-CCp_EhjV.js → ProvenanceBadge-CIAg6-JQ.js} +1 -1
  14. package/dist/assets/Scheduler-YOuZKkES.js +14 -0
  15. package/dist/assets/{ScopeSwitcher-BlQvpyLE.js → ScopeSwitcher-DZ_3gEus.js} +1 -1
  16. package/dist/assets/{Settings-BzvmZvGa.js → Settings-B0x4oflz.js} +3 -3
  17. package/dist/assets/{SkillReferenceGraph-CGtwmCjm.js → SkillReferenceGraph-CoIwsol8.js} +1 -1
  18. package/dist/assets/{Skills-Cauvbtk7.js → Skills-CN8R6AWn.js} +2 -2
  19. package/dist/assets/{SystemPrompt-BqzM76PV.js → SystemPrompt-DceChpAi.js} +1 -1
  20. package/dist/assets/{TagLibrary-DVKPjeRu.js → TagLibrary-BKmz2W7B.js} +1 -1
  21. package/dist/assets/{TiptapBody-CUWdBAYf.js → TiptapBody-W7n5SwPM.js} +1 -1
  22. package/dist/assets/{Toggle-Bos67yDJ.js → Toggle-QuxlVHGI.js} +1 -1
  23. package/dist/assets/{index-LlWpj2VJ.css → index-14dBLqE_.css} +1 -1
  24. package/dist/assets/{index-BfPVknuV.js → index-TejhHSzN.js} +526 -525
  25. package/dist/assets/{settingsSchema-DKuyaEK5.js → settingsSchema-DNqx6BKJ.js} +1 -1
  26. package/dist/index.html +2 -2
  27. package/package.json +5 -1
  28. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +120 -0
  29. package/plugins/session-manager-dev/skills/builder/SKILL.md +13 -3
  30. package/plugins/session-manager-dev/skills/develop/SKILL.md +41 -13
  31. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +10 -0
  32. package/src/main/__tests__/agentLibrary.test.cjs +40 -0
  33. package/src/main/__tests__/flatPrdTickSweep.test.cjs +110 -0
  34. package/src/main/__tests__/prdAdminRouteParity.test.cjs +68 -0
  35. package/src/main/__tests__/prdAdminRoutes.test.cjs +311 -0
  36. package/src/main/__tests__/prdCreate.test.cjs +7 -2
  37. package/src/main/__tests__/prdMigration.test.cjs +17 -0
  38. package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +91 -0
  39. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +109 -0
  40. package/src/main/__tests__/scheduleJobSchema.test.cjs +127 -0
  41. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +65 -0
  42. package/src/main/__tests__/scheduleJobTransitions.test.cjs +152 -0
  43. package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +59 -0
  44. package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +203 -0
  45. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +196 -0
  46. package/src/main/agentLibrary.cjs +40 -2
  47. package/src/main/index.cjs +2 -0
  48. package/src/main/ipcSchemas.cjs +60 -0
  49. package/src/main/lib/localAdminHttp.cjs +10 -3
  50. package/src/main/lib/prdAdminRoutes.cjs +175 -0
  51. package/src/main/lib/prdCreate.cjs +37 -2
  52. package/src/main/lib/prdFrontmatter.cjs +179 -1
  53. package/src/main/lib/prdMigration.cjs +82 -5
  54. package/src/main/lib/queueStore.cjs +41 -7
  55. package/src/main/lib/scheduleJobSchema.cjs +114 -0
  56. package/src/main/lib/scheduleJobTransitions.cjs +164 -0
  57. package/src/main/scheduler/prdParser.cjs +7 -0
  58. package/src/main/scheduler.cjs +649 -137
  59. package/src/preload/api.d.ts +54 -2
  60. package/src/preload/index.cjs +12 -0
  61. package/dist/assets/AgentLibrary-9KJ4fedH.js +0 -1
  62. package/dist/assets/Scheduler-DfoTegGG.js +0 -14
  63. /package/plugins/session-manager-dev/skills/builder/{4-report → 5-report}/SKILL.md +0 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * flatPrdTickSweep.test.cjs — the retired flat `scheduler/prds/` dir must be
3
+ * TRULY inert, not just documented as such.
4
+ *
5
+ * Prior to this PRD, `runPrdMigration()`'s consolidation of the flat dir into
6
+ * `prds-archived/` ran ONLY at Electron boot (src/main/scheduler.cjs). But
7
+ * `prdLocations.cjs`'s `resolvePrdsDirs()` unconditionally keeps scanning the
8
+ * flat dir as a live PRD source, and `reconcile()` runs on every tick — so a
9
+ * PRD hand-written into the flat dir *after* boot sat there live and WOULD
10
+ * get reconciled into a pending job and executed (observed 2026-08-07:
11
+ * 1021/1022 PRDs in social-signals-trader executed once their status was
12
+ * repaired), contradicting the develop skill's documented claim that
13
+ * anything landed there is "auto-consolidated ... WITHOUT being executed."
14
+ *
15
+ * The fix: `consolidateAllFlatPrds()` now also runs at the START of every
16
+ * `reconcile()` call itself (not just one caller like tickQueue's poll — every
17
+ * caller of reconcile gets the guarantee), BEFORE reconcile scans the flat
18
+ * dir. A freshly hand-written PRD has no queue row yet (reconcile hasn't
19
+ * seen it), so it is never in LIVE_JOB_STATUSES — this sweep archives it
20
+ * before reconcile ever gets a chance to turn it into a job. This test
21
+ * proves that invariant directly against `consolidateAllFlatPrds`: a file
22
+ * with no matching queue job is swept into `prds-archived/` on the very next
23
+ * call, which prdLocations.cjs's `resolvePrdsDirs()` never scans as a live
24
+ * source — so it can never be reconciled into a job afterward.
25
+ *
26
+ * Run: timeout 120 npx vitest run src/main/__tests__/flatPrdTickSweep.test.cjs
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ import { test, expect, afterEach } from 'vitest';
32
+ const fs = require('node:fs');
33
+ const fsp = require('node:fs/promises');
34
+ const os = require('node:os');
35
+ const path = require('node:path');
36
+ const { consolidateAllFlatPrds } = require('../scheduler.cjs');
37
+ const { resolvePrdWriteDir, resolvePrdsDirs } = require('../lib/prdLocations.cjs');
38
+
39
+ const tmpDirs = [];
40
+ afterEach(async () => {
41
+ while (tmpDirs.length) {
42
+ const d = tmpDirs.pop();
43
+ await fsp.rm(d, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ function newProjectRoot() {
48
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-flat-tick-sweep-'));
49
+ tmpDirs.push(d);
50
+ return d;
51
+ }
52
+
53
+ test('a PRD hand-written into the retired flat dir after boot is swept before it could ever be reconciled', async () => {
54
+ const cwd = newProjectRoot();
55
+ const flatDir = resolvePrdWriteDir(cwd);
56
+ await fsp.mkdir(flatDir, { recursive: true });
57
+ const filename = '999-hand-written-bypass.md';
58
+ await fsp.writeFile(
59
+ path.join(flatDir, filename),
60
+ `---\ntitle: Hand-written bypass\ncwd: ${cwd}\nestimateMinutes: 10\n---\n\n# Goal\n\nSomething.\n`,
61
+ 'utf8',
62
+ );
63
+ // No queue.json at all yet — this is exactly the state of a PRD dropped on
64
+ // disk moments ago, before any reconcile() pass has scanned it into a job.
65
+
66
+ await consolidateAllFlatPrds([cwd]);
67
+
68
+ // Swept out of the live flat dir...
69
+ expect(fs.existsSync(path.join(flatDir, filename))).toBe(false);
70
+ // ...and into prds-archived/, which resolvePrdsDirs() never returns as a
71
+ // scan source — so no future reconcile() pass can find it and queue it.
72
+ const archivedPath = path.join(path.dirname(flatDir), 'prds-archived', filename);
73
+ expect(fs.existsSync(archivedPath)).toBe(true);
74
+
75
+ const liveDirs = resolvePrdsDirs(Infinity, { projectsDir: path.join(cwd, '.nonexistent') });
76
+ expect(liveDirs.every((d) => !d.includes('prds-archived'))).toBe(true);
77
+ });
78
+
79
+ test('consolidateAllFlatPrds still protects a PRD that already has a live queue job (PRD 992 invariant preserved)', async () => {
80
+ const cwd = newProjectRoot();
81
+ const flatDir = resolvePrdWriteDir(cwd);
82
+ await fsp.mkdir(flatDir, { recursive: true });
83
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
84
+ await fsp.mkdir(stateDir, { recursive: true });
85
+ await fsp.writeFile(
86
+ path.join(flatDir, '998-live.md'),
87
+ `---\ntitle: Live\ncwd: ${cwd}\nestimateMinutes: 10\n---\n\n# Goal\n`,
88
+ 'utf8',
89
+ );
90
+ await fsp.writeFile(
91
+ path.join(stateDir, 'queue.json'),
92
+ JSON.stringify({ jobs: [{ slug: '998-live', status: 'running' }] }),
93
+ 'utf8',
94
+ );
95
+
96
+ await consolidateAllFlatPrds([cwd]);
97
+
98
+ expect(fs.existsSync(path.join(flatDir, '998-live.md'))).toBe(true);
99
+ });
100
+
101
+ test('consolidateAllFlatPrds never throws when a project errors — one bad cwd does not block the sweep of the rest', async () => {
102
+ const good = newProjectRoot();
103
+ const flatDir = resolvePrdWriteDir(good);
104
+ await fsp.mkdir(flatDir, { recursive: true });
105
+ await fsp.writeFile(path.join(flatDir, '997-ok.md'), `---\ntitle: Ok\ncwd: ${good}\nestimateMinutes: 5\n---\n\n# Goal\n`, 'utf8');
106
+
107
+ await expect(consolidateAllFlatPrds([null, good])).resolves.toBeUndefined();
108
+
109
+ expect(fs.existsSync(path.join(flatDir, '997-ok.md'))).toBe(false);
110
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * prdAdminRouteParity.test.cjs — capability-parity gate for PRD 1024 ("lock
3
+ * the PRD entity behind the scheduler service boundary, step 1").
4
+ *
5
+ * Enumerates every PRD/job mutation the codebase can perform (the full set
6
+ * of `schedule:*` IPC channels that read/write a PRD file or a queue job,
7
+ * plus create-prd and cancel-job which have no IPC twin) and asserts each
8
+ * one has a corresponding admin HTTP route registered by scheduler.cjs's
9
+ * registerAdminRoutes, prdCreate.cjs's registerAdminRoute, or
10
+ * prdAdminRoutes.cjs's registerAdminRoute. A future PRD-lifecycle capability
11
+ * added only as an IPC handler (filesystem-only from the admin API's point
12
+ * of view) fails this test until an admin route is added for it too — that
13
+ * is the whole point: the admin API must never fall behind what's actually
14
+ * possible against a PRD/job.
15
+ *
16
+ * Route registration is captured via a fake `adminHttp` stub (just
17
+ * `registerRoute`) — no real HTTP server or Electron boot needed, matching
18
+ * how prdCreate.test.cjs already verifies wiring.
19
+ *
20
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdAdminRouteParity.test.cjs
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ import { test, expect } from 'vitest';
26
+ const scheduler = require('../scheduler.cjs');
27
+ const prdCreate = require('../lib/prdCreate.cjs');
28
+ const prdAdminRoutes = require('../lib/prdAdminRoutes.cjs');
29
+
30
+ // Every PRD-lifecycle capability the codebase can perform, mapped to the
31
+ // admin HTTP route that must exist for it. `ipcChannel` is informational
32
+ // (documents the renderer-facing twin, if any) — the assertion is only
33
+ // against `adminRoute`.
34
+ const REQUIRED_CAPABILITIES = [
35
+ { capability: 'create a PRD', ipcChannel: null, adminRoute: 'POST /admin/scheduler/create-prd' },
36
+ { capability: 'list PRDs (live + archived)', ipcChannel: 'schedule:list-prds', adminRoute: 'GET /admin/scheduler/prds' },
37
+ { capability: 'read one PRD (body + frontmatter)', ipcChannel: 'schedule:read-prd', adminRoute: 'GET /admin/scheduler/prd' },
38
+ { capability: 'edit a not-yet-running PRD', ipcChannel: 'schedule:write-prd', adminRoute: 'POST /admin/scheduler/update-prd' },
39
+ { capability: 'archive a PRD', ipcChannel: 'schedule:archive-prd', adminRoute: 'POST /admin/scheduler/archive-prd' },
40
+ { capability: 'retag a PRD (parallelGroup/estimateMinutes)', ipcChannel: 'schedule:retag-prd', adminRoute: 'POST /admin/scheduler/retag-prd' },
41
+ { capability: 'list scheduler jobs', ipcChannel: null, adminRoute: 'GET /admin/scheduler/jobs' },
42
+ { capability: 'reset a stuck job', ipcChannel: 'schedule:reset-job', adminRoute: 'POST /admin/scheduler/reset-job' },
43
+ { capability: 'cancel a not-yet-terminal job', ipcChannel: null, adminRoute: 'POST /admin/scheduler/cancel-job' },
44
+ ];
45
+
46
+ function captureRegisteredRoutes() {
47
+ const registered = new Set();
48
+ const fakeAdminHttp = {
49
+ registerRoute(method, url) {
50
+ registered.add(`${method} ${url}`);
51
+ },
52
+ };
53
+ scheduler.registerAdminRoutes(fakeAdminHttp, scheduler.remote);
54
+ prdCreate.registerAdminRoute(fakeAdminHttp, scheduler.remote);
55
+ prdAdminRoutes.registerAdminRoute(fakeAdminHttp, scheduler.remote);
56
+ return registered;
57
+ }
58
+
59
+ test('every enumerated PRD/job mutation capability has a registered admin route', () => {
60
+ const registered = captureRegisteredRoutes();
61
+ const missing = REQUIRED_CAPABILITIES.filter((c) => !registered.has(c.adminRoute));
62
+ expect(missing).toEqual([]);
63
+ });
64
+
65
+ test.each(REQUIRED_CAPABILITIES)('capability "$capability" → $adminRoute is registered', ({ adminRoute }) => {
66
+ const registered = captureRegisteredRoutes();
67
+ expect(registered.has(adminRoute)).toBe(true);
68
+ });
@@ -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
+ });