specrails-desktop 2.23.0 → 2.23.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.23.0",
3
+ "version": "2.23.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -326,15 +326,24 @@ summary later.
326
326
  advances from "building" to the PR question on its own), and the app's rail
327
327
  header / Job Detail page stream every event. Tell the user that, and that
328
328
  they can ask you for a status check any time (\`specrails_jobs(get)\`).
329
- - **After a rail settles, the DECISION belongs to the user, in the app's UI**
330
- (the PR card in this chat and the rail header show the same buttons): Create
331
- PR draft on GitHub; Publish; Discard. There is NO "Ship/Revert" never
332
- invent UI. When the repo has NO GitHub remote (or push fails), Create PR
333
- degrades to a local-only delivery: the card then offers **Integrate locally**
334
- (merge the delivered branches into the integration branch in the user's
335
- checkout requires that branch checked out + a clean tree) as the way to
336
- ACCEPT the work, alongside retry and Discard. Explain exactly that when the
337
- user asks "where is the PR?" on a remote-less repo.
329
+ - **A PR-decision card only exists when the launch isolated in a git worktree.**
330
+ A launch response with an \`isolationUnavailable\` field means the run fell back
331
+ to the shared working tree there is NO card (not in this chat, not on the
332
+ rail header) and NO branch. NEVER promise a card in that case; instead tell the
333
+ user the run writes changes DIRECTLY into their files, say why (\`no-git\` = the
334
+ folder is not a git repo; \`no-commits\` = git repo with no initial commit;
335
+ \`error\` = isolation failed, relay the detail), and for no-git/no-commits
336
+ that \`git init\` + one commit (no GitHub remote needed) unlocks the PR flow.
337
+ When isolation IS available (no such field), the card WILL appear on settle.
338
+ - **After an ISOLATED rail settles, the DECISION belongs to the user, in the
339
+ app's UI** (the PR card in this chat and the rail header show the same
340
+ buttons): Create PR → draft on GitHub; Publish; Discard. There is NO
341
+ "Ship/Revert" — never invent UI. When the repo has NO GitHub remote (or push
342
+ fails), Create PR degrades to a local-only delivery: the card then offers
343
+ **Integrate locally** (merge the delivered branches into the integration
344
+ branch in the user's checkout — requires that branch checked out + a clean
345
+ tree) as the way to ACCEPT the work, alongside retry and Discard. Explain
346
+ exactly that when the user asks "where is the PR?" on a remote-less repo.
338
347
  - \`specrails_watch\` on a launched rail/job is RESERVED for when the user
339
348
  explicitly asks you to wait for completion ("wait until it finishes"). Even
340
349
  then prefer a bounded \`untilMs\` and report status back when it elapses —
@@ -133,6 +133,7 @@ function createLoopExecutors(opts = {}) {
133
133
  if (repoDir) {
134
134
  try {
135
135
  (0, workspace_manager_1.ensureFrameworkAgents)(cwd, adapter.projectDirName);
136
+ (0, workspace_manager_1.ensureFrameworkCommandSubtrees)(cwd, adapter.projectDirName);
136
137
  }
137
138
  catch { /* best-effort */ }
138
139
  }
@@ -243,6 +244,7 @@ function createLoopExecutors(opts = {}) {
243
244
  if (repoDir) {
244
245
  try {
245
246
  (0, workspace_manager_1.ensureFrameworkAgents)(cwd, adapter.projectDirName);
247
+ (0, workspace_manager_1.ensureFrameworkCommandSubtrees)(cwd, adapter.projectDirName);
246
248
  }
247
249
  catch { /* best-effort */ }
248
250
  }
@@ -208,10 +208,33 @@ function railsTools() {
208
208
  body.reasoning_effort = defaults.reasoningEffort;
209
209
  }
210
210
  const r = await (0, types_1.apiCall)(ctx, 'POST', `${base}/${railIndex}/launch`, body);
211
+ const railLabel = `Rail ${railIndex + 1}`;
212
+ // Isolation status governs whether a PR-decision card EXISTS. When
213
+ // the router reports `isolationUnavailable`, the run fell back to the
214
+ // shared working tree: NO git worktree, NO PR delivery row, and so
215
+ // NO implementation/PR card will EVER appear (not in this chat, not
216
+ // in the rail header). The agent MUST NOT promise one — it must tell
217
+ // the user the run writes changes DIRECTLY to their files and why.
218
+ const isoReason = typeof r.isolationUnavailable === 'string' ? r.isolationUnavailable : null;
219
+ if (isoReason) {
220
+ const why = isoReason === 'no-git'
221
+ ? 'the project folder is NOT a git repository'
222
+ : isoReason === 'no-commits'
223
+ ? 'the git repository has NO commits yet (an unborn HEAD cannot be branched)'
224
+ : `worktree isolation failed${typeof r.isolationUnavailableDetail === 'string' ? `: ${r.isolationUnavailableDetail}` : ''}`;
225
+ const fix = isoReason === 'error'
226
+ ? 'This is unexpected — report the detail to the user.'
227
+ : 'To get the PR flow, the user should `git init` the folder and make at least one commit (a GitHub remote is NOT required — the review flow then offers "Integrate locally" to accept without GitHub), then relaunch.';
228
+ return {
229
+ ...r,
230
+ railLabel,
231
+ hint: `Launch accepted (202) on ${railLabel}, but WORKTREE ISOLATION IS UNAVAILABLE because ${why}. The run proceeds on the SHARED working tree and writes changes DIRECTLY into the user's files — there is NO PR-decision/implementation card and NO branch. Do NOT tell the user to look for a PR card; tell them the run writes to their files in place, and explain why. ${fix} When it finishes, the spec parks at on_review — the user accepts it by moving it to Done on the board (the changes are already in their files) or reverts the spec's status (which does NOT undo the file changes).`,
232
+ };
233
+ }
211
234
  return {
212
235
  ...r,
213
- railLabel: `Rail ${railIndex + 1}`,
214
- hint: `Launch accepted (202) on railIndex ${railIndex} — tell the user it runs on "Rail ${railIndex + 1}" (UI labels are 1-based). Use specrails_watch with the returned jobId/jobIds (or loopRunIds for loop mode) to await completion. Live output streams over the job WS. Rails run for minutes; pass untilMs up to 600000 and re-watch on timeout.`,
236
+ 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.`,
215
238
  };
