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
@@ -1,13 +1,60 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ExplicitPrTargetError = void 0;
37
+ exports.resolveExplicitPrTarget = resolveExplicitPrTarget;
38
+ exports.listPrCandidatesForTickets = listPrCandidatesForTickets;
3
39
  exports.resolveActivePrContinuationTargets = resolveActivePrContinuationTargets;
4
40
  const jira_db_1 = require("./jira/jira-db");
5
41
  const rail_pr_store_1 = require("./rail-pr-store");
6
42
  const integration_branch_1 = require("./integration-branch");
43
+ const rail_worktrees_store_1 = require("./rail-worktrees-store");
7
44
  const pr_lifecycle_1 = require("./pr-lifecycle");
45
+ const fs = __importStar(require("fs"));
46
+ class ExplicitPrTargetError extends Error {
47
+ code;
48
+ constructor(code, message) {
49
+ super(message);
50
+ this.name = 'ExplicitPrTargetError';
51
+ this.code = code;
52
+ }
53
+ }
54
+ exports.ExplicitPrTargetError = ExplicitPrTargetError;
55
+ const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
8
56
  const PR_MATCH_RANK = {
9
- 'title-similarity': 1,
10
- 'ticket-ref': 2,
57
+ 'jira-key': 1,
11
58
  'pr-number': 3,
12
59
  };
