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,175 @@
1
+ /**
2
+ * prdAdminRoutes.cjs — admin HTTP routes closing the PRD-lifecycle capability
3
+ * gap (PRD 1024, step 1 of locking the PRD entity behind the scheduler
4
+ * service boundary — see that PRD's Goal). Before this file, the admin API
5
+ * exposed only create-prd/jobs/reset-job/send-prompt: there was no way to
6
+ * READ, LIST, EDIT, ARCHIVE, or CANCEL a PRD/job over HTTP, which is exactly
7
+ * why direct filesystem access to session-manager-operations/scheduler/ was
8
+ * still sanctioned. This file plumbs those operations to HTTP; it adds no
9
+ * enforcement/denial of direct access — that's the dependent PRD's job (see
10
+ * this PRD's "Out of scope").
11
+ *
12
+ * Every route here reuses an existing verified implementation rather than
13
+ * reimplementing it:
14
+ * - list/get/update/cancel delegate to scheduler.cjs's `remote` object
15
+ * (job-status-aware; remote already owns the PRD-dir search + symlink
16
+ * defense patterns these need)
17
+ * - archive/retag delegate to queueOps.cjs's archiveMany/retagMany
18
+ * directly (job-status-agnostic bulk file ops; already used by the
19
+ * renderer's schedule:archive-prd/schedule:retag-prd IPC handlers)
20
+ *
21
+ * Registered against the same injected localAdminHttp.cjs transport as
22
+ * scheduler.cjs's registerAdminRoutes and prdCreate.cjs's registerAdminRoute
23
+ * (see index.cjs) — auth, loopback-only binding, and the bearer-token
24
+ * lifecycle are all the transport's job, not this file's.
25
+ */
26
+ 'use strict';
27
+
28
+ const { schemas } = require('../ipcSchemas.cjs');
29
+ const { readBody, sendJson } = require('./localAdminHttp.cjs');
30
+ const { appendAuditEvent } = require('./auditLog.cjs');
31
+ const queueOps = require('../queueOps.cjs');
32
+
33
+ function parseJsonBody(raw) {
34
+ try {
35
+ return { ok: true, value: raw ? JSON.parse(raw) : {} };
36
+ } catch {
37
+ return { ok: false };
38
+ }
39
+ }
40
+
41
+ /**
42
+ * registerAdminRoute(adminHttp, remote) — `remote` is scheduler.cjs's
43
+ * `remote` object, injected (not required directly) so this file stays
44
+ * testable without booting Electron, matching prdCreate.cjs's and
45
+ * scheduler.cjs's own registerAdminRoute(s) pattern.
46
+ */
47
+ function registerAdminRoute(adminHttp, remote) {
48
+ adminHttp.registerRoute('GET', '/admin/scheduler/prds', async (req, res, query) => {
49
+ let input;
50
+ try {
51
+ input = schemas.adminListPrdsQuery.parse(Object.fromEntries(query ?? []));
52
+ } catch (e) {
53
+ sendJson(res, 400, { ok: false, error: 'invalid query', details: e?.issues ?? e?.message });
54
+ return;
55
+ }
56
+ const prds = await remote.listPrds(input);
57
+ sendJson(res, 200, { ok: true, prds });
58
+ });
59
+
60
+ adminHttp.registerRoute('GET', '/admin/scheduler/prd', async (req, res, query) => {
61
+ let input;
62
+ try {
63
+ input = schemas.adminGetPrdQuery.parse(Object.fromEntries(query ?? []));
64
+ } catch (e) {
65
+ sendJson(res, 400, { ok: false, error: 'invalid query', details: e?.issues ?? e?.message });
66
+ return;
67
+ }
68
+ const result = await remote.getPrdParsed(input.slug, input.cwd);
69
+ if (!result.ok) {
70
+ sendJson(res, 404, result);
71
+ return;
72
+ }
73
+ sendJson(res, 200, result);
74
+ });
75
+
76
+ adminHttp.registerRoute('POST', '/admin/scheduler/update-prd', async (req, res) => {
77
+ const raw = await readBody(req);
78
+ const parsedBody = parseJsonBody(raw);
79
+ if (!parsedBody.ok) {
80
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
81
+ return;
82
+ }
83
+ let input;
84
+ try {
85
+ input = schemas.adminUpdatePrd.parse(parsedBody.value);
86
+ } catch (e) {
87
+ sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
88
+ return;
89
+ }
90
+ const result = await remote.updatePrd(input);
91
+ if (!result.ok) {
92
+ const status = /not found/i.test(result.error ?? '') ? 404 : /status is/i.test(result.error ?? '') ? 409 : 400;
93
+ sendJson(res, status, result);
94
+ return;
95
+ }
96
+ appendAuditEvent('admin_prd_update', { route: '/admin/scheduler/update-prd', slug: input.slug, cwd: input.cwd ?? null });
97
+ sendJson(res, 200, result);
98
+ });
99
+
100
+ adminHttp.registerRoute('POST', '/admin/scheduler/archive-prd', async (req, res) => {
101
+ const raw = await readBody(req);
102
+ const parsedBody = parseJsonBody(raw);
103
+ if (!parsedBody.ok) {
104
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
105
+ return;
106
+ }
107
+ let input;
108
+ try {
109
+ input = schemas.scheduleArchivePrd.parse(parsedBody.value);
110
+ } catch (e) {
111
+ sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
112
+ return;
113
+ }
114
+ const result = await queueOps.archiveMany(input.slugs);
115
+ // Audit only the slugs that actually archived — archiveMany's `results`
116
+ // array carries the real per-slug outcome; logging every requested slug
117
+ // regardless of whether it succeeded would misrepresent a partial-failure
118
+ // batch as a clean one in audit-log.jsonl.
119
+ for (const r of result.results ?? []) {
120
+ if (!r.ok) continue;
121
+ appendAuditEvent('admin_prd_archive', { route: '/admin/scheduler/archive-prd', slug: r.slug, cwd: null });
122
+ }
123
+ sendJson(res, result.ok ? 200 : 500, result);
124
+ });
125
+
126
+ adminHttp.registerRoute('POST', '/admin/scheduler/cancel-job', async (req, res) => {
127
+ const raw = await readBody(req);
128
+ const parsedBody = parseJsonBody(raw);
129
+ if (!parsedBody.ok) {
130
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
131
+ return;
132
+ }
133
+ let input;
134
+ try {
135
+ input = schemas.adminCancelJob.parse(parsedBody.value);
136
+ } catch (e) {
137
+ sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
138
+ return;
139
+ }
140
+ const result = await remote.cancelJob(input.slug);
141
+ if (!result.ok) {
142
+ const status = /not found/i.test(result.error ?? '') ? 404 : 409;
143
+ sendJson(res, status, result);
144
+ return;
145
+ }
146
+ appendAuditEvent('admin_job_cancel', { route: '/admin/scheduler/cancel-job', slug: input.slug, cwd: result.cwd ?? null });
147
+ sendJson(res, 200, result);
148
+ });
149
+
150
+ adminHttp.registerRoute('POST', '/admin/scheduler/retag-prd', async (req, res) => {
151
+ const raw = await readBody(req);
152
+ const parsedBody = parseJsonBody(raw);
153
+ if (!parsedBody.ok) {
154
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
155
+ return;
156
+ }
157
+ let input;
158
+ try {
159
+ input = schemas.scheduleRetagPrd.parse(parsedBody.value);
160
+ } catch (e) {
161
+ sendJson(res, 400, { ok: false, error: 'invalid payload', details: e?.issues ?? e?.message });
162
+ return;
163
+ }
164
+ const result = await queueOps.retagMany(input.items);
165
+ // Audit only the items that actually retagged (see the archive-prd route
166
+ // above for why this must read result.results rather than the request).
167
+ for (const r of result.results ?? []) {
168
+ if (!r.ok) continue;
169
+ appendAuditEvent('admin_prd_retag', { route: '/admin/scheduler/retag-prd', slug: r.newSlug ?? r.slug, cwd: null });
170
+ }
171
+ sendJson(res, result.ok ? 200 : 500, result);
172
+ });
173
+ }
174
+
175
+ module.exports = { registerAdminRoute };
@@ -57,6 +57,15 @@ function buildPrdBody(input) {
57
57
  // filename prefix is the single source of truth for grouping; adding a
58
58
  // second one here would let the two drift out of sync.
59
59
  const fmLines = ['---', `title: ${title}`, `cwd: ${cwd}`, `estimateMinutes: ${estimateMinutes}`];
60
+ // Provenance stamp (PRD-authoring lockdown): every PRD written through this
61
+ // function — the sole in-process implementation behind both the admin
62
+ // HTTP create-prd route and the renderer's chat:create-prd IPC handler —
63
+ // carries proof it was authored via the API, not a bare Write/Edit tool
64
+ // call. scheduler.cjs's reconcile() refuses to run a discovered PRD with no
65
+ // `createdVia` (quarantines it instead); this line is why every
66
+ // API-created PRD is exempt. Stamped here, not left to a caller, so the
67
+ // guarantee can't be forgotten by a future route.
68
+ fmLines.push('createdVia: scheduler-api', `issuedAt: ${new Date().toISOString()}`);
60
69
  // Optional, additive: traces this PRD back to the PromptTicket (PRD 748)
61
70
  // that was classified 'develop' and spawned it (PRD 749).
62
71
  if (sourcePromptId) fmLines.push(`sourcePromptId: ${sourcePromptId}`);
@@ -204,7 +213,24 @@ async function createPrd(input, remote) {
204
213
  sourceTabId: input.sourceTabId ?? null,
205
214
  });
