claude-code-session-manager 0.57.1 → 0.59.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 (68) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{TiptapBody-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
  3. package/dist/assets/index-BPnfPdLW.js +3222 -0
  4. package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -2
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
  8. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  9. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  11. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
  12. package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
  13. package/scripts/lib/watchdogHelpers.cjs +0 -434
  14. package/scripts/mint-epic.cjs +6 -6
  15. package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
  16. package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
  17. package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
  18. package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
  19. package/src/main/__tests__/epicMint.test.cjs +165 -207
  20. package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
  21. package/src/main/__tests__/prdMigration.test.cjs +160 -0
  22. package/src/main/__tests__/projectPages.test.cjs +151 -0
  23. package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
  24. package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
  25. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
  26. package/src/main/__tests__/rcaReport.test.cjs +188 -0
  27. package/src/main/__tests__/runVerify.test.cjs +109 -6
  28. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
  29. package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
  30. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
  31. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
  32. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
  33. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
  34. package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
  35. package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
  36. package/src/main/chatRunner.cjs +10 -1
  37. package/src/main/health.cjs +5 -1
  38. package/src/main/index.cjs +3 -5
  39. package/src/main/ipcSchemas.cjs +72 -4
  40. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
  41. package/src/main/lib/agentModelResolve.cjs +97 -0
  42. package/src/main/lib/auditLog.cjs +2 -2
  43. package/src/main/lib/classifyTranscriptLine.cjs +131 -48
  44. package/src/main/lib/epicContextDigest.cjs +78 -0
  45. package/src/main/lib/epicMint.cjs +86 -164
  46. package/src/main/lib/epicValidationHook.cjs +192 -0
  47. package/src/main/lib/prdMigration.cjs +76 -5
  48. package/src/main/lib/promptSessionSchema.cjs +95 -0
  49. package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
  50. package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
  51. package/src/main/lib/schedulerBatch.cjs +70 -111
  52. package/src/main/lib/schedulerConfig.cjs +0 -1
  53. package/src/main/otel.cjs +3 -1
  54. package/src/main/projectPages.cjs +60 -14
  55. package/src/main/promptSessionEvents.cjs +16 -1
  56. package/src/main/runVerify.cjs +17 -2
  57. package/src/main/scheduler.cjs +302 -68
  58. package/src/main/templates/project-pages-default-home.html +123 -0
  59. package/src/main/transcripts.cjs +191 -32
  60. package/src/main/webRemote.cjs +8 -7
  61. package/src/preload/api.d.ts +100 -57
  62. package/src/preload/index.cjs +9 -7
  63. package/dist/assets/index-DCa3QvAi.js +0 -3227
  64. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
  65. package/scripts/propose-epic.cjs +0 -56
  66. package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
  67. package/src/main/repoAnalyzer.cjs +0 -346
  68. package/src/main/search.cjs +0 -332
@@ -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');
@@ -77,7 +78,7 @@ const {
77
78
  } = require('./lib/schedulerConfig.cjs');
78
79
  const { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD } = require('./lib/schedulerBatch.cjs');
79
80
  const { runDefinitionOfDoneOnDrain } = require('./lib/dodDrainHook.cjs');
80
- const { fileRcaFeedback, extractRcaBlock } = require('./lib/rcaFeedbackHook.cjs');
81
+ const { writeRcaReport, extractRcaBlock } = require('./lib/rcaReport.cjs');
81
82
  const queueHistory = require('./lib/queueHistory.cjs');
82
83
  const queueOps = require('./queueOps.cjs');
83
84
  // Feedback-auto-PRD sweep — formerly only run by the external scheduler-watchdog
@@ -87,6 +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');
91
+ const { buildContextDigest } = require('./lib/epicContextDigest.cjs');
90
92
 
91
93
  // ---------- origin session resolution (PRD 832) ----------
92
94
  // An Epic IS a tagged claude session — job rows carry the originating
@@ -358,15 +360,6 @@ const HEARTBEAT_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'sc
358
360
  const HEARTBEAT_MAX_BYTES = 1024 * 1024;
359
361
  // DEFAULT_PROJECT_CWD imported from lib/schedulerBatch.cjs (single source of truth).
360
362
 
361
- // Read lazily (not captured at module-load time) so tests can stub the env
362
- // per-case without vi.resetModules(); production behavior is unaffected
363
- // since the env var never changes mid-process.
364
- function getEnvCap() {
365
- return process.env.SM_SCHEDULER_MAX_CONCURRENCY
366
- ? Math.max(1, Math.min(20, parseInt(process.env.SM_SCHEDULER_MAX_CONCURRENCY, 10) || 3))
367
- : null;
368
- }
369
-
370
363
  // Each headless claude -p job can shell out to tsc/vite/pytest and grow well
371
364
  // past 1 GB at peak; reserve 2.5 GB per running+pending slot. Raised from 1.5 GB
372
365
  // after the 2026-06-16 OOM: 3 concurrent cross-project jobs + their build
@@ -391,7 +384,6 @@ const OOM_SCORE_ADJ_JOB = 500;
391
384
 
392
385
  const DEFAULT_CONFIG = {
393
386
  offsetMinutes: 15,
394
- concurrencyCap: getEnvCap() ?? 3,
395
387
  defaultCwd: DEFAULT_PROJECT_CWD,
396
388
  // 'when-available' = poll usage and fire whenever utilization < threshold.
397
389
  // 'on-reset' = fire offsetMinutes after the next 5h reset (legacy).
@@ -756,6 +748,16 @@ async function runPrdMigration() {
756
748
  meta: { cwd, reason: f.reason },
757
749
  });
758
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
+ }
759
761
  } catch (e) {
760
762
  logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'flat-PRD consolidation failed', meta: { cwd, error: e?.message } });
