specrails-desktop 2.29.3 → 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 (55) hide show
  1. package/client/dist/assets/{ActivityFeedPage-CFB4UBhT.js → ActivityFeedPage-CDMzpGXd.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-C-T0r16B.js → AgentBrowserCapture-BVTacapZ.js} +1 -1
  3. package/client/dist/assets/{AgentModeCodePane-IljV5FOQ.js → AgentModeCodePane-D9bhFyuy.js} +2 -2
  4. package/client/dist/assets/{AgentModeJobsPane-B5miH10_.js → AgentModeJobsPane-DAjN8Au5.js} +1 -1
  5. package/client/dist/assets/{AgentsPage-BgYQrAcH.js → AgentsPage-CebykajE.js} +1 -1
  6. package/client/dist/assets/{AnalyticsPage-BtVCuNjL.js → AnalyticsPage-BiPrVvgv.js} +1 -1
  7. package/client/dist/assets/{CodePage-Cb_jAjSV.js → CodePage-Dw9eQFRS.js} +1 -1
  8. package/client/dist/assets/{DesktopAnalyticsPage-s_Cc_JL1.js → DesktopAnalyticsPage-cCG_JAEi.js} +1 -1
  9. package/client/dist/assets/{DocsDialog-Cr47QI9L.js → DocsDialog-C1kZ_mRP.js} +1 -1
  10. package/client/dist/assets/{DocsPage-C_WPRuG_.js → DocsPage-3GivxWJm.js} +1 -1
  11. package/client/dist/assets/{InteractiveJobComposer-NfFOciRB.js → InteractiveJobComposer-KuZXGdr8.js} +1 -1
  12. package/client/dist/assets/{JobDetailModal-DqEW7Z7K.js → JobDetailModal-DWpRALkB.js} +1 -1
  13. package/client/dist/assets/{JobDetailPage-D4ppEpDE.js → JobDetailPage-mrXDKafj.js} +1 -1
  14. package/client/dist/assets/{JobsPage-DStSoMyq.js → JobsPage-CUYXjcoc.js} +1 -1
  15. package/client/dist/assets/{LoopBuilderPage-DltCwS4P.js → LoopBuilderPage-CwjBD5Gm.js} +1 -1
  16. package/client/dist/assets/{LoopPreviewModal-BWaiAbUq.js → LoopPreviewModal-CO9ton0-.js} +1 -1
  17. package/client/dist/assets/{LoopsPage-BqjbXuBZ.js → LoopsPage-CLL5UuvN.js} +1 -1
  18. package/client/dist/assets/{MinimizedChatsContext--o3JAM9F.js → MinimizedChatsContext-DJAkRYps.js} +1 -1
  19. package/client/dist/assets/{PluginsPage-B-UOmzXw.js → PluginsPage-DZvCC5Ri.js} +1 -1
  20. package/client/dist/assets/{ProjectSettingsDialog-DkGajIxE.js → ProjectSettingsDialog-C7Hesadg.js} +1 -1
  21. package/client/dist/assets/{TemplatePreviewModal-5CUXlkw7.js → TemplatePreviewModal-DczCzUzP.js} +1 -1
  22. package/client/dist/assets/{TicketDetailModal-B_igqmti.js → TicketDetailModal-CFh7GOv5.js} +3 -3
  23. package/client/dist/assets/{Trans-DDMIBBUd.js → Trans-Cm9rhdnG.js} +1 -1
  24. package/client/dist/assets/{dashboard-DAr6F9et.js → dashboard-CHCEkds4.js} +1 -1
  25. package/client/dist/assets/{dashboard-D4Uj-XWc2.js → dashboard-Cqmmbh3l2.js} +1 -1
  26. package/client/dist/assets/{dashboard-BYzxom0L.js → dashboard-CrlTV1ms.js} +1 -1
  27. package/client/dist/assets/dashboard-Cslu_GCH.js +1 -0
  28. package/client/dist/assets/{dashboard-CnNtJBQ5.js → dashboard-DUU8-_Rg.js} +1 -1
  29. package/client/dist/assets/{dashboard-vrjZvxWy.js → dashboard-DweT3Y4R.js} +1 -1
  30. package/client/dist/assets/{dashboard-D57uRaGw2.js → dashboard-Dy2Um-US2.js} +1 -1
  31. package/client/dist/assets/{dashboard-vAcsn0L3.js → dashboard-WhzOQw1S.js} +1 -1
  32. package/client/dist/assets/{dist-js-DcoXTnEI.js → dist-js-BERxOT0X.js} +1 -1
  33. package/client/dist/assets/{dist-js-BCUhGUXS.js → dist-js-Di8lflPo.js} +1 -1
  34. package/client/dist/assets/{formatDistanceToNow-DP3Ab6fX.js → formatDistanceToNow-BAXZRbEO.js} +1 -1
  35. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-DdKKL8Fx.js → getTimezoneOffsetInMilliseconds-DwzZXHtg.js} +1 -1
  36. package/client/dist/assets/{i18n-DREQCE2F.js → i18n-cK5pPbj8.js} +2 -2
  37. package/client/dist/assets/{index-CQBIemiz.css → index-BT6xDQjB.css} +1 -1
  38. package/client/dist/assets/index-CCq_kIrF.js +76 -0
  39. package/client/dist/assets/{spending-CtY7yYes.js → spending-BLb5pj6G.js} +1 -1
  40. package/client/dist/assets/{useBrowserCaptureSession-BFBdW59q.js → useBrowserCaptureSession-DR1_jL56.js} +1 -1
  41. package/client/dist/assets/{useDesktop-uWVZ8eia.js → useDesktop-D4DxBGPU.js} +1 -1
  42. package/client/dist/index.html +12 -12
  43. package/docs/internals/safe-pr-review-flow.md +32 -3
  44. package/package.json +1 -1
  45. package/server/dist/active-pr-continuation.js +115 -1
  46. package/server/dist/agent-operator-prompt.js +9 -0
  47. package/server/dist/mcp/tools/rails.js +8 -0
  48. package/server/dist/project-git.js +7 -2
  49. package/server/dist/rail-isolated-launch.js +82 -23
  50. package/server/dist/rail-pr-decision.js +8 -0
  51. package/server/dist/rail-worktree-release.js +88 -2
  52. package/server/dist/rails-router.js +99 -1
  53. package/server/dist/worktree-manager.js +32 -5
  54. package/client/dist/assets/dashboard-BmDZmHyY.js +0 -1
  55. package/client/dist/assets/index-B784m8RT.js +0 -76