13
60
  function parsePrNumber(prUrl) {
@@ -16,28 +63,18 @@ function parsePrNumber(prUrl) {
16
63
  const m = /\/pull\/(\d+)/.exec(prUrl);
17
64
  return m ? parseInt(m[1], 10) : null;
18
65
  }
19
- function normalizedText(v) {
20
- return typeof v === 'string' ? v.toLowerCase() : '';
21
- }
22
- function words(v) {
23
- return normalizedText(v)
24
- .replace(/[^a-z0-9]+/g, ' ')
25
- .split(/\s+/)
26
- .filter((w) => w.length >= 4);
27
- }
28
- function ticketRefs(db, ticketId, spec) {
29
- const refs = new Set([`#${ticketId}`, `[${ticketId}]`, `ticket ${ticketId}`, `spec ${ticketId}`]);
30
- let jiraKey = spec?.jira_key ?? null;
66
+ function jiraRefs(db, ticketId, spec) {
67
+ const refs = new Set();
68
+ if (spec?.jira_key?.trim())
69
+ refs.add(spec.jira_key.trim());
31
70
  try {
32
71
  const link = (0, jira_db_1.getLinkByLocalId)(db, ticketId);
33
72
  if (link && !link.tombstoned && link.jiraKey)
34
- jiraKey = link.jiraKey;
73
+ refs.add(link.jiraKey);
35
74
  }
36
75
  catch {
37
76
  /* tolerated */
38
77
  }
39
- if (jiraKey?.trim())
40
- refs.add(jiraKey.trim());
41
78
  return [...refs];
42
79
  }
43
80
  function mentionedPrNumbers(spec) {
@@ -67,23 +104,13 @@ function prMatchKind(db, ticketId, spec, pr) {
67
104
  const prNumber = typeof pr.number === 'number' ? pr.number : parsePrNumber(pr.url);
68
105
  if (prNumber !== null && mentionedPrs.has(prNumber))
69
106
  return 'pr-number';
70
- const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`.toLowerCase();
71
- for (const ref of ticketRefs(db, ticketId, spec)) {
72
- const needle = ref.toLowerCase();
73
- if (needle.startsWith('#') || needle.startsWith('[')) {
74
- if (haystack.includes(needle))
75
- return 'ticket-ref';
76
- }
77
- else if (new RegExp(`(^|[^a-z0-9])${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i').test(haystack)) {
78
- return 'ticket-ref';
79
- }
107
+ const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`;
108
+ for (const key of jiraRefs(db, ticketId, spec)) {
109
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
110
+ if (new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(haystack))
111
+ return 'jira-key';
80
112
  }
81
- const titleWords = words(spec?.title);
82
- if (titleWords.length < 3)
83
- return null;
84
- const prWords = new Set(words(`${pr.title ?? ''} ${pr.body ?? ''}`));
85
- const hits = titleWords.filter((w) => prWords.has(w)).length;
86
- return hits >= Math.max(3, Math.ceil(titleWords.length * 0.7)) ? 'title-similarity' : null;
113
+ return null;
87
114
  }
88
115
  function canProbeGithubForContinuation(db, ticketId, spec) {
89
116
  if (spec?.status === 'on_review')
@@ -93,11 +120,6 @@ function canProbeGithubForContinuation(db, ticketId, spec) {
93
120
  // Jira-linked ticket and later only accept explicit PR references.
94
121
  return spec?.status === 'in_progress' && hasJiraRef(db, ticketId, spec);
95
122
  }
96
- function matchAllowedForTicket(spec, kind) {
97
- if (spec?.status === 'on_review')
98
- return true;
99
- return kind !== 'title-similarity';
100
- }
101
123
  async function localBranchExists(git, repoDir, branch) {
102
124
  const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoDir);
103
125
  return r.code === 0;
@@ -106,7 +128,21 @@ async function remoteBranchExists(git, repoDir, branch) {
106
128
  const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], repoDir);
107
129
  return r.code === 0;
108
130
  }
109
- async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBranch, prUrl, prNumber, isDraft, source) {
131
+ async function observeRef(git, repoDir, ref) {
132
+ try {
133
+ const result = await git.run(['rev-parse', '--verify', '--quiet', ref], repoDir);
134
+ if (result.code === 1)
135
+ return { state: 'missing' };
136
+ if (result.code !== 0)
137
+ return { state: 'invalid' };
138
+ const sha = result.stdout.trim().toLowerCase();
139
+ return COMMIT_SHA_RE.test(sha) ? { state: 'exact', sha } : { state: 'invalid' };
140
+ }
141
+ catch {
142
+ return { state: 'invalid' };
143
+ }
144
+ }
145
+ async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBranch, prUrl, prNumber, isDraft, deliverySha, deliveryId, source) {
110
146
  const head = branch?.trim();
111
147
  const base = baseBranch?.trim();
112
148
  if (!head || !base || head === base)
@@ -115,36 +151,198 @@ async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBr
115
151
  return null;
116
152
  const remoteRef = fetchOk && await remoteBranchExists(git, repoDir, head) ? `origin/${head}` : undefined;
117
153
  if (await localBranchExists(git, repoDir, head)) {
118
- return { ticketId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, source };
154
+ return { ticketId, deliveryId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, deliverySha, source };
119
155
  }
120
156
  if (remoteRef)
121
- return { ticketId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, source };
157
+ return { ticketId, deliveryId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, deliverySha, source };
122
158
  return null;
123
159
  }
124
- function internalDeliveryTargets(db, ticketIds) {
160
+ /**
161
+ * Resolve an EXPLICITLY user-designated PR number into a continuation target.
162
+ * This is the user's answer, not a discovery guess: it bypasses the automatic
163
+ * inference gates (`canProbeGithubForContinuation`, spec-text matching) but
164
+ * keeps the same authoritative validation ladder — the PR must be verifiably
165
+ * OPEN on the SAME repository with a materializable head. Every failure throws
166
+ * `ExplicitPrTargetError` with a distinct code; callers reject the launch
167
+ * before any delivery row or worktree exists.
168
+ */
169
+ async function resolveExplicitPrTarget(input) {
170
+ const { prNumber } = input;
171
+ // 1. Resolve the PR number to its canonical URL (identity for the delivery
172
+ // row and for every later lifecycle observation / poll-merge).
173
+ let lookup;
174
+ try {
175
+ lookup = await input.exec.run('gh', ['pr', 'view', String(prNumber), '--json', 'url,number'], input.repoDir);
176
+ }
177
+ catch (err) {
178
+ throw new ExplicitPrTargetError('target_pr_not_found', `PR #${prNumber} could not be looked up: ${(err instanceof Error ? err.message : String(err)).slice(0, 256)}`);
179
+ }
180
+ if (lookup.code !== 0) {
181
+ const detail = (lookup.stderr.trim() || lookup.stdout.trim()).split('\n')[0].slice(0, 256);
182
+ throw new ExplicitPrTargetError('target_pr_not_found', `PR #${prNumber} could not be looked up: ${detail || `gh exited ${lookup.code}`}`);
183
+ }
184
+ let prUrl;
185
+ try {
186
+ const parsed = JSON.parse(lookup.stdout);
187
+ if (typeof parsed.url !== 'string' || !parsed.url.trim() || parsed.number !== prNumber) {
188
+ throw new Error('unexpected gh payload');
189
+ }
190
+ prUrl = parsed.url.trim();
191
+ }
192
+ catch {
193
+ throw new ExplicitPrTargetError('target_pr_invalid', `GitHub returned an unusable identity for PR #${prNumber}`);
194
+ }
195
+ // 2. Authoritative lifecycle observation — state, fork boundary, exact head.
196
+ const observed = await (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, prUrl);
197
+ if (!observed.ok) {
198
+ throw new ExplicitPrTargetError('target_pr_not_found', `PR #${prNumber} lifecycle could not be confirmed: ${observed.detail}`);
199
+ }
200
+ if (observed.state !== 'OPEN') {
201
+ throw new ExplicitPrTargetError('target_pr_not_open', `PR #${prNumber} is ${observed.state.toLowerCase()}, not open`);
202
+ }
203
+ // `null` means GitHub did not prove the head belongs to this repository —
204
+ // conservative rejection: a fork head is not pushable with origin rights.
205
+ if (observed.isCrossRepository !== false) {
206
+ throw new ExplicitPrTargetError('target_pr_fork', `PR #${prNumber} head lives on a fork (or same-repo could not be proven); fork PRs are not pushable`);
207
+ }
208
+ const head = observed.headRefName?.trim();
209
+ const base = observed.baseRefName?.trim() || input.integrationBranch;
210
+ if (!head || !base || head === base || !(0, integration_branch_1.isValidBranchName)(head) || !(0, integration_branch_1.isValidBranchName)(base) || !observed.headRefOid) {
211
+ throw new ExplicitPrTargetError('target_pr_invalid', `PR #${prNumber} head/base refs are unusable (head=${head ?? '?'}, base=${base ?? '?'})`);
212
+ }
213
+ // 3. Materialize exactly like automatic continuations: the head branch must
214
+ // exist locally or be fetchable from origin; the worktree verification
215
+ // later pins it to the observed head OID.
216
+ const target = await materializeTarget(input.git, input.repoDir, input.fetchOk, 0, head, base, prUrl, prNumber, observed.isDraft, observed.headRefOid, null, 'explicit-target');
217
+ if (!target) {
218
+ throw new ExplicitPrTargetError('target_pr_unfetchable', `PR #${prNumber} head branch ${head} is neither present locally nor fetchable from origin`);
219
+ }
220
+ return target;
221
+ }
222
+ function hasRetainedOrUnsettledWorktree(db, worktreeIds) {
223
+ return worktreeIds.some((id) => {
224
+ const worktree = (0, rail_worktrees_store_1.getRailWorktree)(db, id);
225
+ if (!worktree)
226
+ return false;
227
+ return !['released', 'merged', 'failed'].includes(worktree.merge_state) || fs.existsSync(worktree.worktree_path);
228
+ });
229
+ }
230
+ function internalDeliveryTargets(db, rows, ticketIds) {
125
231
  const wanted = new Set(ticketIds);
126
232
  const out = new Map();
127
- for (const row of (0, rail_pr_store_1.listActivePrDeliveries)(db)) {
233
+ for (const row of rows) {
128
234
  const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
129
235
  if (!snap.prUrl || !snap.branch)
130
236
  continue;
237
+ // A still-mounted or unsettled prior worktree can contain bytes not
238
+ // represented by delivery_sha. Never let the next agent iteration absorb
239
+ // them merely because HEAD/ref still happen to match the remote PR.
240
+ if (hasRetainedOrUnsettledWorktree(db, snap.worktreeIds))
241
+ continue;
131
242
  for (const ticketId of snap.ticketIds) {
132
243
  if (!wanted.has(ticketId) || out.has(ticketId))
133
244
  continue;
134
245
  out.set(ticketId, {
135
246
  ticketId,
247
+ deliveryId: row.id,
136
248
  branch: snap.branch,
137
249
  baseBranch: snap.baseBranch,
138
250
  prUrl: snap.prUrl,
139
251
  prNumber: snap.prNumber,
140
252
  isDraft: snap.decision === 'pr_draft',
253
+ deliverySha: snap.deliverySha,
141
254
  source: 'rail-pr-delivery',
142
255
  });
143
256
  }
144
257
  }
145
258
  return out;
146
259
  }
147
- const PR_JSON_FIELDS = 'number,title,body,headRefName,baseRefName,url,isDraft,state';
260
+ function ticketTargetKey(ticketIds) {
261
+ if (ticketIds.length === 0)
262
+ return null;
263
+ const seen = new Set();
264
+ for (const value of ticketIds) {
265
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
266
+ seen.add(value);
267
+ }
268
+ return seen.size > 0 ? [...seen].sort((a, b) => a - b).join(',') : null;
269
+ }
270
+ function exactTicketTargetKey(ticketIds) {
271
+ const key = ticketTargetKey(ticketIds);
272
+ if (!key || key.split(',').length !== ticketIds.length)
273
+ return null;
274
+ return key;
275
+ }
276
+ /** Recover only the newest terminal generation for the exact launch target.
277
+ * A newer fresh/other generation intentionally prevents an older continuation
278
+ * from lending its PR. Of terminal continuations, only an explicitly discarded
279
+ * one can be resumed: merged/completed/superseded history remains history. */
280
+ function latestHistoricalContinuation(db, ticketIds) {
281
+ const targetKey = exactTicketTargetKey(ticketIds);
282
+ if (!targetKey)
283
+ return { ownsTarget: false, candidate: null };
284
+ const latest = (0, rail_pr_store_1.getLatestTerminalPrDeliveryTouchingTicketSet)(db, ticketIds);
285
+ if (!latest)
286
+ return { ownsTarget: false, candidate: null };
287
+ // The newest exact-target generation is authoritative even when it is a
288
+ // fresh lineage. It must shadow every older continuation and also prevent a
289
+ // fuzzy GitHub/Jira fallback from rediscovering that stale PR.
290
+ if (latest.is_continuation !== 1)
291
+ return { ownsTarget: true, candidate: null };
292
+ if (latest.decision !== 'discarded')
293
+ return { ownsTarget: true, candidate: null };
294
+ const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(latest);
295
+ if (exactTicketTargetKey(snap.ticketIds) !== targetKey || latest.operation_token !== null) {
296
+ return { ownsTarget: true, candidate: null };
297
+ }
298
+ if (hasRetainedOrUnsettledWorktree(db, snap.worktreeIds)) {
299
+ return { ownsTarget: true, candidate: null };
300
+ }
301
+ const branch = snap.branch;
302
+ const baseBranch = snap.baseBranch;
303
+ const prUrl = snap.prUrl;
304
+ const deliverySha = snap.deliverySha?.toLowerCase() ?? null;
305
+ if (!branch || branch.trim() !== branch || !(0, integration_branch_1.isValidBranchName)(branch) ||
306
+ !baseBranch || baseBranch.trim() !== baseBranch || !(0, integration_branch_1.isValidBranchName)(baseBranch) ||
307
+ branch === baseBranch ||
308
+ !prUrl || prUrl.trim() !== prUrl ||
309
+ snap.prNumber === null || parsePrNumber(prUrl) !== snap.prNumber ||
310
+ !deliverySha || !COMMIT_SHA_RE.test(deliverySha))
311
+ return { ownsTarget: true, candidate: null };
312
+ return {
313
+ ownsTarget: true,
314
+ candidate: { deliveryId: latest.id, branch, baseBranch, prUrl, prNumber: snap.prNumber, deliverySha },
315
+ };
316
+ }
317
+ /** Historical recovery is stricter than ordinary active continuation
318
+ * materialization. Every available fetched/local ref must still name the exact
319
+ * immutable GitHub head; a stale, ahead, diverged, or unreadable ref fails
320
+ * closed rather than allowing worktree allocation to infer or rewrite it. */
321
+ async function materializeHistoricalContinuation(input, candidate, isDraft) {
322
+ const local = await observeRef(input.git, input.repoDir, `refs/heads/${candidate.branch}`);
323
+ if (local.state === 'invalid' || (local.state === 'exact' && local.sha !== candidate.deliverySha))
324
+ return null;
325
+ const remote = input.fetchOk
326
+ ? await observeRef(input.git, input.repoDir, `refs/remotes/origin/${candidate.branch}`)
327
+ : { state: 'missing' };
328
+ if (remote.state === 'invalid' || (remote.state === 'exact' && remote.sha !== candidate.deliverySha))
329
+ return null;
330
+ if (local.state !== 'exact' && remote.state !== 'exact')
331
+ return null;
332
+ return new Map(input.ticketIds.map((ticketId) => [ticketId, {
333
+ ticketId,
334
+ deliveryId: candidate.deliveryId,
335
+ branch: candidate.branch,
336
+ baseBranch: candidate.baseBranch,
337
+ ...(remote.state === 'exact' ? { baseRef: `origin/${candidate.branch}` } : {}),
338
+ prUrl: candidate.prUrl,
339
+ prNumber: candidate.prNumber,
340
+ isDraft,
341
+ deliverySha: candidate.deliverySha,
342
+ source: 'rail-pr-delivery',
343
+ }]));
344
+ }
345
+ const PR_JSON_FIELDS = 'number,title,body,headRefName,baseRefName,url,isDraft,state,isCrossRepository';
148
346
  async function listOpenPrs(exec, repoDir) {
149
347
  const r = await exec.run('gh', ['pr', 'list', '--state', 'open', '--limit', '200', '--json', PR_JSON_FIELDS], repoDir);
150
348
  if (r.code !== 0)
@@ -195,35 +393,120 @@ function mergeOpenPrs(prs) {
195
393
  }
196
394
  return out;
197
395
  }
396
+ /**
397
+ * Display-only candidate discovery for the launch UI's "deliver into existing
398
+ * PR" picker. Reuses the same matchers as automatic continuation (PR-number
399
+ * mention in the spec text, Jira key in the PR title/body/head) but WITHOUT
400
+ * the ticket-status gate — suggestions are informational and never
401
+ * auto-selected, so the strict gates guarding automatic adoption don't apply.
402
+ * Matched PRs sort first; the remaining open PRs follow for manual pick.
403
+ */
404
+ async function listPrCandidatesForTickets(input) {
405
+ const mentioned = new Set();
406
+ for (const ticketId of input.ticketIds) {
407
+ for (const n of mentionedPrNumbers(input.getTicketSpec(ticketId)))
408
+ mentioned.add(n);
409
+ }
410
+ const openPrs = mergeOpenPrs([
411
+ ...await listOpenPrs(input.exec, input.repoDir),
412
+ ...await mentionedOpenPrs(input.exec, input.repoDir, mentioned),
413
+ ]);
414
+ const matched = [];
415
+ const rest = [];
416
+ for (const pr of openPrs) {
417
+ const number = typeof pr.number === 'number' ? pr.number : parsePrNumber(pr.url);
418
+ const url = pr.url?.trim();
419
+ const head = pr.headRefName?.trim();
420
+ if (!number || !url || !head)
421
+ continue;
422
+ const candidate = {
423
+ number,
424
+ title: pr.title?.trim() || `PR #${number}`,
425
+ headRefName: head,
426
+ baseRefName: pr.baseRefName?.trim() || '',
427
+ url,
428
+ isDraft: pr.isDraft === true,
429
+ isCrossRepository: pr.isCrossRepository !== false,
430
+ };
431
+ const isMatch = input.ticketIds.some((ticketId) => (prMatchKind(input.db, ticketId, input.getTicketSpec(ticketId), pr) !== null));
432
+ (isMatch ? matched : rest).push(candidate);
433
+ }
434
+ return [...matched, ...rest];
435
+ }
198
436
  /**
199
437
  * Detects tickets whose next implementation pass should continue an already
200
438
  * open PR branch instead of creating fresh work from the integration branch.
201
- * It is intentionally fail-open: no gh, no auth, ambiguous branch state, or no
202
- * confident PR match all return no target, preserving the normal new-work flow.
439
+ * External discovery is fail-open when no durable Specrails lineage exists.
440
+ * Once an active or newest overlapping historical generation owns the target,
441
+ * however, missing/ambiguous GitHub or ref evidence is authoritative negative
442
+ * evidence and blocks fuzzy fallback to a different PR.
203
443
  */
204
444
  async function resolveActivePrContinuationTargets(input) {
205
445
  const out = new Map();
206
- const internal = internalDeliveryTargets(input.db, input.ticketIds);
446
+ const activeRows = (0, rail_pr_store_1.listActivePrDeliveries)(input.db);
447
+ const internal = internalDeliveryTargets(input.db, activeRows, input.ticketIds);
207
448
  const lifecycleByUrl = new Map();
208
449
  for (const [ticketId, target] of internal) {
209
450
  if (!target.prUrl)
210
451
  continue;
211
452
  let lifecycle = lifecycleByUrl.get(target.prUrl);
212
453
  if (!lifecycle) {
213
- lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, target.prUrl);
454
+ lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, target.prUrl, target.deliverySha);
214
455
  lifecycleByUrl.set(target.prUrl, lifecycle);
215
456
  }
216
457
  const observed = await lifecycle;
217
458
  // A ledger row is only a hint. GitHub remains lifecycle authority: a stale
218
459
  // draft/ready row for a CLOSED/MERGED or different head/base PR must not be
219
460
  // superseded and must not lend its branch to another implementation run.
220
- if (!observed.ok || !(0, pr_lifecycle_1.isExactOpenPr)(observed, target.branch, target.baseBranch))
461
+ if (!target.deliverySha || !observed.ok ||
462
+ !(0, pr_lifecycle_1.isExactOpenPr)(observed, target.branch, target.baseBranch) ||
463
+ observed.includesExpectedSha !== true ||
464
+ observed.headRefOid?.toLowerCase() !== target.deliverySha.toLowerCase())
221
465
  continue;
222
- const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, target.branch, target.baseBranch, target.prUrl, target.prNumber, observed.isDraft, 'rail-pr-delivery');
466
+ const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, target.branch, target.baseBranch, target.prUrl, target.prNumber, observed.isDraft, target.deliverySha, target.deliveryId, 'rail-pr-delivery');
223
467
  if (materialized)
224
468
  out.set(ticketId, materialized);
225
469
  }
