specrails-desktop 2.15.0 → 2.16.0
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 +1 -1
- package/server/dist/loop-command-catalog.js +30 -1
- package/server/dist/loop-run-manager.js +54 -2
- package/server/dist/loop-templates.js +73 -0
- package/server/dist/rail-isolation.js +8 -7
- package/server/dist/rails-router.js +5 -5
- package/server/dist/workspace-manager.js +134 -14
package/package.json
CHANGED
|
@@ -116,6 +116,33 @@ exports.LOOP_COMMANDS = [
|
|
|
116
116
|
providerNative: { claude: '/loop', codex: '$goal' },
|
|
117
117
|
template: LOOP_FALLBACK_PROMPT,
|
|
118
118
|
},
|
|
119
|
+
// ── OpenSpec lifecycle (opsx:*) — provider-native slash commands ─────────────
|
|
120
|
+
// The opsx commands are installed by OpenSpec itself (NOT the specrails
|
|
121
|
+
// framework) and live under the `/opsx:` namespace, so they are `providerNative`
|
|
122
|
+
// — NOT `coreCommand` (which would wrongly emit `/specrails:<name>`). claude and
|
|
123
|
+
// gemini use the slash form; codex uses the `$`-skill form. Providers without a
|
|
124
|
+
// native opsx command fall back to the `template` prompt. Archive is invoked via
|
|
125
|
+
// the `openspec` CLI in a shell node (provider-independent) and has no command.
|
|
126
|
+
// NOTE: opsx commands are confirmed on claude today; codex/gemini lean on the
|
|
127
|
+
// fallback until OpenSpec ships their native commands (see opsx-lifecycle loop).
|
|
128
|
+
{
|
|
129
|
+
name: 'opsx:ff', label: 'opsx:ff', ticketScope: 'per-ticket',
|
|
130
|
+
description: 'OpenSpec fast-forward: create (or continue) a change and generate all its artifacts (proposal, specs, design, tasks). Native /opsx:ff (claude/gemini) or $opsx:ff (codex).',
|
|
131
|
+
providerNative: { claude: '/opsx:ff', gemini: '/opsx:ff', codex: '$opsx:ff' },
|
|
132
|
+
template: 'Create or continue an OpenSpec change for the work described next and generate all of its artifacts (proposal, specs, design, and tasks) so it is ready to implement.',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'opsx:apply', label: 'opsx:apply', ticketScope: 'per-ticket',
|
|
136
|
+
description: 'OpenSpec apply: implement all pending tasks of the active change. Native /opsx:apply (claude/gemini) or $opsx:apply (codex).',
|
|
137
|
+
providerNative: { claude: '/opsx:apply', gemini: '/opsx:apply', codex: '$opsx:apply' },
|
|
138
|
+
template: 'Implement every pending task of the active OpenSpec change, editing the code as needed and marking each task complete as you finish it.',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'opsx:verify', label: 'opsx:verify', ticketScope: 'per-ticket',
|
|
142
|
+
description: "OpenSpec verify: check the active change's implementation against its specs/tasks; ends with VERIFICATION: PASS|FAIL. Native /opsx:verify (claude/gemini) or $opsx:verify (codex).",
|
|
143
|
+
providerNative: { claude: '/opsx:verify', gemini: '/opsx:verify', codex: '$opsx:verify' },
|
|
144
|
+
template: 'Verify the active OpenSpec change: inspect the REAL implementation against its specs, design, and tasks. Finish with exactly `VERIFICATION: PASS` when nothing required is missing, or `VERIFICATION: FAIL — <what is still missing>` otherwise.',
|
|
145
|
+
},
|
|
119
146
|
// ── Merge-resolver (parallel rails: integrate worktree branches back) ───────
|
|
120
147
|
{
|
|
121
148
|
name: 'resolve-merge', label: 'resolve-merge', ticketScope: 'per-ticket',
|
|
@@ -185,7 +212,9 @@ const COMMANDS_BY_NAME = new Map(exports.LOOP_COMMANDS.map((c) => [c.name, c]));
|
|
|
185
212
|
function getLoopCommand(name) {
|
|
186
213
|
return COMMANDS_BY_NAME.get(name);
|
|
187
214
|
}
|
|
188
|
-
|
|
215
|
+
// Allow `:` in the command name so namespaced commands like `{{cmd:opsx:ff}}`
|
|
216
|
+
// (whose name is literally `opsx:ff`) tokenize — colon-free names are unaffected.
|
|
217
|
+
const CMD_TOKEN_RE = /\{\{\s*cmd:([\w:-]+)\s*\}\}/g;
|
|
189
218
|
/** Build the native, provider-correct invocation of a core slash command —
|
|
190
219
|
* identical in shape to the rail's `/specrails:implement #1 #2 --yes`. Codex has
|
|
191
220
|
* no `/namespace:cmd` parser, so it invokes the equivalent `$<name>` skill. */
|
|
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.LoopRunManager = void 0;
|
|
7
7
|
exports.truncate = truncate;
|
|
8
|
+
exports.resolveRunVars = resolveRunVars;
|
|
9
|
+
exports.extractChangeId = extractChangeId;
|
|
8
10
|
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
9
11
|
const db_1 = require("./db");
|
|
10
12
|
const loop_graph_1 = require("./loop-graph");
|
|
@@ -29,6 +31,25 @@ function truncate(s, max = 600) {
|
|
|
29
31
|
const tail = max - head;
|
|
30
32
|
return s.slice(0, head) + '\n…\n' + s.slice(s.length - tail);
|
|
31
33
|
}
|
|
34
|
+
// ── Run-scoped captured variables ({{run.<name>}}) ───────────────────────────
|
|
35
|
+
// A run can capture values from a step's output and reference them in LATER
|
|
36
|
+
// ai-step prompts and shell commands as `{{run.<name>}}`. v1 captures exactly one
|
|
37
|
+
// — `changeId`, the OpenSpec change id — written so the family can generalize.
|
|
38
|
+
const RUN_TOKEN_RE = /\{\{\s*run\.(\w+)\s*\}\}/g;
|
|
39
|
+
/** First `openspec/changes/<id>` path mentioned in a step's output (the same
|
|
40
|
+
* detection SpecLauncherManager uses). The id stops at the first `/`. */
|
|
41
|
+
const CHANGE_ID_RE = /openspec\/changes\/([A-Za-z0-9._-]+)/;
|
|
42
|
+
/** Replace `{{run.<name>}}` with the captured value; uncaptured → '' (never a
|
|
43
|
+
* leaked literal token). Applied AFTER `{{cmd:*}}` and `{{spec.*}}`. */
|
|
44
|
+
function resolveRunVars(text, vars) {
|
|
45
|
+
return text.replace(RUN_TOKEN_RE, (_m, key) => vars[key] ?? '');
|
|
46
|
+
}
|
|
47
|
+
/** Extract the OpenSpec change id from a step's output (first match wins), or
|
|
48
|
+
* undefined when none is present. */
|
|
49
|
+
function extractChangeId(text) {
|
|
50
|
+
const m = CHANGE_ID_RE.exec(text);
|
|
51
|
+
return m ? m[1] : undefined;
|
|
52
|
+
}
|
|
32
53
|
class LoopRunManager {
|
|
33
54
|
db;
|
|
34
55
|
broadcast;
|
|
@@ -187,6 +208,10 @@ class LoopRunManager {
|
|
|
187
208
|
// values layer on top. Used to expand `{{const:*}}` in every node's text.
|
|
188
209
|
const constMap = { ...loop_constants_1.BUILTIN_CONSTANTS, ...(req.constants ?? {}) };
|
|
189
210
|
const history = [];
|
|
211
|
+
// Run-scoped captured variables ({{run.<name>}}). Populated as steps run
|
|
212
|
+
// (e.g. `changeId` from the first opsx:ff step's output) and resolved in later
|
|
213
|
+
// ai-step prompts and shell commands. Empty until something is captured.
|
|
214
|
+
const runVars = {};
|
|
190
215
|
// Backstop against a cycle with no Decider (would otherwise never increment
|
|
191
216
|
// `iteration`): cap total node executions well above any honest run.
|
|
192
217
|
const stepCap = (maxIterations + 1) * (req.graph.nodes.length + 2) + 16;
|
|
@@ -316,7 +341,7 @@ class LoopRunManager {
|
|
|
316
341
|
// --yes`, codex `$implement #<id> --yes`) — then resolve `{{spec.*}}`
|
|
317
342
|
// data tokens and finally `{{const:*}}` library constants.
|
|
318
343
|
const rawTemplate = String(node.data?.prompt ?? '');
|
|
319
|
-
const base = (0, loop_constants_1.resolveConstants)((0, loop_graph_1.interpolateSpec)((0, loop_command_catalog_1.expandCommands)(rawTemplate, { provider: nodeProvider, ticketIds: req.spec?.ticketIds, specId: req.spec?.id }), req.spec), constMap);
|
|
344
|
+
const base = (0, loop_constants_1.resolveConstants)(resolveRunVars((0, loop_graph_1.interpolateSpec)((0, loop_command_catalog_1.expandCommands)(rawTemplate, { provider: nodeProvider, ticketIds: req.spec?.ticketIds, specId: req.spec?.id }), req.spec), runVars), constMap);
|
|
320
345
|
// Inject the cross-iteration history only when there's no live session
|
|
321
346
|
// to carry it (a fresh pass) OR right after a Decider 'continue' (so the
|
|
322
347
|
// step sees the verdict). A mid-body resumed step already has it.
|
|
@@ -342,6 +367,17 @@ class LoopRunManager {
|
|
|
342
367
|
aiSessionId = res.sessionId;
|
|
343
368
|
history.push(`AI Step: ${truncate(res.text)}`);
|
|
344
369
|
record(`loop:${runId}`, res, aiStepStart);
|
|
370
|
+
// Capture the OpenSpec change id from a step's output the FIRST time it
|
|
371
|
+
// appears (first-match-wins, kept stable across loop-back iterations so
|
|
372
|
+
// the re-pass amends the same change). Used by `{{run.changeId}}` in the
|
|
373
|
+
// loop-back ff prompt and the unattended archive shell node.
|
|
374
|
+
if (!runVars.changeId) {
|
|
375
|
+
const cid = extractChangeId(res.text);
|
|
376
|
+
if (cid) {
|
|
377
|
+
runVars.changeId = cid;
|
|
378
|
+
logLine(`↪ Captured OpenSpec change id: ${cid}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
345
381
|
// Fail-fast: a hard-failed step (non-zero exit / spawn error) that
|
|
346
382
|
// produced NO output means the provider never really ran — quota,
|
|
347
383
|
// auth, crash. One can be transient; AI_FAILFAST_THRESHOLD in a row
|
|
@@ -363,7 +399,23 @@ class LoopRunManager {
|
|
|
363
399
|
break;
|
|
364
400
|
}
|
|
365
401
|
case 'shell': {
|
|
366
|
-
|
|
402
|
+
// Guard: refuse to run when a declared run-variable was never captured
|
|
403
|
+
// (e.g. an archive node whose `{{run.changeId}}` is empty) — running
|
|
404
|
+
// `openspec archive -y` against an unknown change would archive the
|
|
405
|
+
// wrong thing. Settle the run failed with a clear reason instead.
|
|
406
|
+
const reqVarsRaw = node.data?.requireRunVars;
|
|
407
|
+
const requireRunVars = Array.isArray(reqVarsRaw)
|
|
408
|
+
? reqVarsRaw.filter((v) => typeof v === 'string')
|
|
409
|
+
: [];
|
|
410
|
+
const missingRunVars = requireRunVars.filter((name) => !runVars[name]);
|
|
411
|
+
if (missingRunVars.length > 0) {
|
|
412
|
+
emitStep('shell', `⚡ ${nodeLabel || 'Shell'}`);
|
|
413
|
+
logLine(`Skipped: required run variable(s) not captured: ${missingRunVars.map((n) => `{{run.${n}}}`).join(', ')} — refusing to run the command against an unknown target.`, 'stderr');
|
|
414
|
+
outcome = 'failed';
|
|
415
|
+
settled = true;
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
const command = (0, loop_constants_1.resolveConstants)(resolveRunVars((0, loop_graph_1.interpolateSpec)(String(node.data?.command ?? ''), req.spec), runVars), constMap);
|
|
367
419
|
emitStep('shell', `⚡ ${nodeLabel || 'Shell'}`);
|
|
368
420
|
logLine(`$ ${command}`);
|
|
369
421
|
const sh = await this.executors.runShell({ command, cwd: req.cwd, onLine: logLine, onSpawn: (c) => this._activeChild.set(runId, c) });
|
|
@@ -4,6 +4,7 @@ exports.LOOP_TEMPLATES = exports.LOOP_CATEGORIES = void 0;
|
|
|
4
4
|
exports.compilePortSpec = compilePortSpec;
|
|
5
5
|
exports.aiLoopGraph = aiLoopGraph;
|
|
6
6
|
exports.fixLoopGraph = fixLoopGraph;
|
|
7
|
+
exports.opsxLifecycleGraph = opsxLifecycleGraph;
|
|
7
8
|
exports.getLoopTemplate = getLoopTemplate;
|
|
8
9
|
const loop_templates_ported_1 = require("./loop-templates-ported");
|
|
9
10
|
/** Closed taxonomy a template's `category` must belong to. Single source of truth
|
|
@@ -102,7 +103,79 @@ function fixLoopGraph(mainPrompts, deciderGoal, maxIterations = 12, timeoutMinut
|
|
|
102
103
|
edges.push({ id: 'e-stop', source: 'decide', target: 'done', branch: 'stop' }); // green → exit (drops down)
|
|
103
104
|
return { nodes, edges, config: { maxIterations, timeoutMinutes, ...(aiStepTimeoutMinutes != null ? { aiStepTimeoutMinutes } : {}) } };
|
|
104
105
|
}
|
|
106
|
+
// ── OpenSpec lifecycle loop ──────────────────────────────────────────────────
|
|
107
|
+
// A hand-authored graph (NOT a PortSpec) because it combines an AI-step spine, a
|
|
108
|
+
// Decider, a `shell` archive node, AND a terminal action on the Decider's `stop`
|
|
109
|
+
// branch — a shape the stock aiLoopGraph/fixLoopGraph builders do not produce.
|
|
110
|
+
// Per iteration: opsx:ff → opsx:apply → opsx:verify → Decider. The Decider stops
|
|
111
|
+
// when verify reports PASS (→ unattended `openspec archive <id> -y` shell node);
|
|
112
|
+
// otherwise it loops back to opsx:ff, which AMENDS the same change ({{run.changeId}}
|
|
113
|
+
// captured by the engine from ff's first-pass output) using the gaps verify found.
|
|
114
|
+
const OPSX_FF_PROMPT = [
|
|
115
|
+
'{{cmd:opsx:ff}} {{spec.title}}',
|
|
116
|
+
'',
|
|
117
|
+
'{{spec.description}}',
|
|
118
|
+
'',
|
|
119
|
+
'If a change id appears here — "{{run.changeId}}" — an OpenSpec change for this ticket already exists: CONTINUE that change (do NOT create a new one) and address only what the verification reported as still missing (see the context below). If it is blank, create the change and generate all required artifacts.',
|
|
120
|
+
'',
|
|
121
|
+
'Run fully unattended: make reasonable decisions to keep momentum and NEVER stop to ask — there is no human to answer. When something is unclear, pick the most sensible option, proceed, and note the assumption.',
|
|
122
|
+
].join('\n');
|
|
123
|
+
const OPSX_APPLY_PROMPT = [
|
|
124
|
+
'{{cmd:opsx:apply}}',
|
|
125
|
+
'',
|
|
126
|
+
'Implement every pending task of the active OpenSpec change for ticket "{{spec.title}}", editing code as needed and marking tasks complete as you finish them.',
|
|
127
|
+
'',
|
|
128
|
+
'Run fully unattended: decide and keep momentum, never pause to ask. If you hit an ambiguity or blocker, make the most reasonable choice, implement it, and continue.',
|
|
129
|
+
].join('\n');
|
|
130
|
+
const OPSX_VERIFY_PROMPT = [
|
|
131
|
+
'{{cmd:opsx:verify}}',
|
|
132
|
+
'',
|
|
133
|
+
'Verify the active OpenSpec change against its specs, design, and tasks for ticket "{{spec.title}}". Be strict and honest — inspect the REAL code and tests, not any step\'s self-report.',
|
|
134
|
+
'',
|
|
135
|
+
'Finish with a clear final line: exactly `{{const:VERIFICATION_PASS}}` when the change fully matches the ticket with nothing required missing, or `{{const:VERIFICATION_FAIL}} — <what is still missing>` otherwise.',
|
|
136
|
+
].join('\n');
|
|
137
|
+
const OPSX_DECIDER_GOAL = 'The verify step reported {{const:VERIFICATION_PASS}} — the implementation fully matches ticket "{{spec.title}}" and nothing required is missing.';
|
|
138
|
+
/** The OpenSpec-lifecycle graph (see comment above). Exported for unit testing. */
|
|
139
|
+
function opsxLifecycleGraph() {
|
|
140
|
+
return {
|
|
141
|
+
nodes: [
|
|
142
|
+
{ id: 'start', type: 'start', position: { x: COL_X, y: 0 } },
|
|
143
|
+
{ id: 'ff', type: 'ai-step', position: { x: COL_X, y: ROW_GAP * 1 }, data: { label: 'opsx:ff', prompt: OPSX_FF_PROMPT } },
|
|
144
|
+
{ id: 'apply', type: 'ai-step', position: { x: COL_X, y: ROW_GAP * 2 }, data: { label: 'opsx:apply', prompt: OPSX_APPLY_PROMPT } },
|
|
145
|
+
{ id: 'verify', type: 'ai-step', position: { x: COL_X, y: ROW_GAP * 3 }, data: { label: 'opsx:verify', prompt: OPSX_VERIFY_PROMPT } },
|
|
146
|
+
{ id: 'decide', type: 'decider', position: { x: COL_X, y: ROW_GAP * 4 }, data: { goal: OPSX_DECIDER_GOAL } },
|
|
147
|
+
// Unattended archive: deterministic CLI, no AI, no prompt. `requireRunVars`
|
|
148
|
+
// makes the engine REFUSE to run if no change id was captured (never archive
|
|
149
|
+
// an unknown change); `openspec archive -y` syncs the main specs by default.
|
|
150
|
+
{ id: 'archive', type: 'shell', position: { x: COL_X, y: ROW_GAP * 5 }, data: { label: 'archive', command: 'openspec archive {{run.changeId}} -y', requireRunVars: ['changeId'] } },
|
|
151
|
+
{ id: 'done', type: 'end', position: { x: COL_X, y: ROW_GAP * 6 }, data: { outcome: 'success' } },
|
|
152
|
+
],
|
|
153
|
+
edges: [
|
|
154
|
+
{ id: 'e-start', source: 'start', target: 'ff' },
|
|
155
|
+
{ id: 'e-ff', source: 'ff', target: 'apply' },
|
|
156
|
+
{ id: 'e-apply', source: 'apply', target: 'verify' },
|
|
157
|
+
{ id: 'e-verify', source: 'verify', target: 'decide' },
|
|
158
|
+
// not-done (verify FAIL) → loop back to ff (firstStepId ⇒ session resets, the
|
|
159
|
+
// pass re-reads disk; verify's gaps ride in the injected history).
|
|
160
|
+
{ id: 'e-continue', source: 'decide', target: 'ff', branch: 'continue' },
|
|
161
|
+
// done (verify PASS) → archive then end.
|
|
162
|
+
{ id: 'e-stop', source: 'decide', target: 'archive', branch: 'stop' },
|
|
163
|
+
{ id: 'e-archive', source: 'archive', target: 'done' },
|
|
164
|
+
],
|
|
165
|
+
// Conservative bounds so a never-satisfied verify can't spin: at most 3 full
|
|
166
|
+
// lifecycle passes; per-step cap raised (apply can implement a whole change).
|
|
167
|
+
config: { maxIterations: 3, timeoutMinutes: 180, aiStepTimeoutMinutes: 45 },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
105
170
|
exports.LOOP_TEMPLATES = [
|
|
171
|
+
{
|
|
172
|
+
id: 'opsx-lifecycle',
|
|
173
|
+
name: 'OpenSpec Lifecycle',
|
|
174
|
+
description: 'Single-agent, ticket-to-archive OpenSpec lifecycle: generate artifacts (opsx:ff) → implement (opsx:apply) → verify (opsx:verify); on a FAIL verdict loop back to amend the SAME change, on PASS archive it unattended. The artifact-centric counterpart to the implement pipeline. Claude-first — codex/gemini fall back to a generic prompt until OpenSpec ships their native opsx commands.',
|
|
175
|
+
category: 'Automation',
|
|
176
|
+
tags: ['Automation', 'openspec', 'lifecycle'],
|
|
177
|
+
graph: opsxLifecycleGraph(),
|
|
178
|
+
},
|
|
106
179
|
{
|
|
107
180
|
id: 'ship-and-green',
|
|
108
181
|
name: 'Ship & Green',
|
|
@@ -28,16 +28,17 @@ function mutatesRepo(loop) {
|
|
|
28
28
|
return !loop.readOnly;
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
|
-
* The isolation gate flag. **
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
31
|
+
* The isolation gate flag. **Default-on kill-switch**: worktree isolation runs for
|
|
32
|
+
* every repo-mutating per-ticket rail UNLESS `SPECRAILS_RAIL_WORKTREES` is set to
|
|
33
|
+
* `0`/`false`/`off`, which restores the legacy single shared cwd (byte-identical to
|
|
34
|
+
* before this feature). Default-on is safe because the launch path degrades
|
|
35
|
+
* gracefully when isolation is unavailable — a non-git repo, an unborn HEAD, or a
|
|
36
|
+
* worktree-allocation error all fall back to the shared cwd (see rails-router).
|
|
37
|
+
* Read per-call so a test can flip the env without re-importing.
|
|
37
38
|
*/
|
|
38
39
|
function isRailWorktreesEnabled() {
|
|
39
40
|
const v = (process.env.SPECRAILS_RAIL_WORKTREES ?? '').trim().toLowerCase();
|
|
40
|
-
return v
|
|
41
|
+
return v !== '0' && v !== 'false' && v !== 'off';
|
|
41
42
|
}
|
|
42
43
|
/**
|
|
43
44
|
* True when this launch should isolate each ticket's run in its own worktree.
|
|
@@ -377,11 +377,11 @@ function createRailsRouter() {
|
|
|
377
377
|
return;
|
|
378
378
|
}
|
|
379
379
|
const scope = (0, loop_command_catalog_1.dominantTicketScope)(promptsText);
|
|
380
|
-
// Parallel isolation (
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
380
|
+
// Parallel isolation (default-on; disable with SPECRAILS_RAIL_WORKTREES=0):
|
|
381
|
+
// a per-ticket rail on a repo-mutating loop runs each ticket in its own git
|
|
382
|
+
// worktree, then merges the branches back. Degrades gracefully — a non-git
|
|
383
|
+
// repo, an unborn HEAD, or a worktree-allocation failure all fall through to
|
|
384
|
+
// the shared-cwd path below. See rail-isolation.ts.
|
|
385
385
|
let isolationUnavailable;
|
|
386
386
|
if ((0, rail_isolation_1.isolationApplies)({ loopsEnabled: (0, feature_flags_1.isLoopsEnabled)(), scope, ticketCount: rail.ticketIds.length, readOnly: false })) {
|
|
387
387
|
// Worktree isolation needs a git repo WITH at least one commit (an
|
|
@@ -135,6 +135,7 @@ function ensureWorkspace(slug, projectPath, home) {
|
|
|
135
135
|
const ws = workspacePathFor(slug, home);
|
|
136
136
|
fs_1.default.mkdirSync(ws, { recursive: true });
|
|
137
137
|
ensureProjectLink(ws, projectPath);
|
|
138
|
+
ensureOpenspecLink(ws, projectPath);
|
|
138
139
|
return ws;
|
|
139
140
|
}
|
|
140
141
|
/**
|
|
@@ -192,24 +193,28 @@ function removeWorkspace(slug, home) {
|
|
|
192
193
|
const ws = workspacePathFor(slug, home);
|
|
193
194
|
if (!fs_1.default.existsSync(ws))
|
|
194
195
|
return;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
catch {
|
|
196
|
+
// Unlink the carve-out links BEFORE the recursive remove so the user's repo is
|
|
197
|
+
// never followed/deleted: `project` → the repo, and `openspec` → the repo's
|
|
198
|
+
// openspec carve-out. (POSIX symlinks unlink; Windows junctions rmdir.)
|
|
199
|
+
for (const name of ['project', 'openspec']) {
|
|
200
|
+
const linkPath = path_1.default.join(ws, name);
|
|
201
|
+
try {
|
|
202
|
+
const st = fs_1.default.lstatSync(linkPath);
|
|
203
|
+
if (st.isSymbolicLink() || (process.platform === 'win32' && st.isDirectory())) {
|
|
204
204
|
try {
|
|
205
|
-
fs_1.default.
|
|
205
|
+
fs_1.default.unlinkSync(linkPath);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
try {
|
|
209
|
+
fs_1.default.rmdirSync(linkPath);
|
|
210
|
+
}
|
|
211
|
+
catch { /* best-effort */ }
|
|
206
212
|
}
|
|
207
|
-
catch { /* best-effort */ }
|
|
208
213
|
}
|
|
209
214
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
215
|
+
catch {
|
|
216
|
+
/* link may not exist */
|
|
217
|
+
}
|
|
213
218
|
}
|
|
214
219
|
fs_1.default.rmSync(ws, { recursive: true, force: true });
|
|
215
220
|
}
|
|
@@ -281,3 +286,118 @@ function ensureProjectLink(cwd, projectPath) {
|
|
|
281
286
|
catch { /* ignore */ }
|
|
282
287
|
}
|
|
283
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Ensure `<ws>/openspec` is a LINK to `<repo>/openspec` — the openspec carve-out.
|
|
291
|
+
*
|
|
292
|
+
* openspec is a repo-resident deliverable (CLAUDE.md: "openspec/** … the versioned
|
|
293
|
+
* spec deliverable … repo-relative by design"). For specrails-core's own slash
|
|
294
|
+
* commands the `${SPECRAILS_REPO_DIR:-.}` indirection re-points openspec I/O to the
|
|
295
|
+
* repo — but the EXTERNAL `openspec` binary (invoked as `openspec new change` /
|
|
296
|
+
* `openspec archive` by the opsx lifecycle) does NOT read that env var; it writes
|
|
297
|
+
* relative to its cwd. Since relocated rails/loops spawn with cwd = the WORKSPACE,
|
|
298
|
+
* without this link every `openspec` write would strand the change under
|
|
299
|
+
* `~/.specrails/projects/<slug>/workspace/openspec` instead of the user's repo.
|
|
300
|
+
*
|
|
301
|
+
* Mirrors `ensureProjectLink` (symlink on POSIX, junction on Windows) and adds a
|
|
302
|
+
* one-time, NON-DESTRUCTIVE migration: a pre-carve-out workspace that already holds
|
|
303
|
+
* a REAL `openspec/` dir (created by the binary writing to the workspace cwd) has
|
|
304
|
+
* its contents rescued into the repo — never clobbering the repo's own files —
|
|
305
|
+
* before the dir is replaced by the link. Idempotent: a no-op once the correct
|
|
306
|
+
* link exists. Best-effort throughout — a link failure must never abort a spawn.
|
|
307
|
+
*/
|
|
308
|
+
function ensureOpenspecLink(cwd, projectPath) {
|
|
309
|
+
const linkPath = path_1.default.join(cwd, 'openspec');
|
|
310
|
+
const repoOpenspec = path_1.default.join(projectPath, 'openspec');
|
|
311
|
+
try {
|
|
312
|
+
const st = fs_1.default.lstatSync(linkPath);
|
|
313
|
+
if (st.isSymbolicLink()) {
|
|
314
|
+
const current = fs_1.default.readlinkSync(linkPath);
|
|
315
|
+
if (path_1.default.resolve(cwd, current) === path_1.default.resolve(repoOpenspec))
|
|
316
|
+
return; // already correct
|
|
317
|
+
try {
|
|
318
|
+
fs_1.default.unlinkSync(linkPath);
|
|
319
|
+
}
|
|
320
|
+
catch { /* replaced below */ }
|
|
321
|
+
}
|
|
322
|
+
else if (st.isDirectory()) {
|
|
323
|
+
// Pre-carve-out workspace: a real openspec/ dir the binary wrote to the
|
|
324
|
+
// workspace cwd. Rescue its contents into the repo, then replace with a link.
|
|
325
|
+
mergeDirInto(linkPath, repoOpenspec);
|
|
326
|
+
try {
|
|
327
|
+
fs_1.default.rmSync(linkPath, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
try {
|
|
335
|
+
fs_1.default.unlinkSync(linkPath);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
/* does not exist — create below */
|
|
344
|
+
}
|
|
345
|
+
// The carve-out target must exist for the link to resolve (the binary expects to
|
|
346
|
+
// write under it). openspec/** is an intentional repo carve-out, so creating it
|
|
347
|
+
// does not violate the pristine-repo guarantee.
|
|
348
|
+
try {
|
|
349
|
+
fs_1.default.mkdirSync(repoOpenspec, { recursive: true });
|
|
350
|
+
}
|
|
351
|
+
catch { /* best-effort */ }
|
|
352
|
+
if (process.platform === 'win32') {
|
|
353
|
+
try {
|
|
354
|
+
fs_1.default.symlinkSync(repoOpenspec, linkPath, 'junction');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
catch { /* fall through to POSIX symlink */ }
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
fs_1.default.symlinkSync(repoOpenspec, linkPath);
|
|
361
|
+
}
|
|
362
|
+
catch { /* best-effort: a failed link just means the binary writes a fresh workspace dir next run */ }
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Recursively copy `src` into `dest`, creating directories and copying only files
|
|
366
|
+
* that do NOT already exist in `dest` (the repo's own copy always wins). Used by
|
|
367
|
+
* the openspec carve-out migration to rescue workspace-stranded artifacts into the
|
|
368
|
+
* repo without overwriting committed content. Best-effort per entry.
|
|
369
|
+
*/
|
|
370
|
+
function mergeDirInto(src, dest) {
|
|
371
|
+
let entries;
|
|
372
|
+
try {
|
|
373
|
+
entries = fs_1.default.readdirSync(src, { withFileTypes: true });
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
fs_1.default.mkdirSync(dest, { recursive: true });
|
|
380
|
+
}
|
|
381
|
+
catch { /* best-effort */ }
|
|
382
|
+
for (const e of entries) {
|
|
383
|
+
const s = path_1.default.join(src, e.name);
|
|
384
|
+
const d = path_1.default.join(dest, e.name);
|
|
385
|
+
if (e.isDirectory()) {
|
|
386
|
+
mergeDirInto(s, d);
|
|
387
|
+
}
|
|
388
|
+
else if (e.isSymbolicLink()) {
|
|
389
|
+
if (!fs_1.default.existsSync(d)) {
|
|
390
|
+
try {
|
|
391
|
+
fs_1.default.symlinkSync(fs_1.default.readlinkSync(s), d);
|
|
392
|
+
}
|
|
393
|
+
catch { /* skip */ }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else if (!fs_1.default.existsSync(d)) {
|
|
397
|
+
try {
|
|
398
|
+
fs_1.default.copyFileSync(s, d);
|
|
399
|
+
}
|
|
400
|
+
catch { /* skip */ }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|