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
package/server/dist/db.js CHANGED
@@ -1405,6 +1405,27 @@ const MIGRATIONS = [
1405
1405
  }
1406
1406
  backfillTerminalRailPrTicketEffects(db);
1407
1407
  },
1408
+ // Migration 53: an allocation rollback is the one legal way a previously
1409
+ // superseded delivery can become active again. Persist the failed replacement
1410
+ // id on the restored predecessor so clients can distinguish that explicit
1411
+ // rollback from an ordinary stale non-terminal replay.
1412
+ (db) => {
1413
+ const cols = new Set(db.prepare(`PRAGMA table_info(rail_pr_deliveries)`).all()
1414
+ .map((row) => row.name));
1415
+ if (!cols.has('restored_from_delivery_id')) {
1416
+ db.exec(`ALTER TABLE rail_pr_deliveries ADD COLUMN restored_from_delivery_id TEXT`);
1417
+ }
1418
+ },
1419
+ // Migration 54: automatic worktree release quarantines authenticated overlay
1420
+ // roots instead of deleting them. Persist the bounded archive locations so
1421
+ // recovery remains discoverable after restart and card refresh.
1422
+ (db) => {
1423
+ const cols = new Set(db.prepare(`PRAGMA table_info(rail_pr_deliveries)`).all()
1424
+ .map((row) => row.name));
1425
+ if (!cols.has('safety_archives')) {
1426
+ db.exec(`ALTER TABLE rail_pr_deliveries ADD COLUMN safety_archives TEXT NOT NULL DEFAULT '[]'`);
1427
+ }
1428
+ },
1408
1429
  ];
