specrails-desktop 2.29.4 → 2.29.5

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.
@@ -5,8 +5,9 @@
5
5
  * Standalone runner that spawns a single Claude turn to produce the
6
6
  * Contract Layer for a just-committed Explore Spec ticket. Lives outside the
7
7
  * ChatManager lifecycle for now (design.md D3 — "thin sibling helper" option):
8
- * the refine is fire-and-forget, single-attempt, 60 s budget, no idle-kill /
9
- * crash-respawn semantics.
8
+ * the refine is fire-and-forget with a 60 s budget per invocation and no
9
+ * idle-kill / crash-respawn semantics. Only the exact relocated missing-session
10
+ * diagnostic permits one fresh compatibility retry.
10
11
  *
11
12
  * See openspec/changes/explore-spec-contract-refine.
12
13
  */
@@ -35,6 +36,7 @@ const workspace_resolution_1 = require("./workspace-resolution");
35
36
  const process_admission_1 = require("./process-admission");
36
37
  const transient_children_1 = require("./transient-children");
37
38
  const REFINE_TIMEOUT_MS = 60_000;
39
+ const CLAUDE_MISSING_SESSION_DIAGNOSTIC = 'No conversation found with session ID';
38
40
  /**
39
41
  * Relocate-artifacts: resolve the ticket store path honouring the gate.
40
42
  * Relocated ⇒ the registry entry's tickets path (workspace); legacy ⇒
@@ -83,6 +85,46 @@ function buildRefineArgs(model, systemPrompt, sessionId) {
83
85
  '-p', explore_contract_refine_1.CONTRACT_MARKER_USER_MESSAGE,
84
86
  ];
85
87
  }
88
+ /** Build the pure-output, no-resume invocation used by Quick Refine and by the
89
+ * one-time compatibility recovery for cwd-scoped Explore sessions. */
90
+ function buildFreshRefineArgs(model, title, description) {
91
+ const systemPrompt = [
92
+ (0, explore_contract_refine_1.buildContractRefineSystemPrompt)(),
93
+ '',
94
+ '## Spec under refinement',
95
+ '',
96
+ '### Title',
97
+ title,
98
+ '',
99
+ '### Description',
100
+ description,
101
+ ].join('\n');
102
+ return [
103
+ '--model', model,
104
+ '--tools', '__none__',
105
+ '--output-format', 'stream-json',
106
+ '--verbose',
107
+ '--system-prompt', systemPrompt,
108
+ '--max-turns', '1',
109
+ '-p', explore_contract_refine_1.CONTRACT_MARKER_USER_MESSAGE,
110
+ ];
111
+ }
112
+ /** Match only Claude's missing cwd-scoped session diagnostic. The CLI has
113
+ * emitted it both as stderr and inside structured result payloads, so inspect
114
+ * all captured provider output while keeping unrelated failures ineligible. */
115
+ function containsMissingClaudeSessionDiagnostic(value) {
116
+ if (typeof value === 'string') {
117
+ return value.includes(CLAUDE_MISSING_SESSION_DIAGNOSTIC);
118
+ }
119
+ if (value == null)
120
+ return false;
121
+ try {
122
+ return JSON.stringify(value).includes(CLAUDE_MISSING_SESSION_DIAGNOSTIC);
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
86
128
  /**
87
129
  * Build the spawn argv + cwd for the refine turn. Exported for tests.
88
130
  */
@@ -103,19 +145,26 @@ function prepareContractRefineSpawn(deps, conversation) {
103
145
  // different per-cwd session store and fails with "No conversation found with
104
146
  // session ID …" (the Desktop-tier bug). chat-manager `_resolveSpawnCwd` for an
105
147
  // Explore turn uses:
106
- // - mcp ON → `project.path` (NOT the relocated workspace),
148
+ // - mcp ON → the relocate-artifacts gate (workspace when relocated, else
149
+ // `project.path`); `SPECRAILS_EXPLORE_LEGACY_CWD=1` forces `project.path`,
107
150
  // - mcp OFF → the app-managed explore-cwd.
108
151
  // We mirror that EXACTLY here. The refine uses no tools (`--tools __none__`)
109
152
  // and its prompt rides on `--system-prompt`, so it needs
110
- // neither the workspace's `.mcp.json` nor its `.claude/commands`. Env still
111
- // carries SPECRAILS_REPO_DIR when relocated (harmless; no tools consume it).
153
+ // neither the workspace's `.mcp.json` nor its `.claude/commands`. The env is
154
+ // mirrored too so the legacy-cwd escape hatch remains byte-identical.
112
155
  const exec = (0, workspace_resolution_1.resolveProjectExecution)({ slug: deps.projectSlug, path: deps.projectPath });
113
- let env = exec.relocated ? { ...process.env, ...exec.env } : undefined;
156
+ let env;
114
157
  let cwd;
115
158
  if (mcpEnabled) {
116
- // Match the Explore mcp-on spawn cwd (`this._cwd` = project.path), even when
117
- // relocated (where exec.cwd would be the workspace ≠ project.path).
118
- cwd = deps.projectPath;
159
+ // Match the Explore mcp-on spawn cwd through the same gate + escape hatch.
160
+ if (process.env.SPECRAILS_EXPLORE_LEGACY_CWD === '1') {
161
+ cwd = deps.projectPath;
162
+ }
163
+ else {
164
+ cwd = exec.cwd;
165
+ if (exec.relocated)
166
+ env = { ...process.env, ...exec.env };
167
+ }
119
168
  }
120
169
  else {
121
170
  try {
@@ -126,7 +175,9 @@ function prepareContractRefineSpawn(deps, conversation) {
126
175
  });
127
176
  }
128
177
  catch {
129
- cwd = exec.cwd;
178
+ // Match ChatManager's fail-closed fallback for an unavailable
179
+ // app-managed Explore cwd.
180
+ cwd = deps.projectPath;
130
181
  }
131
182
  }
132
183
  const args = buildRefineArgs(conversation.model ?? 'sonnet', systemPrompt, conversation.session_id ?? '');
@@ -280,7 +331,8 @@ function applyContractLayerToTicket(filePath, ticketId, layer, nowIso) {
280
331
  return updated;
281
332
  }
282
333
  /**
283
- * Fire a single Contract Refine attempt for the given conversation + ticket.
334
+ * Run Contract Refine for the given conversation + ticket (normally one
335
+ * invocation; at most two for the narrow missing-session compatibility path).
284
336
  *
285
337
  * Returns a Promise that resolves with the outcome. Side effects:
286
338
  * - On success: patches the ticket's description, broadcasts `ticket_updated`
@@ -353,59 +405,97 @@ async function runContractRefine(deps, conversationId, ticketId) {
353
405
  const startedAt = now().toISOString();
354
406
  // Spawn/stream/timeout/settlement is owned by the shared spawn-lifecycle; the
355
407
  // contract-refine-specific raw parse (fullText from assistant text blocks,
356
- // the raw result event) and ALL finalize/record/broadcast logic stay here,
357
- // byte-for-byte, so behaviour is unchanged (it still records via the legacy
358
- // recordSafely path).
359
- let fullText = '';
360
- let resultEvent = null;
361
- const run = await (0, spawn_lifecycle_1.runAiCliInvocation)({
362
- adapter: (0, registry_1.getAdapter)('claude'),
363
- binary: 'claude',
364
- argv: args,
365
- cwd,
366
- env: refineEnv ?? process.env,
367
- spawn,
368
- timeoutMs,
369
- onSpawn: (child) => (0, transient_children_1.trackTransientChild)(deps.projectId, child),
370
- onStdoutLine: (line) => {
371
- let parsed = null;
372
- try {
373
- parsed = JSON.parse(line);
374
- }
375
- catch {
376
- return;
377
- }
378
- if (!parsed)
379
- return;
380
- const type = parsed.type;
381
- if (type === 'result') {
382
- resultEvent = parsed;
383
- }
384
- else if (type === 'assistant') {
385
- const message = parsed.message;
386
- for (const b of (message?.content ?? [])) {
387
- if (b.type === 'text' && typeof b.text === 'string')
388
- fullText += b.text;
408
+ // the raw result event) and all finalize/record/broadcast logic stay here.
409
+ // Keeping the invocation result in one shape also lets the narrowly-gated
410
+ // missing-session recovery re-enter the exact same finalization path.
411
+ const invoke = async (invocationArgs) => {
412
+ let fullText = '';
413
+ let resultEvent = null;
414
+ const run = await (0, spawn_lifecycle_1.runAiCliInvocation)({
415
+ adapter: (0, registry_1.getAdapter)('claude'),
416
+ binary: 'claude',
417
+ argv: invocationArgs,
418
+ cwd,
419
+ env: refineEnv ?? process.env,
420
+ spawn,
421
+ timeoutMs,
422
+ onSpawn: (child) => (0, transient_children_1.trackTransientChild)(deps.projectId, child),
423
+ onStdoutLine: (line) => {
424
+ let parsed = null;
425
+ try {
426
+ parsed = JSON.parse(line);
389
427
  }
390
- }
391
- },
392
- });
428
+ catch {
429
+ return;
430
+ }
431
+ if (!parsed)
432
+ return;
433
+ const type = parsed.type;
434
+ if (type === 'result') {
435
+ resultEvent = parsed;
436
+ }
437
+ else if (type === 'assistant') {
438
+ const message = parsed.message;
439
+ for (const b of (message?.content ?? [])) {
440
+ if (b.type === 'text' && typeof b.text === 'string')
441
+ fullText += b.text;
442
+ }
443
+ }
444
+ },
445
+ });
446
+ return {
447
+ fullText,
448
+ resultEvent,
449
+ code: run.code,
450
+ timedOut: run.timedOut,
451
+ spawnFailed: run.spawnFailed,
452
+ stderrTail: run.stderrTail,
453
+ };
454
+ };
455
+ let result = await invoke(args);
393
456
  if (!admission.isCurrent()) {
394
457
  return { ok: false, reason: 'aborted', ticketId, conversationId };
395
458
  }
396
- if (run.spawnFailed) {
397
- recordSafely(deps, conversationId, ticketId, conversation.model, startedAt, now().toISOString(), 'failed', null);
459
+ // A pre-relocation Explore session was created under the repo cwd and cannot
460
+ // be resumed after the corrected mcp=true route moves to the workspace.
461
+ // Recover only that exact provider failure, once, and never by returning to
462
+ // the repo. The fresh turn is explicitly seeded because it has no resumed
463
+ // conversation context.
464
+ const resumedFromRelocatedWorkspace = refineEnv?.SPECRAILS_WORKSPACE_DIR === cwd && cwd !== deps.projectPath;
465
+ const resumeFailed = !result.spawnFailed &&
466
+ !result.timedOut &&
467
+ (result.code !== 0 || !result.resultEvent || isResultErrorEvent(result.resultEvent));
468
+ const missingSessionDiagnostic = containsMissingClaudeSessionDiagnostic(result.fullText) ||
469
+ containsMissingClaudeSessionDiagnostic(result.resultEvent) ||
470
+ containsMissingClaudeSessionDiagnostic(result.stderrTail);
471
+ if (resumedFromRelocatedWorkspace && resumeFailed && missingSessionDiagnostic) {
472
+ let ticket = null;
473
+ try {
474
+ ticket = (0, ticket_store_1.readStore)(resolveContractTicketsPath(deps.projectPath)).tickets[String(ticketId)] ?? null;
475
+ }
476
+ catch (err) {
477
+ console.error('[contract-refine-runner] unable to read ticket for missing-session recovery:', err);
478
+ }
479
+ if (ticket) {
480
+ console.warn(`[contract-refine-runner] resume session unavailable; retrying once fresh from workspace ticket=${ticketId}`);
481
+ result = await invoke(buildFreshRefineArgs(conversation.model ?? 'sonnet', ticket.title, ticket.description));
482
+ if (!admission.isCurrent()) {
483
+ return { ok: false, reason: 'aborted', ticketId, conversationId };
484
+ }
485
+ }
486
+ }
487
+ const finishedAt = now().toISOString();
488
+ if (result.spawnFailed) {
489
+ recordSafely(deps, conversationId, ticketId, conversation.model, startedAt, finishedAt, 'failed', null);
398
490
  deps.broadcast({
399
491
  type: 'explore.contract_refine_failed',
400
492
  projectId: deps.projectId,
401
493
  ticketId,
402
494
  reason: 'crashed',
403
- timestamp: now().toISOString(),
495
+ timestamp: finishedAt,
404
496
  });
405
497
  return { ok: false, reason: 'crashed', ticketId, conversationId };
406
498
  }
407
- const result = { fullText, resultEvent, code: run.code, timedOut: run.timedOut };
408
- const finishedAt = now().toISOString();
409
499
  console.log(`[contract-refine-runner] spawn done code=${result.code} timedOut=${result.timedOut} hasResult=${!!result.resultEvent} textBytes=${result.fullText.length}`);
410
500
  if (result.timedOut) {
411
501
  recordSafely(deps, conversationId, ticketId, conversation.model, startedAt, finishedAt, 'aborted', result.resultEvent);
@@ -515,26 +605,7 @@ async function runContractRefineForQuick(deps, ticketId, generatedTitle, generat
515
605
  return { ok: false, reason: 'disabled', ticketId, conversationId: '' };
516
606
  }
517
607
  const admission = (0, process_admission_1.captureProcessAdmission)(deps.projectId);
518
- const systemPrompt = [
519
- (0, explore_contract_refine_1.buildContractRefineSystemPrompt)(),
520
- '',
521
- '## Spec under refinement',
522
- '',
523
- `### Title`,
524
- generatedTitle,
525
- '',
526
- `### Description`,
527
- generatedDescription,
528
- ].join('\n');
529
- const args = [
530
- '--model', model ?? 'sonnet',
531
- '--tools', '__none__',
532
- '--output-format', 'stream-json',
533
- '--verbose',
534
- '--system-prompt', systemPrompt,
535
- '--max-turns', '1',
536
- '-p', explore_contract_refine_1.CONTRACT_MARKER_USER_MESSAGE,
537
- ];
608
+ const args = buildFreshRefineArgs(model ?? 'sonnet', generatedTitle, generatedDescription);
538
609
  const startedAt = now().toISOString();
539
610
  deps.broadcast({
540
611
  type: 'explore.contract_refine_started',
@@ -408,10 +408,18 @@ async function launchIsolatedRail(input, io = {}) {
408
408
  // overlay their repo's own untracked on-disk entries instead. Resolved ONCE
409
409
  // per launch; a resolution failure degrades to the legacy (repo) source.
410
410
  let overlaySourceRoot = baseRepo;
411
+ let overlayFallbackRoots;
411
412
  try {
412
413
  const exec = resolveExecution({ slug, path: baseRepo });
413
- if (exec.relocated && exec.workspaceDir)
414
+ if (exec.relocated && exec.workspaceDir) {
414
415
  overlaySourceRoot = exec.workspaceDir;
416
+ // The repo as FALLBACK root: repo-resident untracked carve-outs
417
+ // (OpenSpec's `/opsx:*` commands, `openspec-*` skills, user extras)
418
+ // are absent from both the checkout (untracked) and the workspace
419
+ // (the framework ships only `specrails/`), so without this the claude
420
+ // CLI reports `Unknown command: /opsx:ff` inside isolated rails.
421
+ overlayFallbackRoots = [baseRepo];
422
+ }
415
423
  }
416
424
  catch { /* legacy fallback */ }
417
425
  let overlayProviderDir = '.claude';
@@ -668,6 +676,7 @@ async function launchIsolatedRail(input, io = {}) {
668
676
  const res = overlay({
669
677
  worktreePath: handle.worktreePath,
670
678
  sourceRoot: overlaySourceRoot,
679
+ fallbackSourceRoots: overlayFallbackRoots,
671
680
  providerDir: overlayProviderDir,
672
681
  instructionsFilename: overlayInstructions,
673
682
  });
@@ -847,6 +856,7 @@ async function launchIsolatedRail(input, io = {}) {
847
856
  a.overlayCleanupEvidence = (0, worktree_overlay_1.revalidateOverlayCleanupEvidence)({
848
857
  worktreePath: a.handle.worktreePath,
849
858
  sourceRoot: overlaySourceRoot,
859
+ fallbackSourceRoots: overlayFallbackRoots,
850
860
  providerDir: overlayProviderDir,
851
861
  instructionsFilename: overlayInstructions,
852
862
  }, a.overlayCleanupEvidence);
@@ -99,7 +99,9 @@ function ensureFrameworkAgents(workspaceDir, providerDir, home) {
99
99
  }
100
100
  /** The framework subtrees `assemble` installs as a single DIR-symlink into
101
101
  * `current/<provider>/` (unlike agents, which are per-file). `/specrails:*`
102
- * and `/opsx:*` slash commands live under `commands/`. */
102
+ * slash commands live under `commands/`. (`/opsx:*` is NOT framework — the
103
+ * external OpenSpec binary installs it into the REPO's provider dir; isolated
104
+ * rails receive it via the worktree overlay's repo fallback root.) */
103
105
  const DIR_LINKED_SUBTREES = ['commands', 'skills', 'rules'];
104
106
  /**
105
107
  * Windows repair for the DIR-symlinked framework subtrees (`commands`, `skills`,