761
763
  }
@@ -1421,8 +1423,8 @@ let heartbeatInterval = null;
1421
1423
  // (The 5-minute feedback sweep that used to piggyback on this heartbeat is
1422
1424
  // gone: it scanned each active project's session-manager-operations/feedback/
1423
1425
  // and auto-queued a /process-feedback PRD. Both the folder and that skill are
1424
- // retired — agents now file a `proposed` Epic that waits for human approval,
1425
- // so there is nothing to sweep for. See lib/rcaFeedbackHook.cjs.)
1426
+ // retired, and nothing auto-creates work any more — a parked job is surfaced
1427
+ // back into the Epic that authored its PRD instead. See lib/rcaReport.cjs.)
1426
1428
  // In-memory set of slugs currently spawned in this process. Prevents
1427
1429
  // double-spawn when runDueJobs() is called while jobs are in flight.
1428
1430
  const runningSet = new Set();
@@ -1449,6 +1451,18 @@ let cancelToken = { cancelled: false };
1449
1451
  // Last memory-gate observation; included in snapshot for renderer visibility.
1450
1452
  let lastMemGate = null;
1451
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
+
1452
1466
  /**
1453
1467
  * Pure: applies clearPause()'s effect on the tick cancel-token.
1454
1468
  *
@@ -1504,10 +1518,19 @@ function buildScheduleStatePayload(state, { withPaths = false } = {}) {
1504
1518
  lastFailureKind,
1505
1519
  },
1506
1520
  memGate: lastMemGate,
1507
- effectiveConcurrency: {
1508
- cap: getEnvCap() ?? state.config.concurrencyCap,
1509
- source: getEnvCap() != null ? 'env' : 'config',
1510
- },
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
+ })(),
1511
1534
  };
1512
1535
  if (withPaths) {
1513
1536
  payload.paths = { root: ROOT, prds: PRDS_DIR, runs: RUNS_DIR, queue: queueStore.MACHINE_STATE_PATH };
@@ -1796,6 +1819,43 @@ function extractResultTextFromLog(logPath) {
1796
1819
  }
1797
1820
  }
1798
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
+
1799
1859
  /**
1800
1860
  * notifyOriginatingTab(job) → void
1801
1861
  *
@@ -1834,18 +1894,38 @@ async function notifyOriginatingTab(job, {
1834
1894
  appendResponseEvent = appendResponseEventIfKnown,
1835
1895
  appendTranscriptTurn = promptSessionTranscript.appendTurn,
1836
1896
  readResultFromLog = extractResultTextFromLog,
1897
+ enqueueValidation = maybeEnqueueValidationPrompt,
1837
1898
  } = {}) {
1838
1899
  try {
1839
- const prdPath = prdPathForJob(job);
1840
- const prd = await parsePrdRaw(prdPath).catch(() => null);
1900
+ const prd = await resolveNotifyPrd(job, parsePrdRaw);
1841
1901
  const message = `PRD ${job.slug} finished: ${job.status}. Check Scheduler for details.`;
1842
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
+
1843
1920
  // Persist the job's real result text (not just the short status chip
1844
1921
  // above) to the durable per-Epic transcript, keyed off whichever id
1845
- // notifyOriginatingTab would otherwise notify. Best-effort: a missing
1846
- // cwd/epic id, an unreadable run log, or an IPC error here must never
1847
- // block the notification below.
1848
- 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;
1849
1929
  if (epicIdForTranscript && job.cwd) {
1850
1930
  try {
1851
1931
  const logPath = job.runId ? path.join(RUNS_DIR, job.runId, `${job.slug}.log`) : null;
@@ -1859,12 +1939,46 @@ async function notifyOriginatingTab(job, {
1859
1939
  }
1860
1940
  }
1861
1941
 
1862
- if (prd?.sourcePromptId) {
1863
- 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) => {
1864
1951
  console.error('[scheduler] notifyOriginatingTab appendResponseEvent error', job?.slug, e);
1865
1952
  return false;
1866
1953
  });
1867
- 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
+ }
1868
1982
  }
1869
1983
 
1870
1984
  // appendResponseEvent already refused above (unknown id, completed
@@ -1873,7 +1987,7 @@ async function notifyOriginatingTab(job, {
1873
1987
  // re-checks against its own live PromptSession store. Deliberate
1874
1988
  // defense-in-depth (main's disk-backed check vs. the renderer's
1875
1989
  // in-memory one can disagree/race), not a redundant duplicate to prune.
1876
- let targetTabId = prd?.sourceTabId || prd?.sourcePromptId || null;
1990
+ let targetTabId = prd?.sourceTabId || prd?.sourcePromptId || job.epicId || null;
1877
1991
  if (!targetTabId) {
1878
1992
  const jobCwd = job.cwd || null;
1879
1993
  if (jobCwd) {
@@ -1893,6 +2007,51 @@ async function notifyOriginatingTab(job, {
1893
2007
  }
1894
2008
  }
1895
2009
 
2010
+ /**
2011
+ * notifyNeedsReview(job, report) → Promise<boolean>
2012
+ *
2013
+ * A job parked in `needs_review` is a question, not a result: something about
2014
+ * the PRD was unclear, unverifiable, or ran past its acceptance criteria.
2015
+ * That question belongs in the conversation that WROTE the PRD, so this
2016
+ * appends a 'response' event to the authoring Epic's own chain (the same
2017
+ * channel notifyOriginatingTab uses for completed/failed, which deliberately
2018
+ * excludes needs_review — isNotifiableTerminalStatus). It resolves the Epic
2019
+ * from the PRD's `sourcePromptId`, falling back to the job's own `epicId`.
2020
+ *
2021
+ * Join-only by construction: appendResponseEventIfKnown refuses an unknown or
2022
+ * non-active session and returns false. Nothing here can create an Epic — if
2023
+ * no open authoring Epic exists, the root-cause report in the run directory
2024
+ * is the whole record and the Scheduler tab is where it surfaces.
2025
+ */
2026
+ async function notifyNeedsReview(job, report, {
2027
+ parsePrdRaw = prdParser.parsePrdRaw,
2028
+ appendResponseEvent = appendResponseEventIfKnown,
2029
+ } = {}) {
2030
+ try {
2031
+ if (!job || !report || !report.filed) return false;
2032
+ const prd = await resolveNotifyPrd(job, parsePrdRaw);
2033
+ const epicId = prd?.sourcePromptId || job.epicId || null;
2034
+ if (!epicId || !job.cwd) {
2035
+ console.log(`[scheduler] notifyNeedsReview: no authoring Epic for ${job.slug}, report only`);
2036
+ return false;
2037
+ }
2038
+ const message = `${report.summary}. Root-cause report: ${report.path}`;
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
+ });
2049
+ } catch (e) {
2050
+ console.error('[scheduler] notifyNeedsReview error', job?.slug, e);
2051
+ return false;
2052
+ }
2053
+ }
2054
+
1896
2055
  /** Scan the tail of a job's log for the canonical rate-limit signal. We look
1897
2056
  * at the last 16 KB — final result event always lands at the end.
1898
2057
  * Uses readTail() so no raw fd lifecycle is needed here. */
