specrails-desktop 2.28.0 → 2.29.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.
Files changed (73) hide show
  1. package/cli/dist/specrails-desktop.js +13 -3
  2. package/client/dist/assets/{ActivityFeedPage-DcMmDAP3.js → ActivityFeedPage-Be-6L0Ld.js} +1 -1
  3. package/client/dist/assets/AgentBrowserCapture-CpZ-dd8O.js +1 -0
  4. package/client/dist/assets/{AgentIntegrationsModal-DkqooCFn.js → AgentIntegrationsModal-DO1pN_Ez.js} +1 -1
  5. package/client/dist/assets/{AgentModeCodePane-CM77ljte.js → AgentModeCodePane-DLltTOqp.js} +2 -2
  6. package/client/dist/assets/{AgentModeJobsPane-DbZzogbi.js → AgentModeJobsPane-Dp5YvmW0.js} +1 -1
  7. package/client/dist/assets/AgentsPage-ehHuVUds.js +86 -0
  8. package/client/dist/assets/{AnalyticsPage-B4qflNxi.js → AnalyticsPage-C4Ik95FX.js} +1 -1
  9. package/client/dist/assets/{BarChart-Ckstq9fl.js → BarChart-DVZgrhJd.js} +1 -1
  10. package/client/dist/assets/CodePage-D4OCMfxL.js +2 -0
  11. package/client/dist/assets/{DesktopAnalyticsPage-DbgMa8OO.js → DesktopAnalyticsPage-2Xf-52x2.js} +1 -1
  12. package/client/dist/assets/{DocsDialog-BQc9Kwev.js → DocsDialog-CHihLjWd.js} +2 -2
  13. package/client/dist/assets/{DocsPage-OsV44ECy.js → DocsPage-1vEk7XbH.js} +2 -2
  14. package/client/dist/assets/{ExportDropdown-a43XHO3Y.js → ExportDropdown-DLHFQxVn.js} +1 -1
  15. package/client/dist/assets/IntegrationsPage-CCb2YuyJ.js +1 -0
  16. package/client/dist/assets/InteractiveJobComposer-CxJJEnjw.js +19 -0
  17. package/client/dist/assets/JobDetailModal-CJI7qRkm.js +1 -0
  18. package/client/dist/assets/JobDetailPage-DwA9bbwx.js +1 -0
  19. package/client/dist/assets/JobsPage-BDIyFfd5.js +1 -0
  20. package/client/dist/assets/LoopBuilderPage-CpBaW59t.js +7 -0
  21. package/client/dist/assets/{LoopPreviewModal-B2wmifF6.js → LoopPreviewModal-CwpKcv0Y.js} +1 -1
  22. package/client/dist/assets/LoopsPage-BO0Pa8oz.js +1 -0
  23. package/client/dist/assets/PluginsPage-BBGTBpGh.js +2 -0
  24. package/client/dist/assets/ProjectSettingsDialog-M9zGQo0l.js +1 -0
  25. package/client/dist/assets/{TemplatePreviewModal-CERf1IYB.js → TemplatePreviewModal-eBI0zL5P.js} +1 -1
  26. package/client/dist/assets/{agent-MaTxBlg5.js → agent-BEXYxDbs.js} +1 -1
  27. package/client/dist/assets/{agent-ostmMYNz2.js → agent-BoXSzaBk2.js} +1 -1
  28. package/client/dist/assets/{agent-BRJCESkc2.js → agent-C2-rROL52.js} +1 -1
  29. package/client/dist/assets/{agent-D4JAuEGN2.js → agent-CeRTdGT12.js} +1 -1
  30. package/client/dist/assets/{agent-CxnRzL6Z.js → agent-DZ8zTrQT.js} +1 -1
  31. package/client/dist/assets/{agent-DyzUH89R.js → agent-DqyzT33X.js} +1 -1
  32. package/client/dist/assets/{agent-DNQlI8EN.js → agent-fQI_6tsA.js} +1 -1
  33. package/client/dist/assets/{agent-BuYz6fJd.js → agent-tJdAobaM.js} +1 -1
  34. package/client/dist/assets/{brain-BPbhM1Jx.js → brain-DecvjjPU.js} +1 -1
  35. package/client/dist/assets/{dist-js-Dh-9lbLD.js → dist-js-BTAcxhPb.js} +1 -1
  36. package/client/dist/assets/{dist-js-Byjcju1W.js → dist-js-UjXbEWhN.js} +1 -1
  37. package/client/dist/assets/index-BVbgKXUu.css +2 -0
  38. package/client/dist/assets/index-OBDI7bHm.js +158 -0
  39. package/client/dist/assets/{lib-yKyTNJ1c.js → lib-C5ZxrPSR.js} +1 -1
  40. package/client/dist/assets/{upload-Qh1NE3xi.js → upload-lgjKJgrl.js} +1 -1
  41. package/client/dist/assets/{value-BMPeyInx.js → value-Bc0_jlJA.js} +1 -1
  42. package/client/dist/index.html +3 -3
  43. package/package.json +2 -2
  44. package/server/dist/agent-operator-prompt.js +33 -1
  45. package/server/dist/contract-refine-runner.js +30 -9
  46. package/server/dist/explore-cwd-manager.js +6 -3
  47. package/server/dist/headroom-manager.js +78 -13
  48. package/server/dist/loop-factory.js +15 -8
  49. package/server/dist/loop-graph.js +7 -0
  50. package/server/dist/loop-run-manager.js +115 -13
  51. package/server/dist/loop-runs-store.js +27 -8
  52. package/server/dist/loop-templates.js +10 -2
  53. package/server/dist/mcp/tools/jobs.js +3 -3
  54. package/server/dist/mcp/tools/rails.js +2 -1
  55. package/server/dist/project-registry.js +2 -0
  56. package/server/dist/project-router-jobs.js +15 -11
  57. package/server/dist/rails-router.js +1 -1
  58. package/server/dist/terminal-shell-integration.js +4 -1
  59. package/server/dist/worktree-manager.js +52 -4
  60. package/client/dist/assets/AgentBrowserCapture-CZ3oc1UL.js +0 -1
  61. package/client/dist/assets/AgentsPage-BZIml7eL.js +0 -86
  62. package/client/dist/assets/CodePage-DFZXLbI1.js +0 -2
  63. package/client/dist/assets/IntegrationsPage-C3E8hZhB.js +0 -1
  64. package/client/dist/assets/InteractiveJobComposer-zj8SrOAl.js +0 -19
  65. package/client/dist/assets/JobDetailModal-Cm6ZeVoh.js +0 -1
  66. package/client/dist/assets/JobDetailPage-DN67LsdX.js +0 -1
  67. package/client/dist/assets/JobsPage-DF9wKGbw.js +0 -1
  68. package/client/dist/assets/LoopBuilderPage-RJ6GGjli.js +0 -7
  69. package/client/dist/assets/LoopsPage-DdlvH-dJ.js +0 -1
  70. package/client/dist/assets/PluginsPage-9DI0a6jI.js +0 -2
  71. package/client/dist/assets/ProjectSettingsDialog-dgy5hqPd.js +0 -1
  72. package/client/dist/assets/index-89SuhD4Y.js +0 -158
  73. package/client/dist/assets/index-DOGzWJhO.css +0 -2
