claude-code-session-manager 0.58.0 → 0.60.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 (41) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{TiptapBody-BEBLdJl_.js → TiptapBody-BtVPGBaq.js} +1 -1
  3. package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
  4. package/dist/assets/{index-DEQzGYa6.js → index-DtNip-Zx.js} +1076 -1081
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
  8. package/src/main/__tests__/epicContextDigest.test.cjs +41 -1
  9. package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
  10. package/src/main/__tests__/prdMigration.test.cjs +160 -0
  11. package/src/main/__tests__/projectPages.test.cjs +151 -0
  12. package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
  13. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
  14. package/src/main/__tests__/scheduler-epic-digest.test.cjs +9 -2
  15. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
  16. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
  17. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
  18. package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
  19. package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
  20. package/src/main/health.cjs +5 -1
  21. package/src/main/index.cjs +1 -1
  22. package/src/main/ipcSchemas.cjs +26 -3
  23. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
  24. package/src/main/lib/classifyTranscriptLine.cjs +131 -48
  25. package/src/main/lib/epicContextDigest.cjs +36 -0
  26. package/src/main/lib/epicMint.cjs +6 -1
  27. package/src/main/lib/epicValidationHook.cjs +192 -0
  28. package/src/main/lib/prdMigration.cjs +76 -5
  29. package/src/main/lib/promptSessionSchema.cjs +16 -0
  30. package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
  31. package/src/main/lib/schedulerBatch.cjs +70 -111
  32. package/src/main/lib/schedulerConfig.cjs +0 -1
  33. package/src/main/otel.cjs +3 -1
  34. package/src/main/projectPages.cjs +60 -14
  35. package/src/main/promptSessionEvents.cjs +16 -1
  36. package/src/main/scheduler.cjs +228 -49
  37. package/src/main/templates/project-pages-default-home.html +123 -0
  38. package/src/main/transcripts.cjs +191 -32
  39. package/src/main/webRemote.cjs +8 -7
  40. package/src/preload/api.d.ts +75 -9
  41. package/src/preload/index.cjs +3 -0
@@ -63,6 +63,7 @@ const prdParser = require('./scheduler/prdParser.cjs');
63
63
  const sessionsStore = require('./sessionsStore.cjs');
64
64
  const { enqueueExternalPrompt } = require('./chatRunner.cjs');
65
65
  const { appendResponseEventIfKnown } = require('./promptSessionEvents.cjs');
66
+ const { maybeEnqueueValidationPrompt } = require('./lib/epicValidationHook.cjs');
66
67
  const promptSessionTranscript = require('./promptSessionTranscript.cjs');
67
68
  const { verifyRun } = require('./runVerify.cjs');
68
69
  const { latestTerminalOutcomeForSlug, COMPLETED_EQUIVALENT_VERDICTS } = require('./lib/terminalRunOutcome.cjs');
@@ -87,7 +88,7 @@ const queueOps = require('./queueOps.cjs');
87
88
  // home-dir layout.
88
89
  const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs } = require('./lib/prdLocations.cjs');
89
90
  const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
90
- const { buildContextDigest } = require('./lib/epicContextDigest.cjs');
91
+ const { buildContextDigest, composeExecutorPrompt } = require('./lib/epicContextDigest.cjs');
91
92
 
92
93
  // ---------- origin session resolution (PRD 832) ----------
93
94
  // An Epic IS a tagged claude session — job rows carry the originating
@@ -359,15 +360,6 @@ const HEARTBEAT_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'sc
359
360
  const HEARTBEAT_MAX_BYTES = 1024 * 1024;
360
361
  // DEFAULT_PROJECT_CWD imported from lib/schedulerBatch.cjs (single source of truth).
361
362
 
362
- // Read lazily (not captured at module-load time) so tests can stub the env
363
- // per-case without vi.resetModules(); production behavior is unaffected
364
- // since the env var never changes mid-process.
365
- function getEnvCap() {
366
- return process.env.SM_SCHEDULER_MAX_CONCURRENCY
367
- ? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 3))
368
- : null;
369
- }
370
-
371
363
  // Each headless claude -p job can shell out to tsc/vite/pytest and grow well
372
364
  // past 1 GB at peak; reserve 2.5 GB per running+pending slot. Raised from 1.5 GB
373
365
  // after the 2026-06-16 OOM: 3 concurrent cross-project jobs + their build
@@ -392,7 +384,6 @@ const OOM_SCORE_ADJ_JOB = 500;
392
384
 