216
239
  }
217
240
  case 'launch_all': {
@@ -1427,6 +1427,10 @@ class QueueManager {
1427
1427
  if (execution.relocated) {
1428
1428
  try {
1429
1429
  (0, workspace_manager_1.ensureFrameworkAgents)(execution.cwd, adapter.projectDirName);
1430
+ // AND the dir-linked subtrees (commands/skills/rules): a broken Windows
1431
+ // `current` junction leaves the workspace with no `/specrails:*` commands
1432
+ // → the CLI reports "Unknown command: /specrails:implement".
1433
+ (0, workspace_manager_1.ensureFrameworkCommandSubtrees)(execution.cwd, adapter.projectDirName);
1430
1434
  }
1431
1435
  catch {
1432
1436
  /* best-effort — never block a rail spawn on the repair */
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.ensureFrameworkAgents = ensureFrameworkAgents;
7
+ exports.ensureFrameworkCommandSubtrees = ensureFrameworkCommandSubtrees;
7
8
  exports.workspacePathFor = workspacePathFor;
8
9
  exports.ensureWorkspace = ensureWorkspace;
9
10
  exports.assembleWorkspaceFramework = assembleWorkspaceFramework;
@@ -47,22 +48,25 @@ function compareFrameworkVersionDesc(a, b) {
47
48
  * byte-identical). Additive (never touches user `custom-*.md`) + idempotent.
48
49
  * Returns the number of agents copied.
49
50
  */
50
- function ensureFrameworkAgents(workspaceDir, providerDir, home) {
51
- if (process.platform !== 'win32')
52
- return 0;
53
- const root = (0, framework_manager_1.frameworkRoot)(home);
54
- // Resolve the framework version by listing REAL version dirs — NOT via the
55
- // `current` junction (which is the very thing that may be untraversable here).
56
- let version;
51
+ /** Newest REAL framework version dir under the root — resolved by LISTING (not
52
+ * via the `current` junction, which is the very thing that may be untraversable
53
+ * on Windows). Null when the framework isn't materialized. */
54
+ function newestFrameworkVersion(root) {
57
55
  try {
58
- version = fs_1.default
56
+ return fs_1.default
59
57
  .readdirSync(root)
60
58
  .filter((n) => /^\d+\.\d+\.\d+$/.test(n))
61
- .sort(compareFrameworkVersionDesc)[0];
59
+ .sort(compareFrameworkVersionDesc)[0] ?? null;
62
60
  }
63
61
  catch {
64
- return 0;
62
+ return null;
65
63
  }
64
+ }
65
+ function ensureFrameworkAgents(workspaceDir, providerDir, home) {
66
+ if (process.platform !== 'win32')
67
+ return 0;
68
+ const root = (0, framework_manager_1.frameworkRoot)(home);
69
+ const version = newestFrameworkVersion(root);
66
70
  if (!version)
67
71
  return 0;
68
72
  const src = path_1.default.join(root, version, providerDir, 'agents');
@@ -93,6 +97,93 @@ function ensureFrameworkAgents(workspaceDir, providerDir, home) {
93
97
  }
94
98
  return copied;
95
99
  }
100
+ /** The framework subtrees `assemble` installs as a single DIR-symlink into
101
+ * `current/<provider>/` (unlike agents, which are per-file). `/specrails:*`
102
+ * and `/opsx:*` slash commands live under `commands/`. */
103
+ const DIR_LINKED_SUBTREES = ['commands', 'skills', 'rules'];
104
+ /**
105
+ * Windows repair for the DIR-symlinked framework subtrees (`commands`, `skills`,
106
+ * `rules`) — the sibling of `ensureFrameworkAgents` for the whole-directory links.
107
+ *
108
+ * Why a SEPARATE repair: agents are linked per-file, so a broken `current`
109
+ * junction leaves an EMPTY-but-real `agents/` dir that `ensureFrameworkAgents`
110
+ * refills file-by-file. `commands`/`skills`/`rules` are instead a single
111
+ * dir-symlink INTO `current/<provider>/<subtree>`; when the `current` junction is
112
+ * untraversable by the sidecar (the documented Windows failure), that link is
113
+ * unreadable and the workspace has NO `/specrails:*` commands at all — the claude
114
+ * CLI then reports `Unknown command: /specrails:implement`. This replaces an
115
+ * unreadable/missing link with a REAL recursively-copied directory read straight
116
+ * from the versioned framework dir (never through `current`).
117
+ *
118
+ * SAFETY: only heals when the dest is missing OR unreadable OR empty — a dest
119
+ * that lists content is a working link/dir and is left untouched, so a working
120
+ * symlink's real target is NEVER deleted through. NO-OP on POSIX (per-provider
121
+ * symlinks resolve normally). Best-effort + idempotent. Returns the count healed.
122
+ */
123
+ function ensureFrameworkCommandSubtrees(workspaceDir, providerDir, home) {
124
+ if (process.platform !== 'win32')
125
+ return 0;
126
+ const root = (0, framework_manager_1.frameworkRoot)(home);
127
+ const version = newestFrameworkVersion(root);
128
+ if (!version)
129
+ return 0;
130
+ let healed = 0;
131
+ for (const subtree of DIR_LINKED_SUBTREES) {
132
+ const src = path_1.default.join(root, version, providerDir, subtree);
133
+ // The versioned source must be a real, readable, non-empty dir to heal from.
134
+ let srcEntries;
135
+ try {
136
+ srcEntries = fs_1.default.readdirSync(src);
137
+ }
138
+ catch {
139
+ continue; // this provider/version ships no such subtree
140
+ }
141
+ if (srcEntries.length === 0)
142
+ continue;
143
+ const dest = path_1.default.join(workspaceDir, providerDir, subtree);
144
+ // A dest that lists entries is a working link/dir → leave it (never risk
145
+ // deleting through a live symlink). Heal only when unreadable / empty.
146
+ try {
147
+ if (fs_1.default.readdirSync(dest).length > 0)
148
+ continue;
149
+ }
150
+ catch {
151
+ /* unreadable (broken/untraversable link, or missing) → heal below */
152
+ }
153
+ // Remove the broken link / empty dir. We only reach here when there is no
154
+ // traversable content, so no real framework files can be destroyed. Layered
155
+ // removal because a Windows dir-junction resists unlink/rmdir differently.
156
+ try {
157
+ const st = fs_1.default.lstatSync(dest);
158
+ if (st.isSymbolicLink()) {
159
+ fs_1.default.unlinkSync(dest);
160
+ }
161
+ else {
162
+ fs_1.default.rmSync(dest, { recursive: true, force: true });
163
+ }
164
+ }
165
+ catch {
166
+ try {
167
+ fs_1.default.rmdirSync(dest);
168
+ }
169
+ catch {
170
+ try {
171
+ fs_1.default.rmSync(dest, { recursive: true, force: true });
172
+ }
173
+ catch { /* dest may be absent — fine */ }
174
+ }
175
+ }
176
+ try {
177
+ fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
178
+ fs_1.default.cpSync(src, dest, { recursive: true });
179
+ healed += 1;
180
+ }
181
+ catch {
182
+ /* best-effort per subtree — a copy failure must never abort the spawn */
183
+ }
184
+ }
185
+ return healed;
186
+ }
96
187
  /**
97
188
  * WorkspaceManager — a reusable materializer for the per-project workspace dir
98
189
  * under `~/.specrails/projects/<slug>/workspace`. This is the relocation target