@@ -347,6 +347,7 @@ async function rerouteFromStalePr(deps, row, detail, exactWasPushed) {
347
347
  worktreeIds: releasableWorktreeIds(deps, snap),
348
348
  expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
349
349
  overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
350
+ settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
350
351
  onSafetyArchive: safetyArchiveRecorder(deps, row),
351
352
  });
352
353
  const partial = row.implementation_outcome === 'partially_succeeded';
@@ -386,6 +387,7 @@ async function settleObservedExistingPr(deps, row, observation, pushed) {
386
387
  worktreeIds: releasableWorktreeIds(deps, snap),
387
388
  expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
388
389
  overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
390
+ settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
389
391
  onSafetyArchive: safetyArchiveRecorder(deps, row),
390
392
  });
391
393
  const conflict = casTransition(deps, row, next, {
@@ -420,6 +422,7 @@ async function settleObservedClosedPr(deps, row, observation) {
420
422
  worktreeIds: releasableWorktreeIds(deps, snap),
421
423
  expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
422
424
  overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
425
+ settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
423
426
  onSafetyArchive: safetyArchiveRecorder(deps, row),
424
427
  });
425
428
  const conflict = casTransition(deps, row, 'pr_closed', {
@@ -1397,6 +1400,7 @@ async function runCreatePr(deps, row) {
1397
1400
  db: deps.db, git: deps.git, repoDir: deps.project.path, worktreeIds: releasableWorktreeIds(deps, snap),
1398
1401
  expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(effectiveBranches),
1399
1402
  overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(effectiveBranches),
1403
+ settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(effectiveBranches),
1400
1404
  onSafetyArchive: safetyArchiveRecorder(deps, row),
1401
1405
  });
1402
1406
  if (patch.cleanupWarnings.length > 0)
@@ -1537,6 +1541,7 @@ async function runDiscard(deps, row, opts = { removeNeedsReview: true }) {
1537
1541
  state: 'failed',
1538
1542
  expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
1539
1543
  overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
1544
+ settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
1540
1545
  onSafetyArchive: safetyArchiveRecorder(deps, row),
1541
1546
  }));
1542
1547
  const retainedWorktreeBranches = new Set(snap.worktreeIds
@@ -1628,6 +1633,7 @@ async function runAcknowledgeNoChanges(deps, row) {
1628
1633
  worktreeIds: snap.worktreeIds,
1629
1634
  expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
1630
1635
  overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
1636
+ settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
1631
1637
  onSafetyArchive: safetyArchiveRecorder(deps, row),
1632
1638
  });
1633
1639
  // A fresh no-change branch is disposable only while Git still proves that it
@@ -1683,6 +1689,7 @@ async function settleMergedPr(deps, row) {
1683
1689
  worktreeIds: releasableWorktreeIds(deps, snap), state: 'merged',
1684
1690
  expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
1685
1691
  overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
1692
+ settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
1686
1693
  onSafetyArchive: safetyArchiveRecorder(deps, row),
1687
1694
  });
