specrails-desktop 2.55.0 → 2.56.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 (368) hide show
  1. package/cli/dist/args.js +80 -0
  2. package/cli/dist/desktop-client.js +125 -0
  3. package/cli/dist/desktop-control.js +339 -0
  4. package/cli/dist/format.js +45 -0
  5. package/cli/dist/help.js +68 -0
  6. package/cli/dist/output.js +47 -0
  7. package/cli/dist/run-command.js +340 -0
  8. package/cli/dist/specrails-desktop.js +41 -1107
  9. package/cli/dist/status.js +111 -0
  10. package/client/dist/assets/{ActivityFeedPage-ILXMZXcb.js → ActivityFeedPage-DdMNmVBk.js} +1 -1
  11. package/client/dist/assets/{AgentBrowserCapture-UcsupCy7.js → AgentBrowserCapture-CeerUf--.js} +1 -1
  12. package/client/dist/assets/{AgentModeAnalyticsPane-CQuxYh8B.js → AgentModeAnalyticsPane-BjGb-8rh.js} +2 -2
  13. package/client/dist/assets/{AgentModeCodePane-zPWbW7ei.js → AgentModeCodePane-8XRkQEo_.js} +2 -2
  14. package/client/dist/assets/{AgentModeJobsPane-CInByIdl.js → AgentModeJobsPane-BN3dgJOA.js} +1 -1
  15. package/client/dist/assets/{AgentsPage-BWvgxaBv.js → AgentsPage-DfHWgP_x.js} +1 -1
  16. package/client/dist/assets/{AnalyticsPage-DP3jakv-.js → AnalyticsPage-CyfhYiBn.js} +1 -1
  17. package/client/dist/assets/{CodePage-BKXiwE5a.js → CodePage-C2Nf-Q5W.js} +1 -1
  18. package/client/dist/assets/{DesktopAnalyticsPage-DryJOaMz.js → DesktopAnalyticsPage-tc0p4jJW.js} +1 -1
  19. package/client/dist/assets/{DocsDialog-BsCG9ksG.js → DocsDialog-Br3bczCp.js} +1 -1
  20. package/client/dist/assets/{DocsPage-Cc-GeKYl.js → DocsPage-BcDr2cBm.js} +1 -1
  21. package/client/dist/assets/{ExportDropdown-ClahJdDn.js → ExportDropdown-BVGKEgkd.js} +1 -1
  22. package/client/dist/assets/{InteractiveJobComposer-DfLF6N0f.js → InteractiveJobComposer-DFPpdg2D.js} +1 -1
  23. package/client/dist/assets/{JobDetailModal-DHeur9YK.js → JobDetailModal-BdOv-l-M.js} +1 -1
  24. package/client/dist/assets/{JobDetailPage-CraDKzeG.js → JobDetailPage-vBbNeRuz.js} +1 -1
  25. package/client/dist/assets/{JobsPage-CcbuSwj7.js → JobsPage-CJCtPhIz.js} +1 -1
  26. package/client/dist/assets/{LoopBuilderPage-BKtNp494.js → LoopBuilderPage-BemvUOxy.js} +1 -1
  27. package/client/dist/assets/{LoopPreviewModal-DbZd9isy.js → LoopPreviewModal-C6hNAeYN.js} +1 -1
  28. package/client/dist/assets/{LoopsPage-DEg6sIkG.js → LoopsPage-BZr0quvh.js} +1 -1
  29. package/client/dist/assets/{MinimizedChatsContext-BGtEjWsc.js → MinimizedChatsContext-D3QzgV0B.js} +1 -1
  30. package/client/dist/assets/{PluginsPage-BVqSsPzL.js → PluginsPage-DOpTV75p.js} +1 -1
  31. package/client/dist/assets/{ProjectSettingsDialog-CAjLgJAZ.js → ProjectSettingsDialog-Dkwrn0z1.js} +1 -1
  32. package/client/dist/assets/{RepositoryDeliveries-DrcrxLe0.js → RepositoryDeliveries-Du2UCf1a.js} +1 -1
  33. package/client/dist/assets/{RepositoryScopeSelector-CAGY3xW6.js → RepositoryScopeSelector-hPGZNRBm.js} +1 -1
  34. package/client/dist/assets/ReviewPacketPage-DcOq-UTA.js +4 -0
  35. package/client/dist/assets/{TemplatePreviewModal-Bqs-9fhn.js → TemplatePreviewModal-Cejcquah.js} +1 -1
  36. package/client/dist/assets/{TicketDetailModalContext-C0jQ5yOk.js → TicketDetailModalContext-DAK-NoK6.js} +1 -1
  37. package/client/dist/assets/{Trans-DtWlCJN9.js → Trans-DEbgkuAY.js} +1 -1
  38. package/client/dist/assets/{dashboard-CTfEtkqX.js → dashboard-B9uWNGO2.js} +1 -1
  39. package/client/dist/assets/{dashboard-DMklepmz.js → dashboard-BIwmO9wc.js} +1 -1
  40. package/client/dist/assets/{dashboard-DBAP2_SQ.js → dashboard-BV4vOZkR.js} +1 -1
  41. package/client/dist/assets/{dashboard-D40HP2ks.js → dashboard-Bf6MPVsM.js} +1 -1
  42. package/client/dist/assets/{dashboard-Csf1yGSN.js → dashboard-C8klWVvZ.js} +1 -1
  43. package/client/dist/assets/{dashboard-C29Qwa-L.js → dashboard-D1ORhPF0.js} +1 -1
  44. package/client/dist/assets/{dashboard-Cr4D8oMn.js → dashboard-DSubvs9x.js} +1 -1
  45. package/client/dist/assets/{dashboard-CSJzf5OT.js → dashboard-px_vlRx_.js} +1 -1
  46. package/client/dist/assets/{formatDistanceToNow-DYmvOA3R.js → formatDistanceToNow-Cc2u5FwQ.js} +1 -1
  47. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BAhW5E6V.js → getTimezoneOffsetInMilliseconds-BHm2U-w1.js} +1 -1
  48. package/client/dist/assets/index-C51x61Bz.css +2 -0
  49. package/client/dist/assets/{index-BfAZg5DG.js → index-Cxb288Rj.js} +29 -29
  50. package/client/dist/assets/{jira-api-B27Za5FP.js → jira-api-DrGxiD9p.js} +1 -1
  51. package/client/dist/assets/packet-8nn9RsU7.js +1 -0
  52. package/client/dist/assets/packet-B0H15elv.js +1 -0
  53. package/client/dist/assets/packet-BQvBgrwh.js +1 -0
  54. package/client/dist/assets/packet-BR7mO0-a.js +1 -0
  55. package/client/dist/assets/packet-BgcQ6q_K.js +1 -0
  56. package/client/dist/assets/packet-D2s92Jnn.js +1 -0
  57. package/client/dist/assets/packet-YbtcD1HS.js +1 -0
  58. package/client/dist/assets/packet-c0Qj_9eX.js +1 -0
  59. package/client/dist/assets/{project-repositories-DixIZHTA.js → project-repositories-9Wzs0coQ.js} +1 -1
  60. package/client/dist/assets/{spending-BGmZBX5B.js → spending-T5GM7wpn.js} +1 -1
  61. package/client/dist/assets/{useDesktop-CPB4cnwI.js → useDesktop-QogiN0zR.js} +1 -1
  62. package/client/dist/assets/{useRuntimeRuns-DEQ9HdAM.js → useRuntimeRuns-Dqmz9fS9.js} +1 -1
  63. package/client/dist/assets/{useSharedWebSocket-DcPRKR8R.js → useSharedWebSocket-Dz-HI6De.js} +2 -2
  64. package/client/dist/index.html +17 -17
  65. package/docs/codex.md +5 -5
  66. package/docs/customizing.md +1 -1
  67. package/docs/gemini-cli-provider-study.md +14 -14
  68. package/docs/gemini-core-support-evaluation.md +1 -1
  69. package/docs/gemini.md +5 -5
  70. package/docs/internals/README.md +6 -2
  71. package/docs/internals/adding-a-provider.md +22 -15
  72. package/docs/internals/agent-runtime-framework-evaluation.md +8 -8
  73. package/docs/internals/api-reference.md +2 -2
  74. package/docs/internals/architecture.md +9 -9
  75. package/docs/internals/browser-capture-performance.md +3 -3
  76. package/docs/internals/code-explorer-story.md +2 -2
  77. package/docs/internals/companion-rails-as-loops-contract.md +2 -2
  78. package/docs/internals/core-runtime-updates.md +5 -0
  79. package/docs/internals/embedded-browser-native-webview-evaluation.md +5 -5
  80. package/docs/internals/gemini-mcp-registration.md +2 -2
  81. package/docs/internals/global-artifacts-alignment-contract.md +1 -1
  82. package/docs/internals/global-artifacts-relocation-evaluation.md +1 -1
  83. package/docs/internals/interactive-jobs.md +8 -8
  84. package/docs/internals/legacy-implementation-notes.md +722 -0
  85. package/docs/internals/loop-step-log-explorer.md +8 -8
  86. package/docs/internals/mission-rail-cards.md +14 -14
  87. package/docs/internals/modular-architecture.md +107 -0
  88. package/docs/internals/profiles.md +1 -1
  89. package/docs/internals/programmatic-agent-runtime.md +66 -0
  90. package/docs/internals/project-builder.md +18 -18
  91. package/docs/internals/review-packet.md +8 -8
  92. package/docs/internals/safe-pr-review-flow.md +11 -11
  93. package/docs/internals/source-architecture.md +131 -0
  94. package/docs/internals/source-map.md +1356 -0
  95. package/docs/internals/spec-addenda.md +65 -9
  96. package/docs/internals/switch-to-agent-mode-design.md +11 -11
  97. package/docs/tracking-cost.md +1 -1
  98. package/package.json +5 -2
  99. package/server/dist/agent-mcp-config.js +1 -1
  100. package/server/dist/attachment-manager.js +1 -1
  101. package/server/dist/build-dirs.js +2 -2
  102. package/server/dist/config.js +0 -3
  103. package/server/dist/core-completion.js +1 -1
  104. package/server/dist/core-execution.js +1 -1
  105. package/server/dist/db/activity.js +39 -0
  106. package/server/dist/db/connection.js +41 -0
  107. package/server/dist/db/conversations.js +48 -0
  108. package/server/dist/db/jobs.js +251 -0
  109. package/server/dist/db/migrations.js +1521 -0
  110. package/server/dist/db/proposals.js +50 -0
  111. package/server/dist/db/settings.js +15 -0
  112. package/server/dist/db/stats.js +61 -0
  113. package/server/dist/db/telemetry.js +56 -0
  114. package/server/dist/db/templates.js +37 -0
  115. package/server/dist/db/types.js +2 -0
  116. package/server/dist/db.js +66 -2224
  117. package/server/dist/desktop-db.js +3 -3
  118. package/server/dist/desktop-router.js +8 -8
  119. package/server/dist/git-diagnostics.js +1 -1
  120. package/server/dist/index.js +11 -11
  121. package/server/dist/integration-branch.js +3 -14
  122. package/server/dist/jira/jira-issue-fields.js +0 -1
  123. package/server/dist/jira/jira-materializer.js +1 -1
  124. package/server/dist/jira/jira-sync-manager.js +1 -1
  125. package/server/dist/mcp/guide.js +16 -2
  126. package/server/dist/mcp/mcp-admin-router.js +1 -1
  127. package/server/dist/mcp/mcp-server.js +1 -1
  128. package/server/dist/mcp/tools/catalog.js +2 -0
  129. package/server/dist/mcp/tools/jobs.js +23 -5
  130. package/server/dist/mcp/tools/rails.js +2 -2
  131. package/server/dist/mcp/tools/recovery.js +35 -0
  132. package/server/dist/mcp/tools/specs.js +2 -2
  133. package/server/dist/mcp/tools/support.js +4 -1
  134. package/server/dist/mcp/tools/types.js +3 -3
  135. package/server/dist/mobile/mobile-gateway.js +1 -1
  136. package/server/dist/mobile/mobile-missions.js +1 -1
  137. package/server/dist/{desktop-analytics.js → modules/accounting/runtime/desktop-analytics.js} +2 -2
  138. package/server/dist/{pricing.js → modules/accounting/runtime/pricing.js} +10 -3
  139. package/server/dist/{result-event.js → modules/accounting/runtime/result-event.js} +3 -3
  140. package/server/dist/{spending.js → modules/accounting/runtime/spending.js} +0 -3
  141. package/server/dist/{telemetry-compactor.js → modules/accounting/runtime/telemetry-compactor.js} +1 -1
  142. package/server/dist/{telemetry-receiver.js → modules/accounting/runtime/telemetry-receiver.js} +2 -2
  143. package/server/dist/{agent-runtime-bridge.js → modules/agent-runtime/runtime/agent-runtime-bridge.js} +2 -2
  144. package/server/dist/{agent-runtime-controls-router.js → modules/agent-runtime/runtime/agent-runtime-controls-router.js} +16 -0
  145. package/server/dist/{agent-runtime-controls.js → modules/agent-runtime/runtime/agent-runtime-controls.js} +100 -8
  146. package/server/dist/{agent-runtime-effective-config.js → modules/agent-runtime/runtime/agent-runtime-effective-config.js} +1 -1
  147. package/server/dist/{agent-runtime-loader.js → modules/agent-runtime/runtime/agent-runtime-loader.js} +3 -3
  148. package/server/dist/{agent-runtime-paths.js → modules/agent-runtime/runtime/agent-runtime-paths.js} +1 -1
  149. package/server/dist/modules/agent-runtime/runtime/agent-runtime-recovery.js +53 -0
  150. package/server/dist/{agent-runtime-settings-router.js → modules/agent-runtime/runtime/agent-runtime-settings-router.js} +2 -2
  151. package/server/dist/{agent-runtime-settings.js → modules/agent-runtime/runtime/agent-runtime-settings.js} +3 -3
  152. package/server/dist/{agent-runtime-settlement.js → modules/agent-runtime/runtime/agent-runtime-settlement.js} +6 -6
  153. package/server/dist/{agent-defaults.js → modules/agents/runtime/agent-defaults.js} +3 -3
  154. package/server/dist/{agent-generator.js → modules/agents/runtime/agent-generator.js} +7 -7
  155. package/server/dist/{agent-refine-manager.js → modules/agents/runtime/agent-refine-manager.js} +5 -5
  156. package/server/dist/{agent-store.js → modules/agents/runtime/agent-store.js} +1 -1
  157. package/server/dist/{profile-manager.js → modules/agents/runtime/profile-manager.js} +2 -2
  158. package/server/dist/{profiles-router.js → modules/agents/runtime/profiles-router.js} +5 -5
  159. package/server/dist/{browser-capture-manager.js → modules/browser/runtime/browser-capture-manager.js} +2 -4
  160. package/server/dist/{browser-playwright.js → modules/browser/runtime/browser-playwright.js} +1 -1
  161. package/server/dist/{blueprint-chat-manager.js → modules/builder/runtime/blueprint-chat-manager.js} +7 -7
  162. package/server/dist/{blueprint-commit.js → modules/builder/runtime/blueprint-commit.js} +8 -8
  163. package/server/dist/{blueprint-draft-parser.js → modules/builder/runtime/blueprint-draft-parser.js} +1 -1
  164. package/server/dist/{blueprint-generation.js → modules/builder/runtime/blueprint-generation.js} +1 -1
  165. package/server/dist/{blueprint-operator-prompt.js → modules/builder/runtime/blueprint-operator-prompt.js} +1 -1
  166. package/server/dist/{blueprint-router.js → modules/builder/runtime/blueprint-router.js} +3 -3
  167. package/server/dist/{blueprint-spec-quality.js → modules/builder/runtime/blueprint-spec-quality.js} +1 -1
  168. package/server/dist/{milestone-chain.js → modules/builder/runtime/milestone-chain.js} +1 -1
  169. package/server/dist/{milestone-progress.js → modules/builder/runtime/milestone-progress.js} +6 -6
  170. package/server/dist/{code-activity.js → modules/code/runtime/code-activity.js} +1 -1
  171. package/server/dist/{code-explorer-router.js → modules/code/runtime/code-explorer-router.js} +5 -5
  172. package/server/dist/{file-provenance.js → modules/code/runtime/file-provenance.js} +1 -1
  173. package/server/dist/{file-story-manager.js → modules/code/runtime/file-story-manager.js} +2 -2
  174. package/server/dist/{file-story.js → modules/code/runtime/file-story.js} +2 -2
  175. package/server/dist/{file-summary-generator.js → modules/code/runtime/file-summary-generator.js} +3 -3
  176. package/server/dist/{file-summary-manager.js → modules/code/runtime/file-summary-manager.js} +3 -3
  177. package/server/dist/{project-code-discovery.js → modules/code/runtime/project-code-discovery.js} +2 -2
  178. package/server/dist/modules/conversations/domain/draft-stream.js +91 -0
  179. package/server/dist/modules/conversations/domain/recovery-context.js +101 -0
  180. package/server/dist/modules/conversations/index.js +10 -0
  181. package/server/dist/{chat-manager.js → modules/conversations/runtime/chat-manager.js} +32 -216
  182. package/server/dist/{context-budget.js → modules/conversations/runtime/context-budget.js} +1 -1
  183. package/server/dist/{explore-cwd-manager.js → modules/conversations/runtime/explore-cwd-manager.js} +1 -1
  184. package/server/dist/{explore-smash.js → modules/conversations/runtime/explore-smash.js} +1 -1
  185. package/server/dist/{explore-stdin-session.js → modules/conversations/runtime/explore-stdin-session.js} +1 -1
  186. package/server/dist/modules/delivery/adapters/decisions/chains.js +138 -0
  187. package/server/dist/modules/delivery/adapters/decisions/contracts.js +2 -0
  188. package/server/dist/modules/delivery/adapters/decisions/create.js +234 -0
  189. package/server/dist/modules/delivery/adapters/decisions/discard.js +251 -0
  190. package/server/dist/modules/delivery/adapters/decisions/dispatch.js +107 -0
  191. package/server/dist/modules/delivery/adapters/decisions/evidence.js +205 -0
  192. package/server/dist/modules/delivery/adapters/decisions/merge-local.js +338 -0
  193. package/server/dist/modules/delivery/adapters/decisions/metadata.js +109 -0
  194. package/server/dist/modules/delivery/adapters/decisions/observations.js +260 -0
  195. package/server/dist/modules/delivery/adapters/decisions/ownership.js +83 -0
  196. package/server/dist/modules/delivery/adapters/decisions/publish.js +242 -0
  197. package/server/dist/modules/delivery/adapters/decisions/recovery.js +425 -0
  198. package/server/dist/modules/delivery/adapters/decisions/retry.js +112 -0
  199. package/server/dist/modules/delivery/adapters/decisions/transitions.js +94 -0
  200. package/server/dist/modules/delivery/domain/decision-policy.js +50 -0
  201. package/server/dist/modules/delivery/domain/state.js +2 -0
  202. package/server/dist/modules/delivery/index.js +7 -0
  203. package/server/dist/{active-pr-continuation.js → modules/delivery/runtime/active-pr-continuation.js} +2 -2
  204. package/server/dist/{delivery-evidence.js → modules/delivery/runtime/delivery-evidence.js} +3 -3
  205. package/server/dist/{multi-repo-bases.js → modules/delivery/runtime/multi-repo-bases.js} +1 -1
  206. package/server/dist/{multi-repo-checkout.js → modules/delivery/runtime/multi-repo-checkout.js} +4 -4
  207. package/server/dist/{multi-repo-delivery.js → modules/delivery/runtime/multi-repo-delivery.js} +4 -4
  208. package/server/dist/{multi-repo-execution.js → modules/delivery/runtime/multi-repo-execution.js} +8 -8
  209. package/server/dist/{pr-body.js → modules/delivery/runtime/pr-body.js} +1 -1
  210. package/server/dist/{pr-follow-up-scope.js → modules/delivery/runtime/pr-follow-up-scope.js} +2 -2
  211. package/server/dist/{pr-follow-up.js → modules/delivery/runtime/pr-follow-up.js} +1 -1
  212. package/server/dist/{pr-naming.js → modules/delivery/runtime/pr-naming.js} +1 -1
  213. package/server/dist/{pr-publisher.js → modules/delivery/runtime/pr-publisher.js} +2 -2
  214. package/server/dist/{rail-isolated-launch.js → modules/delivery/runtime/rail-isolated-launch.js} +29 -27
  215. package/server/dist/{rail-launch-parser.js → modules/delivery/runtime/rail-launch-parser.js} +2 -2
  216. package/server/dist/{rail-merge-orchestrator.js → modules/delivery/runtime/rail-merge-orchestrator.js} +5 -5
  217. package/server/dist/modules/delivery/runtime/rail-pr-decision.js +12 -0
  218. package/server/dist/{rail-pr-delivery.js → modules/delivery/runtime/rail-pr-delivery.js} +1 -1
  219. package/server/dist/{rail-pr-store.js → modules/delivery/runtime/rail-pr-store.js} +2 -2
  220. package/server/dist/{rail-pr-ticket-effects.js → modules/delivery/runtime/rail-pr-ticket-effects.js} +3 -3
  221. package/server/dist/{rail-worktree-release.js → modules/delivery/runtime/rail-worktree-release.js} +4 -4
  222. package/server/dist/{rails-router.js → modules/delivery/runtime/rails-router.js} +66 -67
  223. package/server/dist/{review-packet.js → modules/delivery/runtime/review-packet.js} +3 -3
  224. package/server/dist/modules/execution/adapters/budget-storage.js +21 -0
  225. package/server/dist/modules/execution/adapters/usage-reader.js +38 -0
  226. package/server/dist/modules/execution/application/enforce-budget.js +14 -0
  227. package/server/dist/modules/execution/application/record-job-invocations.js +64 -0
  228. package/server/dist/modules/execution/application/recover-job-usage.js +200 -0
  229. package/server/dist/modules/execution/domain/job-accounting.js +2 -0
  230. package/server/dist/modules/execution/domain/scheduling.js +24 -0
  231. package/server/dist/modules/execution/domain/usage.js +21 -0
  232. package/server/dist/modules/execution/index.js +14 -0
  233. package/server/dist/modules/execution/ports.js +2 -0
  234. package/server/dist/{accept-ladder.js → modules/execution/runtime/accept-ladder.js} +1 -1
  235. package/server/dist/{interactive-job-session.js → modules/execution/runtime/interactive-job-session.js} +11 -11
  236. package/server/dist/{job-phase-breakdown.js → modules/execution/runtime/job-phase-breakdown.js} +1 -1
  237. package/server/dist/{queue-manager.js → modules/execution/runtime/queue-manager.js} +74 -491
  238. package/server/dist/{revision-seed.js → modules/execution/runtime/revision-seed.js} +1 -1
  239. package/server/dist/{spawn-lifecycle.js → modules/execution/runtime/spawn-lifecycle.js} +3 -3
  240. package/server/dist/{stuck-run-detector.js → modules/execution/runtime/stuck-run-detector.js} +1 -1
  241. package/server/dist/{loop-command-catalog.js → modules/loops/runtime/loop-command-catalog.js} +1 -1
  242. package/server/dist/{loop-executors.js → modules/loops/runtime/loop-executors.js} +17 -17
  243. package/server/dist/{loop-factory.js → modules/loops/runtime/loop-factory.js} +2 -21
  244. package/server/dist/{loop-graph.js → modules/loops/runtime/loop-graph.js} +25 -0
  245. package/server/dist/{loop-role-engines.js → modules/loops/runtime/loop-role-engines.js} +4 -4
  246. package/server/dist/{loop-run-manager.js → modules/loops/runtime/loop-run-manager.js} +101 -26
  247. package/server/dist/{loop-runs-store.js → modules/loops/runtime/loop-runs-store.js} +1 -1
  248. package/server/dist/{loop-shell-invocation.js → modules/loops/runtime/loop-shell-invocation.js} +2 -2
  249. package/server/dist/{loop-step-idle.js → modules/loops/runtime/loop-step-idle.js} +1 -1
  250. package/server/dist/{loop-templates.js → modules/loops/runtime/loop-templates.js} +24 -29
  251. package/server/dist/{loops-router.js → modules/loops/runtime/loops-router.js} +2 -2
  252. package/server/dist/{agent-chat-manager.js → modules/missions/runtime/agent-chat-manager.js} +19 -19
  253. package/server/dist/{agent-chat-router.js → modules/missions/runtime/agent-chat-router.js} +6 -6
  254. package/server/dist/{agent-context-resolver.js → modules/missions/runtime/agent-context-resolver.js} +8 -8
  255. package/server/dist/{agent-failure-briefing.js → modules/missions/runtime/agent-failure-briefing.js} +4 -2
  256. package/server/dist/{agent-fence-promotion.js → modules/missions/runtime/agent-fence-promotion.js} +1 -1
  257. package/server/dist/{agent-input-store.js → modules/missions/runtime/agent-input-store.js} +1 -1
  258. package/server/dist/{agent-operator-prompt.js → modules/missions/runtime/agent-operator-prompt.js} +75 -12
  259. package/server/dist/{agent-spec-framing.js → modules/missions/runtime/agent-spec-framing.js} +4 -4
  260. package/server/dist/{agent-steering.js → modules/missions/runtime/agent-steering.js} +1 -1
  261. package/server/dist/{mission-run-notify.js → modules/missions/runtime/mission-run-notify.js} +5 -2
  262. package/server/dist/modules/project-settings/adapters/http.js +24 -0
  263. package/server/dist/modules/project-settings/adapters/sqlite.js +120 -0
  264. package/server/dist/modules/project-settings/application.js +11 -0
  265. package/server/dist/modules/project-settings/domain.js +95 -0
  266. package/server/dist/modules/project-settings/index.js +11 -0
  267. package/server/dist/modules/project-settings/ports.js +2 -0
  268. package/server/dist/{contract-refine-runner.js → modules/specs/runtime/contract-refine-runner.js} +12 -12
  269. package/server/dist/{proposal-manager.js → modules/specs/runtime/proposal-manager.js} +8 -8
  270. package/server/dist/{smash-runner.js → modules/specs/runtime/smash-runner.js} +26 -58
  271. package/server/dist/{spec-addenda-core.js → modules/specs/runtime/spec-addenda-core.js} +2 -2
  272. package/server/dist/{spec-addenda.js → modules/specs/runtime/spec-addenda.js} +3 -3
  273. package/server/dist/{spec-launcher-manager.js → modules/specs/runtime/spec-launcher-manager.js} +8 -8
  274. package/server/dist/{spec-models.js → modules/specs/runtime/spec-models.js} +1 -1
  275. package/server/dist/{ticket-watcher.js → modules/specs/runtime/ticket-watcher.js} +1 -1
  276. package/server/dist/{background-process-control.js → modules/terminals/runtime/background-process-control.js} +1 -1
  277. package/server/dist/{background-process-service.js → modules/terminals/runtime/background-process-service.js} +3 -3
  278. package/server/dist/{background-process-store.js → modules/terminals/runtime/background-process-store.js} +1 -1
  279. package/server/dist/{background-windows-bootstrap.js → modules/terminals/runtime/background-windows-bootstrap.js} +3 -3
  280. package/server/dist/{terminal-manager.js → modules/terminals/runtime/terminal-manager.js} +3 -3
  281. package/server/dist/project-git.js +1 -1
  282. package/server/dist/project-profile-support.js +32 -0
  283. package/server/dist/project-registry.js +34 -40
  284. package/server/dist/project-router-background-processes.js +1 -1
  285. package/server/dist/project-router-chat.js +6 -6
  286. package/server/dist/project-router-git.js +1 -1
  287. package/server/dist/project-router-helpers.js +2 -2
  288. package/server/dist/project-router-jobs.js +14 -12
  289. package/server/dist/project-router-loop-runs.js +12 -12
  290. package/server/dist/project-router-settings.js +15 -75
  291. package/server/dist/project-router-setup.js +2 -2
  292. package/server/dist/project-router-spending.js +4 -4
  293. package/server/dist/project-router-terminals.js +4 -4
  294. package/server/dist/project-router-tickets.js +14 -14
  295. package/server/dist/project-router.js +13 -13
  296. package/server/dist/providers/claude-adapter.js +3 -2
  297. package/server/dist/providers/codex-adapter.js +3 -3
  298. package/server/dist/providers/gemini-adapter.js +1 -1
  299. package/server/dist/providers/telemetry-env.js +55 -0
  300. package/server/dist/runtime-role-prompts-router.js +2 -2
  301. package/server/dist/setup-manager.js +2 -48
  302. package/server/dist/shared/git-branch-name.js +16 -0
  303. package/server/dist/transient-children.js +3 -3
  304. package/server/dist/util/distribute-int.js +33 -0
  305. package/client/dist/assets/ReviewPacketPage-D-UjTef3.js +0 -4
  306. package/client/dist/assets/index-BAtqmAZR.css +0 -2
  307. package/client/dist/assets/packet-CjS_478C.js +0 -1
  308. package/client/dist/assets/packet-D69dKsHN.js +0 -1
  309. package/client/dist/assets/packet-D9rU6Oxo.js +0 -1
  310. package/client/dist/assets/packet-DMDFoamg.js +0 -1
  311. package/client/dist/assets/packet-Dd2RMs1h.js +0 -1
  312. package/client/dist/assets/packet-DjC9OibC.js +0 -1
  313. package/client/dist/assets/packet-DwPtJoDz.js +0 -1
  314. package/client/dist/assets/packet-ceahDg8_.js +0 -1
  315. package/server/dist/rail-pr-decision.js +0 -2377
  316. package/server/dist/ticket-broadcast.js +0 -47
  317. /package/server/dist/{ai-invocations.js → modules/accounting/runtime/ai-invocations.js} +0 -0
  318. /package/server/dist/{codex-otel-bridge.js → modules/accounting/runtime/codex-otel-bridge.js} +0 -0
  319. /package/server/dist/{metrics.js → modules/accounting/runtime/metrics.js} +0 -0
  320. /package/server/dist/{telemetry-export.js → modules/accounting/runtime/telemetry-export.js} +0 -0
  321. /package/server/dist/{agent-runtime-accounting.js → modules/agent-runtime/runtime/agent-runtime-accounting.js} +0 -0
  322. /package/server/dist/{agent-runtime-events.js → modules/agent-runtime/runtime/agent-runtime-events.js} +0 -0
  323. /package/server/dist/{agent-runtime-history.js → modules/agent-runtime/runtime/agent-runtime-history.js} +0 -0
  324. /package/server/dist/{agent-runtime-metrics.js → modules/agent-runtime/runtime/agent-runtime-metrics.js} +0 -0
  325. /package/server/dist/{agent-runtime-package.js → modules/agent-runtime/runtime/agent-runtime-package.js} +0 -0
  326. /package/server/dist/{agent-runtime-repositories.js → modules/agent-runtime/runtime/agent-runtime-repositories.js} +0 -0
  327. /package/server/dist/{agent-runtime-verification-suggestions.js → modules/agent-runtime/runtime/agent-runtime-verification-suggestions.js} +0 -0
  328. /package/server/dist/{agent-refine-db.js → modules/agents/runtime/agent-refine-db.js} +0 -0
  329. /package/server/dist/{browser-capture-types.js → modules/browser/runtime/browser-capture-types.js} +0 -0
  330. /package/server/dist/{browser-context-pool.js → modules/browser/runtime/browser-context-pool.js} +0 -0
  331. /package/server/dist/{browser-network.js → modules/browser/runtime/browser-network.js} +0 -0
  332. /package/server/dist/{browser-viewport.js → modules/browser/runtime/browser-viewport.js} +0 -0
  333. /package/server/dist/{blueprint-render.js → modules/builder/runtime/blueprint-render.js} +0 -0
  334. /package/server/dist/{blueprint-spec-fixtures.js → modules/builder/runtime/blueprint-spec-fixtures.js} +0 -0
  335. /package/server/dist/{blueprint-store.js → modules/builder/runtime/blueprint-store.js} +0 -0
  336. /package/server/dist/{blueprint-types.js → modules/builder/runtime/blueprint-types.js} +0 -0
  337. /package/server/dist/{builder-cwd-manager.js → modules/builder/runtime/builder-cwd-manager.js} +0 -0
  338. /package/server/dist/{milestone-chain-store.js → modules/builder/runtime/milestone-chain-store.js} +0 -0
  339. /package/server/dist/{changes-reader.js → modules/code/runtime/changes-reader.js} +0 -0
  340. /package/server/dist/{context-scope.js → modules/conversations/runtime/context-scope.js} +0 -0
  341. /package/server/dist/{explore-contract-refine.js → modules/conversations/runtime/explore-contract-refine.js} +0 -0
  342. /package/server/dist/{explore-draft-title.js → modules/conversations/runtime/explore-draft-title.js} +0 -0
  343. /package/server/dist/{merge-manager.js → modules/delivery/runtime/merge-manager.js} +0 -0
  344. /package/server/dist/{multi-repo-execution-store.js → modules/delivery/runtime/multi-repo-execution-store.js} +0 -0
  345. /package/server/dist/{pr-lifecycle.js → modules/delivery/runtime/pr-lifecycle.js} +0 -0
  346. /package/server/dist/{rail-isolation.js → modules/delivery/runtime/rail-isolation.js} +0 -0
  347. /package/server/dist/{rail-pr-recovery-git.js → modules/delivery/runtime/rail-pr-recovery-git.js} +0 -0
  348. /package/server/dist/{rail-worktrees-store.js → modules/delivery/runtime/rail-worktrees-store.js} +0 -0
  349. /package/server/dist/{rails-store.js → modules/delivery/runtime/rails-store.js} +0 -0
  350. /package/server/dist/{job-listing.js → modules/execution/runtime/job-listing.js} +0 -0
  351. /package/server/dist/{job-spawn-idempotency.js → modules/execution/runtime/job-spawn-idempotency.js} +0 -0
  352. /package/server/dist/{run-duration-stats.js → modules/execution/runtime/run-duration-stats.js} +0 -0
  353. /package/server/dist/{verification-sentinel.js → modules/execution/runtime/verification-sentinel.js} +0 -0
  354. /package/server/dist/{loop-constants.js → modules/loops/runtime/loop-constants.js} +0 -0
  355. /package/server/dist/{loop-decider.js → modules/loops/runtime/loop-decider.js} +0 -0
  356. /package/server/dist/{loop-effect.js → modules/loops/runtime/loop-effect.js} +0 -0
  357. /package/server/dist/{loop-preview.js → modules/loops/runtime/loop-preview.js} +0 -0
  358. /package/server/dist/{loop-templates-ported.js → modules/loops/runtime/loop-templates-ported.js} +0 -0
  359. /package/server/dist/{loops-store.js → modules/loops/runtime/loops-store.js} +0 -0
  360. /package/server/dist/{agent-chat-registry.js → modules/missions/runtime/agent-chat-registry.js} +0 -0
  361. /package/server/dist/{agent-cwd-manager.js → modules/missions/runtime/agent-cwd-manager.js} +0 -0
  362. /package/server/dist/{agent-tier.js → modules/missions/runtime/agent-tier.js} +0 -0
  363. /package/server/dist/{spec-contract-prompt.js → modules/specs/runtime/spec-contract-prompt.js} +0 -0
  364. /package/server/dist/{spec-draft-parser.js → modules/specs/runtime/spec-draft-parser.js} +0 -0
  365. /package/server/dist/{ticket-store.js → modules/specs/runtime/ticket-store.js} +0 -0
  366. /package/server/dist/{terminal-marks-store.js → modules/terminals/runtime/terminal-marks-store.js} +0 -0
  367. /package/server/dist/{terminal-osc-parser.js → modules/terminals/runtime/terminal-osc-parser.js} +0 -0
  368. /package/server/dist/{terminal-settings.js → modules/terminals/runtime/terminal-settings.js} +0 -0
