specrails-desktop 2.34.0 → 2.34.1

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 (31) hide show
  1. package/client/dist/assets/{ActivityFeedPage-DIDxNsDj.js → ActivityFeedPage-B4yaoZsD.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-7pJ2G5VY.js → AgentBrowserCapture-Bhykbzog.js} +1 -1
  3. package/client/dist/assets/{AgentModeAnalyticsPane-BSSyTnTM.js → AgentModeAnalyticsPane-prvv23b7.js} +2 -2
  4. package/client/dist/assets/{AgentModeCodePane-DzW6gvDw.js → AgentModeCodePane-BmxySwPP.js} +2 -2
  5. package/client/dist/assets/{AgentModeJobsPane-9wJff15f.js → AgentModeJobsPane-BvW6tTmU.js} +1 -1
  6. package/client/dist/assets/{AgentsPage-CIA67psb.js → AgentsPage-DinMMQPm.js} +1 -1
  7. package/client/dist/assets/{AnalyticsPage-C0LwYELz.js → AnalyticsPage-d5GmoZY2.js} +1 -1
  8. package/client/dist/assets/{CodePage-RtKvHmAO.js → CodePage-Cgbj4lpK.js} +1 -1
  9. package/client/dist/assets/{DesktopAnalyticsPage-BU8Y_Ts1.js → DesktopAnalyticsPage-DbbZi6fw.js} +1 -1
  10. package/client/dist/assets/{DocsDialog-DIpoaGlQ.js → DocsDialog-B87oF3aC.js} +1 -1
  11. package/client/dist/assets/{DocsPage-ME47KZpK.js → DocsPage-CqJ-yRkh.js} +1 -1
  12. package/client/dist/assets/{InteractiveJobComposer-BGjIe9R6.js → InteractiveJobComposer-nPGDKuCk.js} +1 -1
  13. package/client/dist/assets/{JobDetailModal-1UZkqH4I.js → JobDetailModal-duouGMzi.js} +1 -1
  14. package/client/dist/assets/{JobDetailPage-CN65sAf1.js → JobDetailPage-Cw3Hu579.js} +1 -1
  15. package/client/dist/assets/{JobsPage-f-4dqys7.js → JobsPage-DYyV-qWT.js} +1 -1
  16. package/client/dist/assets/{LoopBuilderPage-WeiofeL6.js → LoopBuilderPage-B1GJJ0QZ.js} +1 -1
  17. package/client/dist/assets/{LoopPreviewModal-DDTnTyH0.js → LoopPreviewModal-Md6KF7rU.js} +1 -1
  18. package/client/dist/assets/{LoopsPage-BVNchIex.js → LoopsPage-BKNiwsy1.js} +1 -1
  19. package/client/dist/assets/{PluginsPage-CdFzGEfQ.js → PluginsPage-r682uE6n.js} +1 -1
  20. package/client/dist/assets/{ProjectSettingsDialog-BkCXBQWk.js → ProjectSettingsDialog-D6Vxjpv5.js} +1 -1
  21. package/client/dist/assets/{TemplatePreviewModal-RDi0khFG.js → TemplatePreviewModal-DcgWnn23.js} +1 -1
  22. package/client/dist/assets/{TicketDetailModal-MhJzrqbi.js → TicketDetailModal-BPaqyvrs.js} +2 -2
  23. package/client/dist/assets/{dist-js-BmsFWRly.js → dist-js-B1s_CM_v.js} +1 -1
  24. package/client/dist/assets/{dist-js-J5W_N5ng.js → dist-js-BNETKUa_.js} +1 -1
  25. package/client/dist/assets/{index-C_sdsSZt.js → index-DdC_faQv.js} +21 -21
  26. package/client/dist/index.html +2 -2
  27. package/package.json +1 -1
  28. package/server/dist/providers/claude-adapter.js +21 -1
  29. package/server/dist/rail-isolated-launch.js +18 -3
  30. package/server/dist/rail-worktree-release.js +17 -1
  31. package/server/dist/worktree-node-modules.js +138 -16