@@ -3,7 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.HeadroomManager = void 0;
6
+ exports.HeadroomManager = exports.HEADROOM_MANAGED_PYTHON_VERSION = void 0;
7
+ exports.getHeadroomManagedInstallPlan = getHeadroomManagedInstallPlan;
7
8
  const child_process_1 = require("child_process");
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const os_1 = __importDefault(require("os"));
@@ -12,11 +13,15 @@ const desktop_db_1 = require("./desktop-db");
12
13
  const headroom_routing_1 = require("./headroom-routing");
13
14
  const STATE_KEY = 'plugins.headroom.state';
14
15
  const DEFAULT_PORT = 8787;
16
+ exports.HEADROOM_MANAGED_PYTHON_VERSION = '3.12';
15
17
  function nowIso() {
16
18
  return new Date().toISOString();
17
19
  }
20
+ function specrailsHome() {
21
+ return process.env.SPECRAILS_REGISTRY_HOME || os_1.default.homedir();
22
+ }
18
23
  function toolRoot() {
19
- return path_1.default.join(os_1.default.homedir(), '.specrails', 'tools');
24
+ return path_1.default.join(specrailsHome(), '.specrails', 'tools');
20
25
  }
21
26
  function uvToolDir() {
22
27
  return path_1.default.join(toolRoot(), 'uv', 'tools');
@@ -24,6 +29,15 @@ function uvToolDir() {
24
29
  function uvCacheDir() {
25
30
  return path_1.default.join(toolRoot(), 'uv', 'cache');
26
31
  }
32
+ function uvPythonInstallDir() {
33
+ return path_1.default.join(toolRoot(), 'uv', 'python');
34
+ }
35
+ function uvPythonCacheDir() {
36
+ return path_1.default.join(toolRoot(), 'uv', 'python-cache');
37
+ }
38
+ function uvPythonBinDir() {
39
+ return path_1.default.join(toolRoot(), 'uv', 'python-bin');
40
+ }
27
41
  function uvBinDir() {
28
42
  return path_1.default.join(toolRoot(), 'bin');
29
43
  }
@@ -152,6 +166,40 @@ function classifyInstallFailure(output, command) {
152
166
  }
153
167
  return issue('unknown', output, command);
154
168
  }
169
+ function getHeadroomManagedInstallPlan() {
170
+ return {
171
+ pythonVersion: exports.HEADROOM_MANAGED_PYTHON_VERSION,
172
+ pythonInstallArgs: [
173
+ 'python',
174
+ 'install',
175
+ exports.HEADROOM_MANAGED_PYTHON_VERSION,
176
+ '--managed-python',
177
+ ],
178
+ toolInstallArgs: [
179
+ 'tool',
180
+ 'install',
181
+ '--python',
182
+ exports.HEADROOM_MANAGED_PYTHON_VERSION,
183
+ '--managed-python',
184
+ '--force',
185
+ 'headroom-ai[all]',
186
+ ],
187
+ env: {
188
+ UV_TOOL_DIR: uvToolDir(),
189
+ UV_TOOL_BIN_DIR: uvBinDir(),
190
+ UV_CACHE_DIR: uvCacheDir(),
191
+ UV_PYTHON: exports.HEADROOM_MANAGED_PYTHON_VERSION,
192
+ UV_MANAGED_PYTHON: 'true',
193
+ UV_PYTHON_DOWNLOADS: 'automatic',
194
+ UV_PYTHON_INSTALL_DIR: uvPythonInstallDir(),
195
+ UV_PYTHON_CACHE_DIR: uvPythonCacheDir(),
196
+ UV_PYTHON_BIN_DIR: uvPythonBinDir(),
197
+ UV_PYTHON_NO_REGISTRY: 'true',
198
+ UV_PYTHON_INSTALL_REGISTRY: 'false',
199
+ UV_NO_PROGRESS: '1',
200
+ },
201
+ };
202
+ }
155
203
  function classifyProxyFailure(output, command) {
156
204
  const lower = output.toLowerCase();
157
205
  if (lower.includes('address already in use') ||
@@ -334,18 +382,32 @@ class HeadroomManager {
334
382
  fs_1.default.mkdirSync(uvToolDir(), { recursive: true });
335
383
  fs_1.default.mkdirSync(uvCacheDir(), { recursive: true });
336
384
  fs_1.default.mkdirSync(uvBinDir(), { recursive: true });
337
- const args = ['tool', 'install', 'headroom-ai[all]', '--force'];
338
- const command = `${uv} ${args.join(' ')}`;
339
- const logs = [`Running ${command}`];
385
+ fs_1.default.mkdirSync(uvPythonInstallDir(), { recursive: true });
386
+ fs_1.default.mkdirSync(uvPythonCacheDir(), { recursive: true });
387
+ fs_1.default.mkdirSync(uvPythonBinDir(), { recursive: true });
388
+ const plan = getHeadroomManagedInstallPlan();
389
+ const pythonCommand = `${uv} ${plan.pythonInstallArgs.join(' ')}`;
390
+ const installCommand = `${uv} ${plan.toolInstallArgs.join(' ')}`;
391
+ const logs = [
392
+ `Ensuring Headroom Python runtime (CPython ${plan.pythonVersion})`,
393
+ `Running ${pythonCommand}`,
394
+ ];
340
395
  this.emit('installing', logs[0]);
341
- const result = await this.runCommand(uv, args, {
342
- UV_TOOL_DIR: uvToolDir(),
343
- UV_TOOL_BIN_DIR: uvBinDir(),
344
- UV_CACHE_DIR: uvCacheDir(),
345
- UV_NO_PROGRESS: '1',
346
- }, logs);
396
+ this.emit('installing', logs[1]);
397
+ const pythonResult = await this.runCommand(uv, plan.pythonInstallArgs, plan.env, logs);
398
+ if (pythonResult.code !== 0) {
399
+ const failure = classifyInstallFailure(logs.join('\n'), pythonCommand);
400
+ this.updatePersisted({ lastIssue: failure });
401
+ this.emit('failed', failure.title);
402
+ return { ok: false, state: this.getState(), issue: failure, logs };
403
+ }
404
+ logs.push(`Installing Headroom with managed CPython ${plan.pythonVersion}`);
405
+ logs.push(`Running ${installCommand}`);
406
+ this.emit('installing', logs[logs.length - 2]);
407
+ this.emit('installing', logs[logs.length - 1]);
408
+ const result = await this.runCommand(uv, plan.toolInstallArgs, plan.env, logs);
347
409
  if (result.code !== 0) {
348
- const failure = classifyInstallFailure(logs.join('\n'), command);
410
+ const failure = classifyInstallFailure(logs.join('\n'), installCommand);
349
411
  this.updatePersisted({ lastIssue: failure });
350
412
  this.emit('failed', failure.title);
351
413
  return { ok: false, state: this.getState(), issue: failure, logs };
@@ -353,7 +415,7 @@ class HeadroomManager {
353
415
  const exe = this.resolveHeadroomInstall(null, false)?.path ?? null;
354
416
  const version = exe ? this.readHeadroomVersion(exe) : null;
355
417
  if (!exe || !version) {
356
- const failure = issue('headroom_not_found_after_install', logs.join('\n'), command);
418
+ const failure = issue('headroom_not_found_after_install', logs.join('\n'), installCommand);
357
419
  this.updatePersisted({ lastIssue: failure });
358
420
  this.emit('failed', failure.title);
359
421
  return { ok: false, state: this.getState(), issue: failure, logs };
@@ -494,6 +556,9 @@ class HeadroomManager {
494
556
  toolDir: uvToolDir(),
495
557
  binDir: uvBinDir(),
496
558
  cacheDir: uvCacheDir(),
559
+ pythonInstallDir: uvPythonInstallDir(),
560
+ pythonBinDir: uvPythonBinDir(),
561
+ pythonCacheDir: uvPythonCacheDir(),
497
562
  proxyTail: this.proxyTail.slice(-4000),
498
563
  installSource: state.installSource,
499
564
  detectedRoutes: state.detectedRoutes,
@@ -14,6 +14,13 @@ const GREEN_GOAL = 'Stop only when the latest verification step reports {{const:
14
14
  const FACTORY_MAX_ITERATIONS = 12;
15
15
  const FACTORY_LOOP_TIMEOUT_MIN = 360;
16
16
  const FACTORY_AI_STEP_TIMEOUT_MIN = 60;
17
+ const SDD_QUICK_OPENSPEC_FACTORY = {
18
+ id: 'factory:sdd-quick-openspec',
19
+ name: 'SDD Quick (OpenSpec)',
20
+ description: 'Quick spec-driven OpenSpec lifecycle for small contract-governed changes: amend artifacts, apply, verify, and archive only after PASS.',
21
+ mode: 'loop',
22
+ graph: (0, loop_templates_1.opsxLifecycleGraph)(),
23
+ };
17
24
  exports.FACTORY_LOOPS = [
18
25
  {
19
26
  id: 'factory:implement',
@@ -40,15 +47,15 @@ exports.FACTORY_LOOPS = [
40
47
  claudeOnly: true,
41
48
  graph: (0, loop_templates_1.fixLoopGraph)(['{{cmd:freestyle}}'], GREEN_GOAL, FACTORY_MAX_ITERATIONS, FACTORY_LOOP_TIMEOUT_MIN, FACTORY_AI_STEP_TIMEOUT_MIN),
42
49
  },
43
- {
44
- id: 'factory:openspec',
45
- name: 'OpenSpec Lifecycle',
46
- description: 'Ticket-to-archive OpenSpec lifecycle: generate artifacts (opsx:ff) → implement (opsx:apply) → verify (opsx:verify); on FAIL loop back and amend the same change, on PASS archive it unattended.',
47
- mode: 'loop',
48
- graph: (0, loop_templates_1.opsxLifecycleGraph)(),
49
- },
50
+ SDD_QUICK_OPENSPEC_FACTORY,
50
51
  ];
51
- const FACTORY_BY_ID = new Map(exports.FACTORY_LOOPS.map((f) => [f.id, f]));
52
+ const FACTORY_ALIASES = new Map([
53
+ ['factory:openspec', SDD_QUICK_OPENSPEC_FACTORY],
54
+ ]);
55
+ const FACTORY_BY_ID = new Map([
56
+ ...exports.FACTORY_LOOPS.map((f) => [f.id, f]),
57
+ ...FACTORY_ALIASES,
58
+ ]);
52
59
  function getFactoryLoop(id) {
53
60
  return FACTORY_BY_ID.get(id);
54
61
  }
@@ -155,6 +155,13 @@ function interpolateSpec(text, spec) {
155
155
  const ids = spec?.ticketIds ?? (spec?.id != null ? [spec.id] : []);
156
156
  return ids.map((id) => `#${id}`).join(' ');
157
157
  }
158
+ if (key === 'openspecChangeName') {
159
+ const direct = typeof spec?.openspecChangeName === 'string' ? spec.openspecChangeName.trim() : '';
160
+ const fromMetadata = typeof spec?.metadata?.openspecChangeName === 'string' ? spec.metadata.openspecChangeName.trim() : '';
161
+ return direct || fromMetadata;
162
+ }
163
+ if (!['id', 'title', 'description', 'status', 'priority', 'labels', 'jira_key', 'jira_url'].includes(key))
164
+ return '';
158
165
  const value = spec ? spec[key] : undefined;
159
166
  if (value == null)
160
167
  return '';
@@ -108,6 +108,7 @@ class LoopRunManager {
108
108
  * id, which IS the backing job row's id — the manager-agnostic turn routing
109
109
  * (project-router-jobs) addresses sessions by that job id. */
110
110
  _interactiveSteps = new Map();
111
+ _pausedHumanDecisions = new Map();
111
112
  constructor(db, broadcast, executors, now = () => Date.now()) {
112
113
  this.db = db;
113
114
  this.broadcast = broadcast;
@@ -122,6 +123,11 @@ class LoopRunManager {
122
123
  * leave the engine's `await` hanging forever. */
123
124
  cancel(runId) {
124
125
  this._cancelled.add(runId);
126
+ const paused = this._pausedHumanDecisions.get(runId);
127
+ if (paused) {
128
+ this._pausedHumanDecisions.delete(runId);
129
+ paused.resolve({ action: 'stop' });
130
+ }
125
131
  const session = this._interactiveSteps.get(runId);
126
132
  if (session) {
127
133
  session.abort('■ Run canceled — tearing down the interactive step session.');
@@ -139,10 +145,23 @@ class LoopRunManager {
139
145
  isInteractiveJob(jobId) {
140
146
  return this._interactiveSteps.has(jobId);
141
147
  }
148
+ /** True while the loop is paused between engine steps awaiting a human call. */
149
+ isPaused(jobId) {
150
+ return this._pausedHumanDecisions.has(jobId);
151
+ }
152
+ pausedReason(jobId) {
153
+ return this._pausedHumanDecisions.get(jobId)?.reason ?? null;
154
+ }
142
155
  /** Feed one more user prompt to the ACTIVE step session owning this job row
143
156
  * (queued behind the active turn — steering; the loop tends to continue its
144
157
  * plan). Returns false when no interactive step is live for the id. */
145
158
  sendInteractiveTurn(jobId, text) {
159
+ const paused = this._pausedHumanDecisions.get(jobId);
160
+ if (paused) {
161
+ this._pausedHumanDecisions.delete(jobId);
162
+ paused.resolve({ action: 'resume', text });
163
+ return true;
164
+ }
146
165
  const session = this._interactiveSteps.get(jobId);
147
166
  if (!session)
148
167
  return false;
@@ -180,6 +199,14 @@ class LoopRunManager {
180
199
  }
181
200
  }
182
201
  this._activeChild.clear();
202
+ for (const [runId, paused] of this._pausedHumanDecisions.entries()) {
203
+ this._cancelled.add(runId);
204
+ try {
205
+ paused.resolve({ action: 'stop' });
206
+ }
207
+ catch { /* ignore */ }
208
+ }
209
+ this._pausedHumanDecisions.clear();
183
210
  }
184
211
  async run(req) {
185
212
  const runId = req.runId ?? (0, ids_1.newId)();
@@ -459,6 +486,65 @@ class LoopRunManager {
459
486
  // that loop-back so each iteration starts FRESH (re-reading the current code),
460
487
  // keeping context bounded regardless of pass count. Within an iteration the
461
488
  // steps still resume, so RED→GREEN→REFACTOR share context.
489
+ const awaitHumanDecision = async (reason) => {
490
+ const decisionPromise = new Promise((resolve) => {
491
+ this._pausedHumanDecisions.set(runId, { reason, resolve });
492
+ });
493
+ logLine(`\n■ Loop paused — needs a human decision: ${reason}`, 'stderr');
494
+ try {
495
+ (0, db_1.markJobInteractive)(this.db, runId);
496
+ }
497
+ catch { /* best-effort */ }
498
+ (0, loop_runs_store_1.pauseLoopRun)(this.db, runId, {
499
+ iterationCount: iteration,
500
+ totalCostUsd: totalCost,
501
+ totalTokens,
502
+ totalDurationMs: totalDuration,
503
+ });
504
+ const pausedAt = new Date(this.now()).toISOString();
505
+ this.broadcast({
506
+ type: 'loop.run_paused',
507
+ projectId: req.projectId,
508
+ loopRunId: runId,
509
+ railIndex: req.railIndex ?? null,
510
+ reason,
511
+ ticketIds: req.ticketId != null ? [req.ticketId] : [],
512
+ });
513
+ this.broadcast({
514
+ type: 'job.interactive',
515
+ projectId: req.projectId,
516
+ jobId: runId,
517
+ acceptingTurns: true,
518
+ settleMode: 'auto',
519
+ timestamp: pausedAt,
520
+ });
521
+ const decision = await decisionPromise;
522
+ if (decision.action === 'stop' || this._cancelled.has(runId)) {
523
+ logLine(`\n■ Loop stop requested while paused.`, 'stderr');
524
+ return { action: 'stop' };
525
+ }
526
+ (0, loop_runs_store_1.resumeLoopRun)(this.db, runId);
527
+ const answer = decision.text.trim();
528
+ const resumedAt = new Date(this.now()).toISOString();
529
+ logLine(`\n▶ Loop resumed — human decision received${answer ? `:\n${answer}` : '.'}`);
530
+ history.push(`Human decision: ${truncate(answer)}`);
531
+ this.broadcast({
532
+ type: 'loop.run_resumed',
533
+ projectId: req.projectId,
534
+ loopRunId: runId,
535
+ railIndex: req.railIndex ?? null,
536
+ ticketIds: req.ticketId != null ? [req.ticketId] : [],
537
+ });
538
+ this.broadcast({
539
+ type: 'job.interactive',
540
+ projectId: req.projectId,
541
+ jobId: runId,
542
+ acceptingTurns: false,
543
+ settleMode: 'auto',
544
+ timestamp: resumedAt,
545
+ });
546
+ return decision;
547
+ };
462
548
  const firstStepId = start ? req.graph.edges.find((e) => e.source === start.id)?.target : undefined;
463
549
  try {
464
550
  let nodeId = start?.id;
@@ -601,15 +687,20 @@ class LoopRunManager {
601
687
  // Only carry forward the session of a step that actually ran — a
602
688
  // hard-failed turn (codex still emits thread.started before its error)
603
689
  // would otherwise make the next step `--resume` a dead session.
604
- if (res.sessionId && !stepFailed)
690
+ if (res.sessionId && (!stepFailed || (blockedReason !== null && !res.failed && !zeroWork)))
605
691
  aiSessionId = res.sessionId;
606
692
  history.push(`AI Step: ${truncate(res.text)}`);
607
693
  record(`loop:${runId}`, { ...res, failed: stepFailed }, aiStepStart);
608
694
  if (blockedReason) {
609
- logLine(`\n■ Loop blocked — needs a human decision: ${blockedReason}`, 'stderr');
610
- outcome = 'blocked';
611
- settled = true;
612
- break;
695
+ const decision = await awaitHumanDecision(blockedReason);
696
+ if (decision.action === 'stop') {
697
+ outcome = 'stopped';
698
+ settled = true;
699
+ break;
700
+ }
701
+ justContinued = true;
702
+ nodeId = node.id;
703
+ continue;
613
704
  }
614
705
  // Capture the OpenSpec change id from a step's output the FIRST time it
615
706
  // appears (first-match-wins, kept stable across loop-back iterations so
@@ -754,10 +845,21 @@ class LoopRunManager {
754
845
  // the fix for the verify→fix cycle spinning on an unanswered scope
755
846
  // question the loop can't resolve on its own.
756
847
  if (dec.blocked) {
757
- logLine(`\n■ Loop blocked — needs a human decision: ${dec.reasoning}`, 'stderr');
758
- outcome = 'blocked';
759
- settled = true;
760
- break;
848
+ const decision = await awaitHumanDecision(dec.reasoning);
849
+ if (decision.action === 'stop') {
850
+ outcome = 'stopped';
851
+ settled = true;
852
+ break;
853
+ }
854
+ const target = branchTarget('continue') ?? conts[0]?.id ?? firstStepId;
855
+ if (!target) {
856
+ outcome = 'failed';
857
+ settled = true;
858
+ break;
859
+ }
860
+ justContinued = true;
861
+ nodeId = target;
862
+ continue;
761
863
  }
762
864
  // Non-convergence guard: if two consecutive 'continue' iterations
763
865
  // leave the working tree byte-identical, the loop isn't progressing
@@ -846,11 +948,11 @@ class LoopRunManager {
846
948
  // Settle the backing job so the Jobs list + JobDetail reflect the final
847
949
  // status, and emit job.finalized so an open JobDetail re-fetches + stops the
848
950
  // live stream (mirrors QueueManager).
849
- // `blocked`/`stalled` are controlled halts (not done, not a hard crash) →
850
- // surface as `canceled`, like a user stop, so tickets revert to todo rather
851
- // than being marked done/on_review AND the run is never counted as success.
951
+ // `stopped`/`stalled` are controlled halts (not done, not a hard crash) →
952
+ // surface as `canceled`, so tickets revert to todo rather than being marked
953
+ // done/on_review AND the run is never counted as success.
852
954
  const jobStatus = outcome === 'success' ? 'completed'
853
- : outcome === 'stopped' || outcome === 'blocked' || outcome === 'stalled' ? 'canceled'
955
+ : outcome === 'stopped' || outcome === 'stalled' ? 'canceled'
854
956
  : 'failed';
855
957
  // `≥` when any cost-bearing step ended unpriced (timeout/crash) — the figure
856
958
  // is a lower bound, not exact.
@@ -3,9 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createLoopRun = createLoopRun;
4
4
  exports.updateLoopRunCounters = updateLoopRunCounters;
5
5
  exports.finishLoopRun = finishLoopRun;
6
+ exports.pauseLoopRun = pauseLoopRun;
7
+ exports.resumeLoopRun = resumeLoopRun;
6
8
  exports.getLoopRun = getLoopRun;
7
9
  exports.getRunEventCounts = getRunEventCounts;
8
10
  exports.listRunningLoopRuns = listRunningLoopRuns;
11
+ exports.listActiveLoopRuns = listActiveLoopRuns;
9
12
  exports.listLoopRuns = listLoopRuns;
10
13
  exports.countRunningForLoop = countRunningForLoop;
11
14
  exports.reconcileOrphanLoopRuns = reconcileOrphanLoopRuns;
@@ -40,6 +43,16 @@ function finishLoopRun(db, id, opts) {
40
43
  db.prepare(`UPDATE loop_runs SET status = 'completed', final_outcome = ?, finished_at = ? WHERE id = ?`).run(opts.outcome, opts.finishedAt, id);
41
44
  return getLoopRun(db, id);
42
45
  }
46
+ function pauseLoopRun(db, id, counters) {
47
+ if (counters)
48
+ updateLoopRunCounters(db, id, counters);
49
+ db.prepare(`UPDATE loop_runs SET status = 'paused' WHERE id = ? AND status = 'running'`).run(id);
50
+ return getLoopRun(db, id);
51
+ }
52
+ function resumeLoopRun(db, id) {
53
+ db.prepare(`UPDATE loop_runs SET status = 'running' WHERE id = ? AND status = 'paused'`).run(id);
54
+ return getLoopRun(db, id);
55
+ }
43
56
  function getLoopRun(db, id) {
44
57
  return db.prepare('SELECT * FROM loop_runs WHERE id = ?').get(id);
45
58
  }
@@ -92,6 +105,11 @@ function listRunningLoopRuns(db, projectId) {
92
105
  .prepare("SELECT * FROM loop_runs WHERE project_id = ? AND status = 'running' ORDER BY started_at ASC")
93
106
  .all(projectId);
94
107
  }
108
+ function listActiveLoopRuns(db, projectId) {
109
+ return db
110
+ .prepare("SELECT * FROM loop_runs WHERE project_id = ? AND status IN ('running','paused') ORDER BY started_at ASC")
111
+ .all(projectId);
112
+ }
95
113
  function listLoopRuns(db, projectId, limit = 100) {
96
114
  return db
97
115
  .prepare('SELECT * FROM loop_runs WHERE project_id = ? ORDER BY started_at DESC LIMIT ?')
@@ -102,19 +120,20 @@ function listLoopRuns(db, projectId, limit = 100) {
102
120
  * Running?" guard that blocks edit/delete of a loop while it executes. */
103
121
  function countRunningForLoop(db, loopId) {
104
122
  const row = db
105
- .prepare(`SELECT COUNT(*) AS n FROM loop_runs WHERE loop_id = ? AND status = 'running'`)
123
+ .prepare(`SELECT COUNT(*) AS n FROM loop_runs WHERE loop_id = ? AND status IN ('running','paused')`)
106
124
  .get(loopId);
107
125
  return row.n;
108
126
  }
109
- /** Reconcile orphan runs at startup: any row still `status='running'` belongs to
110
- * a process that is now dead (server restart, or a run killed before it could
111
- * settle), so mark it terminal (`completed` + outcome `failed`). Without this an
112
- * orphan keeps `countRunningForLoop > 0` forever, which wedges the loop as
113
- * un-editable / un-publishable ("Failed to save loop" via the isRunning 409).
114
- * Returns the number of rows reconciled. */
127
+ /** Reconcile orphan runs at startup: any row still active belongs to a process
128
+ * that is now dead (server restart, or a run killed before it could settle), so
129
+ * mark it terminal (`completed` + outcome `failed`). A paused row is active too:
130
+ * its resumable context lived in the old process. Without this an orphan keeps
131
+ * `countRunningForLoop > 0` forever, which wedges the loop as un-editable /
132
+ * un-publishable ("Failed to save loop" via the isRunning 409). Returns the
133
+ * number of rows reconciled. */
115
134
  function reconcileOrphanLoopRuns(db, finishedAt) {
116
135
  const res = db
117
- .prepare(`UPDATE loop_runs SET status = 'completed', final_outcome = COALESCE(final_outcome, 'failed'), finished_at = COALESCE(finished_at, ?) WHERE status = 'running'`)
136
+ .prepare(`UPDATE loop_runs SET status = 'completed', final_outcome = COALESCE(final_outcome, 'failed'), finished_at = COALESCE(finished_at, ?) WHERE status IN ('running','paused')`)
118
137
  .run(finishedAt);
119
138
  return res.changes ?? 0;
120
139
  }
@@ -116,7 +116,11 @@ const OPSX_FF_PROMPT = [
116
116
  '',
117
117
  '{{spec.description}}',
118
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.',
119
+ 'Structured OpenSpec target from the local ticket metadata: "{{spec.openspecChangeName}}". If this value is non-blank, CONTINUE that exact OpenSpec change and do NOT create a duplicate change for the same follow-up. If it is blank and no run change id exists, create a new OpenSpec change from this ticket and generate all required artifacts.',
120
+ '',
121
+ 'If a run 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).',
122
+ '',
123
+ 'OpenSpec artifacts are authoritative. If the requested implementation changes requirements, acceptance criteria, design decisions, APIs, states, data models, or invariants, amend the relevant OpenSpec artifacts before any code changes.',
120
124
  '',
121
125
  '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
126
  ].join('\n');
@@ -125,6 +129,8 @@ const OPSX_APPLY_PROMPT = [
125
129
  '',
126
130
  '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
131
  '',
132
+ 'Before editing code, confirm the active OpenSpec artifacts already describe the contract being implemented. If implementation requires changing requirements, acceptance criteria, design decisions, APIs, states, data models, or invariants, stop code work and amend the OpenSpec artifacts first.',
133
+ '',
128
134
  '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
135
  ].join('\n');
130
136
  const OPSX_VERIFY_PROMPT = [
@@ -132,9 +138,11 @@ const OPSX_VERIFY_PROMPT = [
132
138
  '',
133
139
  '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
140
  '',
141
+ 'Report FAIL if the implementation diverges from the active OpenSpec artifacts, or if code changed requirements, acceptance criteria, design decisions, APIs, states, data models, or invariants without corresponding artifact amendments.',
142
+ '',
135
143
  '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
144
  ].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.';
145
+ const OPSX_DECIDER_GOAL = 'The verify step reported {{const:VERIFICATION_PASS}} — the implementation fully matches the active OpenSpec artifacts for ticket "{{spec.title}}" and nothing required is missing.';
138
146
  /** The OpenSpec-lifecycle graph (see comment above). Exported for unit testing. */
139
147
  function opsxLifecycleGraph() {
140
148
  return {
@@ -38,9 +38,9 @@ function backgroundStartBusyReason(projectCtx) {
38
38
  if (activeJobId)
39
39
  return `job ${activeJobId} is still running`;
40
40
  try {
41
- const runningLoops = (0, loop_runs_store_1.listRunningLoopRuns)(projectCtx.db, projectCtx.project.id);
42
- if (runningLoops.length > 0)
43
- return `loop run ${runningLoops[0].id} is still running`;
41
+ const activeLoops = (0, loop_runs_store_1.listActiveLoopRuns)(projectCtx.db, projectCtx.project.id);
42
+ if (activeLoops.length > 0)
43
+ return `loop run ${activeLoops[0].id} is still running`;
44
44
  }
45
45
  catch {
46
46
  // Some unit harnesses stub the DB narrowly; absence of loop metadata is not a blocker.
@@ -52,6 +52,7 @@ function railsTools() {
52
52
  'For on_review tickets with an already-open GitHub PR (including a published pr_ready delivery), launch automatically tries to continue that PR head branch; Jira-linked in_progress tickets can do the same when the PR match is explicit; fresh tickets still start from the project integration branch. ' +
53
53
  'When launched from the in-app agent chat without an explicit aiEngine, the engine defaults to your conversation\'s provider (pass aiEngine to override; launch_all always uses each rail\'s stored engine). ' +
54
54
  'User-facing naming: call the free-form autonomous mode "Freestyle"; use "freestyle" as the canonical API enum value for that same capability. ' +
55
+ 'For small OpenSpec-governed work, recommend "SDD Quick (OpenSpec)" and launch with mode "loop" plus loopId "factory:sdd-quick-openspec"; keep Freestyle for ticket-local implementation-only work. ' +
55
56
  'NAMING: railIndex is the 0-BASED internal identity; the dashboard shows rails 1-based ("Rail N" = railIndex N-1). When talking to the user, ALWAYS say "Rail <railIndex + 1>" (or the rail\'s custom name) — results include railLabel with the correct user-facing label.',
56
57
  hintTier: 'read',
57
58
  tier: (a) => {
@@ -114,7 +115,7 @@ function railsTools() {
114
115
  loopId: zod_1.z
115
116
  .string()
116
117
  .optional()
117
- .describe('Loop id to run (launch). Factory ids (e.g. "factory:implement") map to a legacy mode; a custom published loop id keeps mode="loop". Browse/author loops with specrails_loops; the loop must be Published.'),
118
+ .describe('Loop id to run (launch). Use "factory:sdd-quick-openspec" for SDD Quick (OpenSpec): small OpenSpec-governed work via mode="loop". Factory ids (e.g. "factory:implement") map to a legacy mode; a custom published loop id keeps mode="loop". Browse/author loops with specrails_loops; the loop must be Published.'),
118
119
  reasoning_effort: zod_1.z
119
120
  .enum(['low', 'medium', 'high'])
120
121
  .optional()
@@ -712,6 +712,8 @@ class ProjectRegistry {
712
712
  labels: t.labels,
713
713
  jira_key: t.jira_key ?? null,
714
714
  jira_url: t.jira_url ?? null,
715
+ openspecChangeName: typeof t.metadata?.openspecChangeName === 'string' ? t.metadata.openspecChangeName : undefined,
716
+ metadata: t.metadata,
715
717
  };
716
718
  }
717
719
  catch {
@@ -23,9 +23,9 @@ function backgroundStartBusyReason(c) {
23
23
  if (activeJobId)
24
24
  return `job ${activeJobId} is still running`;
25
25
  try {
26
- const runningLoops = (0, loop_runs_store_1.listRunningLoopRuns)(c.db, c.project.id);
27
- if (runningLoops.length > 0)
28
- return `loop run ${runningLoops[0].id} is still running`;
26
+ const activeLoops = (0, loop_runs_store_1.listActiveLoopRuns)(c.db, c.project.id);
27
+ if (activeLoops.length > 0)
28
+ return `loop run ${activeLoops[0].id} is still running`;
29
29
  }
30
30
  catch {
31
31
  // If the loop table is unavailable in a test harness, do not block startup.
@@ -249,7 +249,7 @@ function registerJobsRoutes(deps) {
249
249
  // the loop is still executing — cancel through the engine instead of
250
250
  // 404ing with a live child (the mission-modal "cancel did nothing" bug).
251
251
  const loopRun = (0, loop_runs_store_1.getLoopRun)(c.db, id);
252
- if (loopRun && loopRun.status === 'running' && c.loopRunManager) {
252
+ if (loopRun && (loopRun.status === 'running' || loopRun.status === 'paused') && c.loopRunManager) {
253
253
  c.loopRunManager.cancel(id);
254
254
  res.json({ ok: true, status: 'canceling' });
255
255
  return;
@@ -280,7 +280,10 @@ function registerJobsRoutes(deps) {
280
280
  // 409 = neither manager has an active interactive session for the id
281
281
  // (unknown / non-interactive / finalized / between loop steps).
282
282
  router.post('/:projectId/jobs/:id/messages', (req, res) => {
283
- if (!(0, feature_flags_1.isInteractiveJobsEnabled)()) {
283
+ const c = ctx(req);
284
+ const jobId = req.params.id;
285
+ const loopPaused = c.loopRunManager?.isPaused?.(jobId) ?? false;
286
+ if (!(0, feature_flags_1.isInteractiveJobsEnabled)() && !loopPaused) {
284
287
  res.status(403).json({ error: 'Interactive jobs are disabled on this server' });
285
288
  return;
286
289
  }
@@ -289,8 +292,6 @@ function registerJobsRoutes(deps) {
289
292
  res.status(400).json({ error: 'text is required' });
290
293
  return;
291
294
  }
292
- const c = ctx(req);
293
- const jobId = req.params.id;
294
295
  const accepted = c.queueManager.sendInteractiveTurn(jobId, text)
295
296
  || (c.loopRunManager?.sendInteractiveTurn(jobId, text) ?? false);
296
297
  if (!accepted) {
@@ -571,13 +572,16 @@ function registerJobsRoutes(deps) {
571
572
  // composer can phrase the waiting state truthfully instead of guessing.
572
573
  let interactiveSettleMode = null;
573
574
  let interactiveAcceptingTurns = false;
574
- if ((0, feature_flags_1.isInteractiveJobsEnabled)() && job.interactive && job.status === 'running') {
575
+ const loopRun = (0, loop_runs_store_1.getLoopRun)(db, jobId);
576
+ const loopPaused = loopRun?.status === 'paused' || (c.loopRunManager?.isPaused?.(jobId) ?? false);
577
+ const loopPauseReason = c.loopRunManager?.pausedReason?.(jobId) ?? null;
578
+ if (job.status === 'running' && (((0, feature_flags_1.isInteractiveJobsEnabled)() && job.interactive) || loopPaused)) {
575
579
  const qmMode = queueManager.getInteractiveSettleMode?.(jobId) ?? null;
576
580
  const loopStepActive = c.loopRunManager?.isInteractiveJob?.(jobId) ?? false;
577
- interactiveAcceptingTurns = qmMode !== null || loopStepActive;
578
- interactiveSettleMode = qmMode ?? ((loopStepActive || (0, loop_runs_store_1.getLoopRun)(db, jobId)) ? 'auto' : null);
581
+ interactiveAcceptingTurns = qmMode !== null || loopStepActive || loopPaused;
582
+ interactiveSettleMode = qmMode ?? ((loopStepActive || loopPaused || loopRun) ? 'auto' : null);
579
583
  }
580
- const annotated = { ...job, hasTelemetry: (0, db_1.hasJobTelemetry)(db, jobId), tickets, interactiveSettleMode, interactiveAcceptingTurns };
584
+ const annotated = { ...job, hasTelemetry: (0, db_1.hasJobTelemetry)(db, jobId), tickets, interactiveSettleMode, interactiveAcceptingTurns, loopPaused, loopPauseReason };
581
585
  res.json({ job: annotated, events, phaseDefinitions });
582
586
  });
583
587
  router.delete('/:projectId/jobs', (req, res) => {
@@ -111,7 +111,7 @@ function createRailsRouter() {
111
111
  // Also surface DB 'running' runs not tracked in-memory (the rail map is
112
112
  // cleared on every server restart) — DB is the authoritative source, so the
113
113
  // dashboard metrics survive both a page refresh AND a server restart.
114
- for (const run of (0, loop_runs_store_1.listRunningLoopRuns)(c.db, c.project.id)) {
114
+ for (const run of (0, loop_runs_store_1.listActiveLoopRuns)(c.db, c.project.id)) {
115
115
  if (run.rail_index == null || activeLoopRuns[run.rail_index])
116
116
  continue;
117
117
  const counts = (0, loop_runs_store_1.getRunEventCounts)(c.db, run.id);
@@ -62,8 +62,11 @@ function warnShimMissingOnce(name) {
62
62
  _warnedShims.add(name);
63
63
  console.warn(`[terminal-shell-integration] bundled shim not found: ${name} — shell integration disabled for this shell`);
64
64
  }
65
+ function specrailsHome() {
66
+ return process.env.SPECRAILS_REGISTRY_HOME || os_1.default.homedir();
67
+ }
65
68
  function projectsRoot() {
66
- return path_1.default.join(os_1.default.homedir(), '.specrails', 'projects');
69
+ return path_1.default.join(specrailsHome(), '.specrails', 'projects');
67
70
  }
68
71
  function shimDirFor(projectSlug, sessionId) {
69
72
  return path_1.default.join(projectsRoot(), projectSlug, 'terminals', sessionId);