206
215
 
207
- return { ok: true, nn, filename: `${filenameSlug}.md` };
216
+ // No `status` field here, and never one named 'queued' — that string was
217
+ // handed straight to callers as if it were a real ScheduleJob status, and
218
+ // is exactly how the 1021/1022 incident's invalid `"status": "queued"`
219
+ // rows got onto disk (this route's own response was the most likely
220
+ // source). A PRD is a queue row's SOURCE, not the row itself: writePrd
221
+ // only ever joins an Epic and writes the .md file — the queue row is
222
+ // created separately, by the next reconcile() pass. `enqueued: false` is
223
+ // true by construction (this function never touches queue.json), so no
224
+ // caller can copy a fake job status out of this response again.
225
+ return {
226
+ ok: true,
227
+ nn,
228
+ filename: `${filenameSlug}.md`,
229
+ prdPath: writeResult.path ?? null,
230
+ epicId: writeResult.epicId ?? null,
231
+ enqueued: false,
232
+ note: 'PRD file written; the queue row is derived by the next scheduler reconcile pass, not created here',
233
+ };
208
234
  }
209
235
 
210
236
  /**
@@ -240,7 +266,16 @@ function registerAdminRoute(adminHttp, remote) {
240
266
  return;
241
267
  }
242
268
 
243
- sendJson(res, 200, { nn: result.nn, filename: result.filename, status: 'queued' });
269
+ // Mirror createPrd()'s own honest shape verbatim — never re-add a
270
+ // 'status' field here. See createPrd's return above for why.
271
+ sendJson(res, 200, {
272
+ nn: result.nn,
273
+ filename: result.filename,
274
+ prdPath: result.prdPath ?? null,
275
+ epicId: result.epicId ?? null,
276
+ enqueued: false,
277
+ note: result.note,
278
+ });
244
279
  });
245
280
  }
246
281
 
@@ -48,4 +48,182 @@ function splitFrontmatter(raw) {
48
48
  };
49
49
  }
50
50
 
51
- module.exports = { splitFrontmatter };
51
+ /**
52
+ * parsePrdFile / serializePrdFile — CJS port of
53
+ * src/renderer/lib/prdFrontmatter.ts's round-trip parse+serialize pair, so
54
+ * main-process callers (the admin update-prd route, PRD 1024) get the same
55
+ * "unrecognized keys round-trip verbatim via `extras`, recognized keys emit
56
+ * in a stable order, unchanged values re-emit their original line verbatim"
57
+ * guarantee the renderer's PRD editor already relies on — an edit through
58
+ * either surface never reorders or drops a key it didn't touch. Recognized
59
+ * key set intentionally mirrors the TS module exactly (title, cwd,
60
+ * estimateMinutes, parallelGroup, sourcePromptId, sourceTabId, tag,
61
+ * createdVia, issuedAt); every other frontmatter key (e.g. dependsOn)
62
+ * round-trips via `extras`, same as the renderer's editor today.
63
+ *
64
+ * `createdVia`/`issuedAt` (PRD provenance-lockdown) are recognized here so
65
+ * the update-prd route (the only sanctioned way to stamp a PRD's provenance
66
+ * after creation — see prdCreate.cjs's buildPrdBody for the create-time
67
+ * stamp) round-trips them like any other known key rather than shunting them
68
+ * into `extras`.
69
+ */
70
+
71
+ const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'createdVia', 'issuedAt']);
72
+ const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'createdVia', 'issuedAt'];
73
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
74
+
75
+ function indentOf(line) {
76
+ let n = 0;
77
+ for (const ch of line) {
78
+ if (ch === ' ') n++;
79
+ else if (ch === '\t') n += 2;
80
+ else break;
81
+ }
82
+ return n;
83
+ }
84
+
85
+ function parseScalar(raw) {
86
+ const v = raw.trim();
87
+ if (v === '' || v === '~' || v === 'null') return null;
88
+ if (v === 'true') return true;
89
+ if (v === 'false') return false;
90
+ if (/^-?\d+$/.test(v)) return Number(v);
91
+ if (/^-?\d+\.\d+$/.test(v)) return Number(v);
92
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
93
+ return v.slice(1, -1);
94
+ }
95
+ return v;
96
+ }
97
+
98
+ function applyKey(fm, key, after) {
99
+ const v = parseScalar(after);
100
+ if (v === null) return;
101
+ switch (key) {
102
+ case 'estimateMinutes':
103
+ fm.estimateMinutes = typeof v === 'number' ? v : Number(v);
104
+ return;
105
+ case 'parallelGroup':
106
+ fm.parallelGroup = typeof v === 'number' ? v : Number(v);
107
+ return;
108
+ case 'title':
109
+ fm.title = String(v);
110
+ return;
111
+ case 'cwd':
112
+ fm.cwd = String(v);
113
+ return;
114
+ case 'sourcePromptId':
115
+ fm.sourcePromptId = String(v);
116
+ return;
117
+ case 'sourceTabId':
118
+ fm.sourceTabId = String(v);
119
+ return;
120
+ case 'tag':
121
+ // Matches the TS source's applyKey exactly: a 'discussion'-tagged
122
+ // ticket never reaches PRD authoring, so that value is never parsed
123
+ // here even if present in a hand-edited file — see this file's header
124
+ // comment.
125
+ if (v === 'feature' || v === 'bug' || v === 'build') fm.tag = v;
126
+ return;
127
+ case 'createdVia':
128
+ fm.createdVia = String(v);
129
+ return;
130
+ case 'issuedAt':
131
+ fm.issuedAt = String(v);
132
+ return;
133
+ }
134
+ }
135
+
136
+ /** Parse a full PRD file into { frontmatter, body }, same shape as the TS module. */
137
+ function parsePrdFile(text) {
138
+ const m = text.match(FRONTMATTER_RE);
139
+ if (!m) return { frontmatter: {}, body: text };
140
+ const fmText = m[1];
141
+ const body = m[2] ?? '';
142
+
143
+ const lines = fmText.split(/\r?\n/);
144
+ const fm = {};
145
+ const extras = {};
146
+
147
+ for (let i = 0; i < lines.length;) {
148
+ const line = lines[i];
149
+ if (!line.trim() || line.trimStart().startsWith('#')) { i++; continue; }
150
+ const ind = indentOf(line);
151
+ if (ind !== 0) { i++; continue; }
152
+ const colon = line.indexOf(':');
153
+ if (colon < 0) { i++; continue; }
154
+ const key = line.slice(0, colon).trim();
155
+ const after = line.slice(colon + 1);
156
+ let j = i + 1;
157
+ const band = [line];
158
+ while (j < lines.length) {
159
+ const nxt = lines[j];
160
+ if (nxt.trim() === '' || nxt.trimStart().startsWith('#')) { band.push(nxt); j++; continue; }
161
+ if (indentOf(nxt) === 0 && nxt.indexOf(':') >= 0) break;
162
+ band.push(nxt);
163
+ j++;
164
+ }
165
+
166
+ if (RECOGNIZED_KEYS.has(key)) {
167
+ applyKey(fm, key, after);
168
+ const parsed = parseScalar(after);
169
+ if (parsed !== null && (typeof parsed === 'string' || typeof parsed === 'number')) {
170
+ if (!fm._raw) fm._raw = {};
171
+ fm._raw[key] = { line, parsed };
172
+ }
173
+ } else {
174
+ extras[key] = { lines: band };
175
+ }
176
+ i = j;
177
+ }
178
+
179
+ if (Object.keys(extras).length) fm.extras = extras;
180
+ return { frontmatter: fm, body };
181
+ }
182
+
183
+ function serializeScalar(v) {
184
+ if (v === null || v === undefined) return '';
185
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
186
+ const s = String(v);
187
+ const needsQuote =
188
+ s === '' ||
189
+ /^(true|false|null|~)$/.test(s) ||
190
+ /^-?\d+(\.\d+)?$/.test(s) ||
191
+ /^\s|\s$/.test(s) ||
192
+ s.startsWith("'") ||
193
+ s.startsWith('"') ||
194
+ s.includes('\n');
195
+ if (!needsQuote) return s;
196
+ if (s.includes("'")) return `"${s.replace(/"/g, '\\"')}"`;
197
+ return `'${s}'`;
198
+ }
199
+
200
+ /** Serialize { frontmatter, body } back to a full file. Byte-identical round-trip when unchanged. */
201
+ function serializePrdFile(fm, body) {
202
+ const out = [];
203
+ for (const key of EMIT_ORDER) {
204
+ const v = fm[key];
205
+ if (v === undefined || v === null || v === '') continue;
206
+ const cached = fm._raw?.[key];
207
+ if (cached && cached.parsed === v) {
208
+ out.push(cached.line);
209
+ continue;
210
+ }
211
+ if (typeof v === 'number') {
212
+ out.push(`${key}: ${v}`);
213
+ continue;
214
+ }
215
+ out.push(`${key}: ${serializeScalar(v)}`);
216
+ }
217
+ if (fm.extras) {
218
+ for (const k of Object.keys(fm.extras)) {
219
+ const band = fm.extras[k];
220
+ if (!band || !band.lines.length) continue;
221
+ out.push(band.lines.join('\n'));
222
+ }
223
+ }
224
+
225
+ const fmText = out.join('\n');
226
+ return `---\n${fmText}\n---\n${body}`;
227
+ }
228
+
229
+ module.exports = { splitFrontmatter, parsePrdFile, serializePrdFile, RECOGNIZED_KEYS };
@@ -16,8 +16,8 @@
16
16
  const fs = require('node:fs');