@@ -204,7 +204,7 @@
204
204
  .specrails-splash__mark .pill { opacity: 1; }
205
205
  }
206
206
  </style>
207
- <script type="module" crossorigin src="/assets/index-C_sdsSZt.js"></script>
207
+ <script type="module" crossorigin src="/assets/index-DdC_faQv.js"></script>
208
208
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CNC7AqOf.js">
209
209
  <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-zu2_FqZY.js">
210
210
  <link rel="modulepreload" crossorigin href="/assets/react-dom-DiWzBDP9.js">
@@ -277,7 +277,7 @@
277
277
  <link rel="modulepreload" crossorigin href="/assets/getTimezoneOffsetInMilliseconds-EjGOpn4b.js">
278
278
  <link rel="modulepreload" crossorigin href="/assets/formatDistanceToNow-BOuXgebD.js">
279
279
  <link rel="modulepreload" crossorigin href="/assets/spending-BpYPsevQ.js">
280
- <link rel="modulepreload" crossorigin href="/assets/TicketDetailModal-MhJzrqbi.js">
280
+ <link rel="modulepreload" crossorigin href="/assets/TicketDetailModal-BPaqyvrs.js">
281
281
  <link rel="stylesheet" crossorigin href="/assets/index-DCN5QbDm.css">
282
282
  </head>
283
283
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.34.0",
3
+ "version": "2.34.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.claudeAdapter = void 0;
14
14
  exports._normaliseClaudeModel = normaliseModel;
15
+ exports._resolveClaudeSpawnModel = resolveSpawnModel;
15
16
  const child_process_1 = require("child_process");
16
17
  const path_1 = __importDefault(require("path"));
17
18
  const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
@@ -33,6 +34,7 @@ function normaliseModel(model) {
33
34
  return 'sonnet';
34
35
  case 'claude-fable-5':
35
36
  return 'fable';
37
+ case 'claude-opus-5':
36
38
  case 'claude-opus-4-8':
37
39
  case 'claude-opus-4-5':
38
40
  case 'claude-opus-4-1-20250805':
@@ -46,6 +48,24 @@ function normaliseModel(model) {
46
48
  return model || 'sonnet';
47
49
  }
48
50
  }
