claude-code-session-manager 0.65.0 → 0.67.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 (70) hide show
  1. package/dist/assets/AgentLibrary-CiqimwWl.js +3 -0
  2. package/dist/assets/{History-DB-9-zwc.js → History-DbzjII2Z.js} +2 -2
  3. package/dist/assets/{Hooks-DnxqMRrR.js → Hooks-C0PTplI_.js} +3 -3
  4. package/dist/assets/{HostBilko-Cw6JocV7.js → HostBilko-vGc1FYLD.js} +1 -1
  5. package/dist/assets/{Library-BYSB0dmY.js → Library-4tU4rg2h.js} +1 -1
  6. package/dist/assets/{ListDetail-BfKqnL0r.js → ListDetail-BGIvkCwZ.js} +1 -1
  7. package/dist/assets/{MarkdownEditor-Cs-l5JOv.js → MarkdownEditor-ChFdpCam.js} +1 -1
  8. package/dist/assets/{McpServers-bBqj3hGg.js → McpServers-DsRWWpU3.js} +2 -2
  9. package/dist/assets/{Memory-DzPGXT5J.js → Memory-DmxoxxxY.js} +6 -6
  10. package/dist/assets/{Panel-BX6UZ-W0.js → Panel-KC-rv3jS.js} +1 -1
  11. package/dist/assets/{Permissions-Db7S4T3Z.js → Permissions-DrDjCRFl.js} +3 -3
  12. package/dist/assets/{Plugins-CIuey9R3.js → Plugins-KexSQwk0.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-FPWNWc_-.js → ProvenanceBadge-Rg-jL94y.js} +1 -1
  14. package/dist/assets/SaveBar-BbEo8U2e.js +1 -0
  15. package/dist/assets/Scheduler-C4Ti9TCc.js +14 -0
  16. package/dist/assets/ScopeSwitcher-Di9BmYze.js +1 -0
  17. package/dist/assets/Settings-CxyhPuYQ.js +3 -0
  18. package/dist/assets/{SkillReferenceGraph-CprLcOCe.js → SkillReferenceGraph-0q26RHrn.js} +1 -1
  19. package/dist/assets/Skills-BXkVyCo5.js +3 -0
  20. package/dist/assets/SystemPrompt-Bwe3NTbi.js +1 -0
  21. package/dist/assets/{TagLibrary-q66s4N3i.js → TagLibrary-CnwEf6EI.js} +1 -1
  22. package/dist/assets/{TiptapBody-I22aArc2.js → TiptapBody-faCtLj9L.js} +1 -1
  23. package/dist/assets/{Toggle-D9sapSAv.js → Toggle-Bw7G_-RR.js} +1 -1
  24. package/dist/assets/{index-BRwaw_1W.js → index-B6dJ2CsU.js} +666 -665
  25. package/dist/assets/{index-LlWpj2VJ.css → index-CEnMgeQU.css} +1 -1
  26. package/dist/assets/{settingsSchema-CTc4qelV.js → settingsSchema-B5C9hZoS.js} +1 -1
  27. package/dist/index.html +2 -2
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/develop/SKILL.md +73 -15
  30. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +10 -0
  31. package/src/main/__tests__/agentLibrary.test.cjs +40 -0
  32. package/src/main/__tests__/develop-skill-failure-modes.test.cjs +70 -0
  33. package/src/main/__tests__/flatPrdTickSweep.test.cjs +110 -0
  34. package/src/main/__tests__/health-per-project-stall.test.cjs +82 -0
  35. package/src/main/__tests__/prdAdminRouteParity.test.cjs +68 -0
  36. package/src/main/__tests__/prdAdminRoutes.test.cjs +311 -0
  37. package/src/main/__tests__/prdCreate.test.cjs +7 -2
  38. package/src/main/__tests__/prdMigration.test.cjs +17 -0
  39. package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +91 -0
  40. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +109 -0
  41. package/src/main/__tests__/scheduleJobSchema.test.cjs +127 -0
  42. package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +65 -0
  43. package/src/main/__tests__/scheduleJobTransitions.test.cjs +152 -0
  44. package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +59 -0
  45. package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +203 -0
  46. package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +196 -0
  47. package/src/main/__tests__/scheduler-stall-per-project.test.cjs +108 -0
  48. package/src/main/agentLibrary.cjs +40 -2
  49. package/src/main/health.cjs +97 -2
  50. package/src/main/index.cjs +2 -0
  51. package/src/main/ipcSchemas.cjs +60 -0
  52. package/src/main/lib/localAdminHttp.cjs +10 -3
  53. package/src/main/lib/prdAdminRoutes.cjs +175 -0
  54. package/src/main/lib/prdCreate.cjs +37 -2
  55. package/src/main/lib/prdFrontmatter.cjs +179 -1
  56. package/src/main/lib/prdMigration.cjs +82 -5
  57. package/src/main/lib/queueStore.cjs +41 -7
  58. package/src/main/lib/scheduleJobSchema.cjs +114 -0
  59. package/src/main/lib/scheduleJobTransitions.cjs +164 -0
  60. package/src/main/lib/schedulerConfig.cjs +7 -0
  61. package/src/main/scheduler/prdParser.cjs +7 -0
  62. package/src/main/scheduler.cjs +769 -138
  63. package/src/preload/api.d.ts +54 -2
  64. package/src/preload/index.cjs +12 -0
  65. package/dist/assets/AgentLibrary-DYriNDGf.js +0 -1
  66. package/dist/assets/Scheduler-X5y252Qw.js +0 -14
  67. package/dist/assets/ScopeSwitcher-DU7M_q5-.js +0 -1
  68. package/dist/assets/Settings-DEptXcEY.js +0 -3
  69. package/dist/assets/Skills-CGN56X1i.js +0 -3
  70. package/dist/assets/SystemPrompt-DIEK7FpJ.js +0 -1