226
- const remaining = input.ticketIds.filter((id) => !out.has(id));
470
+ // Dismiss/Discard closes the continuation generation but deliberately keeps
471
+ // its borrowed PR alive. Recover that exact target only when no active row
472
+ // owns any requested ticket and the latest terminal generation for the whole
473
+ // target is the discarded continuation. Failed historical verification is
474
+ // still authoritative negative evidence: do not fall through to fuzzy PR
475
+ // discovery and accidentally borrow a same-Jira foreign PR.
476
+ const wanted = new Set(input.ticketIds);
477
+ const activeTouchesTarget = activeRows.some((row) => ((0, rail_pr_store_1.toPrDeliverySnapshot)(row).ticketIds.some((ticketId) => wanted.has(ticketId))));
478
+ const historicalOwned = new Set();
479
+ const activeOwned = new Set();
480
+ if (activeTouchesTarget)
481
+ input.ticketIds.forEach((ticketId) => activeOwned.add(ticketId));
482
+ if (!activeTouchesTarget && out.size === 0) {
483
+ const historical = latestHistoricalContinuation(input.db, input.ticketIds);
484
+ if (historical.ownsTarget)
485
+ input.ticketIds.forEach((ticketId) => historicalOwned.add(ticketId));
486
+ if (historical.candidate) {
487
+ const candidate = historical.candidate;
488
+ let lifecycle = lifecycleByUrl.get(candidate.prUrl);
489
+ if (!lifecycle) {
490
+ lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, candidate.prUrl, candidate.deliverySha);
491
+ lifecycleByUrl.set(candidate.prUrl, lifecycle);
492
+ }
493
+ const observed = await lifecycle;
494
+ if (observed.ok &&
495
+ (0, pr_lifecycle_1.isExactOpenPr)(observed, candidate.branch, candidate.baseBranch) &&
496
+ observed.headRefOid?.toLowerCase() === candidate.deliverySha &&
497
+ observed.includesExpectedSha === true) {
498
+ const materialized = await materializeHistoricalContinuation(input, candidate, observed.isDraft);
499
+ if (materialized) {
500
+ for (const [ticketId, target] of materialized)
501
+ out.set(ticketId, target);
502
+ }
503
+ }
504
+ }
505
+ }
506
+ // A durable Specrails delivery that fails exact verification must never be
507
+ // downgraded into an inferred "external" match for the same ticket/PR. The
508
+ // visible delivery card owns reconciliation of that stale immutable SHA.
509
+ const remaining = input.ticketIds.filter((id) => (!out.has(id) && !internal.has(id) && !historicalOwned.has(id) && !activeOwned.has(id)));
227
510
  const candidateIds = remaining.filter((id) => canProbeGithubForContinuation(input.db, id, input.getTicketSpec(id)));
