claude-code-session-manager 0.122.1 → 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-C2qUX9WU.js → AgentLibrary-DxWN3FSN.js} +2 -2
  2. package/dist/assets/{Button-ssBzvFha.js → Button-CISLFSKb.js} +1 -1
  3. package/dist/assets/DataModel-CYPbhGrx.js +1 -0
  4. package/dist/assets/{History-BRdRp1J_.js → History-Fhouektw.js} +2 -2
  5. package/dist/assets/{Hooks--hiGPBQy.js → Hooks-COfIcyYV.js} +3 -3
  6. package/dist/assets/{Library-0XYvkhh2.js → Library-Bt5WOyyu.js} +7 -7
  7. package/dist/assets/ListDetail-DH7uzYd5.js +1 -0
  8. package/dist/assets/{McpServers-CCjHSwFm.js → McpServers-CwyVEzMN.js} +2 -2
  9. package/dist/assets/{Memory-C7RXoVO4.js → Memory-k9TWT9N4.js} +8 -8
  10. package/dist/assets/{Panel-C92O_aAH.js → Panel-Di8JqCYN.js} +1 -1
  11. package/dist/assets/{Permissions-DuTJ1FUb.js → Permissions-FFs6Rq1H.js} +3 -3
  12. package/dist/assets/{Plugins-DSXsP4hM.js → Plugins-uK5vl_RE.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-CWdDkU1m.js → ProvenanceBadge-CGDyGDoN.js} +1 -1
  14. package/dist/assets/{SaveBar-ob2dJJjH.js → SaveBar-Cnwc_XfF.js} +1 -1
  15. package/dist/assets/{Scheduler-Du4HgPKY.js → Scheduler-aACa6_mC.js} +7 -7
  16. package/dist/assets/{ScopeSwitcher-Dv_6M_Ci.js → ScopeSwitcher-CoG3CSlC.js} +1 -1
  17. package/dist/assets/Settings-BG0RM6Di.js +4 -0
  18. package/dist/assets/{SkillReferenceGraph-Dx1Qs8Y0.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-Cihe5er3.js → TiptapBody-CCHsa8Jv.js} +1 -1
  23. package/dist/assets/{Toggle-CpYVC8bF.js → Toggle-BUOj4pws.js} +1 -1
  24. package/dist/assets/{ViewTabs-C3FN6P_C.js → ViewTabs-BdpsL7yr.js} +1 -1
  25. package/dist/assets/{historyTotals-C4Rs37Cd.js → historyTotals-Dx6ca881.js} +1 -1
  26. package/dist/assets/{index-Dd7UQabV.css → index-BnKaYERU.css} +1 -1
  27. package/dist/assets/{index-S-F5jp_h.js → index-CkNGj8FA.js} +151 -151
  28. package/dist/assets/{settingsSchema-BZcdzHTQ.js → settingsSchema-BZhkpr9e.js} +1 -1
  29. package/dist/assets/{skillFrontmatter-EfNERt4b.js → skillFrontmatter-N13KGqbs.js} +1 -1
  30. package/dist/assets/{useAgentsDirWatch-BfbrFvB3.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 -25
  34. package/scripts/scheduler-mcp-server.cjs +124 -74
  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 +31 -2
  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 +205 -32
  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 +124 -29
  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-CENoTvc2.js +0 -1
  84. package/dist/assets/ListDetail-CkKXAM59.js +0 -1
  85. package/dist/assets/RemoteMachines-BFmbIGpW.js +0 -3
  86. package/dist/assets/Settings-DMoI0cqB.js +0 -4
  87. package/dist/assets/Skills-DYWQTQB1.js +0 -2
  88. package/dist/assets/SystemPrompt-Df0IfOuI.js +0 -1
  89. package/dist/assets/TagLibrary-BuMsGx-2.js +0 -1
  90. package/dist/assets/useRemotePeers-CKVakQPc.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
@@ -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 };
@@ -34,6 +34,7 @@ const { isFixPlanSlug } = require('../scheduler/fixPlanSlug.cjs');
34
34
  const { isIncomplete, resolveChainTerminals, isValidPlanId, mintPlanId, resolveInheritedPlanId } = require('./prdDisposition.cjs');
35
35
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
36
36
  const { sizingWarnings } = require('./prdSizing.cjs');
37
+ const telemetryCounters = require('../telemetry/telemetryCounters.cjs');
37
38
  const { execGit } = require('../git/gitExec.cjs');