393
385
  const DEFAULT_CONFIG = {
394
386
  offsetMinutes: 15,
395
- concurrencyCap: getEnvCap() ?? 3,
396
387
  defaultCwd: DEFAULT_PROJECT_CWD,
397
388
  // 'when-available' = poll usage and fire whenever utilization < threshold.
398
389
  // 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
@@ -757,6 +748,16 @@ async function runPrdMigration() {
757
748
  meta: { cwd, reason: f.reason },
758
749
  });
759
750
  }
751
+ // Deliberately left behind because a live job still points at them
752
+ // (PRD 992). Logged so a permanently-stuck flat PRD is visible rather
753
+ // than looking like a clean consolidation.
754
+ for (const s of c.skipped ?? []) {
755
+ logs.writeLine({
756
+ level: 'info', scope: 'scheduler',
757
+ message: `flat-PRD consolidation: left ${s.file} in place`,
758
+ meta: { cwd, reason: s.reason },
759
+ });
760
+ }
760
761
  } catch (e) {
761
762
  logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'flat-PRD consolidation failed', meta: { cwd, error: e?.message } });
762
763
  }
@@ -1450,6 +1451,18 @@ let cancelToken = { cancelled: false };
1450
1451
  // Last memory-gate observation; included in snapshot for renderer visibility.
1451
1452
  let lastMemGate = null;
1452
1453
 