@@ -0,0 +1,108 @@
1
+ /**
2
+ * scheduler-stall-per-project.test.cjs — unit tests for computeStallSummary's
3
+ * per-project `stalled` verdict and findStaleQuarantinedJobs' age-based
4
+ * escalation.
5
+ *
6
+ * Covers the exact burrow-vs-others shape observed live: one project (burrow)
7
+ * holds only quarantined rows (0 running, 0 pending) while another project
8
+ * has running + pending work — the machine-wide `stalled` boolean reads
9
+ * false (masking burrow), but burrow's own byProject entry must read true.
10
+ *
11
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-stall-per-project.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const { test } = require('node:test');
17
+ const assert = require('node:assert/strict');
18
+ const { computeStallSummary, findStaleQuarantinedJobs, QUARANTINE_ESCALATE_MS } = require('../scheduler.cjs');
19
+
20
+ test('computeStallSummary: burrow fully stalled (quarantined only) while another project is busy — machine-wide false, burrow true', () => {
21
+ const state = {
22
+ paused: null,
23
+ jobs: [
24
+ { slug: '821-a', cwd: '/home/bilko/Projects/burrow', status: 'quarantined' },
25
+ { slug: '822-b', cwd: '/home/bilko/Projects/burrow', status: 'quarantined' },
26
+ { slug: '823-c', cwd: '/home/bilko/Projects/burrow', status: 'quarantined' },
27
+ { slug: '824-d', cwd: '/home/bilko/Projects/burrow', status: 'quarantined' },
28
+ { slug: '900-e', cwd: '/home/bilko/Projects/other', status: 'running' },
29
+ { slug: '901-f', cwd: '/home/bilko/Projects/other', status: 'pending' },
30
+ ],
31
+ invalidJobs: [],
32
+ };
33
+ const summary = computeStallSummary(state);
34
+ assert.strictEqual(summary.stalled, false, 'machine-wide must NOT read stalled — other project is busy');
35
+ assert.strictEqual(summary.byProject['/home/bilko/Projects/burrow'].stalled, true, 'burrow must read stalled on its own');
36
+ assert.strictEqual(summary.byProject['/home/bilko/Projects/other'].stalled, false);
37
+ });
38
+
39
+ test('computeStallSummary: both projects busy — neither stalled', () => {
40
+ const state = {
41
+ paused: null,
42
+ jobs: [
43
+ { slug: 'a', cwd: '/p1', status: 'running' },
44
+ { slug: 'b', cwd: '/p2', status: 'pending' },
45
+ ],
46
+ invalidJobs: [],
47
+ };
48
+ const summary = computeStallSummary(state);
49
+ assert.strictEqual(summary.stalled, false);
50
+ assert.strictEqual(summary.byProject['/p1'].stalled, false);
51
+ assert.strictEqual(summary.byProject['/p2'].stalled, false);
52
+ });
53
+
54
+ test('computeStallSummary: both projects stalled — machine-wide AND both per-project read true', () => {
55
+ const state = {
56
+ paused: null,
57
+ jobs: [
58
+ { slug: 'a', cwd: '/p1', status: 'failed' },
59
+ { slug: 'b', cwd: '/p2', status: 'needs_review' },
60
+ ],
61
+ invalidJobs: [],
62
+ };
63
+ const summary = computeStallSummary(state);
64
+ assert.strictEqual(summary.stalled, true);
65
+ assert.strictEqual(summary.byProject['/p1'].stalled, true);
66
+ assert.strictEqual(summary.byProject['/p2'].stalled, true);
67
+ });
68
+
69
+ test('computeStallSummary: paused scheduler never reads stalled, machine-wide or per-project', () => {
70
+ const state = {
71
+ paused: { reason: 'rate_limit' },
72
+ jobs: [{ slug: 'a', cwd: '/p1', status: 'quarantined' }],
73
+ invalidJobs: [],
74
+ };
75
+ const summary = computeStallSummary(state);
76
+ assert.strictEqual(summary.stalled, false);
77
+ assert.strictEqual(summary.byProject['/p1'].stalled, false);
78
+ });
79
+
80
+ test('findStaleQuarantinedJobs: fires past threshold, not before', () => {
81
+ const now = Date.parse('2026-08-08T12:00:00.000Z');
82
+ const jobs = [
83
+ {
84
+ slug: 'fresh',
85
+ cwd: '/p1',
86
+ status: 'quarantined',
87
+ statusHistory: [{ to: 'quarantined', at: new Date(now - 1 * 60 * 60_000).toISOString() }],
88
+ },
89
+ {
90
+ slug: 'stale',
91
+ cwd: '/p1',
92
+ status: 'quarantined',
93
+ statusHistory: [{ to: 'quarantined', at: new Date(now - 25 * 60 * 60_000).toISOString() }],
94
+ },
95
+ {
96
+ slug: 'exactly-at-threshold',
97
+ cwd: '/p1',
98
+ status: 'quarantined',
99
+ statusHistory: [{ to: 'quarantined', at: new Date(now - QUARANTINE_ESCALATE_MS).toISOString() }],
100
+ },
101
+ { slug: 'no-history', cwd: '/p1', status: 'quarantined' },
102
+ { slug: 'not-quarantined', cwd: '/p1', status: 'pending' },
103
+ ];
104
+ const stale = findStaleQuarantinedJobs(jobs, now, QUARANTINE_ESCALATE_MS);
105
+ const slugs = stale.map((s) => s.slug).sort();
106
+ assert.deepStrictEqual(slugs, ['exactly-at-threshold', 'stale']);
107
+ assert.strictEqual(stale.find((s) => s.slug === 'stale').cwd, '/p1');
108
+ });
@@ -34,6 +34,25 @@ function parseTools(raw) {
34
34
  /** Filename-safe persona name: lowercase, hyphenated, matches the `.md` files on disk. */
