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.
- package/docs/customizing.md +1 -1
- package/docs/internals/gemini-mcp-registration.md +1 -1
- package/docs/internals/global-artifacts-alignment-contract.md +4 -4
- package/docs/internals/safe-pr-review-flow.md +29 -19
- package/package.json +1 -1
- package/server/dist/chat-manager.js +309 -18
- package/server/dist/context-scope.js +6 -3
- package/server/dist/contract-refine-runner.js +143 -72
- package/server/dist/rail-isolated-launch.js +11 -1
- package/server/dist/workspace-manager.js +3 -1
- package/server/dist/worktree-overlay.js +233 -51
|
@@ -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
|
|
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 →
|
|
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`.
|
|
111
|
-
//
|
|
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
|
|
156
|
+
let env;
|
|
114
157
|
let cwd;
|
|
115
158
|
if (mcpEnabled) {
|
|
116
|
-
// Match the Explore mcp-on spawn cwd
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
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`,
|