@@ -2109,6 +2268,25 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
2109
2268
  }
2110
2269
  }
2111
2270
 
2271
+ // Prepend the Epic's own session digest (PRD 950/958) when this job traces
2272
+ // back to a known Epic — additive only, never mutates the PRD body itself.
2273
+ // A missing/unresolved epicId or a digest build failure is a silent no-op:
2274
+ // the PRD's own body must remain sufficient to complete the job on its own.
2275
+ const digestEpicId = job.epicId ?? job.sourcePromptId ?? null;
2276
+ const originSessionId = resolveOriginSessionId(cwd, digestEpicId);
2277
+ let contextDigestApplied = false;
2278
+ if (originSessionId) {
2279
+ try {
2280
+ const digestText = await buildContextDigest({ cwd, epicId: digestEpicId });
2281
+ if (digestText) {
2282
+ prompt = digestText + '\n\n' + prompt;
2283
+ contextDigestApplied = true;
2284
+ }
2285
+ } catch (e) {
2286
+ safeLog(`[scheduler] context digest build failed (job still dispatches without it): ${e?.message ?? e}\n`);
2287
+ }
2288
+ }
2289
+
2112
2290
  const promptCheck = validatePromptForSpawn(prompt, prdPath);
2113
2291
  if (!promptCheck.ok) {
2114
2292
  safeLog(`[scheduler] ${promptCheck.error}\n`);
@@ -2262,7 +2440,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
2262
2440
  sl(`\n[scheduler] ${errMsg}\n`);
2263
2441
  // Sync write: inside a Promise executor callback; must flush meta
2264
2442
  // before resolve() so the spawnJob mutate() that follows sees it.
2265
- config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs, schedulerBootedAt: SCHEDULER_BOOTED_AT, schedulerCodeSha: SCHEDULER_CODE_SHA });
2443
+ config.writeJsonSync(metaPath, { slug: job.slug, cwd, sessionId, exitCode: -1, error: errMsg, startedAt, finishedAt: Date.now(), durationMs, schedulerBootedAt: SCHEDULER_BOOTED_AT, schedulerCodeSha: SCHEDULER_CODE_SHA, originSessionId, contextDigestApplied });
2266
2444
  resolve({ exitCode: -1, durationMs, error: errMsg, sessionId });