1454
+ // Last tickQueue outcome, kept for the UI. tickQueue already computes a precise
1455
+ // reason for every way a batch can come back empty (dependency holds, slot
1456
+ // exhaustion, the memory gate, pause, manual policy) and used to throw all of
1457
+ // it into console.log — so the Home slot count read "5" while the queue was
1458
+ // actually pinned at 1 with nothing on screen explaining why. This is that
1459
+ // explanation, recorded once per tick and broadcast with the rest of the state.
1460
+ let lastTick = null;
1461
+ function recordTick(outcome, extra = {}) {
1462
+ lastTick = { ...outcome, ...extra, at: new Date().toISOString() };
1463
+ return outcome;
1464
+ }
1465
+
1453
1466
  /**
1454
1467
  * Pure: applies clearPause()'s effect on the tick cancel-token.
1455
1468
  *
@@ -1505,10 +1518,19 @@ function buildScheduleStatePayload(state, { withPaths = false } = {}) {
1505
1518
  lastFailureKind,
1506
1519
  },
1507
1520
  memGate: lastMemGate,
1508
- effectiveConcurrency: {
1509
- cap: getEnvCap() ?? state.config.concurrencyCap,
1510
- source: getEnvCap() != null ? 'env' : 'config',
1511
- },
1521
+ lastTick,
1522
+ // The machine-wide slot pool IS the concurrency limit — there is no
1523
+ // separate scheduler cap any more. `source` distinguishes the
1524
+ // SM_SESSION_SLOTS env override from the persisted Home-tab value so the
1525
+ // UI can disable its own control when the env has taken over.
1526
+ effectiveConcurrency: (() => {
1527
+ const snap = sessionSlots.snapshot();
1528
+ return {
1529
+ cap: snap.total,
1530
+ free: Math.max(0, snap.total - snap.inUse),
1531
+ source: snap.envOverride ? 'env' : 'pool',
1532
+ };
1533
+ })(),
1512
1534
  };
1513
1535
  if (withPaths) {
1514
1536
  payload.paths = { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: queueStore.MACHINE_STATE_PATH };
@@ -1797,6 +1819,43 @@ function extractResultTextFromLog(logPath) {
1797
1819
  }
1798
1820
  }
1799
1821
 
1822
+ /**
1823
+ * resolveNotifyPrd(job, parsePrdRaw) → Promise<parsed PRD | null>
1824
+ *
1825
+ * Locate and parse a finishing job's PRD for the notification path, trying
1826
+ * the live Epic-scoped dir first (findPrdDir scans candidatePrdsDirs) and
1827
+ * then the archived twin (archivedPrdPathForJob scans listArchivedPrdDirs).
1828
+ *
1829
+ * Both lookups are required, and `prdPathForJob` is deliberately NOT used
1830
+ * here (PRD 985). It resolves to `resolvePrdWriteDir(cwd)` — the RETIRED
1831
+ * flat `<cwd>/session-manager-operations/scheduler/prds/` dir, which today
1832
+ * holds only zero-byte `.reserved-NNN` number stubs and no PRD at all, so it
1833
+ * ENOENT'd for every Epic-scoped PRD. On top of that, archiveCompletedPrd
1834
+ * renames the file into `prds-archived/` immediately BEFORE the notify call
1835
+ * (see tickQueue's newlyCompletedPrds loop), so even a correct live-dir
1836
+ * lookup finds nothing by the time we run. The net effect was that `prd`
1837
+ * was always null on the completed path: sourcePromptId never resolved, no
1838
+ * transcript turn was written, no response event was appended, and the
1839
+ * completed PRD silently never reported back to the Epic that authored it.
1840
+ * Verified live: PRDs 961/962/963/964/967 all completed 2026-08-03 and not
1841
+ * one response event landed on either authoring Epic's chain.
1842
+ *
1843
+ * Never throws — an unreadable/absent PRD returns null and each caller
1844
+ * falls back to the job's own `epicId`.
1845
+ */
1846
+ async function resolveNotifyPrd(job, parsePrdRaw) {
1847
+ if (!job || !job.slug) return null;
1848
+ const liveDir = await findPrdDir(job.slug).catch(() => null);
1849
+ const livePath = liveDir ? safeSlugPathIn(liveDir, job.slug) : null;
1850
+ if (livePath) {
1851
+ const parsed = await parsePrdRaw(livePath).catch(() => null);
1852
+ if (parsed) return parsed;
1853
+ }
1854
+ const archivedPath = archivedPrdPathForJob(job);
1855
+ if (!archivedPath) return null;
1856
+ return await parsePrdRaw(archivedPath).catch(() => null);
1857
+ }
1858
+
1800
1859
  /**
1801
1860
  * notifyOriginatingTab(job) → void
1802
1861
  *
@@ -1835,18 +1894,38 @@ async function notifyOriginatingTab(job, {
1835
1894
  appendResponseEvent = appendResponseEventIfKnown,
1836
1895
  appendTranscriptTurn = promptSessionTranscript.appendTurn,
1837
1896
  readResultFromLog = extractResultTextFromLog,
1897
+ enqueueValidation = maybeEnqueueValidationPrompt,
1838
1898
  } = {}) {
1839
1899
  try {
1840
- const prdPath = prdPathForJob(job);
1841
- const prd = await parsePrdRaw(prdPath).catch(() => null);
1900
+ const prd = await resolveNotifyPrd(job, parsePrdRaw);
1842
1901
  const message = `PRD ${job.slug} finished: ${job.status}. Check Scheduler for details.`;
1843
1902
 
1903
+ // The EPIC this check-in belongs to. Deliberately does NOT consider
1904
+ // `sourceTabId` — that is a Terminal tab id, not an Epic id, and
1905
+ // appending an Epic event chain onto one would be a category error (a
1906
+ // pre-813 PRD carrying only sourceTabId must still take the
1907
+ // chat:external-send path below; covered by the 'no sourcePromptId at
1908
+ // all (pre-813 PRD)' test).
1909
+ //
1910
+ // `job.epicId` is the last resort and is what makes the notification
1911
+ // survive an entirely unreadable PRD: the queue row carries the Epic FK
1912
+ // independently of the .md on disk (20 of 21 history rows had it when
1913
+ // PRD 985 was written), so a parse failure degrades to "notified without
1914
+ // frontmatter detail" instead of "silently dropped". Mirrors
1915
+ // notifyNeedsReview, which has had this fallback since PRD 854 — which
1916
+ // is precisely why needs_review routing kept working while
1917
+ // completed/failed routing did not.
1918
+ const epicId = prd?.sourcePromptId || job.epicId || null;
1919
+
1844
1920
  // Persist the job's real result text (not just the short status chip
1845
1921
  // above) to the durable per-Epic transcript, keyed off whichever id
1846
- // notifyOriginatingTab would otherwise notify. Best-effort: a missing
1847
- // cwd/epic id, an unreadable run log, or an IPC error here must never
1848
- // block the notification below.
1849
- const epicIdForTranscript = prd?.sourcePromptId || prd?.sourceTabId || null;
1922
+ // notifyOriginatingTab would otherwise notify. Unlike the event append
1923
+ // above, this one DOES accept sourceTabId — the durable transcript is
1924
+ // keyed by whatever id the run is associated with, not strictly by Epic
1925
+ // (pre-existing behaviour, unchanged by PRD 985 apart from the epicId
1926
+ // fallback). Best-effort: a missing cwd/epic id, an unreadable run log,
1927
+ // or an IPC error here must never block the notification below.
1928
+ const epicIdForTranscript = prd?.sourcePromptId || prd?.sourceTabId || job.epicId || null;
1850
1929
  if (epicIdForTranscript && job.cwd) {
1851
1930
  try {
1852
1931
  const logPath = job.runId ? path.join(RUNS_DIR, job.runId, `${job.slug}.log`) : null;
@@ -1860,12 +1939,46 @@ async function notifyOriginatingTab(job, {
1860
1939
  }
1861
1940
  }
1862
1941
 
1863
- if (prd?.sourcePromptId) {
1864
- const routed = await appendResponseEvent(job.cwd || null, prd.sourcePromptId, message).catch((e) => {
1942
+ if (epicId) {
1943
+ // PRD 986: the check-in event is born validation:'unvalidated' — never
1944
+ // 'verified' — regardless of the job's self-reported outcome. The
1945
+ // check-in is a request to validate, not an assertion of done.
1946
+ const routed = await appendResponseEvent(job.cwd || null, epicId, message, {
1947
+ prdSlug: job.slug,
1948
+ outcome: job.status,
1949
+ validation: 'unvalidated',
1950
+ }).catch((e) => {
1865
1951
  console.error('[scheduler] notifyOriginatingTab appendResponseEvent error', job?.slug, e);
1866
1952
  return false;
1867
1953
  });
1868
- if (routed) return;
1954
+ if (routed) {
1955
+ // PRD 986: a successful check-in append triggers ONE validation
1956
+ // prompt into the authoring Epic's own session, asking it to verify
1957
+ // every acceptance criterion against the real working tree and reply
1958
+ // VERIFIED or REFUTED. Fire-and-forget, same convention as this
1959
+ // function's own call site in tickQueue: a failure to enqueue is
1960
+ // logged inside the hook, never thrown, and never blocks the job's
1961
+ // status transition or the response-event append (which already
1962
+ // happened above). All gating (kill-switch SM_EPIC_VALIDATION_DISABLE,
1963
+ // active-Epic check, once-per-(epicId, prdSlug), loop guard, slot-pool
1964
+ // routing) lives in lib/epicValidationHook.cjs.
1965
+ try {
1966
+ enqueueValidation(
1967
+ {
1968
+ cwd: job.cwd || null,
1969
+ epicId,
1970
+ prdSlug: job.slug,
1971
+ prdPath: prd?.path || archivedPrdPathForJob(job) || null,
1972
+ outcome: job.status,
1973
+ eventValidation: 'unvalidated',
1974
+ },
1975
+ { sendPrompt },
1976
+ );
1977
+ } catch (e) {
1978
+ console.error('[scheduler] notifyOriginatingTab enqueueValidation error', job?.slug, e);
1979
+ }
1980
+ return;
1981
+ }
1869
1982
  }
1870
1983
 
1871
1984
  // appendResponseEvent already refused above (unknown id, completed
@@ -1874,7 +1987,7 @@ async function notifyOriginatingTab(job, {
1874
1987
  // re-checks against its own live PromptSession store. Deliberate
1875
1988
  // defense-in-depth (main's disk-backed check vs. the renderer's
1876
1989
  // in-memory one can disagree/race), not a redundant duplicate to prune.
1877
- let targetTabId = prd?.sourceTabId || prd?.sourcePromptId || null;
1990
+ let targetTabId = prd?.sourceTabId || prd?.sourcePromptId || job.epicId || null;
1878
1991
  if (!targetTabId) {
1879
1992
  const jobCwd = job.cwd || null;
1880
1993
  if (jobCwd) {
@@ -1916,14 +2029,23 @@ async function notifyNeedsReview(job, report, {
1916
2029
  } = {}) {
1917
2030
  try {
1918
2031
  if (!job || !report || !report.filed) return false;
1919
- const prd = await parsePrdRaw(prdPathForJob(job)).catch(() => null);
2032
+ const prd = await resolveNotifyPrd(job, parsePrdRaw);
1920
2033
  const epicId = prd?.sourcePromptId || job.epicId || null;
1921
2034
  if (!epicId || !job.cwd) {
1922
2035
  console.log(`[scheduler] notifyNeedsReview: no authoring Epic for ${job.slug}, report only`);
1923
2036
  return false;
1924
2037
  }
1925
2038
  const message = `${report.summary}. Root-cause report: ${report.path}`;
1926
- return await appendResponseEvent(job.cwd, epicId, message);
2039
+ // PRD 986: like every scheduler check-in, born 'unvalidated' — the RCA
2040
+ // report is itself a question, not a verified outcome. No validation
2041
+ // prompt is enqueued here: that fires only for the terminal
2042
+ // completed/failed path (notifyOriginatingTab); needs_review already
2043
+ // routes its own question via this very report.
2044
+ return await appendResponseEvent(job.cwd, epicId, message, {
2045
+ prdSlug: job.slug,
2046
+ outcome: 'needs_review',
2047
+ validation: 'unvalidated',
2048
+ });
1927
2049
  } catch (e) {
1928
2050
  console.error('[scheduler] notifyNeedsReview error', job?.slug, e);
1929
2051
  return false;
@@ -2157,7 +2279,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
2157
2279
  try {
2158
2280
  const digestText = await buildContextDigest({ cwd, epicId: digestEpicId });
2159
2281
  if (digestText) {
2160
- prompt = digestText + '\n\n' + prompt;
2282
+ prompt = composeExecutorPrompt({ prdBody: prompt, digestText });
2161
2283
  contextDigestApplied = true;
2162
2284
  }
2163
2285
  } catch (e) {
@@ -3184,20 +3306,34 @@ function tickQueue() {
3184
3306
  }
3185
3307
  if (state.paused) {
3186
3308
  console.log('[scheduler] tickQueue skipped: paused');
3187
- return { fired: false, reason: 'paused' };
3309
+ return recordTick({ fired: false, reason: 'paused' }, { detail: 'scheduler paused' });
3188
3310
  }
3189
3311
  if (cancelToken.cancelled) return { fired: false, reason: 'cancelled' };
3190
3312
 
3191
3313
  await reconcile(state);
3192
- const cap = getEnvCap() ?? state.config.concurrencyCap;
3193
- const { batch, reason: holdReason } = pickNextBatch(state.jobs, runningSet, cap);
3314
+ // Session-Manager's machine-wide slot pool is the ONLY concurrency limit
3315
+ // the picker answers to (plus the memory gate below). The scheduler used
3316
+ // to also carry a private `concurrencyCap` of 3 — the exact per-consumer
3317
+ // cap that sessionSlots.cjs was written to replace — which silently
3318
+ // ceilinged the queue at 3 while the pool the user configured said 5.
3319
+ const freeSlots = sessionSlots.available();
3320
+ const { batch, reason: holdReason, holds } = pickNextBatch(state.jobs, runningSet, freeSlots);
3321
+ if (batch.length === 0 && freeSlots === 0) {
3322
+ const snap = sessionSlots.snapshot();
3323
+ const pendingCount = state.jobs.filter((j) => j.status === 'pending').length;
3324
+ console.log(`[scheduler] slot gate: 0 of ${snap.total} session slots free (${snap.holders.map((h) => h.owner).join(', ')}) — deferring ${pendingCount} pending job(s)`);
3325
+ return recordTick(
3326
+ { fired: false, reason: 'slots-exhausted', deferredCount: pendingCount, holders: snap.holders },
3327
+ { detail: `${snap.total} of ${snap.total} session slots busy (${snap.holders.map((h) => h.owner).join(', ')})`, holds },
3328
+ );
3329
+ }
3194
3330
  if (batch.length === 0) {
3195
3331
  // Queue drained — run the definition-of-done gate fire-and-forget.
3196
3332
  // Non-blocking: does not hold the mutate lock; errors are logged, not thrown.
3197
3333
  runDefinitionOfDoneOnDrain(state, { cancelToken }).catch((err) => {
3198
3334
  console.log(`[scheduler] dod-drain: ${err?.message ?? String(err)}`);
3199
3335
  });
3200
- if (holdReason) return { fired: false, reason: 'held', detail: holdReason };
3336
+ if (holdReason) return recordTick({ fired: false, reason: 'held', detail: holdReason }, { holds });
3201
3337
  // Distinguish "genuinely nothing to do" from "the batch was already
3202
3338
  // fired by a concurrent tickQueue() call" (e.g. the periodic
3203
3339
  // when-available poll winning a race against a manual force-tick —
@@ -3210,9 +3346,9 @@ function tickQueue() {
3210
3346
  state.jobs.filter((j) => j.status === 'running').length,
3211
3347
  );
3212
3348
  if (runningCount > 0) {
3213
- return { fired: false, reason: 'already-running', runningCount };
3349
+ return recordTick({ fired: false, reason: 'already-running', runningCount }, { holds });
3214
3350
  }
3215
- return { fired: false, reason: 'drained' };
3351
+ return recordTick({ fired: false, reason: 'drained' }, { holds });
3216
3352
  }
3217
3353
 
3218
3354
  const availableMb = getAvailableMemMb();
@@ -3220,23 +3356,19 @@ function tickQueue() {
3220
3356
  // job is never started into the host's own headroom (that path OOM-kills
3221
3357
  // Electron and SIGHUPs every pty — 2026-06-16 incident).
3222
3358
  const jobBudgetMb = availableForJobs(availableMb, RESERVED_HOST_MB);
3223
- // Session-Manager's machine-wide slot pool is the outer bound: chat runs
3224
- // and scheduler jobs share it, so a busy chat lane shrinks this batch.
3225
- const slotAllowed = sessionSlots.available();
3226
- if (slotAllowed === 0) {
3227
- const snap = sessionSlots.snapshot();
3228
- console.log(`[scheduler] slot gate: 0 of ${snap.total} session slots free (${snap.holders.map((h) => h.owner).join(', ')}) — deferring ${batch.length} job(s)`);
3229
- return { fired: false, reason: 'slots-exhausted', deferredCount: batch.length, holders: snap.holders };
3230
- }
3231
- const allowed = Math.min(
3232
- slotAllowed,
3233
- memoryLimitedBatchSize(jobBudgetMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length),
3359
+ // The slot pool already bounded `batch` (it was passed as freeSlots to
3360
+ // pickNextBatch above), so only the memory gate can narrow it further.
3361
+ const allowed = memoryLimitedBatchSize(
3362
+ jobBudgetMb, MIN_FREE_MB_PER_JOB, runningSet.size, batch.length,
3234
3363
  );
3235
3364
  if (allowed === 0) {
3236
3365
  const threshold = RESERVED_HOST_MB + MIN_FREE_MB_PER_JOB * (runningSet.size + 1);
3237
3366
  console.log(`[scheduler] memory gate: available=${availableMb} MB < threshold=${threshold} MB (host reserve ${RESERVED_HOST_MB} + ${MIN_FREE_MB_PER_JOB}/job × ${runningSet.size + 1}) — deferring ${batch.length} job(s)`);
3238
3367
  lastMemGate = { availableMb, threshold, deferred: true, at: new Date().toISOString() };
3239
- return { fired: false, reason: 'memory-deferred', deferredCount: batch.length, availableMb, threshold };
3368
+ return recordTick(
3369
+ { fired: false, reason: 'memory-deferred', deferredCount: batch.length, availableMb, threshold },
3370
+ { detail: `memory gate: ${availableMb} MB free, need ${threshold} MB`, holds },
3371
+ );
3240
3372
  }
3241
3373
  const gatedBatch = batch.slice(0, allowed);
3242
3374
  if (gatedBatch.length < batch.length) {
@@ -3257,7 +3389,7 @@ function tickQueue() {
3257
3389
  // spawnJob is fire-and-forget; it calls tickQueue() on completion.
3258
3390
  spawnJob(job, runId, runDir, state.config.defaultCwd).catch(() => {});
3259
3391
  }
3260
- return { fired: true, count: gatedBatch.length, group: gatedBatch[0]?.parallelGroup };
3392
+ return recordTick({ fired: true, count: gatedBatch.length, group: gatedBatch[0]?.parallelGroup }, { holds });
3261
3393
  });
3262
3394
  tickTail = next.catch(() => {});
3263
3395
  return next;
@@ -3628,6 +3760,50 @@ function isUnresolvableNeedsReview(job, { hasRunDir }) {
3628
3760
  * commit-guard) — verifyRun can't see git, so re-scanning it would falsely
3629
3761
  * heal an unfinished job. Exported for tests.
3630
3762
  */
