claude-code-session-manager 0.111.0 → 0.112.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 (282) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/dist/assets/{AgentLibrary-rBO1daqM.js → AgentLibrary-RwPvEgqm.js} +2 -2
  3. package/dist/assets/{Button-DsEYRZg0.js → Button-36j3PzYv.js} +1 -1
  4. package/dist/assets/{DataModel-D8sbvvBC.js → DataModel-DUhANo4l.js} +1 -1
  5. package/dist/assets/{History-BEHLtxia.js → History-BeZzNjmv.js} +2 -2
  6. package/dist/assets/{Hooks-CKpvlXrg.js → Hooks-iQ8845Hp.js} +3 -3
  7. package/dist/assets/{Library-CeF4qh9d.js → Library-DPE1h-1L.js} +6 -6
  8. package/dist/assets/{ListDetail-DcfmxPFk.js → ListDetail-PStOuQAo.js} +1 -1
  9. package/dist/assets/{MarkdownEditor-CvK1pyto.js → MarkdownEditor-DPw-N2-m.js} +1 -1
  10. package/dist/assets/{McpServers-D-h2ihzJ.js → McpServers-CUyRTNep.js} +2 -2
  11. package/dist/assets/{Memory-BnF89x8v.js → Memory-bZ2xjBGy.js} +3 -3
  12. package/dist/assets/{Panel-CQY5zano.js → Panel-_VZ_uXNg.js} +1 -1
  13. package/dist/assets/{Permissions-DLukmmsz.js → Permissions-PPRmfNbK.js} +3 -3
  14. package/dist/assets/{Plugins-0Pf0OZ4l.js → Plugins-BFcvfvlx.js} +2 -2
  15. package/dist/assets/{ProvenanceBadge-vC7Tj-JP.js → ProvenanceBadge-B1WUknak.js} +1 -1
  16. package/dist/assets/RemoteMachines-D5V0lzgW.js +3 -0
  17. package/dist/assets/{SaveBar-DjijiUb0.js → SaveBar-Dw64FVdM.js} +1 -1
  18. package/dist/assets/{Scheduler-Cm3EXZvE.js → Scheduler-C8s_pJ3I.js} +9 -9
  19. package/dist/assets/{ScopeSwitcher-B-P-kSfq.js → ScopeSwitcher-D-nKDMAE.js} +1 -1
  20. package/dist/assets/{Settings-DYDlp2m_.js → Settings-CQSrR4mO.js} +4 -4
  21. package/dist/assets/{SkillReferenceGraph-Epv2GJmj.js → SkillReferenceGraph-BbZFMHim.js} +1 -1
  22. package/dist/assets/{Skills-DHO0N0C2.js → Skills-Bzyrh6EQ.js} +1 -1
  23. package/dist/assets/SystemPrompt-DP6i3Dx0.js +1 -0
  24. package/dist/assets/TagLibrary-iip0C6Bl.js +1 -0
  25. package/dist/assets/{TiptapBody-DAMO3DhO.js → TiptapBody-Cr-hxX8L.js} +1 -1
  26. package/dist/assets/Toggle-7WpPzcZH.js +1 -0
  27. package/dist/assets/{ViewTabs-CGX6MAhc.js → ViewTabs-9lQCRwWJ.js} +1 -1
  28. package/dist/assets/{historyTotals-vgfk8EOL.js → historyTotals-BrFxw5J1.js} +1 -1
  29. package/dist/assets/{index-CPieAFsl.js → index-CTBpf-26.js} +250 -250
  30. package/dist/assets/{settingsSchema-D0FxNrTy.js → settingsSchema-Dh5Kl7VO.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-DCu6BNYN.js → useAgentsDirWatch-CMpx0d04.js} +1 -1
  32. package/dist/assets/{useRemotePeers-D4cdJDyh.js → useRemotePeers-CI2axd-L.js} +1 -1
  33. package/dist/index.html +1 -1
  34. package/package.json +3 -2
  35. package/plugins/CLAUDE.md +5 -8
  36. package/plugins/session-manager-dev/.claude-plugin/plugin.json +3 -6
  37. package/plugins/session-manager-dev/skills/builder/1-classify-and-bump/SKILL.md +2 -2
  38. package/plugins/session-manager-dev/skills/builder/SKILL.md +2 -3
  39. package/plugins/session-manager-dev/skills/develop/SKILL.md +1 -1
  40. package/plugins/session-manager-dev/skills/develop/standards.md +3 -3
  41. package/scripts/README.md +3 -3
  42. package/scripts/audit-ops-hygiene.cjs +4 -4
  43. package/scripts/hooks/guard-destructive-git.cjs +4 -4
  44. package/scripts/hooks/guard-inline-implementation.cjs +1 -1
  45. package/scripts/hooks/guard-prd-writes.cjs +2 -2
  46. package/scripts/hooks/lib/guard-destructive-git-policy.cjs +1 -1
  47. package/scripts/mint-epic.cjs +2 -2
  48. package/scripts/scheduler-mcp-server.cjs +4 -4
  49. package/src/main/agentLibrary.cjs +5 -5
  50. package/src/main/agentsIpc.cjs +76 -0
  51. package/src/main/build-info.json +4 -4
  52. package/src/main/chatRunner.cjs +12 -12
  53. package/src/main/config.cjs +2 -2
  54. package/src/main/crashDiagnostics.cjs +2 -2
  55. package/src/main/docEdit.cjs +5 -5
  56. package/src/main/files.cjs +2 -2
  57. package/src/main/git.cjs +1 -1
  58. package/src/main/health.cjs +20 -18
  59. package/src/main/heapSnapshot.cjs +1 -1
  60. package/src/main/historyAggregator.cjs +2 -2
  61. package/src/main/index.cjs +44 -132
  62. package/src/main/ipcSchemas.cjs +10 -10
  63. package/src/main/lib/CLAUDE.md +48 -0
  64. package/src/main/lib/activeSessions.cjs +1 -1
  65. package/src/main/lib/classifyPromptTicket.cjs +2 -2
  66. package/src/main/lib/{agentModelResolve.cjs → claude/agentModelResolve.cjs} +7 -7
  67. package/src/main/lib/{claudeBin.cjs → claude/claudeBin.cjs} +4 -4
  68. package/src/main/lib/{effectiveModelInfo.cjs → claude/effectiveModelInfo.cjs} +9 -9
  69. package/src/main/lib/{modelCatalog.cjs → claude/modelCatalog.cjs} +3 -3
  70. package/src/main/lib/{rateLimitDetect.cjs → claude/rateLimitDetect.cjs} +1 -1
  71. package/src/main/lib/{rateLimitWindow.cjs → claude/rateLimitWindow.cjs} +1 -1
  72. package/src/main/lib/{runClaudeP.cjs → claude/runClaudeP.cjs} +1 -1
  73. package/src/main/lib/credentials.cjs +3 -3
  74. package/src/main/lib/cwdClassify.cjs +1 -1
  75. package/src/main/lib/{activeIndexMerge.cjs → epic/activeIndexMerge.cjs} +3 -3
  76. package/src/main/lib/{activeIndexRebuild.cjs → epic/activeIndexRebuild.cjs} +1 -1
  77. package/src/main/lib/{delegationReadiness.cjs → epic/delegationReadiness.cjs} +21 -29
  78. package/src/main/lib/{epicContextDigest.cjs → epic/epicContextDigest.cjs} +1 -1
  79. package/src/main/lib/{epicDelegationStats.cjs → epic/epicDelegationStats.cjs} +2 -2
  80. package/src/main/lib/{epicMint.cjs → epic/epicMint.cjs} +11 -11
  81. package/src/main/lib/{epicSpawnPlan.cjs → epic/epicSpawnPlan.cjs} +3 -3
  82. package/src/main/lib/{epicStatusMirror.cjs → epic/epicStatusMirror.cjs} +2 -2
  83. package/src/main/lib/{epicTranscriptDiagnostic.cjs → epic/epicTranscriptDiagnostic.cjs} +2 -2
  84. package/src/main/lib/{epicTranscriptPath.cjs → epic/epicTranscriptPath.cjs} +2 -2
  85. package/src/main/lib/{epicValidationHook.cjs → epic/epicValidationHook.cjs} +3 -3
  86. package/src/main/lib/{epicWorktreeBoot.cjs → epic/epicWorktreeBoot.cjs} +1 -1
  87. package/src/main/lib/{epicWorktreeMerge.cjs → epic/epicWorktreeMerge.cjs} +3 -3
  88. package/src/main/lib/{epicWorktreeMint.cjs → epic/epicWorktreeMint.cjs} +3 -3
  89. package/src/main/lib/{epicWorktreeProjectConfig.cjs → epic/epicWorktreeProjectConfig.cjs} +4 -4
  90. package/src/main/lib/{promptSessionsCreateEpic.cjs → epic/promptSessionsCreateEpic.cjs} +4 -4
  91. package/src/main/lib/{crossProjectFeedback.cjs → feedback-remote/crossProjectFeedback.cjs} +7 -7
  92. package/src/main/lib/{customerFeedbackClient.cjs → feedback-remote/customerFeedbackClient.cjs} +3 -3
  93. package/src/main/lib/{customerFeedbackInbox.cjs → feedback-remote/customerFeedbackInbox.cjs} +2 -2
  94. package/src/main/lib/{customerFeedbackIpc.cjs → feedback-remote/customerFeedbackIpc.cjs} +1 -1
  95. package/src/main/lib/{localAdminHttp.cjs → feedback-remote/localAdminHttp.cjs} +2 -2
  96. package/src/main/lib/{macroAdminRoutes.cjs → feedback-remote/macroAdminRoutes.cjs} +1 -1
  97. package/src/main/lib/{projectHomeAdminRoutes.cjs → feedback-remote/projectHomeAdminRoutes.cjs} +4 -4
  98. package/src/main/lib/{relayClientAuth.cjs → feedback-remote/relayClientAuth.cjs} +2 -2
  99. package/src/main/lib/{remoteClientSessions.cjs → feedback-remote/remoteClientSessions.cjs} +1 -1
  100. package/src/main/lib/{gitWorktree.cjs → git/gitWorktree.cjs} +7 -7
  101. package/src/main/lib/historyRollup.cjs +1 -1
  102. package/src/main/lib/importReferences.cjs +1 -1
  103. package/src/main/lib/machineProfile.cjs +1 -1
  104. package/src/main/lib/openExternalApp.cjs +1 -1
  105. package/src/main/lib/opsOwnership.cjs +3 -3
  106. package/src/main/lib/{agentPersonaSchema.cjs → persona-macro/agentPersonaSchema.cjs} +2 -2
  107. package/src/main/lib/{macroLibrary.cjs → persona-macro/macroLibrary.cjs} +5 -5
  108. package/src/main/lib/{personaMerge.cjs → persona-macro/personaMerge.cjs} +4 -4
  109. package/src/main/lib/{prdAdminRoutes.cjs → prd/prdAdminRoutes.cjs} +7 -7
  110. package/src/main/lib/{prdAgentType.cjs → prd/prdAgentType.cjs} +3 -3
  111. package/src/main/lib/{prdCreate.cjs → prd/prdCreate.cjs} +12 -12
  112. package/src/main/lib/{prdDeclaredPaths.cjs → prd/prdDeclaredPaths.cjs} +1 -1
  113. package/src/main/lib/{prdGateFiles.cjs → prd/prdGateFiles.cjs} +1 -1
  114. package/src/main/lib/{prdLocations.cjs → prd/prdLocations.cjs} +2 -2
  115. package/src/main/lib/{prdMigration.cjs → prd/prdMigration.cjs} +3 -3
  116. package/src/main/lib/prereqs.cjs +2 -2
  117. package/src/main/lib/{instanceLock.cjs → process/instanceLock.cjs} +4 -11
  118. package/src/main/lib/{pidAlive.cjs → process/pidAlive.cjs} +1 -1
  119. package/src/main/lib/{procName.cjs → process/procName.cjs} +2 -2
  120. package/src/main/lib/{timeoutShim.cjs → process/timeoutShim.cjs} +1 -1
  121. package/src/main/lib/projectRootResolve.cjs +1 -1
  122. package/src/main/lib/promptSessionSchema.cjs +1 -1
  123. package/src/main/lib/{definitionOfDone.cjs → scheduler/definitionOfDone.cjs} +6 -12
  124. package/src/main/lib/{fixChainDepth.cjs → scheduler/fixChainDepth.cjs} +1 -1
  125. package/src/main/lib/{jobDirtFilter.cjs → scheduler/jobDirtFilter.cjs} +1 -1
  126. package/src/main/lib/{jobSupervisorRecord.cjs → scheduler/jobSupervisorRecord.cjs} +1 -1
  127. package/src/main/lib/{launchFailure.cjs → scheduler/launchFailure.cjs} +2 -2
  128. package/src/main/lib/{queueStore.cjs → scheduler/queueStore.cjs} +6 -6
  129. package/src/main/lib/{quietMachineLease.cjs → scheduler/quietMachineLease.cjs} +1 -1
  130. package/src/main/lib/{rcaReport.cjs → scheduler/rcaReport.cjs} +4 -4
  131. package/src/main/lib/{reaperHelpers.cjs → scheduler/reaperHelpers.cjs} +3 -3
  132. package/src/main/lib/{reservationExpiry.cjs → scheduler/reservationExpiry.cjs} +4 -11
  133. package/src/main/lib/{reviewNotice.cjs → scheduler/reviewNotice.cjs} +1 -1
  134. package/src/main/lib/{scheduleJobTransitions.cjs → scheduler/scheduleJobTransitions.cjs} +3 -3
  135. package/src/main/lib/{schedulerBatch.cjs → scheduler/schedulerBatch.cjs} +2 -2
  136. package/src/main/lib/{schedulerConfig.cjs → scheduler/schedulerConfig.cjs} +1 -1
  137. package/src/main/lib/{schedulerRuntimeState.cjs → scheduler/schedulerRuntimeState.cjs} +1 -1
  138. package/src/main/lib/{sessionSlots.cjs → scheduler/sessionSlots.cjs} +2 -2
  139. package/src/main/lib/{upgradeDrain.cjs → scheduler/upgradeDrain.cjs} +8 -11
  140. package/src/main/lib/{watchdogHelpers.cjs → scheduler/watchdogHelpers.cjs} +5 -19
  141. package/src/main/lib/{auditLog.cjs → telemetry/auditLog.cjs} +1 -1
  142. package/src/main/lib/{opsErrorLog.cjs → telemetry/opsErrorLog.cjs} +4 -4
  143. package/src/main/lib/{telemetryBacklog.cjs → telemetry/telemetryBacklog.cjs} +4 -4
  144. package/src/main/lib/{telemetryBoot.cjs → telemetry/telemetryBoot.cjs} +1 -1
  145. package/src/main/lib/{telemetryClient.cjs → telemetry/telemetryClient.cjs} +4 -4
  146. package/src/main/lib/{telemetrySettings.cjs → telemetry/telemetrySettings.cjs} +1 -1
  147. package/src/main/logs.cjs +1 -1
  148. package/src/main/mcpStatus.cjs +2 -2
  149. package/src/main/memoryAggregate.cjs +3 -3
  150. package/src/main/memoryTool.cjs +1 -1
  151. package/src/main/otel.cjs +2 -2
  152. package/src/main/pluginInstall.cjs +29 -3
  153. package/src/main/projectBrief.cjs +6 -6
  154. package/src/main/projectPages.cjs +1 -1
  155. package/src/main/promptSessionEvents.cjs +2 -2
  156. package/src/main/pty.cjs +7 -7
  157. package/src/main/queueOps.cjs +6 -6
  158. package/src/main/remoteClient.cjs +4 -4
  159. package/src/main/remoteClientIpc.cjs +1 -1
  160. package/src/main/runVerify.cjs +1 -1
  161. package/src/main/scheduler/CLAUDE.md +26 -0
  162. package/src/main/scheduler/budget.cjs +576 -0
  163. package/src/main/scheduler/gitTreeGuard.cjs +672 -0
  164. package/src/main/scheduler/prdParser.cjs +4 -4
  165. package/src/main/scheduler/prdPaths.cjs +487 -0
  166. package/src/main/scheduler/reviewLadder.cjs +1011 -0
  167. package/src/main/scheduler/starvation.cjs +390 -0
  168. package/src/main/scheduler.cjs +289 -3311
  169. package/src/main/seedAgentPersonas.cjs +5 -5
  170. package/src/main/seedDevPlugin.cjs +70 -3
  171. package/src/main/seedSchedulerMcp.cjs +2 -2
  172. package/src/main/supervisor.cjs +5 -5
  173. package/src/main/telemetryIpc.cjs +51 -0
  174. package/src/main/transcripts.cjs +3 -3
  175. package/src/main/updaterIpc.cjs +1 -1
  176. package/src/main/usage.cjs +1 -1
  177. package/src/main/watchers.cjs +1 -1
  178. package/src/main/webRemote.cjs +7 -7
  179. package/src/preload/api.d.ts +8 -8
  180. package/src/preload/index.cjs +3 -3
  181. package/dist/assets/RemoteMachines-CWnKbFHc.js +0 -3
  182. package/dist/assets/SystemPrompt-DxeNofL9.js +0 -1
  183. package/dist/assets/TagLibrary-QOAs7ISO.js +0 -1
  184. package/dist/assets/Toggle-Ba_f7q_m.js +0 -1
  185. package/plugins/session-manager-dev/skills/blog-for-project-feature/0-select/SKILL.md +0 -39
  186. package/plugins/session-manager-dev/skills/blog-for-project-feature/1-research/SKILL.md +0 -37
  187. package/plugins/session-manager-dev/skills/blog-for-project-feature/2-storyboard/SKILL.md +0 -64
  188. package/plugins/session-manager-dev/skills/blog-for-project-feature/3-capture/SKILL.md +0 -52
  189. package/plugins/session-manager-dev/skills/blog-for-project-feature/4-draft-copy/SKILL.md +0 -37
  190. package/plugins/session-manager-dev/skills/blog-for-project-feature/5-illustrate/SKILL.md +0 -50
  191. package/plugins/session-manager-dev/skills/blog-for-project-feature/6-compose/SKILL.md +0 -51
  192. package/plugins/session-manager-dev/skills/blog-for-project-feature/7-review/SKILL.md +0 -54
  193. package/plugins/session-manager-dev/skills/blog-for-project-feature/SKILL.md +0 -144
  194. package/plugins/session-manager-dev/skills/blog-for-project-feature/feature.config.yaml +0 -90
  195. package/plugins/session-manager-dev/skills/discover-features/SKILL.md +0 -95
  196. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +0 -205
  197. package/plugins/session-manager-dev/skills/explain-to-me/assets/style-reference.html +0 -163
  198. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +0 -83
  199. package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +0 -38
  200. package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +0 -64
  201. package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +0 -71
  202. package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +0 -39
  203. package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +0 -75
  204. package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +0 -39
  205. package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +0 -57
  206. package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +0 -40
  207. package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +0 -33
  208. package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +0 -40
  209. package/plugins/session-manager-dev/skills/issue-address/SKILL.md +0 -120
  210. package/plugins/session-manager-dev/skills/local-project-health/SKILL.md +0 -58
  211. package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +0 -147
  212. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +0 -137
  213. package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +0 -41
  214. package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +0 -56
  215. package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +0 -49
  216. package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +0 -57
  217. package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +0 -60
  218. package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +0 -124
  219. package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +0 -67
  220. package/plugins/session-manager-dev/skills/project-status/SKILL.md +0 -243
  221. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +0 -98
  222. package/plugins/session-manager-dev/skills/requesting-code-review/code-reviewer.md +0 -146
  223. /package/src/main/lib/{agentEffortResolve.cjs → claude/agentEffortResolve.cjs} +0 -0
  224. /package/src/main/lib/{claudeCliCaps.cjs → claude/claudeCliCaps.cjs} +0 -0
  225. /package/src/main/lib/{usageCircuit.cjs → claude/usageCircuit.cjs} +0 -0
  226. /package/src/main/lib/{epicSpawnCwd.cjs → epic/epicSpawnCwd.cjs} +0 -0
  227. /package/src/main/lib/{customerFeedbackAdminRoutes.cjs → feedback-remote/customerFeedbackAdminRoutes.cjs} +0 -0
  228. /package/src/main/lib/{remoteE2eCrypto.cjs → feedback-remote/remoteE2eCrypto.cjs} +0 -0
  229. /package/src/main/lib/{branchSweep.cjs → git/branchSweep.cjs} +0 -0
  230. /package/src/main/lib/{gitExec.cjs → git/gitExec.cjs} +0 -0
  231. /package/src/main/lib/{jobWorktree.cjs → git/jobWorktree.cjs} +0 -0
  232. /package/src/main/lib/{jobWorktreeBootLive.cjs → git/jobWorktreeBootLive.cjs} +0 -0
  233. /package/src/main/lib/{jobWorktreeTerminalOrphanLive.cjs → git/jobWorktreeTerminalOrphanLive.cjs} +0 -0
  234. /package/src/main/lib/{builtinMacros.cjs → persona-macro/builtinMacros.cjs} +0 -0
  235. /package/src/main/lib/{personaImportHealth.cjs → persona-macro/personaImportHealth.cjs} +0 -0
  236. /package/src/main/lib/{shippedPersonaSeeds.cjs → persona-macro/shippedPersonaSeeds.cjs} +0 -0
  237. /package/src/main/lib/{depSlugResolve.cjs → prd/depSlugResolve.cjs} +0 -0
  238. /package/src/main/lib/{planValidator.cjs → prd/planValidator.cjs} +0 -0
  239. /package/src/main/lib/{prdAuthoringSeed.cjs → prd/prdAuthoringSeed.cjs} +0 -0
  240. /package/src/main/lib/{prdDisposition.cjs → prd/prdDisposition.cjs} +0 -0
  241. /package/src/main/lib/{prdFrontmatter.cjs → prd/prdFrontmatter.cjs} +0 -0
  242. /package/src/main/lib/{prdSizing.cjs → prd/prdSizing.cjs} +0 -0
  243. /package/src/main/lib/{workTypeLibrary.cjs → prd/workTypeLibrary.cjs} +0 -0
  244. /package/src/main/lib/{childWithLog.cjs → process/childWithLog.cjs} +0 -0
  245. /package/src/main/lib/{cleanEnv.cjs → process/cleanEnv.cjs} +0 -0
  246. /package/src/main/lib/{defaultShell.cjs → process/defaultShell.cjs} +0 -0
  247. /package/src/main/lib/{killTree.cjs → process/killTree.cjs} +0 -0
  248. /package/src/main/lib/{loginShellPath.cjs → process/loginShellPath.cjs} +0 -0
  249. /package/src/main/lib/{procIdentity.cjs → process/procIdentity.cjs} +0 -0
  250. /package/src/main/lib/{singleInstanceGuard.cjs → process/singleInstanceGuard.cjs} +0 -0
  251. /package/src/main/lib/{smProcNames.cjs → process/smProcNames.cjs} +0 -0
  252. /package/src/main/lib/{timeoutShimScript.cjs → process/timeoutShimScript.cjs} +0 -0
  253. /package/src/main/lib/{winSpawn.cjs → process/winSpawn.cjs} +0 -0
  254. /package/src/main/lib/{adoptedRunSupervisor.cjs → scheduler/adoptedRunSupervisor.cjs} +0 -0
  255. /package/src/main/lib/{dispatchLoop.cjs → scheduler/dispatchLoop.cjs} +0 -0
  256. /package/src/main/lib/{dodDrainHook.cjs → scheduler/dodDrainHook.cjs} +0 -0
  257. /package/src/main/lib/{fixPlanSlug.cjs → scheduler/fixPlanSlug.cjs} +0 -0
  258. /package/src/main/lib/{gateAuthority.cjs → scheduler/gateAuthority.cjs} +0 -0
  259. /package/src/main/lib/{landedSinceRun.cjs → scheduler/landedSinceRun.cjs} +0 -0
  260. /package/src/main/lib/{loadGate.cjs → scheduler/loadGate.cjs} +0 -0
  261. /package/src/main/lib/{needsReviewLedger.cjs → scheduler/needsReviewLedger.cjs} +0 -0
  262. /package/src/main/lib/{queueHealth.cjs → scheduler/queueHealth.cjs} +0 -0
  263. /package/src/main/lib/{queueHistory.cjs → scheduler/queueHistory.cjs} +0 -0
  264. /package/src/main/lib/{runLogRetention.cjs → scheduler/runLogRetention.cjs} +0 -0
  265. /package/src/main/lib/{scheduleJobSchema.cjs → scheduler/scheduleJobSchema.cjs} +0 -0
  266. /package/src/main/lib/{schedulerPaths.cjs → scheduler/schedulerPaths.cjs} +0 -0
  267. /package/src/main/lib/{terminalRunOutcome.cjs → scheduler/terminalRunOutcome.cjs} +0 -0
  268. /package/src/main/lib/{validationSentinels.cjs → scheduler/validationSentinels.cjs} +0 -0
  269. /package/src/main/lib/{crashTelemetry.cjs → telemetry/crashTelemetry.cjs} +0 -0
  270. /package/src/main/lib/{otlpTraceExporter.cjs → telemetry/otlpTraceExporter.cjs} +0 -0
  271. /package/src/main/lib/{telemetryConsent.cjs → telemetry/telemetryConsent.cjs} +0 -0
  272. /package/src/main/lib/{telemetryCounters.cjs → telemetry/telemetryCounters.cjs} +0 -0
  273. /package/src/main/lib/{atomicFs.cjs → util/atomicFs.cjs} +0 -0
  274. /package/src/main/lib/{encodeCwd.cjs → util/encodeCwd.cjs} +0 -0
  275. /package/src/main/lib/{expandHome.cjs → util/expandHome.cjs} +0 -0
  276. /package/src/main/lib/{extractJson.cjs → util/extractJson.cjs} +0 -0
  277. /package/src/main/lib/{fileTail.cjs → util/fileTail.cjs} +0 -0
  278. /package/src/main/lib/{headTailBuffer.cjs → util/headTailBuffer.cjs} +0 -0
  279. /package/src/main/lib/{kebabCase.cjs → util/kebabCase.cjs} +0 -0
  280. /package/src/main/lib/{loopDelay.cjs → util/loopDelay.cjs} +0 -0
  281. /package/src/main/lib/{lruCache.cjs → util/lruCache.cjs} +0 -0
  282. /package/src/main/lib/{withTimeout.cjs → util/withTimeout.cjs} +0 -0
