claude-code-session-manager 0.35.15 → 0.35.17

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 (35) hide show
  1. package/dist/assets/{TiptapBody-B7PHDZwk.js → TiptapBody-BM9Kz1Nm.js} +1 -1
  2. package/dist/assets/index-DX2w2YhJ.css +32 -0
  3. package/dist/assets/index-DuoC6oCy.js +3559 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +2 -1
  6. package/src/main/__tests__/adminServer.test.cjs +205 -0
  7. package/src/main/__tests__/chat-queue.test.cjs +27 -0
  8. package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
  9. package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
  10. package/src/main/__tests__/prdCreate.test.cjs +82 -0
  11. package/src/main/__tests__/runVerify.test.cjs +146 -0
  12. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
  13. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
  14. package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
  15. package/src/main/adminServer.cjs +87 -2
  16. package/src/main/browserCapture.cjs +21 -7
  17. package/src/main/chatRunner.cjs +5 -1
  18. package/src/main/health.cjs +114 -3
  19. package/src/main/hives.cjs +2 -1
  20. package/src/main/ipcSchemas.cjs +33 -4
  21. package/src/main/lib/kebabCase.cjs +12 -0
  22. package/src/main/lib/memorySlug.cjs +10 -0
  23. package/src/main/lib/prdCreate.cjs +73 -0
  24. package/src/main/memoryAggregate.cjs +1 -1
  25. package/src/main/memoryTool.cjs +2 -1
  26. package/src/main/pty.cjs +2 -1
  27. package/src/main/runVerify.cjs +119 -6
  28. package/src/main/scheduler/prdParser.cjs +88 -0
  29. package/src/main/scheduler.cjs +176 -9
  30. package/src/main/templates/PRD_AUTHORING.md +126 -0
  31. package/src/main/usage.cjs +4 -0
  32. package/src/main/webRemote.cjs +13 -18
  33. package/src/preload/api.d.ts +1 -0
  34. package/dist/assets/index-B39Qiq0n.css +0 -32
  35. package/dist/assets/index-DRSHENEg.js +0 -3559
@@ -9,10 +9,27 @@ const fsp = require('node:fs/promises');
9
9
  const path = require('node:path');
10
10
  const os = require('node:os');
11
11
  const { execFileSync } = require('node:child_process');
12
+ const { POLL_INTERVAL_MS } = require('./lib/schedulerConfig.cjs');
12
13
 
13
14
  const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
14
15
  const PROJECT_ROOT = path.resolve(__dirname, '../..');
15
16
 
