specrails-desktop 2.29.2 → 2.29.4

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 (101) hide show
  1. package/client/dist/assets/{ActivityFeedPage-ClpD5CFj.js → ActivityFeedPage-CDMzpGXd.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-CSPs9Ofc.js → AgentBrowserCapture-BVTacapZ.js} +1 -1
  3. package/client/dist/assets/{AgentModeCodePane-DwcyklA5.js → AgentModeCodePane-D9bhFyuy.js} +2 -2
  4. package/client/dist/assets/{AgentModeJobsPane-C69bqlMa.js → AgentModeJobsPane-DAjN8Au5.js} +1 -1
  5. package/client/dist/assets/{AgentsPage-pRiRr9-W.js → AgentsPage-CebykajE.js} +1 -1
  6. package/client/dist/assets/{AnalyticsPage-C14exKsC.js → AnalyticsPage-BiPrVvgv.js} +1 -1
  7. package/client/dist/assets/{CodePage-CK76JKm9.js → CodePage-Dw9eQFRS.js} +1 -1
  8. package/client/dist/assets/{DesktopAnalyticsPage-Cnv0f3XQ.js → DesktopAnalyticsPage-cCG_JAEi.js} +1 -1
  9. package/client/dist/assets/{DocsDialog-CHUm_Ym0.js → DocsDialog-C1kZ_mRP.js} +1 -1
  10. package/client/dist/assets/{DocsPage-COI-oDok.js → DocsPage-3GivxWJm.js} +1 -1
  11. package/client/dist/assets/{InteractiveJobComposer-CypM7R1F.js → InteractiveJobComposer-KuZXGdr8.js} +1 -1
  12. package/client/dist/assets/{JobDetailModal-B_W0H9dw.js → JobDetailModal-DWpRALkB.js} +1 -1
  13. package/client/dist/assets/{JobDetailPage-CeeXOoKS.js → JobDetailPage-mrXDKafj.js} +1 -1
  14. package/client/dist/assets/{JobsPage-CwhJG9jw.js → JobsPage-CUYXjcoc.js} +1 -1
  15. package/client/dist/assets/{LoopBuilderPage-C51t1WOM.js → LoopBuilderPage-CwjBD5Gm.js} +1 -1
  16. package/client/dist/assets/{LoopPreviewModal-Y2HgtIJu.js → LoopPreviewModal-CO9ton0-.js} +1 -1
  17. package/client/dist/assets/{LoopsPage-Bvn1Kw5V.js → LoopsPage-CLL5UuvN.js} +1 -1
  18. package/client/dist/assets/{MinimizedChatsContext-DiFvZDPW.js → MinimizedChatsContext-DJAkRYps.js} +1 -1
  19. package/client/dist/assets/{PluginsPage-BGPRJpdf.js → PluginsPage-DZvCC5Ri.js} +1 -1
  20. package/client/dist/assets/{ProjectSettingsDialog-D_GTyGLc.js → ProjectSettingsDialog-C7Hesadg.js} +1 -1
  21. package/client/dist/assets/{TemplatePreviewModal-szwBhd7h.js → TemplatePreviewModal-DczCzUzP.js} +1 -1
  22. package/client/dist/assets/{TicketDetailModal-9UEyK974.js → TicketDetailModal-CFh7GOv5.js} +4 -4
  23. package/client/dist/assets/{Trans-B_vKZ-Hb.js → Trans-Cm9rhdnG.js} +1 -1
  24. package/client/dist/assets/agent-BXc59ARK2.js +1 -0
  25. package/client/dist/assets/agent-BbIFXdDq.js +1 -0
  26. package/client/dist/assets/agent-BqEfA5u5.js +1 -0
  27. package/client/dist/assets/agent-BtGzgTC4.js +1 -0
  28. package/client/dist/assets/agent-CIhys_5d.js +1 -0
  29. package/client/dist/assets/agent-CJPaqsVR.js +1 -0
  30. package/client/dist/assets/agent-n-hY4GuL.js +1 -0
  31. package/client/dist/assets/agent-tJ-efa3_2.js +1 -0
  32. package/client/dist/assets/common-6MLbD-jM.js +1 -0
  33. package/client/dist/assets/common-BKGEu4rm.js +1 -0
  34. package/client/dist/assets/common-BNbnv8cE2.js +1 -0
  35. package/client/dist/assets/common-BqGWtDbt.js +1 -0
  36. package/client/dist/assets/common-DBtTzeYm2.js +1 -0
  37. package/client/dist/assets/common-DEW3Nr8n.js +1 -0
  38. package/client/dist/assets/{common-DVQuhs-m.js → common-DHsv9N5d.js} +1 -1
  39. package/client/dist/assets/common-mkrEcW-Y.js +1 -0
  40. package/client/dist/assets/dashboard-CHCEkds4.js +1 -0
  41. package/client/dist/assets/dashboard-Cqmmbh3l2.js +1 -0
  42. package/client/dist/assets/dashboard-CrlTV1ms.js +1 -0
  43. package/client/dist/assets/dashboard-Cslu_GCH.js +1 -0
  44. package/client/dist/assets/dashboard-DUU8-_Rg.js +1 -0
  45. package/client/dist/assets/dashboard-DweT3Y4R.js +1 -0
  46. package/client/dist/assets/dashboard-Dy2Um-US2.js +1 -0
  47. package/client/dist/assets/dashboard-WhzOQw1S.js +1 -0
  48. package/client/dist/assets/{dist-js-VwztRkGj.js → dist-js-BERxOT0X.js} +1 -1
  49. package/client/dist/assets/{dist-js-CcBCbb32.js → dist-js-Di8lflPo.js} +1 -1
  50. package/client/dist/assets/{formatDistanceToNow-ByHIauTQ.js → formatDistanceToNow-BAXZRbEO.js} +1 -1
  51. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BxHAksaU.js → getTimezoneOffsetInMilliseconds-DwzZXHtg.js} +1 -1
  52. package/client/dist/assets/{i18n-CgaRxpII.js → i18n-cK5pPbj8.js} +2 -2
  53. package/client/dist/assets/index-BT6xDQjB.css +2 -0
  54. package/client/dist/assets/index-CCq_kIrF.js +76 -0
  55. package/client/dist/assets/{spending-C4p06nen.js → spending-BLb5pj6G.js} +1 -1
  56. package/client/dist/assets/{useBrowserCaptureSession-2OqX0vap.js → useBrowserCaptureSession-DR1_jL56.js} +1 -1
  57. package/client/dist/assets/{useDesktop-X8YB9xzv.js → useDesktop-D4DxBGPU.js} +1 -1
  58. package/client/dist/index.html +14 -14
  59. package/docs/internals/safe-pr-review-flow.md +250 -38
  60. package/package.json +1 -1
  61. package/server/dist/active-pr-continuation.js +352 -53
  62. package/server/dist/agent-chat-manager.js +29 -12
  63. package/server/dist/agent-operator-prompt.js +9 -0
  64. package/server/dist/agent-store.js +19 -4
  65. package/server/dist/db.js +21 -0
  66. package/server/dist/mcp/tools/rails.js +8 -0
  67. package/server/dist/pr-lifecycle.js +100 -8
  68. package/server/dist/project-git.js +68 -3
  69. package/server/dist/rail-isolated-launch.js +719 -124
  70. package/server/dist/rail-pr-decision.js +1073 -181
  71. package/server/dist/rail-pr-recovery-git.js +235 -0
  72. package/server/dist/rail-pr-store.js +120 -9
  73. package/server/dist/rail-worktree-release.js +376 -4
  74. package/server/dist/rails-router.js +196 -10
  75. package/server/dist/worktree-manager.js +149 -18
  76. package/server/dist/worktree-overlay.js +166 -4
  77. package/client/dist/assets/agent-4rmXoo_p.js +0 -1
  78. package/client/dist/assets/agent-BxZuNGih.js +0 -1
  79. package/client/dist/assets/agent-C3uXOYv_.js +0 -1
  80. package/client/dist/assets/agent-CA8Y4wbw.js +0 -1
  81. package/client/dist/assets/agent-CASKrhG12.js +0 -1
  82. package/client/dist/assets/agent-Cv-jud5n2.js +0 -1
  83. package/client/dist/assets/agent-DitUxYh2.js +0 -1
  84. package/client/dist/assets/agent-Doow4X7o.js +0 -1
  85. package/client/dist/assets/common-CD1QHriA2.js +0 -1
  86. package/client/dist/assets/common-CwwPaRxx.js +0 -1
  87. package/client/dist/assets/common-Dg2jaX7d.js +0 -1
  88. package/client/dist/assets/common-DuBwj6tK.js +0 -1
  89. package/client/dist/assets/common-MRj9MHcc.js +0 -1
  90. package/client/dist/assets/common-O4ZpnMLp.js +0 -1
  91. package/client/dist/assets/common-fyeF0v7C2.js +0 -1
  92. package/client/dist/assets/dashboard-B_NkSHYm.js +0 -1
  93. package/client/dist/assets/dashboard-ClBb_3pq.js +0 -1
  94. package/client/dist/assets/dashboard-CpCmJvMB.js +0 -1
  95. package/client/dist/assets/dashboard-Czohu35O2.js +0 -1
  96. package/client/dist/assets/dashboard-DcjbD0UK.js +0 -1
  97. package/client/dist/assets/dashboard-Dvx5F3we.js +0 -1
  98. package/client/dist/assets/dashboard-R1bOPjBh2.js +0 -1
  99. package/client/dist/assets/dashboard-owcDucDi.js +0 -1
  100. package/client/dist/assets/index-BflYQpWm.css +0 -2
  101. package/client/dist/assets/index-C1_-BTXy.js +0 -72