1688
1695
  await deleteOwnedBranchesIfUnchanged(deps, row, snap, cleanupWarnings);
@@ -2049,6 +2056,7 @@ async function runMergeLocalLocked(deps, row) {
2049
2056
  state: 'merged',
2050
2057
  expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
2051
2058
  overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
2059
+ settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
2052
2060
  onSafetyArchive: safetyArchiveRecorder(deps, row),
2053
2061
  }));
2054
2062
  await deleteOwnedBranchesIfUnchanged(deps, row, snap, cleanupWarnings);
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.durableBranchHeads = durableBranchHeads;
37
37
  exports.durableOverlayCleanupEvidence = durableOverlayCleanupEvidence;
38
+ exports.durableSettlementIgnoredPaths = durableSettlementIgnoredPaths;
38
39
  exports.releaseRailWorktrees = releaseRailWorktrees;
39
40
  const rail_worktrees_store_1 = require("./rail-worktrees-store");
40
41
  const worktree_manager_1 = require("./worktree-manager");
@@ -104,6 +105,53 @@ function durableOverlayCleanupEvidence(records) {
104
105
  }
105
106
  return new Map([...byBranch].map(([branch, values]) => [branch, [...values.values()]]));
106
107
  }
108
+ /** Collapse persisted settlement ignored-path snapshots per branch. The same
109
+ * conservative rules as overlay evidence: malformed paths, a failed capture
110
+ * (null), or conflicting per-branch snapshots grant NO authorization. */
111
+ function durableSettlementIgnoredPaths(records) {
112
+ const byBranch = new Map();
113
+ for (const record of records) {
114
+ if (!record.branch || record.settlementIgnoredPaths === undefined)
115
+ continue;
116
+ const prior = byBranch.get(record.branch);
117
+ if (prior === null)
118
+ continue; // already poisoned
119
+ if (record.settlementIgnoredPaths === null) {
120
+ byBranch.set(record.branch, null);
121
+ continue;
122
+ }
123
+ if (!Array.isArray(record.settlementIgnoredPaths)) {
124
+ byBranch.set(record.branch, null);
125
+ continue;
126
+ }
127
+ const safe = [];
128
+ let valid = true;
129
+ for (const value of record.settlementIgnoredPaths) {
130
+ const normalized = typeof value === 'string' ? safeRelativeOverlayPath(value.replace(/\/$/, '')) : null;
131
+ if (!normalized) {
132
+ valid = false;
133
+ break;
134
+ }
135
+ safe.push(normalized);
136
+ }
137
+ if (!valid) {
138
+ byBranch.set(record.branch, null);
139
+ continue;
140
+ }
141
+ const sorted = [...new Set(safe)].sort();
142
+ if (prior === undefined) {
143
+ byBranch.set(record.branch, sorted);
144
+ continue;
145
+ }
146
+ if (prior.join('\0') !== sorted.join('\0'))
147
+ byBranch.set(record.branch, null);
148
+ }
149
+ return new Map([...byBranch].flatMap(([branch, paths]) => (paths === null ? [] : [[branch, paths]])));
150
+ }
151
+ function coveredBySnapshot(livePath, snapshot) {
152
+ const live = livePath.replace(/\/$/, '');
153
+ return snapshot.some((entry) => live === entry || live.startsWith(`${entry}/`));
154
+ }
107
155
  async function verifyReleaseEvidence(input, worktree) {
108
156
  const expected = input.expectedHeadByBranch.get(worktree.branch)?.toLowerCase();
109
157
  if (!expected || !COMMIT_SHA_RE.test(expected)) {
@@ -117,10 +165,31 @@ async function verifyReleaseEvidence(input, worktree) {
117
165
  'status', '--porcelain', '--untracked-files=all', '--ignored=matching', '--', '.',
118
166
  ...overlayExcludes.map((entry) => `:(top,exclude,literal)${entry}`),
119
167
  ], worktree.worktree_path);
120
- if (status.code !== 0)
168
+ if (status.code !== 0) {
169
+ if (!pathStillExists(worktree.worktree_path))
170
+ return { failure: null, overlayEntries: [], gone: true };
121
171
  return { failure: 'Git could not verify worktree cleanliness', overlayEntries: [] };
122
- if (status.stdout.trim())
172
+ }
173
+ // Partition: tracked/untracked entries are unconditional dirt (unchanged
174
+ // strictness); gitignored entries are release-safe ONLY when the immutable
175
+ // settlement snapshot already contained them — run-created build caches
176
+ // release cleanly, anything ignored that APPEARED after settlement (a
177
+ // user-parked file) preserves the worktree exactly as before.
178
+ const statusLines = status.stdout.split('\n').map((line) => line.trimEnd()).filter(Boolean);
179
+ const dirtyLines = statusLines.filter((line) => !line.startsWith('!! '));
180
+ if (dirtyLines.length > 0)
123
181
  return { failure: 'the worktree contains changes made after settlement', overlayEntries: [] };
182
+ const ignoredPaths = statusLines.filter((line) => line.startsWith('!! ')).map((line) => line.slice(3));
183
+ if (ignoredPaths.length > 0) {
184
+ const snapshot = input.settlementIgnoredByBranch?.get(worktree.branch);
185
+ if (!snapshot) {
186
+ return { failure: 'the worktree contains ignored paths and no settlement snapshot authorizes them', overlayEntries: [] };
187
+ }
188
+ const uncovered = ignoredPaths.filter((livePath) => !coveredBySnapshot(livePath, snapshot));
189
+ if (uncovered.length > 0) {
190
+ return { failure: `the worktree contains ignored paths that appeared after settlement (${uncovered.slice(0, 3).join(', ')})`, overlayEntries: [] };
191
+ }
192
+ }
124
193
  const [head, branchRef] = await Promise.all([
125
194
  input.git.run(['rev-parse', '--verify', 'HEAD'], worktree.worktree_path),
126
195
  input.git.run(['rev-parse', '--verify', `refs/heads/${worktree.branch}`], input.repoDir),
@@ -136,6 +205,8 @@ async function verifyReleaseEvidence(input, worktree) {
136
205
  return { failure: null, overlayEntries };
137
206
  }
138
207
  catch (err) {
208
+ if (!pathStillExists(worktree.worktree_path))
209
+ return { failure: null, overlayEntries: [], gone: true };
139
210
  return { failure: `verification failed: ${err instanceof Error ? err.message : String(err)}`, overlayEntries: [] };
140
211
  }
141
212
  }
@@ -271,6 +342,21 @@ async function releaseRailWorktrees(input) {
271
342
  if (wt.merge_state !== 'needs-review' && (0, rail_worktrees_store_1.isTerminalMergeState)(wt.merge_state))
272
343
  continue;
273
344
  const evidence = await verifyReleaseEvidence(input, wt);
345
+ // The mount is already GONE (the user ran `git worktree remove` manually,
346
+ // or an external prune cleaned it): Git could not even inspect the path
347
+ // AND the filesystem confirms it is absent. There are no bytes left to
348
+ // preserve — failing verification forever would permanently block Checkout
349
+ // on a branch that is actually free. Prune the stale registration
350
+ // (best-effort; prune only drops entries whose dir is missing) and
351
+ // terminalize the ledger row honestly.
352
+ if (evidence.gone) {
353
+ try {
354
+ await input.git.run(['worktree', 'prune'], input.repoDir);
355
+ }
356
+ catch { /* a leftover registration surfaces at checkout with git's own error */ }
357
+ (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, state);
358
+ continue;
359
+ }
274
360
  if (evidence.failure) {
275
361
  (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, 'needs-review');
276
362
  const warning = `worktree ${wt.worktree_path}: preserved because ${evidence.failure}`;
@@ -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");
@@ -277,6 +278,40 @@ function createRailsRouter() {
277
278
  res.status(500).json({ error: 'Failed to update rail profile' });
278
279
  }
279
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
+ });
280
315
  // PUT /rails/:railIndex/engine — set the AI engine override for a rail
281
316
  // Body: { aiEngine: string | null } (null = use the project's primary provider)
282
317
  router.put('/:railIndex/engine', (req, res) => {
@@ -352,7 +387,16 @@ function createRailsRouter() {
352
387
  return;
353
388
  }
354
389
  let { mode = 'implement' } = req.body ?? {};
355
- 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
+ }
356
400
  let loopId = rawLoopId;
357
401
  // Origin link (safe-pr-review-flow): an agent-chat/MCP launch tags itself so
358
402
  // the PR decision can later be posted back into the launching conversation.
@@ -395,6 +439,13 @@ function createRailsRouter() {
395
439
  res.status(403).json({ error: 'Loops are disabled on this server' });
396
440
  return;
397
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
+ }
398
449
  // Freestyle model picker: optional, validated against the allow-list.
399
450
  // Ignored for non-freestyle modes (they use the orchestrator model).
400
451
  if (mode === 'freestyle' && model !== undefined && model !== null) {
@@ -571,11 +622,23 @@ function createRailsRouter() {
571
622
  res.status(409).json({ error: 'pr_decision_pending', prDeliveryId: pendingSnapshot.id });
572
623
  return;
573
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
+ }
574
633
  continuablePrDelivery = pendingSnapshot;
575
634
  }
576
635
  const useIsolation = (0, rail_isolation_1.isolationApplies)({
577
636
  loopsEnabled: (0, feature_flags_1.isLoopsEnabled)(), scope, ticketCount: rail.ticketIds.length, readOnly: loopReadOnly,
578
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
+ }
579
642
  if (continuablePrDelivery && !loopReadOnly && !useIsolation) {
580
643
  res.status(409).json({
581
644
  error: 'pr_continuation_isolation_required',
@@ -589,6 +652,13 @@ function createRailsRouter() {
589
652
  // unborn HEAD can't be branched). Fall back (with a message) otherwise.
590
653
  const status = await (0, worktree_manager_1.repoIsolationStatus)(worktree_manager_1.defaultGitRunner, c.project.path);
591
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
+ }
592
662
  if (continuablePrDelivery) {
593
663
  res.status(409).json({
594
664
  error: 'pr_continuation_isolation_required',
@@ -618,6 +688,12 @@ function createRailsRouter() {
618
688
  deliverySha: continuablePrDelivery.deliverySha,
619
689
  },
620
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
+ : {}),
621
697
  });
622
698
  res.status(202).json({ loopRunIds: ids, railIndex, mode, isolated: true });
623
699
  return;
@@ -630,6 +706,16 @@ function createRailsRouter() {
630
706
  });
631
707
  return;
632
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
+ }
633
719
  if (continuablePrDelivery || err instanceof rail_isolated_launch_1.PrContinuationIsolationError) {
634
720
  const detail = err instanceof Error ? err.message : String(err);
635
721
  res.status(409).json({
@@ -1052,6 +1138,7 @@ function createRailsRouter() {
1052
1138
  worktreeIds: currentSnap.worktreeIds,
1053
1139
  expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(currentSnap.branches),
1054
1140
  overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(currentSnap.branches),
1141
+ settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(currentSnap.branches),
1055
1142
  onSafetyArchive: (archive) => {
1056
1143
  archived = true;
1057
1144
  if (!(0, rail_pr_store_1.appendPrDeliverySafetyArchive)(c.db, row.id, archive)) {
@@ -1077,6 +1164,17 @@ function createRailsRouter() {
1077
1164
  detail: cleanupWarnings[0],
1078
1165
  };
1079
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
+ }
1080
1178
  const checkedOut = await (0, project_git_1.checkoutProjectReviewBranch)(c.project.path, currentSnap.branch, currentSnap.deliverySha);
1081
1179
  if (!checkedOut.ok) {
1082
1180
  return { ok: false, status: 409, error: 'checkout_failed', detail: checkedOut.error };
@@ -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;
@@ -243,7 +243,7 @@ async function gitRun(git, args, cwd) {
243
243
  function commitPathspecs(excludePaths) {
244
244
  return [
245
245
  '--', '.',
246
- ...exports.PR_NEVER_STAGE_PATHS.map((p) => `:(exclude)${p}`),
246
+ ...exports.PR_NEVER_STAGE_PATHSPEC_ROOTS.map((p) => `:(exclude)${p}`),
247
247
  // Overlay names originate in the workspace filesystem. Treat them as
248
248
  // literal top-level paths so glob/pathspec metacharacters cannot broaden
249
249
  // the never-commit surface.
@@ -261,6 +261,23 @@ function normalizedGitPath(value) {
261
261
  function isPathAtOrBelow(candidate, root) {
262
262
  return candidate === root || candidate.startsWith(`${root}/`);
263
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);
264
281
  function forbiddenStagedPaths(staged, excludePaths) {
265
282
  const overlayRoots = excludePaths.map(normalizedGitPath).filter(Boolean);
266
283
  return staged.filter((candidate) => (PR_NEVER_STAGE_ROOTS.some((root) => isPathAtOrBelow(candidate, root))
@@ -307,7 +324,17 @@ function describeForbiddenPaths(paths) {
307
324
  */
308
325
  async function commitWorktreeAndVerify(git, worktreePath, message, excludePaths = []) {
309
326
  const pathspecs = commitPathspecs(excludePaths);
310
- const add = await gitRun(git, ['add', '-A', ...pathspecs], worktreePath);
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);
311
338
  let indexSafe = false;
312
339
  let indexError;
313
340
  if (add.code === 0) {
@@ -319,8 +346,8 @@ async function commitWorktreeAndVerify(git, worktreePath, message, excludePaths
319
346
  indexSafe = true;
320
347
  }
321
348
  else {
322
- // Exclude pathspecs prevent newly-staged private/overlay files, but an
323
- // earlier process may already have put them in the index. Reset only the
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
324
351
  // prohibited roots back to HEAD: working files remain intact for recovery.
325
352
  const reset = await gitRun(git, ['reset', '--quiet', ...forbiddenResetPathspecs(excludePaths)], worktreePath);
326
353
  if (reset.code !== 0) {
@@ -1 +0,0 @@
1
- import{r as e}from"./rolldown-runtime-CNC7AqOf.js";var t=e({default:()=>d,freestyleDialog:()=>l,launchAll:()=>a,launchAllDialog:()=>o,railControls:()=>s,railPr:()=>r,railRow:()=>c,railsBoard:()=>i,toasts:()=>n,useRails:()=>u}),n={railAdded:`Rail {{n}} added`,railRemoved:`{{rail}} removed`,railLimitReached:`Rail limit reached ({{max}} max)`,railDeleteFailed:`Couldn't remove the rail — it holds specs, is running, or has a pending PR decision`,launchTicketsInFlight:`A spec on this rail is already being worked by an active run`,launchAllSummary_one:`{{count}} rail launched in parallel`,launchAllSummary_other:`{{count}} rails launched in parallel`,launchAllNoneEligible:`No rails ready to launch — assign specs to an idle rail first`,launchAllFailed:`No rails could be launched`,railCompleted:`Rail {{n}} completed`,railFailed:`Rail {{n}} failed — specs returned to Specs`,railEnded:`Rail {{n}} {{status}} — specs returned to Specs`,alreadyOnRail:`Already on {{rail}}`,movedToRail:`Moved to {{rail}}`,railRunningStopFirst:`{{rail}} is running — stop it before removing`,removedFromRail:`Removed from {{rail}}`,queuedOnRail:`Queued on {{rail}}`,queuedOnRailDescription:`Rail is currently running`,railStopped:`{{rail}} stopped`,stopFailed:`Failed to stop rail`,syncTicketsFailed:`Failed to sync rail tickets`,launchFailed:`Failed to launch rail`,launchNetworkError:`Network error launching rail`,railLaunched:`{{rail}} launched`,launchDescription_one:`{{mode}} with {{count}} spec`,launchDescription_other:`{{mode}} with {{count}} specs`,railWorktreeNeedsReview:`Rail {{n}}: spec #{{ticket}} needs review after merge`,railWorktreesNoGit:`Worktree isolation unavailable: this project isn't a git repository — running in shared mode.`,railWorktreesNoCommits:`Worktree isolation unavailable: the repository has no commits yet — make an initial commit, then relaunch.`,railWorktreesError:`Worktree isolation failed — this run continues WITHOUT the PR review flow (no implementation card). See the error detail below.`,onReviewCannotMoveToRail:`This spec is awaiting PR review — resolve the PR decision first`,onReviewNotLaunchable:`A spec on this rail is awaiting PR review — resolve the PR decision before launching`},r={operation:{inProgress:`Action in progress…`,"create-pr":`Creating PR…`,publish:`Publishing…`,discard:`Discarding…`,dismiss:`Dismissing follow-up…`,"poll-merge":`Checking merge…`,reopen:`Reopening…`,"merge-local":`Integrating locally…`,"recover-and-retry":`Committing and retrying the preserved result…`,"acknowledge-no-changes":`Marking done…`},readyForReview:`Ready for review`,draftPr:`Draft PR`,branchPushed:`Branch pushed — PR pending`,branchPushedHint:`The branch was pushed but the PR couldn't be created (GitHub CLI unavailable). Retry to create it.`,localOnly:`Changes available locally`,localOnlyHint:`The implementation completed, but the changes have not been pushed. Integrate them locally or retry publishing when the remote is available.`,noChanges:`No changes needed`,markDone:`Mark done`,refine:`Refine`,completedToast:`Confirmed — specs moved to Done`,markDoneConfirmTitle:`Mark these specs done?`,markDoneConfirmBody:`The implementation confirmed that no code changes are needed. The specs will move to Done and the no-change worktree will be cleaned up.`,refineConfirmTitle:`Refine these specs?`,refineConfirmBody:`The no-change result will be closed and the specs will return to the backlog so you can refine them. The run logs remain available.`,partial:`{{succeeded}} of {{total}} implementations ready`,createPartialPr:`Create PR with {{count}}`,deliveryBlocked:`Implementation complete — delivery needs attention`,updateFailed:`Implementation complete — update failed`,retryPush:`Retry push`,retryPushVerified:`Push verified · commit {{sha}} is in the PR`,commitAlreadyVerified:`Commit {{sha}} is already verified in the PR`,deliveryCommit:`commit {{sha}}`,prClosed:`PR closed without merge`,reopen:`Reopen`,iteratingPr:`Updating PR`,prReady:`PR ready`,implementationFailed:`Implementation failed`,implementationFailedHint:`The implementation run failed. Open the run log, then discard this card before relaunching.`,prFailed:`PR failed`,prFailedDetail:`PR failed: {{detail}}`,createDegraded:`Couldn't publish the PR — {{detail}}. Retry when resolved.`,createPr:`Create PR`,createPrTooltip:`→ {{base}}`,publish:`Publish`,publishTooltip:`Open this draft PR for team review`,checkout:`Checkout`,checkoutTooltip:`Check out {{branch}} in the main project folder`,checkoutSuccess:`Checked out {{branch}}`,checkoutFailed:`Could not check out the PR branch`,checkoutDirtyTitle:`Checkout blocked to protect your changes`,checkoutDirtyBody:`The main project folder has uncommitted changes. Commit or stash them, then retry. Nothing was changed.`,checkoutSafetyUnknownTitle:`Checkout paused because safety could not be verified`,checkoutSafetyUnknownBody:`Specrails could not read the main project folder’s Git status. Nothing was released or changed. Retry after Git is available.`,checkoutNotDeliverableTitle:`This PR branch is not the preserved implementation`,checkoutNotDeliverableBody:`No exact delivery commit has been verified yet. Inspect or recover the local result instead.`,inspectLocalResult:`Inspect local result`,inspectLocalResultTooltip:`Reveal the preserved isolated worktree and copy its path`,localResultPathCopied:`Local result path copied`,localResultPath:`Preserved local result`,localResultUnavailableHere:`No recoverable worktree is available on this computer. Review the run log from the machine where it executed.`,recoverAndRetry:`Commit & retry push`,recoverAndRetryConfirmTitle:`Commit and push the preserved result?`,recoverAndRetryConfirmBody:`Specrails will use only the isolated worktree or recorded fast-forward branch for {{branch}}. It will commit deliverable files if needed, then push the exact commit only if the PR is still open on the expected head and base. Your main project folder and its uncommitted changes will not be touched.`,recoverAndRetryVerified:`Recovered commit {{sha}} is verified in the PR`,recoveryStillBlocked:`Local recovery still needs attention`,recheckRecovery:`Check again`,recheckRecoveryConfirmTitle:`Check this computer again?`,recheckRecoveryConfirmBody:`Specrails will rescan this clone’s delivery-owned worktree, branch, refs, reflogs, and orphan Git objects for {{branch}}. If it proves one result, it may commit isolated changes and retry the exact push. Your main project folder will not be touched.`,recoveryUnavailable:`Recovery could not be verified on this computer`,recoveryUnavailableBody:`Specrails could not safely prove one delivery-owned result in this clone. Nothing was changed or removed. Inspect the stage detail and run logs. If the run executed on another computer, open Specrails there, then check again.`,recoveryDetailSummary:`Technical recovery detail`,discard:`Discard`,dismiss:`Dismiss follow-up`,discardLocalResult:`Discard local result`,discardTooltip:`Close the delivery and return its tickets to Specs; changed local work is preserved`,retryPr:`Retry PR`,retry:`Retry`,checkMerge:`Check merge`,verifyPr:`Verify PR`,verifyPrTooltip:`Verify the exact commit in GitHub and check whether the PR is merged`,checkMergeTooltip:`Ask GitHub whether the PR has been merged`,prLinkFallback:`PR`,openPrTitle:`Open the PR on GitHub`,notMergedYet:`Not merged yet`,commitMissingRetry:`The delivery commit is no longer in the PR. Retry push is available again.`,prVerifiedWaiting:`Commit {{sha}} verified · waiting for merge`,alreadyResolved:`Already resolved elsewhere`,ghFailed:`GitHub CLI failed: {{detail}}`,actionFailed:`Couldn't apply the PR decision`,mergedToast:`PR merged — tickets moved to Done`,discardedToast:`PR discarded — tickets returned to Specs`,decisionPending:`This rail has a pending PR decision — create, publish or discard it before relaunching`,discardConfirmTitle:`Discard this delivery?`,discardConfirmBody:`The PR will be closed without deleting its remote branch. Only SpecRails-owned local branches and worktrees that are still at their recorded commit will be removed; anything changed will be preserved with a warning. The tickets will return to the Specs backlog. Removed local resources cannot be recovered.`,implementationFailedDiscardTitle:`Discard this failed delivery?`,implementationFailedDiscardBody:`The card will be cleared and its tickets will return to Specs. Local work and branches will be kept for inspection. Only resources proven safe to clean up may be removed.`,dismissConfirmTitle:`Dismiss this follow-up?`,dismissConfirmBody:`This follow-up card will be cleared. Its owned worktree will be removed only if it is still safe; otherwise it will be preserved with a warning. The existing PR, its branch, and review state will remain unchanged.`,discardLocalConfirmTitle:`Discard the local result?`,discardLocalConfirmBody:`The recoverable local iteration will be removed only if it is still safe; if it changed, it will be preserved with a warning. Any existing PR and its branch will remain unchanged. A removed result cannot be recovered.`,mergeLocal:`Integrate locally`,mergeLocalTooltip:`Merge the delivered branches into {{base}} in your local checkout (no GitHub needed)`,mergeLocalConfirmTitle:`Integrate locally?`,mergeLocalConfirmBody:`The delivered branches will be merged into {{base}} in your local checkout. Your checkout must be on {{base}} with a clean working tree.`,mergeLocalBlockedDirty:`Cannot integrate: your working tree on {{base}} has uncommitted changes. Commit or stash them, then retry.`,mergeLocalBlockedBranch:`Cannot integrate: your checkout is on {{current}}, not {{base}}. Check out {{base}}, then retry.`,mergeLocalFailed:`Local merge failed: {{detail}}`,mergedLocally:`Integrated into {{base}} locally`,viewRunLog:`View run log for {{ref}}`,cleanupIncomplete_one:`Cleanup is incomplete ({{count}} warning)`,cleanupIncomplete_other:`Cleanup is incomplete ({{count}} warnings)`,cleanupIncompleteHint:`Local recovery data was preserved. Inspect these details before removing anything manually.`,copyCleanupDetails:`Copy recovery details`,safetyArchiveTitle:`Safety archive ({{count}})`,safetyArchiveHint:`Temporary workspace files were preserved outside the PR as a precaution. You can inspect or remove them manually.`,copySafetyArchive:`Copy paths`,unit:{failed:`implementation failed`,blocked:`delivery blocked`,noChanges:`no changes`,succeeded:`ready`}},i={title:`Rails`,runningCount_one:`{{count}} running`,runningCount_other:`{{count}} running`,launchAll:`Launch all`,launchAllTitle:`Launch every ready rail in parallel — each run is isolated in its own git worktree`},a={skipRunning_one:`{{count}} already running`,skipRunning_other:`{{count}} already running`,skipEmpty_one:`{{count}} without specs`,skipEmpty_other:`{{count}} without specs`,skipPendingDecision_one:`{{count}} awaiting a PR decision`,skipPendingDecision_other:`{{count}} awaiting a PR decision`,skipOnReview_one:`{{count}} with specs on review`,skipOnReview_other:`{{count}} with specs on review`,skipInFlight_one:`{{count}} skipped (spec already in flight)`,skipInFlight_other:`{{count}} skipped (specs already in flight)`,failedCount_one:`{{count}} failed`,failedCount_other:`{{count}} failed`},o={title:`Launch all rails`,railsCount_one:`{{count}} rail`,railsCount_other:`{{count}} rails`,specsCount_one:`{{count}} spec`,specsCount_other:`{{count}} specs`,isolationTitle:`Parallel & isolated.`,isolationBody:`Each rail runs in its own git worktree — concurrent launches never collide.`,pipelinesTitle_one:`{{count}} AI pipeline.`,pipelinesTitle_other:`{{count}} AI pipelines.`,pipelinesBody:`One launch per rail, each with its configured mode, engine and profile.`,costTitle:`Multiplied cost.`,costBody:`Every rail spawns its own AI run — spend scales with the number of rails.`,launch_one:`Launch {{count}} rail`,launch_other:`Launch {{count}} rails`},s={viewJobLog:`View job log`,log:`Log`,implement:`Implement`,batch:`Batch`,freestyle:`Freestyle`,freestyleTitle:`Freestyle — Claude implements the spec autonomously, no pipeline`,loop:`Loop`,loopTitle:`Loop — run a published automation loop against the rail's specs`,pickLoop:`Pick a loop…`,effortTitle:`Reasoning effort`,stop:`Stop`,play:`Play`,failedRetry:`Job failed — click to retry`,addSpecsFirst:`Add specs to this rail first`,builtInLoops:`Built-in`,customLoops:`Custom loops`,worktreesMerged:`{{count}} merged`,worktreesNeedsReview:`{{count}} need review`,execSteps:`{{count}} steps`,execLines:`{{count}} lines`,openspec:`OpenSpec`},c={moveToSpecs:`Move to Specs`,deleteRail:`Delete {{label}}`,dropHere:`Drop here`,dropOnThisRail:`Drop on this rail`,dragSpecsHere:`Drag specs here`},l={title:`Launch in Freestyle mode`,context_one:`{{rail}} · {{count}} spec`,context_other:`{{rail}} · {{count}} specs`,noPipelineTitle:`No OpenSpec pipeline.`,noPipelineBody:`The structured architect → developer → reviewer flow is skipped entirely.`,autonomyTitle:`Native Claude autonomy.`,autonomyBody:`Claude works on its own using native agents and dynamic workflows.`,costTitle:`Variable cost.`,costBody:`Spend depends on what Claude decides to do — it can be higher than expected.`,continue:`Continue`},u={fetchFailed:`Failed to fetch rails: {{status}}`},d={toasts:n,railPr:r,railsBoard:i,launchAll:a,launchAllDialog:o,railControls:s,railRow:c,freestyleDialog:l,useRails:u};export{t};