35
35
  const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
36
36
 
37
+ /** Sentinel `projects:` entry meaning "this agent's Action button shows in every project". */
38
+ const ALL_PROJECTS = '*';
39
+
40
+ /**
41
+ * Frontmatter values are single-line (see lib/prdFrontmatter.cjs) but an
42
+ * Action's opening instruction is genuinely multi-line prose (the builder
43
+ * action is `/builder\n\n<instruction>`). Encode newlines as a literal `\n`
44
+ * two-char sequence on the way out, decode on the way in — one place, so the
45
+ * round-trip is structural rather than something each caller remembers.
46
+ */
47
+ function encodeLine(s) {
48
+ return String(s).replace(/\\/g, '\\\\').replace(/\r?\n/g, '\\n');
49
+ }
50
+
51
+ function decodeLine(s) {
52
+ if (!s) return '';
53
+ return String(s).replace(/\\([\\n])/g, (_m, c) => (c === 'n' ? '\n' : '\\'));
54
+ }
55
+
37
56
  /**
38
57
  * Serializes a persona's frontmatter + body back to `.md` file text.
39
58
  * `tags` is a Claude-Code-agnostic extension of the frontmatter (Claude Code
@@ -41,14 +60,25 @@ const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
41
60
  * this persona is associated with, so Agent Library and Tag Library can each
42
61
  * assign/remove the relationship from either side. Stored the same
43
62
  * comma-list way as `tools` for one parser to cover both.
63
+ *
64
+ * `projects` / `action` / `actionLabel` are the same kind of extension, for
65
+ * the Sessions workspace's per-project **Action buttons**: `projects` is the
66
+ * list of project cwds (or the `*` sentinel = every project) whose Sessions
67
+ * toolbar shows a one-click button for this agent, `action` is the opening
68
+ * instruction that button sends into the new session, and `actionLabel`
69
+ * overrides the button's caption (defaults to the persona name). Claude Code
70
+ * ignores all three; only this app reads them.
44
71
  */
