claude-code-session-manager 0.122.0 → 0.123.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 (93) hide show
  1. package/dist/assets/{AgentLibrary-BmJ8hIPw.js → AgentLibrary-DxWN3FSN.js} +2 -2
  2. package/dist/assets/{Button-BS4kmz4p.js → Button-CISLFSKb.js} +1 -1
  3. package/dist/assets/DataModel-CYPbhGrx.js +1 -0
  4. package/dist/assets/{History-pg00b2Gd.js → History-Fhouektw.js} +2 -2
  5. package/dist/assets/{Hooks-BTEs0UZY.js → Hooks-COfIcyYV.js} +3 -3
  6. package/dist/assets/{Library-DuaJ-VSn.js → Library-Bt5WOyyu.js} +7 -7
  7. package/dist/assets/ListDetail-DH7uzYd5.js +1 -0
  8. package/dist/assets/{McpServers-Dy-wnG_X.js → McpServers-CwyVEzMN.js} +2 -2
  9. package/dist/assets/{Memory-Bs1T1Jo7.js → Memory-k9TWT9N4.js} +8 -8
  10. package/dist/assets/{Panel-8UhLXEOW.js → Panel-Di8JqCYN.js} +1 -1
  11. package/dist/assets/{Permissions-BfwzvmBS.js → Permissions-FFs6Rq1H.js} +3 -3
  12. package/dist/assets/{Plugins-McjyvvKh.js → Plugins-uK5vl_RE.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-D0Ov_6WJ.js → ProvenanceBadge-CGDyGDoN.js} +1 -1
  14. package/dist/assets/{SaveBar-Cd6hJR6K.js → SaveBar-Cnwc_XfF.js} +1 -1
  15. package/dist/assets/{Scheduler-BZXKqYJP.js → Scheduler-aACa6_mC.js} +7 -7
  16. package/dist/assets/{ScopeSwitcher-C24ysV1K.js → ScopeSwitcher-CoG3CSlC.js} +1 -1
  17. package/dist/assets/Settings-BG0RM6Di.js +4 -0
  18. package/dist/assets/{SkillReferenceGraph-BsWuiTbw.js → SkillReferenceGraph-COffGu_A.js} +1 -1
  19. package/dist/assets/Skills--qQpFjjf.js +2 -0
  20. package/dist/assets/SystemPrompt-Cr9RRSiZ.js +1 -0
  21. package/dist/assets/TagLibrary-D_ZbXwBv.js +1 -0
  22. package/dist/assets/{TiptapBody-C4kIvIMH.js → TiptapBody-CCHsa8Jv.js} +1 -1
  23. package/dist/assets/{Toggle-pP19zlyk.js → Toggle-BUOj4pws.js} +1 -1
  24. package/dist/assets/{ViewTabs-CpzN8e5I.js → ViewTabs-BdpsL7yr.js} +1 -1
  25. package/dist/assets/{historyTotals-Caq78bb5.js → historyTotals-Dx6ca881.js} +1 -1
  26. package/dist/assets/{index-Dd7UQabV.css → index-BnKaYERU.css} +1 -1
  27. package/dist/assets/{index-Bjtguv6I.js → index-CkNGj8FA.js} +151 -151
  28. package/dist/assets/{settingsSchema-CC2ySu3d.js → settingsSchema-BZhkpr9e.js} +1 -1
  29. package/dist/assets/{skillFrontmatter-hDhNo8jE.js → skillFrontmatter-N13KGqbs.js} +1 -1
  30. package/dist/assets/{useAgentsDirWatch-BuOtY25U.js → useAgentsDirWatch-De9UsbDt.js} +1 -1
  31. package/dist/index.html +2 -2
  32. package/package.json +1 -1
  33. package/plugins/session-manager-dev/skills/develop/SKILL.md +29 -24
  34. package/scripts/scheduler-mcp-server.cjs +142 -78
  35. package/src/main/agentLibrary.cjs +8 -3
  36. package/src/main/build-info.json +4 -4
  37. package/src/main/crashDiagnostics.cjs +24 -0
  38. package/src/main/historyAggregator.cjs +41 -7
  39. package/src/main/index.cjs +13 -3
  40. package/src/main/ipcSchemas.cjs +30 -22
  41. package/src/main/lib/activeSessions.cjs +93 -32
  42. package/src/main/lib/epic/delegationReadiness.cjs +2 -0
  43. package/src/main/lib/epic/delegationSelfHeal.cjs +77 -0
  44. package/src/main/lib/epic/epicMint.cjs +1 -1
  45. package/src/main/lib/epic/epicStandingInstructions.cjs +73 -0
  46. package/src/main/lib/epic/guardSpawnSettings.cjs +76 -0
  47. package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +9 -2
  48. package/src/main/lib/feedback-remote/remoteClientSessions.cjs +3 -11
  49. package/src/main/lib/feedback-remote/remoteE2eCrypto.cjs +1 -1
  50. package/src/main/lib/git/gitWorktree.cjs +9 -4
  51. package/src/main/lib/git/worktreeDeps.cjs +13 -2
  52. package/src/main/lib/git/worktreeSetup.cjs +212 -0
  53. package/src/main/lib/mcpToolCatalog.cjs +36 -9
  54. package/src/main/lib/persona-macro/agentPersonaSchema.cjs +4 -0
  55. package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +23 -0
  56. package/src/main/lib/prd/prdAgentType.cjs +28 -0
  57. package/src/main/lib/prd/prdBodyPatch.cjs +138 -0
  58. package/src/main/lib/prd/prdCreate.cjs +245 -27
  59. package/src/main/lib/prd/prdGateFiles.cjs +31 -0
  60. package/src/main/lib/scheduler/definitionOfDone.cjs +24 -3
  61. package/src/main/lib/scheduler/queueStore.cjs +5 -4
  62. package/src/main/lib/scheduler/scheduleJobSchema.cjs +1 -0
  63. package/src/main/lib/scheduler/schedulerBatch.cjs +43 -5
  64. package/src/main/lib/scheduler/schedulerPaths.cjs +2 -0
  65. package/src/main/lib/scheduler/watchdogHelpers.cjs +114 -22
  66. package/src/main/lib/telemetry/mainHeapSnapshot.cjs +69 -0
  67. package/src/main/lib/telemetry/telemetryBoot.cjs +25 -3
  68. package/src/main/lib/telemetry/telemetryClient.cjs +12 -0
  69. package/src/main/lib/telemetry/telemetryCounters.cjs +25 -2
  70. package/src/main/pty.cjs +0 -53
  71. package/src/main/runVerify.cjs +38 -11
  72. package/src/main/scheduler/prdParser.cjs +3 -0
  73. package/src/main/scheduler/prdPaths.cjs +48 -2
  74. package/src/main/scheduler.cjs +135 -31
  75. package/src/main/seedDevPlugin.cjs +1 -1
  76. package/src/main/seedSchedulerMcp.cjs +1 -1
  77. package/src/main/templates/PRD_AUTHORING.md +8 -4
  78. package/src/main/webRemote.cjs +38 -132
  79. package/src/preload/api.d.ts +4 -52
  80. package/src/preload/index.cjs +0 -24
  81. package/src/seed/agents/dev-lead.md +2 -1
  82. package/src/seed/agents/validator.md +2 -1
  83. package/dist/assets/DataModel-DZHBZrfL.js +0 -1
  84. package/dist/assets/ListDetail-Cud72s-O.js +0 -1
  85. package/dist/assets/RemoteMachines-gOlFTvWq.js +0 -3
  86. package/dist/assets/Settings-DWnN2qxn.js +0 -4
  87. package/dist/assets/Skills-6pHn6glu.js +0 -2
  88. package/dist/assets/SystemPrompt-DT0LBH-K.js +0 -1
  89. package/dist/assets/TagLibrary-DbJ0zbYL.js +0 -1
  90. package/dist/assets/useRemotePeers-CNQmRBpt.js +0 -1
  91. package/src/main/lib/feedback-remote/relayClientAuth.cjs +0 -133
  92. package/src/main/remoteClient.cjs +0 -334
  93. package/src/main/remoteClientIpc.cjs +0 -133
