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
@@ -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,
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Canonical runtime schema for a ScheduleJob record, mirroring the TS
3
+ * `ScheduleJob` interface at src/preload/api.d.ts:375-422 field-for-field.
4
+ *
5
+ * Modelled directly on promptSessionSchema.cjs's "zod schema asserted at the
6
+ * main-process boundary" pattern: the Epic entity has had this guarantee
7
+ * since epicMint.cjs:243, but the Job entity had none — `shapeJobs` in
8
+ * queueStore.cjs was `JSON.parse` + `Array.isArray` with zero field checks,
9
+ * which is how two PRDs sat invisible for 4+ hours on 2026-08-07 with
10
+ * `"status": "queued"`, a value outside `ScheduleJobStatus`, silently
11
+ * skipped by every picker that filters on `status === 'pending'` exactly.
12
+ *
13
+ * `JOB_STATUSES` is the single source of truth for the status enum. The
14
+ * renderer can't import this .cjs file directly, so
15
+ * src/preload/api.d.ts's `ScheduleJobStatus` union and the renderer's
16
+ * `JobStatus`/`FilterStatus` mirrors are kept in sync against this array by
17
+ * src/main/__tests__/scheduleJobSchema.test.cjs.
18
+ *
19
+ * Plain CJS, no Electron dependency, so it's requirable from queueStore.cjs
20
+ * (which itself must load outside the Electron app, e.g. from watchdog
21
+ * scripts).
22
+ */
23
+ 'use strict';
24
+
25
+ const { z } = require('zod');
26
+
27
+ const JOB_STATUSES = ['pending', 'running', 'investigating', 'completed', 'failed', 'needs_review', 'quarantined'];
28
+
29
+ const ScheduleJobStatusSchema = z.enum(JOB_STATUSES);
30
+
31
+ const ScheduleJobRuntimeSchema = z
32
+ .object({
33
+ pid: z.number().optional(),
34
+ runId: z.string().optional(),
35
+ startedAt: z.string().nullable().optional(),
36
+ sessionId: z.string().optional(),
37
+ cwd: z.string().optional(),
38
+ })
39
+ .passthrough();
40
+
41
+ // One entry per accepted status transition (scheduleJobTransitions.cjs).
42
+ // Bounded to STATUS_HISTORY_CAP entries there; this schema just validates
43
+ // shape, not length, since the cap is enforced at write time.
44
+ const ScheduleJobStatusHistoryEntrySchema = z
45
+ .object({
46
+ from: z.string().nullable(),
47
+ to: z.string(),
48
+ reason: z.string().nullable(),
49
+ source: z.string().nullable(),
50
+ at: z.string(),
51
+ })
52
+ .passthrough();
53
+
54
+ // Only `slug` and `status` are required. Every other ScheduleJob field
55
+ // (src/preload/api.d.ts:375-422) is declared here for documentation and type
56
+ // checking when present, but kept optional: a job row moves through several
57
+ // legitimate partial shapes across its life (freshly minted with nulls,
58
+ // mid-run with runtime fields, terminal with exitCode/error) and plenty of
59
+ // call sites — including this repo's own test fixtures — construct a row
60
+ // with only the fields relevant to what they're exercising. The 2026-08-07
61
+ // incident this schema exists to catch was an invalid `status` value, not a
62
+ // missing optional field, so `status` is where the strictness belongs.
63
+ // Unknown extra fields are preserved (.passthrough()) rather than stripped —
64
+ // this schema's job is to gate on shape/status validity, not to be the sole
65
+ // place new ScheduleJob fields get declared before they're usable.
66
+ const ScheduleJobSchema = z
67
+ .object({
68
+ slug: z.string(),
69
+ title: z.string().optional(),
70
+ cwd: z.string().nullable().optional(),
71
+ parallelGroup: z.number().optional(),
72
+ estimateMinutes: z.number().nullable().optional(),
73
+ bodyPreview: z.string().optional(),
74
+ status: ScheduleJobStatusSchema,
75
+ runId: z.string().nullable().optional(),
76
+ startedAt: z.string().nullable().optional(),
77
+ finishedAt: z.string().nullable().optional(),
78
+ exitCode: z.number().nullable().optional(),
79
+ error: z.string().nullable().optional(),
80
+ sessionId: z.string().optional(),
81
+ runtime: ScheduleJobRuntimeSchema.optional(),
82
+ verifierVerdict: z.string().optional(),
83
+ dependsOn: z.array(z.string()).optional(),
84
+ originSessionId: z.string().nullable().optional(),
85
+ sourceTabId: z.string().nullable().optional(),
86
+ sourcePromptId: z.string().nullable().optional(),
87
+ epicId: z.string().nullable().optional(),
88
+ statusHistory: z.array(ScheduleJobStatusHistoryEntrySchema).optional(),
89
+ })
90
+ .passthrough();
91
+
92
+ /**
93
+ * Throws a clear, descriptive error (not a raw ZodError dump) when `job`
94
+ * doesn't match ScheduleJobSchema. Mirrors assertValidPromptSession's shape.
95
+ */
96
+ function assertValidScheduleJob(job) {
97
+ const result = ScheduleJobSchema.safeParse(job);
98
+ if (!result.success) {
99
+ const issues = result.error.issues
100
+ .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
101
+ .join('; ');
102
+ throw new Error(`assertValidScheduleJob: invalid ScheduleJob shape — ${issues}`);
103
+ }
104
+ return result.data;
105
+ }
106
+
107
+ module.exports = {
108
+ ScheduleJobSchema,
109
+ ScheduleJobRuntimeSchema,
110
+ ScheduleJobStatusSchema,
111
+ ScheduleJobStatusHistoryEntrySchema,
112
+ JOB_STATUSES,
113
+ assertValidScheduleJob,
114
+ };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * scheduleJobTransitions.cjs — the ONE place a ScheduleJob's `status` field
3
+ * is assigned, mirroring epicMint.cjs's fail-closed ownership of the Epic's
4
+ * `proposed -> active` transition.
5
+ *
6
+ * Before this module, `j.status = '...'` was a bare mutation at 16 separate
7
+ * sites in scheduler.cjs — no legality table, no record of who changed a
8
+ * status or why. That is why the 1021/1022 stall (2026-08-07) was
9
+ * undiagnosable from the app: the only evidence was a heartbeat count, not a
10
+ * trace of what the job's status actually did over time.
11
+ *
12
+ * `transitionJob(job, toStatus, { reason, source })` is the chokepoint:
13
+ * - Looks up `job.status -> toStatus` in LEGAL_TRANSITIONS. An identity
14
+ * transition (status unchanged) is always accepted as a no-op — it never
15
+ * mutates statusHistory or writes an audit record, since nothing changed.
16
+ * - A legal transition mutates `job.status`, appends one bounded
17
+ * `statusHistory` entry, and appends one record to the existing
18
+ * ~/.claude/session-manager/audit-log.jsonl via auditLog.cjs (the same
19
+ * writer epicMint.cjs already uses for `epic_mint`/`epic_mint_refused` —
20
+ * deliberately not a second log file).
21
+ * - An illegal transition is refused: the job is left untouched, an
22
+ * error-level line is logged, a refusal counter increments, and a
23
+ * `job_transition_refused` audit record is written. It never throws —
24
+ * scheduler.cjs's tickQueue must keep running even if a call site asks
25
+ * for an illegal edge (same non-blocking posture as dodDrainHook.cjs).
26
+ *
27
+ * Plain Node module (no Electron deps), like scheduleJobSchema.cjs and
28
+ * epicMint.cjs, so it stays requirable from watchdog scripts outside
29
+ * Electron.
30
+ */
31
+ 'use strict';
32
+
33
+ const { appendAuditEvent } = require('./auditLog.cjs');
34
+
35
+ // Bounded so queue.json (mutation cost, broadcast payload, pickNextBatch
36
+ // scan) stays small — same rationale as lib/queueHistory.cjs's retention
37
+ // window.
38
+ const STATUS_HISTORY_CAP = 20;
39
+
40
+ /**
41
+ * Explicit from->to edges. Every real assignment site in scheduler.cjs maps
42
+ * onto one of these (verified against the 16 sites this module replaces):
43
+ * - pending->running (dispatch), pending->completed (archived-PRD skip,
44
+ * manual archive of an already-shipped PRD), pending->failed (admin
45
+ * cancelJob on a not-yet-started job)
46
+ * - running->completed|failed|needs_review (normal run outcomes, reaper),
47
+ * running->pending (halt/rate-limit reset, transient-failure retry)
48
+ * - investigating->failed|needs_review (restore prior status once the
49
+ * investigation probe exits), investigating->completed (defensive: the
50
+ * restored prior status could in principle be 'completed' if a caller
51
+ * ever marks investigating on a completed job), investigating->pending
52
+ * (admin reset can target a job mid-investigation)
53
+ * - failed->investigating (spawn a probe), failed->pending (admin reset /
54
+ * transient retry), failed->completed (auto-promote a fix-plan's healed
55
+ * original)
56
+ * - needs_review->investigating, needs_review->pending, needs_review->completed
57
+ * (heal on reverify, or auto-promote) — same shape as `failed`
58
+ * - completed->pending (force-only reset, gated separately by
59
+ * resetJobFields' own guard — this table only says the edge is
60
+ * structurally legal, not that every caller may take it unconditionally)
61
+ * - quarantined->pending (reconcile()'s adopt path, PRD-authoring lockdown:
62
+ * a PRD discovered with no `createdVia` provenance stamp is queued
63
+ * 'quarantined' instead of 'pending'; the ONLY way out is the PRD being
64
+ * stamped via the update-prd API — reconcile() detects the stamp on its
65
+ * next pass and promotes the row)
66
+ */
67
+ const LEGAL_TRANSITIONS = {
68
+ pending: ['running', 'completed', 'failed'],
69
+ running: ['completed', 'failed', 'needs_review', 'pending'],
70
+ investigating: ['failed', 'needs_review', 'completed', 'pending'],
71
+ failed: ['investigating', 'pending', 'completed'],
72
+ needs_review: ['investigating', 'pending', 'completed'],
73
+ completed: ['pending'],
74
+ quarantined: ['pending'],
75
+ };
76
+
77
+ let refusedTransitionCount = 0;
78
+
79
+ function isLegalTransition(from, to) {
80
+ if (from === to) return true;
81
+ const edges = LEGAL_TRANSITIONS[from];
82
+ return Array.isArray(edges) && edges.includes(to);
83
+ }
84
+
85
+ /**
86
+ * transitionJob(job, toStatus, { reason, source }) → boolean
87
+ *
88
+ * Mutates `job` in place on success. `reason` and `source` are free-text
89
+ * (why this transition is happening, and which call site requested it) —
90
+ * both are required in spirit (every call site in scheduler.cjs passes real
91
+ * strings) but not enforced here, since a missing reason/source is a lesser
92
+ * sin than a call this function refuses outright.
93
+ */
94
+ function transitionJob(job, toStatus, { reason, source, allowAnyFrom = false } = {}) {
95
+ if (!job || typeof job !== 'object') return false;
96
+ const from = job.status;
97
+
98
+ // `allowAnyFrom` is a narrow escape hatch for repairing a row whose
99
+ // persisted `status` was never a legal value to begin with (e.g. the
100
+ // 1021/1022 incident's `"status": "queued"`, quarantined by
101
+ // scheduleJobSchema.cjs and repaired by reconcile()'s invalid-row pass).
102
+ // That is a data repair, not a lifecycle transition — there is no legal
103
+ // predecessor to check `from` against — so it still goes through this
104
+ // chokepoint (mutation, statusHistory, audit) without consulting
105
+ // LEGAL_TRANSITIONS. Every other caller leaves this false.
106
+ if (!allowAnyFrom && !isLegalTransition(from, toStatus)) {
107
+ refusedTransitionCount += 1;
108
+ console.error(
109
+ `[scheduleJobTransitions] illegal transition refused: slug=${job.slug ?? '(unknown)'} `
110
+ + `from=${from ?? '(none)'} to=${toStatus ?? '(none)'} reason=${reason ?? '(none)'} source=${source ?? '(none)'}`,
111
+ );
112
+ appendAuditEvent('job_transition_refused', {
113
+ slug: job.slug ?? null,
114
+ from: from ?? null,
115
+ to: toStatus ?? null,
116
+ reason: reason ?? null,
117
+ source: source ?? null,
118
+ cwd: job.cwd ?? null,
119
+ });
120
+ return false;
121
+ }
122
+
123
+ if (from === toStatus) return true; // identity — nothing changed, nothing to record
124
+
125
+ job.status = toStatus;
126
+ const entry = {
127
+ from: from ?? null,
128
+ to: toStatus,
129
+ reason: reason ?? null,
130
+ source: source ?? null,
131
+ at: new Date().toISOString(),
132
+ };
133
+ const history = Array.isArray(job.statusHistory) ? job.statusHistory : [];
134
+ history.push(entry);
135
+ while (history.length > STATUS_HISTORY_CAP) history.shift();
136
+ job.statusHistory = history;
137
+
138
+ appendAuditEvent('job_transition', {
139
+ slug: job.slug ?? null,
140
+ from: from ?? null,
141
+ to: toStatus,
142
+ reason: reason ?? null,
143
+ source: source ?? null,
144
+ cwd: job.cwd ?? null,
145
+ });
146
+ return true;
147
+ }
148
+
149
+ function getRefusedTransitionCount() {
150
+ return refusedTransitionCount;
151
+ }
152
+
153
+ // Test-only: reset the module-level refusal counter between test cases.
154
+ function _resetRefusedTransitionCountForTests() {
155
+ refusedTransitionCount = 0;
156
+ }
157
+
158
+ module.exports = {
159
+ transitionJob,
160
+ LEGAL_TRANSITIONS,
161
+ STATUS_HISTORY_CAP,
162
+ getRefusedTransitionCount,
163
+ _resetRefusedTransitionCountForTests,
164
+ };
@@ -92,6 +92,13 @@ async function parsePrdRaw(filePath) {
92
92
  // queue row is completed (a slug with no row is treated as already
93
93
  // done/archived, matching retireCompletedSlugs semantics).
94
94
  dependsOn: parseDependsOn(fm.dependsOn),
95
+ // Provenance stamp (PRD-authoring lockdown): set only by prdCreate.cjs's
96
+ // buildPrdBody (create) or the update-prd route's legacy-adopt patch
97
+ // (migration/manual adopt). A PRD discovered on disk with no value here
98
+ // was never written through the sanctioned API path — reconcile()
99
+ // quarantines it instead of queuing it to run.
100
+ createdVia: fm.createdVia || null,
101
+ issuedAt: fm.issuedAt || null,
95
102
  body: body.trim(),
96
103
  };
97
104
  }