1409
1430
  function applyMigrations(db) {
1410
1431
  // Ensure the migrations table exists (migration 1 creates it, but we need
@@ -120,6 +120,12 @@ function railsTools() {
120
120
  .enum(['low', 'medium', 'high'])
121
121
  .optional()
122
122
  .describe('Reasoning-effort tier for loop launches (launch, loop mode). From the in-app agent chat, omitting it defaults to the launching conversation\'s effort when that is low/medium/high.'),
123
+ targetPrNumber: zod_1.z
124
+ .number()
125
+ .int()
126
+ .min(1)
127
+ .optional()
128
+ .describe('Deliver INTO an existing open PR instead of creating a new one (launch). When the user names an existing PR ("extend PR #151"), pass its number here — the rail then works on that PR\'s head branch and settle pushes to it; NEVER launch without it in that case (a plain launch would create a duplicate PR). The PR must be open and same-repo (fork PRs are rejected with target_pr_fork).'),
123
129
  },
124
130
  async handler(ctx, args) {
125
131
  const base = `${(0, types_1.projectPath)(ctx, args.projectId)}/rails`;
@@ -206,6 +212,8 @@ function railsTools() {
206
212
  body.loopId = args.loopId;
207
213
  if (args.reasoning_effort !== undefined)
208
214
  body.reasoning_effort = args.reasoning_effort;
215
+ if (args.targetPrNumber !== undefined)
216
+ body.targetPrNumber = args.targetPrNumber;
209
217
  // Origin link (safe-pr-review-flow): a launch driven by the in-app
210
218
  // agent carries its conversation id (from the loopback header, via
211
219
  // ctx) so the PR decision card is posted back into that conversation.
@@ -1,16 +1,97 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PR_LIFECYCLE_JSON_FIELDS = void 0;
4
+ exports.verifyPushRemoteForPr = verifyPushRemoteForPr;
4
5
  exports.observePrLifecycle = observePrLifecycle;
6
+ exports.matchesRecordedPrIdentity = matchesRecordedPrIdentity;
5
7
  exports.isExactOpenPr = isExactOpenPr;
6
8
  const SHA_RE = /^[0-9a-f]{40,64}$/i;
7
- exports.PR_LIFECYCLE_JSON_FIELDS = 'state,isDraft,headRefName,baseRefName,headRefOid,mergeCommit,commits';
9
+ exports.PR_LIFECYCLE_JSON_FIELDS = 'state,isDraft,headRefName,baseRefName,isCrossRepository,headRefOid,mergeCommit,commits';
8
10
  function firstLine(result) {
9
11
  return (result.stderr.trim() || result.stdout.trim()).split('\n')[0].trim().slice(0, 512);
10
12
  }
11
13
  function oid(value) {
12
14
  return typeof value === 'string' && SHA_RE.test(value) ? value.toLowerCase() : null;
13
15
  }
16
+ function githubRepositoryIdentity(rawUrl, kind) {
17
+ const raw = rawUrl.trim();
18
+ if (!raw || raw.includes('\n') || raw.includes('\r'))
19
+ return null;
20
+ let host = '';
21
+ let pathname = '';
22
+ try {
23
+ const parsed = new URL(raw);
24
+ if (!['http:', 'https:', 'ssh:', 'git:'].includes(parsed.protocol))
25
+ return null;
26
+ // The verified target is later passed as an argv element. Never carry
27
+ // embedded credentials or token-like URL suffixes into a child process or
28
+ // its timeout/error diagnostics; credential helpers remain the only
29
+ // supported authentication channel. SSH's conventional `git` username is
30
+ // identity, not a secret, and is safe to retain.
31
+ if (parsed.password || parsed.search || parsed.hash)
32
+ return null;
33
+ if (parsed.username && !(parsed.protocol === 'ssh:' && parsed.username === 'git'))
34
+ return null;
35
+ host = parsed.hostname.toLowerCase();
36
+ pathname = parsed.pathname;
37
+ }
38
+ catch {
39
+ // SCP-style Git URL: git@github.com:owner/repository.git
40
+ const scp = /^(?:git@)?([^@:/\s]+):(.+)$/.exec(raw);
41
+ if (!scp)
42
+ return null;
43
+ host = scp[1].toLowerCase();
44
+ pathname = scp[2];
45
+ if (pathname.includes('?') || pathname.includes('#'))
46
+ return null;
47
+ }
48
+ const segments = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
49
+ if (kind === 'pr') {
50
+ if (segments.length !== 4 || segments[2] !== 'pull' || !/^\d+$/.test(segments[3]))
51
+ return null;
52
+ }
53
+ else if (segments.length !== 2) {
54
+ return null;
55
+ }
56
+ const owner = segments[0]?.toLowerCase();
57
+ const repository = segments[1]?.replace(/\.git$/i, '').toLowerCase();
58
+ return host && owner && repository ? `${host}/${owner}/${repository}` : null;
59
+ }
60
+ /**
61
+ * Prove that the local push remote names the repository that owns a same-repo
62
+ * PR. Branch names alone are insufficient: another clone may call its fork
63
+ * `origin`, which would otherwise receive the exact recovery refspec.
64
+ */
65
+ async function verifyPushRemoteForPr(exec, repoDir, prUrl, remote = 'origin') {
66
+ const expected = githubRepositoryIdentity(prUrl, 'pr');
67
+ if (!expected)
68
+ return { ok: false, detail: 'the recorded PR URL does not identify an exact GitHub repository' };
69
+ let result;
70
+ try {
71
+ result = await exec.run('git', ['remote', 'get-url', '--push', '--all', remote], repoDir);
72
+ }
73
+ catch (err) {
74
+ return { ok: false, detail: `the ${remote} push remote could not be inspected: ${err instanceof Error ? err.message : String(err)}` };
75
+ }
76
+ if (result.code !== 0) {
77
+ return { ok: false, detail: `the ${remote} push remote could not be inspected: ${firstLine(result) || `exit ${result.code}`}` };
78
+ }
79
+ const pushTargets = result.stdout.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
80
+ if (pushTargets.length !== 1) {
81
+ return {
82
+ ok: false,
83
+ detail: `the ${remote} push remote must resolve to exactly one URL; found ${pushTargets.length}`,
84
+ };
85
+ }
86
+ const pushTarget = pushTargets[0];
87
+ const actual = githubRepositoryIdentity(pushTarget, 'remote');
88
+ if (!actual)
89
+ return { ok: false, detail: `the ${remote} push remote is not an unambiguous GitHub repository URL` };
90
+ if (actual !== expected) {
91
+ return { ok: false, detail: `the ${remote} push remote does not own the recorded PR repository` };
92
+ }
93
+ return { ok: true, identity: actual, pushTarget };
94
+ }
14
95
  /**
15
96
  * Observe one exact PR and, when requested, whether its immutable commit/merge
16
97
  * evidence contains a delivery SHA. The helper never guesses from a ledger row:
@@ -42,11 +123,15 @@ async function observePrLifecycle(exec, repoDir, prUrl, expectedSha) {
42
123
  // A merged PR's live head branch can advance after the merge. Its current
43
124
  // headRefOid is therefore NOT proof that the merge included that object.
44
125
  // Only the PR's commit snapshot / merge commit are valid MERGED evidence.
45
- const observedOids = new Set([
46
- ...(normalizedState === 'OPEN' ? [headRefOid] : []),
47
- mergeCommitOid,
48
- ...commitOids,
49
- ].filter((value) => value !== null));
126
+ // While OPEN, delivery verification is intentionally stronger than mere
127
+ // ancestry: the remote PR head itself must still be the frozen delivery
128
+ // object. Otherwise a concurrent writer could advance/substitute the head
129
+ // and a stale card would incorrectly report our exact push as verified.
130
+ // CLOSED/MERGED no longer have a mutable review head, so their immutable PR
131
+ // commit snapshot (or merge object) is the relevant inclusion evidence.
132
+ const observedOids = new Set((normalizedState === 'OPEN'
133
+ ? [headRefOid]
134
+ : [mergeCommitOid, ...commitOids]).filter((value) => value !== null));
50
135
  const includesExpectedSha = expected === null
51
136
  ? null
52
137
  : observedOids.has(expected)
@@ -60,6 +145,7 @@ async function observePrLifecycle(exec, repoDir, prUrl, expectedSha) {
60
145
  isDraft: parsed.isDraft === true,
61
146
  headRefName: typeof parsed.headRefName === 'string' ? parsed.headRefName : null,
62
147
  baseRefName: typeof parsed.baseRefName === 'string' ? parsed.baseRefName : null,
148
+ isCrossRepository: typeof parsed.isCrossRepository === 'boolean' ? parsed.isCrossRepository : null,
63
149
  headRefOid,
64
150
  mergeCommitOid,
65
151
  commitOids,
@@ -70,8 +156,14 @@ async function observePrLifecycle(exec, repoDir, prUrl, expectedSha) {
70
156
  return { ok: false, detail: 'unparseable gh pr view output' };
71
157
  }
72
158
  }
159
+ /** Head/base identity is required in every lifecycle state. A PR retargeted to
160
+ * another base must never satisfy delivery merely because it still lists the
161
+ * expected commit. */
162
+ function matchesRecordedPrIdentity(observation, branch, baseBranch) {
163
+ return observation.ok && observation.isCrossRepository === false &&
164
+ observation.headRefName === branch && observation.baseRefName === baseBranch;
165
+ }
73
166
  /** OPEN is continuation-safe only for the same recorded head/base identity. */
74
167
  function isExactOpenPr(observation, branch, baseBranch) {
75
- return observation.ok && observation.state === 'OPEN' &&
76
- observation.headRefName === branch && observation.baseRefName === baseBranch;
168
+ return observation.ok && matchesRecordedPrIdentity(observation, branch, baseBranch) && observation.state === 'OPEN';
77
169
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseWorktreePorcelain = parseWorktreePorcelain;
4
+ exports.inspectProjectCheckoutCleanliness = inspectProjectCheckoutCleanliness;
4
5
  exports.getProjectGitInfo = getProjectGitInfo;
5
6
  exports.compactCheckoutError = compactCheckoutError;
6
7
  exports.checkoutProjectBranch = checkoutProjectBranch;
@@ -17,6 +18,7 @@ const integration_branch_1 = require("./integration-branch");
17
18
  // against `git branch` output, so no user string can smuggle flags or refs.
18
19
  const GIT_TIMEOUT_MS = 10_000;
19
20
  const GIT_MAX_BUFFER = 4 * 1024 * 1024;
21
+ const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
20
22
  function git(repoDir, args) {
21
23
  return new Promise((resolve, reject) => {
22
24
  (0, child_process_1.execFile)('git', args, { cwd: repoDir, env: file_provenance_1.GIT_EXEC_ENV, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, windowsHide: true }, (err, stdout, stderr) => {
@@ -57,6 +59,25 @@ async function listLocalBranches(repoDir) {
57
59
  const out = await git(repoDir, ['branch', '--format=%(refname:short)']);
58
60
  return out.split('\n').map((b) => b.trim()).filter(Boolean);
59
61
  }
62
+ /** Lossless checkout preflight. Unlike the dashboard-oriented aggregate info,
63
+ * this result is tri-state: an unreadable status is never interpreted as a
64
+ * clean checkout. */
65
+ async function inspectProjectCheckoutCleanliness(repoDir) {
66
+ try {
67
+ // Tracked modifications only. `git checkout` never loses untracked files —
68
+ // app-owned state (`.specrails/local-tickets.json`) or editor droppings
69
+ // must not permanently block Checkout; a genuine untracked collision still
70
+ // aborts inside git itself and surfaces as a failed checkout.
71
+ const status = await git(repoDir, ['status', '--porcelain', '--untracked-files=no']);
72
+ return { ok: true, clean: status === '' };
73
+ }
74
+ catch (err) {
75
+ return {
76
+ ok: false,
77
+ detail: `Working tree cleanliness could not be verified: ${err instanceof Error ? err.message : String(err)}`,
78
+ };
79
+ }
80
+ }
60
81
  async function getProjectGitInfo(repoDir) {
61
82
  try {
62
83
  await git(repoDir, ['rev-parse', '--is-inside-work-tree']);
@@ -159,14 +180,22 @@ async function remoteBranchExists(repoDir, branch) {
159
180
  /** Checkout a PR/review branch into the user's main repo. Unlike the small
160
181
  * AgentGitBar switcher, this path may materialize a local tracking branch from
161
182
  * `origin/<branch>` because a PR delivery can be remote-only after worktree
162
- * cleanup or a fresh clone. It never overwrites dirty local work. */
163
- async function checkoutProjectReviewBranch(repoDir, branch) {
183
+ * cleanup or a fresh clone. When supplied, `expectedSha` is an immutable
184
+ * delivery lease: a divergent local/remote branch is preserved and checkout
185
+ * succeeds only when the final branch HEAD equals that exact object. It never
186
+ * overwrites dirty local work. */
187
+ async function checkoutProjectReviewBranch(repoDir, branch, expectedSha) {
164
188
  const target = branch.trim();
165
189
  if (!(0, integration_branch_1.isValidBranchName)(target))
166
190
  return { ok: false, error: `Invalid branch name: ${branch}` };
191
+ const expected = expectedSha?.trim().toLowerCase() ?? null;
192
+ if (expected !== null && !COMMIT_SHA_RE.test(expected)) {
193
+ return { ok: false, error: 'The verified delivery commit is invalid; checkout was not attempted.' };
194
+ }
167
195
  let dirty = false;
168
196
  try {
169
- dirty = (await git(repoDir, ['status', '--porcelain'])) !== '';
197
+ // Tracked modifications only — see inspectProjectCheckoutCleanliness.
198
+ dirty = (await git(repoDir, ['status', '--porcelain', '--untracked-files=no'])) !== '';
170
199
  }
171
200
  catch (err) {
172
201
  return { ok: false, error: err instanceof Error ? err.message : 'git status failed' };
@@ -185,9 +214,27 @@ async function checkoutProjectReviewBranch(repoDir, branch) {
185
214
  }
186
215
  try {
187
216
  if (branches.includes(target)) {
217
+ if (expected) {
218
+ const localSha = (await git(repoDir, ['rev-parse', '--verify', `refs/heads/${target}`])).toLowerCase();
219
+ if (localSha !== expected) {
220
+ return {
221
+ ok: false,
222
+ error: `Local branch '${target}' points to ${localSha.slice(0, 8)}, not verified delivery ${expected.slice(0, 8)}. It was preserved and not checked out.`,
223
+ };
224
+ }
225
+ }
188
226
  await git(repoDir, ['checkout', target]);
189
227
  }
190
228
  else if (await remoteBranchExists(repoDir, target)) {
229
+ if (expected) {
230
+ const remoteSha = (await git(repoDir, ['rev-parse', '--verify', `refs/remotes/origin/${target}`])).toLowerCase();
231
+ if (remoteSha !== expected) {
232
+ return {
233
+ ok: false,
234
+ error: `Remote branch 'origin/${target}' points to ${remoteSha.slice(0, 8)}, not verified delivery ${expected.slice(0, 8)}. Checkout was not attempted.`,
235
+ };
236
+ }
237
+ }
191
238
  await git(repoDir, ['checkout', '-b', target, '--track', `origin/${target}`]);
192
239
  }
193
240
  else {
@@ -197,6 +244,24 @@ async function checkoutProjectReviewBranch(repoDir, branch) {
197
244
  catch (err) {
198
245
  return { ok: false, error: compactCheckoutError(err instanceof Error ? err.message : 'git checkout failed') };
199
246
  }
247
+ if (expected) {
248
+ try {
249
+ const [currentBranch, currentSha] = await Promise.all([
250
+ git(repoDir, ['rev-parse', '--abbrev-ref', 'HEAD']),
251
+ git(repoDir, ['rev-parse', '--verify', 'HEAD']),
252
+ ]);
253
+ if (currentBranch !== target || currentSha.toLowerCase() !== expected) {
254
+ return {
255
+ ok: false,
256
+ error: `Checkout changed concurrently and did not land on verified delivery ${expected.slice(0, 8)}. No reset or cleanup was performed.`,
257
+ };
258
+ }
259
+ return { ok: true };
260
+ }
261
+ catch (err) {
262
+ return { ok: false, error: compactCheckoutError(err instanceof Error ? err.message : 'git checkout verification failed') };
263
+ }
264
+ }
200
265
  // A failed ff-only pull should not undo a successful checkout; the branch is
201
266
  // already where the user can inspect/fix it. Surface only hard checkout errors.
202
267
  await git(repoDir, ['pull', '--ff-only']).catch(() => { });