3763
+ /**
3764
+ * healRefusalReason(job, verdict, committedDuringRun) → string | null
3765
+ *
3766
+ * Guards reverifyNeedsReview's needs_review → completed self-heal. Returns a
3767
+ * refusal reason when the heal must NOT happen, or null to allow it.
3768
+ *
3769
+ * THE INCIDENT (PRD 983, 2026-08-03). Job 972 ran 34 s, edited zero files and
3770
+ * exited 0. verifyRun correctly raised `no_verdict_sentinel` (no commit AND no
3771
+ * SCHEDULER_VERDICT sentinel) with downgradeTo 'needs_review', and the job was
3772
+ * parked. One minute later this self-heal pass promoted it to `completed`, and
3773
+ * that false green shipped to the user.
3774
+ *
3775
+ * The mechanism is subtle and worth spelling out, because the individual parts
3776
+ * all look correct: `no_verdict_sentinel` is in RESCANNABLE_VERDICTS, so the
3777
+ * job was re-verified. The rescan recomputes `committedDuringRun` via
3778
+ * `committedInWindow`, which runs `git log --all --since=<startedAt>
3779
+ * --until=<finishedAt+60s>` with NO author, message, or slug filter — it
3780
+ * answers "did ANY commit land in this repo during that window", not "did THIS
3781
+ * job commit". With up to 3 concurrent scheduler jobs plus interactive
3782
+ * sessions sharing one repo, a sibling job's commit lands inside the window
3783
+ * routinely. `committedDuringRun` therefore flipped to true on the rescan,
3784
+ * runVerify's `if (sentinel === null && !committedDuringRun)` no longer fired,
3785
+ * `no_verdict_sentinel` was never re-raised, the issue list came back empty,
3786
+ * and the verdict concluded `clean` — a COMPLETED_EQUIVALENT verdict, so the
3787
+ * job healed. Another job's commit was silently credited to this one.
3788
+ *
3789
+ * The rule: a run that emitted no sentinel and landed no commit ATTRIBUTABLE
3790
+ * TO ITSELF has produced no evidence it did anything, and repo-wide
3791
+ * time-window evidence is not attribution. `landedCommit` is per-job and
3792
+ * survives a resetJob (resetJobFields), so it is the honest signal here.
3793
+ * Bias to needs_review: a false yellow costs a human glance, a false green
3794
+ * costs a silently-unfixed bug — which is exactly what happened.
3795
+ */
3796
+ function healRefusalReason(job, verdict, committedDuringRun) {
3797
+ if (!job || !verdict) return null;
3798
+ if (!COMPLETED_EQUIVALENT_VERDICTS.has(verdict.verdict)) return null;
3799
+ if (job.verifierVerdict !== 'no_verdict_sentinel') return null;
3800
+ // A commit this job actually recorded as its own is real evidence; the
3801
+ // repo-wide window scan is not.
3802
+ if (job.landedCommit) return null;
3803
+ return 'no_verdict_sentinel with no job-attributable commit — refusing to heal'
3804
+ + ` (committedInWindow=${committedDuringRun === true} is repo-wide, not proof this job delivered)`;
3805
+ }
3806
+
3631
3807
  function isRescanCandidate(job) {
3632
3808
  return !!job
3633
3809
  && job.status === 'needs_review'
@@ -3729,7 +3905,10 @@ async function reverifyNeedsReview() {
3729
3905
  priorLandedCommit,
3730
3906
  });