17
17
  const fsp = require('node:fs/promises');
18
18
  const path = require('node:path');
19
- const { splitFrontmatter } = require('./prdFrontmatter.cjs');
20
- const { resolvePrdWriteDir } = require('./prdLocations.cjs');
19
+ const { splitFrontmatter, parsePrdFile, serializePrdFile } = require('./prdFrontmatter.cjs');
20
+ const { resolvePrdWriteDir, resolvePrdsDirs } = require('./prdLocations.cjs');
21
21
  const { projectQueuePath } = require('./queueStore.cjs');
22
22
  const { expandHome } = require('./expandHome.cjs');
23
23
 
@@ -114,7 +114,21 @@ async function migratePrds(legacyPrdsDir) {
114
114
  const LIVE_JOB_STATUSES = new Set(['pending', 'running', 'needs_review', 'investigating']);
115
115
 
116
116
  /**
117
- * Slugs with a live job in this project's own queue shard.
117
+ * Statuses that mean a job is genuinely finished and its source may be
118
+ * archived. Anything else — including an UNRECOGNIZED/invalid status string
119
+ * (e.g. the 2026-08-07 1021/1022 incident's `"status": "queued"`, which is
120
+ * outside ScheduleJobStatus entirely) — protects the file. reconcile() is
121
+ * what repairs a corrupted row back to `pending` (scheduleJobTransitions.cjs
122
+ * / the invalid-row-repair path); this consolidation must never race that
123
+ * repair by archiving the row's only source out from under it first. A slug
124
+ * with NO row at all (never queued, or already reaped from history) is not
125
+ * covered by this set and falls through to "archive" in liveSlugsForCwd.
126
+ */
127
+ const TERMINAL_ARCHIVABLE_STATUSES = new Set(['completed', 'failed']);
128
+
129
+ /**
130
+ * Slugs with a live (i.e. not safely archivable) job in this project's own
131
+ * queue shard.
118
132
  *
119
133
  * Returns null when liveness cannot be determined (unreadable/unparseable
120
134
  * queue.json) — the caller then FAILS CLOSED and archives nothing, since it
@@ -139,7 +153,7 @@ async function liveSlugsForCwd(cwd) {
139
153
  const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
140
154
  const live = new Set();
141
155
  for (const job of jobs) {
142
- if (job && typeof job.slug === 'string' && LIVE_JOB_STATUSES.has(job.status)) {
156
+ if (job && typeof job.slug === 'string' && !TERMINAL_ARCHIVABLE_STATUSES.has(job.status)) {
143
157
  live.add(job.slug);
144
158
  }
145
159
  }
@@ -199,4 +213,67 @@ async function consolidateFlatPrds(cwd, opts = {}) {
199
213
  return { moved, failed, skipped };
200
214
  }
201
215
 
202
- module.exports = { migratePrds, consolidateFlatPrds, LIVE_JOB_STATUSES };
216
+ /**
217
+ * legacyAdoptExistingPrds() — one-time (per file), idempotent rollout
218
+ * migration for the PRD-authoring-lockdown feature: every PRD source .md
219
+ * already on disk when this ships has no `createdVia` frontmatter (that
220
+ * field didn't exist yet), and reconcile()'s new provenance gate would
221
+ * otherwise quarantine every single one of them the very first boot after
222
+ * this lands. Runs at every boot (see scheduler.cjs's runPrdMigration,
223
+ * BEFORE reconcile() ever gets a chance to see these files) and stamps
224
+ * `createdVia: legacy-adopted` + `issuedAt: <this run's timestamp>` on any
225
+ * `.md` under a project's PRD dirs (flat + every Epic's, live only — never
226
+ * the retired prds-archived/ dirs, since an archived PRD isn't reconciled
227
+ * again and stamping it would just be needless churn) that doesn't already
228
+ * carry a `createdVia` value.
229
+ *
230
+ * Idempotent by construction: a file already stamped (by this migration on
231
+ * a prior boot, or by prdCreate.cjs at create time) is skipped outright, so
232
+ * a repeat run across every subsequent boot is a cheap scan-and-skip.
233
+ * Failures are per-file and non-fatal — one unreadable/unwritable PRD must
234
+ * never abort the sweep for every other project.
235
+ */
236
+ async function legacyAdoptExistingPrds() {
237
+ let dirs;
238
+ try {
239
+ dirs = resolvePrdsDirs();
240
+ } catch (e) {
241
+ return { stamped: 0, failed: [{ file: '(resolvePrdsDirs)', reason: e?.message ?? 'failed to enumerate PRD dirs' }] };
242
+ }
243
+
244
+ let stamped = 0;
245
+ const failed = [];
246
+ const issuedAt = new Date().toISOString();
247
+
248
+ for (const dir of dirs) {
249
+ let entries;
250
+ try {
251
+ entries = await fsp.readdir(dir);
252
+ } catch {
253
+ continue;
254
+ }
255
+ for (const name of entries) {
256
+ if (!name.endsWith('.md') || name.startsWith('.')) continue;
257
+ const filePath = path.join(dir, name);
258
+ try {
259
+ const raw = await fsp.readFile(filePath, 'utf8');
260
+ const { frontmatter: fm, body } = parsePrdFile(raw);
261
+ if (fm.createdVia) continue; // already stamped — nothing to do
262
+ fm.createdVia = 'legacy-adopted';
263
+ fm.issuedAt = issuedAt;
264
+ const newRaw = serializePrdFile(fm, body);
265
+ const tmp = `${filePath}.tmp-${process.pid}`;
266
+ await fsp.writeFile(tmp, newRaw, 'utf8');
267
+ await fsp.rename(tmp, filePath);
268
+ stamped += 1;
269
+ } catch (e) {
270
+ if (e?.code === 'ENOENT') continue; // raced with a concurrent mover/archiver
271
+ failed.push({ file: filePath, reason: e?.message ?? 'stamp failed' });
272
+ }
273
+ }
274
+ }
275
+
276
+ return { stamped, failed };
277
+ }
278
+
279
+ module.exports = { migratePrds, consolidateFlatPrds, legacyAdoptExistingPrds, LIVE_JOB_STATUSES };
@@ -34,6 +34,7 @@ const path = require('node:path');
34
34
  const os = require('node:os');
35
35
  const { allProjectCwds, activeProjectCwds } = require('../../../scripts/lib/activeSessions.cjs');
36
36
  const { assertOpsWrite } = require('./opsOwnership.cjs');
37
+ const { ScheduleJobSchema } = require('./scheduleJobSchema.cjs');
37
38
 
38
39
  const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduler-machine.json');
39
40
  const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
@@ -110,9 +111,37 @@ function shapeMachine(raw) {
110
111
  };
111
112
  }