17
+ // tickQueue() only fires (and updates lastRunAt) when a batch is actually
18
+ // spawned — it does not tick on a fixed cadence — but the poll loop that
19
+ // *invokes* tickQueue backs off starting from POLL_INTERVAL_MS (scheduler.cjs
20
+ // pollLoop). 3x gives the poll loop three chances to notice free capacity +
21
+ // pending work before we call it a stall, absorbing normal jitter (backoff,
22
+ // memory-gate deferrals, boot warmup) without waiting so long that a real
23
+ // outage goes unnoticed for hours (the 2026-07-14 incident sat stalled for
24
+ // 16.5h before anything asserted on it).
25
+ const TICK_STALL_MULTIPLIER = 3;
26
+ const TICK_STALL_THRESHOLD_MS = TICK_STALL_MULTIPLIER * POLL_INTERVAL_MS;
27
+ // A heartbeat line older than this is treated as "the app isn't running /
28
+ // we can't observe live utilization" rather than "utilization is low" —
29
+ // scheduler-heartbeat.log is only appended to while Electron is running, so
30
+ // a stale line here is silent-on-purpose, not evidence of anything.
31
+ const HEARTBEAT_STALE_MS = 5 * 60_000;
32
+
16
33
  function runCheck(cmd, cwd = PROJECT_ROOT) {
17
34
  try {
18
35
  execFileSync('bash', ['-c', cmd], {
@@ -26,6 +43,80 @@ function runCheck(cmd, cwd = PROJECT_ROOT) {
26
43
  }
27
44
  }
28
45
 
46
+ // Reads the last line of scheduler-heartbeat.log, if fresh enough to trust.
47
+ // Returns null when the file is missing, empty, unparseable, or stale — all
48
+ // of which mean "can't observe live utilization right now", not "utilization
49
+ // is low".
50
+ function readFreshHeartbeat(heartbeatPath) {
51
+ let lines;
52
+ try {
53
+ lines = fs.readFileSync(heartbeatPath, 'utf8').split('\n').filter(Boolean);
54
+ } catch {
55
+ return null;
56
+ }
57
+ if (lines.length === 0) return null;
58
+ let entry;
59
+ try {
60
+ entry = JSON.parse(lines[lines.length - 1]);
61
+ } catch {
62
+ return null;
63
+ }
64
+ if (typeof entry.ts !== 'number' || Date.now() - entry.ts > HEARTBEAT_STALE_MS) return null;
65
+ return entry;
66
+ }
67
+
68
+ // Evaluates whether the scheduler tick looks stalled: pending work exists,
69
+ // there's free capacity to run it, and nothing about the queue's own state
70
+ // explains why it hasn't. Kept as a pure function of (queueState, heartbeat,
71
+ // now) so it's testable without touching the filesystem.
72
+ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
73
+ const jobs = queueState.jobs || [];
74
+ const pending = jobs.filter((j) => j.status === 'pending');
75
+ const running = runningCount ?? jobs.filter((j) => j.status === 'running').length;
76
+ const config = queueState.config || {};
77
+ const concurrencyCap = config.concurrencyCap ?? Infinity;
78
+
79
+ if (pending.length === 0) return { stalled: false, reason: 'no-pending-jobs' };
80
+ if (queueState.paused) return { stalled: false, reason: 'paused' };
81
+ if (config.enabled === false) return { stalled: false, reason: 'disabled' };
82
+ if (running >= concurrencyCap) return { stalled: false, reason: 'at-capacity' };
83
+
84
+ const lastRunAt = queueState.lastRunAt ? Date.parse(queueState.lastRunAt) : null;
85
+ // No lastRunAt at all (fresh install, never ticked) — nothing to measure
86
+ // staleness against yet; don't manufacture a false positive.
87
+ if (lastRunAt == null || Number.isNaN(lastRunAt)) {
88
+ return { stalled: false, reason: 'no-lastRunAt', caveat: true };
89
+ }
90
+ const tickAgeMs = now - lastRunAt;
91
+ if (tickAgeMs <= TICK_STALL_THRESHOLD_MS) return { stalled: false, reason: 'recent-tick' };
92
+
93
+ // Candidate stall. The 'when-available' firePolicy legitimately holds
94
+ // pending jobs when billing utilization is at/above utilizationThreshold —
95
+ // rule that out before calling it a stall.
96
+ if (config.firePolicy === 'when-available' && typeof config.utilizationThreshold === 'number') {
97
+ if (!heartbeat) {
98
+ return {
99
+ stalled: false,
100
+ caveat: true,
101
+ reason: 'cannot-verify-utilization',
102
+ tickAgeMs,
103
+ oldestPendingSlug: pending[0]?.slug,
104
+ };
105
+ }
106
+ if (typeof heartbeat.utilization === 'number' && heartbeat.utilization >= config.utilizationThreshold) {
107
+ return { stalled: false, reason: 'utilization-at-threshold', utilization: heartbeat.utilization };
108
+ }
109
+ }
110
+
111
+ return {
112
+ stalled: true,
113
+ reason: 'stalled',
114
+ tickAgeMs,
115
+ oldestPendingSlug: pending[0]?.slug,
116
+ pendingCount: pending.length,
117
+ };
118
+ }
119
+
29
120
  async function check() {
30
121
  const start = Date.now();
31
122
  const status = {
@@ -106,13 +197,33 @@ async function check() {
106
197
  const failedCount = Object.values(queueState.jobs || {}).filter(
107
198
  (j) => j.status === 'failed'
108
199
  ).length;
200
+ const heartbeatPath = path.join(
201
+ os.homedir(),
202
+ '.claude/session-manager/scheduler-heartbeat.log'
203
+ );
204
+ const heartbeat = readFreshHeartbeat(heartbeatPath);
205
+ const liveness = evaluateTickLiveness(queueState, heartbeat, Date.now(), runningCount);
109
206
  status.components.scheduler_queue = {
110
- ok: true,
207
+ ok: !liveness.stalled,
111
208
  path: queuePath,
112
209
  jobs: Object.keys(queueState.jobs || {}).length,
113
210
  running: runningCount,
114
211
  failed: failedCount,
212
+ tickLiveness: liveness.reason,
115
213
  };
214
+ if (liveness.stalled) {
215
+ const ageMin = Math.round(liveness.tickAgeMs / 60_000);
216
+ status.components.scheduler_queue.stalledJob = liveness.oldestPendingSlug;
217
+ status.components.scheduler_queue.tickAgeMs = liveness.tickAgeMs;
218
+ status.issues.push(
219
+ `Scheduler tick appears stalled: "${liveness.oldestPendingSlug}" (and ${liveness.pendingCount - 1} other pending job(s)) has been waiting ~${ageMin}m with free capacity and no tick progress`
220
+ );
221
+ } else if (liveness.caveat) {
222
+ status.components.scheduler_queue.caveat =
223
+ liveness.reason === 'cannot-verify-utilization'
224
+ ? `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`
225
+ : 'No lastRunAt recorded yet — cannot assess tick liveness';
226
+ }
116
227
  } catch (e) {
117
228
  if (e.code !== 'ENOENT') {
118
229
  status.issues.push(`Scheduler queue unreadable: ${e.message}`);
@@ -197,7 +308,7 @@ async function check() {
197
308
  // Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
198
309
  // Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
199
310
  // Informational: app log age (shows if app is running, but not blocking).
200
- const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure'];
311
+ const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue'];
201
312
  status.ok = criticalComponents.every((c) => status.components[c]?.ok !== false);
202
313
 
203
314
  status.elapsedMs = Date.now() - start;
@@ -213,4 +324,4 @@ if (require.main === module) {
213
324
  })();
214
325
  }
215
326
 
216
- module.exports = { check };
327
+ module.exports = { check, evaluateTickLiveness, readFreshHeartbeat, TICK_STALL_THRESHOLD_MS, HEARTBEAT_STALE_MS };
@@ -33,6 +33,7 @@ const path = require('node:path');
33
33
  const os = require('node:os');
34
34
  const { z } = require('zod');
35
35
  const config = require('./config.cjs');
36
+ const { kebabCase: sharedKebabCase } = require('./lib/kebabCase.cjs');
36
37
 
37
38
  // ──────────────────────────────────────────── caps
38
39
  const SLUG_RE = /^[a-z0-9-_]{1,64}$/;
@@ -89,7 +90,7 @@ async function ensureRoot() {
89
90
 
90
91
  // ──────────────────────────────────────────── helpers
91
92
  function kebabCase(s) {
92
- return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'agent';
93
+ return sharedKebabCase(s, { maxLen: 64, fallback: 'agent' });
93
94
  }
94
95
 
95
96
  // ──────────────────────────────────────────── legacy migration
@@ -228,6 +228,33 @@ const scheduleWritePrd = z.object({
228
228
  ),
229
229
  });
230
230
 
231
+ // PRD create (admin API — scheduler_create_prd, PRD 549). This slug is
232
+ // deliberately STRICTER than SCHEDULE_SLUG_RE: it becomes a brand-new
233
+ // filename segment written to disk by the create route, not a lookup key
234
+ // for an existing file, so it excludes '.' and '_' too (matches
235
+ // pluginInstall.cjs's slug precedent, minus '/' — this slug must never
236
+ // introduce a subdirectory).
237
+ const PRD_CREATE_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
238
+ // title/cwd become frontmatter VALUES (`title: ${title}`) in prdCreate.cjs's
239
+ // hand-rolled `key: value` serializer, which — unlike a real YAML
240
+ // library — has no escaping. A title/cwd containing '\n' could inject a
241
+ // bare `\n---\n` that terminates the frontmatter block early (per
242
+ // prdFrontmatter.cjs's `indexOf('\n---')` parse) and smuggle extra
243
+ // frontmatter-shaped lines into the body. Block newlines at the boundary
244
+ // instead of trying to escape them later.
245
+ const NO_NEWLINE_RE = /^[^\r\n]*$/;
246
+ const schedulerCreatePrd = z.object({
247
+ title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
248
+ cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines'),
249
+ estimateMinutes: z.number().int().min(1).max(100000),
250
+ goal: z.string().min(1).max(20000),
251
+ acceptanceCriteria: z.array(z.string().min(1).max(2000)).min(1).max(100),
252
+ implementationNotes: z.string().min(1).max(20000),
253
+ outOfScope: z.array(z.string().min(1).max(2000)).max(100).optional(),
254
+ slug: z.string().min(1).max(60).regex(PRD_CREATE_SLUG_RE).optional(),
255
+ parallelGroup: z.number().int().min(1).max(999999).optional(),
256
+ });
257
+
231
258
  // Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
232
259
  const scheduleArchivePrd = z.object({
233
260
  slugs: z.array(z.string().regex(SCHEDULE_SLUG_RE)).min(1).max(500),
@@ -290,11 +317,11 @@ const setConfigSchema = z.object({
290
317
  }).strict();
291
318
 
292
319
  // ──────────────────────────────────────────── Memory tool (Bundle C, cycle 3)
293
- // Workspace-scoped markdown store at ~/.claude/session-manager/memories/<ws>/.
294
- // Slug regex must match memoryTool.cjs SLUG_RE; workspace regex matches its
295
- // encodeWorkspace() output (alphanumeric + dash) plus 'default'.
320
+ // Workspace-scoped markdown store at ~/.claude/projects/<ws>/memory/.
321
+ // Slug regex is shared with memoryTool.cjs/memoryAggregate.cjs via lib/memorySlug.cjs;
322
+ // workspace regex matches encodeWorkspace() output (alphanumeric + dash) plus 'default'.
296
323
  const MEMORY_WORKSPACE_RE = /^[a-zA-Z0-9-_]{1,256}$/;
297
- const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
324
+ const { MEMORY_SLUG_RE } = require('./lib/memorySlug.cjs');
298
325
  // 1 MiB hard cap — matches MAX_FILE_BYTES in memoryTool.cjs.
299
326
  const MEMORY_MAX_BYTES = 1024 * 1024;
300
327
 
@@ -613,6 +640,7 @@ module.exports = {
613
640
  // direct test()/match() containment checks alongside the zod parses.
614
641
  SCHEDULE_SLUG_RE,
615
642
  SCHEDULE_RUN_ID_RE,
643
+ PRD_CREATE_SLUG_RE,
616
644
  READ_COMMANDS,
617
645
  SAS_GATED_READS,
618
646
  MUTATE_COMMANDS,
@@ -651,6 +679,7 @@ module.exports = {
651
679
  scheduleSlug,
652
680
  scheduleReadLog,
653
681
  scheduleWritePrd,
682
+ schedulerCreatePrd,
654
683
  scheduleArchivePrd,
655
684
  scheduleRetagPrd,
656
685
  setConfigSchema,
@@ -0,0 +1,12 @@
1
+ /**
2
+ * kebabCase.cjs — shared lowercase/kebab-case slugify. Extracted from
3
+ * hives.cjs (private `kebabCase` helper) so prdCreate.cjs doesn't fork a
4
+ * second, subtly-different copy (API reuse: one concept, one implementation).
5
+ */
6
+ 'use strict';
7
+
8
+ function kebabCase(s, { maxLen = 64, fallback = '' } = {}) {
9
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, maxLen) || fallback;
10
+ }
11
+
12
+ module.exports = { kebabCase };
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared workspace-memory filename regex. memoryTool.cjs (CRUD), memoryAggregate.cjs
5
+ * (clustering), and ipcSchemas.cjs (IPC validation) all gate on the same slug shape —
6
+ * single source of truth so the three don't drift.
7
+ */
8
+ const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
9
+
10
+ module.exports = { MEMORY_SLUG_RE };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * prdCreate.cjs — PRD-body builder for the create-prd admin route (PRD 549,
3
+ * gh-issue-6). Pure functions only: no filesystem writes, no NN allocation,
4
+ * no HTTP. adminServer.cjs owns orchestration (auth, cwd validation via
5
+ * config.cjs's validatePath, NN allocation via the injected remote,
6
+ * writing via remote.writePrd -> config.cjs's writeTextAtomic) so this
7
+ * module stays trivially unit-testable.
8
+ *
9
+ * Standards are read fresh from disk on every call (no in-process caching)
10
+ * so a live edit to standards.md is picked up by the next create-prd call
11
+ * without an app restart — same one-concept-one-implementation reasoning
12
+ * that keeps the /develop skill re-reading it fresh per PRD (see SKILL.md).
13
+ */
14
+ 'use strict';
15
+
16
+ const fsp = require('node:fs/promises');
17
+ const path = require('node:path');
18
+ const { PRD_CREATE_SLUG_RE } = require('../ipcSchemas.cjs');
19
+ const { kebabCase } = require('./kebabCase.cjs');
20
+
21
+ const STANDARDS_PATH = path.join(
22
+ __dirname, '..', '..', '..',
23
+ 'plugins', 'session-manager-dev', 'skills', 'develop', 'standards.md',
24
+ );
25
+
26
+ async function readStandards() {
27
+ return fsp.readFile(STANDARDS_PATH, 'utf8');
28
+ }
29
+
30
+ /** Lowercase, kebab-case, strip anything outside [a-z0-9-], cap at 60 chars. */
31
+ function deriveSlugFromTitle(title) {
32
+ return kebabCase(String(title), { maxLen: 60 });
33
+ }
34
+
35
+ /**
36
+ * Build the full PRD markdown body (frontmatter + required sections +
37
+ * verbatim engineering standards), matching the structure `/develop`'s
38
+ * SKILL.md documents: frontmatter, then Goal / Acceptance criteria /
39
+ * Implementation notes / Out of scope / Engineering standards, in order.
40
+ */
41
+ function buildPrdBody(input, standardsText) {
42
+ const {
43
+ title, cwd, estimateMinutes, goal, acceptanceCriteria,
44
+ implementationNotes, outOfScope,
45
+ } = input;
46
+
47
+ // No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
48
+ // filename prefix is the single source of truth for grouping; adding a
49
+ // second one here would let the two drift out of sync.
50
+ const fmLines = ['---', `title: ${title}`, `cwd: ${cwd}`, `estimateMinutes: ${estimateMinutes}`, '---', ''];
51
+
52
+ const acLines = acceptanceCriteria.map((line) => `- [ ] ${line}`).join('\n');
53
+ const oosSource = outOfScope && outOfScope.length ? outOfScope : ['(none)'];
54
+ const oosLines = oosSource.map((line) => `- ${line}`).join('\n');
55
+
56
+ const bodyLines = [
57
+ '# Goal', '', goal, '',
58
+ '# Acceptance criteria', '', acLines, '',
59
+ '# Implementation notes', '', implementationNotes, '',
60
+ '# Out of scope', '', oosLines, '',
61
+ '## Engineering standards', '', standardsText.trimEnd(), '',
62
+ ];
63
+
64
+ return `${fmLines.join('\n')}${bodyLines.join('\n')}`;
65
+ }
66
+
67
+ module.exports = {
68
+ PRD_CREATE_SLUG_RE,
69
+ STANDARDS_PATH,
70
+ readStandards,
71
+ deriveSlugFromTitle,
72
+ buildPrdBody,
73
+ };
@@ -27,10 +27,10 @@ const { encodeCwd } = require('./lib/encodeCwd.cjs');
27
27
  const { extractJson } = require('./lib/extractJson.cjs');
28
28
  const { writeJson } = require('./config.cjs');
29
29
  const config = require('./config.cjs');
30
+ const { MEMORY_SLUG_RE } = require('./lib/memorySlug.cjs');
30
31
 
31
32
  const HOME = os.homedir();
32
33
  const CLUSTERS_DIR = path.join(HOME, '.claude', 'session-manager', 'memory-clusters');
33
- const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
34
34
 
35
35
  function memoryDir(workspace) {
36
36
  return path.join(HOME, '.claude', 'projects', workspace, 'memory');
@@ -30,9 +30,10 @@ const path = require('node:path');
30
30
  const os = require('node:os');
31
31
  const config = require('./config.cjs');
32
32
 
33
+ const { MEMORY_SLUG_RE: SLUG_RE } = require('./lib/memorySlug.cjs');
34
+
33
35
  const MAX_FILE_BYTES = 1024 * 1024; // 1 MiB
34
36
  const MAX_ENTRIES = 1000;
35
- const SLUG_RE = /^[a-z0-9-_]+\.md$/;
36
37
 
37
38
  const { encodeCwd: encodeWorkspace } = require('./lib/encodeCwd.cjs');
38
39
 
package/src/main/pty.cjs CHANGED
@@ -245,8 +245,9 @@ function registerPtyHandlers() {
245
245
  ipcMain.on('pty:resize', (_e, payload) => { try { manager.resize(s.ptyResize.parse(payload)); } catch { /* ignore */ } });
246
246
  ipcMain.on('pty:kill', (_e, tabId) => {
247
247
  if (typeof tabId !== 'string') return;
248
+ // manager.kill() already drops superagent run state for tabId — see kill()
249
+ // above. Do not duplicate that call here.
248
250
  manager.kill(tabId);
249
- try { require('./superagent.cjs').dropTab(tabId); } catch { /* superagent module not initialized (e2e) */ }
250
251
  });
251
252
  }
252
253
 
@@ -36,9 +36,13 @@
36
36
 
37
37
  const fs = require('node:fs');
38
38
  const path = require('node:path');
39
+ const { execFileSync } = require('node:child_process');
39
40
 
40
41
  const VERDICTS_SCHEMA_VERSION = 1;
41
42
 
43
+ // Bound on the `gh pr view` postcondition check below (§ merge-main exemption).
44
+ const GH_CHECK_TIMEOUT_MS = 15_000;
45
+
42
46
  // ─── content pattern detectors ───────────────────────────────────────────────
43
47
 
44
48
  /**
@@ -445,6 +449,69 @@ function scanSentinel(resultEvent, events) {
445
449
  return null;
446
450
  }
447
451
 
452
+ // ─── merge-main postcondition exemption ──────────────────────────────────────
453
+
454
+ /**
455
+ * True for the `NN-prXXX-merge-main` / `NN-fix-prXXX-merge-main` slug
456
+ * convention already used dozens of times in this queue (merge-current-main
457
+ * PRDs targeting a shared repo's PR branch). Narrow, mechanical string check —
458
+ * this is the only slug shape exempted from the `pass_no_commit` flag below.
459
+ */
460
+ function isMergeMainSlug(slug) {
461
+ return typeof slug === 'string' && /-merge-main$/.test(slug);
462
+ }
463
+
464
+ /**
465
+ * Extract the target PR number for a `-merge-main` PRD. The convention embeds
466
+ * it two ways: in the frontmatter title/body as "PR #<n>" (preferred — the
467
+ * human-authored, unambiguous source), and in the slug itself as "prNNN"
468
+ * (fallback, for PRD text that doesn't spell it out). Returns null if neither
469
+ * source yields a number.
470
+ */
471
+ function extractMergeMainPrNumber(slug, prdFullText) {
472
+ if (typeof prdFullText === 'string') {
473
+ const m = prdFullText.match(/PR\s*#(\d+)/i);
474
+ if (m) return parseInt(m[1], 10);
475
+ }
476
+ const sm = typeof slug === 'string' ? slug.match(/pr(\d+)-merge-main$/) : null;
477
+ if (sm) return parseInt(sm[1], 10);
478
+ return null;
479
+ }
480
+
481
+ /**
482
+ * Independently re-check a PR's mergeable state via `gh pr view`. Used only to
483
+ * decide whether a `-merge-main` PRD's unsubstantiated PASS (no commit landed
484
+ * during the run) reflects a target that was already satisfied by an
485
+ * out-of-band actor before this run started — see the 2026-07-18 false-positive
486
+ * feedback item.
487
+ *
488
+ * Bounded (15s) and fully fail-safe: ANY error (gh missing/unauthed, network,
489
+ * timeout, malformed JSON, non-existent PR) resolves `{ ok: false }` rather
490
+ * than throwing, so a failure here only ever falls back to today's behavior —
491
+ * it can never turn a real failure into a false "verified".
492
+ *
493
+ * `execImpl` is injectable (defaults to `child_process.execFileSync`) so unit
494
+ * tests can stub the subprocess call without shelling out to a real `gh`.
495
+ *
496
+ * @returns {{ ok: boolean, data?: { mergeable: string, mergeStateStatus: string }, error?: string }}
497
+ */
498
+ function checkMergeablePr({ cwd, prNumber, timeoutMs = GH_CHECK_TIMEOUT_MS, execImpl = execFileSync }) {
499
+ try {
500
+ const out = execImpl(
501
+ 'gh',
502
+ ['pr', 'view', String(prNumber), '--json', 'mergeable,mergeStateStatus'],
503
+ { cwd, timeout: timeoutMs, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
504
+ );
505
+ const data = JSON.parse(out);
506
+ if (data && data.mergeable === 'MERGEABLE' && data.mergeStateStatus !== 'CONFLICTING') {
507
+ return { ok: true, data };
508
+ }
509
+ return { ok: false, data };
510
+ } catch (e) {
511
+ return { ok: false, error: e?.message ?? String(e) };
512
+ }
513
+ }
514
+
448
515
  // ─── main verifier ────────────────────────────────────────────────────────────
449
516
 
450
517
  /**
@@ -467,9 +534,13 @@ function scanSentinel(resultEvent, events) {
467
534
  * even without a PASS sentinel. Only
468
535
  * set by the boot reverify self-heal
469
536
  * pass for pre-sentinel legacy runs.
537
+ * @param {Function} [params.ghExecImpl] Test-only override for the `gh pr view`
538
+ * subprocess call used by the -merge-main
539
+ * postcondition exemption. Defaults to
540
+ * child_process.execFileSync.
470
541
  * @returns {Promise<{verdict:string, reason:string, downgradeTo:string|null}>}
471
542
  */
472
- async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedDuringRun = false, allowPreSentinelHeal = false }) {
543
+ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedDuringRun = false, allowPreSentinelHeal = false, ghExecImpl }) {
473
544
  const { slug } = queueEntry;
474
545
  const logPath = path.join(runDir, `${slug}.log`);
475
546
  const verdictsPath = path.join(runDir, `${slug}.verdicts.json`);
@@ -491,8 +562,10 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
491
562
  try {
492
563
  // ── Read PRD body (strip frontmatter) ──────────────────────────────────
493
564
  let prdBody = '';
565
+ let prdFullText = '';
494
566
  try {
495
567
  const prdText = fs.readFileSync(prdPath, 'utf8');
568
+ prdFullText = prdText;
496
569
  if (prdText.startsWith('---\n')) {
497
570
  const end = prdText.indexOf('\n---', 4);
498
571
  prdBody = end !== -1 ? prdText.slice(end + 4).trim() : prdText;
@@ -650,11 +723,48 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
650
723
  // signal and stays covered by this check.
651
724
  const isFixPlanJob = /^\d+-fix-/.test(queueEntry?.slug || '');
652
725
  if (sentinel === 'pass' && !committedDuringRun && !isFixPlanJob) {
653
- issues.push({
654
- verdict: 'pass_no_commit',
655
- reason: 'SCHEDULER_VERDICT: PASS but no commit landed during the run window — the run claims success but produced no code change',
656
- priority: 1,
657
- });
726
+ // EXEMPTION: `-merge-main` PRDs (see isMergeMainSlug) can genuinely and
727
+ // correctly find nothing left to do when an out-of-band actor (a human,
728
+ // another agent, a sibling scheduler job) already merged/updated the
729
+ // target PR branch before this run started. Before flagging, independently
730
+ // re-check the PR's real mergeable state via `gh` — if it confirms the
731
+ // target is already clean, this is not a false PASS, it's a correct one.
732
+ // Fails safe: any `gh` error falls straight through to the pass_no_commit
733
+ // flag below, exactly as before this exemption existed.
734
+ let mergeMainVerified = false;
735
+ if (isMergeMainSlug(slug)) {
736
+ const prNumber = extractMergeMainPrNumber(slug, prdFullText);
737
+ if (prNumber != null) {
738
+ const ghResult = checkMergeablePr({
739
+ cwd: queueEntry?.cwd,
740
+ prNumber,
741
+ ...(ghExecImpl ? { execImpl: ghExecImpl } : {}),
742
+ });
743
+ if (ghResult.ok) {
744
+ mergeMainVerified = true;
745
+ return conclude(
746
+ 'pass_no_commit_target_verified',
747
+ `SCHEDULER_VERDICT: PASS with no commit, but independently verified PR #${prNumber} is mergeable `
748
+ + `(mergeStateStatus: ${ghResult.data.mergeStateStatus}) — target was already satisfied by an `
749
+ + 'out-of-band actor before this run started',
750
+ null,
751
+ {
752
+ ...(annotations.length ? { annotations } : {}),
753
+ sentinel,
754
+ verifiedPrNumber: prNumber,
755
+ ghMergeState: ghResult.data,
756
+ },
757
+ );
758
+ }
759
+ }
760
+ }
761
+ if (!mergeMainVerified) {
762
+ issues.push({
763
+ verdict: 'pass_no_commit',
764
+ reason: 'SCHEDULER_VERDICT: PASS but no commit landed during the run window — the run claims success but produced no code change',
765
+ priority: 1,
766
+ });
767
+ }
658
768
  }
659
769
 
660
770
  if (issues.length === 0) {
@@ -726,4 +836,7 @@ module.exports = {
726
836
  checkDeps,
727
837
  parseLog,
728
838
  scanSentinel,
839
+ isMergeMainSlug,
840
+ extractMergeMainPrNumber,
841
+ checkMergeablePr,
729
842
  };
@@ -141,10 +141,98 @@ function _resetCache() {
141
141
  prdFileCache.clear();
142
142
  }
143
143
 
144
+ // ────────────────────────────────────────────── group allocation
145
+ //
146
+ // `NN` (the filename prefix, parsed above by groupFromName) is both the
147
+ // authoring counter AND the parallel-group key the scheduler groups by.
148
+ // Two authors computing "max NN in prds/" concurrently can allocate the
149
+ // same NN for unrelated PRDs, which silently merges two projects' work
150
+ // into one parallel group (incident 2026-07-14/2026-07-15, PRDs 05-11 vs
151
+ // 06-09, then 545 collided again). allocateParallelGroup() closes that
152
+ // race with an O(n) full-directory scan (not a caller-narrowed glob — see
153
+ // PRD_AUTHORING.md's `'^10[0-9]'` cautionary example) plus an O_EXCL
154
+ // reservation file, so two concurrent callers can never walk away with
155
+ // the same new NN.
156
+ //
157
+ // A reservation is a zero-byte `.reserved-<NN>` file created with the
158
+ // 'wx' (O_CREAT|O_EXCL) flag — the OS guarantees only one of two racing
159
+ // `open()` calls for the same path succeeds. Reservations are counted
160
+ // alongside real `NN-slug.md` files when computing the next max, so a
161
+ // crash between "reserve NN" and "author NN-slug.md" just permanently
162
+ // retires that one NN rather than wedging future allocations. This keeps
163
+ // the filesystem itself as the single source of truth (no separate
164
+ // `.next-nn`/registry file that could drift out of sync with prds/).
165
+ //
166
+ // Callers who want an EXISTING group (deliberate parallel siblings, e.g.
167
+ // three PRDs sharing 545) never call this — they just write the shared
168
+ // `NN-` prefix directly, same as today. This function only mints NEW
169
+ // group numbers.
170
+
171
+ const RESERVATION_RE = /^\.reserved-(\d+)$/;
172
+ const GROUP_PREFIX_RE = /^(\d+)-/;
173
+ const MAX_RESERVE_ATTEMPTS = 1000;
174
+
175
+ /**
176
+ * Highest `NN` currently in use under prdsDir, across real `NN-slug.md`
177
+ * PRD files (any digit count, unpadded or not — matches groupFromName
178
+ * above) AND in-flight `.reserved-NN` markers. Full directory scan, no
179
+ * narrowed glob. Returns 0 if the directory is empty/missing.
180
+ */
181
+ async function maxParallelGroupInUse(prdsDir) {
182
+ let entries;
183
+ try {
184
+ entries = await fsp.readdir(prdsDir);
185
+ } catch {
186
+ return 0;
187
+ }
188
+ let max = 0;
189
+ for (const name of entries) {
190
+ if (name.startsWith('.')) {
191
+ const rm = name.match(RESERVATION_RE);
192
+ if (rm) max = Math.max(max, Number(rm[1]));
193
+ continue;
194
+ }
195
+ if (!name.endsWith('.md')) continue;
196
+ const m = name.match(GROUP_PREFIX_RE);
197
+ if (m) max = Math.max(max, Number(m[1]));
198
+ }
199
+ return max;
200
+ }
201
+
202
+ /**
203
+ * Atomically allocate a brand-new parallel-group number under prdsDir.
204
+ * Returns the reserved NN. Reservation survives a crash mid-allocation
205
+ * (the `.reserved-NN` marker just sits there forever, retiring that one
206
+ * number) and never wedges future callers, since each attempt only needs
207
+ * the marker for its OWN candidate to not already exist.
208
+ */
209
+ async function allocateParallelGroup(prdsDir) {
210
+ await fsp.mkdir(prdsDir, { recursive: true });
211
+ let candidate = (await maxParallelGroupInUse(prdsDir)) + 1;
212
+ for (let attempt = 0; attempt < MAX_RESERVE_ATTEMPTS; attempt += 1) {
213
+ const markerPath = path.join(prdsDir, `.reserved-${candidate}`);
214
+ let fh;
215
+ try {
216
+ fh = await fsp.open(markerPath, 'wx');
217
+ } catch (e) {
218
+ if (e && e.code === 'EEXIST') {
219
+ candidate += 1;
220
+ continue;
221
+ }
222
+ throw e;
223
+ }
224
+ await fh.close();
225
+ return candidate;
226
+ }
227
+ throw new Error(`allocateParallelGroup: exhausted ${MAX_RESERVE_ATTEMPTS} reservation attempts starting at ${candidate}`);
228
+ }
229
+
144
230
  module.exports = {
145
231
  parsePrdRaw,
146
232
  parsePrd,
147
233
  listPrdFiles,
234
+ allocateParallelGroup,
235
+ maxParallelGroupInUse,
148
236
  PRD_READ_MAX_BYTES,
149
237
  _resetCache,
150
238
  };