228
511
  if (candidateIds.length === 0)
229
512
  return out;
@@ -240,7 +523,7 @@ async function resolveActivePrContinuationTargets(input) {
240
523
  const spec = input.getTicketSpec(ticketId);
241
524
  const matches = openPrs.flatMap((pr) => {
242
525
  const kind = prMatchKind(input.db, ticketId, spec, pr);
243
- return kind && matchAllowedForTicket(spec, kind) ? [{ pr, kind }] : [];
526
+ return kind ? [{ pr, kind }] : [];
244
527
  });
245
528
  if (matches.length === 0)
246
529
  continue;
@@ -249,7 +532,23 @@ async function resolveActivePrContinuationTargets(input) {
249
532
  if (best.length !== 1)
250
533
  continue;
251
534
  const pr = best[0].pr;
252
- const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, pr.headRefName, pr.baseRefName || input.integrationBranch, pr.url ?? null, typeof pr.number === 'number' ? pr.number : parsePrNumber(pr.url), typeof pr.isDraft === 'boolean' ? pr.isDraft : null, 'github-open-pr');
535
+ const prUrl = pr.url?.trim();
536
+ const branch = pr.headRefName?.trim();
537
+ const baseBranch = pr.baseRefName?.trim() || input.integrationBranch;
538
+ if (!prUrl || !branch || !baseBranch)
539
+ continue;
540
+ let lifecycle = lifecycleByUrl.get(prUrl);
541
+ if (!lifecycle) {
542
+ lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, prUrl);
543
+ lifecycleByUrl.set(prUrl, lifecycle);
544
+ }
545
+ const observed = await lifecycle;
546
+ // `gh pr list` is discovery evidence only. Freeze the exact live remote
547
+ // head through an authoritative view before borrowing any local branch;
548
+ // otherwise an unrelated local-ahead commit could be carried into the PR.
549
+ if (!observed.ok || !observed.headRefOid || !(0, pr_lifecycle_1.isExactOpenPr)(observed, branch, baseBranch))
550
+ continue;
551
+ const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, branch, baseBranch, prUrl, typeof pr.number === 'number' ? pr.number : parsePrNumber(prUrl), observed.isDraft, observed.headRefOid, null, 'github-open-pr');
253
552
  if (materialized)