38
39
  const { extractRepoPathCandidates, worktreeReachWarnings } = require('./prdWorktreeReach.cjs');
39
40
  const { validateGate, validateFiles, renderFilesSection, renderGateSection } = require('./prdGateFiles.cjs');
@@ -236,7 +237,10 @@ async function createPrd(input, remote) {
236
237
  }
237
238
  }
238
239
 
239
- async function createPrdInner(input, remote, ctl) {
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.
240
244
  // cwd is untrusted AND, since this PRD, optional — a caller inside an
241
245
  // Epic's git worktree naturally passes its worktree pwd (or omits cwd
242
246
  // entirely and relies on originClaudeSessionId/sourcePromptId). Resolve the
@@ -261,7 +265,7 @@ async function createPrdInner(input, remote, ctl) {
261
265
 
262
266
  if (!resolved.cwd) {
263
267
  return {
264
- ok: false,
268
+ ok: false, applied: false,
265
269
  status: 400,
266
270
  error: 'cwd is required — pass cwd explicitly, or originClaudeSessionId/sourcePromptId that resolves '
267
271
  + 'to an existing, already-approved Epic (create/approve an Epic first)',
@@ -276,7 +280,7 @@ async function createPrdInner(input, remote, ctl) {
276
280
  try {
277
281
  realCwd = config.validatePath(cwd);
278
282
  } catch (e) {
279
- 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'}` };
280
284
  }
281
285
  // Register this project's cwd as a write-allowed root (config.cjs's
282
286
  // validateWrite gate). Chat-only Epics (headless claude -p, no Terminal
@@ -325,7 +329,7 @@ async function createPrdInner(input, remote, ctl) {
325
329
  const nonInteractive = Boolean(process.env.SM_SCHEDULER_JOB_SLUG);
326
330
  if (!nonInteractive) {
327
331
  return {
328
- ok: false,
332
+ ok: false, applied: false,
329
333
  status: 400,
330
334
  error: `Epic ${input.sourcePromptId} already has incomplete PRDs — pass disposition: "append" ` +
331
335
  '(extend the existing chain behind its current tail) or "new-head" (an independent root, ' +
@@ -352,7 +356,7 @@ async function createPrdInner(input, remote, ctl) {
352
356
  const nnMatch = SLUG_HAS_NN_PREFIX_RE.exec(input.slug);
353
357
  if (nnMatch) {
354
358
  return {
355
- ok: false,
359
+ ok: false, applied: false,
356
360
  status: 400,
357
361
  error: `slug "${input.slug}" already starts with a numeric prefix "${nnMatch[1]}-" — the allocator ` +
358
362
  `owns the NN- prefix, not the caller. Pass the bare name instead (e.g. "${input.slug.slice(nnMatch[0].length)}").`,
@@ -362,7 +366,7 @@ async function createPrdInner(input, remote, ctl) {
362
366
 
363
367
  const slug = input.slug || deriveSlugFromTitle(input.title);
364
368
  if (!slug || !PRD_CREATE_SLUG_RE.test(slug)) {
365
- 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' };
366
370
  }
367
371
 
368
372
  // NN allocation is delegated to allocateParallelGroup() (PRD 548) via the
@@ -377,21 +381,21 @@ async function createPrdInner(input, remote, ctl) {
377
381
  // checked BEFORE the NN allocation so a refusal burns no number.
378
382
  const hasArtifactPaths = Array.isArray(input.artifactPaths) && input.artifactPaths.length > 0;
379
383
  if (input.deliverable != null && input.deliverable !== 'artifact') {
380
- 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' };
381
385
  }
382
386
  if (input.deliverable === 'artifact' && !hasArtifactPaths) {
383
- 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' };
384
388
  }
385
389
  if (input.artifactPaths != null && input.deliverable !== 'artifact') {
386
- 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' };
387
391
  }
388
392
  if (hasArtifactPaths) {
389
393
  for (const ap of input.artifactPaths) {
390
394
  if (typeof ap !== 'string' || !ap.trim() || /[\r\n,\[\]]/.test(ap)) {
391
- 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` };
392
396
  }
393
397
  if (ap.includes('..')) {
394
- 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 ".."` };
395
399
  }
396
400
  }
397
401
  }
@@ -415,18 +419,18 @@ async function createPrdInner(input, remote, ctl) {
415
419
  }
416
420
  for (const [fieldLabel, text] of [['goal', input.goal], ['implementation notes', input.implementationNotes]]) {
417
421
  const headingError = headingGuardError(fieldLabel, text);
418
- if (headingError) return { ok: false, status: 400, error: headingError };
422
+ if (headingError) return { ok: false, applied: false, status: 400, error: headingError };
419
423
  }
420
424
  if (Array.isArray(input.acceptanceCriteria)) {
421
425
  for (const entry of input.acceptanceCriteria) {
422
426
  const headingError = headingGuardError('acceptance criteria', entry);
423
- if (headingError) return { ok: false, status: 400, error: headingError };
427
+ if (headingError) return { ok: false, applied: false, status: 400, error: headingError };
424
428
  }
425
429
  }
426
430
  if (Array.isArray(input.outOfScope)) {
427
431
  for (const entry of input.outOfScope) {
428
432
  const headingError = headingGuardError('out of scope', entry);
429
- if (headingError) return { ok: false, status: 400, error: headingError };
433
+ if (headingError) return { ok: false, applied: false, status: 400, error: headingError };
430
434
  }
431
435
  }
432
436
 
@@ -439,7 +443,7 @@ async function createPrdInner(input, remote, ctl) {
439
443
  if (input.gate !== undefined) {
440
444
  const gateValidation = validateGate(input.gate);
441
445
  if (!gateValidation.ok) {
442
- return { ok: false, status: 400, error: gateValidation.error };
446
+ return { ok: false, applied: false, status: 400, error: gateValidation.error };
443
447
  }
444
448
  gateCommands = gateValidation.commands;
445
449
  extraWarnings.push(...gateValidation.warnings);
@@ -450,7 +454,7 @@ async function createPrdInner(input, remote, ctl) {
450
454
  if (input.files !== undefined) {
451
455
  const filesValidation = validateFiles(input.files);
452
456
  if (!filesValidation.ok) {
453
- return { ok: false, status: 400, error: filesValidation.error };
457
+ return { ok: false, applied: false, status: 400, error: filesValidation.error };
454
458
  }
455
459
  filesList = filesValidation.files;
456
460
  } else {
@@ -463,15 +467,17 @@ async function createPrdInner(input, remote, ctl) {
463
467
  // Refused before NN allocation so it burns no number. Fix plans are exempt.
464
468
  if (input.sourcePromptId && !isFixPlanSlug(`0-${slug}`)) {
465
469
  const inFlightKey = `${input.sourcePromptId}\u0000${slug}`;
466
- if (inFlightCreates.has(inFlightKey)) {
470
+ if (!opts.dryRun && inFlightCreates.has(inFlightKey)) {
467
471
  return {
468
- ok: false,
472
+ ok: false, applied: false,
469
473
  status: 409,
470
474
  error: `A create for slug "${slug}" in this Epic is still in progress — wait for it to finish, then retry the same call`,
471
475
  };
472
476
  }
473
- inFlightCreates.add(inFlightKey);
474
- ctl.key = inFlightKey;
477
+ if (!opts.dryRun) {
478
+ inFlightCreates.add(inFlightKey);
479
+ ctl.key = inFlightKey;
480
+ }
475
481
  let epicDirs = [];
476
482
  try {
477
483
  const prdsDir = resolveEpicPrdWriteDir(cwd, input.sourcePromptId);
@@ -494,6 +500,7 @@ async function createPrdInner(input, remote, ctl) {
494
500
  } catch { /* non-fatal */ }
495
501
  return {
496
502
  ok: true,
503
+ applied: true,
497
504
  alreadyExisted: true,
498
505
  nn: Number((/^(\d+)-/.exec(dup) || [])[1]),
499
506
  filename: dup,
@@ -505,7 +512,7 @@ async function createPrdInner(input, remote, ctl) {
505
512
  };
506
513
  }
507
514
  return {
508
- ok: false,
515
+ ok: false, applied: false,
509
516
  status: 409,
510
517
  error: `A PRD with slug "${slug}" already exists in this Epic as "${dup}". Its work may already have landed. ` +
511
518
  'Use scheduler_update_prd / scheduler_reset_job on it, or pick a distinct slug.',
@@ -514,7 +521,8 @@ async function createPrdInner(input, remote, ctl) {
514
521
  }
515
522
  }
516
523
 
517
- 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);
518
526
  const filenameSlug = `${nn}-${slug}`;
519
527
 
520
528
  // Fix-chain depth guard (PRD 1113): refuse a fix-of-a-fix before it's ever
@@ -526,7 +534,7 @@ async function createPrdInner(input, remote, ctl) {
526
534
  if (chainDepth > FIX_CHAIN_DEPTH_CAP) {
527
535
  const base = baseSlugOf(filenameSlug);
528
536
  return {
529
- ok: false,
537
+ ok: false, applied: false,
530
538
  status: 409,
531
539
  error: `Fix-chain depth cap exceeded: "${base}" has already failed to close ${chainDepth} time(s) for ` +
532
540
  `infrastructure reasons (refusing to write "${filenameSlug}.md" at depth ${chainDepth}). Authoring a ` +
@@ -549,7 +557,7 @@ async function createPrdInner(input, remote, ctl) {
549
557
  // — but only when spawnInvestigation authors it, never through this API).
550
558
  if (isFixPlanSlug(filenameSlug)) {
551
559
  return {
552
- ok: false,
560
+ ok: false, applied: false,
553
561
  status: 400,
554
562
  error: `slug "${filenameSlug}" collides with the reserved NN-fix-... fix-plan naming convention ` +
555
563
  '(reserved for PRDs the scheduler\'s own investigation loop authors itself). Reword the title/slug ' +
@@ -563,9 +571,9 @@ async function createPrdInner(input, remote, ctl) {
563
571
  // no existence guard (by design, it doubles as the edit-in-place
564
572
  // path for Scheduler UI PRD edits), so "create" must not silently
565
573
  // clobber an existing job's PRD.
566
- const existing = await remote.readPrd(filenameSlug, input.cwd);
574
+ const existing = opts.dryRun ? null : await remote.readPrd(filenameSlug, input.cwd);
567
575
  if (existing?.ok) {
568
- 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` };
569
577
  }
570
578
 
571
579
  // Write-time FK check (throw on write, report on read — see
@@ -578,7 +586,7 @@ async function createPrdInner(input, remote, ctl) {
578
586
  try {
579
587
  await assertAgentTypeWritable(input.cwd, input.agentType);
580
588
  } catch (e) {
581
- return { ok: false, status: 400, error: e?.message ?? 'invalid agentType' };
589
+ return { ok: false, applied: false, status: 400, error: e?.message ?? 'invalid agentType' };
582
590
  }
583
591
  }
584
592
 
@@ -602,13 +610,13 @@ async function createPrdInner(input, remote, ctl) {
602
610
  }
603
611
  if (listing) {
604
612
  depRows = listing.prds ?? [];
605
- const candidateSlugs = depRows.map((p) => p.slug);
613
+ const candidateSlugs = [...depRows.map((p) => p.slug), ...(opts.extraDepSlugs ?? [])];
606
614
  for (const dep of input.dependsOn) {
607
615
  if (resolveDepSlug(dep, candidateSlugs).length > 0) continue;
608
616
  const near = findNearMatches(dep, candidateSlugs);
609
617
  const suggestion = near.length ? ` Closest existing slug(s): ${near.join(', ')}.` : '';
610
618
  return {
611
- ok: false,
619
+ ok: false, applied: false,
612
620
  status: 400,
613
621
  error: `dependsOn entry "${dep}" does not resolve to any existing PRD in this project.${suggestion} ` +
614
622
  'Pass the bare name (preferred) or the exact NN-prefixed slug of an existing PRD.',
@@ -628,6 +636,7 @@ async function createPrdInner(input, remote, ctl) {
628
636
  console.warn(`[prdCreate] dependsOn spans plans ${inherited.diverged.join(', ')} — inheriting ${planId} (lowest-numbered dependency)`);
629
637
  }
630
638
  }
639
+ const inheritedPlanId = planId;
631
640
  if (!planId) planId = mintPlanId();
632
641
 
633
642
  const body = buildPrdBody({ ...input, planId, gate: gateCommands, files: filesList });
@@ -647,7 +656,7 @@ async function createPrdInner(input, remote, ctl) {
647
656
  && JSON.stringify(resolved.sequence) === JSON.stringify(expectedSequence);
648
657
  if (!matches) {
649
658
  return {
650
- ok: false,
659
+ ok: false, applied: false,
651
660
  status: 400,
652
661
  error: 'The PRD text already holds a gate fence (three backticks + gate), so the scheduler would read '
653
662
  + 'the wrong gate. Remove it from the goal, implementation notes, acceptance criteria, out of scope '
@@ -656,9 +665,16 @@ async function createPrdInner(input, remote, ctl) {
656
665
  }
657
666
  }
658
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
+
659
675
  const writeResult = await remote.writePrd(filenameSlug, body, input.cwd);
660
676
  if (!writeResult?.ok) {
661
- return { ok: false, status: 500, error: writeResult?.error ?? 'write failed' };
677
+ return { ok: false, applied: false, status: 500, error: writeResult?.error ?? 'write failed' };
662
678
  }
663
679
 
664
680
  // Trace-back point for "who/what created this PRD" (auditLog.cjs) — every
@@ -671,6 +687,8 @@ async function createPrdInner(input, remote, ctl) {
671
687
  sourcePromptId: input.sourcePromptId ?? null,
672
688
  sourceTabId: input.sourceTabId ?? null,
673
689
  });
690
+ // Anonymous counter: enum-mapped agentType only (telemetryCounters.cjs).
691
+ telemetryCounters.trackPrdCreate({ agentType: input.agentType || DEFAULT_PRD_AGENT_TYPE });
674
692
 
675
693
  // PRD 1446: writePrd() succeeding used to just promise "the next scheduler
676
694
  // reconcile pass (~1 minute)" adopts this file — but nothing reliably ran
@@ -686,7 +704,12 @@ async function createPrdInner(input, remote, ctl) {
686
704
  let enqueued = false;
687
705
  if (typeof remote.requestReconcile === 'function') {
688
706
  try {
689
- 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); }
690
713
  if (typeof remote.getJob === 'function') {
691
714
  enqueued = Boolean(await remote.getJob(filenameSlug));
692
715
  }
@@ -706,6 +729,7 @@ async function createPrdInner(input, remote, ctl) {
706
729
  // row before this function returned — it is not a fabricated status.
707
730
  return {
708
731
  ok: true,
732
+ applied: true,
709
733
  nn,
710
734
  filename: `${filenameSlug}.md`,
711
735
  prdPath: writeResult.path ?? null,
@@ -728,6 +752,128 @@ async function createPrdInner(input, remote, ctl) {
728
752
  };
729
753
  }
730
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
+
731
877
  /**
732
878
  * Registers the create-prd admin HTTP route (PRD 689 — moved verbatim out of
733
879
  * the former standalone admin HTTP server module's handleRequest, no behavior
@@ -776,6 +922,31 @@ function registerAdminRoute(adminHttp, remote) {
776
922
  warnings: result.warnings,
777
923
  });
778
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
+ });
779
950
  }
780
951
 
781
952
  module.exports = {
@@ -786,5 +957,7 @@ module.exports = {
786
957
  buildPrdBody,
787
958
  resolveSourcePromptIdFromClaudeSession,
788
959
  createPrd,
960
+ createPrdBatch,
961
+ PRD_BATCH_MAX,
789
962
  registerAdminRoute,
790
963
  };
@@ -244,6 +244,36 @@ function renderFilesSection(files) {
244
244
  ];
245
245
  }
246
246
 
247
+ /**
248
+ * Inverse of renderFilesSection: read the bullet entries under the `# Files`
249
+ * heading (up to the next top-level heading) back out of a PRD body. Returns
250
+ * [] when there is no such section. Strips only the renderer's `- ` prefix
251
+ * (plus tolerated wrapping backticks); prose lines are ignored.
252
+ *
253
+ * @param {string} body
254
+ * @returns {string[]}
255
+ */
256
+ function parseFilesSection(body) {
257
+ if (typeof body !== 'string') return [];
258
+ const lines = body.split(/\r?\n/);
259
+ const start = lines.findIndex((l) => /^#\s+Files\s*$/.test(l));
260
+ if (start === -1) return [];
261
+ const out = [];
262
+ let inFence = false;
263
+ for (let i = start + 1; i < lines.length; i++) {
264
+ const line = lines[i];
265
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
266
+ if (!inFence && /^#\s/.test(line)) break;
267
+ const m = line.match(/^[-*]\s+(.+?)\s*$/);
268
+ if (!m) continue;
269
+ let entry = m[1];
270
+ const tick = entry.match(/^`([^`]+)`$/);
271
+ if (tick) entry = tick[1];
272
+ if (entry && !out.includes(entry)) out.push(entry);
273
+ }
274
+ return out;
275
+ }
276
+
247
277
  /**
248
278
  * Render the `# Gate` body section as an array of lines. `commandsOrNone`
249
279
  * must already be validated (validateGate's `commands` output) — either
@@ -280,5 +310,6 @@ module.exports = {
280
310
  validateGate,
281
311
  validateFiles,
282
312
  renderFilesSection,
313
+ parseFilesSection,
283
314
  renderGateSection,
284
315
  };