@@ -21,6 +21,7 @@ const rail_isolation_1 = require("./rail-isolation");
21
21
  const rail_pr_store_1 = require("./rail-pr-store");
22
22
  const loop_effect_1 = require("./loop-effect");
23
23
  const rail_pr_decision_1 = require("./rail-pr-decision");
24
+ const active_pr_continuation_1 = require("./active-pr-continuation");
24
25
  const rail_isolated_launch_1 = require("./rail-isolated-launch");
25
26
  const worktree_manager_1 = require("./worktree-manager");
26
27
  const rail_worktree_release_1 = require("./rail-worktree-release");
@@ -40,12 +41,21 @@ const VALID_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
40
41
  function prDeliveryContinuesTickets(delivery, ticketIds) {
41
42
  if (delivery.decision !== 'pr_draft' && delivery.decision !== 'pr_ready')
42
43
  return false;
43
- if (!delivery.prUrl || !delivery.branch || delivery.prState !== 'pr-created')
44
+ if (!delivery.prUrl || !delivery.branch || !delivery.deliverySha || delivery.prState !== 'pr-created')
44
45
  return false;
45
46
  const covered = new Set(delivery.ticketIds);
46
47
  const requested = new Set(ticketIds);
47
48
  return requested.size > 0 && requested.size === covered.size && [...requested].every((id) => covered.has(id));
48
49
  }