254
553
  out.set(ticketId, materialized);
255
554
  }
@@ -696,6 +696,11 @@ class AgentChatManager {
696
696
  console.log(`[agent-chat] pr-decision card skipped — conversation gone (${conversationId})`);
697
697
  return;
698
698
  }
699
+ const existing = this._findPrDecisionCards(conversationId, envelope.prDeliveryId);
700
+ if (existing.length > 0) {
701
+ this.updatePrDecisionCard(conversationId, envelope);
702
+ return;
703
+ }
699
704
  (0, agent_store_1.addAgentMessage)(this._db, { conversationId, role: 'system', content: JSON.stringify(envelope) });
700
705
  this._broadcastPrDecision(conversationId, envelope);
701
706
  }
@@ -714,33 +719,45 @@ class AgentChatManager {
714
719
  if (this._disposed)
715
720
  return;
716
721
  try {
717
- const existing = (0, agent_store_1.findAgentSystemMessage)(this._db, conversationId, (content) => {
718
- try {
719
- const parsed = JSON.parse(content);
720
- return parsed.kind === 'pr_decision' && parsed.prDeliveryId === envelope.prDeliveryId;
721
- }
722
- catch {
723
- return false;
724
- }
725
- });
726
- if (!existing) {
722
+ const existing = this._findPrDecisionCards(conversationId, envelope.prDeliveryId);
723
+ if (existing.length === 0) {
727
724
  this.postPrDecisionCard(conversationId, envelope);
728
725
  return;
729
726
  }
730
727
  const serialized = JSON.stringify(envelope);
728
+ // Keep the newest history anchor: client cold-load dedupe uses the same
729
+ // newest-row rule, so a crash between hydration and this repair cannot
730
+ // make an older blocked envelope authoritative again.
731
+ const canonical = existing[existing.length - 1];
732
+ const duplicateIds = existing.slice(0, -1).map((message) => message.id);
731
733
  // Startup recovery may inspect historical deliveries to heal a card that
732
734
  // missed its terminal update. Identical projection is not new activity:
733
735
  // avoid a redundant DB write and, crucially, do not broadcast an unread
734
736
  // event for an old card on every app launch.
735
- if (existing.content === serialized)
737
+ if (canonical.content === serialized && duplicateIds.length === 0)
736
738
  return;
737
- (0, agent_store_1.updateAgentMessageContent)(this._db, existing.id, serialized);
739
+ this._db.transaction(() => {
740
+ if (canonical.content !== serialized)
741
+ (0, agent_store_1.updateAgentMessageContent)(this._db, canonical.id, serialized);
742
+ (0, agent_store_1.deleteAgentMessagesByIds)(this._db, duplicateIds);
743
+ })();
738
744
  this._broadcastPrDecision(conversationId, envelope);
739
745
  }
740
746
  catch (err) {
741
747
  console.error(`[agent-chat] updatePrDecisionCard failed (${conversationId}):`, err);
742
748
  }
743
749
  }
750
+ _findPrDecisionCards(conversationId, prDeliveryId) {
751
+ return (0, agent_store_1.findAgentSystemMessages)(this._db, conversationId, (content) => {
752
+ try {
753
+ const parsed = JSON.parse(content);
754
+ return parsed.kind === 'pr_decision' && parsed.prDeliveryId === prDeliveryId;
755
+ }
756
+ catch {
757
+ return false;
758
+ }
759
+ });
760
+ }
744
761
  _broadcastPrDecision(conversationId, envelope) {
745
762
  this._broadcast({
746
763
  type: 'agent_pr_decision',
@@ -396,6 +396,15 @@ summary later.
396
396
  is explicit, covering Jira projects whose Review status has not been mapped to
397
397
  Specrails \`on_review\`. You do NOT need to know or pass the branch name. If
398
398
  there is no confident open PR match, the normal new-work flow is preserved.
399
+ - **Deliver into an existing PR (explicit target).** When the user names an
400
+ existing PR as the destination ("extend PR #151", "push this into the open
401
+ PR"), pass \`targetPrNumber\` on \`specrails_rails(launch)\` — NEVER launch
402
+ without it in that case, because a plain launch creates a DUPLICATE PR. The
403
+ rail then works on that PR's head branch and settle pushes to it. The PR must
404
+ be open and same-repo; the launch fails closed with \`target_pr_not_found\` /
405
+ \`target_pr_not_open\` / \`target_pr_fork\` / \`target_pr_invalid\` /
406
+ \`target_pr_unfetchable\` — report the reason, do not retry with a fresh
407
+ launch unless the user explicitly chooses a new PR.
399
408
  - **PR-aware relaunch classification.** Before classifying or relaunching a
400
409
  follow-up for an \`on_review\` spec or active PR, inspect that PR's head
401
410
  branch/diff/files, not only \`main\`. Treat OpenSpec artifacts added or
@@ -7,7 +7,9 @@ exports.updateAgentConversation = updateAgentConversation;
7
7
  exports.deleteAgentConversation = deleteAgentConversation;
8
8
  exports.addAgentMessage = addAgentMessage;
9
9
  exports.listAgentMessages = listAgentMessages;
10
+ exports.findAgentSystemMessages = findAgentSystemMessages;
10
11
  exports.findAgentSystemMessage = findAgentSystemMessage;
12
+ exports.deleteAgentMessagesByIds = deleteAgentMessagesByIds;
11
13
  exports.updateAgentMessageContent = updateAgentMessageContent;
12
14
  const crypto_1 = require("crypto");
13
15
  const agent_tier_1 = require("./agent-tier");
@@ -94,16 +96,29 @@ function listAgentMessages(db, conversationId) {
94
96
  .all(conversationId).map(mapMessage);
95
97
  }
96
98
  /**
97
- * First `system`-role message in a conversation whose content satisfies
99
+ * Every `system`-role message in a conversation whose content satisfies
98
100
  * `predicate` (oldest first). The store stays payload-agnostic — callers own
99
101
  * the content parsing (e.g. matching a PR-decision card by prDeliveryId).
100
102
  */
101
- function findAgentSystemMessage(db, conversationId, predicate = () => true) {
103
+ function findAgentSystemMessages(db, conversationId, predicate = () => true) {
102
104
  const rows = db
103
105
  .prepare("SELECT * FROM agent_messages WHERE conversation_id = ? AND role = 'system' ORDER BY created_at ASC, rowid ASC")
104
106
  .all(conversationId);
105
- const hit = rows.find((r) => predicate(r.content));
106
- return hit ? mapMessage(hit) : undefined;
107
+ return rows.filter((row) => predicate(row.content)).map(mapMessage);
108
+ }
109
+ function findAgentSystemMessage(db, conversationId, predicate = () => true) {
110
+ return findAgentSystemMessages(db, conversationId, predicate)[0];
111
+ }
112
+ /** Delete exact message ids without bumping conversation activity. Callers use
113
+ * this only to consolidate duplicate derived system cards, never user turns. */
114
+ function deleteAgentMessagesByIds(db, messageIds) {
115
+ if (messageIds.length === 0)
116
+ return 0;
117
+ const remove = db.prepare('DELETE FROM agent_messages WHERE id = ?');
118
+ let deleted = 0;
119
+ for (const id of messageIds)
120
+ deleted += remove.run(id).changes;
121
+ return deleted;
107
122
  }
108
123
  /**
109
124
  * Replace a message's content in place (inline-card state updates). Does NOT