2267
2445
  return;
2268
2446
  }
@@ -2293,6 +2471,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
2293
2471
  startedAt, finishedAt: Date.now(), durationMs,
2294
2472
  agentResultSubtype, mappedFromSignal: mappedToSuccess ? signal || `code=${exitCode}` : null,
2295
2473
  schedulerBootedAt: SCHEDULER_BOOTED_AT, schedulerCodeSha: SCHEDULER_CODE_SHA,
2474
+ originSessionId, contextDigestApplied,
2296
2475
  });
2297
2476
  resolve({ exitCode: effectiveCode, durationMs, rateLimited, networkError, sessionId });
2298
2477
  },
@@ -2610,19 +2789,19 @@ async function spawnInvestigation(failedJob, runDir) {
2610
2789
  return;
2611
2790
  }
2612
2791
  sl(`\n[scheduler] investigation exit code=${exitCode}\n`);
2613
- // Fold the investigation's <RCA> summary into the RCA feedback item already
2614
- // filed for this job (needs_review jobs only — fileRcaFeedback no-ops when
2792
+ // Fold the investigation's <RCA> summary into the root-cause report already
2793
+ // written for this job (needs_review jobs only — writeRcaReport no-ops when
2615
2794
  // failedJob has no verifierVerdict, e.g. plain 'failed' jobs never got one).
2616
2795
  const investigationText = extractRcaBlock(readTail(investigationLogPath, 64 * 1024));