50
+ function prDeliveryCheckoutBlock(delivery) {
51
+ if (delivery.deliveryOutcome === 'blocked') {
52
+ return 'delivery is blocked and has no safely checkoutable result';
53
+ }
54
+ if (!delivery.deliverySha) {
55
+ return 'delivery has no verified commit available for checkout';
56
+ }
57
+ return null;
58
+ }
49
59
  function emitPrDeliveryUpdate(c, prDeliveryId) {
50
60
  const row = (0, rail_pr_store_1.getPrDelivery)(c.db, prDeliveryId);
51
61
  if (!row)
@@ -268,6 +278,40 @@ function createRailsRouter() {
268
278
  res.status(500).json({ error: 'Failed to update rail profile' });
269
279
  }
270
280
  });
281
+ // GET /rails/:railIndex/pr-candidates — display-only open-PR suggestions for
282
+ // the "deliver into existing PR" launch affordance. Reuses the automatic-
283
+ // continuation matchers WITHOUT the ticket-status gate: suggestions are
284
+ // informational, selection is always an explicit user action. No side effects.
285
+ router.get('/:railIndex/pr-candidates', async (req, res) => {
286
+ const c = ctx(req);
287
+ const railIndex = parseInt(req.params.railIndex, 10);
288
+ if (isNaN(railIndex) || railIndex < 0 || railIndex >= rails_store_1.MAX_RAILS) {
289
+ res.status(400).json({ error: 'Invalid rail index' });
290
+ return;
291
+ }
292
+ try {
293
+ const rail = (0, rails_store_1.getRail)(c.db, railIndex);
294
+ const candidates = await (0, active_pr_continuation_1.listPrCandidatesForTickets)({
295
+ db: c.db,
296
+ exec: pr_publisher_1.defaultExec,
297
+ repoDir: c.project.path,
298
+ ticketIds: [...(rail?.ticketIds ?? [])],
299
+ getTicketSpec: (ticketId) => {
300
+ try {
301
+ return c.getTicketSpec(ticketId) ?? undefined;
302
+ }
303
+ catch {
304
+ return undefined;
305
+ }
306
+ },
307
+ });
308
+ res.json({ candidates });
309
+ }
310
+ catch (err) {
311
+ console.error('[rails-router] pr-candidates error:', err);
312
+ res.status(500).json({ error: 'Failed to list PR candidates' });
313
+ }
314
+ });
271
315
  // PUT /rails/:railIndex/engine — set the AI engine override for a rail
272
316
  // Body: { aiEngine: string | null } (null = use the project's primary provider)