45
- function serializePersona({ name, description, tools, model, color, tags, title, body }) {
72
+ function serializePersona({ name, description, tools, model, color, tags, projects, action, actionLabel, title, body }) {
46
73
  const lines = ['---', `name: ${name}`];
47
74
  if (description) lines.push(`description: ${description}`);
48
75
  if (tools && tools.length) lines.push(`tools: ${tools.join(', ')}`);
49
76
  if (model && model !== 'inherit') lines.push(`model: ${model}`);
50
77
  if (color) lines.push(`color: ${color}`);
51
78
  if (tags && tags.length) lines.push(`tags: ${tags.join(', ')}`);
79
+ if (projects && projects.length) lines.push(`projects: ${projects.join(', ')}`);
80
+ if (action) lines.push(`action: ${encodeLine(action)}`);
81
+ if (actionLabel) lines.push(`actionLabel: ${encodeLine(actionLabel)}`);
52
82
  if (title) lines.push(`title: ${title}`);
53
83
  lines.push('---', '');
54
84
  return lines.join('\n') + (body || '').trim() + '\n';
@@ -67,6 +97,9 @@ async function savePersona({
67
97
  model,
68
98
  color,
69
99
  tags,
100
+ projects,
101
+ action,
102
+ actionLabel,
70
103
  title,
71
104
  body,
72
105
  globalDir = path.join(os.homedir(), '.claude', 'agents'),
@@ -77,7 +110,7 @@ async function savePersona({
77
110
  throw new Error('agent name must be lowercase, hyphenated (e.g. "my-agent")');
78
111
  }
79
112
  const target = validatePath(path.join(globalDir, `${name}.md`));
80
- const text = serializePersona({ name, description, tools, model, color, tags, title, body });
113
+ const text = serializePersona({ name, description, tools, model, color, tags, projects, action, actionLabel, title, body });
81
114
  await writeTextAtomic(target, text);
82
115
  if (originalName && originalName !== name) {
83
116
  const oldReal = validatePath(path.join(globalDir, `${originalName}.md`));
@@ -203,6 +236,9 @@ async function listPersonas({
203
236
  model: fm.model || null,
204
237
  color: fm.color || null,
205
238
  tags: parseTools(fm.tags),
239
+ projects: parseTools(fm.projects),
240
+ action: decodeLine(fm.action) || null,
241
+ actionLabel: decodeLine(fm.actionLabel) || null,
206
242
  title: fm.title || null,
207
243
  path: real,
208
244
  body: body.trim(),
@@ -220,5 +256,7 @@ module.exports = {
220
256
  savePersona,
221
257
  deletePersona,
222
258
  removeOverride,
259
+ serializePersona,
223
260
  PERSONA_NAME_RE,
261
+ ALL_PROJECTS,
224
262
  };
@@ -14,6 +14,7 @@ const { checkPersonaImports } = require('./lib/personaImportHealth.cjs');
14
14
  const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
15
15
  const { migratePrds } = require('./lib/prdMigration.cjs');
16
16
  const queueStore = require('./lib/queueStore.cjs');
17
+ const { computeStallSummary } = require('./scheduler.cjs');
17
18
  const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
18
19
 
19
20
  const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
@@ -131,6 +132,55 @@ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
131
132
  };
132
133
  }
133
134
 
135
+ // computeProjectProblemCounts(jobs) → { [cwd]: { failed, needs_review, quarantined } }
136
+ //
137
+ // health.cjs's machine-wide `failed` count answered "is the machine stuck",
138
+ // never "is any ONE project stuck" — a single project with 4 quarantined
139
+ // PRDs and nothing else running was invisible in a rollup dominated by other
140
+ // projects' healthy jobs. Breaks down every non-terminal-problem status
141
+ // (failed/needs_review/quarantined — deliberately NOT 'completed'/'running'/
142
+ // 'pending'/'investigating', which are not problems) by project cwd.
143
+ function computeProjectProblemCounts(jobs) {
144
+ const byProject = {};
145
+ for (const j of jobs || []) {
146
+ if (j.status !== 'failed' && j.status !== 'needs_review' && j.status !== 'quarantined') continue;
147
+ const cwd = j.cwd || '(unknown)';
148
+ byProject[cwd] = byProject[cwd] || { failed: 0, needs_review: 0, quarantined: 0 };
149
+ byProject[cwd][j.status] += 1;
150
+ }
151
+ return byProject;
152
+ }
153
+
154
+ // evaluatePerProjectStall(stallSummary, lastRunAtIso, now, thresholdMs) →
155
+ // { [cwd]: { stalled, pastThreshold?, ageMs?, caveat? } }
156
+ //
157
+ // computeStallSummary's per-project `stalled` flag (scheduler.cjs) is a
158
+ // point-in-time verdict with no duration attached — a project can flip
159
+ // stalled/unstalled within a single tick as work completes, so flagging RED
160
+ // the instant it's true would false-trip on ordinary queue churn. There is
161
+ // no per-project lastRunAt persisted (only a machine-wide one), so this
162
+ // reuses that machine-wide timestamp as the best available "has the
163
+ // scheduler ticked recently at all" signal, gated per-project by whether
164
+ // THAT project currently holds stalled work.
165
+ function evaluatePerProjectStall(stallSummary, lastRunAtIso, now, thresholdMs) {
166
+ const lastRunAt = lastRunAtIso ? Date.parse(lastRunAtIso) : null;
167
+ const results = {};
168
+ for (const cwd of Object.keys(stallSummary?.byProject || {})) {
169
+ const counts = stallSummary.byProject[cwd];
170
+ if (!counts.stalled) {
171
+ results[cwd] = { stalled: false };
172
+ continue;
173
+ }
174
+ if (lastRunAt == null || Number.isNaN(lastRunAt)) {
175
+ results[cwd] = { stalled: true, pastThreshold: false, caveat: 'no-lastRunAt' };
176
+ continue;
177
+ }
178
+ const ageMs = now - lastRunAt;
179
+ results[cwd] = { stalled: true, pastThreshold: ageMs >= thresholdMs, ageMs };
180
+ }
181
+ return results;
182
+ }
183
+
134
184
  // Pure evaluator over migratePrds()'s { moved, skipped, unresolved } result —
135
185
  // kept separate from the fs-touching check() call site so it's directly
136
186
  // unit-testable, matching evaluateTickLiveness's pattern.
@@ -222,18 +272,42 @@ async function check() {
222
272
  const failedCount = Object.values(queueState.jobs || {}).filter(
223
273
  (j) => j.status === 'failed'
224
274
  ).length;
275
+ const needsReviewCount = Object.values(queueState.jobs || {}).filter(
276
+ (j) => j.status === 'needs_review'
277
+ ).length;
278
+ const quarantinedCount = Object.values(queueState.jobs || {}).filter(
279
+ (j) => j.status === 'quarantined'
280
+ ).length;
225
281
  const heartbeatPath = path.join(
226
282
  os.homedir(),
227
283
  '.claude/session-manager/scheduler-heartbeat.log'
228
284
  );
229
285
  const heartbeat = readFreshHeartbeat(heartbeatPath);
230
286
  const liveness = evaluateTickLiveness(queueState, heartbeat, Date.now(), runningCount);
287
+
288
+ // Per-project rollup (PRD: monitoring must not collapse per-project
289
+ // reality into one machine-wide boolean — see computeStallSummary /
290
+ // computeProjectProblemCounts headers). A project holding ONLY
291
+ // failed/needs_review/quarantined rows (0 running, 0 pending) never
292
+ // trips evaluateTickLiveness above, since that check requires actual
293
+ // pending work — this is what let the burrow project go dark.
294
+ const stallSummary = computeStallSummary(queueState);
295
+ const now = Date.now();
296
+ const perProjectStall = evaluatePerProjectStall(stallSummary, queueState.lastRunAt, now, TICK_STALL_THRESHOLD_MS);
297
+ const projectsPastThreshold = Object.entries(perProjectStall)
298
+ .filter(([, v]) => v.pastThreshold)
299
+ .map(([cwd]) => cwd);
300
+
231
301
  status.components.scheduler_queue = {
232
- ok: !liveness.stalled,
302
+ ok: !liveness.stalled && projectsPastThreshold.length === 0,
233
303
  path: queuePath,
234
304
  jobs: Object.keys(queueState.jobs || {}).length,
235
305
  running: runningCount,
236
306
  failed: failedCount,
307
+ needsReview: needsReviewCount,
308
+ quarantined: quarantinedCount,
309
+ byProject: computeProjectProblemCounts(queueState.jobs),
310
+ perProjectStall,
237
311
  tickLiveness: liveness.reason,
238
312
  };
239
313
  if (liveness.stalled) {
@@ -249,6 +323,18 @@ async function check() {
249
323
  ? `Tick hasn't advanced in a while but scheduler-heartbeat.log is missing/stale, so current billing utilization can't be checked — cannot rule out a legitimate when-available hold`
250
324
  : 'No lastRunAt recorded yet — cannot assess tick liveness';
251
325
  }
326
+ if (projectsPastThreshold.length > 0) {
327
+ status.components.scheduler_queue.stalledProjects = projectsPastThreshold;
328
+ for (const cwd of projectsPastThreshold) {
329
+ const ageMin = Math.round(perProjectStall[cwd].ageMs / 60_000);
330
+ const counts = status.components.scheduler_queue.byProject[cwd] || {};
331
+ status.issues.push(
332
+ `Project fully stalled: ${cwd} — 0 running, 0 pending, only problem jobs `
333
+ + `(failed=${counts.failed ?? 0} needs_review=${counts.needs_review ?? 0} quarantined=${counts.quarantined ?? 0}), `
334
+ + `no scheduler tick in ~${ageMin}m (threshold ${Math.round(TICK_STALL_THRESHOLD_MS / 60_000)}m)`
335
+ );
336
+ }
337
+ }
252
338
  } catch (e) {
253
339
  if (e.code !== 'ENOENT') {
254
340
  status.issues.push(`Scheduler queue unreadable: ${e.message}`);
@@ -422,4 +508,13 @@ if (require.main === module) {
422
508
  })();
423
509
  }
424
510
 
425
- module.exports = { check, evaluateTickLiveness, readFreshHeartbeat, evaluatePrdMigrationHealth, TICK_STALL_THRESHOLD_MS, HEARTBEAT_STALE_MS };
511
+ module.exports = {
512
+ check,
513
+ evaluateTickLiveness,
514
+ readFreshHeartbeat,
515
+ evaluatePrdMigrationHealth,
516
+ computeProjectProblemCounts,
517
+ evaluatePerProjectStall,
518
+ TICK_STALL_THRESHOLD_MS,
519
+ HEARTBEAT_STALE_MS,
520
+ };
@@ -26,6 +26,7 @@ const voiceWizard = require('./voiceWizard.cjs');
26
26
  const scheduler = require('./scheduler.cjs');
27
27
  const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
28
28
  const prdCreate = require('./lib/prdCreate.cjs');
29
+ const prdAdminRoutes = require('./lib/prdAdminRoutes.cjs');
29
30
  const { appendAuditEvent } = require('./lib/auditLog.cjs');
30
31
  const chatRunner = require('./chatRunner.cjs');
31
32
  const promptSessionEvents = require('./promptSessionEvents.cjs');
@@ -37,6 +38,7 @@ const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
37
38
  const adminHttp = createAdminHttp();
38
39
  scheduler.registerAdminRoutes(adminHttp);
39
40
  prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
41
+ prdAdminRoutes.registerAdminRoute(adminHttp, scheduler.remote);
40
42
  chatRunner.registerAdminRoute(adminHttp);
41
43
  const supervisor = require('./supervisor.cjs');
42
44
  const watchers = require('./watchers.cjs');
@@ -327,6 +327,62 @@ const scheduleRetagPrd = z.object({
327
327
  items: z.array(scheduleRetagItem).min(1).max(500),
328
328
  });
329
329
 
330
+ // ──────────────────────────────────────────── Admin API — PRD lifecycle (PRD 1024)
331
+ //
332
+ // GET routes parse their input from URLSearchParams (all values arrive as
333
+ // strings), so numeric/array fields aren't accepted here — only slug/cwd/
334
+ // epicId/status, matching what a query string can actually carry.
335
+ const adminListPrdsQuery = z.object({
336
+ cwd: z.string().min(1).max(4096).optional(),
337
+ epicId: z.string().min(1).max(200).optional(),
338
+ status: z.string().min(1).max(40).optional(),
339
+ });
340
+
341
+ const adminGetPrdQuery = z.object({
342
+ slug: z.string().regex(SCHEDULE_SLUG_RE),
343
+ cwd: z.string().min(1).max(4096).optional(),
344
+ });
345
+
346
+ // Partial frontmatter patch for update-prd — same recognized-key set as
347
+ // prdFrontmatter.cjs's parsePrdFile/serializePrdFile round-trip pair. Every
348
+ // field optional; the route refuses an empty patch+no-body request via the
349
+ // top-level .refine below.
350
+ const adminPrdFrontmatterPatch = z.object({
351
+ title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
352
+ cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
353
+ estimateMinutes: z.number().int().min(1).max(100000).optional(),
354
+ parallelGroup: z.number().int().min(0).max(999999).optional(),
355
+ sourcePromptId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
356
+ sourceTabId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
357
+ // 'discussion' deliberately excluded: matches prdFrontmatter.cjs's
358
+ // parsePrdFile, which never parses that value for a PRD's tag key (a
359
+ // discussion-tagged Epic never reaches PRD authoring).
360
+ tag: z.enum(['feature', 'bug', 'build']).optional(),
361
+ // Provenance stamp (PRD-authoring lockdown). Free-text rather than an enum
362
+ // — 'scheduler-api' (create-time) and 'legacy-adopted' (migration/manual
363
+ // adopt) are the two values this codebase writes today, but the field
364
+ // itself is descriptive, not a closed set.
365
+ createdVia: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
366
+ issuedAt: z.string().min(1).max(64).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
367
+ });
368
+
369
+ const adminUpdatePrd = z.object({
370
+ slug: z.string().regex(SCHEDULE_SLUG_RE),
371
+ cwd: z.string().min(1).max(4096).optional(),
372
+ frontmatter: adminPrdFrontmatterPatch.optional(),
373
+ body: z.string().refine(
374
+ (s) => Buffer.byteLength(s, 'utf8') <= PRD_WRITE_MAX_BYTES,
375
+ `body must be ≤ ${PRD_WRITE_MAX_BYTES} bytes`,
376
+ ).optional(),
377
+ }).refine(
378
+ (it) => (it.frontmatter && Object.keys(it.frontmatter).length > 0) || it.body !== undefined,
379
+ 'at least one of frontmatter or body is required',
380
+ );
381
+
382
+ const adminCancelJob = z.object({
383
+ slug: z.string().regex(SCHEDULE_SLUG_RE),
384
+ });
385
+
330
386
  // ──────────────────────────────────────────── Projects
331
387
  const ENCODED_SLUG_RE = /^[A-Za-z0-9._-]+$/;
332
388
 
@@ -812,6 +868,10 @@ module.exports = {
812
868
  schedulerCreatePrd,
813
869
  scheduleArchivePrd,
814
870
  scheduleRetagPrd,
871
+ adminListPrdsQuery,
872
+ adminGetPrdQuery,
873
+ adminUpdatePrd,
874
+ adminCancelJob,
815
875
  setConfigSchema,
816
876
  setSessionSlotsSchema,
817
877
  shellOpen,
@@ -151,17 +151,24 @@ function createAdminHttp() {
151
151
  sendJson(res, 401, { ok: false, error: 'unauthorized' });
152
152
  return;
153
153
  }
154
- if (req.method === 'GET' && req.url === HEALTH_PATH) {
154
+ // Route lookup is keyed by pathname only — req.url carries the raw query
155
+ // string too, which would otherwise never match a registered route (every
156
+ // route before PRD 1024 was query-free, so this was a latent gap, not a
157
+ // behavior change for them). `query` is handed to the route handler as a
158
+ // third argument (URLSearchParams) for routes that need it (e.g. GET
159
+ // /admin/scheduler/prds?cwd=&epicId=&status=).
160
+ const urlObj = new URL(req.url, 'http://127.0.0.1');
161
+ if (req.method === 'GET' && urlObj.pathname === HEALTH_PATH) {
155
162
  sendJson(res, 200, { ok: true });
156
163
  return;
157
164
  }
158
165
  try {
159
- const handler = routes.get(`${req.method} ${req.url}`);
166
+ const handler = routes.get(`${req.method} ${urlObj.pathname}`);
160
167
  if (!handler) {
161
168
  sendJson(res, 404, { ok: false, error: 'not found' });
162
169
  return;
163
170
  }
164
- await handler(req, res);
171
+ await handler(req, res, urlObj.searchParams);
165
172
  } catch (e) {
166
173
  sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
167
174
  }
@@ -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 };