@@ -15,6 +15,8 @@
15
15
 
16
16
  const { personaFileExists, resolvePersonaPaths } = require('../epic/epicMint.cjs');
17
17
  const { listPersonas } = require('../../agentLibrary.cjs');
18
+ const { splitFrontmatter } = require('./prdFrontmatter.cjs');
19
+ const fs = require('node:fs');
18
20
 
19
21
  /** PRD execution's documented default persona (dev-lead.md's own frontmatter role). */
20
22
  const DEFAULT_PRD_AGENT_TYPE = 'dev-lead';
@@ -77,8 +79,34 @@ async function assertAgentTypeWritable(cwd, agentType, deps = {}) {
77
79
  );
78
80
  }
79
81
 
82
+ /**
83
+ * resolvePersonaDefaultEstimate(cwd, agentType, deps) — the persona's
84
+ * optional `defaultEstimateMinutes` frontmatter (integer 1..60), project
85
+ * overlay before global. Returns a number or null; never throws (missing,
86
+ * malformed or out-of-range → null). Uncached sync read: called once per
87
+ * job spawn.
88
+ */
89
+ function resolvePersonaDefaultEstimate(cwd, agentType, deps = {}) {
90
+ try {
91
+ if (!cwd || typeof agentType !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(agentType)) return null;
92
+ const { projectPath, globalPath } = resolvePersonaPaths(cwd, agentType, deps);
93
+ for (const file of [projectPath, globalPath]) {
94
+ let text;
95
+ try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
96
+ const raw = splitFrontmatter(text).fm.defaultEstimateMinutes;
97
+ if (raw === undefined) continue;
98
+ const str = String(raw).trim();
99
+ if (!/^\d+$/.test(str)) continue;
100
+ const n = Number(str);
101
+ if (n >= 1 && n <= 60) return n;
102
+ }
103
+ } catch { /* never throws */ }
104
+ return null;
105
+ }
106
+
80
107
  module.exports = {
81
108
  DEFAULT_PRD_AGENT_TYPE,
109
+ resolvePersonaDefaultEstimate,
82
110
  assertAgentTypeWritable,
83
111
  reportDanglingAgentTypeOnce,
84
112
  };
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+ /**
3
+ * prdBodyPatch.cjs — pure structured patch of a PRD body. Replaces only the
4
+ * `# Gate`, `# Files` and/or `# Acceptance criteria` sections, validating and
5
+ * rendering gate/files with the same prdGateFiles.cjs functions createPrd uses.
6
+ * No I/O.
7
+ */
8
+
9
+ const { validateGate, validateFiles, renderFilesSection, renderGateSection } = require('./prdGateFiles.cjs');
10
+ const { resolveGate, parseChain, isNoneGate } = require('../scheduler/definitionOfDone.cjs');
11
+
12
+ const H_AC = '# Acceptance criteria';
13
+ const H_FILES = '# Files';
14
+ const H_GATE = '# Gate';
15
+ const H_NOTES = '# Implementation notes';
16
+ const H_STANDARDS = '## Engineering standards';
17
+
18
+ /** Locate a heading's span [start, end) in lines, ignoring fenced code. */
19
+ function findSection(lines, heading) {
20
+ let inFence = false;
21
+ let start = -1;
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const line = lines[i];
24
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
25
+ if (inFence) continue;
26
+ if (start === -1) {
27
+ if (line === heading) start = i;
28
+ } else if (/^#{1,2} /.test(line)) {
29
+ return { start, end: i };
30
+ }
31
+ }
32
+ return start === -1 ? null : { start, end: lines.length };
33
+ }
34
+
35
+ function findHeadingIndex(lines, heading) {
36
+ const s = findSection(lines, heading);
37
+ return s ? s.start : -1;
38
+ }
39
+
40
+ function replaceSection(lines, heading, rendered, insertBeforeHeadings) {
41
+ const span = findSection(lines, heading);
42
+ if (span) {
43
+ lines.splice(span.start, span.end - span.start, ...rendered);
44
+ return;
45
+ }
46
+ let at = lines.length;
47
+ for (const h of insertBeforeHeadings) {
48
+ const idx = findHeadingIndex(lines, h);
49
+ if (idx !== -1) { at = idx; break; }
50
+ }
51
+ lines.splice(at, 0, ...rendered);
52
+ }
53
+
54
+ function gateSignature(body) {
55
+ const r = resolveGate(body);
56
+ return JSON.stringify([r.source, r.sequence]);
57
+ }
58
+
59
+ /**
60
+ * @param {string} body
61
+ * @param {{gate?: string[], files?: string[], acceptanceCriteria?: string[]}} patch
62
+ * @returns {{ok:true, body:string, warnings:string[]}|{ok:false, error:string}}
63
+ */
64
+ function applyStructuredPrdPatch(body, patch) {
65
+ if (typeof body !== 'string') return { ok: false, error: 'body must be a string.' };
66
+ if (!patch || typeof patch !== 'object') return { ok: false, error: 'patch must be an object.' };
67
+ const hasGate = patch.gate !== undefined;
68
+ const hasFiles = patch.files !== undefined;
69
+ const hasAc = patch.acceptanceCriteria !== undefined;
70
+ if (!hasGate && !hasFiles && !hasAc) {
71
+ return { ok: false, error: 'patch must name at least one of gate, files, acceptanceCriteria.' };
72
+ }
73
+
74
+ let warnings = [];
75
+ let gateCommands = null;
76
+ let filesList = null;
77
+ let acLines = null;
78
+
79
+ if (hasGate) {
80
+ const g = validateGate(patch.gate);
81
+ if (!g.ok) return { ok: false, error: g.error };
82
+ gateCommands = g.commands;
83
+ warnings = warnings.concat(g.warnings || []);
84
+ }
85
+ if (hasFiles) {
86
+ const f = validateFiles(patch.files);
87
+ if (!f.ok) return { ok: false, error: f.error };
88
+ filesList = f.files;
89
+ }
90
+ if (hasAc) {
91
+ const ac = patch.acceptanceCriteria;
92
+ if (!Array.isArray(ac) || ac.length === 0) {
93
+ return { ok: false, error: 'acceptanceCriteria must be a non-empty array of strings.' };
94
+ }
95
+ acLines = [];
96
+ for (let i = 0; i < ac.length; i++) {
97
+ if (typeof ac[i] !== 'string' || ac[i].trim() === '') {
98
+ return { ok: false, error: `acceptanceCriteria entry ${i + 1}: must be a non-empty string.` };
99
+ }
100
+ if (/[\r\n]/.test(ac[i])) {
101
+ return { ok: false, error: `acceptanceCriteria entry ${i + 1}: must not contain a newline.` };
102
+ }
103
+ acLines.push(`- [ ] ${ac[i].trim()}`);
104
+ }
105
+ }
106
+
107
+ const lines = body.split('\n');
108
+ if (hasAc) {
109
+ if (!findSection(lines, H_AC)) return { ok: false, error: `body has no "${H_AC}" section to patch.` };
110
+ replaceSection(lines, H_AC, [H_AC, '', ...acLines, ''], []);
111
+ }
112
+ if (hasFiles) {
113
+ replaceSection(lines, H_FILES, renderFilesSection(filesList), [H_NOTES, H_STANDARDS]);
114
+ }
115
+ if (hasGate) {
116
+ replaceSection(lines, H_GATE, renderGateSection(gateCommands), [H_STANDARDS]);
117
+ }
118
+ const next = lines.join('\n');
119
+
120
+ // Read-back with the scheduler's own reader, as createPrd does.
121
+ if (hasGate) {
122
+ const expectedSource = gateCommands.length === 1 && isNoneGate(gateCommands[0]) ? 'none' : 'explicit';
123
+ const expectedSequence = expectedSource === 'none' ? [] : gateCommands.flatMap(parseChain);
124
+ const resolved = resolveGate(next);
125
+ if (resolved.source !== expectedSource || JSON.stringify(resolved.sequence) !== JSON.stringify(expectedSequence)) {
126
+ return {
127
+ ok: false,
128
+ error: 'The PRD text holds another gate fence (three backticks + gate) outside the # Gate section, so the scheduler would read the wrong gate. Remove it and retry.',
129
+ };
130
+ }
131
+ } else if (gateSignature(next) !== gateSignature(body)) {
132
+ return { ok: false, error: 'The patch would change how the scheduler reads the PRD gate (stray gate fence in the new text).' };
133
+ }
134
+
135
+ return { ok: true, body: next, warnings };
136
+ }
137
+
138
+ module.exports = { applyStructuredPrdPatch };
@@ -32,7 +32,9 @@ const { resolveDepSlug, findNearMatches } = require('./depSlugResolve.cjs');
32
32
  const { resolveEpicPrdWriteDir } = require('./prdLocations.cjs');