@@ -0,0 +1,722 @@
1
+ # Historical implementation notes
2
+
3
+ Snapshot of the former root CLAUDE.md before the modularity refactor. These are
4
+ reference notes, not additional agent instructions. Some paths and descriptions
5
+ reflect older implementations; current code, tests, AGENTS.md and the architecture
6
+ decision take precedence. Search the relevant feature instead of loading the
7
+ whole document. Relative links below were originally relative to the repo root.
8
+
9
+ ---
10
+
11
+ # CLAUDE.md
12
+
13
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
14
+
15
+ ## What is this
16
+
17
+ specrails-desktop (the **Specrails** desktop app) is a local dashboard and CLI for managing multiple [specrails-core](https://github.com/fjpulidop/specrails-core) projects from a single interface. It visualizes AI pipeline phases (Architect → Developer → Reviewer → Ship), streams Claude CLI logs in real-time, and provides job queues, analytics, and chat per project.
18
+
19
+ ## Commands
20
+
21
+ ```bash
22
+ npm run dev # Start server (4200) + client (4201) concurrently
23
+ npm run dev:server # Server only with tsx watch
24
+ npm run dev:client # Vite dev client only
25
+ npm run build # Production build: client (tsc + vite) then CLI (tsc)
26
+ npm run typecheck # TypeScript check both server and client
27
+ npm test # Run vitest (server + CLI tests)
28
+ npm run test:watch # Vitest in watch mode
29
+ ```
30
+
31
+ Tests use vitest with `:memory:` SQLite databases. Run a single test file:
32
+ ```bash
33
+ npx vitest run server/db.test.ts
34
+ ```
35
+
36
+ ## Architecture
37
+
38
+ ### Three-layer monorepo
39
+
40
+ ```
41
+ server/ → Express + WebSocket + SQLite (TypeScript, CommonJS)
42
+ client/ → React + Vite + Tailwind v4 (TypeScript, ESM)
43
+ cli/ → specrails-desktop CLI bridge (TypeScript, CommonJS)
44
+ ```
45
+
46
+ Server and CLI compile to CommonJS (`tsconfig.json`). Client is ESM with its own `client/tsconfig.json`. Two separate `npm install` are needed (root + `client/`).
47
+
48
+ ### Super mode
49
+
50
+ The server runs in **Super mode** — one Express process manages multiple projects. Super is the only supported mode (health/state APIs report `mode: 'super'`).
51
+
52
+ **Data layout:**
53
+ ```
54
+ ~/.specrails/
55
+ desktop.sqlite # project registry (renamed from hub.sqlite via startup migration)
56
+ registry.json # canonical repo-realpath → workspace map (shared with specrails-core; see Artifact relocation)
57
+ registry.json.lock # advisory write lock (exclusive 'wx' create + mtime TTL)
58
+ manager.pid # server PID
59
+ framework/<version>/<provider>/ # bundled specrails-core framework (agents/commands/skills/rules), materialized once
60
+ framework/current # symlink → the active framework/<version> (atomic swap on update)
61
+ projects/<slug>/jobs.sqlite # per-project DB
62
+ projects/<slug>/workspace/ # relocated spawn cwd (artifactRoot); ./project symlink → repo
63
+ ```
64
+
65
+ **Key server modules:**
66
+ - `desktop-db.ts` — app-level SQLite (project registry CRUD)
67
+ - `project-registry.ts` — `ProjectRegistry` class: loads per-project `ProjectContext` (DB, QueueManager, ChatManager, SetupManager) at startup
68
+ - `desktop-router.ts` — `/api/*` root routes (projects CRUD, resolve by path, settings, state, theme, language, budget, webhooks, analytics, setup-prerequisites)
69
+ - `project-router.ts` — `/api/projects/:projectId/*` routes (all per-project operations). `createProjectRouter` is now a thin shell: it sets up the `:projectId` middleware + sub-router mounts, then calls `register<Domain>Routes(deps)` from the per-domain modules (`project-router-{jobs,spending,chat,setup,tickets,terminals,settings}.ts`) in their original registration order on the SHARED router — Express route-matching precedence is preserved. Shared spec/agent-model helpers + the `ProjectRoutesDeps` contract live in `project-router-helpers.ts` (re-exported from `project-router.ts` for existing importers).
70
+ - `spawn-lifecycle.ts` — `runAiCliInvocation(hooks)`: the shared AI-CLI spawn → stream → settle core (spawn the provider binary, read stdout through `adapter.parseStreamLine` into an `AdapterEvent[]`, capture the session id, drain stderr, settle once on close / spawn-error / timeout). Callers (contract-refine, agent-refine) keep their unique finalise/record/validate logic via hooks + the returned `InvocationResult`.
71
+ - `index.ts` — entry point, mode detection, mounts both routers
72
+
73
+ **Mount order (critical):** the desktop router is mounted at `/api` **before** `app.use('/api/projects', createProjectRouter(registry))`, so exact routes like `GET /api/projects` and `DELETE /api/projects/:id` are handled by the desktop router while everything under `/api/projects/:projectId/*` falls through to the project router.
74
+
75
+ **Per-project isolation:** Each `ProjectContext` gets its own SQLite, QueueManager, ChatManager. The `boundBroadcast` closure injects `projectId` into all WebSocket messages — no constructor changes needed on managers.
76
+
77
+ ### Client architecture
78
+
79
+ - `App.tsx` mounts `DesktopProvider` and `DesktopApp` unconditionally
80
+ - `useDesktop.tsx` — `DesktopProvider` context: project list, active project (also owns the provider-detection focus-refresh + `providers.detected_changed` reconciliation)
81
+ - `getApiBase()` (`lib/api.ts`) — module-level store returns `/api/projects/<id>` for the active project; throws when no project is set. Updated by `DesktopProvider` on project switch.
82
+ - `useProjectCache.ts` — stale-while-revalidate cache per project to eliminate flicker on tab switch
83
+ - `useProjectRouteMemory` (in `App.tsx`) — saves/restores URL route per project
84
+
85
+ **Per-project tab switch pattern:** All pages and hooks use `activeProjectId` as a `useEffect` dependency. On switch: cached data shown instantly, fresh data fetched in background. Never reset to empty state.
86
+
87
+ **SpecsBoard status selector.** The board header's old ToDo|Done tab pair is replaced by a premium status filter (`client/src/features/specs/components/SpecStatusFilter.tsx`): one chip per `TicketStatus` (draft/todo/in_progress/on_review/done/cancelled, each with its pill color + a live count) plus two smart buckets — **Active** (the default; the draft+todo+in_progress+on_review family, preserving the old ToDo-tab semantics) and **All** (everything, Done pinned at the bottom). Pure logic + per-project persistence live in `client/src/features/specs/lib/spec-status-filter.ts` (`statusMatchesFilter`, `loadSpecStatusFilter` — reads the legacy `spec-status-tab` localStorage key once as an upgrade fallback: `'done'`→`done`, else `active`). On Jira-connected projects a second dimension appears (`client/src/features/specs/components/SpecJiraStatusFilterDropdown.tsx`): the board's REAL Jira workflow statuses — the RAW `status.name` persisted additively per ticket as `jira_status` on the JSON store (written by `server/jira/jira-materializer.ts` `mapIssueToTicket` on every inbound poll, included in `sameJiraContent` so a raw-only move still broadcasts, frozen together with the logical status during a pending-outbox window; no SQLite migration) — grouped under the logical state each raw name majority-maps to (unresolvable → muted "Other"), ANDed with the status chips. Both dimensions are URL-synced (`?status=…&jiraStatus=…`, defaults deleted, URL wins over persisted on mount, persisted wins on project switch) in `SpecsBoard.tsx`. Counts recompute from the ticket props, so `ticket_updated` WS events keep them live.
88
+
89
+ **Job Detail page surfaces.** BOTH job views — the routed Job Detail page (`client/src/features/jobs/pages/JobDetailPage.tsx`, `variant="page"`) and the mission-mode `JobDetailModal` (`variant="glass"`, the prop only changes chrome) — render ONE shared header, `JobRunHeader` (`client/src/features/jobs/components/job-run/JobRunHeader.tsx`). It replaced the former `JobStatusPanel` + the contextual `AgentRuntimeRuns` card + the surfaces' own `PipelineProgress` placement, which stacked the same duration/phase/step facts two or three times and filled the viewport with "calculated when finished" placeholders. Layout: row 1 = status pill · live elapsed (1 s ticker while running, final wall-clock after) · one muted `phase · activity` line (running pipeline phase, else the runtime `nextStep`, plus the last observed tool action) · `N steps` once (loop jobs only) · the surface's own actions (Cancel / Re-run / Export) + the runtime continuation actions (resume/approve/recover/prepare delivery/cancel, rendered ONLY when the run exposes them; a pending architect question shows an "Answer the question" button that opens Details); row 2 = the `PipelineProgress` chips exactly once; then a **Details** disclosure (collapsed by default, remembered per surface in `localStorage['specrails-desktop:job-run-details']`) holding only real figures — while running, turns/tokens ONLY when `assistant` frames carried a `usage` object (captioned as provider-reported so far); after exit, the authoritative cost/turns/tokens (`—` + "Not available" when the provider reported none, never a fake 0), pipeline totals, modified files; plus the runtime `AgentRuntimeMetrics`, `RuntimeExecutionEvidence`, question textarea, error and historical note when a run exists. No placeholders, no estimates. The pure model lives in `client/src/features/jobs/components/job-run/job-run-model.ts` (wall-clock formatting, the incremental `activityReducer`/`useJobActivity` aggregator over `deriveFrameActivity` — tolerant of missing `usage` — `finalMetricsFor`, `computePipelineTotals`), and the runtime polling/actions in the hook `client/src/features/jobs/components/job-run/useRuntimeRuns.ts`, which the Settings `AgentRuntimeRuns` list consumes too. `JobTicketHeader` (`client/src/features/jobs/components/JobTicketHeader.tsx`) renders a premium ticket-identity card above the existing job info row whenever the server resolves at least one ticket from the job's `command` (the `tickets[]` field on `GET /jobs/:id`); list mode at 2–3 tickets, compact `+ N more` mode with expand chevron at ≥4. Clicking a chip routes through `TicketDetailModalProvider` (`client/src/features/specs/context/TicketDetailModalContext.tsx`) — a thin context mounted at the App root that opens the existing `TicketDetailModal` over the page without changing the route. **Loop jobs** (`job.command.startsWith('loop:')`) swap the log surface on BOTH job views (JobDetailPage `variant="page"` and the mission-mode `JobDetailModal` `variant="glass"`) for `LoopStepExplorer` (`client/src/components/loop-log/`): the pure model `loop-log-model.ts` `groupByLoopStep` segments the SAME parsed line stream LogViewer produces by the three structured events the loop engine persists on the run's job row — `loop_graph` (graph snapshot, once at run start), `loop_step` `{index,kind,title,nodeId,iteration}` (before each step spawns), `loop_step_end` `{index,nodeId,status,exitCode,durationMs,decision?}` (at the step's tail; MISSING for a step torn down mid-flight — missing end + settled job renders **Interrupted**). The UI is a `LoopOverviewStrip` (live per-node chips in graph-traversal order, decider verdicts, iteration counter; legacy runs without `loop_graph`/`nodeId` degrade to a linear chip strip) above collapsible per-step sections plus a Setup bucket, with follow mode (only the latest step open + autoscroll; any manual interaction pauses it and a floating Resume-follow pill restores it), expand/collapse-all, and per-step + whole-log copy. Non-loop jobs keep the legacy `LogViewer` byte-identical. Payload types exported from `server/modules/loops/runtime/loop-run-manager.ts`; full contract in `docs/internals/loop-step-log-explorer.md`.
90
+
91
+ **Tablet split-view spec compare.** `TicketDetailModalProvider` (`client/src/features/specs/context/TicketDetailModalContext.tsx`) now tracks split state `{ leftId, rightId, originSide, splitRatio }` on top of the existing centered-modal API (`openTicketDetail`, `closeTicketDetail`). Dragging a `TicketDetailModal` header past 20% of the viewport (or clicking the new "Comparar" toolbar button) calls `enterSplit(side)`; the provider then mounts `<SplitViewShell />` (`client/src/components/SplitViewShell.tsx`) which renders the origin ticket as an embedded `TicketDetailModal` on one side and a `<SpecComparePicker />` (todo-only, search, exclude-already-open) on the other. Clicking a picker card fills that side with a second embedded `TicketDetailModal`; the non-origin `×` returns the side to the picker, the origin `×` and the backdrop close all. Intra-modal navigation to a third ticket (`onOpenTicket`) collapses split and opens that ticket centered. The vertical divider between panels is a `role="separator"` with pointer-drag (clamped `[0.25, 0.75]`) and arrow-key/Home/End/0 resize. State is URL-synced as `?compare=<comparedId>&compareSide=left|right&compareOrigin=<originId>` via `useCompareUrlSync()` (`client/src/features/jobs/hooks/useCompareUrlSync.ts`) so refresh restores the comparison; centered-modal opens never write URL params. Viewport gating: drag, Comparar button, and split-view are all disabled below 900px (the provider auto-collapses on resize-below-threshold). **v1 limitation**: the picker uses a dedicated `SpecComparePicker` layout rather than mirroring the user's active dashboard view (list / grid / postit) — lifting `viewMode` and adding `mode='picker'` to the four dashboard view components is deferred to a follow-up change.
92
+
93
+ **Minimizable chat windows.** `MinimizedChatsProvider` (`client/src/features/missions/context/MinimizedChatsContext.tsx`) lives at App root inside `DesktopProvider` and manages a global stack of parked chat sessions. Chips are rendered by a dedicated, always-visible `MinimizedChatsDock` (`client/src/features/missions/components/minimized-chats/MinimizedChatsDock.tsx`) — a `fixed bottom-left` glass-card list the provider renders itself (NOT sonner toasts; that earlier approach was dropped because `<Toaster visibleToasts={6}>` + `duration:Infinity` silently hid the 7th+ chip and competed with transient toasts). The dock is `pointer-events-none` with `pointer-events-auto` chips at `z-[60]` so it floats above the `z-50` Explore/AI-Edit overlays — letting the user click a chip to switch sessions even while a shell is open — without blocking the overlay through the empty gutter. It reads `projects` reactively so chip project names stay live. Two surfaces opt in today: `ExploreSpecShell` (Add Spec → Explore) and `AiRefineOverlay` (Agents → AI Edit). Triggers (`SpecsBoard`, `AgentsCatalogTab`) own the shell's local state and call `provider.minimize({ kind, projectId, label, restoreRoute, params })` from the shell's minimize button. `minimize()` **dedupes by session identity** (`resumeConversationId` / `resumeRefineId`) so re-parking a session never stacks a duplicate chip, and writes `localStorage['specrails-desktop:minimized-chats']` **synchronously** before returning (so an immediate refresh can't lose a just-parked chip), capped at 50.
94
+
95
+ Restore = chip click → `setActiveProjectId` → `navigate(restoreRoute)` → the chip is moved into a **persisted** pending-restore queue (`localStorage['specrails-desktop:minimized-chats-pending']`) → the matching trigger consumes it via `usePendingRestore(kind, projectId, cb)` → re-mounts the shell with `resumeConversationId` (explore-spec) or `resumeRefineId` (ai-edit) so the server-side session rehydrates. **Never-lost guarantees:** (a) `pendingRestores` is persisted, so a refresh that lands between the click and the trigger mounting recovers the chip into the dock on next mount; (b) an 8s **watchdog** re-surfaces any pending restore that no trigger consumed; (c) `takePendingRestore` uses functional `setState` so concurrent restores never clobber each other. The ai-edit trigger lives inside `AgentsCatalogTab` which only mounts on the Agents `catalog` tab, so `AgentsPage` watches `pendingRestores` and auto-switches to that tab when an ai-edit restore is queued. Each parked session carries its own `projectId` (threaded through `ExploreState` / `RefineMode`) so parking on a project-switch tags the chip with the session's real project, never the live `activeProjectId`. Entries for deleted projects are dropped silently and the whole dock is hidden (chips preserved in storage, never dropped) during the setup-wizard takeover. Minimize never triggers discard-confirm — Esc / close keep their existing destructive-confirm rules.
96
+
97
+ ### WebSocket protocol
98
+
99
+ Single WS connection broadcasts all messages. Every project-scoped message includes `projectId`. Client-side handlers filter by `activeProjectId`. App-level messages (`desktop.project_added`, `desktop.project_removed`, `desktop.projects`) have no `projectId` and reach all handlers.
100
+
101
+ **Mobile gateway wire compat (frozen).** The v1 mobile app (in App Review) consumes a frozen wire contract: the mDNS service type `'specrailshub'`, the pairing/QR payload field names (`hub`, `hubName`, `hubInstanceId`), and the legacy outbound WS message types (`hub.projects`, `hub.project_added`, `hub.project_removed`, `hub_daily_budget_exceeded`) are intentionally NOT renamed. The mobile-ws outbound boundary translates the internal `desktop.*` types back to the legacy `hub.*` names — do not rename these strings.
102
+
103
+ ### Process spawning
104
+
105
+ `QueueManager` and `ChatManager` spawn the selected provider CLI (`claude`, `codex`, `gemini`, or `kimi`). Both accept a `cwd` parameter (set to `project.path` from `ProjectRegistry`) so the provider runs in the correct project directory. **Deterministic repo map (`server/repo-map.ts`):** rail/loop spawns get `SPECRAILS_REPO_MAP_PATH` injected (queue-manager `_startJob` + loop-executors `aiStepEnv`) pointing at a zero-AI fs-only map of the repo (`~/.specrails/repo-maps/<sha1(repoDir)>.md`, 60s TTL cache) so specrails-core's architect starts oriented instead of burning turns on top-level discovery; best-effort (failure ⇒ no env var), kill switch `SPECRAILS_REPO_MAP=false`. **(Under artifact relocation: relocated jobs and MCP-enabled Explore turns spawn with `cwd = ~/.specrails/projects/<slug>/workspace` + `SPECRAILS_REPO_DIR=<project.path>`; MCP-disabled Explore deliberately keeps its app-managed `explore-cwd`. Legacy/non-relocated projects keep their existing cwd. The provenance/git split keeps `QueueManager`'s git calls on `_codeRoot` = `project.path`, never the workspace — see the Artifact relocation section.)**
106
+
107
+ ### Interactive jobs (default-on)
108
+
109
+ Every claude job is an **interactive session** by default: instead of a one-shot `claude -p`, the spawn is a resident persistent-stdin child (`server/modules/execution/runtime/interactive-job-session.ts` `InteractiveJobSession` — the same stream-json transport as Explore). Job Detail (`JobDetailPage`) and the mission-mode job modal (`JobDetailModal`) both mount `InteractiveJobComposer` (`client/src/features/jobs/components/InteractiveJobComposer.tsx`): the user can ask questions or steer mid-run; a message that lands mid-stream **queues** behind the active turn and extends the session — the job/loop still tends to continue its plan. Spike-verified (2026-07-03, claude 2.1.198): slash commands expand over stream-json stdin exactly like the argv `-p` path, which is what makes implement/batch/custom-command jobs transport-compatible (not just freestyle prose). Deep dive: [`docs/internals/interactive-jobs.md`](docs/internals/interactive-jobs.md).
110
+
111
+ - **Spawn-time gate (restart-durable):** `isInteractiveJobsEnabled() && adapter.capabilities.persistentStdin && (interactiveOverride ?? true)` — derived in `QueueManager._startJob`, not at enqueue, so a queued job surviving a restart still spawns interactive. `persistentStdin` is claude-only today; codex/gemini/kimi keep the byte-identical one-shot rail spawn (graceful, no resident composer). Kimi chat surfaces continue a session by spawning a fresh `kimi -p -S` child, not by holding stdin open. Kill-switch `SPECRAILS_INTERACTIVE_JOBS=false` ⇒ byte-identical legacy everywhere (routes 403). `EnqueueOptions.interactive` is a per-job tri-state override (`false` = force one-shot, `true` = force interactive where capable, `undefined` = default ON).
112
+ - **Two settle modes:** `'finalize'` (freestyle/Freestyle QueueManager jobs only — the session idles between turns until the human clicks **Finalize**, unchanged pre-flip behaviour) vs `'auto'` (everything else — the session settles ITSELF on quiescence: a turn `result` arrived and nothing is queued or in flight; a subtle **Wrap up now** / **Settle this step** secondary action settles early). `'auto'` sessions arm the queue's zombie-timeout budget as a wedge detector (no output for the whole budget → fold in-flight turn, settle `crashed`); `'finalize'` never arms it (idling awaiting the human is by design).
113
+ - **Loop path (ALL dashboard rail launches — factory + custom loops):** claude ai-steps run as interactive sessions too (`loop-executors.ts` `planInteractiveAiStep` → `LoopRunManager._runInteractiveAiStep`, settleMode `'auto'`, the loop's ai-step timeout plus the **loop-step idle watchdog** (`server/modules/loops/runtime/loop-step-idle.ts`: default 30 min of provider silence, `SPECRAILS_LOOP_STEP_IDLE_TIMEOUT_MS`, `0|false|off` disables, clamped ≥ the stuck-notification floor) bound the step — factory loops are UNTIMED so the idle bound is their only watchdog; a stall closes the attempt `loop_step_end{status:'stalled',reason:'idle_timeout'}`, retries the step ONCE by resume (`loop_step{attempt:2}`), and a second stall settles the run `stalled` instead of leaving it `running` forever). `runId` = the job row id; the session draws event seqs from the engine's shared allocator. Turns route to the **ACTIVE step's** session; between steps the composer flips to a gentle waiting state; finalize settles the current step and the loop advances. Accounting split: the session feeds live job-row totals per turn (`accumulateInteractiveTurn`); the **engine stays sole `ai_invocations` authority** (one `record()` row per step from the settle totals — nothing double-records).
114
+ - **SDD Quick run vars (run 644eb404, 2026-09-22):** `{{run.changeId}}` is SEEDED at run start from the durable sources the launch already knows — the follow-up's `openspecChangeName`, else the spec's `openspecChangeName` metadata (`seedChangeId`, logged `↪ OpenSpec change id seeded from …`); a step's own prose capture (`extractChangeId`) still fills it when nothing is seeded. A shell step whose command references `{{run.changeId}}` and runs `openspec validate|archive` short-circuits as a successful no-op when `openspecChangeState(cwd, id)` finds the change ALREADY ARCHIVED under `openspec/changes/archive/` (an AI step archived it despite the prompt) instead of refusing/failing and throwing a green implementation away; both opsx prompts now forbid `openspec archive` explicitly.
115
+ - **Notification turns / background tasks / resume misses (run 5c958db2):** a claude `result` frame with `origin.kind === 'task-notification'` (emitted on `--resume` when the previous process died with background tasks running — `num_turns 0`, empty result, BEFORE our prompt's turn) is NOT a turn result: the adapter maps it to a non-terminal `other` event (`isClaudeNotificationResultFrame`) and the session leaves the turn armed without persisting a `result` row. A RESUMED loop step that still settles zero-work (no `Unknown command:`, no timeout) is retried ONCE fresh inside the same step (`isResumeMiss`). `FOREGROUND_RULE` (`loop-constants.ts`, in GUARDRAILS + verify/fix/freestyle) forbids `run_in_background`/`&` because the reply ends the step; the session logs a ⚠️ note when it auto-settles with `background_tasks_changed` tasks still alive. A user `cancel()` never counts toward the "provider appears down" fail-fast streak. **Provider usage/rate limits (run 52124009):** the claude `result` frame's `is_error` now rides the adapter event → `SettleInfo.resultIsError` / one-shot `resultIsError` ⇒ the step is FAILED (feeds fail-fast even with text); `server/provider-limit.ts` `classifyProviderLimit` (tail-only: claude session/usage limit + `rate_limit`/`HTTP 429`, codex quota, gemini `RESOURCE_EXHAUSTED`, with the reset hint) makes the engine stop the run AT ONCE — `loop_step_end { status:'failed', reason:'provider_limit' }`, WS `loop.provider_limit`, `LoopRunResult.stallReason='provider_limit'` forwarded through `onLoopRunFinished(…, { stallReason })` so the milestone chain pauses with reason `provider_limit` — instead of cycling verify/decider/fix to the no-progress guard; the client toasts the reset hint, badges the step and narrates it. Details: `docs/internals/interactive-jobs.md` § CLI notification turns / § Provider usage limits / § Decider starved by its own tool budget (the Loop Decider spawns under `pureOutputToolPolicy` — no tools on claude — because one tool call used to exhaust its `--max-turns 1` budget and turn a real STOP into a paid extra iteration).
116
+ - **REST (manager-agnostic):** `POST /:projectId/jobs/:id/messages` + `/finalize` try QueueManager first, then LoopRunManager (409 when neither has a live session — unknown / non-interactive / finalized / between loop steps). `GET /jobs/:id` carries `interactiveSettleMode` + `interactiveAcceptingTurns`. **WS:** `job.turn_user` (echo, `queued` flag), `job.turn_done` (running real totals), `job.finalized` (terminal totals + status), `job.interactive` (`acceptingTurns` signal on loop-step start/settle).
117
+ - **Provenance:** interactive QueueManager jobs record Code-Explorer provenance exactly like one-shots (pre-spawn `snapshotWorkingTree(execution.repoDir)`, diff at settle).
118
+ - **Bare-mode launches derive their factory loop:** a `POST /rails/:i/launch` without `loopId` (MCP tools, mobile, direct REST) maps its legacy mode to the same factory loop the dashboard sends when Loops are enabled — every launch door gets identical worktree isolation + the ask-first PR flow. The legacy `interactive` launch body param is accepted and ignored (wire compat). Loops off ⇒ legacy QueueManager path.
119
+
120
+ ### Safe PR review flow (ask-first)
121
+
122
+ **Reliability refinements (2026-09-05):** see [`docs/internals/implementation-reliability-audit.md`](docs/internals/implementation-reliability-audit.md). Local integration now allows compatible dirty work and updates an unoccupied integration branch through a temporary worktree when another branch is active. It requires durable commit SHAs and checks destination identity after the advance. Checkout also accepts a single verified local result before PR creation. These rules supersede the older clean-tree/current-branch restrictions described below. Built-in loop verification requires a successful step with an explicit PASS verdict, and named rail profiles reach both AI transports.
123
+
124
+ Every repo-mutating isolated rail — per-ticket AND `scope='all'` (implement/batch included) — runs in git worktree(s) off the project's designated integration branch, and at settle the work **stays on its branches** while the app **asks the user** what to do. specrails is a PR producer, never a merge authority. Supersedes the v1 auto-delivery + toast: `rail.pr_delivered` and `POST /rails/pr-review` are **retired**. Full as-built record: [`docs/internals/safe-pr-review-flow.md`](docs/internals/safe-pr-review-flow.md).
125
+
126
+ - **Single source of truth.** `rail_pr_deliveries` (per-project `jobs.sqlite`, migration 36; `server/modules/delivery/runtime/rail-pr-store.ts`) — ONE durable row per rail LAUNCH, inserted up front (`decision='building'`) so the origin link survives every await and late joiners hydrate mid-build. Carries `rail_key`, `ticket_ids`, `base_branch`, per-unit `branches` (`DeliverBranchRecord[]`) + `worktree_ids` captured at settle (the deferred create-pr / discard actions reconstruct their inputs from these — nothing survives in memory), `loop_name`, `pr_url`/`pr_number`, `pr_state` (the `none|local-only|pushed|pr-created` degradation ladder, independent of `decision`), `origin_surface`/`origin_conversation_id`. **State machine (`decision`):** `building` →(settle, ≥1 succeeded unit)→ `on_review` | →(0 succeeded)→ `discarded` (auto-close); `on_review`/`pr_failed` —create-pr→ `pr_draft` (or `pr_failed`); `pr_draft` with a real `pr_url` —publish→ `pr_ready` (degraded pushed/local-only drafts only offer retry create-pr or discard); `pr_draft`/`pr_ready` —poll-merge→ `merged`; discard from any non-terminal state → `discarded`. `merged`/`discarded` terminal. Every transition is the compare-and-set `transitionDecision` (one atomic `UPDATE … WHERE decision = expected`).
127
+ - **Preparation failures decide as a single row.** A launch that fails BEFORE any repository delivery is allocated (e.g. `git worktree add` refused because the PR branch is checked out in the main clone) persists the parent as `pr_failed / failed / blocked / delivery_failed` WITH its `execution_manifest` (`repositories: []`) and ZERO child rows. `executePrDecision` routes to the per-repository group path ONLY when `hasRepositoryDeliveries(db, id)` — a manifest alone is not a group (the group path used to 404 "Unknown repository", which the UI read as "Already resolved elsewhere", and the rail stayed `pending_decision`). `runDiscard` classifies such a row as a `preparationFailure` (pr_failed + implementation failed + no PR/SHA/branches/worktrees, not a continuation): it transitions to `discarded` and frees the rail but applies NO ticket effect and NO Jira hook — the spec keeps whatever status it had (typically `on_review` from a still-open earlier PR) — and answers `{ preparationFailure: true }`. Existing stuck rows need no migration: pressing Discard again succeeds. The rail strip re-hydrates on a `stale_decision` that carries no snapshot instead of leaving a dead card.
128
+ - **One decision endpoint.** `POST /rails/pr-decision` `{ prDeliveryId, action: 'create-pr'|'publish'|'discard'|'poll-merge'|'merge-local', expectedDecision }` — route validates and delegates to `server/modules/delivery/runtime/rail-pr-decision.ts` `executePrDecision` (DI: db/git/exec/broadcast/jira/agent-chat, fully unit-testable). Stale `expectedDecision` → **409 `stale_decision`** (the anti-desync arbiter). `create-pr` runs the deferred `deliverRailAsPr` reconstructed from the row (drops a stale batch branch first — never the integration branch) and composes the PR title/body at that moment; `publish` = `gh pr ready` (opens the draft for team review); `discard` = best-effort `gh pr close --delete-branch` + remove the launch's worktrees + delete every referenced branch (never the integration branch) + revert still-`on_review` tickets → `todo`; `poll-merge` = **on-demand** `gh pr view --json state` — MERGED flips tickets → `done`, else 200 no-op; `merge-local` = the **remote-less acceptance path** (a repo with no GitHub remote can never leave `local-only` — retry loops forever, discard destroys the work): legal ONLY while `pr_url` is null (on_review / pr_failed / degraded pr_draft), merges the assembled head (else each succeeded unit branch, `--no-ff --no-edit`) into the integration branch DIRECTLY in the user's checkout, triple-guarded (integration branch checked out + clean tree → 409 `merge_local_blocked` `wrong_branch|dirty`, user-fixable, no transition; conflict → `merge --abort` + 502 `merge_failed`, no transition), then sweeps worktrees/branches, transitions → `merged` (`pr_url` stays null), tickets → `done`, Jira `onRailMerged(…, null)`. Both surfaces render an **Integrate locally** button (confirm-gated) wherever no PR exists yet. `gh` failures → 502, no transition.
129
+ - **Conventional naming + canonical PR content (`server/modules/delivery/runtime/pr-naming.ts` / `server/modules/delivery/runtime/pr-body.ts`).** Per-unit worktree branches are `<type>/<ref>-<kebab-title>` and the assembled batch branch is `<type>/<primary-ref>-batch-<n>-tickets`, where `<ref>` is the ticket's Jira key when linked (JIRA ALWAYS PREVAILS — the authoritative `jira_links` row wins over the ticket's `jira_key` field, resolved per ticket with no HTTP) else the local id, and `<type>` comes from a documented labels-then-title heuristic (bug|fix→`fix`, chore|refactor|cleanup→`chore`, docs→`docs`, else `feat`; batch = `feat` unless ALL tickets share one other type). Names are ascii-folded/kebabed (~40-char cap), always pass `isValidBranchName`, NEVER equal the integration branch, and collide-suffix `-2`, `-3`… (bounded; a branch a prior rail run allocated for the SAME ticket is resumed, not suffixed; exhaustion falls back to the legacy `sr/<slug>/ticket-<id>`). `createWorktree` stays generic (optional preferred `branch`, legacy fallback when absent). PR titles are `[<ref>]<type> - <change>` (batch: `[<ref> +<n-1>]<type> - <loop summary>`); the body is composed at CREATE-PR time by `buildCanonicalPrBody`: a summary paragraph (loop, N tickets, base), one `## <ref> — <title>` section per ticket with **Problem** (spec's leading narrative), **Solution** (tight digest, overflow in `<details>`), **Tests** (HONEST — derived from `git diff` name-status; "No test files changed in this diff." when none; explicit unavailable note on diff failure), and a `## Changes` per-branch diffstat section (omitted when git fails — diff problems never block PR creation). The old `buildBatchPrBody` + "Draft PR produced by specrails" footer are gone.
130
+ - **Two synced surfaces, zero desync.** (a) Dashboard rail row: `RailPrDecisionStrip` (both density branches) fed by `client/src/features/delivery/context/RailPrDecisionContext.tsx` — hydrates from `GET /rails` `prDeliveries` (newest active row per slot) and converges on the durable **`rail.pr_state`** WS snapshot broadcast on EVERY row mutation (insert, settle, each decision). (b) Agent chat: a launch tagged with an origin conversation gets a **persisted inline card** — a `'system'`-role `agent_messages` row whose content is the `PrDecisionCardEnvelope` JSON, posted by `AgentChatManager.postPrDecisionCard` / updated in place by `updatePrDecisionCard` (reached from rails code via `server/modules/missions/runtime/agent-chat-registry.ts`, null-safe), live-rendered off the app-global **`agent_pr_decision`** WS event (`AgentPrDecisionCard`). Both surfaces POST the same endpoint; buttons disable in flight and reconcile to the broadcast (no optimistic state); a raced second answer gets the 409 and a neutral "already resolved" toast.
131
+ - **Ticket lifecycle: `on_review` (universal ask-first).** New `TicketStatus` between `in_progress` and `done`. Under the default-on PR-delivery flag EVERY completed job/run parks its tickets at `on_review`, never `done` — both completion chokepoints honour it: (a) `onLoopRunFinished(runId, outcome, { ticketCompletionStatus })` — explicit opts from `launchIsolatedRail` (launch-captured `prMode`), and when opts are ABSENT (shared-cwd rail runs, standalone loop runs, isolation-unavailable fallbacks) the default derives from `isRailPrDeliveryEnabled()` read once per settle; (b) `onJobFinished` for ALL QueueManager jobs (bare-mode launches, MCP `/spawn` jobs, freestyle Finalize, interactive auto-settles) — `QueueManager` captures the flag ONCE at spawn (`_jobPrDelivery`, the same read that injects `SPECRAILS_GIT_AUTO=false`; restart-durable like the interactive gate) and threads `{ ticketCompletionStatus }` into the callback on completed exits, so a mid-flight env flip can't split one job. Both feed `applyJobOutcomeToTickets(…, { completedStatus })` (default `'done'` = byte-identical legacy; failure branches untouched). `done` is reachable ONLY via PR poll-merge, a manual context-menu move, or the kill-switch-off legacy path. Merge → `done`; discard → `todo`. Pipeline-owned: accent-warning "On review" pill, lives in the ToDo board bucket, auto-stripped from rails, NOT draggable/launchable, not Explore-editable (Jira-mirror specs excepted); users can manually move OUT (todo/done) but never INTO it. **Jira:** `SpecLogicalState` += `on_review` (+ `statusMap.on_review` in wizard AND connected card, accepted by `sanitizeStatusMap`); resolver same-category-walk fix (an explicit target ≠ current status name transitions even within one category); materializer preserves inbound issues sitting AT the mapped on_review status; outbox hooks `onRailReview` (settle), `onRailMerged` (done + "PR merged: <url>" comment), `onRailDiscard` (discardStatus, else todo).
132
+ - **Relaunch guard.** `POST /rails/:i/launch` → **409 `{ error: 'pr_decision_pending', prDeliveryId }`** while the slot has an active (non-terminal) delivery AND the launch would take the isolated PR path — a relaunch would append to the undecided branches.
133
+ - **PR review follow-up (pr-follow-up-fixes).** "Resolve the review comments on this PR" is a typed, FROZEN delta, never a spec edit: `server/modules/delivery/runtime/pr-follow-up.ts` (`parseFollowUpInput` validates/bounds `{ comments[{id,source:'user-paste'|'github',author,path,line,body}], scope{objective,requiredOutcomes,excludedChanges,verification}, openspecChangeName }`, `freezeFollowUp` assigns `id` + sha256 `hash`, `renderFollowUpBriefing` is the deterministic briefing, `parseFollowUpReport` reads the run's `FOLLOW-UP REPORT` lines). The launch body accepts `followUp` (400 `invalid_follow_up{code}` / `follow_up_requires_target` — needs `targetPrNumber` or `revisionOfDeliveryId` / `follow_up_requires_pr_mode`), the route freezes it and `launchIsolatedRail` persists it on the delivery row (`follow_up`, migration 62; `PrDeliverySnapshot.followUp`) and passes `LoopRunRequest.followUp{id,version,hash,briefing}` — the loop engine APPENDS the briefing to EVERY ai-step prompt (after the slash command, so it rides as command arguments) so no phase can miss it and a draft edited mid-run never changes the snapshot. Comment bodies are quoted as UNTRUSTED evidence. The packet exposes `followUp` + `followUpReport` (per-comment resolved/partial/blocked parsed from the harvested verify tail; `null` when the run reported nothing — never "all resolved" because tests passed). Surfaces: rail-launch card (`RailLaunchProposal.followUp`, rendered as a scope block), MCP `specrails_rails(launch, followUp)`, operator prompt rule. Guarantee: nothing in the path writes the spec/description/Jira (`onSpecEdited` is never reached).
134
+ - **Spec addenda (spec-addenda) — iterate on a spec WITHOUT rewriting its description.** `Ticket.addenda: SpecAddendum[]` (ticket store schema **1.4**, `server/modules/specs/runtime/spec-addenda-core.ts` pure contract mirrored byte-identically at `client/src/features/specs/lib/spec-addenda-core.ts`, node half `server/modules/specs/runtime/spec-addenda.ts`): structured notes `{ id, kind: change-request|review-feedback|clarification|constraint, title, body (markdown ≤ 8000), status: open|in_flight|applied|dismissed, hash, run_id, applied_at, created_by }`, ≤ 50 per ticket, preserved by the Jira materializer, NEVER accepted by `PATCH /tickets/:id`. Routes `GET/POST /tickets/:id/addenda`, `PATCH/DELETE /tickets/:id/addenda/:aid` (409 `addendum_in_flight`/`addendum_applied`; `PATCH { status:'open', force:true }` = the user's **Release** escape hatch for a claim whose run vanished). Lifecycle: EVERY launch door CLAIMS the specs' open addenda under the run/job id (`claimSpecAddendaForRun`: isolated launch plans once at entry and freezes the snapshot on the delivery row `spec_addenda`, migration 63 → `PrDeliverySnapshot.specAddenda`; shared-cwd `launchLoopRun`; legacy QueueManager claims at enqueue AND re-claims idempotently at spawn from the store, appending the briefing after the command) and renders `renderSpecAddendaBriefing` — deterministic, states precedence (addenda = the delta; a delivered `on_review`/`done` spec is ITERATED, never re-planned; the spec text is never edited; bodies are evidence) and demands an `ADDENDA REPORT` — which the loop engine appends to EVERY ai-step prompt right after the follow-up briefing (`LoopRunRequest.addenda`). Both completion chokepoints (`onJobFinished` + `onLoopRunFinished`) SETTLE them keyed on the run id (`completed` ⇒ `applied`, else back to `open`); a discarded delivery REOPENS the ids in its snapshot inside `applyRailPrTicketEffect`. Surfaces: `SpecAddendaSection` in the ticket modal, `SpecCard` open-count chip, the mission rail card's pre-Play note, the packet (`specAddenda` + `specAddendaReport`, `null` when the run said nothing), MCP `specrails_specs` `list_addenda|add_addendum|update_addendum|dismiss_addendum|reopen_addendum|delete_addendum`, the operator-prompt rule (iterate ⇒ addendum + normal launch, never a description edit), `agent-context-resolver` lines. **Preparation-failure relaunch:** `isPreparationFailureRow` (shared with `runDiscard`) lets the launch route close a never-ran `pr_failed` row in place (`→ discarded`, `rail.pr_state` re-broadcast) and proceed instead of `pr_decision_pending`; a revision naming it gets 409 `invalid_revision_target`. Record: `docs/internals/spec-addenda.md`.
135
+ - **Explicit target PR (deliver-rail-into-existing-pr).** The launch body accepts optional `targetPrNumber` — deliver INTO an existing open PR instead of creating a new one. `resolveExplicitPrTarget` (`active-pr-continuation.ts`, `source: 'explicit-target'`) bypasses the automatic-inference status gates but keeps the authoritative validation ladder (`target_pr_not_found`/`not_open`/`fork`/`invalid`/`unfetchable` — fail-closed, no delivery row/worktree on rejection, never a silent fresh-branch fallback; router adds `invalid_target_pr` + `target_pr_requires_pr_mode`). Reuses the continuation machinery end-to-end (single-checkout collapse, born-attached row, `borrowed-pr` ownership, attached-PR card; PR `baseRefName` wins over the integration branch). UI: `RailTargetPrSelector` chip in the rail header (candidates via `GET /rails/:i/pr-candidates` — matchers without the status gate, display-only, forks disabled; manual number entry; one-shot selection) + `TargetPrLaunchDialog` confirm. MCP: `specrails_rails(launch, targetPrNumber)`. See `docs/internals/safe-pr-review-flow.md` § Explicit target PR.
136
+ - **Explicit base branch (stacked chunks).** `POST /rails/:i/launch` accepts `baseBranch` (a LOCAL branch; `isValidBranchName` + `rev-parse --verify` ⇒ 400 `invalid_base_branch`; loops off / shared-cwd / isolation unavailable ⇒ 400 `base_branch_requires_isolation`) → `launchIsolatedRail({ baseBranch })` → `resolveIntegrationBranch({ explicit })`, recorded as the delivery `base_branch` so `deliverRailAsPr` creates the PR STACKED on it. Driven by the milestone launch chain (chunk k+1 on chunk k's branch); MCP `specrails_rails(launch, baseBranch)`. Consequences in `rail-pr-decision.ts`: `sweepMergedChainAncestors` after every `merged` transition (chain-local, `merge-base --is-ancestor`, own lease released BEFORE `finalizeTransition`), `mergeLocalTargetBranch` (merge-local integrates into the CHAIN's integration branch, never the feature base), `pauseChainsForDiscardedHead` (+ `discardStackedNote` on the strip / agent card / review packet). See `docs/internals/safe-pr-review-flow.md` § Explicit base branch.
137
+ - **Origin link.** The launch body accepts validated `originConversationId` (`/^[A-Za-z0-9-]{1,64}$/`) + `originSurface` (`'dashboard' | 'agent-chat'`), persisted on the row INSERT. The MCP auto-attach chain is **shipped**: `buildSpecrailsMcpEntry` (`agent-mcp-config.ts`) sets `SPECRAILS_AGENT_CONVERSATION` on the bridge spawn (rides `entry.env`, so all three provider registrations carry it) → the bridge forwards it as the `x-specrails-agent-conversation` header (`mcp-bridge`; rebuild the staged bundle via `npm run build:mcp-bridge` after editing) → `registerTieredTool` sanitizes it into the per-call `ctx.originConversationId` (malformed ⇒ null, never throws) → `specrails_rails(launch)` adds `originConversationId` + `originSurface:'agent-chat'` to the POST body — so agent-launched rails arrive tagged and the chat card fires. External MCP clients spawn the bridge without the env → untagged, by design.
138
+ - **Isolation is content-derived.** `server/modules/loops/runtime/loop-effect.ts` `classifyLoopEffect` (read-only iff no `ai-step`/`shell` node) feeds the isolation gate in `rails-router.ts`; `isolationApplies` isolates per-ticket rails always and `scope='all'` rails when PR delivery is on. The **designated integration branch** (`ProjectSettings.integrationBranch`, `server/integration-branch.ts` `resolveIntegrationBranch`: explicit → project setting → repo default `origin/HEAD` → `HEAD`) is the worktree `baseRef` and the recorded `base_branch`; the draft-PR primitive stays `server/modules/delivery/runtime/pr-publisher.ts` `publishDraftPr` (never throws) guarded by `server/git-guardrails.ts` `assertGitAllowed`.
139
+ - **Per-run worktree overlay (`server/worktree-overlay.ts`).** `git worktree add` materializes only TRACKED files, so every allocated worktree gets a merge-overlay of the project's framework surface at launch (`launchIsolatedRail`). Sources are ordered: the effective artifact root first (the **workspace** when relocated, the repo for legacy projects), plus the live repo as a lower-priority fallback when relocated so its untracked `/opsx:*`, `openspec-*`, and user provider entries reach the checkout. Earlier roots win per entry and checkout content is NEVER overwritten; one contributing directory stays a whole-dir link, while directories receiving children from several roots become REAL dirs of per-child links. An authenticated prior whole-dir link/copy is upgraded to that merged-leaf shape on resume; foreign links are untouched. `agent-memory` stays shared; `.mcp.json` + the provider instruction file are COPIED from the first root containing them; every root's `worktrees/` entry and the providerDir root itself are never linked (nested pipeline worktrees stay local). Windows uses junction → dereferencing-copy fallback. Every overlay-owned leaf is recorded in `.sr-rail-overlay.json`; commit delivery runs plain `git add -A`, audits/resets forbidden paths from the index, then enforces literal exclusions with `git commit --only`, so scaffolding never lands on the ticket branch/PR. Failures degrade (server log + project-scoped `rail.overlay_degraded` WS event) instead of aborting the rail. **Warm node_modules reuse (`server/worktree-node-modules.ts`):** a fresh worktree has no `node_modules` (tracked files only), so every isolated run used to pay a cold `npm install`; `linkNodeModulesIntoWorktree` (wired right after the overlay in `launchIsolatedRail`, injectable via `IsolatedLaunchIO.linkNodeModules`) prepares a worktree-OWNED `node_modules` directory per package dir (discovered as `package.json` dirs at depth ≤ 2, dot-dirs and dependency trees skipped) whose PACKAGE ENTRIES are symlinks (junctions on Windows) into the base checkout's identically-named entries, while tool caches (`WORKTREE_DEPENDENCY_CACHES`: `.vite`, `.vite-temp`, `.cache`) stay local writable dirs so concurrent worktrees never share Vite/tsc caches; a legacy whole-directory link from an older pass is migrated only after its exact source is authenticated. `linked` lists the prepared dirs; `authenticated`/`evidence` list the per-entry links. Never overwrites an existing entry, no copy fallback (copying a multi-GB tree would be slower than the install it replaces), and warnings ride the same `rail.overlay_degraded` degradation. **Release safety:** the ordinary `.gitignore` entry `node_modules/` is a DIRECTORY pattern and does NOT ignore a *symlink* of that name, so the link surfaces as untracked in `git status` — which used to make `releaseRailWorktrees` report `the worktree contains changes made after settlement`, park the row at `needs-review` forever and permanently block Checkout of the branch. The links therefore carry `OverlayCleanupEvidence` like overlay scaffolding: `authenticateWarmNodeModulesLinks(baseRepo, worktreePath)` proves an entry ONLY when it is a symlink whose resolved target is exactly the base checkout's identically-named dependency dir (a real dir, a copy, a dangling or foreign link stay unauthorized and preserve the worktree), and `linkNodeModulesIntoWorktree` returns `authenticated` (created **plus** links a prior pass made — resume no longer drops the exclusion) alongside `linked` + `evidence`. `rail-isolated-launch` excludes `authenticated` from the commit, re-proves the evidence at settle against the base repo (kept in its own `warmLinkEvidence` field so overlay revalidation, which authenticates against the framework source, can't silently revoke it) and merges it into the durable branch record; `verifyReleaseEvidence` ALSO derives it live from the filesystem, so worktrees that settled before the evidence existed heal on the next decision. Authorized links are quarantined through the same atomic-rename path as overlay roots (only the link moves — the shared dependency tree is never touched), which is what lets non-force `git worktree remove` succeed. Kill switch `SPECRAILS_WORKTREE_NODE_MODULES=false` restores the cold start. Env correctness rides with it: `SPECRAILS_REPO_DIR` = the WORKTREE per run (loop-executors `aiStepEnv`), while the relocated workspace artifact indirection (`SPECRAILS_TICKETS_PATH`/`BACKLOG_CONFIG`/`PROFILES_DIR`/`STATE_DIR`/`WORKSPACE_DIR`) reaches loop spawns through the executors' lazy base env (`workspace-resolution.ts` `resolveLoopBaseEnv`, wired in `project-registry.ts`). Legacy non-relocated projects keep single-root overlay and env behavior byte-identical. **Claude workspace trust (`server/claude-trust.ts`):** the overlay places a `.claude/settings.json` with a `permissions.allow` list, but headless claude IGNORES it until the spawn directory is "trusted" — a fresh per-run worktree/workspace never is, so every isolated run logged *"Ignoring N permissions.allow entries … this workspace has not been trusted"* and silently dropped its pre-approved permissions. `ensureClaudeTrusted(provider, [cwd, repoDir])` (claude-only, memoized per path, surgical temp+rename) pre-sets `projects[<realpath>].hasTrustDialogAccepted = true` in `~/.claude.json` before the first spawn (wired in `loop-executors` ai-step + `queue-manager._startJob`); it honours `resolveHome()` so tests never touch the real user file.
140
+ - **Kill-switch.** `SPECRAILS_RAIL_DELIVER_PR` is default-on; `0`/`false`/`off` ⇒ byte-identical legacy local merge-back — no `rail_pr_deliveries` rows, no `on_review`, no decision surfaces. Captured ONCE at launch entry so a mid-flight env flip can't split one launch across paths. PR mode also injects `SPECRAILS_GIT_AUTO=false` into rail spawns (loop-executors + queue-manager); the bundled specrails-core **4.11.0** honours it (implement no longer self-ships — the double-ship caveat is resolved).
141
+ - **Rail runtime card flush.** `GET /agent-runtime/runs?railIndex=` lists the rail's latest continuation ONLY while `pinsRailCard(summary)` (`server/modules/agent-runtime/runtime/agent-runtime-controls.ts`): active / resumable / settle-owed / cancellable / pending question or approval / recoverable steps / any non-`succeeded` status pin it; a green run with nothing left to decide leaves the rail at once (evidence stays in the job log + Settings history; the PR strip owns the delivery decision). Explicit Dismiss still works for the pinned cases.
142
+ - **Deferred:** a background merge poller (`poll-merge` is click-driven only — loopback server, no webhooks); an origin hop for QueueManager `/spawn` jobs (they have no delivery row); a per-worktree pre-push hook to block AGENT-issued git. (The relocation per-run workspace overlay SHIPPED — see the bullet above.)
143
+
144
+ ### Review packet (plain-language review for non-tech users)
145
+
146
+ > Discharges the deferred "plain-language Review & Approve bundle" SHALL from the open `safe-pr-workflow` change. Full as-built record: [`docs/internals/review-packet.md`](docs/internals/review-packet.md). OpenSpec change: `nontech-review-experience` (Wave 1 evidence + Wave 2 packet shipped; Wave 3 revisions + narration pending).
147
+
148
+ At `on_review` the app used to offer only git vocabulary (Create PR / Integrate locally / Discard). The **review packet** is the readable surface a non-technical person decides on, composed server-side from durable rows with **zero model calls**.
149
+
150
+ - **Wave 1 foundations.** Migration 56 adds two additive nullable JSON columns on `rail_pr_deliveries`: `spec_snapshot` (per-ticket title/description/labels FROZEN at launch — so "what you asked" cannot drift when the spec is edited mid-run; today's composition reads the live store) and `settle_evidence` (the deterministic settle-time harvest). `server/modules/delivery/runtime/delivery-evidence.ts` `harvestDeliveryEvidence` runs inside the settle path **before any `releaseRailWorktrees` call** and reads three sources with no model: the `VERIFICATION: PASS/FAIL` sentinel (reusing the `rail-merge-orchestrator` regex precedent), a ≤4 KB tail of the verify step's output, and **`sr-reviewer`'s `confidence-score.json`** from the still-mounted worktree — a machine-readable reviewer verdict core ≥4.12 always writes and which the desktop previously read NOWHERE. Harvest never throws: any missing/malformed source degrades to an absent value (`harvest: ok|partial|failed`) and never blocks settle. Injectable via `IsolatedLaunchIO.harvestEvidence`.
151
+ - **Programmatic-runtime evidence (2026-09-19).** core-host runs never emit the prose sentinel nor `confidence-score.json`; the harvest additionally reads the pipeline dir (`EvidenceHarvestUnit.runtimeDir`, `readRuntimeEvidence`: `state.json` verification commands + evidence files, acceptance checks/findings, the reviewer verdict in the workflow checkpoint). Host-run commands are **app-verified** (`proof.hostCommand*`), reviewer checks/summary/findings **ai-reported**, and the structured verdict fills `confidence` when no file score exists. `GET …/packet` heals pre-existing rows lazily (`healRuntimeEvidence` → `updatePrDeliverySettleEvidence`).
152
+ - **The honesty contract (load-bearing).** `server/modules/delivery/runtime/review-packet.ts` splits proof into three **epistemic tiers** that travel with each item so the UI must label them: `app-verified` (diff stats, changed test files, commits recorded — facts), `ai-reported` (the sentinel + verify prose, framed "the AI reports … Specrails did not run these checks itself"), `reviewer-score` (the AI grading its own work). **No numeric verification claim is ever emitted** — there is no structured test count anywhere in the pipeline (`loop_step_end.exitCode` is `null` BY CONTRACT for ai-steps; the PR body's Tests section is test FILE paths), so verify prose rides verbatim inside a labelled `rawExcerpt` instead, guarded by `packetHasUnsourcedNumericClaim`. Absence renders as absence ("No reviewer score", "No test files were changed", an `evidence unavailable` badge).
153
+ - **Four variants** chosen from durable outcomes (`selectVariant`): Success / **Nothing-to-change** (the first-class `no_changes` outcome; offers acknowledge + revise, never discard) / **Partial** ("N of M ready", per-ticket sections from durable per-unit outcomes) / **Failed** (what was attempted, why it stopped, one primary next action). Batch caveat: `file_story_contributions` keys files to a run's PRIMARY ticket, so a multi-ticket run reports per-section churn as `null` and shows the delivery-level total rather than inventing precision.
154
+ - **Three human verbs over ~14 states.** `client/src/features/delivery/lib/packet-verbs.ts` `resolvePacketVerbs` maps the FULL state space (12 decisions × 9 actions × orthogonal outcome/status axes) onto Accept / Request changes / Discard and marks every state where that reduction would LIE as **`fineControlOnly`** (the recovery family, a closed PR, a degraded draft) — those render the existing `RailPrDecisionStrip` controls instead of relabelling git work. Total by construction: an unclaimed state defaults to `fineControlOnly`, never a wrong verb.
155
+ - **Accept declares what it physically does.** `server/modules/execution/runtime/accept-ladder.ts` `resolveAcceptCapability` pre-resolves the ladder from shipped probes (`git remote`, offline `gh auth token`) and **fails CLOSED** to `merge-local`: remote + authed gh ⇒ Accept = create the PR (reversible); otherwise ⇒ Accept writes into the user's own checkout and the page ALWAYS interposes a plain-language consequence confirm first. Decisions execute through the existing `useRailPrDecisions().act()` + `rail.pr_state` broadcast + authoritative POST-response snapshot — no optimistic state, and a raced answer shows the neutral already-resolved message.
156
+ - **REST + surfaces.** `GET /:projectId/rails/pr-deliveries/:id/packet` returns `{ packet, acceptCapability, snapshot }`. Client: routed page `/review/:prDeliveryId` (`ReviewPacketPage.tsx` — a ROUTE, not a portal, so the z-order ladder is irrelevant) in an inverted pyramid (verdict + confidence pill + cost + verbs above the fold; sections as progressive disclosure). The narrative slots (`problem`, `solution`, plus `solutionOverflow` — the digest's remainder) are spec MARKDOWN rendered by `client/src/features/delivery/components/review-packet/PacketMarkdown.tsx` (react-markdown + GFM, `skipHtml`, images dropped, code as quiet chips, `unfoldInlineNumberedList` so a one-paragraph "1. … 2. …" spec still renders as a list); the spec's proposal is NOT reported as what was done — it sits in a collapsed "Planned approach (from the spec)" disclosure under the durable outcome pill + measured churn; `extractSpecNarrative` (`server/modules/delivery/runtime/pr-body.ts`) digests a headed section as `**Label**` on its own line + the content as a BLOCK — the former inline `**Label** — content` join swallowed a numbered journey's first item into the label line and every renderer (GitHub PR body included) lost the list. Entry points: a `Review` button on the rail strip's `on_review` row and on `AgentPrDecisionCard`; both keep their precise git actions. "Discuss this delivery" routes into the EXISTING agent chat rather than adding a second Q&A brain. i18n namespace `packet` ×8.
157
+ - **Also in Wave 1** (server-only, silent): `server/modules/execution/runtime/run-duration-stats.ts` measured p25–p75 duration bands per loop id / job command shape, returning **nothing below 5 samples** (honest-metrics: an unknown renders as absent, never a guess) exposed at `GET /:projectId/run-duration-range`; and `server/modules/execution/runtime/stuck-run-detector.ts`, a per-project sweep over the persisted `loop_step_recovery` activity checkpoints emitting the new project-scoped `job.stuck` WS message at most ONCE per stall episode (episode key = `runId:stepKey:lastActivityAtMs`, so fresh activity re-arms it), routed by the client through the already-shipped `useOsNotifications` path. Threshold is a flat env-overridable value with a 10-minute floor (`SPECRAILS_STUCK_THRESHOLD_MS`; `0`/`false`/`off` disables) — deliberately NOT 2× a step p75, because no per-step duration aggregate exists and a per-RUN p75 is far too lenient as a step threshold.
158
+ - **Flags.** `SPECRAILS_REVIEW_PACKET=false` 404s the packet route; `VITE_FEATURE_REVIEW_PACKET=false` removes the route and both entry points. Either off leaves the existing decision strip byte-identical.
159
+ - **Revisions (Wave 3).** "Ask for changes" is a sentence. `prDeliveryRevisionAllowed` (`rails-router.ts`) is the ONE exemption to the pending-decision 409 — the launch must name the rail's ACTIVE generation, that generation must be non-terminal, and the rail must still cover its EXACT ticket set (a wrong id gets a distinct `invalid_revision_target`; `launch_all` deliberately keeps skipping, since a batch fan-out must never silently revise). A revision creates a NEW superseding delivery row (migration 57: `revision_note` + `revision_of`) — **no new `decision` value**, because an unknown decision makes older clients drop the whole PR card, and supersession already means "a newer attempt replaced the previous one"; version lineage, crash-restore and one-active-generation-per-rail come free. `factory:revision` is the 4th factory loop (`{{cmd:revise}}` — applies the one change on top of the branch then runs `sr-reviewer` over the resulting diff so packet v2 keeps its reviewer tier — followed by the standard `{{cmd:verify}}`; Architect-less, resolvable by id but deliberately UNLISTED in the public catalog because its prompt consumes `{{const:REVISION_REQUEST}}`, injected only by a revision launch). The router forces that loop for any revision whatever the rail's stored mode is. `server/modules/execution/runtime/revision-seed.ts` builds the FRESH-session briefing (instruction + frozen spec + branch + what the previous run REPORTED, failure-first): `--resume` is deliberately NOT attempted — sessions are cwd-scoped to a released worktree and `jobs.session_id` holds the final step's session, so the branch carries the work and the seed carries the intent. `computeDriftNudges` fires on real measurements (cumulative revision cost > 50% of the original build, churn mostly outside the original file set, count backstop) and NEVER blocks. MCP `specrails_rails(launch)` gains `revisionOfDeliveryId` + `revisionNote`; the operator prompt routes any "change what you delivered" ask through them. Kill switch `SPECRAILS_DELIVERY_REVISIONS=false` restores the byte-identical legacy guard. No revision duration is promised anywhere (no measured history yet).
160
+ - **Narrated progress (Wave 3b).** A third altitude on BOTH job log surfaces (routed `JobDetailPage` + mission-mode `JobDetailModal`) between the glance-level phase chips and the raw log. `client/src/features/loops/components/loop-log/narration-model.ts` `buildNarration` turns the persisted event stream into MILESTONES — each a stable i18n key plus factual values, so the model writes no prose and therefore cannot invent. **Zero model calls.** The only outcomes stated are STRUCTURAL (a step's ok/failed status, a shell exit code, the Loop Decider's routed verdict); agent prose is never promoted, so a stream claiming "68 tests passed" produces no milestone and no number. Repeated identical activity collapses into a `×N` count (a loop reading the same file nine times reads as nine attempts, not nine lines); a step with no `loop_step_end` is "interrupted" ONLY once the run settled (a live one is simply still working); an unrecognised frame produces silence rather than filler. **Provider degradation is predictable** because it reuses `deriveFrameActivity` (the same derivation the job status panel and rail metrics use): claude yields tool-level detail, codex/gemini/kimi yield fewer activity milestones while every structural milestone is identical. Waiting is honest — the p25–p75 band comes from the Wave 1 endpoint and renders only when the server returned one (≥5 samples), elapsed time is real clock data, and nothing is ever synthesised client-side. The `Story|Log`-style toggle DEFAULTS to narrated (the Code explorer precedent) and persists to ONE app-level key (`client/src/features/jobs/lib/job-log-mode.ts`) rather than per-project — it describes the reader, not a project, and splitting it made the two surfaces disagree about the same person's choice. i18n namespace `narration` ×8. Flag `VITE_FEATURE_NARRATED_PROGRESS=false` ⇒ both surfaces byte-identical to pre-change, no toggle.
161
+ - **Deferred.** Screenshots/live preview stay out of scope by decision; the LLM catch-up digest is a future change if the deterministic templates test as insufficient; structured test counts are a parallel non-blocking specrails-core ask (when they land, the composer promotes that claim to tier 1).
162
+
163
+ ### Dynamic rails + parallel Launch all
164
+
165
+ Rails are **server-backed and dynamic** — the fixed 0-2 slots are gone. `rail_meta` is the existence authority (db migration 39 seeds the base three ONCE; `rails-store.ts` `listRailIndices` = rail_meta ∪ leftover ticket rows): `createRail` allocates the **lowest free index** (cap `MAX_RAILS = 12`, enforced by the router — indices are IDENTITY, sparse after a middle deletion, never re-numbered because metrics / `rail_pr_deliveries` / worktree-progress all key by railIndex), `deleteRail` is guarded by the router (must exist, empty, idle, no undecided PR delivery, never the last rail). REST: `POST /rails` (201 `{ rail }`, 400 `rail_limit_reached`) + `DELETE /rails/:railIndex` (broadcasts the new `rail.removed` WS message; creation rides the existing `rail.updated` shape for mobile wire-compat). `setRailTickets`/`setRailName` **materialize** an untouched index's rail_meta row, so a legacy client-local rail-4+ becomes server-backed on first touch. Every `:railIndex` route now rejects indices ≥ 12.
166
+
167
+ **Client identity mapping (`client/src/features/rails/lib/rail-id.ts`).** The ONE binding is rail id ↔ server index (`rail-N` ↔ N-1, `railIndexFromId`/`railIdFromIndex`, positional fallback only for exotic test ids). DashboardPage's launch/stop/profile/engine/rename calls, the bidirectional reconcile, `applyRailJobOutcome`, and RailsBoard's metric/PR-decision/worktree lookups all use it — the old ARRAY-POSITION assumption (broken by board reorder / middle deletion) is gone. The reconcile also **adopts server rails the board doesn't know** (agent-created via MCP) and **drops** local empty+idle rails the server deleted (guarded by the pre-fetch snapshot so an optimistic add mid-flight is never eaten); `handleAddRail` is server-first (POST → adopt returned index; offline falls back to a local-only slot).
168
+
169
+ **Launch all (parallel).** Worktree isolation makes N simultaneous rail launches safe; `launchIsolatedRail` serializes ONLY the allocation section (integration-branch resolution + `listLocalBranches` snapshot + `git worktree add` fan-in) under `withRepoLock(baseRepo)` — the same key as the merge-back — so concurrent launches can't race git ref locks or allocate duplicate collision-suffixed branch names; the AI runs stay fully parallel. The launch route adds a **409 `tickets_in_flight`** guard (a rail ticket already covered by an active `railJobs`/`railLoopRuns` entry — a duplicate launch would silently reuse the in-flight per-ticket worktree). UI: a premium **Launch all** button in the RailsBoard header (eligible = idle + has specs + no undecided PR delivery + no on-review spec) → `LaunchAllDialog` confirm framing N rails × cost → `Promise.allSettled` of the same per-rail launch path (silent mode) → ONE summary toast with per-reason skip counts (i18n `dashboard:launchAll.*` ×8). MCP: `specrails_rails` gains `create_rail` (write; returns the new railIndex) and `launch_all` (ai-spawn; server-side fan-out returning per-rail `launched|skipped(reason)|failed` outcomes using each rail's STORED mode/engine/profile); the operator prompt teaches "no free rail → create one" + "parallel is normal" + one-yes-per-batch.
170
+
171
+ ### Silent project add (wizard retired) + provider auto-detection
172
+
173
+ > **global-core-zero-friction.** The old 5-phase setup wizard is GONE (`SetupWizard.tsx`, `CheckpointTracker.tsx`, `SetupChat.tsx` deleted; `setupProjectIds` retired from `useDesktop`). Add Project = path input + prerequisites gate, nothing else. Full contract: `openspec/changes/global-core-zero-friction/`.
174
+
175
+ **Silent add.** `POST /api/projects` registers immediately (desktop.sqlite + registry.json) and returns; the server then runs `SetupManager.startSilentAssemble` in the background — one offline `assembleProjectOffline` init per detected provider, sequential, `continueOnError` (per-provider failure isolation). Progress rides the app-level WS event `project.assemble_progress` (`{projectId, provider, status: running|done|failed}`), consumed by `useAssembleProgress` (`client/src/features/jobs/hooks/useAssembleProgress.ts`) for the ArcSidebar project row's subtle indicator (pulsing dot while assembling; amber dot = failed, click = retry via `POST /api/projects/:id/assemble-retry`, which re-runs only the failed providers). Registration is NEVER rolled back by assembly failure. The wizard's Done-step hints (Jira/MCP/agent-chat) live on `WelcomeScreen`; Jira connect stays reachable from project Settings. `SetupManager` keeps its install/enrich/setup-chat plumbing server-side (MCP `specrails_setup` + standalone escape hatch) but no app UI reaches it.
176
+
177
+ **Provider auto-detection (machine property, not project property).** `server/provider-detection.ts` is an app-level singleton probing every registered adapter (binary presence, version, offline auth heuristics → `authState: authenticated|unauthenticated|unknown`, bounded probes), cached 60s, beta vetoes (`SPECRAILS_CODEX_BETA=0`/`SPECRAILS_GEMINI_BETA=0`, now exported from this module) filtered at the source, kimi additionally core-compat-gated. Refresh triggers: server startup, `POST /api/projects` (background), and `GET /api/providers/detected?refresh=1` (client calls it on window focus, throttled — wired in `DesktopProvider`). A changed usable set broadcasts app-global `providers.detected_changed` AND lazily assembles the new provider's surface into every relocated workspace (`assembleNewlyDetected` in `desktop-router.ts`). Consumers: `provider-selection.ts` (via `setDetectedProvidersSupplier` — detected set is authoritative for `resolveProvider`/`validateRequestedProvider`; primary derivation = stored-while-detected → claude → fixed order `[claude, codex, gemini, kimi]`; a stale stored engine falls back with a log notice, never blocks) and `desktop-db.ts` `mapProjectRow` (via `setProjectProvidersMirror` — every project row READS `providers` as the detected set; the column is wire-compat only and no longer gates anything). Null supplier (startup, unit tests) = legacy row behaviour byte-identical. **Capability visibility is UNION, not intersection**: `sectionVisibleForProviders` uses `some` — a section renders when ANY detected provider supports it; `providersSupportingSection` feeds the engine selectors inside it; `AiEngineSelector` shows a "Not signed in" badge on unauthenticated providers (`useProviderDetection` hook).
178
+
179
+ **Forced legacy migration (`server/legacy-migration.ts`).** On startup (after the first detection), projects still running a repo-resident core install (`<repo>/.specrails/specrails-version` present + workspace unpopulated) migrate automatically in the background, serialized, fail-open: workspace assemble → MOVE per-project state (`.specrails/{profiles,local-tickets.json,backlog-config.json,state,file-summaries,plugins}` + provider `agent-memory/`) into the workspace (repo state WINS over freshly-seeded defaults) → manifest-driven repo cleanup: exact `framework/current` listing ∪ narrow historical patterns (`sr-*.md` agents, `commands/{sr,specrails,opsx}/`, `sr-*`/`specrails-*` skills — covers files older 4.x cores installed). NEVER touched: `openspec/**`, `worktrees/**`, `custom-*`, user instruction files, settings, non-owned `.mcp.json` keys (surgical key removal via `PluginManager.removeMcpServers`). Every action is journaled WRITE-AHEAD to `~/.specrails/projects/<slug>/migration-log.json`; a crash resumes only unexecuted entries; any failure aborts that project's remaining cleanup (project keeps working via the activation gate). Kill switch: `SPECRAILS_LEGACY_MIGRATION=false`.
180
+
181
+ **Framework auto-update + re-seed.** `FrameworkManager.versionCheck` auto-materializes + swaps `current` at startup (pre-existing), now gated by `SPECRAILS_FRAMEWORK_AUTOSWAP` (default on; `false` = manual "check now" only). After the check, `server/framework-reseed.ts` `reseedStaleWorkspaces` re-runs the offline assemble for every relocated workspace whose recorded `.specrails/specrails-version` ≠ current — refreshing the COPIED surfaces (instruction files, Windows copy-fallback trees, Kimi skill links) and repairing missed swaps; the workspace `.mcp.json` is snapshotted and restored byte-identically if the assemble touches it (plugin/user keys can never be clobbered). Prior `framework/<version>/` dirs are never deleted — rollback = re-point `current` + re-seed. In-flight rails keep their resolved version.
182
+
183
+ **Developer prerequisites gate.** `AddProjectDialog` renders `<PrerequisitesPanel />` driven by the shared `usePrerequisites()` hook (60s in-memory cache, recheck on `window.focus`, manual recheck via the install-instructions modal). The hook fetches `GET /api/setup-prerequisites` (server enriches the response with `platform`, `minVersion`, `meetsMinimum`, and `executable` per tool). `AddProjectDialog` disables its submit while any required tool is missing (providers do NOT gate submit — zero detected providers still registers, with a warning banner), surfaces a "More info" link only when the panel is in the missing state (suppressed in desktop mode — see below), and opens `<InstallInstructionsModal />` with OS-aware install commands (Homebrew on macOS, winget on Windows, apt/dnf on Linux) plus copy-to-clipboard. `SetupManager.startInstall` keeps `formatMissingSetupPrerequisites()` as a server-side defence-in-depth before assembling.
184
+
185
+ **Desktop mode prerequisite check.** When `SPECRAILS_IS_DESKTOP=1`, `getSetupPrerequisitesStatus()` probes the four bundled tools (`node`, `npm`, `npx`, `git`) using absolute paths derived from `SPECRAILS_BUNDLED_RUNTIMES_PATH` (set by the Tauri host) instead of running `which` against the system PATH. **The probe is existence-gated**: only when the bundled binary file actually exists on disk does it take the bundled path — probe success → `{ bundled: true, installed: true, executable: true }`; probe failure (file present but `--version` fails) → `{ bundled: true, executable: false, error: 'corrupted-bundle', installHint: 'Bundle corrupted — reinstall the Specrails app.' }`. **When the bundled file is absent** (a build/arch that shipped no runtimes, or a partial extraction) the tool falls through to the normal system `which` probe rather than reporting `corrupted-bundle`, so a system-installed tool still satisfies the requirement instead of dead-ending Add Project with a futile "reinstall the app" message. `getBundledToolCandidates()` returns ordered candidates — Windows git accepts `git/cmd/git.exe` or `git/bin/git.exe`. Provider CLIs (`claude`, `codex`) are always probed via system PATH in all modes. **GitHub CLI (`gh`) is the inverse of node/git**: `probeGhPrerequisite` probes system PATH FIRST (the user's gh carries their auth/hosts config) and falls back to `runtimes/gh/bin/gh[.exe]` only when no system gh resolves; it's `required: false` (missing gh degrades PR delivery, never blocks Add Project) and reports `authenticated` from the offline `gh auth token` exit code — the panel shows a "Not signed in — run `gh auth login`" badge plus an "(optional)" tag on the gh/uv rows (`setup:prerequisites.optional`/`ghNoAuth` ×8). On the client, `PrerequisitesPanel` renders a "Bundle corrupted — reinstall app" message and suppresses the "More info" install link when `error: 'corrupted-bundle'` is present. `InstallInstructionsModal` shows a simplified error panel (no OS install commands) as defence-in-depth. `PathSource` type includes `'bundled'` for diagnostic segments prepended from the runtimes directory.
186
+
187
+ **GUI-launch PATH resolution (`server/path-resolver.ts`).** When the desktop app is launched from Finder/Dock, the embedded server inherits the launchd `PATH` which on Apple Silicon does not include `/opt/homebrew/bin`. There are two execution paths:
188
+
189
+ - **Desktop mode** (`SPECRAILS_IS_DESKTOP=1`, set by Tauri host): `resolveStartupPath()` existence-gates each candidate and prepends only the bundled Node and Git bin directories from `SPECRAILS_BUNDLED_RUNTIMES_PATH` that **exist on disk** as the first PATH entries (tagged `'bundled'` in the diagnostic). When at least one bundled dir is prepended the homebrew/fast-path prepend is skipped and `augmentPathFromLoginShell()` is a no-op (`loginShellStatus: 'skipped'`) — this prevents any login-shell output from prepending system node/git ahead of the bundled ones (tracked via the module-level `bundledRuntimesActive` flag). The bundled **gh** dir is the one exception to prepend-wins: `appendBundledGhDir()` APPENDS `runtimes/gh/bin` to the END of PATH (after whichever branch the base resolution took, including the partial-bundle fallback) so a system gh earlier in PATH always resolves first and the bundled copy only serves machines with no gh installed; login-shell augmentation only prepends, so the appended dir stays last. All `gh` callsites (`pr-publisher.ts`, `rail-pr-decision.ts`) spawn the bare name via PATH, so they need zero changes. **When no bundled dir exists** (runtimes-less build / partial extraction), `resolveStartupPath()` does NOT early-return — it falls through to the normal system discovery (fast-path prepend + login-shell augmentation) so system node/git still resolve, exactly mirroring the prerequisite check's existence-fallback. `resolveBundledRuntimePath()` is an exported helper that returns `SPECRAILS_BUNDLED_RUNTIMES_PATH` or throws if unset.
190
+
191
+ - **Non-desktop mode** (`npm run dev:server`, Docker, etc.): `resolveStartupPath()` prepends missing well-known package-manager directories (`/opt/homebrew/{bin,sbin}`, `/usr/local/{bin,sbin}` on macOS; `/usr/local/{bin,sbin}`, `~/.local/bin` on Linux; no-op on Windows). `augmentPathFromLoginShell()` (async, 1500ms timeout) merges additional segments from the user's `$SHELL -l -i` rc files (Volta/nvm/fnm/asdf shims).
192
+
193
+ The resolved `PATH` is written to `process.env.PATH`, so all downstream spawns (`QueueManager`, `ChatManager`, `SetupManager`, `terminalManager`) inherit it without per-callsite changes. The setup-prerequisites response distinguishes `installed` (on PATH) from `executable` (`<cmd> --version` exited 0), surfacing a broken-symlink hint when `installed && !executable`. `GET /api/setup-prerequisites?diagnostic=1` returns the resolved PATH, per-segment source (`'inherited' | 'fast-path' | 'login-shell' | 'bundled'`), login-shell status, and per-tool results, used by the "Copy diagnostics" button in the install-instructions modal.
194
+
195
+ **Bundled runtimes (desktop capability).** The Tauri host (`src-tauri/src/lib.rs`) sets `SPECRAILS_IS_DESKTOP=1` and `SPECRAILS_BUNDLED_RUNTIMES_PATH=<resource_dir>/runtimes` before spawning the Node.js sidecar — **but only when the `runtimes/` dir exists and is non-empty** (a build that ships no runtimes runs as a normal server and falls back to system PATH, never dead-ending on "corrupted-bundle"). The `runtimes/` directory (under `src-tauri/` at build time, under `Contents/Resources/` or `resources/` in the bundle) is populated by CI before `tauri build` runs, for macOS arm64, Windows x64, and Windows arm64. Its layout: `runtimes/node/bin/{node,npm,npx}` on macOS/Linux, `runtimes/node/{node.exe,npm.cmd,npx.cmd}` on Windows; `runtimes/git/bin/git` on macOS/Linux, `runtimes/git/cmd/git.exe` on Windows; `runtimes/gh/bin/gh` on macOS / `runtimes/gh/bin/gh.exe` on Windows (the GitHub CLI — **SYSTEM-FIRST, the inverse of node/git**: the user's own gh always wins because it carries their auth/hosts config; the bundled copy is pure fallback, so its bin dir is APPENDED to the END of PATH by `appendBundledGhDir()` and `setup-prerequisites.ts` `probeGhPrerequisite` probes system `which gh` before the bundle. Bundling removes the INSTALL step, not the `gh auth login` step — `~/.config/gh` is shared by any gh binary, so a system login carries over. The prereq row reports `authenticated` via the offline `gh auth token` exit code, is `required: false` — a missing gh only degrades PR delivery to local-only/pushed — and never enters the bundle-activation gate, which stays node+git). `src-tauri/tauri.conf.json` includes `"runtimes/**/*"` in `bundle.resources` (array/glob form — preserves nested directory structure) so Tauri copies the directory into the app bundle. The `src-tauri/runtimes/` directory is gitignored (only a `.gitkeep` placeholder is tracked). **Non-desktop mode is completely unaffected** — `SPECRAILS_IS_DESKTOP` is never set outside the Tauri host.
196
+
197
+ **How CI assembles the runtimes (`.github/workflows/desktop-release.yml`).** Tauri's resource bundler **dereferences symlinks** (tauri-apps/tauri#13219), does **not** reliably preserve exec bits, and does **not** codesign `bundle.resources` binaries — so the runtimes are prepared to survive all three:
198
+ - **Node** is downloaded from nodejs.org (SHA256-verified against `SHASUMS256.txt`). On macOS/Linux the `bin/npm` and `bin/npx` relative symlinks are **replaced with POSIX wrapper scripts** (`exec "$DIR/node" "$DIR/../lib/node_modules/npm/bin/npm-cli.js" "$@"`) — plain text survives bundling and always invokes the bundled node. On Windows `npm.cmd`/`npx.cmd` are kept as-is (they resolve the adjacent `node.exe` via `%~dp0`).
199
+ - **Git on macOS is built from source** (shallow clone of the official `github.com/git/git` tag, commit-SHA-pinned — kernel.org stopped hosting the tarballs) with `RUNTIME_PREFIX=YesPlease NO_GETTEXT=1 NO_OPENSSL=1 APPLE_COMMON_CRYPTO=1` and no `USE_LIBPCRE2` → a relocatable tree (`bin/git` + `libexec/git-core` + `share/git-core/templates`) whose only dylib deps are macOS **system** libs (asserted via `otool -L`), so no `install_name_tool` relocation is needed. The builtin hardlinks under `libexec/git-core` are collapsed (git dispatches builtins internally). **Git on Windows** is the SHA256-verified Git-for-Windows PortableGit (`-64-bit` / `-arm64`) self-extracted to `runtimes/git/`.
200
+ - **GitHub CLI** (`GH_BUNDLE_VERSION`) is the prebuilt static Go binary from `cli/cli` releases (MIT — no build step), SHA256-verified against the release's `gh_<v>_checksums.txt`, extracted to `runtimes/gh/bin/` for macOS arm64 + Windows x64/arm64. The macOS Mach-O signing sweep re-signs it with our identity (explicit `codesign --verify` line alongside node/git); smoke tests validate `gh --version` when the binary is present (a gh-less runtimes tree stays valid).
201
+ - **macOS codesigning**: a dedicated CI step signs every Mach-O under `runtimes/` with hardened runtime + `entitlements.plist` (JIT for V8) **before** `tauri build`, so the assembled `.app` notarizes cleanly (signatures embed in the Mach-O and survive Tauri's copy). `scripts/fix-desktop-bundle.mjs` is intentionally **not** wired into `build:desktop` — running it post-`tauri build` would re-sign after notarization and invalidate the updater `.app.tar.gz`.
202
+ - **Smoke tests** validate both the staging `src-tauri/runtimes/` copy and the copy **inside the assembled `.app`** (macOS), running `node`/`npm`/`npx --version` (bundled node prepended to a scrubbed PATH) and a functional `git init`+`commit`+`log` (via `scripts/smoke-bundled-runtimes.sh`) that would catch a missing `git-core` helper, a broken dylib, a dereferenced symlink, or a dropped exec bit. Windows jobs run the equivalent in PowerShell.
203
+
204
+ **Env vars contract (desktop mode only):**
205
+ - `SPECRAILS_IS_DESKTOP=1` — set by Tauri host **iff** the bundled `runtimes/` dir is present and non-empty; gates all desktop-mode branches
206
+ - `SPECRAILS_BUNDLED_RUNTIMES_PATH=<abs path>` — set by Tauri host alongside the flag; absolute path to the `runtimes/` directory inside the app bundle
207
+
208
+ **Install flow.** There is no user-facing install flow anymore — the silent assemble (above) writes a quick-tier `install-config.yaml` per provider with the adapter's default model and runs the offline init. Model presets/overrides are configured post-add in the Agents section. The legacy AI-enriched flow (codebase analysis + persona generation) is intentionally not exposed in the app — users who want it can run `npx specrails-core@latest init` from the project directory. Server code under `server/setup-manager.ts` retains the `'quick' | 'full'` plumbing for forward compatibility but is currently driven into the quick branch only. **(The quick assemble never spawns per-project `npx` when the bundled framework is present — it symlinks `~/.specrails/framework/current`; npx remains the existence-gated fallback. The standalone `npx specrails-core@latest init` enrich escape hatch is unaffected.)**
209
+
210
+ ### Project Builder (greenfield "New project")
211
+
212
+ "+ Add Project" shows an **Existing | New** chooser (`AddProjectDialog` pre-screen, gated by `VITE_FEATURE_PROJECT_BUILDER` / `SPECRAILS_PROJECT_BUILDER`, both default ON / opt-out). *New* puts the **AGENT into builder mode** (reskin — change `reskin-project-builder-into-agent-panel`): the mission/floating agent itself transforms into the Builder, keeping the MISSION format — centered column + docked `BuilderComposer` with the same provider/model/effort selectors, a native `resize-y` textarea, and the same `SendHorizontal` action as the mission composer. In Agent Mode the fresh empty composer shares a `layoutId` with its docked form, so the first send morphs it smoothly down into place. The orbiting **halo** (`BuilderHalo`, CSS-keyframe conic-gradient border band, `motion` enter/exit, `prefers-reduced-motion` ⇒ static glow) is an ENTRY flourish only: it wraps the fresh empty composer and the bubble/panel-header identity, then disappears as soon as the first work message starts; there is no commit wind-down. `builderMode` lives in `AgentChatContext` (`{active, enter, exit, session}`; session logic in `client/src/features/builder/hooks/useBuilderSession.ts`); the agent's own chrome (project/mission selectors, conversation) is hidden but NEVER unmounted, so queues/pinned cards survive the mode. Per UI mode: **board** — the floating `AgentChatPanel` widens and gains an attached blueprint side pane; **Agent Mode** — the mission surface hosts the builder conversation and `AgentWorkspaceSidebar` transforms into the live `BlueprintPanel` (animated swap). The four phases (chat with surprise-me, commit mini-form, streamed progress, done with Launch M1) render in-panel via `BuilderConversation` (which also drops one-click premium **decision cards**, `BuilderDecisionCard`: **Surprise me** after every settled Builder reply while the blueprint is < 5/5 dimensions, **Approve & generate specs** once it is 5/5 with no M1 spec yet; the clicked decision stays as a settled "Decision taken" card in place of the prompt bubble because the intent is persisted on the message row — `blueprint_messages.intent`, desktop-db migration 25, `POST /send { intent }`); exit is confirm-gated once the blueprint is dirty (Esc in the commit form returns to chat). The builder conversation stays on the `/api/blueprint/*` transport (`blueprint_conversations`) — never in the mission selector. The old full-screen `ProjectBuilderShell` overlay is deleted. Full as-built record: [`docs/internals/project-builder.md`](docs/internals/project-builder.md).
213
+
214
+ - **Day-0 chat**: `server/modules/builder/runtime/blueprint-chat-manager.ts` (sibling of `AgentChatManager`, reuses `runAiCliInvocation`, cwd `~/.specrails/builder-cwd/` via `builder-cwd-manager.ts`, prompts in `blueprint-operator-prompt.ts`, NO MCP). Persists to `desktop.sqlite` (`blueprint_conversations`/`blueprint_messages`, migration 22; `blueprint-store.ts`). WS: app-global `blueprint.stream|done|error` (no `projectId`, NOT mobile-translated). Turn accounting → `agent_invocations` with `project_id NULL`. REST at `/api/blueprint/*` (`blueprint-router.ts`); `/models` exposes only providers that can enforce the blueprint generator's structured/read-only boundary and a validated `reasoning_effort` rides each `/send`. Kimi may be the committed project's target provider, but day-0 blueprint generation is rejected before spawn/mutation.
215
+ - **`blueprint-draft` + rich-spec contract**: fenced FULL-snapshot JSON blocks, last-valid-wins, streaming tail cut, `blueprintVersion` gate; parser pair `server/modules/builder/runtime/blueprint-draft-parser.ts` ⇄ `client/src/features/builder/lib/blueprint-draft.ts`. **Fence tolerance:** a closed ```` ```json ````/bare fence whose body is an object with an integer `blueprintVersion` is promoted to a blueprint-draft block before parsing (`promoteJsonBlueprintFences`, never inside a proper block; ordinary json fences untouched) and an open one is cut/reported truncated — models mirrored the prompt's old ```` ```json ```` example and the snapshot silently went unseen; the example is now fenced `blueprint-draft` with an explicit rule. Each successful parse deliberately preserves TWO views: compatibility-normalized `blueprint` for preview/read and the exact `rawBlueprint` JSON for readiness + commit. Client readiness and both commit requests inspect/send the raw value, so defaults/drop rules cannot turn an invalid `kind`, `priority`, dependency, or missing field into a committable spec; `readBlueprint` separately normalizes legacy persisted JSON server-side. The version-1 snapshot carries `specsComplete`; every detailed spec carries `kind: scaffold|feature|verification`, an English action-oriented `title`, one-sentence `shortSummary` (≤240 chars), `description`, a separate **6–10-item** `acceptanceCriteria[]` (each ≥ 20 chars: happy path + failure/edge case + automated verification), `priority: low|medium|high|critical`, domain `labels[]`, and optional backward-only `dependsOnIndex`. The description has exactly these non-empty `##` sections, once and in order: `Problem Statement` (≥ 200 chars narrative), `Proposed Solution` (≥ 500 chars: numbered journey + the five `###` sub-blocks User experience · Data model · Interfaces & contracts · Planned modules · Key decisions), `Out of Scope` (≥ 3 placed bullets), `Technical Considerations` (≥ 5 labelled bullets), `Estimated Complexity` (reasoned); the description MUST NOT contain `## Acceptance Criteria` (ticket materialization folds the structured criteria once via `formatDescriptionWithCriteria`). The contract prose + premium example + `SPEC_DEPTH_FLOORS` live ONCE in `server/modules/specs/runtime/spec-contract-prompt.ts` (consumed by the Builder prompt, M2+ `_buildMilestoneSystemPrompt` and the agent super-spec section; gate-valid test fixtures in `server/modules/builder/runtime/blueprint-spec-fixtures.ts` ⇄ `client/src/lib/__tests__/premium-spec-fixture.ts`). Day-0 interview/Surprise Me proposes dimensions with `m1Specs: []` + `specsComplete: false`; explicit approval/direct backlog generation yields an **OUTLINE** snapshot (every spec titled, bodies empty) and the APP then drives **batched generation** on the same session (`server/modules/builder/runtime/blueprint-generation.ts` + the drive closure in `BlueprintChatManager`): `APP CONTINUE` detail turns of `SPECS_PER_DETAIL_TURN = 2` specs answered with fenced `spec-detail { index, spec }` patches merged by index (one re-ask per unfilled range, else the drive HALTS with `snapshot.generationHalted` and the partial snapshot persisted), then one `APP AUDIT` turn (`spec-audit { specsComplete, issues, fixes }`; issues ⇒ one corrections turn), then the normal quality repair; `MAX_GENERATION_TURNS = 8`; wire `blueprint.generating {phase,from,to,total,turn,totalTurns}` + intermediate `blueprint.done { continuing: true }` frames; `POST …/repair-snapshot` resumes a halted drive (`202 { kind: 'resume' }`, readiness **Continue generating**); while the drive runs the readiness steps read `writing` with NO audit issues listed (an outline's empty bodies are not defects) and unwritten spec cards say "writing…", never "0 acceptance criteria". Providers without `nativeResume` get `GENERATION MODE: single response` and keep the one-snapshot path. `specsComplete: true` appears only on the audited complete snapshot; the deterministic gate enforces the floors regardless. M2+ remain title-only `plannedSpecs` until explicitly generated against the real repo (single response, same contract in `verified` grounding mode). Legacy v1 blueprints default the added fields on read, but both M1 project commit and M2+ milestone commit run the shared deterministic quality gate against raw generated input before ANY mutation; invalid drafts return field/spec-specific detail.
216
+ - **Snapshot hardening (harden-project-builder-snapshots).** A rejected `blueprint-draft` block is never dropped silently any more: both parsers return `rejected[]` / `repaired` / `truncated` after a string-aware tolerant JSON repair pass (`server/json-tolerant.ts` ⇄ `client/src/lib/json-tolerant.ts`: raw newlines, inner quotes, trailing commas, comments, nested fences; a cut-off trailing fence is reported as `truncated` and CUT from the transcript). `BlueprintChatManager` runs ONE automatic repair turn on the same session (`planSnapshotRepair` → `buildSnapshotRepairPrompt(kind: invalid_json|truncated|quality)`, broadcast `blueprint.repairing`, then a single `blueprint.done` whose `snapshot` field says `accepted|rejected|none` + `repaired`/`repairAttempted`/`claimsComplete`/`qualityIssues`); the `quality` kind fires when the model claims `specsComplete: true` but the deterministic gate (`auditRawBlueprintForM1`) disagrees. Manual retry: `POST /api/blueprint/conversations/:id/repair-snapshot` (decides from persisted state). Snapshots are DURABLE (desktop-db migration 23: `blueprint_json`/`raw_blueprint_json`/`snapshot_issue_json`/`committed_project_id` on the conversation, `raw_content` on messages) so `GET /conversations?resumable=1` + `GET /conversations/:id` power "Continue where you left off" (`BuilderRecentBlueprints` under the hero composer → `session.resume`); the conversation row is created lazily on the first send (no orphan rows) and the commit links it via `conversationId`. The CTA gate is `BlueprintReadiness` (three localized steps blueprint · specs · audit, snapshot status with retry, per-spec issues via `client/src/features/builder/lib/blueprint-readiness.ts` `localizeQualityIssue` — `builder:quality.*` ×8), and `BuilderGenerationProgress` shows "Writing the Milestone-1 specs… · spec N" while a block streams. Full record: `docs/internals/project-builder.md` § Snapshot hardening.
217
+ - **Orchestrated commit** (`server/modules/builder/runtime/blueprint-commit.ts`, DI IO bag): sync validation (named errors) → 202 + `blueprint.commit_progress` steps `create-dir → git-init(+deterministic README) → assemble → blueprint → tickets → register → github(best-effort)`. **Register-project-LAST**: a mid-flight crash leaves an orphan dir, never a half-registered project. Offline assemble is the extracted `server/offline-assemble.ts` `assembleProjectOffline` (shared with `SetupManager`'s bundled-core path): it PREFERS the bundle (offline day 0) but falls back to `npx specrails-core` when no bundle is present, so `npm run dev` and runtimes-less builds work. Only a packaged **desktop** build with a missing/corrupted bundle hard-fails at validation (`canAssembleProject` gates on `SPECRAILS_IS_DESKTOP`) with the "reinstall the app" error. Blueprint pair `blueprint.json`/`blueprint.md` lives in `<workspace>/.specrails/` (repo pristine; `blueprint-render.ts`). The `github` step is gh-gated end to end: the commit form's "Create private GitHub repository" checkbox renders ONLY when gh is installed+executable (`usePrerequisites()`; installed-without-auth = visible-disabled with a `gh auth login` hint, absent = not rendered), and the server pre-flights `gh auth token` before `gh repo create` (stale-client defence). Failures stay best-effort warnings but are CLASSIFIED (`classifyGhCreateError`: `gh_not_installed|gh_not_authenticated|gh_scope|gh_repo_exists|gh_network|gh_failed`) via the additive `code` field on `blueprint.commit_progress`; the client renders the i18n message (`builder:progress.githubErrors.*` ×8, stderr tail as tooltip) + ONE non-blocking `toast.warning` per commit attempt.
218
+ - **Milestone lifecycle**: "Launch Milestone N" is **server-owned** (premium-milestone-progress): `POST /:id/blueprint/milestones/:n/launch { mode }` → `server/modules/builder/runtime/milestone-chain.ts` `MilestoneChainManager` chunks the `M<n>` todo tickets at **≤ 3 specs per rail** (rails `M<n>` / `M<n> · k`), persists ONE durable `milestone_launch_chains` row (`milestone-chain-store.ts`, migration 58, one non-terminal chain per milestone, CAS transitions) and launches each chunk through the app's OWN rails launch route over loopback (`server/internal-api.ts` — every guard applies; a 4xx becomes `pause_reason: launch_rejected:<error>`). **Sequential (default)** advances when the in-flight chunk's DELIVERY settles (tap on `rail.pr_state` in the bound broadcast — `onLoopRunFinished` fires BEFORE the row leaves `building`, so it is only the delivery-less fallback) and **STACKS** chunk k+1 on chunk k's delivered branch via the launch route's new `baseBranch` param (→ `resolveIntegrationBranch({ explicit })`, recorded as the delivery `base_branch`, PR created stacked); failure/stall/stop/refusal/missing or discarded head/lost run PAUSE it (never skip), `…/chains/:id/resume|cancel` control it — Resume RETRIES the chunk that failed (`retry_chunk`, set by chunk-failure pauses; reuses the old rail when its delivery is terminal, else a fresh one; a new chain likewise reuses a free rail already named for the chunk via `findRailByName`) and only `launch_rejected`/`head_missing` resume with the NEXT chunk, `recoverOnStartup` (after `listen`) replays a settle that happened while down exactly once; **wave checkpoints (D9)**: the row's `auto_advance` (launch body `autoAdvance`; API default on, the UI sends `localStorage['specrails-desktop:milestone-auto-advance']`, default OFF) — off ⇒ a successfully delivered chunk parks the chain at the non-terminal `awaiting_approval` (head recorded, next chunk NOT launched, healthy — distinct from `paused` whose Resume retries the SAME chunk), `resume` launches the next chunk from it, `PATCH …/chains/:id { autoAdvance }` (`setAutoAdvance`) flips the flag and launches immediately when turning it on at a checkpoint, failures still pause, startup recovery leaves checkpoints alone; surfaces: chain row "Rail k of n delivered — launch rail k+1?" + **Launch next rail** + `MilestoneAutoAdvanceToggle` (chain PATCH, also saves the preference) + Cancel, the same switch in the sidebar launch controls / Builder done screen, and a once-per-rail checkpoint toast with Launch next + Auto-continue (`useMilestoneNotifications`); **parallel** launches all chunks at once; `SPECRAILS_MILESTONE_CHAIN=false` ⇒ parallel, no row. Merging a stacked chunk sweeps its merged ancestors (`sweepMergedChainAncestors`, chain-local `merge-base --is-ancestor`), merge-local targets the CHAIN's integration branch (`mergeLocalTargetBranch`), discarding a stacked head pauses the chain (`head_discarded`) and the three decision surfaces warn first. The client `MilestoneSequencerContext` + its `localStorage` plans are GONE (`dropLegacySequentialPlans()` on load). Works when Kimi is the committed project's target engine.
219
+ - **Milestone progress (server-derived, live)**: `server/modules/builder/runtime/milestone-progress.ts` `deriveMilestoneProgress` → per milestone `{ counts: total/done/onReview/inProgress/todo/failed, rails (active runs + non-terminal deliveries, chunk-ordered), chain, state }` with `state` = `done` (every spec done) · `delivered` (nothing pending, ≥1 on_review) · `running` (in-progress or live chain) · `committed` · stored. `GET /:id/blueprint` returns `{ blueprint, progress }`; `MilestoneProgressBroadcaster` (tapped from the bound broadcast on ticket/rail/delivery/run/chain messages, 150 ms debounce, memoized "no blueprint") re-broadcasts `blueprint.milestone_progress` and persists `status:'done'` ONCE via `markMilestoneDone` (+ `blueprint.milestone_completed`). Surfaces render the DERIVED state only ("8 of 8 delivered · 0 done", never "complete" while unmerged): `useMilestoneProgress` (per-project cache + WS overlay, null-safe WS context), `MilestoneProgressCard.tsx` (segmented bar, state pill, rail rows with `PrDecisionPill` + elapsed + Review → `/review/:id`, chain row with Resume/Cancel) in the 320 px sidebar flyout and the Builder done screen (`BuilderDoneMilestone`), `useMilestoneNotifications` toasts (later chunk launched / paused + Resume / delivered + Review / complete). i18n `builder:milestoneProgress.*` ×8. The launch route enforces the same cap for every launch door (`server/modules/delivery/runtime/rails-store.ts` `MAX_TICKETS_PER_RAIL_LAUNCH`, 400 `rail_ticket_cap_exceeded`). Builder-created M1 and M2+ tickets use `source='project-builder'` + `created_by='project-builder'`, so they enter the board's spec population; the board also recognizes legacy Builder rows with `source='manual'` + `created_by='project-builder'` (no migration required). Sidebar re-entry `BuilderSidebarEntry` (board + mission sidebars; visible iff `GET /:projectId/blueprint` 200) has board-derived progress by `M<n>` label. "Generate M2+" = PROJECT-level `chat_conversations.kind='milestone'` through ChatManager (`_buildMilestoneSystemPrompt` seeded with the workspace blueprint; accounting `surface='explore-spec'`, no new surface). It is genuinely inspection-only: the prompt forbids mutation/builds/tests and names the real repo path for relocated projects; `toolPolicy='read-only'` maps Claude to plan + safe mode with only Read/Grep/Glob, Codex to its native read-only filesystem sandbox (including resume), and Gemini to `--approval-mode plan` without `--yolo`. Kimi has no enforceable restricted-tool policy, so M2+ generation rejects it before spawn or mutation. Gemini has no selectable Codex-style filesystem sandbox, so its boundary is CLI plan/policy-layer only; an incompatible plan flag fails the turn closed rather than falling back to yolo. The complete contract reaches providers without a system-prompt argument through the effective user turn. `POST /:projectId/blueprint/commit-milestone` atomically inserts the authoritative detailed `M<n>` tickets (criteria folded into description, priority/summary/domain+milestone labels and prerequisites retained), while `blueprint.json` stores only the existing milestone metadata (`status='committed'`, advisory `ticketIds`)—there is no detailed-M2-per-milestone schema. Success invalidates/refetches the client blueprint, so the sidebar advances to the first remaining `planned` milestone.
220
+ - i18n namespace `builder` ×8.
221
+
222
+ ### Artifact relocation + bundled framework
223
+
224
+ > **Pre-release / in flight.** Implemented on the UNCOMMITTED branch `feat/relocate-artifacts-to-home` (this repo + `specrails-core`). The deep design + as-built reconciliation live in three internal docs — read these before touching this surface: [`docs/internals/global-artifacts-relocation-evaluation.md`](docs/internals/global-artifacts-relocation-evaluation.md), [`docs/internals/global-artifacts-alignment-contract.md`](docs/internals/global-artifacts-alignment-contract.md) (the **As-built reconciliation** section at the top wins over the design body where they differ), [`docs/internals/bundled-framework-build-plan.md`](docs/internals/bundled-framework-build-plan.md).
225
+
226
+ **The problem solved.** Imported repos stay **PRISTINE**. Previously the setup wizard ran `npx specrails-core` with `repoRoot = project.path`, dumping `.specrails/**` + `<providerDir>/**` (`.claude`/`.codex`/`.gemini`/`.kimi-code`) into the user's repo, mutating their `.gitignore`/provider instruction files, and clobbering their own `sr-*` assets. After relocation, **zero specrails-core/desktop artifacts and zero modifications to the repo's `.claude`/`.codex`/`.gemini`/`.kimi-code` ever land in the repo.** The only repo-resident things are the two intentional carve-outs: `openspec/**` (the versioned spec deliverable, written by the external `@fission-ai/openspec` binary — mandatory, repo-relative by design) plus the openspec provider command dirs it installs, and the git worktrees (`.claude/worktrees/**`, must share the repo's `.git`/object-store for merge-back). Everything else lives under `$HOME`.
227
+
228
+ **The registry (`~/.specrails/registry.json`).** The shared, schema-versioned source of truth mapping each repo's **canonical realpath** (`fs.realpathSync`, symlinks collapsed) → its per-project workspace location. Desktop's `server/artifact-registry.ts` (`resolveArtifacts`, `removeRegistryEntry`) is the primary **writer** (a slim projection of `desktop.sqlite`, reconciled from the DB on startup); specrails-core's `src/installer/util/registry.ts` (`resolveArtifacts`, `frameworkRoot`) is the **reader** and, in standalone `npx … init`, the allocator. `slug` + `workspaceDir` are **immutable once allocated** for a repo (`source: "desktop" | "core-standalone"` codes single-owner-at-a-time; the registry slug is authoritative for artifact location even when it diverges from the `desktop.sqlite` slug for a standalone-then-imported repo). Atomic temp+rename writes under an advisory `registry.json.lock`; readers never take the lock. **Test safety:** `resolveHome()` honours `SPECRAILS_REGISTRY_HOME` (mirrored in both repos) and a `vitest-setup.ts` `setupFiles` pins it to a tmp dir, so no test ever writes the real `~/.specrails/registry.json` (a 115-entry leak was caught and fixed this way).
229
+
230
+ **Per-project workspace.** `~/.specrails/projects/<slug>/workspace` is `artifactRoot` and the **spawn cwd** for relocated jobs plus MCP-enabled Explore/Contract Refine processes (`server/workspace-manager.ts` `ensureWorkspace`, generalizing the explore-cwd pattern); MCP-disabled Explore retains its app-managed `explore-cwd`. It holds `.specrails/**`, the provider dirs, instruction files, and `.mcp.json`; the repo is reached via a `./project` symlink (junction on Windows, `project-path.txt` fallback). The repo is passed as `SPECRAILS_REPO_DIR=<project.path>`. Core templates use a `${SPECRAILS_REPO_DIR:-.}` runtime indirection for repo-relative I/O (openspec reads, the user's layer `CLAUDE.md`, git/worktree ops) so a shared framework file re-points its I/O to the real repo at runtime — and **default-unset ⇒ byte-identical legacy behaviour**. The same `${ENV:-legacy}` pattern backs `SPECRAILS_TICKETS_PATH`, `SPECRAILS_BACKLOG_CONFIG_PATH`, `SPECRAILS_PROFILES_DIR`, `SPECRAILS_STATE_DIR`, `SPECRAILS_WORKSPACE_DIR`.
231
+
232
+ **The two-part activation gate.** `server/workspace-resolution.ts` `resolveProjectExecution` classifies a project as **relocated** only when BOTH a registry entry exists AND the workspace is populated (`<workspace>/.specrails/specrails-version` present); otherwise it falls back to **legacy** (cwd = `project.path`, byte-identical). This makes activation regression-safe — only genuinely relocated projects change behaviour. **The provenance/git split:** `QueueManager` resolves a per-job `ProjectExecution` (`this._resolveExecution()`) exposing `execution.cwd` (the spawn cwd — workspace when relocated) and `execution.repoDir` (= `project.path`); the file-provenance / git snapshot calls use the repo dir (`snapshotWorkingTree(execution.repoDir)`; a local `provenanceRepoDir = jobExecution?.repoDir ?? this._cwd` feeds `diffAgainstSnapshot`/`collectDiffPatches`), NOT the workspace — pointing them at the empty workspace would silently break the Code explorer's "touched by AI" attribution. `TerminalManager` and `file-provenance.ts` always keep `cwd = project.path`.
233
+
234
+ **The bundled framework.** The provider-invariant framework (the `sr-*` agent definitions, commands, skills, rules, instruction files) is materialized **ONCE** into `~/.specrails/framework/<version>/<provider>/` — bundled inside the `.dmg`/`.exe` like the Node/git runtimes (existence-gated; falls back to npx when absent). Each workspace **SYMLINKS** the static subtrees from `~/.specrails/framework/current/<provider>/`: agents are linked **per-file** so plugin/user `custom-*.md` agents coexist; `commands`/`skills`/`rules` are normally dir-symlinked; `agent-memory/` is a **real writable dir** (never linked — it's per-workspace state); copy fallback on Windows where symlinks fail (then framework update becomes O(projects) instead of O(1)). Kimi is the deliberate skills exception: `.kimi-code/skills/` stays real and each framework-owned direct child is linked independently, so top-level `sr-*`, `specrails-*`, `openspec-*`, and user `custom-*` skills coexist and remain discoverable by Kimi's one-level stable scanner. **Windows per-spawn self-heal (`server/workspace-manager.ts`):** the `current` JUNCTION is frequently untraversable by the packaged sidecar, leaving the workspace's linked subtrees unreadable — so before every relocated rail/loop spawn (`queue-manager.ts`, `loop-executors.ts`) two repairs run, reading straight from the REAL versioned dir (never through `current`): `ensureFrameworkAgents` refills the per-file-linked `agents/` (skips `custom-*.md`), and `ensureFrameworkCommandSubtrees` replaces an unreadable/missing/empty `commands|skills|rules` dir-link with a REAL recursive copy (else the CLI reports `Unknown command: /specrails:implement`). Both are win32-only, idempotent, best-effort, and only materialize when the dest can't be listed — a populated dir/working link is left untouched, so a live symlink's target is never deleted through and isolated-worktree overlays (already manifest-excluded from commits) stay no-op. Desktop: `server/framework-manager.ts` (`FrameworkManager.materialize` / `swapCurrent` / `versionCheck` — shells out to core's offline `install-framework` / `assemble` subcommands) + `server/bundled-core.ts` (`getBundledCoreRoot`/`Cli`/`Version`, `hasBundledCore`) + `server/framework-migration.ts` (`migrateWorkspaceToSymlinks`: detect copy → backup → re-link → verify, non-destructive, skips local divergence). Core: `installFramework` / `ensureCurrentSymlink` / `assembleProjectWorkspace` in `src/installer/phases/scaffold.ts`, driven by the offline `install-framework` + `assemble` CLI subcommands (`src/installer/commands/framework.ts`, `src/installer/cli.ts`).
235
+
236
+ **Update channel.** specrails-core updates ride the **app** update — no per-project `npx`. On first-run / post-update, `FrameworkManager.versionCheck()` materializes the new `<version>/` next to the old one, then issues a single atomic `current` swap (`swapCurrent`, under the registry lock so an in-flight rail's resolution completes first); every workspace that links `current/...` is updated at once (O(1) on POSIX). A rail that already spawned keeps its resolved version. A non-intrusive `framework.updated` app-level WS broadcast (no `projectId`) surfaces the version bump.
237
+
238
+ **Offline setup.** Project-add is fully offline: the bundled core (no `npx specrails-core init`) assembles the workspace, and `@fission-ai/openspec` is pinned to **1.4.1** and bundled (`server/bundled-openspec.ts`; `OPENSPEC_NPX_SPEC` in `server/openspec-shim.ts`), so the last network call is removed. `server/setup-manager.ts` no longer spawns a per-project `npx` when the bundle is present — npx remains only the existence-gated fallback.
239
+
240
+ **Definitions vs config.** Agent **definitions** (`sr-*.md`) are framework — shared/symlinked. Agent **config** (per-agent models + routing = *profiles*) is per-project, lives in `<workspace>/.specrails/profiles/`, and is selectable per rail; a default `balanced`-preset profile is seeded. `agent-memory/` is always a real writable per-workspace dir; instruction files (`CLAUDE.md`/`AGENTS.md`/`GEMINI.md`) are seeded per-workspace (they carry the project name), while provider-invariant settings are linked.
241
+
242
+ **Codex caveat (as-built delta):** rails do NOT override `CODEX_HOME` (the PoC showed it's all-or-nothing and pulls in `auth.json` → 401); codex rails use cwd-discovery like claude. The per-project `CODEX_HOME` survives only for the plugin MCP registration (`codex mcp add`).
243
+
244
+ ### Agent profiles (Agents section)
245
+
246
+ Per-project catalog of agent profiles — declarative JSON that tells the implement pipeline which agents to run, which models to use per agent, and how to route tasks. Profiles are selected per rail at launch time (snapshot-per-job) so concurrent rails in the same batch can run distinct profiles. Requires `specrails-core >= 4.1.0` in the project; otherwise the app gracefully falls back to legacy behavior (no env injection).
247
+
248
+ **Server (`server/modules/agents/runtime/profile-manager.ts`)**: CRUD over `<project>/.specrails/profiles/*.json` with `ajv` v1 schema validation (`server/schemas/profile.v1.json`, a desktop-owned **derivative** of the specrails-core schema — it adds the optional `provider` field and relaxes `modelAlias` to defer to provider catalogs, so it carries its own `$id`; per core's schema-identity contract only byte-identical copies may share core's canonical `$id`). Structural checks beyond JSON Schema: exactly one terminal `default: true` routing rule and it must be last; baseline trio (`sr-architect`, `sr-developer`, `sr-reviewer`) must be present in `agents[]`. Resolution order at launch: explicit selection → `.user-preferred.json` (gitignored) → `default` profile. `snapshotForJob` writes the resolved profile to `~/.specrails/projects/<slug>/jobs/<jobId>/profile.json` (chmod 400) before spawn. `persistJobProfile` inserts into `job_profiles` for analytics.
249
+
250
+ **REST surface (`server/modules/agents/runtime/profiles-router.ts`)** mounted at `/api/projects/:projectId/profiles`, gated by `SPECRAILS_AGENTS_SECTION !== 'false'`: list/get/create/update/delete/duplicate/rename profiles; `/active` for the per-developer preference; `/resolve?profile=…` to preview resolution; `/catalog` and `/catalog/:agentId` for the agents catalog viewer; `/core-version` for the upgrade banner; `/analytics?windowDays=30` for per-profile metrics; `/migrate-from-settings` to seed `default.json` from existing frontmatter models (on claude it ALSO best-effort-seeds a companion `fast` profile — haiku architect/reviewer, developer model unchanged — skipped silently when `fast` exists; response carries `fastProfile` when created).
251
+
252
+ **QueueManager integration (`server/modules/execution/runtime/queue-manager.ts`)**: `EnqueueOptions` accepts `profileName` (string = explicit, null = force legacy, undefined = default resolution). At spawn time `projectSupportsProfiles(cwd)` checks `.specrails/specrails-version` (gate: `>= 4.1.0`); when allowed, profile resolved + snapshotted + persisted + env var `SPECRAILS_PROFILE_PATH` injected. OTEL resource attrs include `specrails.profile_name` and `specrails.profile_schema_version` when profile mode is active.
253
+
254
+ **Client (`client/src/features/agents/pages/AgentsPage.tsx`)**: `/agents` route under ProjectLayout, reached from the right sidebar. Two tabs: **Profiles** (full CRUD + agent chain editor with catalog picker + routing rules editor + per-profile analytics card) and **Agents Catalog** (read-only viewer of upstream and custom agents). Yellow banner at the top when the project's core version is below 4.1.0. Profile selection at launch happens in the rail header via `RailProfileSelector` and is sent as `profileName` on the rails launch API (`POST /rails/:i/launch`, or `PUT /rails/:i/profile` to persist). NOTE: the standalone `ImplementWizard`/`BatchImplementWizard` dialogs from the original profiles PR (#246) were superseded by the rail-header flow and have been deleted (along with their `IssuePickerStep`/`ProfilePicker` helpers).
255
+
256
+ **Reserved paths (contract with specrails-core)**: `.specrails/profiles/**` and `.claude/agents/custom-*.md` are never touched by specrails-core's `init` / `update` commands (Node-native from v4.2.0, bash `install.sh`/`update.sh` before). Profiles are committable team assets; `.user-preferred.json` inside `.specrails/profiles/` is auto-gitignored on first write. **(Under artifact relocation these reserved paths keep their semantics but resolve relative to `artifactRoot` = the workspace, NOT the repo — so profiles/`custom-*.md`/`.mcp.json` move to `$HOME` and lose the "committable team asset" property; the git-export affordance is deferred. See Artifact relocation.)**
257
+
258
+ ### Global Specrails Agents defaults (Settings ▸ AI providers (formerly Specrails Agents))
259
+
260
+ App-level (per-machine) customization of the specrails-core pipeline agents per AI provider: **pipeline model + reasoning effort** for every provider, plus **per-agent model overrides** (baseline trio) for profile-capable providers (claude, kimi). Stored as ONE JSON blob in `desktop_settings['specrails_agent_defaults']` (plain k/v — no migration). Every consumer reads **at spawn/launch time**, so a change applies to the next run with zero restart.
261
+
262
+ **Layering contract (least → most specific):** built-in adapter default < GLOBAL agent defaults < per-project setting (`orchestratorModel` — only when explicitly stored; `getProjectSettings` now exposes `orchestratorModelExplicit`, resolved model) < per-project profile < explicit per-launch selection. The global layer only fills gaps — it never overrides a closer choice, and a stale stored model/effort degrades to "no override" (fail-open) at resolve time.
263
+
264
+ - **Module `server/modules/agents/runtime/agent-defaults.ts`**: `readAgentDefaultsSettings` / `applyAgentDefaultsPatch` (PATCH replaces per-provider entries wholesale; typed `AgentDefaultsValidationError` codes), `resolveAgentDefaults` (adapter-validated view or null), `mergeProfileWithAgentDefaults` (fills ONLY agents without an explicit model), `synthesizeProfileFromDefaults` (baseline-trio profile, name `global-defaults`), `ensureGlobalProfileSnapshot` (content-addressed immutable snapshot under `~/.specrails/agent-defaults/` — in-flight runs keep the file they resolved), `createLoopProfilePathResolver`, `buildAgentDefaultsCatalog`.
265
+ - **REST**: `GET/PATCH /api/agent-defaults` (desktop-router) → `{ settings, catalog }`; catalog covers every REGISTERED provider (models, `effortsByModel` — kimi tiers are model-scoped, `baselineAgents`, `perAgentModels` = `capabilities.profiles`, `supportsEffort`).
266
+ - **Seams (all read-at-spawn)**: `QueueManager` (`options.agentDefaults` closure wired in project-registry): model fallback chain + `reasoning_effort` on `railSpawnOptions`/interactive spawn options (rails previously had no effort knob — purely additive; claude `--effort` rides commonFlags, codex `-c model_reasoning_effort`, kimi env via `buildProviderEnv`, gemini none) + profile merge/synthesize before `snapshotForJob`. `rails-router` launch + `project-router-loop-runs` standalone door: `body.model ?? global.pipelineModel ?? adapter.defaultModel()` and effort fallback when the body carries none (dashboard factory launches send neither → the global layer genuinely applies). Loop ai-steps: `createLoopExecutors({ profilePathFor })` injects `SPECRAILS_PROFILE_PATH` per step — **inert unless the global layer carries per-agent models AND they add something the project profile didn't pin** (byte-identical legacy otherwise; deciders excluded).
267
+ - **Honest capability matrix**: claude = pipeline model+effort+per-agent models; kimi = same (effort K3-only); codex = pipeline model+effort, sub-agents inherit by design (UI shows the inherit note); gemini = pipeline model only.
268
+ - **Client**: `client/src/components/settings/SpecrailsAgentsSection.tsx` (GlobalSettings nav id `specrailsAgents`, icon `BrainCircuit`) — one card per provider (undetected → non-clickable + "Not detected" pill; unauthenticated → amber pill via `useProviderDetection`), Default|Custom segmented toggle, `AgentToolbarSelector` model/effort selectors (the agent-composer look), per-agent rows with reset. Optimistic PATCH + revert/toast. i18n `settings.json` `specrailsAgents.*` ×8.
269
+ - **Deferred**: surfacing the effective global default inside the rail header selectors' "default" label; per-agent EFFORT (needs a profile-schema change in desktop + core).
270
+
271
+ ### Terminal panel
272
+
273
+ Per-project bottom terminal panel (VSCode/Cursor style). Both gates default **on** (opt-out): client `VITE_FEATURE_TERMINAL_PANEL !== 'false'` (`client/src/lib/feature-flags.ts`), server `SPECRAILS_TERMINAL_PANEL !== 'false'`. Set either to `false` to disable.
274
+
275
+ **Server (`server/modules/terminals/runtime/terminal-manager.ts`)**: singleton `TerminalManager` owns all PTY sessions via `node-pty`. Each session keeps a 256 KB ring buffer of raw output, a set of attached WebSocket clients, and a stored `projectId`. Shells are spawned with `$SHELL -l -i` on POSIX (so `.zshrc` / `.bashrc` load) or `powershell.exe -NoLogo` on Windows, with `TERM=xterm-256color` + `COLORTERM=truecolor` and `cwd = project.path`. Hard cap of 10 sessions per project. REST endpoints under `/api/projects/:projectId/terminals` (GET list, POST create, PATCH rename, DELETE kill). PTY streaming uses a dedicated WebSocket `/ws/terminal/:id?token=...&projectId=...` — NOT the shared `/ws` — so terminal throughput cannot starve the project event stream. Attach protocol: `<scrollback binary>` → `{type:"ready",cols,rows}` JSON → live binary frames. Project removal via `ProjectRegistry.removeProject` calls `killAllForProject(id)`; graceful shutdown (SIGTERM/SIGINT) runs `terminalManager.shutdown()` (SIGTERM, 2s grace, SIGKILL).
276
+
277
+ **Client (`client/src/features/terminals/context/TerminalsContext.tsx`)**: per-project state (`visibility: hidden|restored|maximized`, `userHeight`, `sessions`, `activeId`) lives in a single provider mounted above the route outlet so it survives project switches. Key invariant: xterm.js `Terminal` instances are created once per session and NEVER unmounted until kill — each session's container div is attached to a hidden `<div id="specrails-terminal-host">` appended to `document.body`. The `TerminalViewport` component `appendChild`s the active session's container into its own subtree on mount and moves it back on unmount (survives StrictMode double-invoke). Panel visibility + `userHeight` persisted per-project to `localStorage` under `specrails-desktop:terminal-panel:<projectId>`. `Cmd+J` / `Ctrl+J` toggles the panel (guarded against `[role="dialog"]`) and focuses the active xterm on open. Minimize (panel chevron + StatusBar chevron at pixel-identical offset, both using `PanelChevronButton`) does NOT kill PTYs; close (trash icon + per-terminal `✕`) kills directly with no confirmation. On Windows, `killTerminalTree` (`terminal-manager.ts`) kills the shell's whole process tree through tree-kill (`taskkill /T /F`) before node-pty closes the ConPTY: node-pty enumerates the console's processes with a helper forked from `process.execPath`, which in the packaged sidecar is the sidecar binary, so without it only the shell died and anything it started (a dev server, a watcher) outlived the closed terminal.
278
+
279
+ **Desktop packaging (`scripts/build-sidecar.mjs`)**: `node-pty` is marked external in esbuild, and its full package directory is copied to `src-tauri/binaries/node-pty/` (so `spawn-helper` resolves on real filesystem instead of inside the pkg snapshot). The prebuilt `pty.node` is also copied to `src-tauri/binaries/pty.node` as a `dlopen` target. The `Module._resolveFilename` / `_load` and `process.dlopen` patches at the top of `server/index.ts` redirect `require('node-pty')` to a `createRequire`-based loader anchored at the externally extracted path. The shell-integration shims (`server/shell-integration/{zsh,bash,fish,powershell}-shim.*`) are also copied to `src-tauri/binaries/shell-integration/` so the runtime resolver in `server/terminal-shell-integration.ts` can locate them via `path.resolve(process.execPath, '..', 'shell-integration', name)`. `APPLE_SIGNING_IDENTITY` triggers codesigning of `pty.node` + `spawn-helper` (hardened runtime + entitlements for the helper) for notarization.
280
+
281
+ **Premium-panel features (post `add-premium-terminal-panel`)**: the panel layers WebGL rendering (with canvas fallback on context loss), Unicode 11 widths, ligatures, scrollback search (Cmd+F), font zoom (Cmd+= / -/0), Cmd+C/V/K clipboard keybindings, right-click context menu, drag-drop file path injection (Tauri only, POSIX/Windows shell-quoted), trailing-debounced resize + sidebar transitionend hook for jitter-free animation, and a shell-integration layer based on OSC 133 / OSC 1337 marks. The `TerminalManager` injects per-shell shims (`ZDOTDIR` for zsh, `--rcfile` for bash, `XDG_CONFIG_HOME` for fish, `-NoLogo -NoExit -File` for PowerShell) chmod-600 under `~/.specrails/projects/<slug>/terminals/<sessionId>/`, parses inbound OSC streams server-side via `OscParser`, broadcasts JSON `{type:"mark",kind,...}` control frames on the existing `/ws/terminal/:id` socket, persists completed commands to `terminal_command_marks` (FIFO-capped at 1000 per session), and cleans up shim dirs on session kill plus a 24h-stale sweep at startup. Settings live in `desktop_settings` (key/value, app-wide) and `terminal_settings_override` per-project; resolution order is project override → app default → built-in. REST: `GET/PATCH /api/terminal-settings`, `GET/PATCH /api/projects/:projectId/terminal-settings`, `GET /api/projects/:projectId/terminals/:id/marks`. Inline images via `@xterm/addon-image` (Sixel + iTerm2 protocol) and long-running command notifications via the Tauri notification plugin (with browser HTML5 `Notification` fallback) round out the differentiator surface. Disabled-by-default behaviours degrade silently when integrations fail (sentinel-not-seen toast informs the user).
282
+
283
+ ### Multi-provider architecture
284
+
285
+ The app supports Claude Code, Codex CLI, Gemini CLI, and Kimi Code as first-class providers via a `ProviderAdapter` contract. Every manager that spawns an AI CLI (`ChatManager`, `QueueManager`, `AgentRefineManager`, `SetupManager`, `project-router /tickets/generate-spec`) consumes the adapter and capability gates — never assumes that registry membership implies every product surface is safe.
286
+
287
+ The contract lives at `server/providers/types.ts`. Adapter implementations: `server/providers/claude-adapter.ts` (full native support — `nativeResume`, `nativeStreamJson`, `nativeCostUsd`, `nativeOtelEnv`, `systemPromptArg` all true), `server/providers/codex-adapter.ts` (codex 0.128.0+ with `nativeCostUsd: false` and `nativeOtelEnv: false`), `server/providers/gemini-adapter.ts` (gemini 0.11.0+ with `nativeCostUsd: false` but `nativeOtelEnv: true` — native OTLP, no synthetic bridge; `systemPromptArg: false` so it uses `GEMINI.md`; uniquely implements the optional `prepareHeadlessSpawn()` hook to pre-acknowledge project subagents for headless `gemini -p` rail spawns), and `server/providers/kimi-adapter.ts` (Kimi Code 0.27.0+, external `kimi -p` stream-JSON CLI; resumable via `-S`, no persistent stdin, token/USD usage, native OTEL, or enforceable restricted-tool policy; profiles/custom roles/Freestyle supported, K3-only `low|high|max` effort). `ProviderId` is `string` (registry-driven, no union); managers resolve via `getAdapter(project.provider)`. **Claude alias pinning:** the catalog values stay the short aliases (`sonnet`/`opus`/`fable`/`haiku`) everywhere they are stored, validated, grouped in analytics or displayed, but `buildArgs` expands them through `resolveSpawnModel` before pushing `--model`, and `PINNED_ALIAS_MODEL_IDS` pins `opus` → `claude-opus-5` — so "Claude Opus" in any selector is Opus 5 instead of whatever generation the CLI's bare `opus` alias currently resolves to. `normaliseModel` recognises `claude-opus-5`, so the round trip id→alias→id is stable. The registry at `server/providers/registry.ts` is populated by `server/providers/index.ts` at module load.
288
+
289
+ Key cross-cutting modules: `server/modules/accounting/runtime/pricing.ts` (rate-card fallback for providers without native cost), `server/modules/accounting/runtime/codex-otel-bridge.ts` (synthetic OTEL spans/metrics/logs for non-native-OTEL providers — feeds the same OTLP receiver claude does), `server/modules/accounting/runtime/result-event.ts` `finaliseInvocationResult` (one entry point per close handler combining `adapter.extractResult` + pricing fallback). Plugins are provider-aware too: `server/plugins/codex-mcp.ts` for the `codex mcp add/remove/list` integration with per-project `CODEX_HOME=~/.specrails/projects/<slug>/codex-home/`; contributors target `adapter.instructionsFilename` (CLAUDE.md vs AGENTS.md) instead of hardcoding.
290
+
291
+ User-facing docs: `docs/codex.md`, `docs/gemini.md`, `docs/kimi.md`. Developer guide for adding a provider: `docs/internals/adding-a-provider.md`.
292
+
293
+ Emergency rollback for the codex path: `SPECRAILS_CODEX_BETA=0` in the app env (the legacy `SPECRAILS_HUB_CODEX_BETA` is still read as a fallback when the new var is unset). Default unset / `1` means codex is enabled. The Gemini path has the same shape: `SPECRAILS_GEMINI_BETA=0` disables Gemini (only the exact string `0`; no legacy fallback name; default unset = enabled). Both adapters stay registered even when disabled — only project *selection* is gated. Kimi has no beta switch: selection requires a bounded readiness/auth/version probe and a Core build advertising the Kimi target.
294
+
295
+ ### Multi-provider per project (detection-driven)
296
+
297
+ Provider availability is a **machine property** (see the auto-detection section above): every project offers every detected, non-vetoed provider — nothing is selected at creation. The data model keeps the existing `provider` column as the **primary/default** and the `providers` JSON-array column (`projects.providers`, desktop-db migration 10; self-healed by migration 11) for wire compat, but on READ `mapProjectRow` mirrors `providers` to the detected set and derives `provider` (stored-while-detected → claude → preference order) whenever the detection supplier is wired (`setProjectProvidersMirror`; null supplier = legacy row behaviour, byte-identical — how unit tests run). **Invariant: when exactly one provider is detected every surface behaves byte-identically to a single-provider project** — selectors don't render, no provider is persisted, no override is sent.
298
+
299
+ **Per-invocation provider (late binding).** Managers keep their primary adapter and resolve a per-call adapter via `getAdapter(requested)` with fallback to primary: `QueueManager._resolveJobAdapter(jobId)` (per-job, from `EnqueueOptions.provider`, consumed once from `_jobProviderSelection`; threaded through the entire `_startJob` + `_onJobExit(…, adapter)` so binary/argv/model/profile/OTEL/plugins/`parseStreamLine`/`finaliseInvocationResult`/`ai_invocations.provider` all use it), and `ChatManager._adapterForConversation(conversation)` (from `chat_conversations.provider`, db migration 24/self-healed by 26). The shared resolver/validator lives in `server/provider-selection.ts` (`resolveProvider`, `validateRequestedProvider`, `isProviderEnabled`, `isMultiProvider` — all tolerant of a row missing `providers`).
300
+
301
+ **Routes** accept an optional `aiEngine` (alias `provider`) validated against the project's installed providers (`POST /spawn`, `POST /chat/conversations`, `POST /tickets/generate-spec`, rails `POST /:i/launch` + `PUT /:i/engine`); `GET /default-spec-model?provider=` returns that engine's catalog + the `providers` list. `POST /api/projects` accepts `providers: string[]` (deduped, first = primary; legacy single `provider` still honoured). `chat_conversations.provider` is persisted only on multi-provider projects (else NULL = legacy). Rails store an `ai_engine` per rail (`rails.ai_engine`, db migration 25); profiles are validated against the effective adapter (Claude and Kimi support provider-scoped profiles; Codex/Gemini force legacy). Kimi is accepted on agentic Project/Agent Chat, Explore/proposal, Quick Launcher `/opsx:ff`, Implement/Batch/Freestyle rails, and compatible loops; safety-sensitive pure-output/read-only actions (Quick Spec, AI Edit, Contract Refine, SMASH/Re-SMASH, Project Builder generation, Loop Decider, file summary/story AI, Agent Studio Generate/Test/Refine) fail before spawn or mutation. Auto-title uses a deterministic fallback. Freestyle is advertised by Claude and Kimi.
302
+
303
+ **Capability UNION.** With >1 detected provider the right sidebar shows sections **at least one** detected provider supports — `client/src/features/providers/lib/provider-capabilities.ts` `sectionVisibleForProviders(section, providers)` (`some`, flipped from the old intersection so installing a weaker provider never hides sections a capable one backs); `providersSupportingSection` returns the capable subset for the engine-scoped affordances inside a visible section. Kimi's manual role/catalog/profile path remains available while its Agent Studio AI automation is gated off. **Integrations is provider-agnostic** — it hosts entries such as Jira, while plugin manifests filter provider-specific entries. Serena currently supports Claude (`.mcp.json`), Codex (`codex mcp add` with isolated `CODEX_HOME`), and Kimi (`.kimi-code/mcp.json`), not Gemini. Add Spec hides SMASH/Contract Layer for providers without structured-action capability, including Kimi. Single-detected-provider machines are unaffected.
304
+
305
+ **Client UI (all gated on the detected set's size > 1).** `AddProjectDialog` has NO provider selection (path + prereqs only — silent add). `AiEngineSelector` (Add Spec, with the "Not signed in" auth badge from `useProviderDetection`), `RailEngineSelector` (rail header), and the terminal `CliLaunchMenu` (the "Open AI CLI" Sparkles button opens a provider picker) let the user pick per-invocation. `AnalyticsPage` adds engine filter chips (`?provider=` → `spending.ts` `buildWhere` `COALESCE(provider,'claude') IN (…)`). The selected engine is remembered per project via `client/src/features/providers/lib/last-engine.ts` (default = primary). There is no per-project provider mutation surface — the detected set IS the project's provider set, live.
306
+
307
+ ### Local AI engines (OpenAI-compatible endpoints)
308
+
309
+ > OpenSpec change `local-ai-engines`. User doc `docs/local-providers.md`; runner contract `docs/internals/local-agent-runner.md`; in-app guide `docs/guide/<lang>/integrations/7-local-engines.md` ×8.
310
+
311
+ Every `openai-compatible` connection in `~/.specrails/runtime-providers.json` (`server/modules/agent-runtime/runtime/agent-runtime-settings.ts`; previously usable ONLY as a per-role provider of core's programmatic runtime) is now a **first-class, selectable AI engine** on every provider surface — rail header, Add Spec Quick/Explore, sidebar chat, agent missions — with zero selector special-casing: `server/providers/local-adapter.ts` `createLocalAdapter(connection)` builds one `ProviderAdapter` per connection (`id` = the connection id, `displayName` = `label ?? id`, `instructionsFilename` `AGENTS.md`, `projectDirName` `.specrails-local`), and `server/providers/local-adapter-registry.ts` `syncLocalAdapters(connections)` registers/unregisters them at boot and after every `PUT /api/runtime-providers` (`registry.unregisterAdapter` never removes a CLI adapter; in-flight jobs keep the instance they resolved). A connection id colliding with a CLI adapter id is rejected by validation.
312
+
313
+ - **Execution = the bundled local agent runner** (`local-runner/` → `scripts/build-local-runner.mjs` → `src-tauri/binaries/specrails-local-runner.js`, run by the bundled Node exactly like `specrails-mcp`). Desktop spawns it like a CLI (`[<script>, -p …, --model, --base-url, --api-key-env, --resume, --tools <csv|__none__>, --disallowedTools, --max-turns, --mcp-config, --add-dir, --output-format stream-json, --input-format stream-json]`); it performs the streaming chat-completions tool loop (`Read/Grep/Glob/Bash/Write/Edit`, realpath-confined to cwd + `--add-dir`), persists resumable sessions under `~/.specrails/local-runner/sessions/`, hosts an MCP stdio client (`mcp__<server>__<tool>`) for missions, and emits **claude-shaped stream-json** (`system/init`, `assistant` text/tool_use + usage, `user` tool_result, one terminal `result`, never `total_cost_usd`). The adapter owns its OWN `parseStreamLine`/`extractResult` (a copy of the claude frame mapping WITHOUT the notification-frame/background-task heuristics) so a claude parser change can never silently break local engines. Result: `runAiCliInvocation`, `InteractiveJobSession`, `ExploreStdinSessions`, kill/zombie/timeout handling, provenance and `ai_invocations` are reused byte-identically — no manager branches on the provider id.
314
+ - **Detection = bounded `GET <baseUrl>/models`** (`server/local-engine-detection.ts` `probeConnection`: 3000 ms, no redirects, Bearer from the `apiKeyEnv` variable; 2xx ⇒ `authenticated` + discovered models, 401/403 ⇒ `unauthenticated` (still listed, "not signed in" badge), else unreachable ⇒ excluded) inside the same 60 s `provider-detection.ts` cycle as the CLI probes; rows carry `kind:'local'` + `models`. `modelCatalog()` reads the probe cache (fallback `[{ value: defaultModel ?? 'default' }]` so selectors never render empty; `customModelAliases: true` keeps off-catalog ids valid). `validateRequestedProvider` accepts DETECTED local ids; primary derivation never prefers a local id over a detected CLI.
315
+ - **Rails:** a local engine maps the launch to `runtimeProviderOverride { provider: <id>, model }` (effort dropped) so core's existing `OpenAICompatibleExecutor` runs architect/developer/reviewer on the connection; every non-core ai-step (freestyle, verify/fix/decider, custom-loop steps, legacy QueueManager jobs) spawns the runner through the adapter (deciders `--tools __none__`). No profile env, no OTEL, no plugins snapshot, no `ensureClaudeTrusted` — all capability-gated. `maxCostUsd` caps are not offered.
316
+ - **Chat/Explore/Quick/Missions:** `toolFlagsForScope` has a provider-aware branch (local ⇒ `--tools …`/`--disallowedTools …` only); `buildUserMcpArgs` ⇒ `[]`; `prepareAgentMcp` `local` branch writes the SAME per-conversation mcp-config file the claude branch writes (bridge + enabled external servers) and returns `--mcp-config <file>`.
317
+ - **Honest capabilities:** `persistentStdin`, `nativeResume`, `nativeStreamJson`, `freestyle`, `customModelAliases`, `toolPolicies ['none','read-only']`, `reportsUsage` true; `nativeCostUsd`, `nativeOtelEnv`, `structuredActions`, `profiles`, `customRoles`, `userMcp`, `supportsImageInput` false; `supportsReasoningEffort` only when the connection sets it (forwarded as OpenAI `reasoning_effort`). SMASH/Contract Layer/Agent Studio/profiles gate themselves off through the existing checks (the Project Builder's pure-output policy IS satisfied by a local engine, so it stays selectable there). **Local-only machines:** app-level conversations default to the machine primary, not a hardcoded claude — `server/provider-selection.ts` `defaultMachineProvider()` (the `derivePrimaryProvider` order over the detected set; `claude` only while no snapshot exists) feeds `POST /api/agent/conversations`, `GET /api/agent/models` and the blueprint router; on the client `preferredProvider(usable)` (`lib/provider-capabilities.ts`) reconciles the mission DRAFT provider in `AgentChatContext` (never a stored conversation, and never after the user picked one — `draftProviderChosenRef` freezes the reconciliation for that draft, because a local engine's usable set comes from a bounded HTTP probe and one slow answer used to snap the draft back to the machine default, which read as "the option will not click"; a fresh mission clears the flag). `useAvailableProviders` (the non-WS catalog every composer selector reads) refetches on window focus (30 s throttle) and on the `providers.detected_changed` broadcast — bridged from `useProviderDetection` as the DOM event `specrails:providers-detected-changed` — so the list and the default never disagree about what the machine can run and the Builder's draft in `useBuilderSession`. `GET /api/available-providers` reports local ids from the detection PROBE (`getDetectionSnapshot().providers[id].usable`), never from `which <node>` — an unreachable endpoint is unavailable. Composer/Builder provider options label local ids with the detection `displayName`.
318
+ - **Accounting:** `provider = <connection id>`, raw model id, real `usage` tokens; `total_cost_usd` is `NULL` ("cost unknown (local engine)", never `$0`) unless the connection carries `rates { inputPer1M, outputPer1M }` ⇒ cost computed + `total_cost_usd_estimated = 1`. Never a rate-card guess.
319
+ - **Settings ▸ Specrails Agents ▸ Provider connections** is now the premium `ProviderConnectionsCard` (`client/src/components/settings/provider-connections/`): CLI rows read-only with detection status; local rows editable (id, label, base URL, API-key env name with an "not set in the app process" hint from `apiKeyEnvMissing`, discovered models, default model, optional rates, reasoning-effort switch), per-row **Test connection** (`POST /api/runtime-providers/test` with the DRAFT, no save), status pill (probing/reachable/not authorized/unreachable/never tested). `GET/PUT /api/runtime-providers` return `{ providers, status }`; a save re-syncs adapters, re-probes and broadcasts `providers.detected_changed` so open selectors update live. MCP: `specrails_settings` `runtime_providers.list|test|save`. `runtime-providers.json` local entries gained ADDITIVE `label`, `defaultModel`, `rates`, `supportsReasoningEffort` (legacy files load unchanged; the desktop-only fields are stripped before parity/handoff to core).
320
+ - **Small-model tolerances (LOCAL ENGINES ONLY — CLI providers stay byte-identical):** (a) the runner never sends `content: null` on tool-call assistant messages (llama.cpp rejects it with `invalid message content type: <nil>`) and, when a model closes a tool turn with an empty final message, nudges ONCE for the user-facing reply before settling; (b) `client/src/features/missions/components/agent-fence-promotion.ts` ⇄ `server/modules/missions/runtime/agent-fence-promotion.ts` (kept in sync) re-tag a CLOSED generic ```` ```json ````/bare fence whose body is unambiguously an `options` array, a `problem-frame` object or a `spec-draft` object — applied by `AgentMessage` only when `tolerantFences` (the view passes `isLocalEngineId(active.provider)`) and by `hasValidProblemFrame(…, { tolerantFences })`, which `checkSpecFraming` derives from the conversation's provider via `isLocalAdapterId`, so a 9B-class model's framing card renders and `commit_draft` stops refusing; (c) the per-turn context prefix (USER turn, never the byte-stable system prompt) carries a protocol reminder for local ids (exact fence tags, never ask the user to type `#noframe`); (d) an `is_error` runner `result` with text fans out to `[error, result]` so managers surface the endpoint message (e.g. `exceeds the available context size`) instead of "returned no output"; (e) local ids are excluded from the silent assemble (core `init` has no such target). Context-window guidance for the user lives in `docs/local-providers.md` (missions ≈ 12–15k tokens of fixed cost; 64k+ recommended, 128k for rails).
321
+ - **Compact agent loop (core `small-model-runtime`, local engines only).** Local connections carry two CORE-owned fields, `agentLoop: 'compact' | 'free'` (default compact) and `contextWindowTokens` (≥ 4096, default 32768), edited in the connection card and validated by `validateDesktopConnectionFields` — plus a third, `maxOutputTokens` (≥ 1024, default 8192; core `guarded-loop.ts` `TOOL_TURN_MAX_OUTPUT`): the `max_tokens` of ONE tool turn, retried once at 2× on `finish_reason: length` (models that think privately spend it on reasoning — the observed *"Tool turn ran out of output budget"* line), ALWAYS bounded by `boundedOutputBudget` to the room left in the context window (prompt + output ≤ window, else llama.cpp refuses the request) and never retried when neither the effort can drop nor the window has room. Forwarded only to a core advertising `capabilities.compactOutputBudget` (`coreConnectionFieldGates` builds both strip gates). Compact = core's OpenAI executor runs the Architect as host-driven structured micro-steps (inventory → proposal → design → specs → tasks, JSON-schema answers, artifacts rendered by the host through the same OpenSpec tools so the graph post-checks pass), the Developer as bounded per-task mini-loops and the Reviewer as a schema answer; both modes get repetition guard, argument repair, empty-reply nudge, `content: ''`, and context compaction. `stripDesktopConnectionFields(providers, { coreCompactLoop })` forwards the two fields ONLY when the loaded core advertises `api.capabilities.compactAgentLoop === 1` (bridge + settings router pass it), so an older core never sees unknown keys. CLI executors are byte-identical.
322
+ - **Hybrid role engines (`hybrid-role-engines`).** A rail whose engine is the sentinel `roles` (`RailEngineSelector` "Roles", `PUT /rails/:i/engine { aiEngine: 'roles' }`, MCP `specrails_rails`) launches with NO `runtimeProviderOverride`, so Core runs architect/developer/reviewer on the per-role providers of the project's `agent-runtime.json` (cloud and local mixed freely). The loop's own AI steps come from two per-project **loop roles** (`server/modules/loops/runtime/loop-role-engines.ts`, `<workspace>/.specrails/loop-role-engines.json`, `GET/PUT /:projectId/agent-runtime/loop-roles`, edited in Settings ▸ Specrails Agents ▸ Loop roles): `verifier` becomes the run's provider/model/effort (verify, fix, custom ai-steps) and `decider` rides `LoopRunRequest.deciderEngine` to the Loop Decider (must support a read-only tool policy). Resolution is fail-open (`resolveLoopRoleEngine`: undetected provider ⇒ the rail's primary, stale model ⇒ adapter default, unsupported effort dropped). Guards: freestyle ⇒ `400 roles_engine_unsupported_mode`; explicit override ⇒ `400 runtime_provider_mismatch`; loops off ⇒ degrades to the primary. Concrete engines are byte-identical.
323
+ - **Reviewer judges behaviour, not the plan's shape (2026-09-18).** Both the default reviewer definition (`prompts.ts`) and the compact reviewer stance say that module layout, file lists, class/function/SFX names and techniques named in the ticket, design or Contract Layer are SUGGESTIONS: a working implementation meeting a criterion another way is correct and "rename/move/rewrite to match the plan" is never an issue. Observed (run f212016c, qwen3.6 thinking on): score 72 with six issues that were all literal-conformance demands (`audio.js`, `.wav` assets, `hardDrop` vs `hard-drop`, class `AudioManager`, a `<script src>`), and none of the three real defects (a broken channel counter, an undeclared `bgMelodyNodes`, soft-drop without SFX).
324
+ - **Write/read economy guards for local developers (2026-09-18).** `compact/developer.ts`: `write_file` on an EXISTING file longer than `REWRITE_MAX_LINES` (150) is refused ("change it with apply_patch") — a 1.4k-line test was regenerated whole 2× then 7× in one run, ~25 min of generation each group; `read_file` of a file longer than `OUTLINE_MIN_LINES` (400) answers with `outlineLargeFile` (first 120 lines + an index of definition lines, "read the range with read_lines") instead of ~15k tokens; `write_file` of a `NARRATION_FILE` (`*summary*`, `*progress*`, `*scratch*`, `*handoff*` .json/.md/.txt) is refused (`fix_summary.json`, `task-summary.json` littered the root); `list_files` hides the `openspec` entry from the developer. The rewrite streak in `guarded-loop.ts` now keys on the file NAME (`pathOf` → basename), because the model alternated `tests/x` with an invented `repo/tests/x` and dodged the path-based streak.
325
+ - **Re-review is host-scoped (2026-09-18).** The compact reviewer's incremental pass now ENFORCES its scope instead of asking for it: `read_file` outside the changed set is refused ("did not change since your previous verdict"), issues that name neither a changed file nor a file from the previous issues (`previousIssueFiles` over the `## Previous review` section) are DROPPED host-side and logged, and a pass with no surviving issue + every criterion met is an approval whatever the model's flag/score said — scores are lifted to the gate thresholds parsed from the prompt (`reviewGateThresholds`), because a fix round cannot worsen an already-reviewed candidate and a reviewer refused the files it wanted has no evidence for a lower score. The reviewer also receives the no-binary-assets fact (`hasBinaryAssets`) so runtime synthesis satisfies spec wording about sound/image files. Observed: re-review 60/4 issues, the resolved one gone, two new ones demanding `assets/audio/*.wav` and an `<audio>` rewrite of working oscillator code — unsatisfiable by the fixer.
326
+ - **Guardrail `exit-code-honesty` (host, 2026-09-18).** `compact/exit-code-honesty.ts` `exitCodeContradiction(exitCode, output)`: a verification command that exited 0 while its output tail carries a failure count > 0 ("3 failed"), a `FAIL:`/`✗`/`not ok` line, or an `AssertionError`/`TypeError:` head is a FAILURE; the verify node routes it to the fixer with `exitHonestyReason` naming the command and telling the fixer to make the harness set `process.exitCode = 1` and fix the tests. Observed: a hand-rolled `tests/ui.test.js` printed three `FAIL:` lines + "39 passed, 3 failed" and exited 0 — verify reported "1 command exited 0" and the reviewer received three red tests as green. Catalog id + schema + `agentRuntime:guardrails.exit-code-honesty` ×8.
327
+ - **Spec hygiene in the compact architect (2026-09-18).** `compact/architect.ts`: (a) each capability's spec prompt lists the requirements ALREADY written under earlier capabilities ("do NOT restate"), and `dedupeSpecRequirements` drops a later requirement whose normalized name matches or whose SHALL-text token Jaccard ≥ 0.6 with one already kept (an emptied capability is dropped from specs and from the proposal's capability list) — observed 20 requirements for 8 obligations, three near-identical lists; (b) `coverCriteria` appends VERBATIM (as the requester's own words, one scenario) any ticket acceptance criterion that no requirement/scenario mentions (identifier tokens, else ≥ half the prose words) to the best-matching capability — observed soft-drop listed in the ticket, absent from every spec and task; (c) `hasBinaryAssets(roots)` false ⇒ the architect's shared facts state that no media will ever be added and effects must be produced at runtime by code (Web Audio, canvas), so design/specs/tasks stop conditioning on "when the asset is loaded".
328
+ - **No-shell awareness + rewrite-loop guard (2026-09-17).** The compact developer prompt states there is NO shell (no scripts, installs, downloads or binary assets — produce runtime effects in application code, e.g. Web Audio instead of `.wav`); `write_file` of a `BINARY_ASSET` extension is refused with that alternative (observed: a 4-byte "RIFF" `audio/move.wav`, then a generator script the developer could never run). `guarded-loop.ts` `REWRITE_WARN_AT` (3): the third consecutive WHOLE-FILE `write_file` of one path — reads of that same file in between keep the streak, `apply_patch` and other files end it — is refused ("stop iterating on this file; you cannot execute it"); observed: `scripts/generate-audio.js` rewritten 10× in a row, 23 s apart, invisible to the identical-arguments guard because the content differed.
329
+ - **Write-only budget extension + no openspec/ reads (2026-09-17).** Core `guarded-loop.ts` `ToolLoopOptions.writeExtension { tools, extraCalls }`: when the tool budget runs out and NONE of the listed tools was ever called successfully, the loop grants `extraCalls` more calls offering ONLY those tools ("you have read enough; make the change now"), extends the turn budget by the same amount, and only then asks for the final reply. The compact developer passes `write_file`/`apply_patch` × `WRITE_EXTENSION_CALLS` (6) per task group and 4 per fix round. Observed: a group spent all 25 calls reading (incl. 5 browsing `openspec/`), wrote zero bytes, and the group check "passed" on the unchanged tree. The developer's read tools now REFUSE paths under `openspec/` (the plan/design/specs are already in the prompt).
330
+ - **Read cache in the guarded tool loop (2026-09-17).** Core `compact/guarded-loop.ts`: a repeated `read_file`/`read_lines`/`search_text`/`get_diff`/`list_files` whose result is byte-identical to an earlier, still-uncompacted result in the transcript (≥ 600 chars) is answered with `{ unchanged: true, note: … still above … use read_lines for a range }` instead of the bytes; a changed file or a folded earlier read returns full content. The loop logs `Read cache: N repeated reads …` at settle. Read tools get 8 identical calls before `tool_loop` (writes keep 5). Observed: a 14 KB test file read four times in one group = 16k tokens of transcript and 4-minute prefills on a GPU spilling to RAM, then the group closed as `tool_loop` on the fifth read.
331
+ - **Brownfield plans + enforced new-file guards (2026-09-17, small local coders).** Core `compact/architect.ts`: the five-group task skeleton (Scaffold · Domain model · Core logic · Integration · Remaining tests) is now GREENFIELD-only; when the inventory says the repository exists the prompt forbids a Scaffold group, smoke/manifest tasks, placeholder files and duplicate tasks and names the inventory's key files to extend (a 32k coder produced a literal "Scaffold" group + `src/index.ts`/`src/feature.ts` stubs in a vanilla-JS game, twice). `validateTaskPlan` coverage is STRICT for identifier-bearing requirements (`#hold-piece-canvas`, `drawHold()`, `7-bag`): one such requirement with no task rejects the plan; prose-only requirements keep the one-third tolerance (an uncovered `ui-rendering` spec shipped under the old rule). `compact/developer.ts` `write_file` guards (both the group loop and the fix round): `foreignLanguageFile` (guardrail `primary-language`, now ENFORCED — a new source file in a language a MONOLINGUAL repository does not use is refused; rewrites, config/data files and mixed repos pass) and `misplacedTestFile` (under `one-test-per-module` — a new test file outside the existing `tests|test|__tests__|spec` dir is refused; co-located tests pass when no such dir exists).
332
+ - **Patient fetch actually applies (2026-09-17).** Core `compact/chat-client.ts` dispatched local requests through an undici Agent with `bodyTimeout: 0` built from the GLOBAL dispatcher's constructor — but Node creates that dispatcher lazily on the first fetch, so a fresh runtime process found no symbol, silently fell back to default fetch and its **300 s body timeout** killed any turn with >5 min of silence on the wire (`UND_ERR_BODY_TIMEOUT`, run c3a0b330 on the Mac mini). `ensurePatientDispatcher()` (awaited before the first send) forces the lazy init with one refused loopback HEAD, then builds the patient agent. `provider_request_error` (a dropped/reset connection) joins `timeout` in `RESUMABLE_CODES` → the node BLOCKS instead of failing the run.
333
+ - **Per-role private thinking (2026-09-17).** `RuntimeAgentConfig.thinking: 'on' | 'off'` (agents.* and `fixer`; core config/schema; `AgentRequest.thinking`; escalation selections inherit it). OpenAI-compatible executor only, **default OFF**: `ChatClient` sends `chat_template_kwargs: { enable_thinking: false }` (the Qwen / llama.cpp / vLLM switch; `reasoning_effort`, when declared, still travels so effort-steered models keep working) and drops the field for the session on a 400 naming it. `on` leaves the server default. Capability `roleThinkingControl: 1`; desktop `forCoreRuntime` strips `thinking` from every agent + fixer unless advertised. UI: Project Settings ▸ Agent engine ▸ each role card (and the Fixer card in Own-engine mode; Inherit follows the developer) shows a **Private thinking Off|On** segmented control ONLY for local providers (`agentRuntime:agents.thinking*` ×8); Off is stored as an absent field. Rationale: in a host-driven micro-step loop hidden reasoning spent >16k tokens and ~13 min on a single tool turn (run d65b946f reviewer).
334
+ - **Incremental re-review after the fixer (2026-09-17).** `reviewerNode` computes `reReview` (`config.efficiency.reviewMode !== 'full'` + incremental `reviewChanges` delta + a previous verdict): the FULL prompt gains `## Re-review after corrections` (`prompts.ts` `reReviewSection`: the changed files, the criteria the journal's acceptance already holds as met, the rule "settle each previous issue; new issues only for regressions in the changed lines") plus a machine-readable `Re-review changes (JSON): […]` line (`RE_REVIEW_MARKER`) that `extractPromptInputs` parses into `reReviewChanges`. The compact reviewer then diffs ONLY those paths (`collectDiff(roots, only)`), runs the re-review stance with a 6-call tool budget (`RE_REVIEW_TOOL_BUDGET`) and logs `Compact reviewer: re-review of N changed files`. CLI reviewers keep their session follow-up. Observed: three full passes 72→78→95 took 35 of 47 minutes, the second pass ADDING an issue to code verify had just accepted.
335
+ - **Role time budget = per unit of work on local engines (2026-09-17).** Core `openai-executor.ts`: the role wall clock (`request.timeoutMs` ⇐ `limits.timeoutMs`, else 45 min compact / 15 min free) is RE-ARMED by `CompactEnv.resetDeadline` at every `toolStep` and `structuredStep` (a task group, a fix round, an inventory, a proposal/design/spec/tasks artifact, a review) — a three-group developer used to die at minute 45 while still patching group 3 (run d65b946f). A real hang is the compact **idle watchdog** (`IDLE_TIMEOUT_MS` 20 min without tool call / reply / usage → `Agent idle timeout`). Both surface as `AgentExecutionError` code `timeout`, which `InvokeOutcome` now carries (`code`), and every node maps through `settle()` to **`blocked`** (resumable: the finished groups are ticked + verified, `write_progress` saved) instead of `failed`. NOTE `limits.timeoutMs` is ALSO the whole-workflow `maxDurationMs` (`core-host.ts`) — leaving it empty keeps the per-unit defaults. Test seams: `OpenAICompatibleOptions.defaultTimeoutMs` / `idleTimeoutMs`. Desktop: the Limits copy (`agentRuntime:limits.hint`/`timeoutMinutes` ×8) states the per-unit semantics; no new field.
336
+ - **Fixer node (2026-09-17).** The fixer is now a GRAPH NODE of its own in core (`CoreNodeId` += `fixer`, `CORE_NODE_ORDER` architect → developer → fixer → verify → reviewer → archive, `CORE_WORKFLOW_VERSION` 5→6 — saved v5 runs need their original runtime): `verify` routes a FAILED receipt (every task ticked, incl. the test-reachability refusal) to `fixer` and unchecked tasks to `developer`; a rejected review routes to `fixer`; `fixer` hands back to `verify`. Both nodes share `implementationVisit` (same `development` record, same output contract, `invoke('developer', …)` so OpenSpec bindings/sessions/agents config stay the developer's; the fixer ENGINE via `agentOverride` only when `config.fixer` is set) — budgets split: `fixerVisitsSinceResume` (corrections, `limits.maxAttempts`) vs `developerVisitsSinceResume` (first pass + continuations; compact doubles it). Spans/steps/events now say `fixer` (`specrails-implementation.fixer`). Desktop accepts the id everywhere a step id is validated: `agent-runtime-metrics.ts` PHASES (a run with a fixer phase used to drop its whole metrics block), `agent-runtime-controls.ts` STEP_IDS (recover/approve/invalidate), narration `RUNTIME_PHASES` + `activity.phase.fixer` ×8 (`phase.corrections` now reads as a developer CONTINUATION).
337
+ - **Fixer role (`fixer-role`).** Correction rounds (developer visits after a failed verification with every task ticked, or a rejected review) run with the FIXER stance in Core's compact developer (`compact/developer.ts`: exact verification output + host-read excerpts first, only the plan tasks naming the failing files, no scope/design/specs dump; log `Compact fixer:`), and — when the project's `agent-runtime.json` carries the optional top-level `fixer: { provider, model?, effort?, … }` — on that engine: `graph/nodes.ts` detects the correction, `graph/roles.ts` accepts `agentOverride` + `stance: 'fixer'` (fresh session, `Fixer route:` note, developer role state untouched), `AgentRequest.stance` reaches `CompactEnv`. Desktop mirrors it: `RuntimeConfig.fixer` (validator, launch override, default-model fill, `[runtime] fixer:` banner, vendored schema) and pipeline phase 4 **Fixer** in Settings ▸ Agent engine ("Inherit developer" default / "Own engine"). First passes and continuations of unchecked tasks always stay on the developer. **The fixer has its own editable prompt**: core `rolePromptDefaults()` publishes a 4th definition `fixer` (`fixerSection` in `prompts.ts`, shares the developer's verification tail + output contract) and `rolePrompts.fixer` is accepted by config/schema; the STANCE now applies to EVERY correction round (`nodes.ts` `correctionRound`), the fixer ENGINE only when configured, and the compact fixer reads the definition through `PromptInputs.definition` (the `## Your task:` section) and appends its own tool mechanics. Session identity stays keyed on the developer definition unless the fixer engine runs, so a CLI provider that resumes the developer's session still gets the short `correctionInstructions`; the fixer definition lands whenever a fresh session starts. Log attribution: a correction round's tool calls and notes are emitted with the event role `fixer` (`AgentEventRole = AgentRole | 'fixer'`, `roles.ts` `forward(extra.stance === 'fixer' ? 'fixer' : role)`), so the desktop log reads `[fixer] read_file …` while the workflow step id stays `developer`. Desktop: `RuntimePromptRole` (validator accepts `fixer`), the loader's `rolePromptDefaults()` types `fixer?` (older cores omit it), and `RuntimeRolePrompts.tsx` renders the **Fixer** tab only when the catalog carries it (`PROMPT_ROLES` in `client/src/features/settings/lib/agent-runtime.ts`; `agentRuntime:roles.fixer` + `prompts.fixerHint` ×8).
338
+ - **Configurable guardrails (`configurable-guardrails`).** Core names every tunable compact-loop guardrail in `src/agent-runtime/guardrails.ts` (id + phase; plan validation, genuine blocking question, plan language hint, frozen plan writes, primary language, duplicate sibling, one test per module, empty write, evidence-gated ticking, inventory retry, synthetic corrections, silent group closure, verify per group — the repository's own test command runs after every task group and a failure gets one in-place fix round with host-read excerpts, **test reachability** — a test file the developer reports that NO verification command executes (an enumerating `test` script that skips the new file; discovery runners and unjudgeable commands fail OPEN) is a verification FAILURE naming the exact file, first at group altitude (`unreachedGroupTests` → one in-place "wire it in" round) and again in the host verify node (`unreachedTestFiles` over the bound plan → back to the developer/fixer with `unreachedTestsReason`), because a 622-line browser suite with six failing cases once shipped green, environment repair, lockfile repair, verify idle timeout) and reads `RuntimeConfig.guardrails { id: false }` (schema-validated) into `CompactEnv.guardrails`; each guard checks `on(env, id)`, host guards are read in the verify node, unset = all on, CLI providers untouched. Core advertises `configurableGuardrails: 1` + the catalog on the `runtime-api` frame; Desktop forwards `guardrails` only when advertised (`forCoreRuntime`, bridge + settings router), serves `GET /:projectId/agent-runtime/guardrails` (`{ supported, catalog }`) and renders the switches (grouped by phase, title/description/why, "N of M active", reset) in project settings ▸ Agent engine ▸ Guardrails, persisted through the normal runtime-config save. Pure correctness fixes (candidate fingerprint, correction counter, argument repair) are deliberately not switchable.
339
+ - **Packaged resolution (Windows-visible bug, fixed):** the runner script ships as `binaries/specrails-local-runner.js` in `bundle.resources`, and the Tauri host now exports **`SPECRAILS_BUNDLED_LOCAL_RUNNER_PATH`** (existence-gated, exactly like `SPECRAILS_BUNDLED_MCP_BRIDGE_PATH`); `resolveLocalRunnerScript()` additionally probes the `process.execPath`-relative packaged layouts (`binaries/`, `../Resources/binaries/`, `resources/binaries/`) BEFORE the repo-relative `__dirname` climb — that climb only resolves in `npm run dev`, so an installed build used to spawn the bundled node against a non-existent script and every local-engine turn died with a Node error while the engine still appeared selectable.
340
+
341
+ **Kill switches:** `SPECRAILS_LOCAL_ENGINES=0|false|off` ⇒ no registration, no probes, local ids rejected at selection, runner never spawned — connections stay editable and remain valid as runtime role providers (byte-identical legacy). `VITE_FEATURE_LOCAL_ENGINES=false` ⇒ the card renders the legacy plain rows without test/models/rates.
342
+
343
+ ## Coverage policy (MANDATORY)
344
+
345
+ CI enforces coverage thresholds: **70% global** (lines/functions/statements) and **80% server** (lines/functions/statements, 70% branches), plus **80% client** (lines/statements, 70% functions). If the local run fails any of these thresholds, you MUST iterate — write more tests — until every threshold passes locally before pushing or asking the user. Never lower the thresholds. Never propose lowering as a fix. The exact commands to mirror CI:
346
+
347
+ ```bash
348
+ npm run typecheck
349
+ npm test
350
+ npm run test:coverage # server, must pass 80% lines/functions/statements
351
+ cd client && npm run test:coverage # client, must pass 80% lines/statements
352
+ ```
353
+
354
+ Excluding files from coverage is allowed only when the file is structurally unreachable in the test environment (e.g. Tauri-only paths in jsdom) — never to mask missing tests. If you exclude, document the reason inline in `client/vitest.config.ts` / `vitest.config.ts` next to the entry.
355
+
356
+ ## Conventions
357
+
358
+ - **File naming**: kebab-case for server/CLI, PascalCase for React components
359
+ - **State per project**: never use module-level caches that bleed between projects. Use `useProjectCache` or per-project Maps in refs.
360
+ - **API calls**: always use `getApiBase()` prefix, never hardcode `/api/...`
361
+ - **WS handlers**: always filter `msg.projectId` against active project via ref (not stale closure)
362
+ - **Settings**: app settings = modal (`GlobalSettingsPage`), project settings = route (`SettingsPage`)
363
+ - **Chat**: sidebar panel in `ProjectLayout`, not a separate page
364
+
365
+ ## Ports
366
+
367
+ - `4200` — Express server (API + WebSocket)
368
+ - `4201` — Vite dev server (proxies `/api` and `/hooks` to 4200)
369
+
370
+ ## Release pipeline
371
+
372
+ Releases are automated via release-please + GitHub Actions:
373
+
374
+ - **CI** (`.github/workflows/ci.yml`) — runs `typecheck` + `vitest` + coverage enforcement on every push and PR. Coverage thresholds are hard gates: **70% global** (lines/functions/statements) and **80% server** (lines/functions/statements, 70% branches). CI fails if thresholds are not met.
375
+ - **Release** (`.github/workflows/release.yml`) — on every push to `main`:
376
+ - release-please creates/updates a Release PR (bumps version in `package.json` + `CHANGELOG.md`)
377
+ - When the Release PR is merged, release-please creates the GitHub Release and `npm publish` runs automatically
378
+ - Publishes with **npm provenance attestation** (`--provenance --access public`) for SLSA Level 2 supply chain security. Requires `id-token: write` permission in the workflow.
379
+ - **Desktop Release** (`.github/workflows/desktop-release.yml`) — on every `v*` tag push or manual dispatch:
380
+ - Runs two build jobs in parallel:
381
+ - `build-macos` on `macos-latest`: signed + notarised Apple Silicon `.dmg`.
382
+ - `build-windows` on `windows-latest`: **unsigned** NSIS `.exe` installer and MSI. v1 ships without Authenticode signing on purpose — users see a SmartScreen warning and must click "More info → Run anyway". Code signing is a separate follow-up change. See `docs/platforms/windows.md`.
383
+ - Canonical installer filenames, enforced by a rename step in `deploy`:
384
+ - `specrails-desktop-<version>-aarch64.dmg`
385
+ - `specrails-desktop-<version>-x64-setup.exe` (NSIS)
386
+ - `specrails-desktop-<version>-x64.msi`
387
+ - The Tauri bundle identifier `sh.specrails.hub` (`src-tauri/tauri.conf.json`) is **intentionally frozen** — it is the existing-install contract (app-data dir + updater identity) and must never change despite the rebrand.
388
+ - FTP-uploads every installer to Hostinger under two paths: the archival versioned folder `downloads/specrails-desktop/v<version>/` and the stable `downloads/specrails-desktop/latest/` channel.
389
+ - Writes a machine-readable `manifest.json` into `latest/` describing the release (schemaVersion, version, releasedAt, releaseUrl, `platforms.darwin-arm64` and `platforms.windows-x64`, each with filename/url/sha256/size). The `windows-x64` entry points at the NSIS `.exe`; the MSI is reachable via the versioned folder but is NOT referenced by manifest. Consumers like specrails-web read this to render Download CTAs without hardcoding versions. **Installer-type alignment (updater ⇄ download).** The Tauri in-app updater's `latest.json` MUST reference the SAME installer type users download — the **NSIS `-setup.exe`**, not the WiX `.msi`. NSIS and MSI keep SEPARATE Windows uninstall databases, so an MSI update over an NSIS install (the old bug) never removed the prior NSIS copy and installed the new MSI copy ALONGSIDE it (two Start-menu entries / two versions). `bundle.createUpdaterArtifacts: true` (v2 mode, not `"v1Compatible"`) makes Tauri emit the NSIS `-setup.exe.sig`; the release workflow's `add_windows_platform` helper points `latest.json` at that NSIS artifact, **falling back to the `.msi` only when the NSIS sig is absent** (fail-safe: a config hiccup logs a `::warning::` and keeps the old behaviour rather than blocking the release or shipping a mismatched updater). Result: install + update are both NSIS → clean in-place upgrade, no duplicates.
390
+ - Ordering: every installer referenced by the manifest (`.dmg`, `.exe`) is uploaded AND HEAD-verified before `manifest.json` is uploaded. A consumer that sees the new manifest must always find the referenced binary, for every platform.
391
+ - **Server-side one-time setup**: the Hostinger `latest/` folder contains a hand-authored `.htaccess` that sets `Cache-Control: no-cache, must-revalidate` and `Access-Control-Allow-Origin: *` on `manifest.json`. This file is server-managed, not in the repo — do not add workflow steps that wipe `latest/` wholesale. The `Delete stale installers in latest/` step only removes `.dmg|.exe|.msi` files. See the inline comment in `desktop-release.yml` for the `.htaccess` contents.
392
+
393
+ Commit message prefixes that affect versioning: `feat:` → minor, `fix:` → patch, `feat!:` → major. Commits without a conventional prefix are ignored by release-please.
394
+
395
+ ### Plugin system (Integrations)
396
+
397
+ Per-project marketplace of MCP-based integrations. Each project independently decides which plugins to install. v1 ships **bundled-only**: every plugin available is compiled into the app binary; there is no remote registry, no user-installable plugins, and no third-party loading. **Zero changes are required in `specrails-core`** — plugins only contribute MCP server entries (via `.mcp.json`) and optionally a fragment in the already-protected `.claude/agents/custom-*.md` namespace.
398
+
399
+ **Additivity is the central invariant.** Adding plugin N+1 must never mutate any artifact owned by plugin N or by the user. Each plugin's manifest declares `owns.mcpServers` / `owns.agentFragments`; ownership conflicts are detected at app startup (`buildOwnershipMap`) and fail fast. All file mutations (`.mcp.json`, `state.json`) are surgical (read → modify only owned keys → atomic temp+rename) and serialised by an in-process file mutex.
400
+
401
+ **Server (`server/plugin-manager.ts` + `server/plugins/`)**: `PluginManager` owns the lifecycle: `listAvailable`, `previewInstall`, `install` (with rollback on verify failure), `uninstall`, `verify` (timeout-bounded, default 2000ms), `removeOrphan`. State lives at `<project>/.specrails/plugins/state.json` (`{ schemaVersion: 1, plugins: { [name]: { version, installedAt, installedFiles, health, healthReason } } }`). `BUNDLED_PLUGINS` is a typed array in `server/plugins/index.ts` — registering a new plugin requires only appending an import. Helpers `PluginManager.mergeMcpServers` / `removeMcpServers` are the only sanctioned way to mutate `.mcp.json`.
402
+
403
+ **REST (`server/plugins-router.ts`)** mounted at `/api/projects/:projectId/plugins`, gated by `SPECRAILS_PLUGINS_SECTION !== 'false'`: `GET /` (catalog with status `installed | not-installed | orphan | degraded`), `GET /:name/preview-install` (diff before mutation), `POST /:name/install` (with WS streaming progress via `plugin.install_progress`), `DELETE /:name` (uninstall or orphan removal), `GET /:name/health` (on-demand verify).
404
+
405
+ **WS events**: `plugin.installed`, `plugin.uninstalled`, `plugin.health_changed`, `plugin.degraded`, `plugin.install_progress` — all `projectId`-scoped.
406
+
407
+ **Rail integration (`server/modules/execution/runtime/queue-manager.ts` + `server/plugins/rail-integration.ts`)**: before spawning a `claude` rail process, `QueueManager` resolves installed plugins (parallel verify with per-plugin timeout), classifies into `active` and `degraded`, and writes a per-job snapshot to `~/.specrails/projects/<slug>/jobs/<jobId>/plugins.json` (chmod 400). It injects env vars `SPECRAILS_PLUGINS_ACTIVE` (CSV) and `SPECRAILS_PLUGINS_SNAPSHOT` (path). When pipeline telemetry is enabled, OTEL resource attrs include `specrails.plugins.active`, `specrails.plugins.degraded`, and `specrails.plugins.versions`. **Healthcheck failure is non-blocking** — degraded plugins emit `plugin.degraded` but the rail spawns normally. `ChatManager` inherits MCP config via `cwd` (no snapshot, no env injection); `SetupManager` ignores plugins entirely (project under construction).
408
+
409
+ **Diagnostic export (`server/modules/accounting/runtime/telemetry-export.ts`)**: includes `plugins.json` in the ZIP and a "Plugins" section in `summary.md` whenever a per-job plugin snapshot exists.
410
+
411
+ **Bundled today**: `serena` (semantic code navigation via LSP+MCP). Manifest in `server/plugins/serena/manifest.ts`; install adds `mcpServers.serena` running `uvx --from git+https://github.com/oraios/serena ...`; verify probes `uvx serena --version`. The optional `templates/instructions.md` fragment lands at `<project>/.claude/agents/custom-serena.md`. Requires `uv` on PATH (auto-detected by an extended `setup-prerequisites.ts` with `includeUv: true`).
412
+
413
+ **Reserved paths (per-project, app-managed)**: `<project>/.mcp.json` (surgical merge), `<project>/.specrails/plugins/state.json`, `<project>/.specrails/plugins/snapshots/<jobId>.json`, `<project>/.claude/agents/custom-<plugin>.md`. The app never wholesale rewrites any of these.
414
+
415
+ ### Theme system
416
+
417
+ App-wide UI theme selectable from `GlobalSettingsPage > Appearance`. Five built-ins: `specrails` (default), `dracula`, `aurora-light`, `obsidian-dark`, `matrix`. Persisted app-wide as `desktop_settings.ui_theme` (server) and mirrored to `localStorage['specrails-desktop:ui-theme']` (client) with an inline anti-FOUC script in `client/index.html` that applies `data-theme` on `<html>` before React hydrates.
418
+
419
+ **Token contract**: components MUST use semantic Tailwind tokens (`accent-primary`, `accent-info`, `accent-success`, `accent-secondary`, `accent-warning`, `accent-highlight`, `surface`, `background-deep`, plus the shadcn-style `background`/`foreground`/`card`/`muted`/`destructive`). Brand-named tokens (`dracula-*`) are forbidden — a regression guard greps for them. Adding a new theme requires only (a) appending a descriptor to `client/src/features/settings/lib/themes.ts`, (b) a new `[data-theme="<id>"] { ... }` block in `client/src/globals.css`, and (c) extending the allow-list in both `THEME_IDS` and `server/desktop-router.ts`. No component-code changes.
420
+
421
+ **Non-CSS surfaces** (xterm, Recharts, syntax highlighting) read the active theme via `useActiveTheme()` (gracefully falls back to `getActiveTheme()` when no `<ThemeProvider>` is mounted, so unit tests don't need provider wrapping). xterm instances reconfigure live (`term.options.theme = ...`) without losing scrollback or shell-integration state.
422
+
423
+ **REST**: `GET /api/theme` returns `{ theme }`; `PATCH /api/theme` validates the body against the allow-list and returns 400 on rejection.
424
+
425
+ ### Effects (Settings ▸ Effects)
426
+
427
+ App-level visual flourishes, per machine (they describe the viewer, not a project): `client/src/features/settings/lib/effects-prefs.ts` (`localStorage['specrails-desktop:effects']`, `useEffectsPrefs()` via `useSyncExternalStore` + a window event so every surface re-renders on a flip; `resetEffectsPrefsCache()` test seam). `GlobalSettingsPage` nav id `effects` (`Sparkles`) renders `client/src/features/settings/components/EffectsSection.tsx`: one switch per effect with a LIVE preview. Shipped effect: **thinking halo** — `AgentThinkingHalo` (`client/src/features/missions/components/AgentThinkingHalo.tsx`) mounts the Builder's `BuilderHalo` on the OUTER composer card (`AgentConversationView`'s dock card in Agent Mode, the composer bar in the floating panel — never the inner textarea box) while `isStreaming`, fading in on turn start (450 ms) and out on settle (800 ms; `BuilderHalo` gained `fadeInMs`/`fadeOutMs`, defaults unchanged); off ⇒ nothing renders; reduced motion ⇒ static glow. i18n `settings:effects.*` + `desktop.nav.effects` ×8.
428
+
429
+ ### Internationalization (i18n)
430
+
431
+ App-wide UI language selectable from `GlobalSettingsPage > Language`. Eight built-ins: `en`, `es`, `fr`, `de`, `pt`, `it`, `zh`, `ja`. Architecture deliberately mirrors the theme system.
432
+
433
+ **Stack**: `i18next` + `react-i18next`, initialized in `client/src/lib/i18n.ts` (the registry: `LANGUAGE_IDS`, `LANGUAGES` descriptors with native names, `isLanguageId`, `DEFAULT_LANGUAGE='en'`). English resources load **eagerly** (`import.meta.glob('../locales/en/*.json', { eager: true })` — always-available fallback, synchronous init so jsdom tests render real strings without provider wrapping); other languages load **lazily** on first switch via dynamic import, then `i18n.changeLanguage` re-renders every `useTranslation` consumer — **hot switch, no restart**.
434
+
435
+ **Locale layout**: `client/src/locales/<lang>/<namespace>.json` — one JSON file per namespace, filename = namespace name (`common`, `settings`, `jobs`, `specs`, `explore`, …). `common` is the default namespace (generic actions/states/status). Components use `useTranslation('<ns>')`; non-React modules use `i18n.t('<ns>:key')`.
436
+
437
+ **First-run default = OS language**: `detectSystemLanguage()` matches `navigator.languages` base subtags (`es-ES`→`es`, `zh-Hans-CN`→`zh`) against the supported list. The server stores only an **explicit user choice** (`desktop_settings.ui_language`); `GET /api/language` returns `{ language: null }` when never chosen, and the client keeps following the OS language without persisting. An explicit pick in Settings persists via `PATCH /api/language` (allow-list validated, 400 on rejection) and mirrors to `localStorage['specrails-desktop:ui-language']`. `LanguageProvider` (`client/src/features/settings/context/LanguageContext.tsx`, mounted in `App.tsx` inside `ThemeProvider`) reconciles server → client on mount and applies switches optimistically with revert-on-failure — same pattern as `ThemeContext`. `main.tsx` awaits `initI18n()` before first render so the first paint is already translated.
438
+
439
+ **Dates**: user-facing date-fns calls pass `{ locale: getDateFnsLocale() }` (exported from `lib/i18n.ts`, maps every `LanguageId` to a date-fns locale).
440
+
441
+ **Adding a language** requires only: (a) append to `LANGUAGE_IDS` + a descriptor to `LANGUAGES` in `client/src/lib/i18n.ts`, (b) create `client/src/locales/<id>/` mirroring every namespace in `locales/en/`, (c) extend `LANGUAGE_ID_ALLOWLIST` in `server/desktop-router.ts`, (d) map a date-fns locale in `DATE_FNS_LOCALES`. No component-code changes (OCP). The key-parity test (`client/src/lib/__tests__/locale-parity.test.ts`) enforces that every locale mirrors the English namespaces, key tree, and `{{placeholders}}` exactly.
442
+
443
+ **String hygiene**: no hardcoded user-visible strings in components — everything goes through `t()`. English is the source-of-truth locale. Do not concatenate translated fragments; use interpolation (`{{var}}`) and i18next plurals (`key_one`/`key_other`). `demo-mode/**` (separate dist-demo build) is exempt.
444
+
445
+ ### Draft tickets (Save as Draft)
446
+
447
+ In-progress Explore Spec sessions can be persisted as **draft tickets** so the user can resume an exploration later from the SpecsBoard.
448
+
449
+ **Schema (`server/modules/specs/runtime/ticket-store.ts`)**: `TicketStatus` includes `'draft'`; `Ticket.priority` is widened to `TicketPriority | null`; `Ticket.origin_conversation_id: string | null` links the Explore conversation. The JSON store's `schema_version` bumps from `'1.0'` to `'1.1'` on first write under this code; old stores remain readable (the read path normalizes missing `origin_conversation_id` to `null`). `validatePriorityForStatus(status, priority)` is the single source of truth: priority MAY be null only when `status === 'draft'`.
450
+
451
+ **Server endpoints (`server/project-router.ts`)**:
452
+ - `POST /tickets/save-as-draft` — persists an Explore session as a draft. Body: `{ conversationId, title?, description?, labels? }`. Rejects when the conversation has no user-submitted turn. Idempotent on `conversationId`: a second save updates the existing draft instead of inserting a duplicate. Auto-title via `server/modules/conversations/runtime/explore-draft-title.ts` when no title is provided (deterministic single-line summary; LLM enrichment is the documented extension point).
453
+ - `POST /tickets/from-draft` — extended with a flip-in-place path: when the body carries `draftTicketId` (or just `conversationId` matching an existing draft's `origin_conversation_id`), the server flips the existing row (`status: 'draft' → 'todo'`, sets `priority`, replaces title/description, preserves `origin_conversation_id`) and broadcasts `ticket_updated`. Legacy non-draft path (no draft match) still inserts a new row and broadcasts `ticket_created`.
454
+ - `DELETE /tickets/:id` — when the deleted ticket is a draft and is the only ticket referencing its `origin_conversation_id`, the linked `chat_conversations` row (kind `'explore'`) is cascade-deleted.
455
+ - `DELETE /chat/conversations/:id` — sweeps tickets whose `origin_conversation_id` matches and clears the field to `null` (application-level "ON DELETE SET NULL").
456
+
457
+ **Client surfaces**:
458
+ - `ExploreSpecShell` exposes a `Save as Draft` button (disabled until at least one user-submitted turn) and replaces the destructive close confirm with a three-way `Save as Draft / Discard / Cancel` prompt (Save is default-focused). The minimize-to-toast path is unchanged.
459
+ - `SpecCard`, `TicketListView`, `TicketGridView`, `TicketPostItView`, and `TicketStatusIndicator` render a draft visual variant: subtle `accent-secondary` background/border (semantic theme tokens, no brand-named colours) and a `Draft` pill in the priority pill's DOM slot. Drafts live in the existing Backlog column — no new column, no filter chip, no collapsible section.
460
+ - `TicketDetailModal` shows a `Continue Explore` action when `status === 'draft'` and `origin_conversation_id` is non-null. Activating it calls `MinimizedChatsContext.triggerResume(...)` to navigate + queue a pending-restore that `ExploreSpecShell` consumes via `usePendingRestore`. The ticket stays `status='draft'` during the resumed session.
461
+
462
+ **Lifecycle**: drafts are never auto-deleted. They disappear only on explicit Discard or when committed to a non-draft status. `from-draft` flip preserves `origin_conversation_id` permanently for future "View origin conversation" UI.
463
+
464
+ **Out of scope** (deferred): concurrency lock when two tabs edit the same draft (last-write-wins for now), and surfacing `origin_conversation_id` on committed tickets as a UI affordance.
465
+
466
+ ### Explore Spec acceleration
467
+
468
+ The Explore Spec chat ships a multi-pronged latency optimisation so that first-token feels electric without compromising spec quality.
469
+
470
+ **App-managed cwd (`server/modules/conversations/runtime/explore-cwd-manager.ts`):** Explore turns spawn `claude` from `~/.specrails/projects/<slug>/explore-cwd/` rather than the project path, so the project's `CLAUDE.md` (often huge) is not auto-loaded by the CLI. The dir contains an app-owned embedded `CLAUDE.md` (~50 lines, focused on the Explore-Spec stance) and a `./project` symlink (junction on Windows; `project-path.txt` fallback if both fail) pointing at `<project.path>`. Tools (`Read`, `Grep`, `Glob`) keep working against the user's repo via that link. The user's `<project>/CLAUDE.md` is never modified, moved, or referenced — only the link target is.
471
+
472
+ **MCP scope is per-spec.** The decision is recorded on `chat_conversations.context_scope.mcp` at conversation creation time (via the Add Spec modal's `Project MCPs` toggle / preset slider). When `true`, the spawn cwd passes through the artifact-relocation gate: the workspace for a relocated project (where `.mcp.json` and `.specrails/` live), `<project.path>` for a legacy project; the same relocation env is retained by persistent-stdin and crash-respawn paths. A pre-fix repo-cwd session that returns the exact `No conversation found with session ID` diagnostic is invalidated and retried fresh ONCE from the workspace with a 48 KiB-bounded persisted transcript; both spawn-per-turn and persistent transports use that recovery, never a repo fallback. `SPECRAILS_EXPLORE_LEGACY_CWD=1` forces `<project.path>`. When `false`/missing, the spawn uses the app-managed `explore-cwd/`. There is no project-level default and no Settings UI — the choice lives with the ticket forever via `origin_conversation_id`.
473
+
474
+ **User-approved MCPs are a SEPARATE per-spec toggle (`context_scope.userMcp`).** The `mcp` flag above loads the project-committed `.mcp.json`; `userMcp` loads the user's OWN already-approved MCP servers — the ones registered locally with `claude mcp add` / `codex mcp add` — independent of `mcp` and WITHOUT changing the spawn cwd (so the explore-cwd latency win is preserved). Surfaced as the Fine-tune checkbox `My approved MCPs` (Explore-only, disabled in Quick; not part of any slider preset). Server: `server/user-mcp-config.ts` `buildUserMcpArgs()` — for claude it reads `~/.claude.json` (user-scope top-level `mcpServers` merged with `projects[<projectPath>].mcpServers` local-scope, local wins on conflict), writes a chmod-600 `~/.specrails/projects/<slug>/user-mcp.json`, and appends `--mcp-config <file>` to the spawn argv. The CLI loads `--mcp-config` additively (no `--strict-mcp-config`), and `--dangerously-skip-permissions` in `COMMON_FLAGS` (`server/providers/claude-adapter.ts`) makes the loaded `mcp__*` tools callable. **Tool-gate caveat (claude 2.1.177):** `--tools Read,Grep,Glob` is a built-in-toolkit RESTRICTION that DOES strip Bash and leaves no room for MCP/connector tools to be exercised at that read-only tier — so `toolFlagsForScope` (`server/modules/conversations/runtime/context-scope.ts`) only applies that restriction for the mid read-only tiers. At the HIGH tier (`scope.full && (scope.mcp || scope.userMcp)` — the Max/Desktop presets / "My approved MCPs") it instead emits `--disallowedTools Write,Edit,NotebookEdit`, keeping the full `--tools default` toolkit so Bash (the `gh` CLI with the user's local auth, repo inspection) and ALL MCP servers — file-scope (`--mcp-config`) AND connector/plugin-scope (via `--setting-sources user` from `loadUserEnv`) — are callable. This tier is intentionally NOT a hard read-only sandbox (Bash can still write to disk; the GUI file-writer tools are removed and the Explore system prompt carries the non-destructive + "verify against real code before recommending" stance). Connector MCP servers cannot be enumerated into an `--allowedTools` allow-list, which is why the high tier uses skip-permissions + a denylist rather than an allow-list. For codex `buildUserMcpArgs` returns `[]`: codex chat turns spawn with `env: process.env` and no `CODEX_HOME` override, so codex already reads the user's global `~/.codex/config.toml` MCP servers natively. Wired in `ChatManager.sendMessage` next to `toolFlagsForScope`.
475
+
476
+ **Byte-stable system prompt:** `ChatManager._buildLightweightSystemPrompt()` is deterministic — no timestamps, costs, or live aggregates — so Anthropic prompt cache hits on turns 2+ within the 5-minute TTL window. The non-Explore `_buildSystemPrompt()` retains its live dashboard context unchanged.
477
+
478
+ **Lifecycle (`ChatManager._exploreLifecycle`):** Per-Explore-conversation state with three policies:
479
+ - **Idle-kill on minimize:** `POST /api/projects/:projectId/chat/conversations/:id/minimize` arms a 2-minute idle timer; if no message and no restore in that window, any active spawn is `treeKill`ed (SIGTERM). The conversation row's `session_id` is preserved; the next message respawns with `--resume`. `POST .../restore` cancels the timer. The timer only arms when the conversation is not currently streaming.
480
+ - **Crash auto-respawn:** if the child exits non-zero before emitting a `result` event and the user did not interrupt, the same turn respawns once with `--resume`. The crash counter resets on any successful turn. A second crash surfaces `chat_error`.
481
+ - **Concurrency cap of 5 per project:** the sixth Explore turn evicts the oldest idle Explore spawn; if all five are streaming, the new turn queues with a 30-second timeout and then emits `chat_error reason='busy'` if no slot opens.
482
+
483
+ **Premium UX (`client/src/features/specs/components/explore-spec/ExploreStatusPills.tsx`):** Status pills `Conectando… → Pensando… → Consultando código…` are rendered above the streaming assistant bubble for the first few hundred ms of every turn, gated by `VITE_FEATURE_EXPLORE_PREMIUM_UX !== 'false'` for an emergency disable. Each pill displays for at least 150 ms to avoid flicker. The pill area unmounts as soon as the first text delta arrives.
484
+
485
+ **Persistent-stdin multi-turn (`server/modules/conversations/runtime/explore-stdin-session.ts`, opt-in).** The default path spawns a fresh `claude` per Explore turn (turns 2+ pay `--resume` rehydration). When `SPECRAILS_EXPLORE_PERSISTENT_STDIN=1` AND the conversation is `kind='explore'` AND the adapter advertises `capabilities.persistentStdin` (claude only), `ChatManager.sendMessage` routes to `_streamPersistentExploreTurn`: one long-lived child per conversation reads newline-delimited stream-json user messages from stdin (`adapter.buildArgs('chat-stream', …)` → `-p --input-format stream-json`, resumes via `--resume` when a session id exists, omits `--max-turns`). `ExploreStdinSessions` owns the persistent child, a single stdout reader fanning each line to the current turn's handler, and teardown. The turn ends on the `result` event (not process close), so the child stays resident for the next turn. It mirrors the legacy close-handler finalisation exactly (spec-draft parse, persist, session capture, `chat_done`, `ai_invocations` accounting, lifecycle) but has NO crash-respawn — a dead child is evicted and the next turn re-spawns with `--resume`. Wired into idle-kill, concurrency-victim eviction, `abort`, `shutdown`, and `forgetExploreLifecycle` so persistent children are never orphaned. Default OFF ⇒ byte-identical legacy behaviour. The client char-by-char render buffer is `useSmoothStream` and is unchanged (it consumes the same `chat_stream` events).
486
+
487
+ **Escape hatches:** `SPECRAILS_EXPLORE_LEGACY_CWD=1` (server env) forces every Explore spawn to use `<project.path>` and skips materialising the explore-cwd entirely. `SPECRAILS_EXPLORE_PERSISTENT_STDIN` is unset/`0` by default (legacy spawn-per-turn); set to `1` to enable the persistent-stdin fast-path. `VITE_FEATURE_EXPLORE_PREMIUM_UX=false` (client build flag) reverts the status pills to a pre-change rendering. All keep Explore functional and lose no data.
488
+
489
+ **Cleanup:** `ProjectRegistry.removeProject` calls `removeExploreCwd(slug)` which recursively rms the explore-cwd directory (the `./project` symlink is `unlink`ed explicitly, never followed). Any active Explore spawns are independently torn down.
490
+
491
+ **Out of scope** (deferred): skeleton-on-submit assistant bubble, per-project user-customizable `<project>/.specrails/explore-instructions.md` override, sidebar-chat (`kind='sidebar'`) cache-busting fix, and Quick-mode acceleration. These remain future-change candidates. (Persistent-stdin multi-turn and the char-by-char client buffer are now implemented — see above.)
492
+
493
+ ### Project spending analytics
494
+
495
+ Per-project unified tracking of every billable AI CLI invocation (model, tokens, cost USD, turns, duration), powering the redesigned `/analytics` page (route name unchanged; right-sidebar entry still labelled "Analytics").
496
+
497
+ **Schema (`ai_invocations` table, per-project SQLite, migration 16):** `id, project_id, surface, surface_ref_id, ticket_id, conversation_id, model, status, started_at, finished_at, duration_ms, duration_api_ms, tokens_in/out/cache_read/cache_create, total_cost_usd, total_cost_usd_estimated (migration 20), num_turns, session_id`. Indexed by `(project_id, started_at DESC)`, `(project_id, surface)`, partial `(project_id, ticket_id)`. `total_cost_usd_estimated=1` marks a row whose cost is a `server/modules/accounting/runtime/pricing.ts` rate-card estimate rather than a provider-billed figure — used both for providers without native cost (codex/gemini) and for the kill-path estimator below.
498
+
499
+ **Surfaces (`Surface` union / `ALLOWED_SURFACES` in `server/modules/accounting/runtime/ai-invocations.ts`):** `job`, `quick-spec`, `explore-spec`, `chat-sidebar`, `ai-edit`, `agent-studio`, `spec-launcher`, `proposal`, `setup`, `smash`, `file-summary`, `loop`. App-level Agent Chat / Mission Control spend is a **separate** ledger (`agent_invocations` in `desktop.sqlite`, app-global, no `project_id` for Home turns) — see below.
500
+
501
+ **Capture sites (in scope):** every AI-CLI spawner writes a row at process exit (success AND every terminal kill/abort/crash/timeout path) via `recordInvocation` from `server/modules/accounting/runtime/ai-invocations.ts`:
502
+ - `server/modules/execution/runtime/queue-manager.ts` — `surface='job'`, `surface_ref_id=<jobId>`, ticket id extracted from command; includes cancel/zombie/shutdown/restart and interactive-job finalize/teardown paths. Interactive sessions (the default for every claude job — see Interactive jobs) accumulate each turn's REAL usage onto the job row live (`accumulateInteractiveTurn`) and write ONE `ai_invocations` row at settle from the summed totals (mid-turn kills fold the in-flight turn as a rate-card estimate).
503
+ - `server/modules/delivery/runtime/rail-merge-orchestrator.ts` — worktree-rail merge-back AI steps (verify / resolve-merge / fix), `surface='job'`, `surface_ref_id=\`${jobId}:merge:${step}\``, ticket = the rail's primary ticket (CRIT-2).
504
+ - `server/project-router-tickets.ts` — `POST /tickets/generate-spec` → `surface='quick-spec'` (ticket id set when creation succeeds); `POST /tickets/:id/ai-edit` → `surface='ai-edit'` (HIGH-4).
505
+ - `server/modules/conversations/runtime/chat-manager.ts` — `surface='explore-spec'` for `kind='explore'`, `surface='chat-sidebar'` for `kind='sidebar'` (MED-4 — sidebar chat is now recorded). One row per turn with `conversation_id`. `POST /tickets/from-draft` calls `updateTicketIdForConversation` to back-fill `ticket_id` on prior rows once the ticket is created.
506
+ - `server/modules/agents/runtime/agent-refine-manager.ts` — `surface='ai-edit'` per refine turn (cancelled/disposed turns also record — MED-12); its default-on auto-test spawn records `surface='agent-studio'` (MED-3).
507
+ - `server/modules/agents/runtime/agent-generator.ts` — Studio custom-agent generate/test (via `profiles-router.ts`) → `surface='agent-studio'` (MED-3), when the caller threads an `AgentStudioRecordCtx`.
508
+ - `server/modules/specs/runtime/spec-launcher-manager.ts` — `/opsx:ff` launches → `surface='spec-launcher'` (HIGH-5).
509
+ - `server/modules/specs/runtime/proposal-manager.ts` — `/specrails:propose-feature` exploration / refinement / issue-create spawns → `surface='proposal'` (HIGH-6).
510
+ - `server/setup-manager.ts` — phase-4 wizard `/setup` chat turns → `surface='setup'` (LOW-2), resolved lazily to the per-project DB.
511
+ - `server/modules/specs/runtime/smash-runner.ts` (`surface='smash'`), `server/file-summary-*.ts` (`surface='file-summary'`, incl. failed-exit/empty-summary paths — MED-13), `server/modules/loops/runtime/loop-run-manager.ts` (`surface='loop'`).
512
+
513
+ **Kill-path estimator (CRIT-1).** Any claude spawn killed/aborted/crashed/timed-out before its terminal `result` event still burned real billed tokens. `server/modules/accounting/runtime/result-event.ts` `finaliseInvocationResult` reconstructs usage from the accumulated per-assistant-event adapter events (`server/providers/claude-adapter.ts` captures per-event `usage`) and prices it via `server/modules/accounting/runtime/pricing.ts` (which now has a cache-write tier), returning `estimated=true`. Every kill/abort/timeout callsite records the row with `total_cost_usd_estimated` set instead of a NULL ($0) cost.
514
+
515
+ **App-level Agent Chat spend (HIGH-3).** `server/modules/missions/runtime/agent-chat-manager.ts` records each Mission Control / Agent Chat turn into `desktop.sqlite`'s `agent_invocations` (`server/desktop-db.ts` `recordAgentInvocation`, same estimated-cost fallback). Pinned-project turns broadcast `spending.invalidated` for that project; Home (app-global) turns record with `project_id=NULL` and no per-project broadcast. `server/modules/accounting/runtime/desktop-analytics.ts` folds this ledger into the app-level grand total + `costToday` via `sumAgentInvocationsCost` (`agentCostSince`).
516
+
517
+ **Genuinely out of scope:** chat auto-title spawns (`_autoTitle`, sub-cent, fire-and-forget). Multi-ticket jobs attribute 100% of cost to the primary ticket (`ticketIds[0]`) by decision (MED-7).
518
+
519
+ **Conversation kind (migration 17):** `chat_conversations.kind TEXT NOT NULL DEFAULT 'sidebar'`. `POST /chat/conversations` accepts an optional `{ kind: 'sidebar' | 'explore' }` field. The Explore client (`ExploreSpecShell`) sends `kind: 'explore'` via `useChat.startWithMessage(text, opts, model, 'explore')`.
520
+
521
+ **Aggregation (`server/modules/accounting/runtime/spending.ts`):** single source of truth. `getSpending(db, projectId, filters)` returns `summary` (totals + prev-period delta), `bySurface`, `byModel` (top 10), `byMode` (Quick vs Explore), `dailyTimeline` (zero-filled, stacked by surface), `scatter` (last 500 points), `topTickets` (top 10 cross-surface, with deleted-ticket and unattributed buckets). `getInvocations` powers the raw table block and exports, with `cap` for the 10k row export limit. Aggregations exclude `failed`/`aborted` rows from cost averages but include them in `totalRuns`/`failureRate`. Cost SUMs count estimated rows (they billed real tokens) and split out the estimated portion so the UI can badge it. The per-project `/stats` KPI (`getStats` in `server/db.ts`, feeding the StatusBar) and the app-level KPIs (`server/modules/accounting/runtime/desktop-analytics.ts`, HOME + `/api/state`) source cost from `ai_invocations` across ALL surfaces (not the jobs table), so every surface above counts (MED-8); job-COUNT/duration fields stay job-sourced.
522
+
523
+ **Per-phase attribution (`server/modules/execution/runtime/job-phase-breakdown.ts`):** `GET /api/projects/:projectId/jobs/:id/phase-breakdown` computes post-hoc, from the job's persisted raw stream events, per-subagent-phase usage (claude `Task` blocks + `parent_tool_use_id` envelope; nested Tasks roll up to their root phase; orchestrator/unattributed buckets) with rate-card **estimated** costs (always flagged `estimated: true` — honest-metrics contract). Non-claude streams (no per-event usage) return `breakdown: null`. This is the measurement base for the pipeline-cost-economy program. PR delivery also appends a `> Specrails cost: $X.XX` footer to every PR body (`sumInvocationCostForRuns` over the delivery's `run_ids`; `~` = partly estimated; best-effort, never blocks).
524
+
525
+ **REST endpoints:**
526
+ - `GET /api/projects/:projectId/spending?period&surface&model&status&minCostUsd&ticketId` — dashboard data.
527
+ - `GET /api/projects/:projectId/invocations?...&limit&offset` — paginated raw rows.
528
+ - `GET /api/projects/:projectId/tickets/:id/spending-summary` — per-ticket aggregate used by `TicketDetailModal`.
529
+ - `GET /api/projects/:projectId/analytics/export?format=csv|json&mode=summary|raw&...` — Summary CSV is a multi-section composite (`# Totals`, `# Daily timeline`, `# By surface`, `# By model`, `# Top tickets`); Raw exports up to 10 000 rows, appending `# truncated_at=N of M` when truncated. Filename pattern: `<slug>-analytics-<period>[-<surface>]-<YYYY-MM-DD>.{csv,json}`.
530
+
531
+ **WebSocket:** `recordInvocation` callsites broadcast `spending.invalidated` (project-scoped, no payload). Open dashboards debounce 500 ms then refetch.
532
+
533
+ **Client (`client/src/features/analytics/pages/AnalyticsPage.tsx`):** seven blocks — sticky filter header (period + surface chips, both URL-synced), Hero burn meter (with `vs prev` delta), daily stacked timeline, Quick vs Explore card (sparse-data CTA when Explore < 5 runs), top-N model breakdown (click-to-filter), cost-vs-turns scatter, top tickets cross-surface, raw invocations table with secondary filters scoped to that block only. Surface colour mapping: `job=accent-info`, `quick-spec=accent-secondary`, `explore-spec=accent-highlight`, `ai-edit=accent-success`, `file-summary=accent-warning` (see the "Code explorer" section). Lives in `client/src/components/analytics/`.
534
+
535
+ **Ticket → Analytics deep link:** `client/src/features/analytics/components/TicketSpendingLine.tsx` renders a single line under the modal title (`$X · N turns · Tm Ts active · breakdown`) when the ticket has any invocations, linking to `/analytics?ticketId=<id>`.
536
+
537
+ **Export (`client/src/features/analytics/components/ExportDropdown.tsx`):** uses `fetch → Blob → URL.createObjectURL → anchor.click()` (works in Tauri webview; previous `window.open` path is gone). Four entries: Summary CSV/JSON, Raw CSV/JSON. Disabled when there's no data; sonner `toast.error('Export failed')` on failure. Filenames preserved from `Content-Disposition` when present.
538
+
539
+ **Tracking start:** no historical backfill. `summary.trackingStartedAt` reflects the first `started_at` in the project, surfaced in the Hero empty state ("Tracking started YYYY-MM-DD").
540
+
541
+ ### Explore Contract Refine
542
+
543
+ Optional post-commit refinement that appends a structured **Contract Layer** section to every committed Explore Spec ticket, giving the downstream agent chain prescriptive anti-reinvention anchors (exact identifiers, data shapes, state machine, invariants, file touch list).
544
+
545
+ **Decision is per-spec.** The Contract Refine choice lives on `chat_conversations.context_scope.contractRefine` (Explore) or the `contractRefine` field of `POST /tickets/generate-spec` request bodies (Quick). There is no project-level default and no Settings UI — the Add Spec modal is the single decision point.
546
+
547
+ **Kill switch.** `SPECRAILS_EXPLORE_CONTRACT_REFINE=0|false|off` (case-insensitive) app-wide disables firing across every project. This is the only ops-level escape hatch.
548
+
549
+ **Explore lifecycle (`server/modules/specs/runtime/contract-refine-runner.ts`).** After `POST /tickets/from-draft` returns successfully (both legacy insert and flip-in-place paths), `process.nextTick` schedules `runContractRefine(deps, conversationId, ticketId)`. The runner gates exclusively on `conversation.context_scope.contractRefine === true` (legacy null or `false` → `scope-disabled`, no spawn, no invocation row) plus the app-wide kill switch. It then loads the parent Explore conversation, skips when `kind !== 'explore'` or no `session_id`, and resolves the exact cwd used by that resumable session: app-managed `explore-cwd/` for `mcp=false`; the relocation-aware workspace/repo gate for `mcp=true`; `<project.path>` when `SPECRAILS_EXPLORE_LEGACY_CWD=1`. It spawns `claude` with the parent's model, `--resume <session_id>`, `--max-turns 1`, `--tools __none__`, the structural system prompt, and the byte-stable contract marker user message. If a relocated workspace resume reports the exact missing-session diagnostic, it retries fresh ONCE in the same workspace/env, still with no tools, seeded with the target ticket; other failures do not retry. Success patches the ticket description and records `surface='explore-spec'`.
550
+
551
+ **Add Spec UI.** Explore mode uses `ContextScopeSlider` as a six-stop preset picker (`Minimal`, `Light`, `Standard`, `Rich`, `Max`, `Desktop`) over the persisted five-flag `contextScope`; Contract Refine is enabled only by the `Max` and `Desktop` presets unless the user opens `Fine-tune` and toggles it manually. Quick mode has a standalone `Enrich with Contract Layer` toggle whose per-project last value is stored under `config.add_spec_quick_contract_refine_last`.
552
+
553
+ **Quick lifecycle.** `POST /tickets/generate-spec` accepts `contractRefine: boolean`. When `true` and the app-wide kill switch is inactive, `runContractRefineForQuick` spawns a fresh `claude` turn without `--resume`, seeds the system prompt with the generated title and description, reuses the Contract Layer parser/renderer/PATCH path, and records `ai_invocations.surface='quick-spec'` with `conversation_id=null` and the new ticket id.
554
+
555
+ **Agent-authored lifecycle (super specs).** `POST /tickets/from-draft` also accepts `contractRefine: boolean`: on the conversation-less insert path (no `conversationId`, no `draftTicketId` — the operator agent's `commit_draft`) and when claude is among the project's installed providers, `contractRefine: true` schedules the same `runContractRefineForQuick` enrichment seeded with the just-inserted spec body (records `surface='quick-spec'`, `surface_ref_id='contract-refine:<ticketId>'`). Default OFF at the HTTP layer (Explore-client payloads stay byte-identical); the MCP `specrails_specs` facade defaults it **ON** for agent-authored `commit_draft`/`create`/`generate` calls with a per-call `contractRefine: false` opt-out — the operator prompt teaches the agent to disclose the enrichment at the confirmation gate and honor a user's "no contract layer".
556
+
557
+ **Retry.** `POST /api/projects/:projectId/tickets/:id/contract-refine` re-fires the refine for an existing ticket (202 scheduled / 404 unknown / 409 when the kill switch is active). Tickets with an `origin_conversation_id` re-run the Explore `--resume` path; tickets without one (agent-authored / Quick) fall back to the Quick-style refine seeded with the ticket's user body (Contract Layer stripped before seeding). Retry is an explicit user action — the click itself is the per-ticket consent, regardless of the origin conversation's stored `contextScope.contractRefine`.
558
+
559
+ **Prompt & parser (`server/modules/conversations/runtime/explore-contract-refine.ts`).** Pure module: `CONTRACT_PROMPT_VERSION = 1`, `buildContractRefineSystemPrompt()` (byte-stable), `parseContractLayerBlock(raw)` (drops unknown keys, defaults missing arrays to `[]`, rejects missing/non-int `contractVersion`), `renderContractLayerMarkdown(layer)` (deterministic five-subsection markdown with `_N/A_` placeholders), `appendContractLayerToDescription`, `splitDescriptionAtContractLayer`.
560
+
561
+ **Client.** `ContractRefineTrackerProvider` (mounted at `App.tsx` root) listens for `explore.contract_refine_failed` and surfaces a sonner error toast with a `Reintentar` action that POSTs the retry endpoint; `ticket_updated` events whose description carries the `\n\n---\n\n## Contract Layer\n\n` separator briefly toast success. `TicketDetailModal` splits the description at the separator and renders the Contract Layer inside a `<details>` disclosure (collapsed by default, badge showing `N/5 populated` non-N/A subsections).
562
+
563
+ **Out of scope (deferred).** Iterative refine; per-feature model override (Haiku for refine); SpecsBoard pending toast; deep ChatManager lifecycle integration (idle-kill awareness, crash auto-respawn, concurrency cap participation).
564
+
565
+ ### Pipeline telemetry
566
+
567
+ Per-project opt-in feature that injects OpenTelemetry env vars into `claude` CLI spawns so the process emits OTLP/JSON signals to the app.
568
+
569
+ **Default state**: OFF. Toggle lives in the project `SettingsPage`.
570
+
571
+ **Storage paths:**
572
+ - Raw blobs: `~/.specrails/projects/<slug>/telemetry/<jobId>.ndjson.gz` (concatenated gzip; one gzip member per received payload)
573
+ - Pointer rows: `telemetry_blobs` table in the per-project `jobs.sqlite`
574
+ - Aggregated summaries (post-compaction): `telemetry_summaries` table in `jobs.sqlite`
575
+
576
+ **Retention policy**: Blobs older than 7 days are compacted at server startup — raw file deleted, aggregates written to `telemetry_summaries`, pointer row set to `state="compacted"`. Blobs are never expired automatically beyond compaction.
577
+
578
+ **QueueManager-only scope**: OTEL env injection happens exclusively in `server/modules/execution/runtime/queue-manager.ts` at spawn time. `ChatManager` and `SetupManager` spawns are intentionally left uninstrumented (interactive sessions / wizard flows, not repeatable pipeline jobs).
579
+
580
+ **OTLP receiver**: `POST /otlp/v1/{traces,metrics,logs}` on the app port. Routes signals by `specrails.job_id` + `specrails.project_id` from `resource.attributes`. Returns 400 if attributes missing, 404 if project/job unknown. 10 MB uncompressed cap per blob — logs are dropped once reached (traces/metrics continue), a `logs_truncated` control line is written exactly once.
581
+
582
+ **Export**: `GET /api/projects/:projectId/jobs/:jobId/diagnostic` streams a ZIP containing `job-metadata.json`, `telemetry.ndjson`, `logs.txt`, and `summary.md`. Export button on job cards visible iff a `telemetry_blobs` row exists (`active` or `compacted` state).
583
+
584
+ **specrails-core**: This feature is entirely app-side. The `specrails-core` repository is intentionally not modified — the Claude CLI subprocess emits OTEL signals by reading env vars set by QueueManager.
585
+
586
+ ### Code explorer (read-only)
587
+
588
+ A read-only **Code** section per project for non-developers: virtualised file tree on the left (provenance chips per file), Monaco viewer on the right with a plain-language AI summary header. Gated by `VITE_FEATURE_CODE_EXPLORER` (client) and `SPECRAILS_CODE_EXPLORER` (server) — both default ON / opt-out (`feature-flags.ts` returns `true` unless the flag is the string `"false"`); set either to `"false"` to disable. Read-only — no in-app editing in v1.
589
+
590
+ **Server modules:**
591
+ - `server/modules/code/runtime/file-provenance.ts` — `snapshotWorkingTree` / `diffAgainstSnapshot` / `recordProvenanceForJob` / `listProvenanceByTicket` / `listProvenanceByPath` / `broadcastProvenanceUpdated`. Per-project SQLite table `file_provenance(id, file_path, ticket_id, job_id, kind, at)` (migration 22), indexed on `file_path`, `ticket_id`, `at DESC`. Migration 37 adds `file_story_contributions(provenance_id PK, job_id, file_path, added_lines, removed_lines, patch_excerpt ≤ 4 KB, summary, summary_model, summary_generated_at)` — stats/excerpt rows are written inside `recordProvenanceForJob`'s transaction for every row with a collected patch (full patches stay in `file_provenance_diffs`, migration 23), so every provenance producer records story stats for free.
592
+ - `server/modules/code/runtime/file-summary-manager.ts` — `FileSummaryManager` class. Hash-gated regeneration (sha256 of file contents is the invalidation key), per-project concurrency 2, app-wide 8, per-job cap 50, monthly budget cap (app setting `summary_monthly_budget_usd`, default $5; user-initiated `overrideBudget` bypasses), source-watcher stale-marking (no auto-regenerate on user edits), orphan sweep capped at 200/pass. **Source watcher (`attachWatcher`, lazily attached on the first Code-Explorer request):** `resolveWatchEngine` picks the kernel's own recursive watch on macOS/Windows — `fs.watch(dir, { recursive: true })` = FSEvents / ReadDirectoryChangesW, ONE handle for the whole tree — and chokidar (build/dot trees pruned via `isInBuildDir`, inotify watches are not fds) on Linux. chokidar ≥ 4 dropped fsevents and watches every file with its own `fs.watch`, i.e. one kqueue fd PER FILE on macOS: a large repo blew past the ~10k-fd limit and the recursive watcher's unhandled `error` event killed the whole server (`Error: EMFILE: too many open files, watch`). Both engines now carry an `error` handler: resource exhaustion (`EMFILE`/`ENFILE`/`ENOSPC`) or a start failure closes the watcher and parks the project in `degraded` status (`watcherStatus(projectId)`; logged once, still counts as attached so no retry storm) — the manager keeps working, edits just stop marking summaries stale until the next explicit read/regenerate hash check. Change events are funnelled through one path (normalised POSIX relpath, build/dot trees + no-summary files skipped, `WATCH_DEBOUNCE_MS` trailing debounce per path — the successor of chokidar's `awaitWriteFinish`) and only broadcast stale when the source hash REALLY differs from the summary's (a byte-identical save or a replayed kernel event never flags a still-valid summary; a vanished source is stale). Test seams: `FileSummaryDeps.watchEngine` / `.fsWatch`. Summaries live at `<project>/.specrails/file-summaries/<sha256-of-relative-path>.json`. Schema validated by `server/schemas/file-summary.v1.json`. `.gitignore` line `.specrails/file-summaries/` appended idempotently on first write (commit to share with team if desired).
593
+ - `server/modules/code/runtime/code-explorer-router.ts` — mounted under `/api/projects/:projectId/code`, returns 404 when `SPECRAILS_CODE_EXPLORER=false`. Endpoints: `GET /tree?withProvenance=1&filter=touched-by-ai|all&cursor=…` (pagination cap 2000, deny-list, `.gitignore` respect), `GET /find?q=…&limit=…` (locate files by name / path-suffix / fragment over the same cached full scan and deny-list + `.gitignore` rules as `filter=all`; `rankFindMatches` orders `exact` → `suffix` → `basename` → `substring`, ties on the shorter path; default 20, cap 50 — the escape hatch for a path copied from a stack trace or import that is relative to a subdirectory; the MCP `specrails_code(find)` action + the `read_file`/`summary` 404 hint point the operator agent at it instead of leaving it to page through `tree`), `GET /file?path=…` (binary refusal, 2 MB cap, path-traversal guard), `GET /summary?path=…`, `POST /file/regenerate-summary?path=…` (body `{ overrideBudget? }` → 202 `{ enqueued: true }`), `GET /provenance?ticketId=…`, `GET /file/story?path=…` (the chronological construction story: provenance × stats × AI contribution × live ticket title/status via `ProjectContext.getTicketSpec`), `POST /file/story/explain?path=…` (body `{ provenanceId, overrideBudget? }` — budget-gated per-intervention contribution paragraph via `server/modules/code/runtime/file-story-manager.ts` `FileStoryManager`, constructed per-ctx at the `project-router.ts` mount; reuses `createFileSummaryGenerator` with a story `systemPrompt` override, shares the `summary_monthly_budget_usd` budget AND records `ai_invocations.surface='file-summary'` — deliberately no new surface). **Reserved paths (app-managed)**: `<project>/.specrails/file-summaries/**`.
594
+
595
+ **QueueManager hook**: pre-spawn `snapshotWorkingTree(cwd)` (stored on `_snapshotRefs: Map<jobId,ref>`), post-exit `diffAgainstSnapshot` → `recordProvenanceForJob` (primary ticket = first id from `tickets[]` extraction) → broadcast per row. Both gated by `isCodeExplorerEnabled()`. Per-job warning `provenance.large_job` when >50 paths (still inserts all rows; the 50-cap applies only to summary enqueues).
596
+
597
+ **Loop-run provenance (construction-story seam)**: loop runs settle OUTSIDE QueueManager, so `server/modules/code/runtime/file-story.ts` `recordLoopRunProvenance` gives them the same recording (single chokepoint: flag-gated, diff + patches + `recordProvenanceForJob` + per-row broadcast, `job_id` = the loop RUN id, never throws). Wired in `rail-isolated-launch.ts` (per-worktree snapshot at allocation — after the overlay so overlay files are never attributed — recorded once at settle on both success/failure paths, injectable via `IsolatedLaunchIO.snapshot`/`.recordProvenance`) and `rails-router.ts` `launchLoopRun` (snapshots the REPO — `loopExec.repoDir` when relocated — never the workspace). `queue-manager.ts` is unchanged.
598
+
599
+ **ai_invocations integration**: every `FileSummaryManager` model call writes a row with `surface='file-summary'`, surfacing cost on `/analytics` (Hero, By Surface, Daily Timeline use `accent-warning`). `spending.invalidated` is broadcast on each row. Cross-reference: the Project spending analytics section now lists `file-summary` alongside `job`, `quick-spec`, `explore-spec`, `ai-edit`, `smash`.
600
+
601
+ **WebSocket events** (project-scoped via `boundBroadcast`): `file.provenance_updated`, `file.summary_updated`, `file.summary_failed`, `file.summary_skipped` (`reason: 'budget'|'per-job-cap'|'ttl'|'not-found'`), `file.story_updated` (`{ path, provenanceId, ok, reason? }` — open story panels for that file refetch).
602
+
603
+ **App settings** (`GET/PATCH /api/code-explorer-settings`): `summary_language` (`'en'`|`'es'`, default `'en'`), `summary_monthly_budget_usd` (default `5.00`). Surfaced in `GlobalSettingsPage` under "Code section", hidden when client flag is off.
604
+
605
+ **Client**: `client/src/features/code/pages/CodePage.tsx` (two-pane), `client/src/components/code-explorer/` (`FileTree`, `FileViewer`, `SummaryHeader`, `CodeViewerMonaco`, `TicketFilesTouched`, `ConstructionStory`). **Construction story**: `FileViewer`'s bottom panel defaults to the narrative Story view (`ConstructionStory` — premium vertical timeline of glass cards: kind, spec chip → `TicketDetailModal`, status pill, `+N/−N` line stats, the AI contribution paragraph or the honest fallback (kind + spec + date, never invented), per-card `Explain this change` with inline budget-override) behind a `Story | Log` toggle persisted at `localStorage['specrails-desktop:code-history-mode:<projectId>']`; Log is the pre-existing raw `ProvenanceTimeline` with on-demand diffs. Agent-Mode Files parity is free (`AgentModeCodePane` embeds `CodePage` → `FileViewer`). i18n: `story.*` block in the `code` namespace ×8. See `docs/internals/code-explorer-story.md`. Monaco loaded via dynamic `import()` so the main route chunk is unaffected when the flag is off; web workers configured via `client/src/features/code/lib/monaco-setup.ts` (Vite `?worker` imports, no `monaco-editor-vite-plugin`). Theme bound to `useActiveTheme()`. URL-synced selection (`?path=<rel>`). Filter `Tocado por IA` (default) ⇄ `All files` persisted to `localStorage['specrails-desktop:code-tree-filter:<projectId>']`. Chip clicks open `TicketDetailModal` via `TicketDetailModalProvider`. `TicketDetailModal` adds a "Files touched by this ticket" section (lazy-fetched, hidden when empty; row click navigates to `/code` and closes the modal).
606
+
607
+ **v1 limitations (out of scope)**: in-app editing, per-symbol summaries, conversational "ask the AI about this file", directory-level summaries, multi-ticket attribution (primary ticket only). The Monaco viewer surfaces an "Edit in external editor" button that copies the absolute path as a stopgap.
608
+
609
+ **Rollback**: set both flags off. `file_provenance` rows and `<project>/.specrails/file-summaries/` files remain on disk; migrations are additive.
610
+
611
+ ### Jira integration (spec = Jira issue)
612
+
613
+ Per-project, hot-swappable integration where a project's specs are backed by a **Jira board** instead of the local `local-tickets.json`. **Each project syncs with its own Jira board** (all Jira state is per-project in `jobs.sqlite`). Gated by `SPECRAILS_JIRA_SECTION` (server, default ON, `="false"` 404s the routes + skips sync) and `VITE_FEATURE_JIRA` (client). Inert until a project configures a connection. Full design + corner-case catalogue: `docs/jira-integration-plan.md`. See [[jira-integration-plan]] memory.
614
+
615
+ **Central design — "Desktop is the sync layer; core is untouched":** the local store stays the canonical READ cache that specrails-core reads. On connect, Desktop writes `backlog-config.json` `{provider:"local", write_access:false}` (`server/jira/jira-backlog-config.ts`) so core enters its READ-ONLY branch — it reads the materialized cache but never mutates status nor talks to Jira. Desktop's `applyJobOutcomeToTickets` + a durable outbox are the sole status authority. **(Under artifact relocation this is RE-STATED, no longer literally "ZERO specrails-core changes": core now reads the relocated `local-tickets.json`/`backlog-config.json` from `<workspace>/.specrails/` via `SPECRAILS_TICKETS_PATH`/`SPECRAILS_BACKLOG_CONFIG_PATH` (env-first) or `registry.json` (fallback) instead of fixed repo-relative paths; `write_access:false` still forces the read-only branch, and the outbox + `applyJobOutcomeToTickets` remain the sole status authority. Desktop MUST always inject `SPECRAILS_BACKLOG_CONFIG_PATH` so core never falls through to its default `write_access:true` branch. See Artifact relocation.)**
616
+
617
+ **Server (`server/jira/`)**:
618
+ - `jira-sync-manager.ts` — `JiraSyncManager`, one per `ProjectContext` (constructed in `project-registry.ts` BEFORE QueueManager so the `onJobFinished` closure can call it). Owns the inbound poll loop, the durable outbox drainer, and the write-back hooks. Inert when no connection.
619
+ - `jira-client.ts` — REST client. Cloud (v3, Basic `email:token`, ADF bodies) vs Data Center (v2, Bearer PAT, wiki-string bodies), branched by `detectDeployment`. Every call returns a normalised `JiraResult<T>` whose `code` (`auth|permission|not_found|rate_limit|validation|server|network`) drives retry/dead-letter.
620
+ - `jira-status-resolver.ts` — the hard part. Two-tier resolution (explicit per-project `statusMap` → `statusCategory.key` fallback `new|indeterminate|done` + cancel/ship lexicons) + `walkToCategory` BFS over workflow-gated transitions (per-hop `getTransitions`) + `buildTransitionFields` (resolution/required-screen handling). Pure (callbacks, no HTTP).
621
+ - `jira-materializer.ts` — inbound issues → `local-tickets.json`, SURGICAL merge via `mutateStore` (preserves un-linked local specs), collision-safe `#id` allocation (UNION of store `next_id` and `jira_links` max), frozen-status guard (never reverts an id with a pending outbox op).
622
+ - `jira-db.ts` — per-project tables (migration 29): `jira_connection` (token stored AES-256-GCM-encrypted), `jira_links` (keyed on the IMMUTABLE Jira numeric id, never the mutable `PROJ-123` key; local id monotonic + tombstoned), `jira_outbox` (durable transactional queue, idempotent on `idempotency_key`, FIFO-per-issue via `claimDrainable`).
623
+ - `jira-credential-store.ts` — `SecretStore` interface; v1 backend = AES-256-GCM (`node:crypto`) under a `~/.specrails/jira-secret.key` 0600 keyfile. v2 keychain swap is one file. Token redacted to `hasToken` for the client.
624
+ - `jira-adf.ts` — ADF build/flatten + the invisible comment self-marker (`[specrails:job=…:ticket=…]`) that makes completion comments idempotent.
625
+
626
+ **Lifecycle hooks (the write-back chokepoints):**
627
+ - `rails-router.ts` launch handler → `jiraSyncManager.onRailLaunch(ticketIds, jobId)`: enqueues an In Progress transition AND writes `in_progress` to the local cache (because `write_access:false` stops core from writing it — the app MUST emit it; verified neither server nor core does otherwise).
628
+ - `project-registry.ts` `onJobFinished` (after the local `applyJobOutcomeToTickets` + broadcast loop) → `jiraSyncManager.onJobOutcome(...)`: enqueues the status transition (Done / revert→To Do) + a completion comment (job id, cost, duration). `needs_review` → comment + no transition (no Jira equivalent). The local mutation stays SYNCHRONOUS; only the durable outbox enqueue happens here, wrapped so a Jira failure never breaks job exit.
629
+ - **Ask-first PR delivery (safe-pr-review-flow, universal):** a completed run/job whose tickets park at `on_review` diverts to `onRailReview(ticketIds, refId)` (on_review transition, `statusMap.on_review` → category fallback `indeterminate`) instead of the done-flavoured `onJobOutcome` — the split lives in BOTH completion chokepoints (`onLoopRunFinished` with `refId=runId`, and `onJobFinished` with `refId=jobId` for QueueManager jobs); failed/canceled/zombie outcomes keep the legacy `onJobOutcome` enqueue. The PR-decision endpoint fires `onRailMerged(ticketIds, refId, prUrl)` (Done + "PR merged: <url>" comment) and `onRailDiscard(ticketIds, refId)` (configured `discardStatus`, else logical todo). All three are outbox-only — the local cache write belongs to the caller.
630
+
631
+ **Outbox drain** (`drainOnce`): idempotency-first (re-GET issue, no-op if already in target category), comment dedup by scanning for the self-marker, 401→pause project + `jira.auth_expired`, 403→dead-letter naming the op, 404→tombstone link, 429→honour `Retry-After`, no_path/blocked→dead-letter + `jira.degraded`, server/network→backoff retry. Crash recovery: `resetInflight` on start.
632
+
633
+ **Inbound = polling only** (the server binds `127.0.0.1`, no public ingress; Jira webhooks need a public URL). `POST /rest/api/3/search/jql` (legacy `GET /search` is dead), high-water mark derived from Jira's own `updated` timestamps (self-corrects clock skew) + 2-min overlap + `nextPageToken`.
634
+
635
+ **REST (`server/jira-router.ts`)** mounted at `/api/projects/:projectId/jira`: `GET/PATCH/DELETE /connection`, the wizard probes `POST /test|/discover-projects|/discover-statuses|/connect`, `POST /sync|/resume`, `GET /outbox` + `POST /outbox/:id/retry`, `POST /specs` (Add Spec → create Jira issue), `GET /links`. **WS events**: `jira.synced`, `jira.sync_error`, `jira.auth_expired`, `jira.outbox_changed`, `jira.degraded` (all project-scoped).
636
+
637
+ **Auth v1 = token-paste** (Cloud Basic email+token / DC Bearer PAT) — zero backend, credential stays on-device, covers Cloud + DC. OAuth 3LO deferred to v2 (needs a hosted token-broker). **Guardrail**: do NOT add a `jira-sync` value to `ai_invocations.surface` (sync ops have no model/token/cost).
638
+
639
+ **Client**: `client/src/features/integrations/components/jira/JiraConnectWizard.tsx` — the reusable step-by-step connect flow (Test → pick project → optional status map → Connect), with optional `onSkip` ("do it later") + an explicit `apiBase` for the setup context. Mounted in `client/src/components/settings/JiraSettingsSection.tsx` (project `SettingsPage` — wizard when disconnected; connected card with hot-swap toggle, Sync now, dead-letter retry, disconnect). The old second mount in the Add-Project setup wizard's Done step is gone with the wizard (global-core-zero-friction); discovery is the `WelcomeScreen` Jira hint pointing at Settings. `WelcomeScreen` shows a one-line Jira hint. `client/src/features/integrations/lib/jira-api.ts` is the API client (every method takes an optional `apiBase`). `SpecCard` renders a `jira_key` badge. New i18n namespace `jira` (all 8 locales) + `setup` keys `wizard.complete.jira.*` / `welcome.jiraHint`. `LocalTicket` gains additive `jira_key`/`jira_url` + `source:'jira'`.
640
+
641
+ **Hot-swap**: `source` is per-ticket, not per-board. Toggling enable/disable (or disconnect) never re-homes existing specs; write-back is gated on the per-ticket Jira link, captured at launch.
642
+
643
+ **Rollback**: set the flags off, or Disconnect (restores `backlog-config.json` `write_access:true`). Migration 29 + `jira_*` rows are additive and harmless when unused.
644
+
645
+ ### Native embedded browser
646
+
647
+ In-app links (`WebViewModal`) use the native Tauri child webview when `FEATURE_NATIVE_BROWSER` and the runtime probe allow it. Mission capture (`AgentBrowserCapture`) uses it on macOS when `browser_capture_supported` confirms the snapshot API. Browser development, unsupported runtimes and failed native opens use the instrumented Chromium screencast. Advanced spec capture stays on Chromium for DOM/network/breakpoint capture.
648
+
649
+ `NativeBrowserPane.tsx` renders the toolbar and a measured hole; the OS composites the page inside that rectangle. Geometry updates are deduplicated. App HTML cannot cover a native child surface, so annotation hides the pane and returning shows the same page without reloading. Every command and event carries an `ownerId`; effect lifetimes use distinct owners, and stale cleanup cannot close a newer pane. The component is covered by lifecycle tests and actual rendering by the macOS smoke fixture.
650
+
651
+ Rust implements navigation in `src-tauri/src/browser.rs` and same-page PNG snapshots plus an isolated fixed picker in `browser_capture.rs` / `.js`. HTTP, HTTPS and `about:blank` are the only allowed schemes; localhost development servers are supported. Popups use the opener's platform configuration so OAuth cookies/postMessage work, have owner-scoped labels, and close with their pane. The custom invoke handler rejects all app commands from views other than `main`; plugin capabilities also target the `main` webview without window-wide inheritance. Both boundaries are required because custom commands otherwise lack an app ACL manifest.
652
+
653
+ Authentication windows can open nested windows within a per-pane limit. `browser_popup.rs` forwards the existing macOS Wry UI delegate and adds the missing public `webViewDidClose:` callback. Never replace `window.open`/`window.close` in remote page JavaScript or recreate OAuth windows without the engine's opener configuration. The streamed path replays already-open child windows with direct-opener ownership checks, reconciles rapid closure and targets browser navigation at the visible page. Popup URLs must not overwrite the persisted root URL, and failed view switches must not enable root capture optimistically. Reproduction and platform limits: `docs/internals/browser-login-popups.md`.
654
+
655
+ Screenshots retain physical display pixels. Element selection intercepts page actions, including long presses and clicks over cross-origin iframes (selected as whole iframe elements), and supports open Shadow DOM. The shared `ImageAnnotationEditor` preserves natural resolution and retains annotations when export or upload fails. Browser sessions use the platform cookie store (Windows: `~/.specrails/native-browser-profile/`; macOS: default WKWebsiteDataStore). Windows native z-order QA remains an open follow-up. Findings and reproducible checks: `docs/internals/browser-native-retina-audit.md`.
656
+
657
+ ### Desktop MCP server (expose Specrails to external LLMs)
658
+
659
+ > **Pre-release / uncommitted.** Implemented on the working tree (OpenSpec change `add-desktop-mcp-server`). User-facing reference: `docs/mcp.md` + the in-app guide `docs/guide/<lang>/integrations/5-mcp-server.md` (all 8 langs). Distinct from the app-as-MCP-*consumer* surfaces above (plugins `.mcp.json`, user-approved MCPs) — this is the app exposing **itself** as an MCP server.
660
+
661
+ The embedded MCP server lets **any MCP client** (Claude Desktop, Cursor, Cline, custom agents) drive ~100% of the platform. Default **ON** (`isMcpEnabled` + `isTierEnabled` in `server/mcp/mcp-tiers.ts` read `!== 'false'`, so a fresh install is reachable from the user's AI tools out of the box and only an explicitly stored `false` disables — the same convention as the app's feature flags; `server/index.ts` pre-mints `~/.specrails/mcp.token` at boot so the stdio bridge finds it before `Settings ▸ MCP` is ever opened); the user can switch MCP or any tier off in `Settings ▸ MCP`. It mirrors the **Mobile Gateway** opt-in pattern (settings-gated second surface in the same sidecar, scoped token, taps the same `broadcast()` bus).
662
+
663
+ **Transport + mount (`server/mcp/`).** `McpServerManager` (`mcp-server.ts`) owns the SDK's **stateful** `StreamableHTTPServerTransport` (one transport per `mcp-session-id`), mirroring `MobileGateway`'s `isEnabledSetting`/`setEnabled`/`start`/`stop`/`status`. Mounted at `/api/mcp` in `index.ts` **before** the global `express.json` (own 4mb parser) **and before** `app.use('/api', requireAuth)` so it does NOT inherit the master token — gated by `requireLoopback` + `requireMcpAuth` (the **scoped** token at `~/.specrails/mcp.token`, `mcp-token.ts`, separate from the all-powerful `desktop.token`). Boot/teardown on the `mcp_enabled` setting without a server restart; sessions closed in `shutdown()`.
664
+
665
+ **Tools = loopback REST (`server/mcp/tools/`).** 22 tools (including `_context`, `_env` and `_support`): 12 **domain-facade** tools (`specrails_specs`/`_rails`/`_jobs`/`_chat`/`_agents`/`_plugins`/`_jira`/`_loops`/`_code`/`_setup`/`_analytics`/`_git`), each with an `action` enum, plus `_projects`/`_settings`/`_watch`/`_guide`/`_search`/`_describe`/`_select_project`. **`specrails_git`** (`server/mcp/tools/git.ts` → `GET /:projectId/git/diagnostic`, backed by `server/git-diagnostics.ts`) is the sanctioned read-only git/GitHub surface for the operator agent (answers "is this repo connected to GitHub?", remote/status/log/diff/branch, `gh repo view`/`auth status`/`pr list`/`pr view`) using the app's BUNDLED git/gh — so the agent never refuses or pushes git questions back to the user's terminal. It's a FIXED allowlist (the caller names an action, never args), all read-only + `tier:'read'`; git runs with the hardened `gitExecEnv()`, gh with the user env for `~/.config/gh` auth. Repo MUTATIONS (push, pr create/merge, commit) are deliberately NOT here — they belong to the ask-first PR flow. Domain tools do NOT re-implement manager logic — they call the app's OWN REST API over loopback with the **master token held server-side** (`apiCall` in `tools/types.ts`), reusing all router logic/validation. `registerTieredTool` wraps every handler with tier enforcement + result/error shaping. Read state is also exposed as MCP **resources** (`specrails://guide`, `specrails://projects[/{id}]`). `specrails_guide` is the machine-facing platform manual (English); `specrails_watch` bridges the pervasive `202 + WebSocket` async pattern via `getMobileEventBus()`.
666
+
667
+ **Four-tier permission model (`mcp-tiers.ts`, design D7).** Every action declares a tier — **Read** (always on when enabled) / **Write** / **AI-spawn** (cost) / **Destructive** — persisted as `desktop_settings` booleans (`mcp_tier_*`), **all ON by default (opt-out)**: unset ⇒ enabled, only the literal `'false'` disables. Refusal of a disabled tier returns an LLM-readable message naming the tier. The MCP tools **cannot** enable their own tiers (no privilege escalation) — only the user, in the panel. **v1 scope excludes** the highest-risk execution vectors (terminal shell-exec, browser-capture, `code_write_file`, the `uv` prereq installer, the global `~/.claude/settings.json` marketplace mutation).
668
+
669
+ **MCP reliability and project context (2026-09-05).** External selections are isolated per session; verified mission pins (including Home) take precedence, so use explicit `projectId` for an intentional target in Home. `specrails_context` returns bounded live overview/backlog/runs/git/blueprint sections with sources and partial availability. Catalog tools/resources retain registered-but-unavailable projects. `specrails_describe` exposes nested SDK JSON schemas and action tiers; optional argument validation is schema-only and never executes. Mixed facades have conservative MCP annotations. `specrails_code(search)` searches literal source text; ranged reads carry continuation coordinates and revision hashes. Watch checks durable jobs/loop runs before waiting, matches exact typed refs, bounds event history and supports cancellation. Invalid or revoked mission capabilities fail closed; sessions are capability-bound and cleaned on revoke/expiry/idle. The bridge refreshes rotated credentials and only recovers rejected session-404 requests, never ambiguous network mutations. Fresh mission invocations restore bounded persisted history; scoped references cannot fall back into another project. Details/tests: `docs/internals/mcp-mission-audit.md`.
670
+
671
+ **Admin control plane (`mcp-admin-router.ts`).** `/api/mcp-admin` (master token + `requireLoopback`, the desktop user — NOT the third-party client): `GET /status`, `POST /enable|/disable`, `PATCH /tiers`, `GET /token` + `POST /regenerate-token` (deliberate loopback-only token reveal), `GET /config`.
672
+
673
+ **stdio bridge (`mcp-bridge/`).** `specrails-mcp` is a transparent stdio↔HTTP relay (carries NO tool catalog) — `StdioServerTransport` facing the client + SDK `Client`/`StreamableHTTPClientTransport` facing `127.0.0.1:4200/api/mcp`, reading the scoped token locally so it never lands in client config; clear "Specrails app is not running" error when down. Built by `scripts/build-mcp-bridge.mjs` (esbuild → `src-tauri/binaries/specrails-mcp.js`), run by the app's **bundled Node** (Option A — no separately code-signed binary); shipped via `tauri.conf.json` `bundle.resources`.
674
+
675
+ **Background-run prerequisite (the tray).** The MCP is reachable only while the app process lives, so `src-tauri/src/lib.rs` now keeps the sidecar alive when the window closes: `CloseRequested → prevent_close() + hide()` (was: kill the sidecar), a tray/menu-bar item (Open / Exit, labels localized in all 8 langs via the `set_tray_labels` IPC), `tauri-plugin-single-instance`, and sidecar termination only on tray **Exit** / `RunEvent::ExitRequested`. macOS Dock retained (no `ActivationPolicy::Accessory`). **Reopen is per-platform**, because a hidden window is reachable differently on each OS: macOS keeps its Dock icon, so clicking it never launches a second process — it raises `applicationShouldHandleReopen`, handled as `RunEvent::Reopen { has_visible_windows }` in the run loop (`!has_visible_windows → show_main_window`, so clicking the Dock icon of a *visible* app is a no-op); Windows hides the taskbar button with the window, so the user relaunches from the taskbar/Start shortcut and the `tauri-plugin-single-instance` callback focuses the existing instance instead of spawning a second sidecar. All three doors (tray Open, Dock reopen, single-instance relaunch) funnel through the same `show_main_window` (show → unminimize → focus).
676
+
677
+ **Client + onboarding.** `Settings ▸ MCP` panel (`client/src/features/settings/components/McpSettingsSection.tsx`, gated by `FEATURE_MCP`, default on) drives the admin routes (enable, tiers, copy config/token). `useTrayLabels.tsx` pushes localized tray labels on mount + language change (sends `{open, exit}`). A one-line MCP hint appears on `WelcomeScreen` (`welcome.mcpHint`, gated `FEATURE_MCP`) — the setup wizard's second hint died with the wizard (global-core-zero-friction). New i18n namespace `mcp` (all 8 locales) + the in-app user guide.
678
+
679
+ **Build gotcha.** The MCP SDK (`@modelcontextprotocol/sdk@^1.29.0`, + `zod`) is `type:module`/exports-map-only, so the server `tsconfig.json` moved to `module:preserve`/`moduleResolution:bundler` to resolve its types; a new `tsconfig.build.json` (`commonjs`) keeps `build:server` emitting CJS (the CLI spawns `node server/dist/index.js`) via `tsc -p tsconfig.build.json --noCheck`. SDK imports MUST use the `.js` subpaths; `registerTool` `inputSchema` is a RAW zod shape (not `z.object`).
680
+
681
+ **Frozen contracts preserved**: master `desktop.token` + `/api/token`, mobile-ws wire compat, bundle id `sh.specrails.hub`.
682
+
683
+ ### Desktop agent chat (operate Specrails from within Specrails)
684
+
685
+ > User-facing reference: the in-app guide `docs/guide/<lang>/integrations/6-agent-chat.md` (all 8 langs) + Welcome hints (`welcome.agentChatHint` / `wizard.complete.agentChatHint`) + the Onboarding tour step (`onboarding.mission.*` — the tour leads with Mission mode right after the welcome, then the Builder; Board mode is presented as one switch away). This is the "future (not now): auto-connect an in-app Specrails Chat to its own MCP" from the MCP design — the app driving **itself** through its own embedded MCP server.
686
+
687
+ A **global, app-level** chat (not per-project) that operates the whole app by calling the embedded Specrails MCP. Gated by `SPECRAILS_AGENT_CHAT` (server) / `VITE_FEATURE_AGENT_CHAT` (client), default ON. Distinct from the pipeline **agents** (Architect/Developer/Reviewer) — this is one assistant that drives the app.
688
+
689
+ **Server** — `AgentChatManager` (`server/modules/missions/runtime/agent-chat-manager.ts`) is a sibling of `ChatManager` that reuses the shared `runAiCliInvocation` core but spawns from an app-level cwd (`server/modules/missions/runtime/agent-cwd-manager.ts`, `~/.specrails/agent-cwd/` with operator `CLAUDE.md`/`AGENTS.md`/`GEMINI.md` always re-written) and streams over app-global `agent_stream`/`agent_done`/`agent_error`/`agent_tool` WS events (no `projectId` → fans to all). Conversations live app-globally in `desktop.sqlite` (`agent_conversations`/`agent_messages`, migration 17; CRUD in `server/modules/agents/runtime/agent-store.ts`). REST at `/api/agent/*` (`server/modules/missions/runtime/agent-chat-router.ts`): conversations CRUD, `/send` (202, streams over WS), `/models?provider=`, provider/model/tier/project PATCH. **Provider-aware MCP wiring** (`server/agent-mcp-config.ts` `prepareAgentMcp`): claude → `--mcp-config` file; codex → inline `-c mcp_servers.specrails.*` overrides (no `CODEX_HOME` override → auth intact); gemini → `mcpServers` merged into `<agent-cwd>/.gemini/settings.json` + `GEMINI_CLI_TRUST_WORKSPACE=true` per spawn (gemini-cli has NEVER read `.mcp.json`, and an untrusted cwd suppresses MCP entirely / exits 55 headless — verified vs gemini-cli 0.49.0; gemini surfaces the tools FQN-prefixed as `mcp_specrails_<name>`, which the operator prompt notes); Kimi → `mcpServers` merged into `<agent-cwd>/.kimi-code/mcp.json`; other project-json providers → the adapter's native `projectMcpPath` (falling back to `.mcp.json`) — all pointing at the bundled `specrails-mcp` bridge (nothing installed). See [`docs/internals/gemini-mcp-registration.md`](docs/internals/gemini-mcp-registration.md) for the gemini facts + the deferred `.mcp.json` gap on other gemini surfaces. The **cumulative Shift+Tab tier ladder** (Observe/Edit/Operate/Autonomous, `server/modules/missions/runtime/agent-tier.ts`) is enforced **per request** via a loopback header at the single tool guard (`registerTieredTool` reads `x-specrails-agent-tier`), independent of the external Settings▸MCP checkboxes; the pinned project rides the same header path (`x-specrails-active-project`). The operator prompt (`server/modules/missions/runtime/agent-operator-prompt.ts`) enforces ask-confirmation-once turn discipline: a confirmation question is asked EXACTLY once and ends the reply immediately (the answer always arrives as the next user message — never repeat the question or narrate waiting). Robustness: session/model reset on provider switch + auto-heal (a resume that yields no text retries fresh — fixes codex "no rollout found"); a stale/foreign model (claude's `sonnet` on codex) falls back to the provider default. `spawn-lifecycle.ts`'s stdout handler is wrapped so a buggy adapter parser can never crash the sidecar.
690
+
691
+ **External MCP servers (missions).** App-level registry of the USER's own MCP servers for mission spawns, stored as ONE JSON blob in `desktop_settings['external_mcp_servers']` (`server/external-mcp.ts`; precedent: `specrails_agent_defaults`). Two entry kinds keyed by stable id: `discovered` (`d:<sourceProvider>:<name>` — selection only, transport re-resolved LIVE at spawn from the provider's native user config: claude `~/.claude.json`, gemini `~/.gemini/settings.json`, kimi `~/.kimi-code/mcp.json`; an entry gone from its source is skipped + badged, never breaks the turn) and `custom` (`c:<name>` — full stdio transport in the blob). Codex is discovery **names-only** (regex over `[mcp_servers.*]` headers, display "native, always on" — codex missions keep the real `CODEX_HOME` and load its config.toml servers natively; headless claude likewise auto-loads user-scope servers, so a claude-tick on a claude-discovered entry is an idempotent same-name merge). Per-provider activation matrix; cross-provider injection is the point (register once in claude, use in kimi). Injection extends `prepareAgentMcp` 1→N: claude gets all entries in the same per-conversation `--mcp-config` file, codex gets extra `-c mcp_servers.<name>.*` (TOML-safe names + string command only), gemini/kimi get merged keys with a sidecar ownership file (`external-owned.json` — each spawn removes previously-owned keys before merging the enabled set, so disable removes on next spawn; foreign keys + `specrails` untouched). Consent-first: discovery only lists, the per-provider tick IS the consent (mission spawns run `--dangerously-skip-permissions` and external tools live OUTSIDE the tier ladder — warning copy in the card); name `specrails` reserved; PATCH rejects duplicate injected names per provider. REST `GET/PATCH /api/external-mcp` (desktop-router); UI = `ExternalMcpServersCard` in Settings ▸ MCP; i18n `mcp:external.*` ×8. Operator prompt discloses external tools may exist and pins app operations to `specrails_*`. Kill switch `SPECRAILS_EXTERNAL_MCP=false` ⇒ byte-identical legacy wiring. Missions-only v1 (rails/loops/Explore untouched).
692
+
693
+ **Review packet in Mission mode.** `/review/:prDeliveryId` is a Board route; in Mission mode it is the third `modalize` surface (`global-route-mode-transition.ts` `reviewDeliveryIdForPath`): `App.tsx` embeds the same `ReviewPacketPage` (`prDeliveryId` + `onClose` props) in a mission modal without resetting the conversation, and Board mode routes to the page — every Review button (rail strip, PR card, milestone card, toasts) works in both modes.
694
+
695
+ **Client** — `AgentChatContext` (`client/src/features/missions/context/AgentChatContext.tsx`) mounted at App root (inside `DesktopProvider`), between `MinimizedChatsProvider` and `TicketDetailModalProvider`. A persistent draggable **bubble** (bottom-center, position persisted) is the single entry point; also `⌘⇧A` / `Ctrl+Shift+A` (NOT `⌘K` — that's the command palette). The glass panel (`client/src/components/agent-chat/`) is movable/resizable (`useMovableResizableModal` with `anchorFromCurrentRect` + `persistKey`), maximizes, minimizes back to the bubble. Streaming renders **plain text + caret** (no per-frame markdown reparse), settling to rich markdown on completion; a single synthetic **activity chip** shows the current tool — and is now a BUTTON that opens the **execution-log modal** (`AgentActivityLogModal.tsx`, hand-rolled portal at z-[65] like `LoopPreviewModal`): every tool call of the turn with bounded input/output previews, copy-all + per-entry copy, live-follow autoscroll, empty state for providers without tool events (gemini). Data flow: `agent_tool` WS now carries `input` (adapter `inputPreview`) + `toolId`, and the new `agent_tool_result` WS (fed by the new `tool-result` AdapterEvent kind — claude-only: `user`-role `tool_result` frames, 600-char preview, `is_error`) merges outputs into the entry by `toolId` (fallback: last entry without output). The context keeps the settled turn's activity as `turnTools` per conversation (session-only, capped at 200 entries) so an open modal survives `agent_done`; `AgentActivityChip` stays a plain div for callers without an activity stream (BuilderConversation). i18n `agent:activity.*` ×8. Prompt history on ↑/↓; per-bubble copy. Messages sent mid-turn queue server-side (FIFO chips); while the queue is non-empty ↑/↓ navigates the QUEUED messages instead and edits them in place — Enter saves back to the slot (`PATCH /conversations/:id/queue/:queueId`, 409 = already dispatched → the text is kept as a draft), Esc cancels, and the un-sent draft is stashed/restored; a dirty edit never navigates away. Header = project selector (Cursor-style, Home = app-global) **paired with a mission selector** (`AgentMissionSelector.tsx`, same trigger/dropdown grade): conversations newest-first with relative time (date-fns locale), a live pulse dot per streaming conversation, a queued-count badge (fed by the context's `liveByConversation` map), a "New mission" top action (reuses `startNewConversation`), search above 8 missions, full listbox keyboard nav (↑/↓/Enter/Esc/Home/End), and a per-row two-step inline delete (trash → ✓/✕, 3s auto-revert, live-stream warn copy; the DELETE route aborts live turns) that hands the active thread off to the newest remaining mission — Agent Mode intentionally keeps its ArcSidebar conversation tree instead (no duplication). The `provider·model·tier` bar sits above the composer. **Project binding is bidirectional in Agent Mode:** `setPinnedProject` already moved the sidebar's active project when the user picked one in the mission selector, and the reverse now holds too — an effect in `AgentChatContext` watches `activeProjectId` and binds the mission the user is composing (no conversation → draft pin; conversation with zero messages → `patchActive({ pinnedProjectId })`). Two guards keep it honest: only an actual CHANGE binds (the mounted value is recorded and skipped, so an explicitly Home-pinned mission is never converted on first render), and a mission that already carries messages keeps its project because its transcript, tool calls and `#ref` resolution are scoped to it. Reading the project from context (not from a click handler) means the invariant holds whichever surface moved it — sidebar, command palette, ref chip, minimized-chat restore. Board mode is excluded, so the floating panel's pin is untouched. `agent` i18n namespace ×8. Dep: `motion`.
696
+
697
+ **Super specs (agent spec authoring).** The operator prompt's spec-refinement mode (`server/modules/missions/runtime/agent-operator-prompt.ts`) mandates grounding in the real codebase BEFORE proposing (a per-spec-type checklist over `specrails_code` tree/read_file/summary + `specrails_specs(list)` — the agent's "Desktop-preset" awareness, exercised through the MCP read tools since it has no raw repo shell), pins the five-section description contract with a quality bar (real file paths/identifiers in Technical Considerations, reasoned complexity), and replaces the old prose "Draft so far" card with a fenced ```` ```spec-draft ```` JSON protocol (same field shape as Explore's `server/modules/specs/runtime/spec-draft-parser.ts`) that `AgentMessage` extracts (`client/src/features/missions/components/agent-spec-draft.ts` — last-valid-snapshot-wins, streaming tail cut) and renders as a premium in-conversation draft card (`AgentSpecDraftCard.tsx`; `agent:specDraft.*` i18n ×8). A side-panel live draft (Explore-style) is deliberately v2. Persisting stays `commit_draft`; the Contract Layer enrichment is ON by default (see "Agent-authored lifecycle" under Explore Contract Refine). The prompt also encodes **launch-then-release**: after a rail/job launch 202 the agent ends its turn immediately (progress streams via the run/PR card + rail header) instead of blocking on `specrails_watch`, which is reserved for an explicit user "wait for it".
698
+
699
+ **Browser capture into missions.** The agent browser-capture modal mounts ONCE globally (`AgentBrowserCaptureHost` at App root) so BOTH agent surfaces reach it: the Agent-Mode workspace sidebar tool AND the composer's "+" menu entry (`AgentPlusMenu` `canBrowserCapture`/`onOpenBrowser` — enabled whenever capture is on and a project is active, floating panel included). `AgentWorkspaceProvider` deliberately wraps `AgentChatProvider` in `App.tsx` — the floating panel renders inside `AgentChatProvider`'s own JSX next to `{children}`, so the old inner nesting handed its composer the NOOP workspace context and silently dropped captures. A capture needs NO active conversation: on the empty compose screen `AgentBrowserCapture` materializes the draft mission first (same contract as attaching a file), and `materializeDraftConversation` migrates the new-mission composer drafts (text + attachment chips) to the created conversation id BEFORE flipping `active` (shared store `client/src/features/missions/lib/agent-composer-drafts.ts`), so externally-triggered materialization never loses typed text. The capture modal's select-mode toggle reads `selectLabel` ("Select to add to mission", `agent:workspace.browserSelectStart` ×8) instead of the default "Select to create spec". Composer attachment chips render image attachments (uploads AND captures) as real THUMBNAILS (`AgentComposerAttachmentChip`, authenticated blob fetch + object-URL, revoked on unmount); non-images keep the paperclip pill. **Instant git refresh:** client-side repo mutations (PR-card Checkout, Integrate locally, Discard, Create PR, Recover & retry) call `notifyGitChanged(projectId)` on the window-event bus `client/src/features/code/lib/git-refresh.ts`; `AgentGitBar` subscribes and refetches immediately instead of waiting for the next turn settle.
700
+
701
+ **Pinned implementation card (agent chat).** The PR-decision card stays **always visible, pinned just above the composer**, while its delivery demands attention — PINNED for decision ∈ {`building`, `on_review`, `pr_draft`, `pr_failed`}; UNPINNED (returns to its chronological history slot) at `pr_ready`/`merged`/`discarded`. Derivation is purely client-side from the conversation's existing system-row envelopes: `client/src/features/missions/components/agent-pr-pinning.ts` (`PINNED_PR_DECISIONS`, `derivePrCards`) parses each system row ONCE per message-state change (memoized on the `messages` array in `AgentConversationView` — stable across streaming frames, and the history render reuses the same map, so no per-frame reparse and no new fetches; the `agent_pr_decision` WS upsert in `AgentChatContext` is unchanged). `AgentPrPinnedDock` (`AgentPrPinnedDock.tsx`) mounts above the composer in BOTH surfaces via the shared `AgentConversationView` (floating panel + Agent-Mode inline; inline centers to the composer's 680px column): EVERY pinned envelope renders its own FULL `AgentPrDecisionCard` (reused verbatim), stacked oldest→newest (newest adjacent to the composer, scroll-bounded at 45vh) under one slim "Pinned" header with a count badge — parallel rails each keep an independent card advancing at its own pace. Each card carries a per-card header row (rail + ticket ids + decision pill) with an INDEPENDENT collapse-to-header toggle (keyed by `prDeliveryId`, sessionStorage), and a dock-level chevron collapses everything to a slim one-row bar (per-conversation, **sessionStorage** — session-only by design). While pinned, the card's history slot renders a slim reference marker (`agent-pr-pinned-marker`: pin icon + "pinned above" + `PrDecisionPill`) instead of the card — never a double render; unpinning animates the dock away (`AnimatePresence`) and the full card resumes its chronological slot. i18n: `agent:prCard.pinned.*` ×8.
702
+
703
+ **Clickable refs in mission messages.** SETTLED assistant messages (never the streaming buffer — the `useSmoothStream` caret path is untouched) linkify three reference shapes into premium inline chips via the `remarkAgentRefs` remark plugin (`client/src/features/missions/lib/agent-refs.ts`, pure + unit-tested; refs are emitted as `#agentref:` link fragments so react-markdown's URL sanitizer passes them through): **ticket refs** `#N` (word-bounded 1–6 digits, optional same-line `— Title` tail folded into the chip label; `accent-primary`), **job/loop-run UUIDs** gated on a job/run/loop context word on the same raw-source line, EN+ES (`accent-info`; loop-run ids ARE job row ids), and **loop refs** (`accent-highlight`): the literal `factory:implement|batch|freestyle` tokens (ungated — unambiguous; backticked forms also convert) plus, at CLICK time, any job-detected UUID that 404s as a job and resolves against the app-global loops API (`useAgentRefActions` fallback — a loop DEFINITION id mentioned in loop-talk opens its loop instead of a dead "job not found"). Loop chips open the read-only `LoopPreviewModal` (`client/src/features/loops/components/LoopPreviewModal.tsx` — hand-rolled body portal at z-[65] because Radix Dialog is pinned at z-50 under the floating panel; steps/limits/status via the exported `NODE_ICON`/`nodeDetail` from `TemplatePreviewModal`, "Open in builder" for non-factory loops, `Built-in` pill for locked factory ones; i18n `loops:preview.openInBuilder`/`preview.builtin` + `agent:refs.openLoop`/`refs.loopNotFound` ×8). Code blocks/inline code/existing links are excluded at the AST level. Refs resolve against the conversation's **pinned** project — Home/app-global missions keep BOTH kinds as plain text (v1 choice: a bare `#N`/uuid has no project to resolve against). Verification is lazy on click (`useAgentRefActions`, `client/src/features/missions/hooks/useAgentRefActions.ts`): the ref is fetched from the pinned project via `API_ORIGIN` (never `getApiBase()`), a miss shows a subtle i18n "not found / maybe deleted" toast (`agent:refs.*` ×8). Ticket chips open the board's `TicketDetailModal` through the new `openTicketDetailInProject(projectId, ticketId)` on `TicketDetailModalProvider` — when the pin differs from the active project it **switches the active project first** (MinimizedChats-restore precedent; the provider is active-project bound by design M22) and completes the open after the closeAll-on-switch effect. Job chips mount the mission-mode `JobDetailModal`, which (with `InteractiveJobComposer`) now accepts an optional explicit `projectId` prop scoping all its REST calls to that project (board-mode callers unchanged). `AgentPrDecisionCard` renders its `ticketIds` as the same clickable `#N` chips (body + building state), scoped to the card's `envelope.projectId`. **Board-mode stacking:** `JobDetailModal` sits at `z-[65]`; `TicketDetailModal`/`SplitViewShell` at `z-[68]` — both above the floating panel (`z-[60]`/grips `z-[61]`), below the `MinimizedChatsDock` (`z-[70]`) and browser-capture portals (`z-[80]`). The ticket surface is deliberately ONE tier above the job modal (68 > 65) so a spec chip clicked INSIDE a mission-mode `JobDetailModal` opens the ticket IN FRONT of it, not behind (the tie at z-65 previously resolved by DOM order, and the body-portalled job modal always won). The ticket surface never opens a job modal, so ticket-always-above-job is a consistent rule. `JobDetailModal` **portals to `document.body`** because the floating panel's backdrop-filter/transform makes it the containing block for fixed descendants and its `overflow-hidden` clipped the modal (looked like "nothing opened"). **Its cancel/stop confirmation is rendered IN-PORTAL** (a plain absolute overlay inside the modal's own `z-[65]` container, NOT a Radix `Dialog` — which portals to `<body>` at `z-50` and rendered BEHIND the modal); Escape dismisses the confirm before the modal.
704
+
705
+ **Inline mission references.** `AgentComposerEditor.tsx` renders atomic reference pills at their invocation positions in the message. Its plain-text value includes each reference token (`#1`, `@project`, etc.); per-occurrence spans carry the entity metadata and move with edits. `AgentComposer` sends the text in that order and deduplicates only the metadata by entity and project. The session draft store keeps text and spans together across Mission/Board remounts and new-mission materialization. Each conversation mounts an editor with its own draft from the first render, so undo cannot restore another conversation’s text. Browser regression: `node scripts/smoke-agent-inline-references.mjs` (also run with `SPECRAILS_SMOKE_ENGINE=webkit` for the macOS engine).
706
+
707
+ **Native mission browser and Retina capture.** `AgentBrowserCapture` now selects the native pane on macOS when `browser_capture_supported` confirms the installed WebKit API. Native navigation has no screencast; selection and PNG capture run on the same WKWebView through a fixed isolated-world script and public snapshot API (`src-tauri/src/browser_capture.rs` / `.js`). All native commands/events carry `ownerId`; never issue a global close from component cleanup. `ImageAnnotationEditor` annotates native and advanced captures at natural image resolution, preserving edits on render/upload failure. Advanced Chromium capture remains available on its existing surfaces and as runtime fallback, with separate CSS viewport and physical Retina raster dimensions (`browser-viewport.ts`). Detailed findings and checks: `docs/internals/browser-native-retina-audit.md`.
708
+
709
+ **Mission search in ⌘K (search-missions-in-palette).** The command palette (`client/src/components/CommandPalette.tsx`) has a **Missions** group that matches app-level agent conversations by title AND by user/assistant message content — substring, case- and diacritics-insensitive ("etris" finds "Tetris", "mision" finds "misión"). Two phases: title rows come synchronously from `useAgentChat().conversations` on every keystroke (`client/src/features/missions/lib/mission-search.ts`: `matchMissionTitles`, `recentMissions` for the empty query = 8 newest, `mergeMissionResults`, `groupOrderForMode`), then a debounced (80 ms, previous request aborted, answers matched back to their query) `GET /api/agent/search?q=&limit=` merges content hits with a highlighted snippet. Rows show title · snippet (`<mark>` from server-provided ranges, never HTML) · pinned project name (Home when unpinned) · relative time · the streaming pulse. `useUiMode()` orders the groups — Agent Mode: Missions first + the `searchPlaceholderAgent` copy; board: after Projects — and Enter calls `selectConversation(id)` (+ `open()` on the board so the floating panel surfaces). cmdk's fuzzy filter is OFF (`shouldFilter={false}`, `matchesPaletteQuery` filters the classic groups) so a content hit whose title lacks the query is never hidden. Server: desktop-db migration 24 creates `agent_messages_fts` — a STANDARD FTS5 table (not external-content: `agent_messages` has a TEXT PK, so its implicit rowid could be renumbered by VACUUM) with UNINDEXED `conversation_id`/`message_id`/`role` + `tokenize='trigram remove_diacritics 1'`, kept in sync by three triggers (`AGENT_SEARCH_INDEX_DDL`, `rebuildAgentSearchIndex` for bulk writers that bypass SQL). `searchAgentConversations` (`server/modules/agents/runtime/agent-store.ts`) returns one row per mission — title hits (newest first) then content hits by `bm25`, `role IN ('user','assistant')` so PR-decision `system` envelopes never match; `highlight()` output is windowed to ~60 chars each side in JS; queries under 3 chars (trigram minimum) take a bounded `LIKE` scan with the same row shape. Scope is missions only — Builder (`blueprint_*`) and per-project Explore chats are not indexed; Enter opens the mission, not the message (the hit already carries `messageId` for a later jump-to-message). i18n `commands:palette.groups.missions` / `searchPlaceholderAgent` / `missions.home` ×8.
710
+
711
+ ### Mission rail cards (agent-proposed launches + run lifecycle)
712
+
713
+ > OpenSpec change `mission-rail-cards` (2026-09-18). As-built record: [`docs/internals/mission-rail-cards.md`](docs/internals/mission-rail-cards.md). Closes the Mission-mode gap where the ONLY object representing a run was the PR-decision card — absent without git, mute on failure, no recovery, agent blind — so users fell back to Board mode to assign specs to rails and to diagnose/resume.
714
+
715
+ - **Proposal = fenced ```` ```rail-launch ```` JSON, not prose.** The operator prompt (`agent-operator-prompt.ts` `RAIL_LAUNCH_CARD_SECTION` + `RAIL_LAUNCH_CARD_SYSTEM_CLAUSE`, assembled by `buildOperatorSystemPrompt()` / `buildOperatorInstructions()`, gated on `isMissionRailCardsEnabled()`) teaches: assign/prepare/launch WITHOUT an explicit "launch now/lánzalo ya" ⇒ emit ONE block PER RAIL (`{ version:1, railIndex|null, newRail?, ticketIds, mode, loopId?, aiEngine?, model?, reasoningEffort?, profileName?, targetPrNumber?, baseBranch?, railName?, rationale? }`) prefilled from `specrails_rails(list)` availability and end the turn; explicit "now" ⇒ `specrails_rails(launch)` directly. Parser pair `server/modules/delivery/runtime/rail-launch-parser.ts` ⇄ `client/src/features/rails/lib/rail-launch-draft.ts` (byte-identical mirror, parity test): `extractRailLaunchProposals(content, streaming)` → `{ body, proposals[], rejected[], pending, truncated, repaired }` — tolerant JSON repair via `json-tolerant`, unknown keys dropped, `#12`/`"12"` ticket forms accepted, `batch` → `batch-implement`, every VALID block kept in order (one card per block), malformed blocks reported in `rejected` (never silent), an open fence cut while streaming (`pending`) and reported `truncated` once settled. `agent-fence-promotion.ts` (both copies) re-tags a generic ```` ```json ```` fence with a launch shape (ticket list + a rail/launch key, checked AFTER spec-draft) for small local models.
716
+ - **`AgentRailLaunchCard` (`client/src/features/missions/components/AgentRailLaunchCard.tsx`).** `AgentMessage` runs the extraction after spec-draft (flag `FEATURE_MISSION_RAIL_CARDS`), strips the fence and renders one editable card per proposal (`AgentRailLaunchPending` chip while streaming, `AgentRailLaunchUnreadable` muted note with the excerpt for rejected/truncated). The card reconciles the proposal against LIVE data of the pinned project — `GET /rails` (`availability` when present, else derived from active jobs/runs/deliveries/on_review), `/tickets`, `/profiles?provider=`, `useProviderDetection`, client model/effort catalogs, factory + custom loops — and degrades honestly (busy rail ⇒ warning + "Use Rail N", stale engine/model ⇒ fallback note, missing spec ⇒ dropped note, no specs/rail/project ⇒ Play disabled with reason). Every rail-header option is editable: rail (free rails + **New rail** with name), specs (chips + add from todo), mode/loop, engine, model, effort, profile, target PR, base branch. **Play is a USER action** (outside the agent tier ladder): `POST /rails` (new) or `PUT /name` → `PUT /tickets { ticketIds, mode, profileName, aiEngine }` → `POST /rails/:i/launch { mode, loopId, aiEngine, model, reasoning_effort, profileName, targetPrNumber, baseBranch, originConversationId, originSurface:'agent-chat' }` → `notifyGitChanged` → intent PATCH; `aiEngine` only rides when >1 provider is detected; 400/409 (`tickets_in_flight`, `pr_decision_pending`, `rail_limit_reached`, …) render inline with the server `detail`/`action` and the card stays editable.
717
+ - **Frozen decisions (`agent_messages.intent`, desktop-db migration 29, column-guarded).** The column holds a JSON ARRAY of `AgentMessageIntent { kind:'rail-launch', proposalIndex, status:'launched'|'dismissed', at, railIndex?, runIds?, prDeliveryId?, config? }` — one per proposal index (a legacy single object reads as a one-element array); `agent-store.ts` `setAgentMessageIntent` APPENDS and refuses a second decision for the same index; `PATCH /api/agent/conversations/:id/messages/:mid/intent` (403 when the feature is off, 409 `already_decided`); the row exposes `intents[]`. Client: `client/src/features/rails/lib/rail-launch-intents.ts` keeps a session-local overlay (`recordLocalIntent`, `intentFor(local, messageId, persisted, proposalIndex)`) so a decision freezes/unpins immediately; a launched proposal renders as a "Launched → Rail N" stub with TWO honest actions: **View run** opens the run's `JobDetailModal` (live log, `runIds[0]`, scoped to the proposal's project, lazy-loaded) and **Go to card** dispatches `FOCUS_PR_CARD_EVENT` (`specrails:focus-pr-card`) to scroll/flash the PR card (it used to hide behind "View run", which did nothing visible when the card was already pinned); dismissed renders a stub. `useRailLaunchProposals(messages)` derives the undecided proposals that `AgentPrPinnedDock` pins ABOVE the PR cards (same collapse chrome; the history slot shows the `agent-rail-launch-pinned-marker`).
718
+ - **One card follows the run.** `PrDecisionCardEnvelope` (server `types.ts` ⇄ `client/src/features/missions/lib/agent-api.ts` `coercePrDecisionEnvelope`, all optional, `decision` vocabulary UNCHANGED so older clients keep rendering) gains `hasDelivery` (false = shared-cwd launch, no delivery phase), `phase: 'launched'|'running'|'settled'|'delivery'`, `railName`, `runtime: MissionRunRuntime { status, currentStep, canResume, recoverableSteps, pendingApproval, failure: { code, detail, stepId }|null, at }`. **Run-only cards** (`server/modules/missions/runtime/mission-run-notify.ts`): the shared-cwd branch of `rails-router.ts` (`isolationUnavailable = 'no-git'|'no-commits'`) now stores `originConversationId` on the `railLoopRuns` meta, `postRunCard`s an envelope keyed on the synthetic `prDeliveryId = 'run:<runId>'` (`runCardId`/`isRunCardId`, `buildRunCardEnvelope`, `hasDelivery:false`, `decision:'building'`), returns `runIds` on the 202, and `settleRunCard`s it (`completed` / `discarded` on stop / failures via the trigger). `AgentPrDecisionCard` renders the run phase (status pill from live `useRuntimeRuns` first, settle snapshot fallback — `deriveMissionRunStatus`; live elapsed; `phase · activity`; log chips → `JobDetailModal`; Stop) and for `hasDelivery === false` an honest "no PR phase (no git)" note with Dismiss only — never create-pr/publish/merge-local. Dismissing a run-only card posts `POST /rails/pr-decision { prDeliveryId:'run:…', action:'dismiss', expectedDecision, conversationId }` — the route short-circuits `isRunCardId` ids to `AgentChatManager.dismissRunCard(conversationId, id)` (400 `run_card_dismiss_only` for any other action, 404 `run_card_not_found`), which flips the persisted envelope to `discarded`/`phase:'settled'`/`operation:'dismiss'` and re-broadcasts. The isolated settle (`rail-isolated-launch.ts`) and `rail-pr-store.ts` `toPrDecisionCardEnvelope` set `phase` + a `runtime` snapshot (`runtimeRunSummary()` from `agent-runtime-controls-router.ts`) on `implementation_failed` and launch-failed `discarded`, and the card's `RunFailureBlock` shows the failure code pill + `statusDetail`/`runtime.failure.detail` + per-unit codes AS TEXT (the `discarded` branch no longer swallows `statusDetail` behind `deliveryBlocked`), with Resume / Approve / Recover (via `useRuntimeRuns`, live first) and Relaunch (origin-tagged re-POST of the rail's stored config, disabled while the rail is busy).
719
+ - **Failure trigger → card → agent.** `notifyMissionRunFailure({ runId, railIndex, projectId, originConversationId, ticketIds, failure, summary })` (`mission-run-notify.ts`, never throws, no-op without an origin) is called from the shared-cwd run promise (`failureForLoopOutcome`: failed / stalled / `provider_limit`) and the isolated settle (`implementation_failed`, `closeFailedGeneration` ⇒ `launch_failed`); a user cancel updates the card but starts no turn. It (a) `updatePrDecisionCard`s the envelope with `runtime.failure`, (b) `AgentChatManager.postRunFailureRow` persists ONE `system` row per run `{ kind:'run-failure', runId, railIndex, projectId, prDeliveryId, ticketIds, code, detail, stepId, at }` and broadcasts the app-global WS `agent_run_failure` (`AgentChatContext` appends it live to the active thread; `agent-run-failure.ts` `parseRunFailureRow` → `AgentRunFailureMarker` compact destructive marker with "Open card"), (c) `AgentChatManager.startSystemTurn` (gated `isMissionFailureTurnEnabled()`) reuses `sendMessage` with `queueId = 'mission-failure:<runId>'` — durable dedup even across restarts, queued behind a live turn, accounted like any turn — carrying the fixed briefing from `server/modules/missions/runtime/agent-failure-briefing.ts` (`buildFailureBriefing`: rail, specs, failure code + `FAILURE_CODE_LABELS`, ≤ `FAILURE_BRIEFING_MAX_TAIL` chars of verify tail, the runtime's recovery options, "do not relaunch by yourself"); the briefing is a `user` row whose `context_refs[0].kind === 'system-briefing'` (`failureBriefingRef`, `FAILURE_BRIEFING_REF_KIND`), which the view renders as a collapsible "automatic briefing" chip (`systemBriefingRunId`), never a user bubble; the prompt clause caps the reply at ≤ 6 lines + ONE recommended action and forbids resume/recover/relaunch/discard in that turn. **Pinning** (`agent-pr-pinning.ts` `isPrEnvelopePinned`): pinned while `phase` ∈ {launched, running}, while `runtime.failure` is unacknowledged, or while `decision` is in the legacy pinned set; run-only cards unpin at `completed`/`discarded`. `useOsNotifications` in Agent Mode routes a job/stuck click through `requestMissionOpenRun` (`MISSION_OPEN_RUN_EVENT`, `specrails:mission-open-run` → `AgentWorkspaceContext` opens the Jobs pane + `JobDetailModal`) instead of navigating to `/jobs/:id`, so the user never leaves Mission mode.
720
+ - **Agent eyes + hands.** `GET /rails` rails carry `availability: 'free'|'busy'|'pending_decision'|'on_review'` (active job/run → busy; non-terminal delivery → pending_decision; any on_review spec → on_review). MCP `specrails_jobs` gains `runtime_runs`/`runtime_evidence` (read), `runtime_resume`/`runtime_recover` (ai-spawn; recover = resume with `recover:[stepIds]`), `runtime_approve`/`runtime_settle`/`runtime_dismiss` (write; approve = resume with `approve:[stepId]`), `runtime_cancel` (destructive) over the real `/agent-runtime/runs` routes; `mcp/guide.ts` gains the recovery section. **`@rail-N` palette entity**: `AgentContextKind` += `'rail'` (`agent-context-palette.ts` `railsFromResponse`/`railChip`, rows from the pinned project's `/rails` fetched best-effort in `AgentComposer`, `accent-warning` tone + `Route` icon, token `@rail-N` 1-based, context-block line `rail N (name, specs, state)`); server `agent-chat-router.ts` `CONTEXT_KINDS` accepts it and `agent-context-resolver.ts` `formatRail` serializes it.
721
+ - **Flags / rollback.** `SPECRAILS_MISSION_RAIL_CARDS` (server; off ⇒ legacy prompt verbs, intent route 403, no run cards) + `VITE_FEATURE_MISSION_RAIL_CARDS` (client; off ⇒ fences ignored, PR card byte-identical) + `SPECRAILS_MISSION_FAILURE_TURN` (off ⇒ card + system row still update, no automatic turn) — all default on. Migration 29 and the envelope fields are additive. i18n `agent:railCard.*`, `agent:runCard.*`, `agent:runFailure.*`, `agent:palette.rail*` ×8.
722
+ - **Deferred.** `job.stuck` → trigger (the stuck detector has no origin link; needs the rails meta map wired into it); QueueManager `/spawn` jobs (no origin, no card — unchanged); per-STEP envelope updates while running (only launch → running and settle are emitted; the card reads live `useRuntimeRuns`/WS meanwhile); a message with N proposals decides each index independently but the agent's `rationale` is per block only; batch `launch_all` proposals are N blocks, never one grouped card.