@@ -0,0 +1,1011 @@
1
+ /**
2
+ * scheduler/reviewLadder.cjs — needs_review / auto-fix / auto-reset eligibility
3
+ * predicates, selectors and their verdict sets + thresholds. Extracted verbatim
4
+ * from scheduler.cjs; the queue-mutating ladder rungs stay there and call in.
5
+ */
6
+ 'use strict';
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const schedulerPaths = require('../lib/scheduler/schedulerPaths.cjs');
11
+ const jobWorktree = require('../lib/git/jobWorktree.cjs');
12
+ const { classifyRunOutcome } = require('../lib/scheduler/reaperHelpers.cjs');
13
+ const { COMPLETED_EQUIVALENT_VERDICTS } = require('../lib/scheduler/terminalRunOutcome.cjs');
14
+ const { resolveIsFixPlan } = require('../lib/scheduler/fixPlanSlug.cjs');
15
+ const { GATE_AUTHORITY_VERDICTS } = require('../lib/scheduler/gateAuthority.cjs');
16
+ const { DEFAULT_PROJECT_CWD } = require('../lib/scheduler/schedulerBatch.cjs');
17
+
18
+ /**
19
+ * Resume-first recovery (PRD 1111). A job parked in needs_review with verdict
20
+ * 'uncommitted_changes' has a live claude session (job.sessionId, minted by
21
+ * spawnJob's `--session-id`) that already has full context of the work it
22
+ * left uncommitted — resuming it via `claude -p --resume <sessionId>` lets it
23
+ * finish its own finish-protocol COMMIT step, instead of spawnInvestigation
24
+ * cold-reading the log to author a fix-plan PRD that a FRESH session then has
25
+ * to re-derive that same context for. Pure/no I/O so the eligibility rule can
26
+ * be unit-tested directly, matching classifyFailureOutcome/commitGuardVerdict.
27
+ *
28
+ * Bounded to exactly one attempt via job.resumeRecoveryAttempted, stamped
29
+ * atomically with the 'running' transition inside spawnJob's own dispatch
30
+ * mutate (see spawnJob) — never here — so a crash between this function
31
+ * returning a target and the resume child actually spawning cannot leave the
32
+ * job re-eligible.
33
+ *
34
+ * Kill-switch: SM_RESUME_RECOVERY_DISABLE=1 restores today's behaviour
35
+ * exactly (always returns null), mirroring SM_RCA_DISABLE/SM_DOD_DISABLE.
36
+ */
37
+ function selectResumeRecoveryTarget(job) {
38
+ if (process.env.SM_RESUME_RECOVERY_DISABLE === '1') return null;
39
+ if (!job || job.status !== 'needs_review') return null;
40
+ if (job.verifierVerdict !== 'uncommitted_changes') return null;
41
+ if (typeof job.sessionId !== 'string' || job.sessionId.length === 0) return null;
42
+ if (job.resumeRecoveryAttempted === true) return null;
43
+ const dirtyPaths = Array.isArray(job.uncommittedPaths)
44
+ ? job.uncommittedPaths.filter((p) => typeof p === 'string' && p.length > 0)
45
+ : [];
46
+ if (!dirtyPaths.length) return null;
47
+ return { slug: job.slug, sessionId: job.sessionId, dirtyPaths, salvagePatch: job.salvagePatch || null };
48
+ }
49
+
50
+ /**
51
+ * Mechanical recovery (PRD 1130). isFixPlanBeyondDepthCap (below) is the
52
+ * ONLY gate on re-investigating a fix-plan job at investigationDepth >= 2 —
53
+ * correct for open-ended "author another plan" recursion, but it also
54
+ * strands a depth-capped job whose failure was fully mechanical (no
55
+ * judgement required) with no other ladder rung, since resume-first recovery
56
+ * (selectResumeRecoveryTarget above) is hard-gated on verdict
57
+ * 'uncommitted_changes'. This rung is evaluated INDEPENDENTLY of
58
+ * isFixPlanBeyondDepthCap — depth never disqualifies it, because unlike
59
+ * auto-fix it authors no plan and spawns no model; it is pure git.
60
+ *
61
+ * The closed set of mechanically-resolvable verdicts starts at exactly
62
+ * 'worktree_integration_failed', which covers two subtypes (job.
63
+ * integrationFailureKind): `blocking_paths` — PRD 1125 taught integrateBranch
64
+ * to parse git's "would be overwritten by merge" stderr, verify the blocking
65
+ * paths are byte-identical to the branch, discard the proven duplicates and
66
+ * retry once, so a re-call can genuinely succeed once the dirty files are
67
+ * gone — and `content_conflict`, which PRD 1125 explicitly does NOT handle
68
+ * (parseBlockingMergePaths returns null for it). Only pure-addition text
69
+ * conflicts are auto-resolved (mc-02, gitWorktree.cjs, kill switch
70
+ * SM_PURE_ADDITION_MERGE_DISABLE=1) — and that already ran inside the
71
+ * original failed integration. Re-calling integrateBranch for a content
72
+ * conflict that survived it, against an unmoved base HEAD, fails
73
+ * deterministically, so the selector skips it (WITHOUT spending the one
74
+ * attempt) until HEAD moves — a sibling merge may make the retry succeed.
75
+ * A job parked with this verdict has its `sm-job/<slug>` branch preserved
76
+ * (cleanupJobWorktree's `keepBranch: !integration.ok`).
77
+ *
78
+ * Bounded to exactly one attempt via job.mechanicalRecoveryAttempted,
79
+ * stamped in the SAME mutate as the outcome (performMechanicalRecovery,
80
+ * below) — never here — so this selector alone can be unit-tested exactly
81
+ * like selectResumeRecoveryTarget/selectLeftoverQuarantineTarget.
82
+ *
83
+ * Kill-switch: SM_MECHANICAL_RECOVERY_DISABLE=1 restores today's behaviour
84
+ * exactly (always returns null), mirroring SM_RESUME_RECOVERY_DISABLE.
85
+ */
86
+ const MECHANICALLY_RESOLVABLE_VERDICTS = new Set(['worktree_integration_failed']);
87
+
88
+ /**
89
+ * True when a retry is provably futile: a content_conflict whose base HEAD is
90
+ * the same as at failure time. Unknown state (no stamp, null current HEAD)
91
+ * is NOT futile — falls back to attempting the retry.
92
+ */
93
+ function isMechanicalRecoveryFutile(job, currentHeadSha) {
94
+ return job.integrationFailureKind === 'content_conflict'
95
+ && !!job.integrationBaseHeadSha
96
+ && !!currentHeadSha
97
+ && job.integrationBaseHeadSha === currentHeadSha;
98
+ }
99
+
100
+ function selectMechanicalRecoveryTarget(job, currentHeadSha = null) {
101
+ if (process.env.SM_MECHANICAL_RECOVERY_DISABLE === '1') return null;
102
+ if (!job || job.status !== 'needs_review') return null;
103
+ if (!MECHANICALLY_RESOLVABLE_VERDICTS.has(job.verifierVerdict)) return null;
104
+ if (job.mechanicalRecoveryAttempted === true) return null;
105
+ if (isMechanicalRecoveryFutile(job, currentHeadSha)) return null;
106
+ const cwd = job.cwd || DEFAULT_PROJECT_CWD;
107
+ return {
108
+ slug: job.slug,
109
+ cwd,
110
+ branch: jobWorktree.branchNameFor(job.slug),
111
+ carriedPaths: job.carriedPaths || [],
112
+ baseBranch: job.worktreeBaseBranch || null,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Pure predicate: a needs_review row parked as shared_tree_reverted that
118
+ * carries a landedCommit — the only shape reverifyNeedsReview can re-check
119
+ * against ground truth (landedCommitIsAncestorOfHead). Deliberately
120
+ * independent of autoFixAttempted: 1229-fo-03 was parked with
121
+ * autoFixAttempted:true and no autoFixOutcome (isStrandedAutoFixPark shape),
122
+ * yet isStrandedAutoFixPark could not release it — that ladder only resolves
123
+ * once job.looksDone is set, and reverifyNeedsReview computes looksDone only
124
+ * for isRescanCandidate / isGuardParkedWithoutAutoFix rows, neither of which
125
+ * a shared_tree_reverted + autoFixAttempted row is.
126
+ */
127
+ function isStaleSharedTreeRevertedPark(job) {
128
+ return !!job && job.status === 'needs_review'
129
+ && job.verifierVerdict === 'shared_tree_reverted'
130
+ && typeof job.landedCommit === 'string' && job.landedCommit.length > 0;
131
+ }
132
+
133
+ /**
134
+ * The fix-plan slug spawnInvestigation authors for a given failed job —
135
+ * shared by selectAutoFixTargets (queue-membership check) and
136
+ * reverifyNeedsReview's autofix_plan_unqueued annotation (same check, run
137
+ * one or more passes later) so both use one slug formula.
138
+ */
139
+ function fixSlugFor(job) {
140
+ return `${String(job.parallelGroup ?? 99).padStart(2, '0')}-fix-${job.slug.replace(/^\d+-/, '')}`;
141
+ }
142
+
143
+ // Transcript-scan verdicts that re-running verifyRun can re-evaluate. NOT
144
+ // 'uncommitted_changes' — that comes from the git commit-guard, which verifyRun
145
+ // does not inspect, so re-scanning it would always return 'clean' and wrongly
146
+ // heal a genuinely-unfinished job. 'no_verdict_sentinel' is included because
147
+ // its raising condition (sentinel === null && !committedDuringRun) depends on
148
+ // commit-detection, which committedInWindow() can fix retroactively (e.g. the
149
+ // git-log --all scan added for missed non-HEAD commits) — rescanning lets a
150
+ // job whose commit is now correctly detected clear on its own instead of
151
+ // staying stuck in needs_review forever. 'pass_no_commit' is included because
152
+ // verifyRun now exempts fix-plan jobs (slug ^\d+-fix-) from that check — a
153
+ // fix-plan job flagged before that exemption shipped gets a genuinely
154
+ // different verdict on rescan (2026-07-12: false-positive cascade where
155
+ // investigation jobs correctly found "nothing to fix" but were flagged
156
+ // anyway). For non-fix-plan jobs the exemption never applies, so rescanning
157
+ // their pass_no_commit verdict is a harmless no-op (same facts, same verdict).
158
+ const RESCANNABLE_VERDICTS = new Set(['transcript_errors', 'verify_unavailable', 'no_verdict_sentinel', 'abandoned_background_task', 'pass_no_commit', 'pass_no_commit_already_shipped']);
159
+ // RESCANNABLE_VERDICTS is a HINT, not a gate: it names the verdicts whose
160
+ // recovery rung is a transcript re-verification (verifyRun). Every other
161
+ // needs_review verdict is still a heal candidate (isRescanCandidate) — it just
162
+ // gets the evidence-only rung (computeLooksDone) instead of a transcript
163
+ // rescan, because verifyRun cannot see a commit-guard / shared-tree verdict and
164
+ // would return 'clean' and falsely heal it.
165
+
166
+ // The ONLY needs_review verdicts NOT eligible for the periodic heal ladder.
167
+ // An allow-list here was reopened three times (2026-09-12 x2, 2026-09-18
168
+ // shared_tree_reverted) because a new park reason was born invisible to
169
+ // self-healing. Add a verdict here only with a one-line proof that no
170
+ // re-verification or evidence scan can ever change it.
171
+ const RESCAN_EXCLUDED_VERDICTS = new Set([
172
+ // Commit-guard verdict verifyRun never inspects: a rescan returns 'clean' and would heal genuinely unfinished work.
173
+ 'uncommitted_changes',
174
+ // Its damage IS a commit stranded on an unmerged sm-job branch — a landedCommit restates it; selectMechanicalRecoveryTarget owns the real re-merge.
175
+ 'worktree_integration_failed',
176
+ // The run overran its own time/cost estimate; no transcript or git evidence can un-overrun it (selectAutoFixTargets excludes it too).
177
+ 'budget_exceeded',
178
+ ]);
179
+
180
+ // Per-pass / per-row bounds on the evidence-only rung (the widened candidate
181
+ // set). Each scan costs one computeLooksDone: a per-cwd-deduped `git fetch`
182
+ // (<=~20s) + a git log. Unbounded, a backlog of N parked rows would pay N of
183
+ // those every 10 minutes forever.
184
+ const REVERIFY_INTERVAL_MS = 10 * 60_000;
185
+ const EVIDENCE_SCAN_MAX_PER_PASS = 20;
186
+ const EVIDENCE_SCAN_MIN_INTERVAL_MS = 6 * REVERIFY_INTERVAL_MS;
187
+
188
+ // Bounds fix-plan recursion: cap N permits at most N+1 fix jobs per original
189
+ // slug (depth 1 = the original job, depth 2 = its `-fix`, depth 3+ is
190
+ // excluded). With N=1 that's `<slug>-fix` and `<slug>-fix-fix`, never a third
191
+ // `-fix-fix-fix`. Lowered from 2 to 1 on 2026-08-31 (starry-night-ships):
192
+ // three concurrent chains (115-fix-fix, 113-fix-fix, 111-fix-fix-fix) were
193
+ // riding the old cap, and 115-fix-fix's own root-cause section read "The
194
+ // code was already CORRECT. Only verification and commit failed." — a third
195
+ // auto-retry re-runs an entire PRD and test battery to redo a `git commit`,
196
+ // at near-zero marginal success probability. Shared by selectAutoFixTargets
197
+ // and spawnInvestigation so both call sites agree on one threshold.
198
+ const MAX_INVESTIGATION_DEPTH = 1;
199
+
200
+ /**
201
+ * True when a fix-plan job's investigationDepth is at or past the recursion
202
+ * cap and it must be excluded from auto-fix eligibility. A fix-plan job with
203
+ * no recorded investigationDepth (a job already in the queue before this
204
+ * depth tracking shipped) is treated as excluded too, preserving the
205
+ * pre-existing blanket-exclusion behavior for legacy jobs — no retroactive
206
+ * migration. Non-fix-plan jobs are never capped here.
207
+ *
208
+ * `isFixPlan` (PRD 1131) is the job's own persisted classification stamp
209
+ * (see lib/scheduler/fixPlanSlug.cjs's resolveIsFixPlan) — an explicit true/false wins
210
+ * over the slug; only a row with the field entirely absent (persisted
211
+ * before this change shipped) falls back to the legacy slug-only heuristic.
212
+ * Exported for tests.
213
+ */
214
+ function isFixPlanBeyondDepthCap(slug, investigationDepth, isFixPlan) {
215
+ if (!resolveIsFixPlan(slug, isFixPlan)) return false;
216
+ if (investigationDepth == null) return true;
217
+ return investigationDepth >= MAX_INVESTIGATION_DEPTH + 1;
218
+ }
219
+
220
+ /**
221
+ * Backfill a job's missing runId by scanning RUNS_DIR for a run directory
222
+ * whose contents reference this job's slug (a '<slug>.log' file inside it).
223
+ * A needs_review job can lose its runId (e.g. an old queue-schema gap) and
224
+ * become invisible to every auto-resolution path that requires job.runId
225
+ * truthy. O(number of run dirs) — a single readdir + per-dir existsSync
226
+ * check, no nested loop over user-scaled data. Dir names are ISO timestamps,
227
+ * so lexical-descending sort picks the newest match. Exported for tests.
228
+ */
229
+ function resolveRunId(job, { runsDir = schedulerPaths.runsDir() } = {}) {
230
+ if (!job || job.runId) return job?.runId || null;
231
+ if (!job.slug) return null;
232
+ let dirs;
233
+ try {
234
+ dirs = fs.readdirSync(runsDir);
235
+ } catch {
236
+ return null;
237
+ }
238
+ const matches = dirs.filter((d) => {
239
+ try {
240
+ return fs.existsSync(path.join(runsDir, d, `${job.slug}.log`));
241
+ } catch {
242
+ return false;
243
+ }
244
+ });
245
+ if (!matches.length) return null;
246
+ matches.sort().reverse();
247
+ return matches[0];
248
+ }
249
+
250
+ /**
251
+ * Pure predicate: a needs_review job with no runId and no backfillable run
252
+ * directory can never self-heal or get an auto-fix investigation — it must
253
+ * be surfaced for a human rather than silently dead-ended. Exported for
254
+ * tests.
255
+ */
256
+ function isUnresolvableNeedsReview(job, { hasRunDir }) {
257
+ return !!job && job.status === 'needs_review' && !job.runId && !hasRunDir;
258
+ }
259
+
260
+ /**
261
+ * Pure predicate: an auto-fix attempt whose one bounded retry is already
262
+ * spent, and whose outcome was NOT 'plan' (a produced plan is judged by
263
+ * isPlanUnqueued instead — a plan is never retried, only annotated if it
264
+ * never reaches the queue). Covers all three exhaustible outcomes
265
+ * ('no-plan', 'error', and unstamped/undefined) — mirrors the retry
266
+ * eligibility rule in selectAutoFixTargets so a job can never be retry-
267
+ * eligible there and simultaneously un-annotatable here.
268
+ *
269
+ * A parent stamped `autoFixReopened: true` (its dead fix-plan child earned
270
+ * it exactly one further attempt — see isFixPlanDead) is a separate
271
+ * exhaustion path: it is spent as soon as that second investigation
272
+ * concludes with ANY outcome, including another 'plan' — a reopened parent
273
+ * never gets a third attempt, so unlike the fresh case a 'plan' outcome does
274
+ * not exempt it here.
275
+ */
276
+ function isExhaustedAutoFix(job) {
277
+ if (!job || job.status !== 'needs_review' || job.autoFixAttempted !== true) return false;
278
+ if (job.autoFixReopened === true) return job.autoFixOutcome != null;
279
+ return job.autoFixOutcome !== 'plan' && (job.autoFixRetries ?? 0) >= 1;
280
+ }
281
+
282
+ /**
283
+ * Verdicts from the POST-RUN GUARDS (commit-guard / shared-tree guard) that a
284
+ * later, independently-checkable commit/looksDone signal can meaningfully
285
+ * confirm or refute. Auto-fix investigations only ever launch for FAILING
286
+ * runs (see isExhaustedAutoFix above and selectAutoFixTargets) — a job parked
287
+ * by one of these GUARD verdicts exits 0 and never has autoFixAttempted set,
288
+ * so it is invisible to isExhaustedAutoFix and can sit in needs_review
289
+ * forever with nothing to spend and nothing to exhaust (PRD 1181, 2026-09-12:
290
+ * exit 0, commit aff5607 landed, parked on a shared-tree verdict, cleared
291
+ * only by a human).
292
+ *
293
+ * 'worktree_integration_failed' is deliberately EXCLUDED — its damage IS a
294
+ * commit: one stranded on an unmerged `sm-job/<slug>` branch. A `landedCommit`
295
+ * existing is not evidence against that verdict, it is a restatement of it,
296
+ * so admitting it here would auto-complete a row whose work never actually
297
+ * reached the target branch. It already has its own dedicated, git-native
298
+ * resolution path (selectMechanicalRecoveryTarget / performMechanicalRecovery
299
+ * — a real re-attempted merge) and must never be pulled into this ladder.
300
+ *
301
+ * 'pidless_reap_with_landed_commit' (PRD 1173, resolvePidlessFailureOverride
302
+ * in reaperHelpers.cjs) is included: it parks on the exact same shape (exit
303
+ * never observed / no autoFixAttempted, real landedCommit evidence) as
304
+ * 'silent_no_op' and 'shared_tree_reverted', and this ladder never trusts
305
+ * landedCommit alone anyway — applyNeedsReviewAutoResolve only resolves once
306
+ * job.looksDone independently reconfirms via a fresh commits-since-this-run
307
+ * scan, which is exactly the "does the commit correspond to THIS dispatch"
308
+ * re-verification resolvePidlessFailureOverride's own header says the
309
+ * pidless-reap path itself cannot do. Omitting it here reproduces the same
310
+ * "nothing to spend, nothing to exhaust" needs_review stall this PRD exists
311
+ * to fix, just for a third verdict.
312
+ */
313
+ const GUARD_VERDICT_EVIDENCE_ELIGIBLE = new Set(['silent_no_op', 'shared_tree_reverted', 'pidless_reap_with_landed_commit']);
314
+
315
+ /**
316
+ * Pure predicate, no I/O: a needs_review row parked directly by one of the
317
+ * GUARD_VERDICT_EVIDENCE_ELIGIBLE verdicts, that never went through an
318
+ * auto-fix investigation at all (autoFixAttempted is not true) — the
319
+ * structural gap this PRD closes, distinct from isExhaustedAutoFix's "went
320
+ * through auto-fix and spent it" case. A job that DID get an auto-fix
321
+ * investigation is left to isExhaustedAutoFix's own ladder rather than this
322
+ * one, even if its verifierVerdict happens to also be in the eligible set.
323
+ * Exported for tests.
324
+ */
325
+ function isGuardParkedWithoutAutoFix(job) {
326
+ if (!job || job.status !== 'needs_review') return false;
327
+ if (job.autoFixAttempted === true) return false;
328
+ return GUARD_VERDICT_EVIDENCE_ELIGIBLE.has(job.verifierVerdict);
329
+ }
330
+
331
+ /**
332
+ * Pure predicate, no I/O: a needs_review row whose auto-fix investigation
333
+ * genuinely ran (autoFixAttempted === true) but whose outcome was NEVER
334
+ * durably stamped at all — and that has nothing left in flight to wait on:
335
+ * no live/queued fix-plan row at fixSlugFor(job).
336
+ *
337
+ * Distinct from isExhaustedAutoFix, which requires autoFixRetries >= 1 to
338
+ * have already accumulated. spawnInvestigation's onExit handler restores the
339
+ * job's status from 'investigating' back to needs_review in ONE mutate()
340
+ * call (scheduler.cjs's spawnInvestigation, source
341
+ * 'spawnInvestigation:onExit') and stamps autoFixOutcome ('plan' / 'no-plan'
342
+ * / 'error') in a SEPARATE, later mutate() call — an app restart or process
343
+ * death between the two leaves autoFixOutcome permanently unset, with
344
+ * autoFixRetries never incremented either, so isExhaustedAutoFix never fires
345
+ * and the row falls through every existing resolving door forever, re-scanned
346
+ * by the periodic reverify pass against the same frozen transcript with no
347
+ * new outcome to observe.
348
+ *
349
+ * Job 1218-fo-01 (2026-09-13, findings filed at
350
+ * session-manager-operations/reviews/2026-09-13-scheduler-stability-investigation.md,
351
+ * "post-run adjudication" section) sat exactly in this state: needs_review,
352
+ * verifierVerdict transcript_errors, autoFixAttempted: true, autoFixOutcome:
353
+ * undefined, autoFixRetries: undefined, statusHistory ending in
354
+ * "investigation probe exited — restoring prior status" — with a landed
355
+ * commit no existing ladder rung would credit.
356
+ *
357
+ * Deliberately narrower than "unset, 'error', or 'no-plan'": a row that DID
358
+ * get a durably-stamped 'error'/'no-plan' outcome with its one bounded retry
359
+ * still unspent (autoFixRetries < 1) is exactly the row
360
+ * selectAutoFixTargets's own retryEligible check still owns and will retry
361
+ * on its own — pulling it into THIS ladder instead would race it away from
362
+ * that retry (scheduler-needs-review-autoresolve.test.cjs's "a non-exhausted
363
+ * needs_review row … is left alone" guards exactly this). Only the
364
+ * outcome-truly-never-stamped case is structurally unrecoverable by any
365
+ * OTHER existing door, because nothing ever wrote a value selectAutoFixTargets
366
+ * or isExhaustedAutoFix could act on.
367
+ * Exported for tests.
368
+ */
369
+ function isStrandedAutoFixPark(job, jobsInProject) {
370
+ if (!job || job.status !== 'needs_review') return false;
371
+ if (job.autoFixAttempted !== true) return false;
372
+ if (job.autoFixOutcome != null) return false;
373
+ const fixSlug = fixSlugFor(job);
374
+ const liveOrQueuedChild = (jobsInProject || []).some(
375
+ (j) => j.slug === fixSlug && j.status !== 'completed' && !DEAD_FIX_CHILD_STATUSES.has(j.status),
376
+ );
377
+ return !liveOrQueuedChild;
378
+ }
379
+
380
+ /**
381
+ * Pure predicate, no I/O: is this needs_review row eligible for the bounded
382
+ * auto-resolve ladder at all — either because its auto-fix path is genuinely
383
+ * spent (isExhaustedAutoFix), because it was parked by a GUARD verdict that
384
+ * never entered auto-fix in the first place (isGuardParkedWithoutAutoFix),
385
+ * or because its auto-fix investigation ran but was stranded before
386
+ * recording any outcome (isStrandedAutoFixPark). All three classes share ONE
387
+ * ladder (applyNeedsReviewAutoResolve) rather than a duplicated one — the
388
+ * ladder itself doesn't care which door a row came through, only whether it
389
+ * now carries completion evidence (job.looksDone). `jobsInProject` is only
390
+ * consulted by isStrandedAutoFixPark (to check for a live/queued fix-plan
391
+ * child) and defaults to empty so existing single-arg callers are unaffected.
392
+ * Exported for tests.
393
+ */
394
+ function isEligibleForNeedsReviewAutoResolve(job, jobsInProject = []) {
395
+ return isExhaustedAutoFix(job) || isGuardParkedWithoutAutoFix(job) || isStrandedAutoFixPark(job, jobsInProject);
396
+ }
397
+
398
+ /**
399
+ * Pure predicate: an investigation produced a fix plan (autoFixOutcome ===
400
+ * 'plan') but its fix-plan slug is not present among `queuedSlugs` — the
401
+ * plan file failed to become a queue row (write failure, or some other
402
+ * reason reconcile() never ingested it). `queuedSlugs` is the caller's
403
+ * current job-slug set, passed in rather than read here so this stays pure.
404
+ */
405
+ function isPlanUnqueued(job, queuedSlugs) {
406
+ if (!job || job.status !== 'needs_review') return false;
407
+ if (job.autoFixOutcome !== 'plan') return false;
408
+ return !queuedSlugs.has(fixSlugFor(job));
409
+ }
410
+
411
+ // Terminal-and-not-completed statuses a fix-plan child can die in — see
412
+ // isFixPlanDead.
413
+ const DEAD_FIX_CHILD_STATUSES = new Set(['needs_review', 'failed', 'quarantined', 'skipped']);
414
+
415
+ /**
416
+ * Pure predicate: a parent stuck at outcome 'plan' whose own fix-plan child
417
+ * (fixSlugFor(job)) has ITSELF died — reached a terminal non-completed
418
+ * status — with nothing left in the ladder that will ever revisit either
419
+ * row again (selectAutoFixTargets skips a 'plan' outcome outright, and
420
+ * isPlanUnqueued only fires when the child never reached the queue at all,
421
+ * which isn't true once a dead child row exists). `job.autoFixReopened`
422
+ * gates this to exactly once per parent — once stamped, this always returns
423
+ * false so the parent can never be reopened a second time. Exported for
424
+ * tests.
425
+ */
426
+ function isFixPlanDead(job, jobsInProject) {
427
+ if (!job || job.status !== 'needs_review') return false;
428
+ if (job.autoFixOutcome !== 'plan') return false;
429
+ if (job.autoFixReopened === true) return false;
430
+ const fixSlug = fixSlugFor(job);
431
+ const child = (jobsInProject || []).find((j) => j.slug === fixSlug);
432
+ if (!child) return false;
433
+ return DEAD_FIX_CHILD_STATUSES.has(child.status);
434
+ }
435
+
436
+ /**
437
+ * Pure predicate: is this job eligible for the boot re-verify self-heal? Only
438
+ * needs_review jobs with a run log (own or backfilled via resolveRunId) AND a
439
+ * transcript-scan verdict. Crucially EXCLUDES 'uncommitted_changes' (git
440
+ * commit-guard) — verifyRun can't see git, so re-scanning it would falsely
441
+ * heal an unfinished job. Exported for tests.
442
+ */
443
+ /**
444
+ * healRefusalReason(job, verdict, committedDuringRun) → string | null
445
+ *
446
+ * Guards reverifyNeedsReview's needs_review → completed self-heal. Returns a
447
+ * refusal reason when the heal must NOT happen, or null to allow it.
448
+ *
449
+ * THE INCIDENT (PRD 983, 2026-08-03). Job 972 ran 34 s, edited zero files and
450
+ * exited 0. verifyRun correctly raised `no_verdict_sentinel` (no commit AND no
451
+ * SCHEDULER_VERDICT sentinel) with downgradeTo 'needs_review', and the job was
452
+ * parked. One minute later this self-heal pass promoted it to `completed`, and
453
+ * that false green shipped to the user.
454
+ *
455
+ * The mechanism is subtle and worth spelling out, because the individual parts
456
+ * all look correct: `no_verdict_sentinel` is in RESCANNABLE_VERDICTS, so the
457
+ * job was re-verified. The rescan recomputes `committedDuringRun` via
458
+ * `committedInWindow`, which runs `git log --all --since=<startedAt>
459
+ * --until=<finishedAt+60s>` with NO author, message, or slug filter — it
460
+ * answers "did ANY commit land in this repo during that window", not "did THIS
461
+ * job commit". With up to 3 concurrent scheduler jobs plus interactive
462
+ * sessions sharing one repo, a sibling job's commit lands inside the window
463
+ * routinely. `committedDuringRun` therefore flipped to true on the rescan,
464
+ * runVerify's `if (sentinel === null && !committedDuringRun)` no longer fired,
465
+ * `no_verdict_sentinel` was never re-raised, the issue list came back empty,
466
+ * and the verdict concluded `clean` — a COMPLETED_EQUIVALENT verdict, so the
467
+ * job healed. Another job's commit was silently credited to this one.
468
+ *
469
+ * The rule: a run that emitted no sentinel and landed no commit ATTRIBUTABLE
470
+ * TO ITSELF has produced no evidence it did anything, and repo-wide
471
+ * time-window evidence is not attribution. `landedCommit` is per-job and
472
+ * survives a resetJob (resetJobFields), so it is the honest signal here.
473
+ * Bias to needs_review: a false yellow costs a human glance, a false green
474
+ * costs a silently-unfixed bug — which is exactly what happened.
475
+ */
476
+ // abandoned_background_task shares no_verdict_sentinel's exact rescan path
477
+ // (same "sentinel === null && !commitEvidence" gate in runVerify, same
478
+ // committedDuringRun repo-wide-not-per-job attribution problem) — the PRD 983
479
+ // incident mechanism above applies identically, so it gets the same guard
480
+ // rather than a carve-out that would silently reopen the same false-heal hole.
481
+ const NO_ATTRIBUTABLE_COMMIT_VERDICTS = new Set(['no_verdict_sentinel', 'abandoned_background_task']);
482
+
483
+ function healRefusalReason(job, verdict, committedDuringRun) {
484
+ if (!job || !verdict) return null;
485
+ if (!COMPLETED_EQUIVALENT_VERDICTS.has(verdict.verdict)) return null;
486
+ if (!NO_ATTRIBUTABLE_COMMIT_VERDICTS.has(job.verifierVerdict)) return null;
487
+ // A commit this job actually recorded as its own is real evidence; the
488
+ // repo-wide window scan is not.
489
+ if (job.landedCommit) return null;
490
+ return `${job.verifierVerdict} with no job-attributable commit — refusing to heal`
491
+ + ` (committedInWindow=${committedDuringRun === true} is repo-wide, not proof this job delivered)`;
492
+ }
493
+
494
+ /**
495
+ * True when a `failed` job's failure is unverified-shaped — no result event
496
+ * was ever recorded for its run (classifyRunOutcome === 'no_result'), so no
497
+ * SCHEDULER_VERDICT sentinel could have been parsed either, OR it already
498
+ * carries a RESCANNABLE_VERDICTS verifierVerdict. A row that failed with a
499
+ * real result event (classifyRunOutcome === 'failed', i.e. a genuine red
500
+ * gate or a real non-zero-exit error) is excluded — that failure is
501
+ * evidence, not silence, and must never become a heal candidate (PRD 1102).
502
+ */
503
+ function isFailedUnverifiedShaped(job) {
504
+ if (!job || job.status !== 'failed') return false;
505
+ if (job.verifierVerdict && RESCANNABLE_VERDICTS.has(job.verifierVerdict)) return true;
506
+ const runId = job.runId || resolveRunId(job);
507
+ if (!runId) return false;
508
+ const logPath = path.join(schedulerPaths.runsDir(), runId, `${job.slug}.log`);
509
+ return classifyRunOutcome(logPath) === 'no_result';
510
+ }
511
+
512
+ function isRescanCandidate(job) {
513
+ if (!job) return false;
514
+ // Default-ELIGIBLE: every needs_review row is a heal candidate unless its
515
+ // verdict is in RESCAN_EXCLUDED_VERDICTS. No runId requirement here — a row
516
+ // without one still gets the evidence rung and the unresolvable annotation.
517
+ if (job.status === 'needs_review') return !RESCAN_EXCLUDED_VERDICTS.has(job.verifierVerdict);
518
+ if (!(job.runId || resolveRunId(job))) return false;
519
+ if (job.status === 'failed') return isFailedUnverifiedShaped(job);
520
+ return false;
521
+ }
522
+
523
+ /**
524
+ * Which rung a needs_review candidate gets (RESCANNABLE_VERDICTS as a hint):
525
+ * true = transcript re-verification (needs a run dir to read); false = the
526
+ * evidence-only rung. I/O only when a rescannable-verdict row lacks a runId.
527
+ */
528
+ function isTranscriptRescannable(job) {
529
+ return !!job && RESCANNABLE_VERDICTS.has(job.verifierVerdict) && !!(job.runId || resolveRunId(job));
530
+ }
531
+
532
+ /**
533
+ * Pure, no I/O: the bounded subset of evidence-only needs_review candidates
534
+ * reverifyNeedsReview scans this pass. Skips rows already carrying looksDone,
535
+ * rows scanned within EVIDENCE_SCAN_MIN_INTERVAL_MS (evidenceScannedAt), and —
536
+ * PRD 1136 — rows with a live auto-fix history unless they are an
537
+ * auto-resolve door (isEligibleForNeedsReviewAutoResolve, which is what
538
+ * consumes looksDone). Never-scanned rows go first, then least-recently
539
+ * scanned; capped at EVIDENCE_SCAN_MAX_PER_PASS. O(n log n) in needs_review rows.
540
+ * Per-pass cost ceiling: EVIDENCE_SCAN_MAX_PER_PASS computeLooksDone calls.
541
+ */
542
+ function selectEvidenceScanTargets(jobs, now = Date.now()) {
543
+ const due = [];
544
+ for (const j of jobs ?? []) {
545
+ if (j.status !== 'needs_review' || !isRescanCandidate(j)) continue;
546
+ if (isTranscriptRescannable(j)) continue;
547
+ if (j.looksDone) continue;
548
+ if (j.autoFixAttempted === true && !isEligibleForNeedsReviewAutoResolve(j, jobs)) continue;
549
+ const last = Date.parse(j.evidenceScannedAt ?? '');
550
+ if (!Number.isNaN(last) && now - last < EVIDENCE_SCAN_MIN_INTERVAL_MS) continue;
551
+ due.push({ j, last: Number.isNaN(last) ? 0 : last });
552
+ }
553
+ due.sort((a, b) => a.last - b.last);
554
+ return due.slice(0, EVIDENCE_SCAN_MAX_PER_PASS).map((d) => d.j);
555
+ }
556
+
557
+ /**
558
+ * Cheap-guard for the 10-minute periodic reverify tick. MUST be expressed in
559
+ * terms of isRescanCandidate — not a hand-written status test — because the
560
+ * two drifted once already: the guard only looked for a `needs_review` row
561
+ * while isRescanCandidate had grown a `failed`/isFailedUnverifiedShaped arm,
562
+ * so reverifyNeedsReview's failed → needs_review looksDone branch was
563
+ * unreachable from the periodic path on any queue holding zero needs_review
564
+ * rows. A `failed` row with no run log sat wedged for five days with the
565
+ * self-heal that exists to inspect it never firing (only an app restart, via
566
+ * the unguarded boot call, could reach it). Reported 2026-09-10 from
567
+ * social-signals-trader.
568
+ *
569
+ * Reopened through a different door 2026-09-12 (starry-night-ships
570
+ * 231-saturn-record-and-docs / 243-neptune-kurama-mode): the guard above only
571
+ * covered isRescanCandidate (re-verification), but reverifyNeedsReview's auto-
572
+ * fix loop ALSO lives behind this same guard, and a `needs_review` row whose
573
+ * mechanical recovery already ran and failed (mechanicalRecoveryAttempted:
574
+ * true, verdict still 'worktree_integration_failed', not itself a
575
+ * RESCANNABLE_VERDICTS member) is invisible to isRescanCandidate — so the
576
+ * next rung (a fix-plan investigation via selectAutoFixTargets) never got a
577
+ * chance to fire either. Widened to OR in every live target of the recovery
578
+ * ladder the periodic pass actually drives (selectMechanicalRecoveryTarget /
579
+ * selectResumeRecoveryTarget / selectAutoFixTargets) so the guard can never
580
+ * again be narrower than the work reverifyNeedsReview performs.
581
+ *
582
+ * Widened again (this PRD): a `needs_review` row parked directly by a GUARD
583
+ * verdict with no auto-fix history (isGuardParkedWithoutAutoFix) is not an
584
+ * isRescanCandidate either — RESCANNABLE_VERDICTS covers transcript-verifier
585
+ * verdicts, not commit-guard/shared-tree-guard verdicts — but
586
+ * reverifyNeedsReview's looksDone-annotation pass now runs for it too (see
587
+ * that function). Same rule as always: never let this guard be narrower than
588
+ * the work reverifyNeedsReview actually performs.
589
+ *
590
+ * Reopened a THIRD time 2026-09-18 (shared_tree_reverted parked 1229-fo-03
591
+ * falsely, 19 of 20 pending rows held): the fix was not another OR-clause but
592
+ * inverting the default — isRescanCandidate is now default-ELIGIBLE for every
593
+ * needs_review row (RESCAN_EXCLUDED_VERDICTS names the few exceptions), so a
594
+ * new park reason can never again be born unhealable. The OR-clauses below
595
+ * are now redundant for needs_review rows and kept only for their
596
+ * non-needs_review inputs.
597
+ *
598
+ * Cost: selectMechanicalRecoveryTarget/selectResumeRecoveryTarget and
599
+ * isGuardParkedWithoutAutoFix are pure (no I/O). selectAutoFixTargets is
600
+ * called with an injected fixSlugExists that always returns false — cheap
601
+ * and deliberately over-inclusive (a false positive here just means one
602
+ * extra periodic pass, never a missed one) so this guard never pays
603
+ * selectAutoFixTargets's production fs.existsSync scan per tick.
604
+ * resolveRunId's IO only fires for rows missing job.runId, same as
605
+ * isRescanCandidate already incurs above.
606
+ */
607
+ function shouldRunPeriodicReverify(jobs) {
608
+ if (!Array.isArray(jobs)) return false;
609
+ if (jobs.some((j) => isRescanCandidate(j) || isGuardParkedWithoutAutoFix(j) || isStaleSharedTreeRevertedPark(j))) return true;
610
+ if (jobs.some((j) => selectMechanicalRecoveryTarget(j) || selectResumeRecoveryTarget(j))) return true;
611
+ return selectAutoFixTargets(jobs, { fixSlugExists: () => false }).length > 0;
612
+ }
613
+
614
+ // Default 24h, overridable via SM_STUCK_FAILED_ESCALATE_HOURS — same
615
+ // env-override shape as QUARANTINE_ESCALATE_MS above.
616
+ const STUCK_FAILED_ESCALATE_MS = process.env.SM_STUCK_FAILED_ESCALATE_HOURS
617
+ ? Number(process.env.SM_STUCK_FAILED_ESCALATE_HOURS) * 60 * 60_000
618
+ : 24 * 60 * 60_000;
619
+
620
+ /**
621
+ * Kill-switch gate for the stuck-failed escalation below
622
+ * (SM_STUCK_FAILED_ESCALATE_DISABLE=1), mirroring the
623
+ * SM_REVERIFY_PERIODIC_DISABLE / SM_RCA_DISABLE convention. A tiny wrapper
624
+ * so the disable path is unit-testable without invoking the 10-minute
625
+ * setInterval body directly.
626
+ */
627
+ function stuckFailedEscalationDisabled() {
628
+ return process.env.SM_STUCK_FAILED_ESCALATE_DISABLE === '1';
629
+ }
630
+
631
+ // Bounded automatic failed -> pending recovery (PRD 1151): a failed row gets
632
+ // up to FAILED_AUTORESET_CAP auto-reset attempts, each gated on having sat
633
+ // `failed` for FAILED_AUTORESET_MS, before the stuck-failed escalation below
634
+ // is allowed to page a human. Same env-override shape as
635
+ // STUCK_FAILED_ESCALATE_MS/QUARANTINE_ESCALATE_MS above.
636
+ const FAILED_AUTORESET_CAP = 3;
637
+ const FAILED_AUTORESET_MS = process.env.SM_FAILED_AUTORESET_MINUTES
638
+ ? Number(process.env.SM_FAILED_AUTORESET_MINUTES) * 60_000
639
+ : 10 * 60_000;
640
+
641
+ /**
642
+ * Kill-switch gate for the failed-autoreset pass below
643
+ * (SM_FAILED_AUTORESET_DISABLE=1), same shape as stuckFailedEscalationDisabled
644
+ * above.
645
+ */
646
+ function failedAutoResetDisabled() {
647
+ return process.env.SM_FAILED_AUTORESET_DISABLE === '1';
648
+ }
649
+
650
+ /**
651
+ * selectFailedAutoResetTargets(jobs, now, thresholdMs) →
652
+ * [{ slug, cwd, ageMs, attempts }]
653
+ *
654
+ * Pure selector — no IO, no `require` inside the function. Selects `failed`
655
+ * rows whose newest statusHistory entry with `to === 'failed'` is older than
656
+ * `thresholdMs` and whose failedAutoResetAttempts counter hasn't yet spent
657
+ * FAILED_AUTORESET_CAP attempts. "Newest" (not first) matters because a row
658
+ * can have failed more than once across its lifetime (an earlier auto-reset
659
+ * attempt that itself failed again) — only the most recent failed-since
660
+ * timestamp should gate the next attempt.
661
+ *
662
+ * Excludes a row whose newest failed-entry came from spawnJob:fail-dirty —
663
+ * that source means a transient failure left genuinely uncommitted work in
664
+ * the job's worktree and the system already decided once, deliberately, not
665
+ * to auto-requeue it (see that call site's own comment: "could discard
666
+ * uncommitted work left by the failed run"). This bounded auto-reset is a
667
+ * different, slower mechanism and must not quietly override that decision
668
+ * 10 minutes later — a human should look at a dirty worktree before it gets
669
+ * re-driven.
670
+ */
671
+ function selectFailedAutoResetTargets(jobs, now, thresholdMs) {
672
+ const targets = [];
673
+ for (const j of jobs ?? []) {
674
+ if (j.status !== 'failed') continue;
675
+ const attempts = j.failedAutoResetAttempts ?? 0;
676
+ if (attempts >= FAILED_AUTORESET_CAP) continue;
677
+ const history = j.statusHistory || [];
678
+ let entry = null;
679
+ for (let i = history.length - 1; i >= 0; i--) {
680
+ if (history[i].to === 'failed') { entry = history[i]; break; }
681
+ }
682
+ if (!entry) continue;
683
+ if (entry.source === 'spawnJob:fail-dirty') continue;
684
+ const since = Date.parse(entry.at);
685
+ if (Number.isNaN(since)) continue;
686
+ const ageMs = now - since;
687
+ if (ageMs < thresholdMs) continue;
688
+ targets.push({ slug: j.slug, cwd: j.cwd ?? null, ageMs, attempts });
689
+ }
690
+ return targets;
691
+ }
692
+
693
+ /**
694
+ * findStuckFailedJobs(jobs, now, thresholdMs) → [{ slug, cwd, ageMs }]
695
+ *
696
+ * Pure predicate (isRescanCandidate's own resolveRunId/classifyRunOutcome log
697
+ * read is the only IO, gated per-job exactly like shouldRunPeriodicReverify
698
+ * above). `failed` used to be a fully terminal state for every automated
699
+ * recovery path — selectResumeRecoveryTarget/selectAutoFixTargets both
700
+ * require needs_review, reapDeadRunningJobs only ever writes running →
701
+ * failed, and reconcile-repair's to-pending is for structurally invalid rows.
702
+ * That is no longer true: selectFailedAutoResetTargets above now drives a
703
+ * bounded failed → pending auto-reset (LEGAL_TRANSITIONS already allowed the
704
+ * edge). This escalation now only fires once that auto-reset budget is
705
+ * genuinely spent (see the interval body's filter on failedAutoResetAttempts)
706
+ * — a rescan candidate (isRescanCandidate) that has sat failed longer than
707
+ * `thresholdMs` AND exhausted its auto-reset attempts can therefore go
708
+ * silently stuck forever — job 4056-outcome-stats sat `failed` for five days
709
+ * with no operator signal (reported 2026-09-10, social-signals-trader) even
710
+ * though the periodic reverify pass (once shouldRunPeriodicReverify's guard
711
+ * was fixed) WAS firing on it — reverifyNeedsReview's failed branch can
712
+ * annotate looksDone but can never resolve a failed row itself (see its own
713
+ * header). This is the visibility half that guard fix was missing: escalate
714
+ * once per exhausted row, never requeue from here.
715
+ *
716
+ * `stuckFailedNotified` gates this to exactly once per row — once the caller
717
+ * stamps it, this always excludes that row so a human is never re-paged on
718
+ * every 10-minute tick for the same stuck job. A row with no recoverable
719
+ * 'failed' timestamp is skipped rather than guessed at (mirrors
720
+ * findStaleQuarantinedJobs above).
721
+ */
722
+ function findStuckFailedJobs(jobs, now, thresholdMs) {
723
+ const stuck = [];
724
+ for (const j of jobs ?? []) {
725
+ if (j.status !== 'failed') continue;
726
+ if (j.stuckFailedNotified === true) continue;
727
+ if (!isRescanCandidate(j)) continue;
728
+ // Newest (not first) to === 'failed' entry — same rationale as
729
+ // selectFailedAutoResetTargets above: a row can have failed more than
730
+ // once across its lifetime (an earlier auto-reset attempt that itself
731
+ // failed again), and only the CURRENT failure episode's age should gate
732
+ // escalation. Using the first/oldest entry would report a stale age
733
+ // (and become instantly escalation-eligible) for a row that failed
734
+ // months ago, recovered, and has only just failed again.
735
+ const history = j.statusHistory || [];
736
+ let entry = null;
737
+ for (let i = history.length - 1; i >= 0; i--) {
738
+ if (history[i].to === 'failed') { entry = history[i]; break; }
739
+ }
740
+ if (!entry) continue;
741
+ const since = Date.parse(entry.at);
742
+ if (Number.isNaN(since)) continue;
743
+ const ageMs = now - since;
744
+ if (ageMs >= thresholdMs) stuck.push({ slug: j.slug, cwd: j.cwd ?? null, ageMs });
745
+ }
746
+ return stuck;
747
+ }
748
+
749
+ // Bounded automatic terminal decision for an EXHAUSTED needs_review row
750
+ // (isExhaustedAutoFix === true — auto-fix attempted, no plan produced,
751
+ // retries spent): up to NEEDS_REVIEW_RESOLVE_CAP requeue attempts (a
752
+ // needs_review -> pending -> ... -> needs_review round trip counts as one
753
+ // spent attempt), each gated on having sat exhausted-needs_review for
754
+ // NEEDS_REVIEW_RESOLVE_MS, before the row is auto-skipped so a `dependsOn`
755
+ // chain behind it always drains without an operator. Same env-override
756
+ // shape as FAILED_AUTORESET_MS above.
757
+ const NEEDS_REVIEW_RESOLVE_CAP = 2;
758
+ const NEEDS_REVIEW_RESOLVE_MS = process.env.SM_NEEDS_REVIEW_RESOLVE_MINUTES
759
+ ? Number(process.env.SM_NEEDS_REVIEW_RESOLVE_MINUTES) * 60_000
760
+ : 30 * 60_000;
761
+
762
+ /**
763
+ * Kill-switch gate for the needs_review auto-resolve pass below
764
+ * (SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE=1), same shape as
765
+ * failedAutoResetDisabled/stuckFailedEscalationDisabled above.
766
+ */
767
+ function needsReviewAutoResolveDisabled() {
768
+ return process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE === '1';
769
+ }
770
+
771
+ /**
772
+ * selectExhaustedNeedsReviewTargets(jobs, now, thresholdMs) →
773
+ * [{ slug, cwd, ageMs, attempts }]
774
+ *
775
+ * Pure selector — no IO. Selects `needs_review` rows eligible for the
776
+ * bounded auto-resolve ladder (isEligibleForNeedsReviewAutoResolve — auto-fix
777
+ * genuinely spent, parked by a GUARD verdict that never entered auto-fix at
778
+ * all, or a stranded auto-fix park with no outcome ever recorded), whose
779
+ * newest statusHistory entry with `to === 'needs_review'` is older than
780
+ * `thresholdMs`, and whose exhaustedResolveAttempts counter has not yet
781
+ * spent its cap.
782
+ *
783
+ * The inclusion bound is inclusive of the cap itself (`<= CAP`, not `<
784
+ * CAP`): NEEDS_REVIEW_RESOLVE_CAP counts REQUEUE attempts already spent, and
785
+ * the pass that observes attempts === CAP is exactly the one that must fire
786
+ * the terminal skip (see the interval body's branch below) — excluding that
787
+ * row here would mean the cap-exhausted row is never selected again and the
788
+ * dependsOn chain behind it never drains, defeating this PRD's own purpose.
789
+ */
790
+ function selectExhaustedNeedsReviewTargets(jobs, now, thresholdMs) {
791
+ const targets = [];
792
+ for (const j of jobs ?? []) {
793
+ if (j.status !== 'needs_review') continue;
794
+ if (!isEligibleForNeedsReviewAutoResolve(j, jobs)) continue;
795
+ if ((j.exhaustedResolveAttempts ?? 0) > NEEDS_REVIEW_RESOLVE_CAP) continue;
796
+ const history = j.statusHistory || [];
797
+ let entry = null;
798
+ for (let i = history.length - 1; i >= 0; i--) {
799
+ if (history[i].to === 'needs_review') { entry = history[i]; break; }
800
+ }
801
+ if (!entry) continue;
802
+ const since = Date.parse(entry.at);
803
+ if (Number.isNaN(since)) continue;
804
+ const ageMs = now - since;
805
+ if (ageMs < thresholdMs) continue;
806
+ targets.push({ slug: j.slug, cwd: j.cwd ?? null, ageMs, attempts: j.exhaustedResolveAttempts ?? 0 });
807
+ }
808
+ return targets;
809
+ }
810
+
811
+ /**
812
+ * Pure helper — no I/O by default (resolveJobRunId is injectable; production
813
+ * caller passes resolveRunId, which does touch disk). Returns the subset of
814
+ * jobs eligible for automatic fix-plan authoring after a reverify pass
815
+ * leaves them still in needs_review.
816
+ *
817
+ * Exclusion rules (all must pass):
818
+ * - status === 'needs_review'
819
+ * - a runId, own or backfilled via resolveJobRunId (need a run log to investigate)
820
+ * - not itself a fix-plan slug (avoids infinite recursion)
821
+ * - autoFixAttempted cap: a job with no prior attempt is eligible; a job
822
+ * whose prior attempt outcome was 'no-plan', 'error', or UNSTAMPED
823
+ * (investigation exited without recording any outcome — unknown is not
824
+ * "succeeded") gets ONE bounded retry (autoFixRetries < 1); a job whose
825
+ * outcome was 'plan' (a fix plan was produced) is never retried here —
826
+ * it either lands as a queue row on its own or gets flagged
827
+ * autofix_plan_unqueued by reverifyNeedsReview's annotation pass; an
828
+ * exhausted retry is excluded
829
+ * - no fix sibling on disk (fixSlugExists) or already in the queue
830
+ */
831
+
832
+ function selectAutoFixTargets(jobs, { fixSlugExists, resolveJobRunId = resolveRunId }) {
833
+ const slugsInQueue = new Set(jobs.map((j) => j.slug));
834
+ return jobs.filter((job) => {
835
+ if (job.status !== 'needs_review') return false;
836
+ // A job parked here because it was blocked by a sibling's foreign WIP
837
+ // three times in a row (never its own regression) has nothing for a
838
+ // fix-plan investigation to diagnose — there is no code defect to
839
+ // author a PRD against, only another job's still-uncommitted tree.
840
+ if (job.blockedByForeignWip === true) return false;
841
+ // A budget-killed job parks for a human/ladder decision, never an
842
+ // auto-fix investigation or auto-retry — the run didn't fail, it simply
843
+ // overran its own estimate; there's no code defect to diagnose (Out of
844
+ // scope: "retrying or auto-resuming a budget-killed job" for this PRD).
845
+ if (job.verifierVerdict === 'budget_exceeded') return false;
846
+ // A stale re-run whose work already shipped (rcaReport's 'already-shipped'
847
+ // class) must never buy a fix-plan PRD — there is nothing to fix, and the
848
+ // correct recovery (archiving the PRD) is a human/reconcile action, not
849
+ // an investigation.
850
+ if (job.rcaRecoveryAction === 'archive') return false;
851
+ // Resume-first recovery (PRD 1111): a job still eligible for its one
852
+ // bounded `--resume` attempt must never also become a fix-plan target
853
+ // in the same pass — see spawnInvestigation's own identical guard.
854
+ if (selectResumeRecoveryTarget(job)) return false;
855
+ // Mechanical recovery (PRD 1130): a job eligible for a pure-git retry
856
+ // must never also become a fix-plan target — it needs no plan and no
857
+ // model. Defensive: today's single mechanically-resolvable verdict
858
+ // (worktree_integration_failed) is already excluded below via the depth
859
+ // cap, but this must hold even if that stops being true.
860
+ if (selectMechanicalRecoveryTarget(job)) return false;
861
+ const runId = job.runId || resolveJobRunId(job);
862
+ if (!runId) return false;
863
+ if (isFixPlanBeyondDepthCap(job.slug, job.investigationDepth, job.isFixPlan)) return false;
864
+ // A dead fix-plan child (PRD 1129) earns its parent exactly one further
865
+ // attempt, bypassing the normal 'plan' exclusion and the fix-slug/queue
866
+ // membership checks below — those checks exist to stop a FRESH
867
+ // investigation from clobbering a live sibling, but here the sibling is
868
+ // dead and reusing its slug is the whole point of the reopen.
869
+ const dead = isFixPlanDead(job, jobs);
870
+ if (job.autoFixAttempted) {
871
+ const retryEligible = dead
872
+ || job.autoFixOutcome === 'no-plan'
873
+ || job.autoFixOutcome === 'error'
874
+ || job.autoFixOutcome == null;
875
+ if (!retryEligible) return false;
876
+ if (!dead && (job.autoFixRetries ?? 0) >= 1) return false;
877
+ }
878
+ if (dead) return true;
879
+ const fixSlug = fixSlugFor(job);
880
+ if (fixSlugExists(fixSlug)) return false;
881
+ if (slugsInQueue.has(fixSlug)) return false;
882
+ return true;
883
+ });
884
+ }
885
+
886
+ /**
887
+ * Pure helper — no I/O by default (fixSlugExists is injectable). Answers
888
+ * "does this single needs_review job qualify for an auto-fix investigation
889
+ * right now?" by delegating to selectAutoFixTargets's eligibility rules
890
+ * (same-1-attempt cap, depth cap, no fix sibling) against the full job list,
891
+ * then checking whether this job is among the returned targets. Shared by
892
+ * spawnJob's immediate same-tick path and (indirectly, via
893
+ * selectAutoFixTargets itself) reverifyNeedsReview's periodic path, so both
894
+ * use one eligibility definition rather than two divergent copies.
895
+ */
896
+ function isEligibleForImmediateAutoFix(job, allJobs, fixSlugExists) {
897
+ const targets = selectAutoFixTargets(allJobs, {
898
+ fixSlugExists,
899
+ resolveJobRunId: () => job.runId,
900
+ });
901
+ return targets.some((t) => t.slug === job.slug);
902
+ }
903
+
904
+ /**
905
+ * Pure: picks the one needs_review row reverifyNeedsReview's background
906
+ * shadow gate should run this pass, or null when none qualifies. No I/O:
907
+ * `headByCwd` is a Map of cwd -> HEAD sha (or null), pre-fetched by the
908
+ * caller with `gitHead` (which never rejects, so building it needs no
909
+ * try/catch — a cwd whose read failed just maps to null here).
910
+ *
911
+ * A row is never picked when its cwd's HEAD is unknown (null) — there is
912
+ * nothing to compare a recorded gateShadow against.
913
+ *
914
+ * Tier 1, skipped when `authorityDisabled`: a stale, gate-authority-eligible
915
+ * row — verdict in GATE_AUTHORITY_VERDICTS, a non-empty landedCommit, and
916
+ * stale (no gateShadow, or one at a different HEAD, or one not marked
917
+ * `definitive`). Ordered so a row that never got a shadow run goes first,
918
+ * then the oldest `gateShadow.ranAt` (an unparseable or missing `ranAt`
919
+ * sorts as oldest), then queue order. Rule first, why second: the old pick
920
+ * took the first stale row in queue order with no rotation, so in a busy
921
+ * plan one row whose gate stays red forever could crowd out every other
922
+ * row's turn (review finding #8).
923
+ *
924
+ * Tier 2: the first needs_review row with no gateShadow at all, in queue
925
+ * order — today's plain observe-only rule, unchanged.
926
+ *
927
+ * Returns the first tier-1 row, else the tier-2 row, else null.
928
+ */
929
+ function selectGateShadowTarget(jobs, headByCwd, { authorityDisabled = false } = {}) {
930
+ const eligible = jobs.filter((j) => {
931
+ if (j.status !== 'needs_review') return false;
932
+ const head = headByCwd.get(j.cwd);
933
+ return typeof head === 'string' && head.length > 0;
934
+ });
935
+
936
+ const isStale = (j) => {
937
+ const head = headByCwd.get(j.cwd);
938
+ return !j.gateShadow || j.gateShadow.head !== head || j.gateShadow.definitive !== true;
939
+ };
940
+
941
+ if (!authorityDisabled) {
942
+ const ranAtMs = (j) => {
943
+ const t = j.gateShadow ? Date.parse(j.gateShadow.ranAt) : NaN;
944
+ return Number.isFinite(t) ? t : -Infinity; // never-run or unparseable sorts oldest
945
+ };
946
+ const tier1 = eligible.filter((j) => isStale(j)
947
+ && j.verifierVerdict
948
+ && GATE_AUTHORITY_VERDICTS.includes(j.verifierVerdict)
949
+ && typeof j.landedCommit === 'string'
950
+ && j.landedCommit.length > 0);
951
+ if (tier1.length) {
952
+ const ranked = tier1
953
+ .map((j, i) => ({ j, i, neverRun: !j.gateShadow, ranAt: ranAtMs(j) }))
954
+ .sort((a, b) => {
955
+ if (a.neverRun !== b.neverRun) return a.neverRun ? -1 : 1;
956
+ if (a.ranAt !== b.ranAt) return a.ranAt - b.ranAt;
957
+ return a.i - b.i; // tie: queue order
958
+ });
959
+ return ranked[0].j;
960
+ }
961
+ }
962
+
963
+ return eligible.find((j) => !j.gateShadow) ?? null;
964
+ }
965
+
966
+ module.exports = {
967
+ selectResumeRecoveryTarget,
968
+ MECHANICALLY_RESOLVABLE_VERDICTS,
969
+ isMechanicalRecoveryFutile,
970
+ selectMechanicalRecoveryTarget,
971
+ isStaleSharedTreeRevertedPark,
972
+ fixSlugFor,
973
+ RESCANNABLE_VERDICTS,
974
+ RESCAN_EXCLUDED_VERDICTS,
975
+ REVERIFY_INTERVAL_MS,
976
+ EVIDENCE_SCAN_MAX_PER_PASS,
977
+ EVIDENCE_SCAN_MIN_INTERVAL_MS,
978
+ MAX_INVESTIGATION_DEPTH,
979
+ isFixPlanBeyondDepthCap,
980
+ resolveRunId,
981
+ isUnresolvableNeedsReview,
982
+ isExhaustedAutoFix,
983
+ GUARD_VERDICT_EVIDENCE_ELIGIBLE,
984
+ isGuardParkedWithoutAutoFix,
985
+ isStrandedAutoFixPark,
986
+ isEligibleForNeedsReviewAutoResolve,
987
+ isPlanUnqueued,
988
+ DEAD_FIX_CHILD_STATUSES,
989
+ isFixPlanDead,
990
+ NO_ATTRIBUTABLE_COMMIT_VERDICTS,
991
+ healRefusalReason,
992
+ isFailedUnverifiedShaped,
993
+ isRescanCandidate,
994
+ isTranscriptRescannable,
995
+ selectEvidenceScanTargets,
996
+ shouldRunPeriodicReverify,
997
+ STUCK_FAILED_ESCALATE_MS,
998
+ stuckFailedEscalationDisabled,
999
+ FAILED_AUTORESET_CAP,
1000
+ FAILED_AUTORESET_MS,
1001
+ failedAutoResetDisabled,
1002
+ selectFailedAutoResetTargets,
1003
+ findStuckFailedJobs,
1004
+ NEEDS_REVIEW_RESOLVE_CAP,
1005
+ NEEDS_REVIEW_RESOLVE_MS,
1006
+ needsReviewAutoResolveDisabled,
1007
+ selectExhaustedNeedsReviewTargets,
1008
+ selectAutoFixTargets,
1009
+ isEligibleForImmediateAutoFix,
1010
+ selectGateShadowTarget,
1011
+ };