2617
2796
  if (investigationText) {
2618
- fileRcaFeedback({
2797
+ writeRcaReport({
2619
2798
  job: failedJob,
2620
2799
  runDir,
2621
2800
  verdict: failedJob.verifierVerdict,
2622
2801
  annotations: failedJob.verifierAnnotations,
2623
2802
  investigationText,
2624
2803
  }).catch((e) => {
2625
- console.error('[scheduler] fileRcaFeedback (investigation enrichment) error', failedJob.slug, e);
2804
+ console.error('[scheduler] writeRcaReport (investigation enrichment) error', failedJob.slug, e);
2626
2805
  });
2627
2806
  }
2628
2807
  if (fs.existsSync(fixPath)) {
@@ -3006,14 +3185,16 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
3006
3185
  if (needsReviewRcaSnapshot) {
3007
3186
  // Fire-and-forget, mirroring the DoD drain hook: never blocks the status
3008
3187
  // transition, never throws to this caller.
3009
- fileRcaFeedback({
3188
+ writeRcaReport({
3010
3189
  job: needsReviewRcaSnapshot,
3011
3190
  runDir,
3012
3191
  verdict: needsReviewRcaSnapshot.verifierVerdict,
3013
3192
  annotations: needsReviewRcaSnapshot.verifierAnnotations,
3014
- }).catch((e) => {
3015
- console.error('[scheduler] fileRcaFeedback error', job.slug, e);
3016
- });
3193
+ })
3194
+ .then((report) => notifyNeedsReview(needsReviewRcaSnapshot, report))
3195
+ .catch((e) => {
3196
+ console.error('[scheduler] writeRcaReport error', job.slug, e);
3197
+ });
3017
3198
  }
3018
3199
 
3019
3200
  if (actuallyFailed && failedJobSnapshot) {
@@ -3125,20 +3306,34 @@ function tickQueue() {
3125
3306
  }
3126
3307
  if (state.paused) {
3127
3308
  console.log('[scheduler] tickQueue skipped: paused');
3128
- return { fired: false, reason: 'paused' };
3309
+ return recordTick({ fired: false, reason: 'paused' }, { detail: 'scheduler paused' });
3129
3310
  }
3130
3311
  if (cancelToken.cancelled) return { fired: false, reason: 'cancelled' };
3131
3312
 
3132
3313
  await reconcile(state);
3133
- const cap = getEnvCap() ?? state.config.concurrencyCap;
3134
- 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
+ }
3135
3330
  if (batch.length === 0) {
3136
3331
  // Queue drained — run the definition-of-done gate fire-and-forget.
3137
3332
  // Non-blocking: does not hold the mutate lock; errors are logged, not thrown.
3138
3333
  runDefinitionOfDoneOnDrain(state, { cancelToken }).catch((err) => {
3139
3334
  console.log(`[scheduler] dod-drain: ${err?.message ?? String(err)}`);
3140
3335
  });
3141
- if (holdReason) return { fired: false, reason: 'held', detail: holdReason };
3336
+ if (holdReason) return recordTick({ fired: false, reason: 'held', detail: holdReason }, { holds });
3142
3337
  // Distinguish "genuinely nothing to do" from "the batch was already
3143
3338
  // fired by a concurrent tickQueue() call" (e.g. the periodic
3144
3339
  // when-available poll winning a race against a manual force-tick —
@@ -3151,9 +3346,9 @@ function tickQueue() {
3151
3346
  state.jobs.filter((j) => j.status === 'running').length,
3152
3347
  );
3153
3348
  if (runningCount > 0) {
3154
- return { fired: false, reason: 'already-running', runningCount };
3349
+ return recordTick({ fired: false, reason: 'already-running', runningCount }, { holds });
3155
3350
  }
3156
- return { fired: false, reason: 'drained' };
3351
+ return recordTick({ fired: false, reason: 'drained' }, { holds });
3157
3352
  }
3158
3353
 
3159
3354
  const availableMb = getAvailableMemMb();
@@ -3161,23 +3356,19 @@ function tickQueue() {
3161
3356
  // job is never started into the host's own headroom (that path OOM-kills
3162
3357
  // Electron and SIGHUPs every pty — 2026-06-16 incident).
3163
3358
  const jobBudgetMb = availableForJobs(availableMb, RESERVED_HOST_MB);
3164
- // Session-Manager's machine-wide slot pool is the outer bound: chat runs
3165
- // and scheduler jobs share it, so a busy chat lane shrinks this batch.
3166
- const slotAllowed = sessionSlots.available();
3167
- if (slotAllowed === 0) {
3168
- const snap = sessionSlots.snapshot();
3169
- console.log(`[scheduler] slot gate: 0 of ${snap.total} session slots free (${snap.holders.map((h) => h.owner).join(', ')}) — deferring ${batch.length} job(s)`);
3170
- return { fired: false, reason: 'slots-exhausted', deferredCount: batch.length, holders: snap.holders };
3171
- }
3172
- const allowed = Math.min(
3173
- slotAllowed,
3174
- 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,
3175
3363
  );
3176
3364
  if (allowed === 0) {
3177
3365
  const threshold = RESERVED_HOST_MB + MIN_FREE_MB_PER_JOB * (runningSet.size + 1);
3178
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)`);
3179
3367
  lastMemGate = { availableMb, threshold, deferred: true, at: new Date().toISOString() };
3180
- 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
+ );
3181
3372
  }
3182
3373
  const gatedBatch = batch.slice(0, allowed);
3183
3374
  if (gatedBatch.length < batch.length) {
@@ -3198,7 +3389,7 @@ function tickQueue() {
3198
3389
  // spawnJob is fire-and-forget; it calls tickQueue() on completion.
3199
3390
  spawnJob(job, runId, runDir, state.config.defaultCwd).catch(() => {});
3200
3391
  }
3201
- return { fired: true, count: gatedBatch.length, group: gatedBatch[0]?.parallelGroup };
3392
+ return recordTick({ fired: true, count: gatedBatch.length, group: gatedBatch[0]?.parallelGroup }, { holds });
3202
3393
  });
3203
3394
  tickTail = next.catch(() => {});
3204
3395
  return next;
@@ -3569,6 +3760,50 @@ function isUnresolvableNeedsReview(job, { hasRunDir }) {
3569
3760
  * commit-guard) — verifyRun can't see git, so re-scanning it would falsely
3570
3761
  * heal an unfinished job. Exported for tests.
3571
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
+
3572
3807
  function isRescanCandidate(job) {
3573
3808
  return !!job
3574
3809
  && job.status === 'needs_review'
@@ -3670,7 +3905,10 @@ async function reverifyNeedsReview() {
3670
3905
  priorLandedCommit,
3671
3906
  });
3672
3907
  } catch { leftForReview.push({ slug: job.slug, reason: 'verifyRun threw' }); continue; }
3673
- 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)) {
3674
3912
  healed.push(job.slug);
3675
3913
  } else {
3676
3914
  leftForReview.push({ slug: job.slug, reason: v ? `${v.verdict}: ${v.reason}` : 'null verdict' });
@@ -4399,12 +4637,9 @@ const remote = {
4399
4637
  if (candidate && fs.existsSync(candidate)) { dir = d; break; }
4400
4638
  }
4401
4639
  if (!dir) {
4402
- // Every PRD must join an EXISTING, already-human-approved Epic —
4403
- // mintIfMissing:false means this never conjures a new one. Only
4404
- // Epics born from explicit human intent (New Epic UI, or
4405
- // /propose-epic + Approve & start) may write PRDs; this write path
4406
- // is not itself a human-intent gate, so it must not become one by
4407
- // accident.
4640
+ // Every PRD must join an EXISTING, human-created Epic — ensureEpic
4641
+ // is join-only for every caller but the New Epic UI (epicMint.cjs's
4642
+ // SINGLE-CREATOR LAW), so this can never conjure one.
4408
4643
  const { fm } = splitFrontmatter(body);
4409
4644
  try {
4410
4645
  const epic = await ensureEpic(cwd, {
@@ -4415,11 +4650,10 @@ const remote = {
4415
4650
  // a literal-equality join. Any other id (e.g. a PromptTicket.id)
4416
4651
  // simply won't match and this call throws below.
4417
4652
  epicId: fm.sourcePromptId,
4418
- mintIfMissing: false,
4419
- // Join-only call (mintIfMissing:false, explicit epicId): the
4653
+ // Join-only call (no mintAuthority, explicit epicId): the
4420
4654
  // explicitEpicId branch in ensureEpic returns before `source` is
4421
4655
  // ever read, so this is a no-op today — kept for symmetry/audit
4422
- // trail if that join path ever grows a source-touch (PRD 902/905).
4656
+ // trail if that join path ever grows a source-touch.
4423
4657
  source: { producer: 'scheduler-dispatch', prdSlug: slug },
4424
4658
  });
4425
4659
  dir = epic.prdDir;
@@ -4428,7 +4662,7 @@ const remote = {
4428
4662
  return {
4429
4663
  ok: false,
4430
4664
  error: `no existing Epic to join (sourcePromptId=${fm.sourcePromptId ?? 'none'}): ${e?.message ?? 'unknown error'}. `
4431
- + 'Create the Epic first via the New Epic UI or /propose-epic + Approve & start, then pass its id as sourcePromptId.',
4665
+ + 'Create the Epic first via the New Epic UI, then pass its id as sourcePromptId.',
4432
4666
  };
4433
4667
  }
4434
4668
  }
@@ -4438,7 +4672,7 @@ const remote = {
4438
4672
  if (dir === PRDS_DIR) ensureDirs();
4439
4673
  }
4440
4674
 
4441
- // writePrd only ever JOINS an existing Epic now (mintIfMissing:false
4675
+ // writePrd only ever JOINS an existing Epic now (no mintAuthority
4442
4676
  // above) — it can never mint one, so there is no orphaned-mint case left
4443
4677
  // to roll back here (contrast the old PRD 825/851 cleanup, removed).
4444
4678
  const resolved = safeSlugPathIn(dir, slug);
@@ -4540,4 +4774,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
4540
4774
  });
4541
4775
  }
4542
4776
 
4543
- 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, 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 };