51
+ /**
52
+ * Catalog aliases whose GENERATION Specrails pins explicitly.
53
+ *
54
+ * The catalog value (`opus`) stays the stored, validated and displayed identity
55
+ * — profiles, project settings, conversation rows and analytics all keep using
56
+ * it, and `normaliseModel` collapses the pinned id back to it. Only the spawn
57
+ * argument is expanded, so picking "Claude Opus" is a product decision about
58
+ * WHICH Opus runs instead of delegating that to whatever generation the CLI's
59
+ * bare `opus` alias currently points at.
60
+ */
61
+ const PINNED_ALIAS_MODEL_IDS = {
62
+ opus: 'claude-opus-5',
63
+ };
64
+ /** Catalog value (or concrete id) in, spawn model id out. */
65
+ function resolveSpawnModel(model) {
66
+ const alias = normaliseModel(model);
67
+ return PINNED_ALIAS_MODEL_IDS[alias] ?? alias;
68
+ }
49
69
  /**
50
70
  * Common flags + the effective tool boundary for this spawn.
51
71
  *
@@ -95,7 +115,7 @@ function commonFlagsFor(opts) {
95
115
  }
96
116
  function buildClaudeArgs(action, opts) {
97
117
  const args = [];
98
- const model = normaliseModel(opts.model);
118
+ const model = resolveSpawnModel(opts.model);
99
119
  // Titles are unconditionally pure-output. Centralising the policy here keeps
100
120
  // both ChatManager and AgentChatManager from accidentally regaining tools.
101
121
  const commonFlags = commonFlagsFor(action === 'auto-title' ? { ...opts, toolPolicy: 'none' } : opts);
@@ -301,7 +301,7 @@ function branchRecords(results) {
301
301
  branchOwnership: result.run.branchOwnership,
302
302
  worktreePath: result.run.handle.worktreePath,
303
303
  overlayExcludes: result.run.overlayExcludes,
304
- overlayCleanupEvidence: result.run.overlayCleanupEvidence,
304
+ overlayCleanupEvidence: [...result.run.overlayCleanupEvidence, ...result.run.warmLinkEvidence],
305
305
  settlementIgnoredPaths: result.run.settlementIgnoredPaths ?? null,
306
306
  })));
307
307
  }
@@ -695,10 +695,17 @@ async function launchIsolatedRail(input, io = {}) {
695
695
  // into the fresh worktree (best-effort; a failure just leaves the run
696
696
  // on the legacy cold start). Linked paths join the overlay exclusion
697
697
  // list so they inherit the commit-time guarantees.
698
+ let warmLinkEvidence = [];
698
699
  try {
699
700
  const nm = (io.linkNodeModules ?? worktree_node_modules_1.linkNodeModulesIntoWorktree)(baseRepo, handle.worktreePath);
700
- if (nm.linked.length > 0)
701
- overlayExcludes = overlayExcludes.concat(nm.linked);
701
+ // `authenticated` (not `linked`) is the authorized set: it also covers
702
+ // links a previous pass created, which a resumed launch would
703
+ // otherwise leave both un-excluded from the commit and unauthorized
704
+ // for release.
705
+ const authorized = nm.authenticated ?? nm.linked ?? [];
706
+ if (authorized.length > 0)
707
+ overlayExcludes = overlayExcludes.concat(authorized);
708
+ warmLinkEvidence = nm.evidence ?? [];
702
709
  if (nm.warnings.length > 0)
703
710
  notifyOverlayDegraded(unit.ticketId, nm.warnings);
704
711
  }
@@ -732,6 +739,7 @@ async function launchIsolatedRail(input, io = {}) {
732
739
  handle,
733
740
  overlayExcludes,
734
741
  overlayCleanupEvidence,
742
+ warmLinkEvidence,
735
743
  provenanceSnapshot,
736
744
  continuationTarget,
737
745
  baseRef: continuationTarget?.baseRef ?? worktreeBaseRef.baseRef,
@@ -876,6 +884,13 @@ async function launchIsolatedRail(input, io = {}) {
876
884
  instructionsFilename: overlayInstructions,
877
885
  }, a.overlayCleanupEvidence);
878
886
  }
887
+ // Warm-dependency links are re-proven against the base repo (their source),
888
+ // not the framework overlay source. A link the engine replaced with a real
889
+ // install loses its authorization here and correctly preserves the worktree.
890
+ if (a.warmLinkEvidence.length > 0) {
891
+ a.warmLinkEvidence = (0, worktree_node_modules_1.authenticateWarmNodeModulesLinks)(baseRepo, a.handle.worktreePath)
892
+ .filter((live) => a.warmLinkEvidence.some((prior) => prior.path === live.path && prior.kind === live.kind && prior.digest === live.digest));
893
+ }
879
894
  let commit;
880
895
  try {
881
896
  commit = await (0, worktree_manager_1.commitWorktreeAndVerify)(git, a.handle.worktreePath, worktreeCommitMessage(ctx, a.ticketId, a.runId, implementationOutcome === 'failed'), a.overlayExcludes);
@@ -42,6 +42,7 @@ const worktree_manager_1 = require("./worktree-manager");
42
42
  const path = __importStar(require("path"));
43
43
  const fs = __importStar(require("fs"));
44
44
  const worktree_overlay_1 = require("./worktree-overlay");
45
+ const worktree_node_modules_1 = require("./worktree-node-modules");
45
46
  const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
46
47
  /** Collapse per-unit settlement evidence into one unambiguous immutable HEAD per
47
48
  * branch. Conflicting or malformed records intentionally yield no authorization. */