3731
3907
  } catch { leftForReview.push({ slug: job.slug, reason: 'verifyRun threw' }); continue; }
3732
- if (v && COMPLETED_EQUIVALENT_VERDICTS.has(v.verdict)) {
3908
+ const refusal = healRefusalReason(job, v, committedDuringRun);
3909
+ if (refusal) {
3910
+ leftForReview.push({ slug: job.slug, reason: refusal });
3911
+ } else if (v && COMPLETED_EQUIVALENT_VERDICTS.has(v.verdict)) {
3733
3912
  healed.push(job.slug);
3734
3913
  } else {
3735
3914
  leftForReview.push({ slug: job.slug, reason: v ? `${v.verdict}: ${v.reason}` : 'null verdict' });
@@ -4595,4 +4774,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
4595
4774
  });
4596
4775
  }
4597
4776
 
4598
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
4777
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, healRefusalReason, writeQueue, reconcile, reconcileSourcePromptId, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, commitGuardVerdict, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, registerAdminRoutes, notifyOriginatingTab, notifyNeedsReview, isNotifiableTerminalStatus, extractResultTextFromLog, candidatePrdsDirs, candidateArchivedPrdsDirs, resolveArchivedPrdStatus, prdDirForCwd, prdPathForJob, archivedPrdPathForJob, archivedTwinExists, findPrdDir, runPrdMigration, shouldSkipInvestigationForCleanRun, archiveCompletedPrd, retireCompletedSlugs, SCHEDULER_BOOTED_AT, SCHEDULER_CODE_SHA, resetJobFields, executeJob, prdArchivedSkipResult };
@@ -0,0 +1,123 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Project Home</title>
7
+ <meta name="description" content="Project Home — a hosted document generated from this project's own repo, not yet generated for this project." />
8
+ <style>
9
+ /* Design tokens copied from src/renderer/lib/projectPages/library/kit.tsx's
10
+ PK palette/fonts — no font files are embedded here (unlike the
11
+ generated lenses), so the font-family stacks fall back to their system
12
+ alternatives (Georgia / system-ui / ui-monospace). This keeps the
13
+ document a zero-network, zero-JS static file while still reading as
14
+ the same visual system: same colors, same type scale, same shapes. */
15
+ :root {
16
+ --pk-paper: #f6efe1;
17
+ --pk-panel: #efe6d3;
18
+ --pk-card: #fbf6ec;
19
+ --pk-deep: #2a221a;
20
+ --pk-edge: #e0d3b8;
21
+ --pk-rule: #d9c9a8;
22
+ --pk-ink: #2a221a;
23
+ --pk-ink-soft: #5b4a36;
24
+ --pk-ink-mute: #8a7a60;
25
+ --pk-accent: #b85c34;
26
+ --pk-accent-deep: #9c4a26;
27
+ --pk-sage: #6f7d52;
28
+ --pk-butter: #e4b85a;
29
+ --pk-serif: 'Newsreader', Georgia, serif;
30
+ --pk-sans: 'Geist', system-ui, sans-serif;
31
+ --pk-mono: 'IBM Plex Mono', ui-monospace, monospace;
32
+ }
33
+ * { box-sizing: border-box; }
34
+ html, body { margin: 0; padding: 0; background: var(--pk-paper); color: var(--pk-ink); }
35
+ body { font-family: var(--pk-sans); }
36
+ .eyebrow {
37
+ font-family: var(--pk-mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
38
+ color: var(--pk-accent); font-weight: 500;
39
+ }
40
+ .pill {
41
+ display: inline-flex; align-items: center; gap: 6px; font-family: var(--pk-mono); font-size: 11px;
42
+ color: var(--pk-ink-soft); background: transparent; border: 1px solid var(--pk-rule); border-radius: 999px;
43
+ padding: 3px 9px; white-space: nowrap;
44
+ }
45
+ h1 {
46
+ margin: 0; font-family: var(--pk-serif); font-weight: 500; font-size: clamp(30px, 5vw, 44px);
47
+ line-height: 1.1; letter-spacing: -0.015em; color: var(--pk-ink);
48
+ }
49
+ h2 {
50
+ margin: 0; font-family: var(--pk-serif); font-weight: 500; font-size: 24px; line-height: 1.15;
51
+ letter-spacing: -0.01em; color: var(--pk-ink);
52
+ }
53
+ p { margin: 0; font-family: var(--pk-sans); font-size: 16px; line-height: 1.62; color: var(--pk-ink-soft); }
54
+ section { padding: clamp(28px, 6vw, 72px); }
55
+ .hero { border-bottom: 1px solid var(--pk-rule); background: var(--pk-panel); }
56
+ .hero-inner { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
57
+ .cta {
58
+ display: inline-flex; width: fit-content; align-items: center; gap: 8px; font-family: var(--pk-sans);
59
+ font-size: 14px; font-weight: 600; padding: 11px 20px; border-radius: 9px; border: 1px solid transparent;
60
+ background: var(--pk-accent); color: #fff;
61
+ }
62
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; max-width: 960px; margin: 0 auto; }
63
+ .card {
64
+ background: var(--pk-card); border: 1px solid var(--pk-edge); border-radius: 12px; padding: 20px;
65
+ display: flex; flex-direction: column; gap: 8px;
66
+ }
67
+ .card .k { font-family: var(--pk-mono); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--pk-ink-mute); }
68
+ .card .t { font-family: var(--pk-serif); font-size: 18px; color: var(--pk-ink); }
69
+ .card .d { font-size: 13.5px; line-height: 1.55; color: var(--pk-ink-soft); }
70
+ .footnote { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 6px; }
71
+ .footnote .label { font-family: var(--pk-mono); font-size: 11px; color: var(--pk-ink-mute); }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <section class="hero">
76
+ <div class="hero-inner">
77
+ <span class="pill">Shipped default — not yet generated for this project</span>
78
+ <div class="eyebrow">Project Home</div>
79
+ <h1>This is Project Home's shipped default page.</h1>
80
+ <p>
81
+ Project Home is a hosted document — a self-contained page describing this specific
82
+ project: what it is, its main areas, and what it's for. Session-manager ships this
83
+ default so the page is never empty, but it does not describe your project yet.
84
+ Nothing shown on this page is specific to any real project.
85
+ </p>
86
+ <a class="cta" href="#" onclick="return false;">Generate My Project Home</a>
87
+ <p style="font-size: 13px; color: var(--pk-ink-mute);">
88
+ Press "Generate My Project Home" in the app (this link is inert inside this static
89
+ preview) to replace this page with one generated from your project's own repo.
90
+ </p>
91
+ </div>
92
+ </section>
93
+ <section>
94
+ <div class="grid">
95
+ <div class="card">
96
+ <div class="k">What it reads</div>
97
+ <div class="t">Your repo, live</div>
98
+ <div class="d">Generation reads this project's own files, git history, and CLAUDE.md conventions — never invented content.</div>
99
+ </div>
100
+ <div class="card">
101
+ <div class="k">What it produces</div>
102
+ <div class="t">A static document</div>
103
+ <div class="d">A self-contained HTML page — inline styling, no network calls, no live app state — that replaces this default at a fixed path.</div>
104
+ </div>
105
+ <div class="card">
106
+ <div class="k">How it stays fresh</div>
107
+ <div class="t">On demand</div>
108
+ <div class="d">Regenerate any time from Project Home; this default only ever shows before the first generation.</div>
109
+ </div>
110
+ </div>
111
+ </section>
112
+ <section>
113
+ <div class="footnote">
114
+ <div class="label">About this page</div>
115
+ <p style="font-size: 13.5px;">
116
+ Shipped with Session Manager as a build-time asset — the same design system as a
117
+ generated Project Home page, but honest, generic copy instead of project-specific
118
+ content. See "About these templates" for how a generated page is built.
119
+ </p>
120
+ </div>
121
+ </section>
122
+ </body>
123
+ </html>