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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- const CMD_TOKEN_RE = /\{\{\s*cmd:([\w-]+)\s*\}\}/g;
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
- const command = (0, loop_constants_1.resolveConstants)((0, loop_graph_1.interpolateSpec)(String(node.data?.command ?? ''), req.spec), constMap);
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. **Opt-in during rollout**: worktree isolation runs
32
- * ONLY when `SPECRAILS_RAIL_WORKTREES` is `1`/`true`/`on`; otherwise every loop run
33
- * keeps the legacy single shared cwd (byte-identical to before this feature). This
34
- * lets the integration land inert and be validated on a live rail before it is
35
- * flipped to default-on. Read per-call so a test can flip the env without
36
- * re-importing.
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 === '1' || v === 'true' || v === 'on';
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 (opt-in via SPECRAILS_RAIL_WORKTREES): a per-ticket
381
- // rail fanning out >1 ticket on a repo-mutating loop runs each ticket in
382
- // its own git worktree, then merges the branches back. Inert by default
383
- // falls through to the shared-cwd path below unless the flag is on; a
384
- // worktree-allocation failure also falls back. See rail-isolation.ts.
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
- const linkPath = path_1.default.join(ws, 'project');
196
- try {
197
- const st = fs_1.default.lstatSync(linkPath);
198
- if (st.isSymbolicLink() || (process.platform === 'win32' && st.isDirectory())) {
199
- // unlink works on POSIX symlinks; rmdir on Windows junctions
200
- try {
201
- fs_1.default.unlinkSync(linkPath);
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.rmdirSync(linkPath);
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
- catch {
212
- /* link may not exist */
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
+ }