@@ -157,8 +158,23 @@ async function verifyReleaseEvidence(input, worktree) {
157
158
  if (!expected || !COMMIT_SHA_RE.test(expected)) {
158
159
  return { failure: 'no durable settled HEAD is recorded for its branch', overlayEntries: [] };
159
160
  }
160
- const overlayEntries = (input.overlayEvidenceByBranch.get(worktree.branch) ?? [])
161
+ const persistedEntries = (input.overlayEvidenceByBranch.get(worktree.branch) ?? [])
161
162
  .filter((entry) => (0, worktree_overlay_1.matchesOverlayCleanupEvidence)(worktree.worktree_path, entry));
163
+ // Warm-dependency links are authenticated LIVE against the base repo, on top
164
+ // of whatever was persisted. Two reasons this is derived and not persisted-
165
+ // only: a repo's ordinary `node_modules/` DIRECTORY ignore pattern does not
166
+ // ignore a SYMLINK of that name, so the link reads as untracked dirt and
167
+ // permanently blocked release (and therefore branch checkout); and worktrees
168
+ // that settled before the links carried evidence are stuck at `needs-review`
169
+ // where no persisted fix could ever reach them. The proof is identical to the
170
+ // persisted one — a symlink resolving to the base checkout's identically-
171
+ // named dependency directory — so nothing weaker is being accepted here.
172
+ const warmEntries = (input.authenticateWarmLinks ?? worktree_node_modules_1.authenticateWarmNodeModulesLinks)(input.repoDir, worktree.worktree_path);
173
+ const seenPaths = new Set(persistedEntries.map((entry) => entry.path));
174
+ const overlayEntries = [
175
+ ...persistedEntries,
176
+ ...warmEntries.filter((entry) => !seenPaths.has(entry.path)),
177
+ ];
162
178
  const overlayExcludes = overlayEntries.map((entry) => entry.path);
163
179
  try {
164
180
  const status = await input.git.run([
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.isWorktreeNodeModulesEnabled = isWorktreeNodeModulesEnabled;
37
+ exports.authenticateWarmNodeModulesLinks = authenticateWarmNodeModulesLinks;
37
38
  exports.linkNodeModulesIntoWorktree = linkNodeModulesIntoWorktree;
38
39
  /**
39
40
  * Warm-dependency reuse for isolated rail worktrees.
@@ -57,10 +58,26 @@ exports.linkNodeModulesIntoWorktree = linkNodeModulesIntoWorktree;
57
58
  * prior link, anything) is left untouched, so resume passes are idempotent
58
59
  * and an agent-made install is never clobbered.
59
60
  *
60
- * The returned `linked` paths are appended to the launch's overlay-exclusion
61
- * list, so a linked `node_modules` inherits the same commit-time guarantees as
62
- * overlay scaffolding (excluded pathspecs + index audit) even if the repo's
63
- * `.gitignore` were ever missing the entry.
61
+ * The returned `authenticated` paths are appended to the launch's overlay-
62
+ * exclusion list, so a linked `node_modules` inherits the same commit-time
63
+ * guarantees as overlay scaffolding (excluded pathspecs + index audit) even if
64
+ * the repo's `.gitignore` were ever missing the entry.
65
+ *
66
+ * Release safety (why `authenticated` exists next to `linked`): a repo whose
67
+ * `.gitignore` carries the ordinary `node_modules/` DIRECTORY pattern does not
68
+ * ignore a SYMLINK named `node_modules`, so the link surfaces as an untracked
69
+ * entry in `git status`. Without evidence authorizing it, worktree release read
70
+ * that as "the worktree contains changes made after settlement", parked the row
71
+ * at `needs-review` forever and permanently blocked checkout of the branch. The
72
+ * links therefore also carry `OverlayCleanupEvidence`, and a link created by an
73
+ * EARLIER pass (resume) is re-authenticated instead of being ignored — the old
74
+ * `linked`-only contract silently dropped both guarantees on resume.
75
+ *
76
+ * Authentication is deliberately narrow and source-anchored, never name-based:
77
+ * the worktree entry must be a symlink whose resolved target is exactly the base
78
+ * checkout's identically-named dependency directory. A real directory, a copy,
79
+ * or a link pointing anywhere else stays unauthorized and preserves the
80
+ * worktree, as `Recoverable work is never removed automatically` requires.
64
81
  *
65
82
  * Honest caveat (documented, accepted): the link SHARES the dependency tree
66
83
  * with the base checkout. Reads (tests, builds) are safe under concurrency; an
@@ -72,8 +89,11 @@ exports.linkNodeModulesIntoWorktree = linkNodeModulesIntoWorktree;
72
89
  */
73
90
  const fs = __importStar(require("fs"));
74
91
  const path = __importStar(require("path"));
92
+ const worktree_overlay_1 = require("./worktree-overlay");
75
93
  /** Package-dir discovery depth: 0 = repo root, 1 = `client/`-style children. */
76
94
  const MAX_DEPTH = 2;
95
+ /** Name of the dependency directory this module links. */
96
+ const DEPS_DIR = 'node_modules';
77
97
  function isWorktreeNodeModulesEnabled() {
78
98
  return (process.env.SPECRAILS_WORKTREE_NODE_MODULES ?? '').toLowerCase() !== 'false';
79
99
  }
@@ -127,31 +147,133 @@ function discoverPackageDirs(baseRepo) {
127
147
  walk('', 0);
128
148
  return found;
129
149
  }
150
+ /** Resolve a path through symlinks when possible; the literal path otherwise
151
+ * (a nonexistent target must still compare, and must still compare EQUAL to an
152
+ * equally-nonexistent expectation rather than silently authorizing). */
153
+ function resolveRealPath(target) {
154
+ try {
155
+ return fs.realpathSync(target);
156
+ }
157
+ catch {
158
+ return path.resolve(target);
159
+ }
160
+ }
161
+ /**
162
+ * Prove one worktree-relative path is an app-created warm-dependency link and
163
+ * fingerprint it. Returns null for anything that is not EXACTLY a symlink whose
164
+ * target resolves to the base checkout's identically-named directory — a real
165
+ * directory, a dereferencing copy, a dangling link, or a link into some other
166
+ * tree are all unauthorized and must keep preserving the worktree.
167
+ */
168
+ function authenticateWarmLink(baseRepo, worktreePath, rel) {
169
+ const dest = path.join(worktreePath, ...rel.split('/'));
170
+ try {
171
+ if (!fs.lstatSync(dest).isSymbolicLink())
172
+ return null;
173
+ const target = path.resolve(path.dirname(dest), fs.readlinkSync(dest));
174
+ const expected = path.join(baseRepo, ...rel.split('/'));
175
+ if (resolveRealPath(target) !== resolveRealPath(expected))
176
+ return null;
177
+ // A link to something that is not a live directory proves nothing about the
178
+ // base checkout's dependency tree.
179
+ if (!isDir(target))
180
+ return null;
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ const fingerprint = (0, worktree_overlay_1.fingerprintOverlayCleanupPath)(dest);
186
+ return fingerprint ? { path: rel, ...fingerprint } : null;
187
+ }
188
+ /** Worktree-relative POSIX paths of every `node_modules` entry the worktree
189
+ * holds at depth ≤ MAX_DEPTH. Mirrors the linker's own discovery depth, so it
190
+ * can only ever see paths this module could have created. Recursion uses lstat
191
+ * so a symlinked directory is never walked into. */
192
+ function discoverWorktreeDependencyPaths(worktreePath) {
193
+ const found = [];
194
+ const walk = (rel, depth) => {
195
+ const abs = rel === '' ? worktreePath : path.join(worktreePath, rel);
196
+ let names;
197
+ try {
198
+ names = fs.readdirSync(abs);
199
+ }
200
+ catch {
201
+ return;
202
+ }
203
+ for (const name of names) {
204
+ const childRel = rel === '' ? name : `${rel}/${name}`;
205
+ if (name === DEPS_DIR) {
206
+ found.push(childRel);
207
+ continue;
208
+ }
209
+ if (depth >= MAX_DEPTH || name.startsWith('.'))
210
+ continue;
211
+ try {
212
+ if (!fs.lstatSync(path.join(abs, name)).isDirectory())
213
+ continue;
214
+ }
215
+ catch {
216
+ continue;
217
+ }
218
+ walk(childRel, depth + 1);
219
+ }
220
+ };
221
+ walk('', 0);
222
+ return found;
223
+ }
224
+ /**
225
+ * Live authentication of the warm-dependency links a worktree currently holds.
226
+ *
227
+ * Deriving this from the filesystem (instead of only from persisted evidence)
228
+ * is what heals worktrees that settled BEFORE the links carried evidence: they
229
+ * are stuck at `needs-review` with the link as their only "dirt", and a
230
+ * persisted-only fix would never reach them. The proof is identical either way
231
+ * — the link target anchored to a directory the app controls.
232
+ */
233
+ function authenticateWarmNodeModulesLinks(baseRepo, worktreePath) {
234
+ const evidence = [];
235
+ for (const rel of discoverWorktreeDependencyPaths(worktreePath)) {
236
+ const entry = authenticateWarmLink(baseRepo, worktreePath, rel);
237
+ if (entry)
238
+ evidence.push(entry);
239
+ }
240
+ return evidence;
241
+ }
130
242
  /**
131
243
  * Link the base checkout's installed `node_modules` trees into a fresh
132
244
  * worktree. Best-effort and side-effect-transparent: never throws, never
133
- * overwrites, returns what it created so the caller can exclude those paths
134
- * from commits.
245
+ * overwrites, and returns both what it created and what it can PROVE is a warm
246
+ * link (including links a previous pass created), so the caller can exclude
247
+ * those paths from commits and authorize them for release.
135
248
  */
136
249
  function linkNodeModulesIntoWorktree(baseRepo, worktreePath) {
137
- const result = { linked: [], warnings: [] };
250
+ const result = { linked: [], authenticated: [], evidence: [], warnings: [] };
138
251
  if (!isWorktreeNodeModulesEnabled())
139
252
  return result;
140
253
  for (const pkgRel of discoverPackageDirs(baseRepo)) {
141
- const rel = pkgRel === '' ? 'node_modules' : `${pkgRel}/node_modules`;
254
+ const rel = pkgRel === '' ? DEPS_DIR : `${pkgRel}/${DEPS_DIR}`;
142
255
  const src = path.join(baseRepo, ...rel.split('/'));
143
256
  if (!isDir(src))
144
257
  continue;
145
258
  const dest = path.join(worktreePath, ...rel.split('/'));
146
- if (exists(dest))
147
- continue;
148
- try {
149
- fs.mkdirSync(path.dirname(dest), { recursive: true });
150
- fs.symlinkSync(src, dest, process.platform === 'win32' ? 'junction' : undefined);
151
- result.linked.push(rel);
259
+ if (!exists(dest)) {
260
+ try {
261
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
262
+ fs.symlinkSync(src, dest, process.platform === 'win32' ? 'junction' : undefined);
263
+ result.linked.push(rel);
264
+ }
265
+ catch (err) {
266
+ result.warnings.push(`failed to link ${rel}: ${errMsg(err)}`);
267
+ continue;
268
+ }
152
269
  }
153
- catch (err) {
154
- result.warnings.push(`failed to link ${rel}: ${errMsg(err)}`);
270
+ // Authenticate what is actually on disk NOW — a link we just made, or one a
271
+ // previous pass made. An entry that cannot prove itself (a real dir, an
272
+ // agent-made install) is deliberately left unauthorized.
273
+ const entry = authenticateWarmLink(baseRepo, worktreePath, rel);
274
+ if (entry) {
275
+ result.authenticated.push(rel);
276
+ result.evidence.push(entry);
155
277
  }
156
278
  }
157
279
  return result;