33
33
  const { isFixPlanSlug } = require('../scheduler/fixPlanSlug.cjs');
34
34
  const { isIncomplete, resolveChainTerminals, isValidPlanId, mintPlanId, resolveInheritedPlanId } = require('./prdDisposition.cjs');
35
+ const { splitFrontmatter } = require('./prdFrontmatter.cjs');
35
36
  const { sizingWarnings } = require('./prdSizing.cjs');
37
+ const telemetryCounters = require('../telemetry/telemetryCounters.cjs');
36
38
  const { execGit } = require('../git/gitExec.cjs');
37
39
  const { extractRepoPathCandidates, worktreeReachWarnings } = require('./prdWorktreeReach.cjs');
38
40
  const { validateGate, validateFiles, renderFilesSection, renderGateSection } = require('./prdGateFiles.cjs');
@@ -224,7 +226,21 @@ async function worktreeReachAdvisory(input, cwd) {
224
226
  * on failure — `status` is the HTTP status code the caller should map errors to
225
227
  * (400/409/500), left to the caller since only the HTTP route needs it.
226
228
  */
229
+ const inFlightCreates = new Set();
230
+
227
231
  async function createPrd(input, remote) {
232
+ const ctl = { key: null };
233
+ try {
234
+ return await createPrdInner(input, remote, ctl);
235
+ } finally {
236
+ if (ctl.key) inFlightCreates.delete(ctl.key);
237
+ }
238
+ }
239
+
240
+ async function createPrdInner(input, remote, ctl, opts = {}) {
241
+ // opts.dryRun: run every check, allocate/write nothing, return the prepared
242
+ // entry (createPrdBatch's validate phase). opts.extraDepSlugs: bare slugs of
243
+ // sibling batch entries, accepted as dependsOn targets.
228
244
  // cwd is untrusted AND, since this PRD, optional — a caller inside an
229
245
  // Epic's git worktree naturally passes its worktree pwd (or omits cwd
230
246
  // entirely and relies on originClaudeSessionId/sourcePromptId). Resolve the
@@ -249,7 +265,7 @@ async function createPrd(input, remote) {
249
265
 
250
266
  if (!resolved.cwd) {
251
267
  return {
252
- ok: false,
268
+ ok: false, applied: false,
253
269
  status: 400,
254
270
  error: 'cwd is required — pass cwd explicitly, or originClaudeSessionId/sourcePromptId that resolves '
255
271
  + 'to an existing, already-approved Epic (create/approve an Epic first)',
@@ -264,7 +280,7 @@ async function createPrd(input, remote) {
264
280
  try {
265
281
  realCwd = config.validatePath(cwd);
266
282
  } catch (e) {
267
- return { ok: false, status: 400, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` };
283
+ return { ok: false, applied: false, status: 400, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` };
268
284
  }
269
285
  // Register this project's cwd as a write-allowed root (config.cjs's
270
286
  // validateWrite gate). Chat-only Epics (headless claude -p, no Terminal
@@ -313,7 +329,7 @@ async function createPrd(input, remote) {
313
329
  const nonInteractive = Boolean(process.env.SM_SCHEDULER_JOB_SLUG);
314
330
  if (!nonInteractive) {
315
331
  return {
316
- ok: false,
332
+ ok: false, applied: false,
317
333
  status: 400,
318
334
  error: `Epic ${input.sourcePromptId} already has incomplete PRDs — pass disposition: "append" ` +
319
335
  '(extend the existing chain behind its current tail) or "new-head" (an independent root, ' +
@@ -340,7 +356,7 @@ async function createPrd(input, remote) {
340
356
  const nnMatch = SLUG_HAS_NN_PREFIX_RE.exec(input.slug);
341
357
  if (nnMatch) {
342
358
  return {
343
- ok: false,
359
+ ok: false, applied: false,
344
360
  status: 400,
345
361
  error: `slug "${input.slug}" already starts with a numeric prefix "${nnMatch[1]}-" — the allocator ` +
346
362
  `owns the NN- prefix, not the caller. Pass the bare name instead (e.g. "${input.slug.slice(nnMatch[0].length)}").`,
@@ -350,7 +366,7 @@ async function createPrd(input, remote) {
350
366
 
351
367
  const slug = input.slug || deriveSlugFromTitle(input.title);
352
368
  if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
353
- return { ok: false, status: 400, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' };
369
+ return { ok: false, applied: false, status: 400, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' };
354
370
  }
355
371
 
356
372
  // NN allocation is delegated to allocateParallelGroup() (PRD 548) via the
@@ -365,21 +381,21 @@ async function createPrd(input, remote) {
365
381
  // checked BEFORE the NN allocation so a refusal burns no number.
366
382
  const hasArtifactPaths = Array.isArray(input.artifactPaths) && input.artifactPaths.length > 0;
367
383
  if (input.deliverable != null && input.deliverable !== 'artifact') {
368
- return { ok: false, status: 400, error: 'deliverable must be exactly "artifact" when given' };
384
+ return { ok: false, applied: false, status: 400, error: 'deliverable must be exactly "artifact" when given' };
369
385
  }
370
386
  if (input.deliverable === 'artifact' && !hasArtifactPaths) {
371
- return { ok: false, status: 400, error: 'deliverable: "artifact" requires a non-empty artifactPaths — name every git-excluded file this PRD produces' };
387
+ return { ok: false, applied: false, status: 400, error: 'deliverable: "artifact" requires a non-empty artifactPaths — name every git-excluded file this PRD produces' };
372
388
  }
373
389
  if (input.artifactPaths != null && input.deliverable !== 'artifact') {
374
- return { ok: false, status: 400, error: 'artifactPaths requires deliverable: "artifact" — artifactPaths given without it' };
390
+ return { ok: false, applied: false, status: 400, error: 'artifactPaths requires deliverable: "artifact" — artifactPaths given without it' };
375
391
  }
376
392
  if (hasArtifactPaths) {
377
393
  for (const ap of input.artifactPaths) {
378
394
  if (typeof ap !== 'string' || !ap.trim() || /[\r\n,\[\]]/.test(ap)) {
379
- return { ok: false, status: 400, error: `artifactPaths entry ${JSON.stringify(ap)} must be a non-empty single-line path without commas or brackets` };
395
+ return { ok: false, applied: false, status: 400, error: `artifactPaths entry ${JSON.stringify(ap)} must be a non-empty single-line path without commas or brackets` };
380
396
  }
381
397
  if (ap.includes('..')) {
382
- return { ok: false, status: 400, error: `artifactPaths entry "${ap}" must not contain ".."` };
398
+ return { ok: false, applied: false, status: 400, error: `artifactPaths entry "${ap}" must not contain ".."` };
383
399
  }
384
400
  }
385
401
  }
@@ -403,18 +419,18 @@ async function createPrd(input, remote) {
403
419
  }
404
420
  for (const [fieldLabel, text] of [['goal', input.goal], ['implementation notes', input.implementationNotes]]) {
405
421
  const headingError = headingGuardError(fieldLabel, text);
406
- if (headingError) return { ok: false, status: 400, error: headingError };
422
+ if (headingError) return { ok: false, applied: false, status: 400, error: headingError };
407
423
  }
408
424
  if (Array.isArray(input.acceptanceCriteria)) {
409
425
  for (const entry of input.acceptanceCriteria) {
410
426
  const headingError = headingGuardError('acceptance criteria', entry);
411
- if (headingError) return { ok: false, status: 400, error: headingError };
427
+ if (headingError) return { ok: false, applied: false, status: 400, error: headingError };
412
428
  }
413
429
  }
414
430
  if (Array.isArray(input.outOfScope)) {
415
431
  for (const entry of input.outOfScope) {
416
432
  const headingError = headingGuardError('out of scope', entry);
417
- if (headingError) return { ok: false, status: 400, error: headingError };
433
+ if (headingError) return { ok: false, applied: false, status: 400, error: headingError };
418
434
  }
419
435
  }
420
436
 
@@ -427,7 +443,7 @@ async function createPrd(input, remote) {
427
443
  if (input.gate !== undefined) {
428
444
  const gateValidation = validateGate(input.gate);
429
445
  if (!gateValidation.ok) {
430
- return { ok: false, status: 400, error: gateValidation.error };
446
+ return { ok: false, applied: false, status: 400, error: gateValidation.error };
431
447
  }
432
448
  gateCommands = gateValidation.commands;
433
449
  extraWarnings.push(...gateValidation.warnings);
@@ -438,7 +454,7 @@ async function createPrd(input, remote) {
438
454
  if (input.files !== undefined) {
439
455
  const filesValidation = validateFiles(input.files);
440
456
  if (!filesValidation.ok) {
441
- return { ok: false, status: 400, error: filesValidation.error };
457
+ return { ok: false, applied: false, status: 400, error: filesValidation.error };
442
458
  }
443
459
  filesList = filesValidation.files;
444
460
  } else {
@@ -450,6 +466,18 @@ async function createPrd(input, remote) {
450
466
  // Finished rows leave queue.json, so an author can re-issue a landed PRD under a fresh NN.
451
467
  // Refused before NN allocation so it burns no number. Fix plans are exempt.
452
468
  if (input.sourcePromptId && !isFixPlanSlug(`0-${slug}`)) {
469
+ const inFlightKey = `${input.sourcePromptId}\u0000${slug}`;
470
+ if (!opts.dryRun && inFlightCreates.has(inFlightKey)) {
471
+ return {
472
+ ok: false, applied: false,
473
+ status: 409,
474
+ error: `A create for slug "${slug}" in this Epic is still in progress — wait for it to finish, then retry the same call`,
475
+ };
476
+ }
477
+ if (!opts.dryRun) {
478
+ inFlightCreates.add(inFlightKey);
479
+ ctl.key = inFlightKey;
480
+ }
453
481
  let epicDirs = [];
454
482
  try {
455
483
  const prdsDir = resolveEpicPrdWriteDir(cwd, input.sourcePromptId);
@@ -460,8 +488,31 @@ async function createPrd(input, remote) {
460
488
  try { names = await fsp.readdir(dir); } catch { /* missing dir counts as empty */ }
461
489
  const dup = names.find((n) => n.endsWith('.md') && n.slice(0, -3).replace(/^\d+-/, '') === slug);
462
490
  if (dup) {
491
+ let existingTitle = null;
492
+ try {
493
+ existingTitle = splitFrontmatter(await fsp.readFile(path.join(dir, dup), 'utf8')).fm.title ?? null;
494
+ } catch { /* unreadable — treat as different */ }
495
+ if (existingTitle !== null && String(existingTitle).trim() === String(input.title ?? '').trim()) {
496
+ const existingSlug = dup.slice(0, -3);
497
+ let enqueuedExisting = false;
498
+ try {
499
+ if (typeof remote.getJob === 'function') enqueuedExisting = Boolean(await remote.getJob(existingSlug));
500
+ } catch { /* non-fatal */ }
501
+ return {
502
+ ok: true,
503
+ applied: true,
504
+ alreadyExisted: true,
505
+ nn: Number((/^(\d+)-/.exec(dup) || [])[1]),
506
+ filename: dup,
507
+ prdPath: path.join(dir, dup),
508
+ epicId: input.sourcePromptId,
509
+ enqueued: enqueuedExisting,
510
+ note: 'PRD was not written again: this Epic already has that file with the same title, which usually means an earlier call\'s response was lost, so treat it as created.',
511
+ warnings: [],
512
+ };
513
+ }
463
514
  return {
464
- ok: false,
515
+ ok: false, applied: false,
465
516
  status: 409,
466
517
  error: `A PRD with slug "${slug}" already exists in this Epic as "${dup}". Its work may already have landed. ` +
467
518
  'Use scheduler_update_prd / scheduler_reset_job on it, or pick a distinct slug.',
@@ -470,7 +521,8 @@ async function createPrd(input, remote) {
470
521
  }
471
522
  }
472
523
 
473
- const nn = await remote.allocateParallelGroup(input.cwd);
524
+ // Dry run burns no number: a placeholder keeps the filename-shaped guards below meaningful.
525
+ const nn = opts.dryRun ? 9999999 : await remote.allocateParallelGroup(input.cwd);
474
526
  const filenameSlug = `${nn}-${slug}`;
475
527
 
476
528
  // Fix-chain depth guard (PRD 1113): refuse a fix-of-a-fix before it's ever
@@ -482,7 +534,7 @@ async function createPrd(input, remote) {
482
534
  if (chainDepth > FIX_CHAIN_DEPTH_CAP) {
483
535
  const base = baseSlugOf(filenameSlug);
484
536
  return {
485
- ok: false,
537
+ ok: false, applied: false,
486
538
  status: 409,
487
539
  error: `Fix-chain depth cap exceeded: "${base}" has already failed to close ${chainDepth} time(s) for ` +
488
540
  `infrastructure reasons (refusing to write "${filenameSlug}.md" at depth ${chainDepth}). Authoring a ` +
@@ -505,7 +557,7 @@ async function createPrd(input, remote) {
505
557
  // — but only when spawnInvestigation authors it, never through this API).
506
558
  if (isFixPlanSlug(filenameSlug)) {
507
559
  return {
508
- ok: false,
560
+ ok: false, applied: false,
509
561
  status: 400,
510
562
  error: `slug "${filenameSlug}" collides with the reserved NN-fix-... fix-plan naming convention ` +
511
563
  '(reserved for PRDs the scheduler\'s own investigation loop authors itself). Reword the title/slug ' +
@@ -519,9 +571,9 @@ async function createPrd(input, remote) {
519
571
  // no existence guard (by design, it doubles as the edit-in-place
520
572
  // path for Scheduler UI PRD edits), so "create" must not silently
521
573
  // clobber an existing job's PRD.
522
- const existing = await remote.readPrd(filenameSlug, input.cwd);
574
+ const existing = opts.dryRun ? null : await remote.readPrd(filenameSlug, input.cwd);
523
575
  if (existing?.ok) {
524
- return { ok: false, status: 409, error: `PRD already exists: ${filenameSlug}.md` };
576
+ return { ok: false, applied: false, status: 409, error: `PRD already exists: ${filenameSlug}.md` };
525
577
  }
526
578
 
527
579
  // Write-time FK check (throw on write, report on read — see
@@ -534,7 +586,7 @@ async function createPrd(input, remote) {
534
586
  try {
535
587
  await assertAgentTypeWritable(input.cwd, input.agentType);
536
588
  } catch (e) {
537
- return { ok: false, status: 400, error: e?.message ?? 'invalid agentType' };
589
+ return { ok: false, applied: false, status: 400, error: e?.message ?? 'invalid agentType' };
538
590
  }
539
591
  }
540
592
 
@@ -558,13 +610,13 @@ async function createPrd(input, remote) {
558
610
  }
559
611
  if (listing) {
560
612
  depRows = listing.prds ?? [];
561
- const candidateSlugs = depRows.map((p) => p.slug);
613
+ const candidateSlugs = [...depRows.map((p) => p.slug), ...(opts.extraDepSlugs ?? [])];
562
614
  for (const dep of input.dependsOn) {
563
615
  if (resolveDepSlug(dep, candidateSlugs).length > 0) continue;
564
616
  const near = findNearMatches(dep, candidateSlugs);
565
617
  const suggestion = near.length ? ` Closest existing slug(s): ${near.join(', ')}.` : '';
566
618
  return {
567
- ok: false,
619
+ ok: false, applied: false,
568
620
  status: 400,
569
621
  error: `dependsOn entry "${dep}" does not resolve to any existing PRD in this project.${suggestion} ` +
570
622
  'Pass the bare name (preferred) or the exact NN-prefixed slug of an existing PRD.',
@@ -584,6 +636,7 @@ async function createPrd(input, remote) {
584
636
  console.warn(`[prdCreate] dependsOn spans plans ${inherited.diverged.join(', ')} — inheriting ${planId} (lowest-numbered dependency)`);
585
637
  }
586
638
  }
639
+ const inheritedPlanId = planId;
587
640
  if (!planId) planId = mintPlanId();
588
641
 
589
642
  const body = buildPrdBody({ ...input, planId, gate: gateCommands, files: filesList });
@@ -603,7 +656,7 @@ async function createPrd(input, remote) {
603
656
  && JSON.stringify(resolved.sequence) === JSON.stringify(expectedSequence);
604
657
  if (!matches) {
605
658
  return {
606
- ok: false,
659
+ ok: false, applied: false,
607
660
  status: 400,
608
661
  error: 'The PRD text already holds a gate fence (three backticks + gate), so the scheduler would read '
609
662
  + 'the wrong gate. Remove it from the goal, implementation notes, acceptance criteria, out of scope '
@@ -612,9 +665,16 @@ async function createPrd(input, remote) {
612
665
  }
613
666
  }
614
667
 
668
+ if (opts.dryRun) {
669
+ return {
670
+ ok: true, dryRun: true, slug, input, gateCommands, filesList,
671
+ inheritedPlanId, warnings: [...extraWarnings, ...sizingWarnings(input)],
672
+ };
673
+ }
674
+
615
675
  const writeResult = await remote.writePrd(filenameSlug, body, input.cwd);
616
676
  if (!writeResult?.ok) {
617
- return { ok: false, status: 500, error: writeResult?.error ?? 'write failed' };
677
+ return { ok: false, applied: false, status: 500, error: writeResult?.error ?? 'write failed' };
618
678
  }
619
679
 
620
680
  // Trace-back point for "who/what created this PRD" (auditLog.cjs) — every
@@ -627,6 +687,8 @@ async function createPrd(input, remote) {
627
687
  sourcePromptId: input.sourcePromptId ?? null,
628
688
  sourceTabId: input.sourceTabId ?? null,
629
689
  });
690
+ // Anonymous counter: enum-mapped agentType only (telemetryCounters.cjs).
691
+ telemetryCounters.trackPrdCreate({ agentType: input.agentType || DEFAULT_PRD_AGENT_TYPE });
630
692
 
631
693
  // PRD 1446: writePrd() succeeding used to just promise "the next scheduler
632
694
  // reconcile pass (~1 minute)" adopts this file — but nothing reliably ran
@@ -642,7 +704,12 @@ async function createPrd(input, remote) {
642
704
  let enqueued = false;
643
705
  if (typeof remote.requestReconcile === 'function') {
644
706
  try {
645
- await remote.requestReconcile();
707
+ let timer;
708
+ const reconcile = Promise.resolve().then(() => remote.requestReconcile());
709
+ // Late rejection (after the race is lost) must never be unhandled.
710
+ reconcile.catch((e) => console.warn(`[prdCreate] requestReconcile failed for ${filenameSlug} (non-fatal): ${e?.message ?? e}`));
711
+ const bound = new Promise((resolve) => { timer = setTimeout(resolve, 2000); });
712
+ try { await Promise.race([reconcile, bound]); } finally { clearTimeout(timer); }
646
713
  if (typeof remote.getJob === 'function') {
647
714
  enqueued = Boolean(await remote.getJob(filenameSlug));
648
715
  }
@@ -662,6 +729,7 @@ async function createPrd(input, remote) {
662
729
  // row before this function returned — it is not a fabricated status.
663
730
  return {
664
731
  ok: true,
732
+ applied: true,
665
733
  nn,
666
734
  filename: `${filenameSlug}.md`,
667
735
  prdPath: writeResult.path ?? null,
@@ -684,6 +752,128 @@ async function createPrd(input, remote) {
684
752
  };
685
753
  }
686
754
 
755
+ const PRD_BATCH_MAX = 40;
756
+
757
+ /**
758
+ * Create several PRDs all-or-nothing. Every entry goes through createPrd's own
759
+ * validation (dry run: no NN, no file); `dependsOn` may also name other entries
760
+ * of the batch by bare slug, earlier or later. Only when every entry validates
761
+ * and the intra-batch graph is acyclic are NNs allocated and files written; a
762
+ * write failure unlinks what this call already wrote. Allocated NNs are never
763
+ * reissued (the allocator's high-water only moves up), so a rollback leaves a
764
+ * gap, not an inconsistency.
765
+ */
766
+ async function createPrdBatch(inputs, remote, opts = {}) {
767
+ const fail = (errors) => ({ ok: false, applied: false, errors });
768
+ if (!Array.isArray(inputs) || inputs.length === 0) return fail([{ index: -1, slug: null, error: 'batch needs at least one entry' }]);
769
+ if (inputs.length > PRD_BATCH_MAX) return fail([{ index: -1, slug: null, error: `batch has ${inputs.length} entries; the maximum is ${PRD_BATCH_MAX}` }]);
770
+
771
+ const envelope = {};
772
+ for (const k of ['cwd', 'originClaudeSessionId', 'originProjectRoot', 'sourcePromptId']) {
773
+ if (opts[k] != null) envelope[k] = opts[k];
774
+ }
775
+ const entries = inputs.map((e) => ({ ...envelope, ...e }));
776
+
777
+ // Pass 1: slugs (cheap, so duplicates / cycles are reported before any I/O).
778
+ const slugs = entries.map((e) => e.slug || deriveSlugFromTitle(e.title));
779
+ const errors = [];
780
+ const firstIndex = new Map();
781
+ slugs.forEach((s, i) => {
782
+ if (!s) return;
783
+ if (firstIndex.has(s)) errors.push({ index: i, slug: s, error: `duplicate slug "${s}" in batch (also entry ${firstIndex.get(s)})` });
784
+ else firstIndex.set(s, i);
785
+ });
786
+ if (errors.length) return fail(errors);
787
+ const batchSlugs = slugs.filter(Boolean);
788
+
789
+ // Pass 2: per-entry validation through createPrd's own checks.
790
+ const prepared = [];
791
+ for (let i = 0; i < entries.length; i++) {
792
+ const r = await createPrdInner(entries[i], remote, { key: null }, { dryRun: true, extraDepSlugs: batchSlugs });
793
+ if (!r.ok) { errors.push({ index: i, slug: slugs[i] ?? null, error: r.error }); continue; }
794
+ if (r.alreadyExisted) { errors.push({ index: i, slug: slugs[i], error: `slug "${slugs[i]}" already exists in this Epic as "${r.filename}"` }); continue; }
795
+ prepared.push(r);
796
+ }
797
+ if (errors.length) return fail(errors);
798
+ const sameTarget = prepared.every((p) => p.input.cwd === prepared[0].input.cwd && (p.input.sourcePromptId ?? null) === (prepared[0].input.sourcePromptId ?? null));
799
+ if (!sameTarget) return fail([{ index: -1, slug: null, error: 'all batch entries must resolve to the same project and Epic' }]);
800
+
801
+ // Cycle detection: DFS over intra-batch dependsOn edges (bare-slug match).
802
+ const edges = prepared.map((p) => (p.input.dependsOn ?? []).filter((d) => firstIndex.has(d)).map((d) => firstIndex.get(d)));
803
+ const state = new Array(prepared.length).fill(0);
804
+ let cycle = null;
805
+ const visit = (n, trail) => {
806
+ if (cycle) return;
807
+ state[n] = 1;
808
+ for (const m of edges[n]) {
809
+ if (state[m] === 1) { cycle = [...trail.slice(trail.indexOf(m)), n, m].map((x) => slugs[x]); return; }
810
+ if (state[m] === 0) visit(m, [...trail, n]);
811
+ if (cycle) return;
812
+ }
813
+ state[n] = 2;
814
+ };
815
+ for (let i = 0; i < prepared.length && !cycle; i++) if (state[i] === 0) visit(i, []);
816
+ if (cycle) {
817
+ const at = firstIndex.get(cycle[0]);
818
+ return fail([{ index: at, slug: cycle[0], error: `dependsOn cycle inside batch: ${cycle.join(' -> ')}` }]);
819
+ }
820
+
821
+ // One planId for the whole batch unless an entry inherits one from existing deps.
822
+ const planId = prepared.map((p) => p.inheritedPlanId).find(Boolean) || mintPlanId();
823
+
824
+ // Write phase: allocate + write in input order; unlink on any failure.
825
+ const written = [];
826
+ const results = [];
827
+ const rollback = async () => {
828
+ for (const w of written) {
829
+ try {
830
+ if (w.path) await fsp.unlink(w.path);
831
+ else if (typeof remote.deletePrd === 'function') await remote.deletePrd(w.filenameSlug, w.cwd);
832
+ } catch (e) { console.warn(`[prdCreate] batch rollback could not remove ${w.filenameSlug}: ${e?.message ?? e}`); }
833
+ }
834
+ };
835
+ for (let i = 0; i < prepared.length; i++) {
836
+ const p = prepared[i];
837
+ let nn; let filenameSlug;
838
+ try {
839
+ nn = await remote.allocateParallelGroup(p.input.cwd);
840
+ filenameSlug = `${nn}-${p.slug}`;
841
+ const body = buildPrdBody({ ...p.input, planId, gate: p.gateCommands, files: p.filesList });
842
+ const w = await remote.writePrd(filenameSlug, body, p.input.cwd);
843
+ if (!w?.ok) throw new Error(w?.error ?? 'write failed');
844
+ written.push({ filenameSlug, path: w.path ?? null, cwd: p.input.cwd });
845
+ results.push({ slug: p.slug, nn, filename: `${filenameSlug}.md`, warnings: p.warnings });
846
+ } catch (e) {
847
+ await rollback();
848
+ return fail([{ index: i, slug: p.slug, error: `write failed (batch rolled back): ${e?.message ?? e}` }]);
849
+ }
850
+ }
851
+
852
+ for (let i = 0; i < prepared.length; i++) {
853
+ const p = prepared[i];
854
+ appendAuditEvent('prd_create', {
855
+ cwd: p.input.cwd, slug: `${results[i].nn}-${p.slug}`, title: p.input.title,
856
+ sourcePromptId: p.input.sourcePromptId ?? null, sourceTabId: p.input.sourceTabId ?? null, batch: true,
857
+ });
858
+ telemetryCounters.trackPrdCreate({ agentType: p.input.agentType || DEFAULT_PRD_AGENT_TYPE });
859
+ }
860
+
861
+ // One bounded reconcile after the last write (best-effort, as in createPrd).
862
+ if (typeof remote.requestReconcile === 'function') {
863
+ let timer;
864
+ try {
865
+ const reconcile = Promise.resolve().then(() => remote.requestReconcile());
866
+ reconcile.catch((e) => console.warn(`[prdCreate] batch requestReconcile failed (non-fatal): ${e?.message ?? e}`));
867
+ const bound = new Promise((resolve) => { timer = setTimeout(resolve, 2000); });
868
+ await Promise.race([reconcile, bound]);
869
+ } catch (e) {
870
+ console.warn(`[prdCreate] batch requestReconcile failed (non-fatal): ${e?.message ?? e}`);
871
+ } finally { clearTimeout(timer); }
872
+ }
873
+
874
+ return { ok: true, applied: true, prds: results };
875
+ }
876
+
687
877
  /**
688
878
  * Registers the create-prd admin HTTP route (PRD 689 — moved verbatim out of
689
879
  * the former standalone admin HTTP server module's handleRequest, no behavior
@@ -720,6 +910,7 @@ function registerAdminRoute(adminHttp, remote) {
720
910
  // Mirror createPrd()'s own honest shape verbatim — never re-add a
721
911
  // 'status' field here. See createPrd's return above for why.
722
912
  sendJson(res, 200, {
913
+ ...(result.alreadyExisted ? { alreadyExisted: true } : {}),
723
914
  nn: result.nn,
724
915
  filename: result.filename,
725
916
  prdPath: result.prdPath ?? null,
@@ -731,6 +922,31 @@ function registerAdminRoute(adminHttp, remote) {
731
922
  warnings: result.warnings,
732
923
  });
733
924
  });
925
+
926
+ adminHttp.registerRoute('POST', '/admin/scheduler/create-prds', async (req, res) => {
927
+ const raw = await readBody(req);
928
+ let parsed;
929
+ try {
930
+ parsed = raw ? JSON.parse(raw) : {};
931
+ } catch {
932
+ sendJson(res, 400, { ok: false, applied: false, error: 'invalid JSON body' });
933
+ return;
934
+ }
935
+ let input;
936
+ try {
937
+ input = schemas.adminCreatePrds.parse(parsed);
938
+ } catch (e) {
939
+ sendJson(res, 400, { ok: false, applied: false, error: 'invalid PRD batch payload', details: e?.issues ?? e?.message });
940
+ return;
941
+ }
942
+ const { prds, ...envelope } = input;
943
+ const result = await createPrdBatch(prds, remote, envelope);
944
+ if (!result.ok) {
945
+ sendJson(res, 400, { ok: false, applied: false, errors: result.errors });
946
+ return;
947
+ }
948
+ sendJson(res, 200, { ok: true, applied: true, prds: result.prds });
949
+ });
734
950
  }
735
951
 
736
952
  module.exports = {
@@ -741,5 +957,7 @@ module.exports = {
741
957
  buildPrdBody,
742
958
  resolveSourcePromptIdFromClaudeSession,
743
959
  createPrd,
960
+ createPrdBatch,
961
+ PRD_BATCH_MAX,
744
962
  registerAdminRoute,
745
963
  };