specrails-desktop 2.24.0 → 2.24.2

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 (39) hide show
  1. package/README.md +5 -4
  2. package/docs/guide/de/integrations/3-jira-integration.md +3 -2
  3. package/docs/guide/de/pipeline/1-rails-and-jobs.md +5 -5
  4. package/docs/guide/de/pipeline/3-batch-implement-and-multi-feature.md +6 -4
  5. package/docs/guide/en/integrations/3-jira-integration.md +4 -3
  6. package/docs/guide/en/pipeline/1-rails-and-jobs.md +5 -5
  7. package/docs/guide/en/pipeline/3-batch-implement-and-multi-feature.md +6 -4
  8. package/docs/guide/es/integrations/3-jira-integration.md +3 -2
  9. package/docs/guide/es/pipeline/1-rails-and-jobs.md +5 -5
  10. package/docs/guide/es/pipeline/3-batch-implement-and-multi-feature.md +6 -4
  11. package/docs/guide/fr/integrations/3-jira-integration.md +3 -2
  12. package/docs/guide/fr/pipeline/1-rails-and-jobs.md +5 -5
  13. package/docs/guide/fr/pipeline/3-batch-implement-and-multi-feature.md +6 -4
  14. package/docs/guide/it/integrations/3-jira-integration.md +3 -2
  15. package/docs/guide/it/pipeline/1-rails-and-jobs.md +5 -5
  16. package/docs/guide/it/pipeline/3-batch-implement-and-multi-feature.md +6 -4
  17. package/docs/guide/ja/integrations/3-jira-integration.md +3 -2
  18. package/docs/guide/ja/pipeline/1-rails-and-jobs.md +5 -5
  19. package/docs/guide/ja/pipeline/3-batch-implement-and-multi-feature.md +6 -4
  20. package/docs/guide/pt/integrations/3-jira-integration.md +3 -2
  21. package/docs/guide/pt/pipeline/1-rails-and-jobs.md +5 -5
  22. package/docs/guide/pt/pipeline/3-batch-implement-and-multi-feature.md +6 -4
  23. package/docs/guide/zh/integrations/3-jira-integration.md +3 -2
  24. package/docs/guide/zh/pipeline/1-rails-and-jobs.md +5 -5
  25. package/docs/guide/zh/pipeline/3-batch-implement-and-multi-feature.md +6 -4
  26. package/package.json +1 -1
  27. package/server/dist/active-pr-continuation.js +171 -0
  28. package/server/dist/agent-operator-prompt.js +9 -0
  29. package/server/dist/claude-trust.js +32 -25
  30. package/server/dist/docs-router.js +75 -10
  31. package/server/dist/integration-branch.js +80 -0
  32. package/server/dist/jira/jira-adf.js +8 -0
  33. package/server/dist/jira/jira-sync-manager.js +50 -11
  34. package/server/dist/mcp/guide.js +9 -0
  35. package/server/dist/mcp/tools/rails.js +2 -1
  36. package/server/dist/pr-publisher.js +40 -2
  37. package/server/dist/rail-isolated-launch.js +107 -7
  38. package/server/dist/vitest-setup.js +28 -0
  39. package/server/dist/worktree-manager.js +5 -1
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveActivePrContinuationTargets = resolveActivePrContinuationTargets;
4
+ const jira_db_1 = require("./jira/jira-db");
5
+ const rail_pr_store_1 = require("./rail-pr-store");
6
+ const integration_branch_1 = require("./integration-branch");
7
+ function parsePrNumber(prUrl) {
8
+ if (!prUrl)
9
+ return null;
10
+ const m = /\/pull\/(\d+)/.exec(prUrl);
11
+ return m ? parseInt(m[1], 10) : null;
12
+ }
13
+ function normalizedText(v) {
14
+ return typeof v === 'string' ? v.toLowerCase() : '';
15
+ }
16
+ function words(v) {
17
+ return normalizedText(v)
18
+ .replace(/[^a-z0-9]+/g, ' ')
19
+ .split(/\s+/)
20
+ .filter((w) => w.length >= 4);
21
+ }
22
+ function ticketRefs(db, ticketId, spec) {
23
+ const refs = new Set([`#${ticketId}`, `[${ticketId}]`, `ticket ${ticketId}`, `spec ${ticketId}`]);
24
+ let jiraKey = spec?.jira_key ?? null;
25
+ try {
26
+ const link = (0, jira_db_1.getLinkByLocalId)(db, ticketId);
27
+ if (link && !link.tombstoned && link.jiraKey)
28
+ jiraKey = link.jiraKey;
29
+ }
30
+ catch {
31
+ /* tolerated */
32
+ }
33
+ if (jiraKey?.trim())
34
+ refs.add(jiraKey.trim());
35
+ return [...refs];
36
+ }
37
+ function hasJiraRef(db, ticketId, spec) {
38
+ if (spec?.jira_key?.trim())
39
+ return true;
40
+ try {
41
+ const link = (0, jira_db_1.getLinkByLocalId)(db, ticketId);
42
+ return Boolean(link && !link.tombstoned && link.jiraKey);
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ function prMatchKind(db, ticketId, spec, pr) {
49
+ const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`.toLowerCase();
50
+ for (const ref of ticketRefs(db, ticketId, spec)) {
51
+ const needle = ref.toLowerCase();
52
+ if (needle.startsWith('#') || needle.startsWith('[')) {
53
+ if (haystack.includes(needle))
54
+ return 'explicit-ref';
55
+ }
56
+ else if (new RegExp(`(^|[^a-z0-9])${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i').test(haystack)) {
57
+ return 'explicit-ref';
58
+ }
59
+ }
60
+ const titleWords = words(spec?.title);
61
+ if (titleWords.length < 3)
62
+ return null;
63
+ const prWords = new Set(words(`${pr.title ?? ''} ${pr.body ?? ''}`));
64
+ const hits = titleWords.filter((w) => prWords.has(w)).length;
65
+ return hits >= Math.max(3, Math.ceil(titleWords.length * 0.7)) ? 'title-similarity' : null;
66
+ }
67
+ function canProbeGithubForContinuation(db, ticketId, spec) {
68
+ if (spec?.status === 'on_review')
69
+ return true;
70
+ // Jira review can materialize as in_progress when the project has not mapped
71
+ // its exact Review status to Specrails on_review. In that fallback, require a
72
+ // Jira-linked ticket and later only accept explicit PR references.
73
+ return spec?.status === 'in_progress' && hasJiraRef(db, ticketId, spec);
74
+ }
75
+ function matchAllowedForTicket(spec, kind) {
76
+ if (spec?.status === 'on_review')
77
+ return true;
78
+ return kind === 'explicit-ref';
79
+ }
80
+ async function localBranchExists(git, repoDir, branch) {
81
+ const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoDir);
82
+ return r.code === 0;
83
+ }
84
+ async function remoteBranchExists(git, repoDir, branch) {
85
+ const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], repoDir);
86
+ return r.code === 0;
87
+ }
88
+ async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBranch, prUrl, prNumber, isDraft, source) {
89
+ const head = branch?.trim();
90
+ const base = baseBranch?.trim();
91
+ if (!head || !base || head === base)
92
+ return null;
93
+ if (!(0, integration_branch_1.isValidBranchName)(head) || !(0, integration_branch_1.isValidBranchName)(base))
94
+ return null;
95
+ if (await localBranchExists(git, repoDir, head)) {
96
+ return { ticketId, branch: head, baseBranch: base, prUrl, prNumber, isDraft, source };
97
+ }
98
+ if (fetchOk && await remoteBranchExists(git, repoDir, head)) {
99
+ return { ticketId, branch: head, baseBranch: base, baseRef: `origin/${head}`, prUrl, prNumber, isDraft, source };
100
+ }
101
+ return null;
102
+ }
103
+ function internalDeliveryTargets(db, ticketIds) {
104
+ const wanted = new Set(ticketIds);
105
+ const out = new Map();
106
+ for (const row of (0, rail_pr_store_1.listActivePrDeliveries)(db)) {
107
+ const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
108
+ if (!snap.prUrl || !snap.branch)
109
+ continue;
110
+ for (const ticketId of snap.ticketIds) {
111
+ if (!wanted.has(ticketId) || out.has(ticketId))
112
+ continue;
113
+ out.set(ticketId, {
114
+ ticketId,
115
+ branch: snap.branch,
116
+ baseBranch: snap.baseBranch,
117
+ prUrl: snap.prUrl,
118
+ prNumber: snap.prNumber,
119
+ isDraft: snap.decision === 'pr_draft',
120
+ source: 'rail-pr-delivery',
121
+ });
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+ async function listOpenPrs(exec, repoDir) {
127
+ const r = await exec.run('gh', ['pr', 'list', '--state', 'open', '--json', 'number,title,body,headRefName,baseRefName,url,isDraft'], repoDir);
128
+ if (r.code !== 0)
129
+ return [];
130
+ try {
131
+ const parsed = JSON.parse(r.stdout);
132
+ return Array.isArray(parsed) ? parsed : [];
133
+ }
134
+ catch {
135
+ return [];
136
+ }
137
+ }
138
+ /**
139
+ * Detects tickets whose next implementation pass should continue an already
140
+ * open PR branch instead of creating fresh work from the integration branch.
141
+ * It is intentionally fail-open: no gh, no auth, ambiguous branch state, or no
142
+ * confident PR match all return no target, preserving the normal new-work flow.
143
+ */
144
+ async function resolveActivePrContinuationTargets(input) {
145
+ const out = new Map();
146
+ const internal = internalDeliveryTargets(input.db, input.ticketIds);
147
+ for (const [ticketId, target] of internal) {
148
+ const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, target.branch, target.baseBranch, target.prUrl, target.prNumber, target.isDraft, 'rail-pr-delivery');
149
+ if (materialized)
150
+ out.set(ticketId, materialized);
151
+ }
152
+ const remaining = input.ticketIds.filter((id) => !out.has(id));
153
+ const candidateIds = remaining.filter((id) => canProbeGithubForContinuation(input.db, id, input.getTicketSpec(id)));
154
+ if (candidateIds.length === 0)
155
+ return out;
156
+ const openPrs = await listOpenPrs(input.exec, input.repoDir);
157
+ for (const ticketId of candidateIds) {
158
+ const spec = input.getTicketSpec(ticketId);
159
+ const matches = openPrs.filter((pr) => {
160
+ const kind = prMatchKind(input.db, ticketId, spec, pr);
161
+ return kind ? matchAllowedForTicket(spec, kind) : false;
162
+ });
163
+ if (matches.length !== 1)
164
+ continue;
165
+ const pr = matches[0];
166
+ 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');
167
+ if (materialized)
168
+ out.set(ticketId, materialized);
169
+ }
170
+ return out;
171
+ }
@@ -324,6 +324,13 @@ summary later.
324
324
  - Configure then launch: \`specrails_rails(set_tickets, railIndex, ticketIds)\` →
325
325
  \`specrails_rails(launch, railIndex, mode, …)\`. Setting a profile and then
326
326
  switching the rail's engine to codex/gemini silently drops the profile.
327
+ - When relaunching an \`on_review\` spec that already has an OPEN GitHub PR,
328
+ \`launch\` automatically tries to continue that PR's head branch (matched by
329
+ Jira key / spec id / title) instead of starting from the integration branch.
330
+ Jira-linked \`in_progress\` specs can also continue an open PR when the match
331
+ is explicit, covering Jira projects whose Review status has not been mapped to
332
+ Specrails \`on_review\`. You do NOT need to know or pass the branch name. If
333
+ there is no confident open PR match, the normal new-work flow is preserved.
327
334
  - Launch proposal shape: tickets (ids + titles), rail number, mode, engine and
328
335
  model/profile, plus "runs for minutes and costs money". Wait for yes.
329
336
  - **Parallel launches are safe and normal.** Every rail launch runs its work in
@@ -362,6 +369,8 @@ summary later.
362
369
  folder is not a git repo; \`no-commits\` = git repo with no initial commit;
363
370
  \`error\` = isolation failed, relay the detail), and — for no-git/no-commits —
364
371
  that \`git init\` + one commit (no GitHub remote needed) unlocks the PR flow.
372
+ In no-git/no-commits projects, there is also no active-PR continuation because
373
+ there is no branch graph to continue.
365
374
  When isolation IS available (no such field), the card WILL appear on settle.
366
375
  - **After an ISOLATED rail settles, the DECISION belongs to the user, in the
367
376
  app's UI** (the PR card in this chat and the rail header show the same
@@ -36,26 +36,35 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.claudeConfigPath = claudeConfigPath;
37
37
  exports.markProjectsTrusted = markProjectsTrusted;
38
38
  exports.ensureClaudeTrusted = ensureClaudeTrusted;
39
- exports.__resetClaudeTrustMemoForTest = __resetClaudeTrustMemoForTest;
40
39
  /**
41
40
  * Pre-trust specrails-managed spawn directories in the user's `~/.claude.json`
42
- * so a HEADLESS claude rail/job spawn honours the `.claude/settings.json`
43
- * `permissions.allow` list the framework overlay places in the worktree.
44
- *
45
- * Why: claude ignores a workspace's `permissions.allow` entries until that
46
- * project directory has been "trusted" (the interactive trust dialog, or
47
- * `projects[<dir>].hasTrustDialogAccepted: true` in `~/.claude.json`). specrails
48
- * spawns claude headlessly in FRESH per-run worktrees / workspaces that were
49
- * never opened interactively, so every isolated rail run logged
41
+ * so a HEADLESS claude rail/job spawn does NOT log the noisy
50
42
  * "Ignoring N permissions.allow entries … this workspace has not been trusted"
51
- * and ran with its pre-approved permissions silently dropped. We manage those
52
- * directories, so pre-marking them trusted is correct and safe.
43
+ * warning.
44
+ *
45
+ * IMPORTANT — this is cosmetic, not load-bearing. Every claude spawn already
46
+ * carries `--dangerously-skip-permissions` (see `claude-adapter.ts` COMMON_FLAGS),
47
+ * which bypasses the permission engine entirely — so a workspace's
48
+ * `.claude/settings.json` `permissions.allow` list is moot on these spawns
49
+ * whether the dir is trusted or not. Marking the dir trusted only silences the
50
+ * warning. (It WOULD become functionally load-bearing if we ever dropped
51
+ * `--dangerously-skip-permissions` and relied on the allow-list.)
52
+ *
53
+ * Why re-assert on EVERY spawn (no persistent memo): `~/.claude.json` is a
54
+ * single ~200KB file holding ALL projects that many concurrent claude processes
55
+ * rewrite wholesale (each carries its own in-memory snapshot). Our surgical
56
+ * `true` for one dir is routinely clobbered by another process writing the whole
57
+ * file back with a stale `false` — a lost update. A one-shot per-process memo
58
+ * (the old design) therefore left the flag stuck `false` for the rest of the
59
+ * process lifetime after the first clobber. Re-asserting immediately before each
60
+ * spawn re-flips a clobbered `false → true` so this spawn reads `true` at
61
+ * startup. It cannot fully win the race against concurrent whole-file writers,
62
+ * but the failure mode is only a cosmetic warning, so best-effort is enough.
53
63
  *
54
64
  * Surgical + best-effort: read → set only `hasTrustDialogAccepted` on the
55
65
  * relevant `projects[<realpath>]` keys → atomic temp+rename. Never throws, never
56
66
  * touches any other field, and only writes when something actually changed.
57
- * claude-only (the trust/allow model is a claude concept). Memoized per path so
58
- * a multi-step run does the I/O once, before the first spawn in that dir.
67
+ * claude-only (the trust/allow model is a claude concept).
59
68
  */
60
69
  const fs = __importStar(require("fs"));
61
70
  const path = __importStar(require("path"));
@@ -122,17 +131,21 @@ function markProjectsTrusted(configPath, dirs) {
122
131
  }
123
132
  return changed;
124
133
  }
125
- // Per-path memo so a multi-step run writes at most once per unique dir.
126
- const _trusted = new Set();
127
134
  /**
128
- * Ensure the claude spawn directories are trusted, once per process per dir.
129
- * No-op for non-claude providers and best-effort otherwise (a failure only
130
- * means the pre-approved permissions stay dropped — never blocks a spawn).
135
+ * Ensure the claude spawn directories are trusted, RE-ASSERTED on every call
136
+ * (no persistent memo — see the module header for why: concurrent whole-file
137
+ * writers clobber our flag back to `false`, and a one-shot memo would leave it
138
+ * stuck). No-op for non-claude providers and best-effort otherwise (a failure
139
+ * only leaves the cosmetic trust warning — never blocks a spawn).
140
+ *
141
+ * `markProjectsTrusted` reads the current on-disk value and writes ONLY when a
142
+ * dir is missing / `false`, so a call where the flag is already `true` is a
143
+ * cheap read with no write.
131
144
  */
132
145
  function ensureClaudeTrusted(provider, dirs, home) {
133
146
  if (provider !== 'claude')
134
147
  return;
135
- const todo = dirs.filter((d) => !!d && !_trusted.has(canonical(d)));
148
+ const todo = dirs.filter((d) => !!d);
136
149
  if (todo.length === 0)
137
150
  return;
138
151
  try {
@@ -141,10 +154,4 @@ function ensureClaudeTrusted(provider, dirs, home) {
141
154
  catch {
142
155
  /* best-effort */
143
156
  }
144
- for (const d of todo)
145
- _trusted.add(canonical(d));
146
- }
147
- /** Test-only: clear the per-path memo. */
148
- function __resetClaudeTrustMemoForTest() {
149
- _trusted.clear();
150
157
  }
@@ -95,13 +95,78 @@ function langDir(guideRoot, lang) {
95
95
  }
96
96
  // ─── Category configuration ─────────────────────────────────────────────────
97
97
  const CATEGORY_LABELS = {
98
- 'getting-started': 'Getting started',
99
- specs: 'Specs',
100
- pipeline: 'Pipeline',
101
- agents: 'Agents',
102
- insights: 'Insights',
103
- integrations: 'Integrations',
104
- settings: 'Settings',
98
+ en: {
99
+ 'getting-started': 'Getting started',
100
+ specs: 'Specs',
101
+ pipeline: 'Pipeline',
102
+ agents: 'Agents',
103
+ insights: 'Insights',
104
+ integrations: 'Integrations',
105
+ settings: 'Settings',
106
+ },
107
+ es: {
108
+ 'getting-started': 'Primeros pasos',
109
+ specs: 'Specs',
110
+ pipeline: 'Pipeline',
111
+ agents: 'Agentes',
112
+ insights: 'Insights',
113
+ integrations: 'Integraciones',
114
+ settings: 'Ajustes',
115
+ },
116
+ fr: {
117
+ 'getting-started': 'Bien démarrer',
118
+ specs: 'Specs',
119
+ pipeline: 'Pipeline',
120
+ agents: 'Agents',
121
+ insights: 'Insights',
122
+ integrations: 'Intégrations',
123
+ settings: 'Réglages',
124
+ },
125
+ de: {
126
+ 'getting-started': 'Erste Schritte',
127
+ specs: 'Specs',
128
+ pipeline: 'Pipeline',
129
+ agents: 'Agenten',
130
+ insights: 'Insights',
131
+ integrations: 'Integrationen',
132
+ settings: 'Einstellungen',
133
+ },
134
+ pt: {
135
+ 'getting-started': 'Primeiros passos',
136
+ specs: 'Specs',
137
+ pipeline: 'Pipeline',
138
+ agents: 'Agentes',
139
+ insights: 'Insights',
140
+ integrations: 'Integrações',
141
+ settings: 'Definições',
142
+ },
143
+ it: {
144
+ 'getting-started': 'Primi passi',
145
+ specs: 'Spec',
146
+ pipeline: 'Pipeline',
147
+ agents: 'Agenti',
148
+ insights: 'Insights',
149
+ integrations: 'Integrazioni',
150
+ settings: 'Impostazioni',
151
+ },
152
+ zh: {
153
+ 'getting-started': '入门',
154
+ specs: '规格',
155
+ pipeline: '流水线',
156
+ agents: '代理',
157
+ insights: '洞察',
158
+ integrations: '集成',
159
+ settings: '设置',
160
+ },
161
+ ja: {
162
+ 'getting-started': 'はじめに',
163
+ specs: 'スペック',
164
+ pipeline: 'パイプライン',
165
+ agents: 'エージェント',
166
+ insights: 'インサイト',
167
+ integrations: '連携',
168
+ settings: '設定',
169
+ },
105
170
  };
106
171
  /**
107
172
  * Preferred display order. Categories not in this list come after, alphabetically.
@@ -119,8 +184,8 @@ const CATEGORY_ORDER = [
119
184
  function slugToTitle(slug) {
120
185
  return slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
121
186
  }
122
- function categoryLabel(slug) {
123
- return CATEGORY_LABELS[slug] ?? slugToTitle(slug);
187
+ function categoryLabel(slug, lang) {
188
+ return CATEGORY_LABELS[lang]?.[slug] ?? CATEGORY_LABELS[DEFAULT_LANG]?.[slug] ?? slugToTitle(slug);
124
189
  }
125
190
  function extractTitle(content, slug) {
126
191
  const match = content.match(/^#\s+(.+)$/m);
@@ -190,7 +255,7 @@ function buildCategories(guideRoot, lang) {
190
255
  return { title, slug: meta.slug };
191
256
  });
192
257
  if (docs.length > 0) {
193
- categories.push({ name: categoryLabel(cat), slug: cat, docs });
258
+ categories.push({ name: categoryLabel(cat, lang), slug: cat, docs });
194
259
  }
195
260
  }
196
261
  return categories.sort((a, b) => categoryRank(a.slug) - categoryRank(b.slug) || a.slug.localeCompare(b.slug));
@@ -1,9 +1,57 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FETCH_ORIGIN_TTL_MS = void 0;
4
+ exports.__resetFetchOriginCache = __resetFetchOriginCache;
5
+ exports.fetchOrigin = fetchOrigin;
3
6
  exports.repoDefaultBranch = repoDefaultBranch;
4
7
  exports.currentBranch = currentBranch;
5
8
  exports.isValidBranchName = isValidBranchName;
6
9
  exports.resolveIntegrationBranch = resolveIntegrationBranch;
10
+ exports.resolveWorktreeBaseRef = resolveWorktreeBaseRef;
11
+ /** How long a fetch outcome (success OR failure) is reused for the same repo
12
+ * before a fresh `git fetch origin` is attempted again. Exists so a burst of
13
+ * near-simultaneous launches against the SAME repo — e.g. a "Launch all"
14
+ * batch, which is N independent HTTP requests with no shared server-side
15
+ * transaction (see design.md Decision 2) — performs one real fetch instead of
16
+ * one per rail. */
17
+ exports.FETCH_ORIGIN_TTL_MS = 15_000;
18
+ const fetchCache = new Map();
19
+ /** Test-only: clear the fetch dedup cache so tests never leak state across
20
+ * cases (mirrors `__resetRepoLocks` in repo-lock.ts). */
21
+ function __resetFetchOriginCache() {
22
+ fetchCache.clear();
23
+ }
24
+ /**
25
+ * `git fetch origin` against `repoDir` — updates ONLY `refs/remotes/origin/*`,
26
+ * never the checked-out local branch or working tree (Git itself refuses to
27
+ * touch either via a plain fetch). Never throws: any non-zero exit or runner
28
+ * rejection resolves to `{ ok: false, error }` so callers can degrade
29
+ * gracefully instead of failing the launch.
30
+ *
31
+ * De-duped per `repoDir` for `FETCH_ORIGIN_TTL_MS`: a call within the window
32
+ * of the last attempt (success OR failure) for the same repo reuses that
33
+ * outcome instead of spawning a new `git fetch` process. `now` is injectable
34
+ * so tests can control TTL expiry without real timers.
35
+ */
36
+ async function fetchOrigin(git, repoDir, now = Date.now) {
37
+ const cached = fetchCache.get(repoDir);
38
+ const nowMs = now();
39
+ if (cached && nowMs - cached.at < exports.FETCH_ORIGIN_TTL_MS)
40
+ return cached.result;
41
+ const result = (async () => {
42
+ try {
43
+ const r = await git.run(['fetch', 'origin'], repoDir);
44
+ if (r.code === 0)
45
+ return { ok: true };
46
+ return { ok: false, error: r.stderr.trim() || r.stdout.trim() || `exit ${r.code}` };
47
+ }
48
+ catch (err) {
49
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
50
+ }
51
+ })();
52
+ fetchCache.set(repoDir, { at: nowMs, result });
53
+ return result;
54
+ }
7
55
  /** The repo's default branch via `origin/HEAD` → the bare branch name, or null. */
8
56
  async function repoDefaultBranch(git, repoDir) {
9
57
  const r = await git.run(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], repoDir);
@@ -61,3 +109,35 @@ async function resolveIntegrationBranch(git, input) {
61
109
  // Detached / no remote / unresolvable → the literal HEAD (legacy-identical).
62
110
  return { branch: 'HEAD', source: 'head-fallback' };
63
111
  }
112
+ /**
113
+ * Decide the actual `baseRef` a worktree should branch from, given the
114
+ * already-resolved integration branch and whether `fetchOrigin` succeeded.
115
+ *
116
+ * `explicit` is a launch-time override the caller chose on purpose — it is
117
+ * NEVER remote-prefixed and NEVER existence-checked (see proposal.md Out of
118
+ * Scope). Every other source (`repo-default`, `project-setting`,
119
+ * `head-fallback`) uses the fetched remote-tracking ref `origin/<branch>`
120
+ * ONLY when the fetch succeeded AND that remote branch actually exists —
121
+ * guards a `project-setting` branch that was never pushed, which would
122
+ * otherwise turn a working local-only setup into a broken `git worktree add`.
123
+ * Any failure of either check falls back to today's bare local branch name,
124
+ * with a human-readable `warning` the caller can log/broadcast.
125
+ */
126
+ async function resolveWorktreeBaseRef(git, input) {
127
+ const { repoDir, integration, fetchOk } = input;
128
+ if (integration.source === 'explicit') {
129
+ return { baseRef: integration.branch, usedRemote: false };
130
+ }
131
+ if (!fetchOk) {
132
+ return { baseRef: integration.branch, usedRemote: false, warning: 'git fetch origin failed; using local ref' };
133
+ }
134
+ const exists = await git.run(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${integration.branch}`], repoDir);
135
+ if (exists.code === 0) {
136
+ return { baseRef: `origin/${integration.branch}`, usedRemote: true };
137
+ }
138
+ return {
139
+ baseRef: integration.branch,
140
+ usedRemote: false,
141
+ warning: `origin/${integration.branch} not found; using local ref`,
142
+ };
143
+ }
@@ -12,6 +12,7 @@ exports.bodyForDeployment = bodyForDeployment;
12
12
  exports.commentMarker = commentMarker;
13
13
  exports.discardCommentMarker = discardCommentMarker;
14
14
  exports.prMergedCommentMarker = prMergedCommentMarker;
15
+ exports.railReviewCommentMarker = railReviewCommentMarker;
15
16
  exports.bodyContainsMarker = bodyContainsMarker;
16
17
  exports.commentHasMarker = commentHasMarker;
17
18
  exports.adfToText = adfToText;
@@ -62,6 +63,13 @@ function discardCommentMarker(ticketId, nonce) {
62
63
  function prMergedCommentMarker(refId, ticketId) {
63
64
  return `[specrails:pr-merged=${refId}:ticket=${ticketId}]`;
64
65
  }
66
+ /**
67
+ * Idempotency marker for the "ready for review" comment posted when an isolated
68
+ * rail parks a Jira-linked ticket on_review awaiting the PR decision.
69
+ */
70
+ function railReviewCommentMarker(refId, ticketId) {
71
+ return `[specrails:rail-review=${refId}:ticket=${ticketId}]`;
72
+ }
65
73
  /** True when an ADF doc or wiki string already contains the given marker. */
66
74
  function bodyContainsMarker(body, marker) {
67
75
  if (typeof body === 'string')
@@ -13,6 +13,7 @@ exports.JiraSyncManager = void 0;
13
13
  exports.backoffMs = backoffMs;
14
14
  exports.formatJqlDate = formatJqlDate;
15
15
  exports.buildCompletionComment = buildCompletionComment;
16
+ exports.buildRailReviewComment = buildRailReviewComment;
16
17
  exports.buildPrMergedComment = buildPrMergedComment;
17
18
  const node_crypto_1 = require("node:crypto");
18
19
  const ticket_store_1 = require("../ticket-store");
@@ -557,6 +558,16 @@ class JiraSyncManager {
557
558
  const link = (0, jira_db_1.getLinkByLocalId)(this.db, localId);
558
559
  if (!link || link.tombstoned)
559
560
  continue;
561
+ ops.push({
562
+ jiraIssueId: link.jiraIssueId,
563
+ opType: 'comment',
564
+ idempotencyKey: `${refId}:${localId}:comment:on-review`,
565
+ payload: {
566
+ jiraIssueId: link.jiraIssueId,
567
+ text: buildRailReviewComment(link.jiraKey),
568
+ marker: (0, jira_adf_1.railReviewCommentMarker)(refId, localId),
569
+ },
570
+ });
560
571
  ops.push({
561
572
  jiraIssueId: link.jiraIssueId,
562
573
  opType: 'transition',
@@ -1144,31 +1155,59 @@ function formatJqlDate(ms) {
1144
1155
  return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
1145
1156
  }
1146
1157
  function buildCompletionComment(args, jiraKey, needsReview) {
1158
+ const issue = jiraKey?.trim() ? jiraKey.trim() : 'the linked Jira issue';
1147
1159
  if (needsReview) {
1148
- return 'Specrails: the implementation rail terminated abnormally after its Ship phase — the result needs review.';
1160
+ return [
1161
+ `Specrails rail finished for ${issue}.`,
1162
+ 'Result: needs human review.',
1163
+ 'The implementation reached its Ship phase, but the rail terminated abnormally before Specrails could fully close the run.',
1164
+ `Run: ${args.jobId}`,
1165
+ 'Jira status: left unchanged for review.',
1166
+ ].join('\n');
1149
1167
  }
1150
1168
  const parts = [];
1151
1169
  if (args.status === 'completed') {
1152
- parts.push('✅ Implementation completed by a Specrails rail.');
1170
+ parts.push(`Specrails rail finished for ${issue}.`);
1171
+ parts.push('Result: completed successfully.');
1172
+ parts.push('Jira status: moving to Done.');
1153
1173
  }
1154
1174
  else if (args.status === 'canceled') {
1155
- parts.push('⏹️ The Specrails implementation rail was cancelled — the spec was returned to the backlog.');
1175
+ parts.push(`Specrails rail finished for ${issue}.`);
1176
+ parts.push('Result: cancelled before completion.');
1177
+ parts.push('Jira status: returning to the backlog.');
1156
1178
  }
1157
1179
  else {
1158
- parts.push('❌ The Specrails implementation rail failed — the spec was returned to the backlog.');
1180
+ parts.push(`Specrails rail finished for ${issue}.`);
1181
+ parts.push('Result: failed before completion.');
1182
+ parts.push('Jira status: returning to the backlog.');
1159
1183
  }
1160
- const meta = [`job ${args.jobId}`];
1161
- if (args.costUsd != null)
1162
- meta.push(`cost $${args.costUsd.toFixed(2)}`);
1184
+ parts.push(`Run: ${args.jobId}`);
1163
1185
  if (args.durationMs != null)
1164
- meta.push(`duration ${formatDuration(args.durationMs)}`);
1165
- parts.push(`(${meta.join(' · ')})`);
1186
+ parts.push(`Duration: ${formatDuration(args.durationMs)}`);
1187
+ if (args.costUsd != null)
1188
+ parts.push(`Cost: $${args.costUsd.toFixed(2)}`);
1166
1189
  return parts.join('\n');
1167
1190
  }
1191
+ /** Comment posted when an isolated rail has produced reviewable work. */
1192
+ function buildRailReviewComment(jiraKey) {
1193
+ const issue = jiraKey?.trim() ? jiraKey.trim() : 'the linked Jira issue';
1194
+ return [
1195
+ `Specrails rail finished for ${issue}.`,
1196
+ 'Result: implementation ready for PR review.',
1197
+ 'Jira status: moving to review while the PR decision remains open in Specrails.',
1198
+ 'Next step: review the Specrails PR card, then publish/merge or discard the delivery.',
1199
+ ].join('\n');
1200
+ }
1168
1201
  /** Comment posted on a linked issue when its delivery PR is merged. */
1169
1202
  function buildPrMergedComment(prUrl) {
1170
- const base = '✅ Specrails: the delivery PR was merged — the spec shipped to the integration branch.';
1171
- return prUrl ? `${base}\n${prUrl}` : base;
1203
+ const parts = [
1204
+ 'Specrails delivery accepted.',
1205
+ 'Result: PR merged into the integration branch.',
1206
+ 'Jira status: moving to Done.',
1207
+ ];
1208
+ if (prUrl)
1209
+ parts.push(`PR: ${prUrl}`);
1210
+ return parts.join('\n');
1172
1211
  }
1173
1212
  function formatDuration(ms) {
1174
1213
  const s = Math.round(ms / 1000);
@@ -108,6 +108,15 @@ heading inside the description); \`labels\`; \`priority\`. Spec content is Engli
108
108
  (ai-spawn) launches EVERY rail that has tickets and no active run / pending
109
109
  PR decision in one call, each with its stored mode/engine/profile, returning
110
110
  per-rail outcomes (launched / skipped with reason / failed).
111
+ - Relaunching an \`on_review\` ticket with a matching OPEN GitHub PR continues
112
+ that PR's head branch automatically. Jira-linked \`in_progress\` tickets can
113
+ also continue an open PR when the match is explicit, covering Jira projects
114
+ whose Review status has not been mapped to Specrails \`on_review\`. New
115
+ tickets, or tickets without a confident open-PR match, keep the normal
116
+ branch-from-integration flow.
117
+ - Projects without Git cannot use isolated worktrees or PR continuation. A
118
+ launch degrades to shared-cwd execution and returns \`isolationUnavailable\`;
119
+ explain that it writes directly to files and no PR card/branch will appear.
111
120
  - Configure: \`set_tickets\` (replaces the assigned set), \`set_profile\` (null =
112
121
  legacy), \`set_engine\` (null = project primary), \`set_name\`.
113
122
  - Launch modes:
@@ -29,6 +29,7 @@ function railsTools() {
29
29
  'launch (ai-spawn — spawns claude/codex/gemini CLI job(s) that WRITE CODE, RUN TESTS, COMMIT, and INCUR TOKEN COST; returns 202 with jobId/jobIds/loopRunIds), ' +
30
30
  'launch_all (ai-spawn — launches EVERY rail that has tickets and no active run/pending PR decision, in parallel, using each rail\'s stored mode/engine/profile; returns per-rail outcomes with skip reasons), ' +
31
31
  'stop (destructive — kills all active jobs and loop runs for the rail). ' +
32
+ 'For on_review tickets with an already-open GitHub PR, launch automatically tries to continue that PR head branch; Jira-linked in_progress tickets can do the same when the PR match is explicit; fresh tickets still start from the project integration branch. ' +
32
33
  'When launched from the in-app agent chat without an explicit aiEngine, the engine defaults to your conversation\'s provider (pass aiEngine to override; launch_all always uses each rail\'s stored engine). ' +
33
34
  'NAMING: railIndex is the 0-BASED internal identity; the dashboard shows rails 1-based ("Rail N" = railIndex N-1). When talking to the user, ALWAYS say "Rail <railIndex + 1>" (or the rail\'s custom name) — results include railLabel with the correct user-facing label.',
34
35
  hintTier: 'read',
@@ -234,7 +235,7 @@ function railsTools() {
234
235
  return {
235
236
  ...r,
236
237
  railLabel,
237
- hint: `Launch accepted (202) on ${railLabel} (isolated worktree, PR flow active) — tell the user it runs on "${railLabel}" (UI labels are 1-based) and that the PR-decision card will appear here and on the rail header when it settles. Use specrails_watch with the returned loopRunIds to await completion only if asked. Rails run for minutes; pass untilMs up to 600000 and re-watch on timeout.`,
238
+ hint: `Launch accepted (202) on ${railLabel} (isolated worktree, PR flow active) — tell the user it runs on "${railLabel}" (UI labels are 1-based). If the assigned spec is on_review with a matching open PR, or is Jira-linked in_progress with an explicit PR match, Specrails continues that PR branch automatically; otherwise it starts a fresh branch from the integration branch. The PR-decision card will appear here and on the rail header when it settles. Use specrails_watch with the returned loopRunIds to await completion only if asked. Rails run for minutes; pass untilMs up to 600000 and re-watch on timeout.`,
238
239
  };
239
240
  }
240
241
  case 'launch_all': {