112
113
 
113
- function shapeJobs(raw) {
114
+ /**
115
+ * shapeJobs(raw, file) → { jobs, invalid }.
116
+ *
117
+ * Every row is validated against ScheduleJobSchema (see that module's header
118
+ * for why: a row with e.g. `status: 'queued'` — a value outside
119
+ * `ScheduleJobStatus` — silently vanished from every picker, which is the
120
+ * exact 2026-08-07 incident this validation exists to catch). A row that
121
+ * fails is quarantined into `invalid` (never dropped silently, never passed
122
+ * through as-is) and logged once per slug at error level naming the file,
123
+ * the slug, and the failing field. One bad row must not affect the others —
124
+ * this never throws.
125
+ */
126
+ function shapeJobs(raw, file) {
114
127
  const data = JSON.parse(raw);
115
- return Array.isArray(data.jobs) ? data.jobs : [];
128
+ const rows = Array.isArray(data.jobs) ? data.jobs : [];
129
+ const jobs = [];
130
+ const invalid = [];
131
+ for (const row of rows) {
132
+ const result = ScheduleJobSchema.safeParse(row);
133
+ if (result.success) {
134
+ jobs.push(result.data);
135
+ continue;
136
+ }
137
+ const issues = result.error.issues
138
+ .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
139
+ .join('; ');
140
+ const slug = typeof row?.slug === 'string' ? row.slug : '(unknown slug)';
141
+ console.error(`[queueStore] quarantined invalid job row (file=${file || '(unknown)'}, slug=${slug}): ${issues}`);
142
+ invalid.push({ slug, file: file || null, issues, row });
143
+ }
144
+ return { jobs, invalid };
116
145
  }
117
146
 
118
147
  /**
@@ -125,7 +154,7 @@ function shapeJobs(raw) {
125
154
  * consulted so writeSplit can persist "this project now has zero jobs".
126
155
  */
127
156
  function readMergedSync(opts) {
128
- const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
157
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, invalidJobs: [] };
129
158
  const sourceCwds = [];
130
159
  try {
131
160
  Object.assign(out, shapeMachine(fs.readFileSync(MACHINE_STATE_PATH, 'utf8')));
@@ -138,7 +167,9 @@ function readMergedSync(opts) {
138
167
  for (const cwd of stateCwds(opts)) {
139
168
  const file = projectQueuePath(cwd);
140
169
  try {
141
- out.jobs.push(...shapeJobs(fs.readFileSync(file, 'utf8')));
170
+ const { jobs, invalid } = shapeJobs(fs.readFileSync(file, 'utf8'), file);
171
+ out.jobs.push(...jobs);
172
+ out.invalidJobs.push(...invalid);
142
173
  sourceCwds.push(cwd);
143
174
  } catch (e) {
144
175
  if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
@@ -152,7 +183,7 @@ function readMergedSync(opts) {
152
183
 
153
184
  /** Async twin of readMergedSync for IPC hot paths. */
154
185
  async function readMerged(opts) {
155
- const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null };
186
+ const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, invalidJobs: [] };
156
187
  const sourceCwds = [];
157
188
  try {
158
189
  Object.assign(out, shapeMachine(await fsp.readFile(MACHINE_STATE_PATH, 'utf8')));
@@ -165,7 +196,9 @@ async function readMerged(opts) {
165
196
  for (const cwd of stateCwds(opts)) {
166
197
  const file = projectQueuePath(cwd);
167
198
  try {
168
- out.jobs.push(...shapeJobs(await fsp.readFile(file, 'utf8')));
199
+ const { jobs, invalid } = shapeJobs(await fsp.readFile(file, 'utf8'), file);
200
+ out.jobs.push(...jobs);
201
+ out.invalidJobs.push(...invalid);
169
202
  sourceCwds.push(cwd);
170
203
  } catch (e) {
171
204
  if (e?.code === 'ENOENT') { sourceCwds.push(cwd); continue; }
@@ -267,7 +300,7 @@ async function migrateLegacyGlobalQueue(defaultCwd) {
267
300
  for (const [cwd, jobs] of byCwd) {
268
301
  const file = projectQueuePath(cwd);
269
302
  let existing = [];
270
- try { existing = shapeJobs(await fsp.readFile(file, 'utf8')); } catch { /* fresh shard */ }
303
+ try { existing = shapeJobs(await fsp.readFile(file, 'utf8'), file).jobs; } catch { /* fresh shard */ }
271
304
  const have = new Set(existing.map((j) => j.slug));
272
305
  const merged = [...existing, ...jobs.filter((j) => !have.has(j.slug))];
273
306
  try {
@@ -293,6 +326,7 @@ module.exports = {
293
326
  projectHistoryPath,
294
327
  stateCwds,
295
328
  bustCwdCache,
329
+ shapeJobs,
296
330
  readMerged,
297
331
  readMergedSync,
298
332
  writeSplit,