273
317
  router.put('/:railIndex/engine', (req, res) => {
@@ -343,7 +387,16 @@ function createRailsRouter() {
343
387
  return;
344
388
  }
345
389
  let { mode = 'implement' } = req.body ?? {};
346
- const { profileName, aiEngine, model, loopId: rawLoopId, reasoning_effort, originConversationId, originSurface } = req.body ?? {};
390
+ const { profileName, aiEngine, model, loopId: rawLoopId, reasoning_effort, originConversationId, originSurface, targetPrNumber } = req.body ?? {};
391
+ // Explicit target PR (deliver-rail-into-existing-pr): the user names an
392
+ // existing open PR as the delivery destination. Shape-validated here;
393
+ // resolved and verified inside the isolated launch, fail-closed.
394
+ if (targetPrNumber !== undefined && targetPrNumber !== null) {
395
+ if (typeof targetPrNumber !== 'number' || !Number.isSafeInteger(targetPrNumber) || targetPrNumber <= 0 || targetPrNumber > 1_000_000_000) {
396
+ res.status(400).json({ error: 'invalid_target_pr', detail: 'targetPrNumber must be a positive integer' });
397
+ return;
398
+ }
399
+ }
347
400
  let loopId = rawLoopId;
348
401
  // Origin link (safe-pr-review-flow): an agent-chat/MCP launch tags itself so
349
402
  // the PR decision can later be posted back into the launching conversation.
@@ -386,6 +439,13 @@ function createRailsRouter() {
386
439
  res.status(403).json({ error: 'Loops are disabled on this server' });
387
440
  return;
388
441
  }
442
+ // An explicit target PR is only meaningful on the isolated PR-delivery
443
+ // path. Reject early when that path is structurally unavailable rather
444
+ // than silently launching fresh work next to the designated PR.
445
+ if (typeof targetPrNumber === 'number' && (!(0, feature_flags_1.isLoopsEnabled)() || !(0, rail_isolation_1.isRailPrDeliveryEnabled)())) {
446
+ res.status(400).json({ error: 'target_pr_requires_pr_mode', detail: 'targetPrNumber requires loops and PR delivery to be enabled' });
447
+ return;
448
+ }
389
449
  // Freestyle model picker: optional, validated against the allow-list.
390
450
  // Ignored for non-freestyle modes (they use the orchestrator model).
391
451
  if (mode === 'freestyle' && model !== undefined && model !== null) {
@@ -562,11 +622,23 @@ function createRailsRouter() {
562
622
  res.status(409).json({ error: 'pr_decision_pending', prDeliveryId: pendingSnapshot.id });
563
623
  return;
564
624
  }
625
+ // Explicit target vs an undecided continuable delivery: the slot's
626
+ // active generation owns its PR. A DIFFERENT explicit target would
627
+ // append to the undecided branches → 409; the SAME PR is redundant
628
+ // (the continuation contract below already drives it) and is dropped.
629
+ if (pendingSnapshot && typeof targetPrNumber === 'number' && pendingSnapshot.prNumber !== targetPrNumber) {
630
+ res.status(409).json({ error: 'pr_decision_pending', prDeliveryId: pendingSnapshot.id });
631
+ return;
632
+ }
565
633
  continuablePrDelivery = pendingSnapshot;
566
634
  }
567
635
  const useIsolation = (0, rail_isolation_1.isolationApplies)({
568
636
  loopsEnabled: (0, feature_flags_1.isLoopsEnabled)(), scope, ticketCount: rail.ticketIds.length, readOnly: loopReadOnly,
569
637
  });
638
+ if (typeof targetPrNumber === 'number' && !useIsolation) {
639
+ res.status(400).json({ error: 'target_pr_requires_pr_mode', detail: 'targetPrNumber requires an isolated (worktree) launch; this launch would run in the shared checkout' });
640
+ return;
641
+ }
570
642
  if (continuablePrDelivery && !loopReadOnly && !useIsolation) {
571
643
  res.status(409).json({
572
644
  error: 'pr_continuation_isolation_required',
@@ -580,6 +652,13 @@ function createRailsRouter() {
580
652
  // unborn HEAD can't be branched). Fall back (with a message) otherwise.
581
653
  const status = await (0, worktree_manager_1.repoIsolationStatus)(worktree_manager_1.defaultGitRunner, c.project.path);
582
654
  if (status !== 'ok') {
655
+ if (typeof targetPrNumber === 'number') {
656
+ res.status(400).json({
657
+ error: 'target_pr_requires_pr_mode',
658
+ detail: `targetPrNumber requires worktree isolation, which is unavailable (${status})`,
659
+ });
660
+ return;
661
+ }
583
662
  if (continuablePrDelivery) {
584
663
  res.status(409).json({
585
664
  error: 'pr_continuation_isolation_required',
@@ -603,10 +682,18 @@ function createRailsRouter() {
603
682
  deliveryId: continuablePrDelivery.id,
604
683
  decision: continuablePrDelivery.decision,
605
684
  branch: continuablePrDelivery.branch,
685
+ baseBranch: continuablePrDelivery.baseBranch,
606
686
  prUrl: continuablePrDelivery.prUrl,
607
687
  prNumber: continuablePrDelivery.prNumber,
688
+ deliverySha: continuablePrDelivery.deliverySha,
608
689
  },
609
690
  } : {}),
691
+ // Explicit target only drives fresh launches; when the slot has
692
+ // a continuable delivery for the same PR, the continuation
693
+ // contract above is the (stricter) authority.
694
+ ...(typeof targetPrNumber === 'number' && !continuablePrDelivery
695
+ ? { explicitPrTarget: { prNumber: targetPrNumber } }
696
+ : {}),
610
697
  });
611
698
  res.status(202).json({ loopRunIds: ids, railIndex, mode, isolated: true });
612
699
  return;
@@ -619,6 +706,16 @@ function createRailsRouter() {
619
706
  });
620
707
  return;
621
708
  }
709
+ // Explicit-target validation failures are the USER's answer being
710
+ // unusable — fail closed with the machine-readable code; never
711
+ // fall back to a fresh shared-cwd run next to the designated PR.
712
+ if (err instanceof active_pr_continuation_1.ExplicitPrTargetError) {
713
+ res.status(err.code === 'target_pr_not_found' ? 404 : 409).json({
714
+ error: err.code,
715
+ detail: err.message,
716
+ });
717
+ return;
718
+ }
622
719
  if (continuablePrDelivery || err instanceof rail_isolated_launch_1.PrContinuationIsolationError) {
623
720
  const detail = err instanceof Error ? err.message : String(err);
624
721
  res.status(409).json({
@@ -881,7 +978,7 @@ function createRailsRouter() {
881
978
  // POST /rails/pr-decision — the ONE decision action (safe-pr-review-flow) both
882
979
  // surfaces (dashboard rail row + agent-chat card) call on a rail_pr_deliveries
883
980
  // row: create-pr | publish | discard/dismiss | poll/reopen | merge-local |
884
- // acknowledge-no-changes, compare-and-set-guarded by
981
+ // acknowledge-no-changes | recover-and-retry, compare-and-set-guarded by
885
982
  // expectedDecision (a raced concurrent answer loses with 409 stale_decision).
886
983
  // Replaces the stateless v1 POST /rails/pr-review passthrough. The route only
887
984
  // validates and delegates — the action logic lives in rail-pr-decision.ts.
@@ -894,7 +991,7 @@ function createRailsRouter() {
894
991
  return;
895
992
  }
896
993
  if (!(0, rail_pr_decision_1.isPrDecisionAction)(action)) {
897
- res.status(400).json({ error: "action must be 'create-pr', 'publish', 'discard', 'dismiss', 'poll-merge', 'reopen', 'merge-local' or 'acknowledge-no-changes'" });
994
+ res.status(400).json({ error: `action must be one of: ${rail_pr_decision_1.PR_DECISION_ACTIONS.join(', ')}` });
898
995
  return;
899
996
  }
900
997
  if (typeof expectedDecision !== 'string' || !expectedDecision) {
@@ -968,7 +1065,21 @@ function createRailsRouter() {
968
1065
  res.status(404).json({ error: 'Unknown prDeliveryId' });
969
1066
  return;
970
1067
  }
1068
+ const active = (0, rail_pr_store_1.getActivePrDeliveryByRail)(c.db, row.rail_index);
1069
+ if (!active || active.id !== row.id) {
1070
+ res.status(409).json({
1071
+ error: 'stale_decision',
1072
+ current: active?.decision ?? null,
1073
+ currentPrDeliveryId: active?.id ?? null,
1074
+ });
1075
+ return;
1076
+ }
971
1077
  const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
1078
+ const checkoutBlock = prDeliveryCheckoutBlock(snap);
1079
+ if (checkoutBlock) {
1080
+ res.status(409).json({ error: 'checkout_not_deliverable', detail: checkoutBlock });
1081
+ return;
1082
+ }
972
1083
  if (!snap.branch || !snap.prUrl) {
973
1084
  res.status(409).json({ error: 'checkout_unavailable', detail: 'delivery has no PR branch' });
974
1085
  return;
@@ -976,20 +1087,95 @@ function createRailsRouter() {
976
1087
  try {
977
1088
  const outcome = await (0, repo_lock_1.withRepoLock)(c.project.path, async () => {
978
1089
  admission.assertCurrent();
1090
+ const current = (0, rail_pr_store_1.getActivePrDeliveryByRail)(c.db, row.rail_index);
1091
+ if (!current || current.id !== row.id) {
1092
+ return {
1093
+ ok: false,
1094
+ status: 409,
1095
+ error: 'stale_decision',
1096
+ detail: 'The delivery generation changed before checkout.',
1097
+ };
1098
+ }
1099
+ const currentSnap = (0, rail_pr_store_1.toPrDeliverySnapshot)(current);
1100
+ const currentCheckoutBlock = prDeliveryCheckoutBlock(currentSnap);
1101
+ if (currentCheckoutBlock) {
1102
+ return {
1103
+ ok: false,
1104
+ status: 409,
1105
+ error: 'checkout_not_deliverable',
1106
+ detail: currentCheckoutBlock,
1107
+ };
1108
+ }
1109
+ if (!currentSnap.branch || !currentSnap.prUrl) {
1110
+ return {
1111
+ ok: false,
1112
+ status: 409,
1113
+ error: 'checkout_not_deliverable',
1114
+ detail: 'The current delivery no longer has an attached PR branch.',
1115
+ };
1116
+ }
1117
+ const cleanliness = await (0, project_git_1.inspectProjectCheckoutCleanliness)(c.project.path);
1118
+ if (!cleanliness.ok) {
1119
+ return {
1120
+ ok: false,
1121
+ status: 409,
1122
+ error: 'checkout_safety_unknown',
1123
+ detail: cleanliness.detail,
1124
+ };
1125
+ }
1126
+ if (!cleanliness.clean) {
1127
+ return { ok: false, status: 409, error: 'checkout_dirty', detail: 'Working tree has uncommitted changes. Commit or stash them before checkout.' };
1128
+ }
979
1129
  const info = await (0, project_git_1.getProjectGitInfo)(c.project.path);
980
1130
  if (!info.git) {
981
1131
  return { ok: false, status: 409, error: 'checkout_unavailable', detail: 'project is not a git repository' };
982
1132
  }
983
- if (info.dirty) {
984
- return { ok: false, status: 409, error: 'checkout_dirty', detail: 'Working tree has uncommitted changes. Commit or stash them before checkout.' };
985
- }
986
- await (0, rail_worktree_release_1.releaseRailWorktrees)({
1133
+ let archived = false;
1134
+ const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
987
1135
  db: c.db,
988
1136
  git: worktree_manager_1.defaultGitRunner,
989
1137
  repoDir: c.project.path,
990
- worktreeIds: snap.worktreeIds,
1138
+ worktreeIds: currentSnap.worktreeIds,
1139
+ expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(currentSnap.branches),
1140
+ overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(currentSnap.branches),
1141
+ settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(currentSnap.branches),
1142
+ onSafetyArchive: (archive) => {
1143
+ archived = true;
1144
+ if (!(0, rail_pr_store_1.appendPrDeliverySafetyArchive)(c.db, row.id, archive)) {
1145
+ throw new Error(`delivery ${row.id} disappeared while recording safety archive ${archive}`);
1146
+ }
1147
+ },
991
1148
  });
992
- const checkedOut = await (0, project_git_1.checkoutProjectReviewBranch)(c.project.path, snap.branch);
1149
+ if (archived) {
1150
+ const archivedRow = (0, rail_pr_store_1.getPrDelivery)(c.db, row.id);
1151
+ if (archivedRow) {
1152
+ const archivedSnap = (0, rail_pr_store_1.toPrDeliverySnapshot)(archivedRow);
1153
+ c.broadcast((0, rail_pr_store_1.toRailPrStateMessage)(c.project.id, archivedSnap));
1154
+ if (archivedRow.origin_conversation_id) {
1155
+ (0, agent_chat_registry_1.getAgentChatManager)()?.updatePrDecisionCard(archivedRow.origin_conversation_id, (0, rail_pr_store_1.toPrDecisionCardEnvelope)(c.project.id, archivedSnap));
1156
+ }
1157
+ }
1158
+ }
1159
+ if (cleanupWarnings.length > 0) {
1160
+ return {
1161
+ ok: false,
1162
+ status: 409,
1163
+ error: 'checkout_unavailable',
1164
+ detail: cleanupWarnings[0],
1165
+ };
1166
+ }
1167
+ // Cleanup just completed with ZERO warnings — clear any stale
1168
+ // cleanup_incomplete evidence persisted by earlier failed release
1169
+ // attempts, so the card stops claiming "Cleanup is incomplete" after
1170
+ // the condition has been resolved. Same-state patch + live broadcast.
1171
+ if (currentSnap.cleanupWarnings.length > 0 || currentSnap.statusCode === 'cleanup_incomplete') {
1172
+ (0, rail_pr_store_1.transitionDecision)(c.db, row.id, currentSnap.decision, currentSnap.decision, {
1173
+ cleanupWarnings: [],
1174
+ ...(currentSnap.statusCode === 'cleanup_incomplete' ? { statusCode: null } : {}),
1175
+ });
1176
+ emitPrDeliveryUpdate(c, row.id);
1177
+ }
1178
+ const checkedOut = await (0, project_git_1.checkoutProjectReviewBranch)(c.project.path, currentSnap.branch, currentSnap.deliverySha);
993
1179
  if (!checkedOut.ok) {
994
1180
  return { ok: false, status: 409, error: 'checkout_failed', detail: checkedOut.error };
995
1181
  }
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.PR_NEVER_STAGE_PATHS = exports.defaultGitRunner = void 0;
36
+ exports.PR_NEVER_STAGE_PATHSPEC_ROOTS = exports.PR_NEVER_STAGE_PATHS = exports.defaultGitRunner = void 0;
37
37
  exports.isGitRepo = isGitRepo;
38
38
  exports.repoIsolationStatus = repoIsolationStatus;
39
39
  exports.worktreeBranch = worktreeBranch;
@@ -63,13 +63,21 @@ const child_process_1 = require("child_process");
63
63
  const fs = __importStar(require("fs"));
64
64
  const path = __importStar(require("path"));
65
65
  const win_spawn_1 = require("./util/win-spawn");
66
+ const GIT_RUNNER_TIMEOUT_MS = 120_000;
66
67
  exports.defaultGitRunner = {
67
68
  run(args, cwd) {
68
69
  return new Promise((resolve) => {
69
70
  // SystemRoot/ComSpec backfill so worktree + PR-decision git ops don't fail
70
71
  // to start under a pkg-stripped Windows sidecar env. No-op on POSIX.
71
72
  const env = (0, win_spawn_1.windowsSpawnEnv)();
72
- (0, child_process_1.execFile)('git', args, { cwd, env, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
73
+ (0, child_process_1.execFile)('git', args, {
74
+ cwd,
75
+ env,
76
+ timeout: GIT_RUNNER_TIMEOUT_MS,
77
+ killSignal: 'SIGTERM',
78
+ maxBuffer: 16 * 1024 * 1024,
79
+ windowsHide: true,
80
+ }, (err, stdout, stderr) => {
73
81
  const code = err && typeof err.code === 'number' ? err.code : err ? 1 : 0;
74
82
  resolve({ code, stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
75
83
  });
@@ -233,8 +241,80 @@ async function gitRun(git, args, cwd) {
233
241
  }
234
242
  }
235
243
  function commitPathspecs(excludePaths) {
236
- const allExcludePaths = [...exports.PR_NEVER_STAGE_PATHS, ...excludePaths];
237
- return ['--', '.', ...allExcludePaths.map((p) => `:(exclude)${p}`)];
244
+ return [
245
+ '--', '.',
246
+ ...exports.PR_NEVER_STAGE_PATHSPEC_ROOTS.map((p) => `:(exclude)${p}`),
247
+ // Overlay names originate in the workspace filesystem. Treat them as
248
+ // literal top-level paths so glob/pathspec metacharacters cannot broaden
249
+ // the never-commit surface.
250
+ ...excludePaths.map((p) => `:(top,exclude,literal)${p}`),
251
+ ];
252
+ }
253
+ const INDEX_AUDIT_ARGS = [
254
+ 'diff', '--cached', '--name-only', '--no-renames', '-z',
255
+ '--diff-filter=ACDMRTUXB', '--',
256
+ ];
257
+ const PR_NEVER_STAGE_ROOTS = [...new Set(exports.PR_NEVER_STAGE_PATHS.map((entry) => entry.endsWith('/**') ? entry.slice(0, -3) : entry))];
258
+ function normalizedGitPath(value) {
259
+ return path.sep === '\\' ? value.replaceAll('\\', '/') : value;
260
+ }
261
+ function isPathAtOrBelow(candidate, root) {
262
+ return candidate === root || candidate.startsWith(`${root}/`);
263
+ }
264
+ /**
265
+ * Collapse a path list to its minimal roots — drop any entry that is at or
266
+ * below another. Overlay-owned entries like `.claude/agent-memory` are
267
+ * SYMLINKS (the overlay links shared memory in). A git pathspec that descends
268
+ * INTO a symlink — `:(exclude).claude/agent-memory/**` or the `/explanations`
269
+ * subpaths — is rejected with `fatal: pathspec ... is beyond a symbolic link`,
270
+ * which aborts the entire `git add`/`git status` and blocks PR delivery. The
271
+ * symlink ROOT alone (`:(exclude).claude/agent-memory`) still excludes all of
272
+ * its contents (dir-level pathspec semantics) without traversing the link, and
273
+ * is equally correct for the copy-fallback case where it is a real directory.
274
+ */
275
+ function minimalPathRoots(paths) {
276
+ const norm = [...new Set(paths.map((p) => (p.endsWith('/**') ? p.slice(0, -3) : p)))];
277
+ return norm.filter((p) => !norm.some((other) => other !== p && isPathAtOrBelow(p, other)));
278
+ }
279
+ /** Symlink-safe never-stage pathspec roots (see {@link minimalPathRoots}). */
280
+ exports.PR_NEVER_STAGE_PATHSPEC_ROOTS = minimalPathRoots(exports.PR_NEVER_STAGE_PATHS);
281
+ function forbiddenStagedPaths(staged, excludePaths) {
282
+ const overlayRoots = excludePaths.map(normalizedGitPath).filter(Boolean);
283
+ return staged.filter((candidate) => (PR_NEVER_STAGE_ROOTS.some((root) => isPathAtOrBelow(candidate, root))
284
+ || overlayRoots.some((root) => isPathAtOrBelow(candidate, root))));
285
+ }
286
+ function parseNulTerminatedPaths(result, phase) {
287
+ if (result.code !== 0) {
288
+ return { paths: [], error: `${phase}: ${gitFailure(result, `git diff failed with exit ${result.code}`)}` };
289
+ }
290
+ if (result.stdout === '')
291
+ return { paths: [] };
292
+ if (!result.stdout.endsWith('\0')) {
293
+ return { paths: [], error: `${phase}: git returned a malformed non-NUL-terminated path list` };
294
+ }
295
+ const paths = result.stdout.slice(0, -1).split('\0');
296
+ if (paths.some((entry) => entry.length === 0)) {
297
+ return { paths: [], error: `${phase}: git returned a malformed path list containing an empty entry` };
298
+ }
299
+ return { paths };
300
+ }
301
+ async function auditStagedPaths(git, worktreePath, excludePaths, phase) {
302
+ const result = await gitRun(git, [...INDEX_AUDIT_ARGS], worktreePath);
303
+ const parsed = parseNulTerminatedPaths(result, phase);
304
+ if (parsed.error)
305
+ return { forbidden: [], error: parsed.error };
306
+ return { forbidden: forbiddenStagedPaths(parsed.paths, excludePaths) };
307
+ }
308
+ function forbiddenResetPathspecs(excludePaths) {
309
+ return [
310
+ '--',
311
+ ...PR_NEVER_STAGE_ROOTS.map((entry) => `:(top,literal)${entry}`),
312
+ ...excludePaths.map(normalizedGitPath).filter(Boolean).map((entry) => `:(top,literal)${entry}`),
313
+ ];
314
+ }
315
+ function describeForbiddenPaths(paths) {
316
+ const shown = paths.slice(0, 5).map((entry) => JSON.stringify(entry)).join(', ');
317
+ return paths.length > 5 ? `${shown}, and ${paths.length - 5} more` : shown;
238
318
  }
239
319
  /**
240
320
  * Commit the worktree's current deliverable changes to its branch and verify
@@ -244,25 +324,75 @@ function commitPathspecs(excludePaths) {
244
324
  */
245
325
  async function commitWorktreeAndVerify(git, worktreePath, message, excludePaths = []) {
246
326
  const pathspecs = commitPathspecs(excludePaths);
247
- const add = await gitRun(git, ['add', '-A', ...pathspecs], worktreePath);
248
- const commit = add.code === 0
249
- ? await gitRun(git, ['commit', '-m', message], worktreePath)
250
- : { code: 1, stdout: '', stderr: 'skipped commit because git add failed' };
327
+ // The add is deliberately PLAIN — no exclude pathspecs. `git add` exits 1
328
+ // with "The following paths are ignored by one of your .gitignore files"
329
+ // whenever ANY pathspec item (exclude items included) names a git-ignored
330
+ // path, and our own info/exclude block (ensurePrNeverStageExcludes) plus repo
331
+ // .gitignore entries (e.g. `.claude/settings.local.json`) make exactly the
332
+ // excluded paths ignored. Ignored paths can never be staged without `-f`, so
333
+ // the plain add is safe for them; non-ignored overlay files DO get staged
334
+ // here and are unstaged by the audit → reset flow below before the commit,
335
+ // whose `--only` pathspecs (which git commit accepts without the
336
+ // ignored-path check) remain the authoritative exclusion.
337
+ const add = await gitRun(git, ['add', '-A', '--', '.'], worktreePath);
338
+ let indexSafe = false;
339
+ let indexError;
340
+ if (add.code === 0) {
341
+ const initialAudit = await auditStagedPaths(git, worktreePath, excludePaths, 'git index audit failed before commit');
342
+ if (initialAudit.error) {
343
+ indexError = initialAudit.error;
344
+ }
345
+ else if (initialAudit.forbidden.length === 0) {
346
+ indexSafe = true;
347
+ }
348
+ else {
349
+ // The plain add routinely stages non-ignored overlay files, and an
350
+ // earlier process may have put private paths in the index. Reset only the
351
+ // prohibited roots back to HEAD: working files remain intact for recovery.
352
+ const reset = await gitRun(git, ['reset', '--quiet', ...forbiddenResetPathspecs(excludePaths)], worktreePath);
353
+ if (reset.code !== 0) {
354
+ indexError = `git could not safely unstage forbidden paths: ${gitFailure(reset, `git reset failed with exit ${reset.code}`)}`;
355
+ }
356
+ else {
357
+ const finalAudit = await auditStagedPaths(git, worktreePath, excludePaths, 'git index re-audit failed after unstaging forbidden paths');
358
+ if (finalAudit.error) {
359
+ indexError = finalAudit.error;
360
+ }
361
+ else if (finalAudit.forbidden.length > 0) {
362
+ indexError = `forbidden paths remain staged after safe unstage: ${describeForbiddenPaths(finalAudit.forbidden)}`;
363
+ }
364
+ else {
365
+ indexSafe = true;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ // `--only` makes the allow/exclude pathspecs authoritative at commit time,
371
+ // not merely at the earlier `git add`/audit. This closes the remaining race
372
+ // where another process could stage a private path between audit and commit.
373
+ // Automated delivery also bypasses repository hooks: a pre-commit hook runs
374
+ // after our final audit and could otherwise stage an excluded secret into the
375
+ // commit. User working files and the ordinary index remain preserved.
376
+ const commit = add.code === 0 && indexSafe
377
+ ? await gitRun(git, ['commit', '--no-verify', '--only', '-m', message, ...pathspecs], worktreePath)
378
+ : { code: 1, stdout: '', stderr: 'skipped commit because the index was not proven safe' };
251
379
  const status = await gitRun(git, ['status', '--porcelain', '--untracked-files=all', ...pathspecs], worktreePath);
252
380
  const dirty = status.code === 0
253
381
  ? status.stdout.split('\n').map((line) => line.trimEnd()).filter(Boolean)
254
382
  : [];
255
383
  const error = add.code !== 0
256
384
  ? gitFailure(add, `git add failed with exit ${add.code}`)
257
- : status.code !== 0
258
- ? gitFailure(status, `git status failed with exit ${status.code}`)
259
- : commit.code !== 0 && dirty.length > 0
260
- ? gitFailure(commit, `git commit failed with exit ${commit.code}`)
261
- : undefined;
385
+ : indexError
386
+ ? indexError
387
+ : status.code !== 0
388
+ ? gitFailure(status, `git status failed with exit ${status.code}`)
389
+ : commit.code !== 0 && dirty.length > 0
390
+ ? gitFailure(commit, `git commit failed with exit ${commit.code}`)
391
+ : undefined;
262
392
  return {
263
393
  staged: add.code === 0,
264
394
  committed: commit.code === 0,
265
- clean: status.code === 0 && dirty.length === 0,
395
+ clean: indexSafe && status.code === 0 && dirty.length === 0,
266
396
  dirty,
267
397
  ...(error ? { error } : {}),
268
398
  };
@@ -276,11 +406,12 @@ async function commitWorktreeAndVerify(git, worktreePath, message, excludePaths
276
406
  async function commitWorktree(git, worktreePath, message, excludePaths = []) {
277
407
  await commitWorktreeAndVerify(git, worktreePath, message, excludePaths);
278
408
  }
279
- /** Remove a worktree (force) and optionally delete its branch. Worktree removal
280
- * failures throw so callers do not mark the ledger released while the checkout
281
- * is still mounted. Branch deletion remains best-effort. */
409
+ /** Remove a worktree and optionally delete its branch. Explicit cleanup keeps
410
+ * the historical force default; automatic cleanup opts out. Removal failures
411
+ * throw so callers do not mark the ledger released while it is still mounted.
412
+ * Branch deletion remains best-effort. */
282
413
  async function removeWorktree(git, input) {
283
- const removed = await git.run(['worktree', 'remove', '--force', input.worktreePath], input.repoDir);
414
+ const removed = await git.run(['worktree', 'remove', ...(input.force === false ? [] : ['--force']), input.worktreePath], input.repoDir);
284
415
  if (removed.code !== 0) {
285
416
  throw new Error(`git worktree remove failed for ${input.worktreePath}: ${gitFailure(removed, `exit ${removed.code}`)}`);
286
417
  }