specrails-desktop 2.45.0 → 2.46.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 (122) hide show
  1. package/client/dist/assets/{ActivityFeedPage-D3SQklu5.js → ActivityFeedPage-CsHQPpvl.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-DNGxE33d.js → AgentBrowserCapture-BVcCK7x0.js} +1 -1
  3. package/client/dist/assets/AgentModeAnalyticsPane-C5F3m29G.js +2 -0
  4. package/client/dist/assets/AgentModeCodePane-C5QuKsu0.js +2 -0
  5. package/client/dist/assets/AgentModeJobsPane-rnD-EPMd.js +1 -0
  6. package/client/dist/assets/AgentsPage-DiyJWZcU.js +87 -0
  7. package/client/dist/assets/{AnalyticsPage-BqV6mM2O.js → AnalyticsPage-Dkmb0dnd.js} +1 -1
  8. package/client/dist/assets/CodePage-C0dH99-t.js +3 -0
  9. package/client/dist/assets/{DesktopAnalyticsPage-D6WNax3K.js → DesktopAnalyticsPage-DuTjr2n8.js} +1 -1
  10. package/client/dist/assets/{DocsDialog-68jKUH9f.js → DocsDialog-BVZJZHVR.js} +1 -1
  11. package/client/dist/assets/{DocsPage-BoGJgRpm.js → DocsPage-D6KHTv8V.js} +1 -1
  12. package/client/dist/assets/{ExportDropdown-B2C_XLGp.js → ExportDropdown-pWZGYCv3.js} +1 -1
  13. package/client/dist/assets/InteractiveJobComposer-Ck3PQxjN.js +19 -0
  14. package/client/dist/assets/JobDetailModal-DkoMH3ie.js +1 -0
  15. package/client/dist/assets/JobDetailPage-CBVivcl2.js +1 -0
  16. package/client/dist/assets/JobsPage-CzxEeca9.js +1 -0
  17. package/client/dist/assets/{LoopBuilderPage-D21GxFmL.js → LoopBuilderPage-CtmOE4-b.js} +2 -2
  18. package/client/dist/assets/{LoopPreviewModal-Bo3fYFxN.js → LoopPreviewModal-BYeLjQbr.js} +1 -1
  19. package/client/dist/assets/{LoopsPage-BcrBw1cg.js → LoopsPage-6iW7g25k.js} +1 -1
  20. package/client/dist/assets/{MinimizedChatsContext-Dsv2eHf_.js → MinimizedChatsContext-B6wVevPq.js} +1 -1
  21. package/client/dist/assets/{PluginsPage-BbyTmSLx.js → PluginsPage-Bf1z3WPF.js} +2 -2
  22. package/client/dist/assets/{ProjectSettingsDialog-B3lEXLv7.js → ProjectSettingsDialog-DsCRIc4v.js} +1 -1
  23. package/client/dist/assets/{RepositoryDeliveries-ok_Wo65C.js → RepositoryDeliveries-BQm_3TO8.js} +1 -1
  24. package/client/dist/assets/{RepositoryScopeSelector-DU6QwNaS.js → RepositoryScopeSelector-DnAFtjTz.js} +1 -1
  25. package/client/dist/assets/{ReviewPacketPage-BBCeHtgd.js → ReviewPacketPage-CaXAwtR7.js} +1 -1
  26. package/client/dist/assets/{TemplatePreviewModal-CfNmxS6O.js → TemplatePreviewModal-9aRFpJ_J.js} +1 -1
  27. package/client/dist/assets/{TicketDetailModalContext-DW3eQ0I7.js → TicketDetailModalContext-B1X5glzV.js} +1 -1
  28. package/client/dist/assets/{Trans-BHPMWkOw.js → Trans-BwLgX6n7.js} +1 -1
  29. package/client/dist/assets/agentRuntime-BUagD2I_.js +1 -0
  30. package/client/dist/assets/agentRuntime-BkL70WtX.js +1 -0
  31. package/client/dist/assets/agentRuntime-CWXVqgqE.js +1 -0
  32. package/client/dist/assets/agentRuntime-CvNBdUAe.js +1 -0
  33. package/client/dist/assets/agentRuntime-D4RmeuNY.js +1 -0
  34. package/client/dist/assets/agentRuntime-DTidBWKg.js +1 -0
  35. package/client/dist/assets/agentRuntime-DhKYfyaU.js +1 -0
  36. package/client/dist/assets/agentRuntime-LL8_GLx9.js +1 -0
  37. package/client/dist/assets/format-duration-NJVPOPBG.js +1 -0
  38. package/client/dist/assets/{formatDistanceToNow-Bq8iptrG.js → formatDistanceToNow-CeZv93iR.js} +1 -1
  39. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BVFky3l_.js → getTimezoneOffsetInMilliseconds-DxAkjvJb.js} +1 -1
  40. package/client/dist/assets/index-Cfh9aaep.css +2 -0
  41. package/client/dist/assets/index-DYoyDWim.js +76 -0
  42. package/client/dist/assets/{jira-api-zrRdZFBB.js → jira-api-ChjVGJx_.js} +1 -1
  43. package/client/dist/assets/narration-4OSg0b5-.js +1 -0
  44. package/client/dist/assets/narration-BPMOQPFe.js +1 -0
  45. package/client/dist/assets/narration-BSldQfjd.js +1 -0
  46. package/client/dist/assets/narration-CvQdWdR5.js +1 -0
  47. package/client/dist/assets/narration-D-e9ssxA.js +1 -0
  48. package/client/dist/assets/narration-DKLjXIFI.js +1 -0
  49. package/client/dist/assets/narration-DnrKC3mF.js +1 -0
  50. package/client/dist/assets/narration-Dypjxeel.js +1 -0
  51. package/client/dist/assets/{project-repositories-CFRaB9UB.js → project-repositories-BvBj9XH9.js} +1 -1
  52. package/client/dist/assets/{provider-capabilities-B-wOHfLP.js → provider-capabilities-CXipwrXJ.js} +1 -1
  53. package/client/dist/assets/{settings-OOyDrZQo.js → settings-B3rWEwOx.js} +1 -1
  54. package/client/dist/assets/{settings-CNiERE-R.js → settings-BcVoiMyw.js} +1 -1
  55. package/client/dist/assets/settings-BlYvaQ7w.js +1 -0
  56. package/client/dist/assets/{settings-DJ2jl3hJ.js → settings-CD5uEBiL.js} +1 -1
  57. package/client/dist/assets/{settings-a7NIODme.js → settings-CyhS0DLJ.js} +1 -1
  58. package/client/dist/assets/{settings-DVRVybR_.js → settings-DCMFUGDs.js} +1 -1
  59. package/client/dist/assets/{settings-CrtCs5-B.js → settings-DEgcA0jd.js} +1 -1
  60. package/client/dist/assets/{settings-ChOCVjX_.js → settings-DmbYKyMJ.js} +1 -1
  61. package/client/dist/assets/{spending-lTZ-bm_y.js → spending-DNM6NAoA.js} +1 -1
  62. package/client/dist/assets/{useDesktop-Duv5ddAa.js → useDesktop-mxHVQOdx.js} +1 -1
  63. package/client/dist/assets/{useSharedWebSocket-5mj2SMKn.js → useSharedWebSocket-DnxHj8jP.js} +2 -2
  64. package/client/dist/index.html +19 -18
  65. package/docs/internals/programmatic-agent-runtime.md +19 -8
  66. package/package.json +1 -1
  67. package/server/dist/agent-mcp-config.js +5 -0
  68. package/server/dist/agent-operator-prompt.js +2 -2
  69. package/server/dist/agent-runtime-bridge.js +62 -14
  70. package/server/dist/agent-runtime-controls-router.js +42 -0
  71. package/server/dist/agent-runtime-controls.js +68 -4
  72. package/server/dist/agent-runtime-loader.js +6 -0
  73. package/server/dist/agent-runtime-metrics.js +46 -0
  74. package/server/dist/agent-runtime-repositories.js +37 -0
  75. package/server/dist/agent-runtime-settings-router.js +5 -3
  76. package/server/dist/agent-runtime-settings.js +125 -21
  77. package/server/dist/agent-runtime-settlement.js +133 -0
  78. package/server/dist/contract-refine-runner.js +7 -7
  79. package/server/dist/core-completion.js +8 -5
  80. package/server/dist/desktop-router.js +26 -0
  81. package/server/dist/job-listing.js +7 -3
  82. package/server/dist/loop-command-catalog.js +2 -2
  83. package/server/dist/loop-executors.js +5 -3
  84. package/server/dist/loop-run-manager.js +2 -2
  85. package/server/dist/mcp/tools/specs.js +7 -4
  86. package/server/dist/multi-repo-execution.js +1 -0
  87. package/server/dist/project-router-jobs.js +8 -2
  88. package/server/dist/project-router-tickets.js +4 -4
  89. package/server/dist/providers/runtime.js +6 -0
  90. package/server/dist/rail-isolated-launch.js +1 -1
  91. package/server/dist/rails-router.js +3 -1
  92. package/server/dist/runtime-role-prompts-router.js +25 -0
  93. package/server/dist/schemas/agent-runtime.schema.json +7 -2
  94. package/server/dist/worktree-overlay.js +32 -1
  95. package/client/dist/assets/AgentModeAnalyticsPane-BUvZNoj0.js +0 -2
  96. package/client/dist/assets/AgentModeCodePane-YW717UvJ.js +0 -2
  97. package/client/dist/assets/AgentModeJobsPane-Cxg_eNmd.js +0 -1
  98. package/client/dist/assets/AgentsPage-B_DCByok.js +0 -87
  99. package/client/dist/assets/CodePage-PyZKCj1B.js +0 -3
  100. package/client/dist/assets/InteractiveJobComposer-l8q-r5Yy.js +0 -19
  101. package/client/dist/assets/JobDetailModal-C-X2GI9j.js +0 -1
  102. package/client/dist/assets/JobDetailPage-C6Jdm7S9.js +0 -1
  103. package/client/dist/assets/JobsPage-DycM6Vgp.js +0 -1
  104. package/client/dist/assets/agentRuntime-14faDjWM.js +0 -1
  105. package/client/dist/assets/agentRuntime-BIokojkJ.js +0 -1
  106. package/client/dist/assets/agentRuntime-Bu4xEcRw.js +0 -1
  107. package/client/dist/assets/agentRuntime-Cz0ASixB.js +0 -1
  108. package/client/dist/assets/agentRuntime-DESdGXLV.js +0 -1
  109. package/client/dist/assets/agentRuntime-DhX5Xy5S.js +0 -1
  110. package/client/dist/assets/agentRuntime-Dpbh_gLp.js +0 -1
  111. package/client/dist/assets/agentRuntime-jS2lplFm.js +0 -1
  112. package/client/dist/assets/index-CBLYwPw6.css +0 -2
  113. package/client/dist/assets/index-DOKftX7y.js +0 -76
  114. package/client/dist/assets/narration-BA8Yw5ZL.js +0 -1
  115. package/client/dist/assets/narration-BuMFUS7Q.js +0 -1
  116. package/client/dist/assets/narration-Bv_iQQG5.js +0 -1
  117. package/client/dist/assets/narration-C6Ost9nL.js +0 -1
  118. package/client/dist/assets/narration-CIbjo2pA.js +0 -1
  119. package/client/dist/assets/narration-CNJ2-2-a.js +0 -1
  120. package/client/dist/assets/narration-OzsPJCXM.js +0 -1
  121. package/client/dist/assets/narration-zCdgiuSU.js +0 -1
  122. package/client/dist/assets/settings-DzXZCb1h.js +0 -1
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toolRepositories = toolRepositories;
4
+ const node_path_1 = require("node:path");
5
+ /** Attribute only explicit paths or repository roots present in a tool action.
6
+ * Never carry a previous action's repository into an unrelated tool call. */
7
+ function toolRepositories(event, repositories, defaultCwd) {
8
+ const found = new Set();
9
+ const cwd = typeof event.cwd === 'string' && (0, node_path_1.isAbsolute)(event.cwd) ? event.cwd : defaultCwd;
10
+ const paths = Array.isArray(event.targetPaths) ? event.targetPaths.filter((p) => typeof p === 'string') : [];
11
+ if (!paths.length && typeof event.detail === 'string' && (0, node_path_1.isAbsolute)(event.detail) && !event.detail.endsWith('…'))
12
+ paths.push(event.detail);
13
+ for (const target of paths) {
14
+ const absolute = (0, node_path_1.resolve)(cwd, target);
15
+ const matches = repositories.filter(repo => {
16
+ if (!repo.path)
17
+ return false;
18
+ const suffix = (0, node_path_1.relative)(repo.path, absolute);
19
+ return suffix === '' || (!(0, node_path_1.isAbsolute)(suffix) && suffix !== '..' && !suffix.startsWith('..' + node_path_1.sep));
20
+ }).sort((a, b) => b.path.length - a.path.length);
21
+ if (matches[0])
22
+ found.add(matches[0].id);
23
+ }
24
+ // Shell activity may contain `cd /repo && ...` or address several repositories.
25
+ if (!paths.length && typeof event.detail === 'string') {
26
+ for (const repo of repositories) {
27
+ if (!repo.path)
28
+ continue;
29
+ const escaped = repo.path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
+ if (new RegExp('(?:^|[\\s\\x22\\x27=])' + escaped + '(?=$|[\\s/\\\\\\x22\\x27;&])').test(event.detail))
31
+ found.add(repo.id);
32
+ }
33
+ }
34
+ if (!found.size && !paths.length && typeof event.cwd === 'string' && (0, node_path_1.isAbsolute)(event.cwd))
35
+ return toolRepositories({ targetPaths: [event.cwd] }, repositories, defaultCwd);
36
+ return repositories.filter(repo => found.has(repo.id));
37
+ }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerAgentRuntimeSettingsRoutes = registerAgentRuntimeSettingsRoutes;
4
+ const node_fs_1 = require("node:fs");
4
5
  const agent_runtime_loader_1 = require("./agent-runtime-loader");
5
6
  const agent_runtime_settings_1 = require("./agent-runtime-settings");
6
7
  const agent_runtime_verification_suggestions_1 = require("./agent-runtime-verification-suggestions");
@@ -9,8 +10,9 @@ function registerAgentRuntimeSettingsRoutes({ router, ctx }) {
9
10
  router.get('/:projectId/agent-runtime/config', (_req, res) => {
10
11
  try {
11
12
  const project = ctx(_req).project;
13
+ const configured = (0, node_fs_1.existsSync)((0, agent_runtime_settings_1.agentRuntimeConfigPath)(project));
12
14
  const config = (0, agent_runtime_settings_1.loadAgentRuntimeConfig)(project);
13
- res.json({ configured: config !== null, config: config ?? (0, agent_runtime_settings_1.defaultAgentRuntimeConfig)(project), runtimeAvailable: (0, agent_runtime_loader_1.findCoreAgentRuntimeEntry)() !== null });
15
+ res.json({ configured, config, runtimeAvailable: (0, agent_runtime_loader_1.findCoreAgentRuntimeEntry)() !== null });
14
16
  }
15
17
  catch (err) {
16
18
  const validation = err instanceof agent_runtime_settings_1.AgentRuntimeConfigError;
@@ -32,13 +34,13 @@ function registerAgentRuntimeSettingsRoutes({ router, ctx }) {
32
34
  });
33
35
  router.put('/:projectId/agent-runtime/config', async (req, res) => {
34
36
  try {
35
- const config = (0, agent_runtime_settings_1.validateAgentRuntimeConfig)(req.body);
37
+ (0, agent_runtime_settings_1.validateAgentRuntimeConfig)(req.body);
38
+ const config = (0, agent_runtime_settings_1.validateAgentRuntimeConfig)({ ...req.body, enabled: true, providers: (0, agent_runtime_settings_1.loadRuntimeProviders)() });
36
39
  const runtimeAvailable = (0, agent_runtime_loader_1.findCoreAgentRuntimeEntry)() !== null;
37
40
  if (config.enabled && !runtimeAvailable) {
38
41
  res.status(503).json({ error: 'runtime_unavailable', message: 'Update Core to an installation with the agent runtime before enabling it' });
39
42
  return;
40
43
  }
41
- // Disabling must remain possible even when an installed runtime is broken.
42
44
  if (config.enabled && runtimeAvailable) {
43
45
  try {
44
46
  (await (0, agent_runtime_loader_1.loadCoreAgentRuntime)()).validateRuntimeConfig(config);
@@ -7,10 +7,19 @@ exports.AgentRuntimeConfigError = exports.REVIEW_THRESHOLD_FLOORS = void 0;
7
7
  exports.validateAgentRuntimeConfig = validateAgentRuntimeConfig;
8
8
  exports.defaultAgentRuntimeConfig = defaultAgentRuntimeConfig;
9
9
  exports.agentRuntimeConfigPath = agentRuntimeConfigPath;
10
+ exports.runtimeProvidersPath = runtimeProvidersPath;
11
+ exports.loadRuntimeProviders = loadRuntimeProviders;
12
+ exports.validateRuntimeProviders = validateRuntimeProviders;
13
+ exports.saveRuntimeProviders = saveRuntimeProviders;
14
+ exports.loadRuntimeConfigFile = loadRuntimeConfigFile;
10
15
  exports.loadAgentRuntimeConfig = loadAgentRuntimeConfig;
11
16
  exports.saveAgentRuntimeConfig = saveAgentRuntimeConfig;
17
+ exports.validateRuntimeRolePrompts = validateRuntimeRolePrompts;
18
+ exports.loadRuntimeRolePrompts = loadRuntimeRolePrompts;
19
+ exports.saveRuntimeRolePrompts = saveRuntimeRolePrompts;
12
20
  const node_fs_1 = __importDefault(require("node:fs"));
13
21
  const node_path_1 = __importDefault(require("node:path"));
22
+ const node_os_1 = __importDefault(require("node:os"));
14
23
  const node_crypto_1 = require("node:crypto");
15
24
  const ajv_1 = __importDefault(require("ajv"));
16
25
  const agent_runtime_schema_json_1 = __importDefault(require("./schemas/agent-runtime.schema.json"));
@@ -35,6 +44,8 @@ function validateAgentRuntimeConfig(input) {
35
44
  throw new AgentRuntimeConfigError(`Invalid runtime configuration at ${error?.instancePath || '/'}: ${error?.message ?? 'invalid value'}`);
36
45
  }
37
46
  const config = input;
47
+ if (config.rolePrompts !== undefined)
48
+ validateRuntimeRolePrompts(config.rolePrompts);
38
49
  if (new Set(config.providers.map(({ id }) => id)).size !== config.providers.length)
39
50
  throw new AgentRuntimeConfigError('Provider IDs must be unique');
40
51
  for (const provider of config.providers) {
@@ -89,7 +100,7 @@ function validateAgentRuntimeConfig(input) {
89
100
  function defaultAgentRuntimeConfig(project) {
90
101
  const provider = CLI_PROVIDERS.includes(project.provider) ? project.provider : 'claude';
91
102
  return {
92
- schemaVersion: 1, enabled: false,
103
+ schemaVersion: 1, enabled: true,
93
104
  providers: CLI_PROVIDERS.map((cli) => ({ id: cli, kind: 'cli', cli })),
94
105
  agents: { architect: { provider }, developer: { provider }, reviewer: { provider } },
95
106
  verification: [],
@@ -98,38 +109,131 @@ function defaultAgentRuntimeConfig(project) {
98
109
  function agentRuntimeConfigPath(project) {
99
110
  return node_path_1.default.join((0, workspace_resolution_1.resolveProjectExecution)(project).specrailsDir, 'agent-runtime.json');
100
111
  }
101
- /** Missing configuration preserves legacy execution. Malformed files fail closed. */
102
- function loadAgentRuntimeConfig(project) {
103
- let text;
112
+ /** Global connections are resolved for new runs; admitted runs keep their snapshot. */
113
+ function runtimeProvidersPath() { return node_path_1.default.join(node_os_1.default.homedir(), '.specrails', 'runtime-providers.json'); }
114
+ function atomicJson(file, value) {
115
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
116
+ const temporary = `${file}.${(0, node_crypto_1.randomUUID)()}.tmp`;
117
+ try {
118
+ node_fs_1.default.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
119
+ node_fs_1.default.renameSync(temporary, file);
120
+ }
121
+ finally {
122
+ node_fs_1.default.rmSync(temporary, { force: true });
123
+ }
124
+ }
125
+ function loadRuntimeProviders() {
104
126
  try {
105
- text = node_fs_1.default.readFileSync(agentRuntimeConfigPath(project), 'utf8');
127
+ return validateRuntimeProviders(JSON.parse(node_fs_1.default.readFileSync(runtimeProvidersPath(), 'utf8')));
106
128
  }
107
- catch (err) {
108
- if (err.code === 'ENOENT')
109
- return null;
110
- throw err;
129
+ catch (error) {
130
+ if (error.code === 'ENOENT')
131
+ return defaultAgentRuntimeConfig({ path: '' }).providers;
132
+ throw error;
111
133
  }
134
+ }
135
+ function validateRuntimeProviders(input) {
136
+ if (!Array.isArray(input) || !input.length)
137
+ throw new AgentRuntimeConfigError('At least one provider connection is required');
138
+ const first = input[0];
139
+ const role = { provider: first?.id, ...(first?.kind === 'openai-compatible' ? { model: 'validation' } : {}) };
140
+ return validateAgentRuntimeConfig({ schemaVersion: 1, enabled: true, providers: input, agents: { architect: role, developer: role, reviewer: role }, verification: [] }).providers;
141
+ }
142
+ function saveRuntimeProviders(input) {
143
+ const providers = validateRuntimeProviders(input);
144
+ atomicJson(runtimeProvidersPath(), providers);
145
+ return providers;
146
+ }
147
+ /** Migrate embedded connections once. Conflicting IDs receive a stable ID so
148
+ * two projects with different endpoints never silently share a connection. */
149
+ function loadRuntimeConfigFile(file, fallbackProvider) {
112
150
  let parsed;
151
+ let text;
113
152
  try {
114
- parsed = JSON.parse(text);
153
+ text = node_fs_1.default.readFileSync(file, 'utf8');
154
+ }
155
+ catch (error) {
156
+ if (error.code !== 'ENOENT')
157
+ throw error;
158
+ }
159
+ if (text !== undefined) {
160
+ try {
161
+ parsed = JSON.parse(text);
162
+ }
163
+ catch {
164
+ throw new AgentRuntimeConfigError('Saved runtime configuration is not valid JSON');
165
+ }
166
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
167
+ throw new AgentRuntimeConfigError('Saved runtime configuration must be an object');
115
168
  }
116
- catch {
117
- throw new AgentRuntimeConfigError('Saved runtime configuration is not valid JSON');
169
+ const providers = loadRuntimeProviders();
170
+ if (!parsed) {
171
+ const config = defaultAgentRuntimeConfig({ path: '', provider: fallbackProvider });
172
+ const selected = providers.find(item => item.id === config.agents.architect.provider) ?? providers.find(item => item.kind === 'cli');
173
+ if (!selected || selected.kind !== 'cli')
174
+ throw new AgentRuntimeConfigError('Configure project role models before using API-only connections');
175
+ for (const role of ROLES)
176
+ config.agents[role].provider = selected.id;
177
+ return validateAgentRuntimeConfig({ ...config, providers });
178
+ }
179
+ if (Array.isArray(parsed.providers)) {
180
+ const legacy = validateAgentRuntimeConfig(parsed);
181
+ for (const original of legacy.providers) {
182
+ let connection = providers.find(item => item.id === original.id);
183
+ if (connection && JSON.stringify(connection) !== JSON.stringify(original)) {
184
+ const id = `${original.id.slice(0, 117)}-${(0, node_crypto_1.createHash)('sha256').update(JSON.stringify(original)).digest('hex').slice(0, 10)}`;
185
+ for (const role of ROLES)
186
+ if (legacy.agents[role].provider === original.id)
187
+ legacy.agents[role].provider = id;
188
+ connection = providers.find(item => item.id === id);
189
+ if (!connection)
190
+ providers.push({ ...original, id });
191
+ }
192
+ else if (!connection)
193
+ providers.push(original);
194
+ }
195
+ saveRuntimeProviders(providers);
196
+ const { providers: _embedded, enabled: _enabled, ...settings } = legacy;
197
+ atomicJson(file, settings);
198
+ parsed = settings;
118
199
  }
119
- return validateAgentRuntimeConfig(parsed);
200
+ return validateAgentRuntimeConfig({ ...parsed, enabled: true, providers });
201
+ }
202
+ /** Missing settings use the programmatic engine. Malformed files fail closed. */
203
+ function loadAgentRuntimeConfig(project) {
204
+ return loadRuntimeConfigFile(agentRuntimeConfigPath(project), project.provider);
120
205
  }
121
206
  /** Atomic replacement works on macOS and Windows, with no partial file on errors. */
122
207
  function saveAgentRuntimeConfig(project, input) {
123
- const config = validateAgentRuntimeConfig(input);
208
+ const config = validateAgentRuntimeConfig({ ...input, enabled: true, providers: loadRuntimeProviders() });
124
209
  const file = agentRuntimeConfigPath(project);
125
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
126
- const temporary = `${file}.${(0, node_crypto_1.randomUUID)()}.tmp`;
210
+ const { providers: _providers, enabled: _enabled, ...settings } = config;
211
+ atomicJson(file, settings);
212
+ return config;
213
+ }
214
+ function validateRuntimeRolePrompts(input) {
215
+ if (!input || typeof input !== 'object' || Array.isArray(input))
216
+ throw new AgentRuntimeConfigError('Role prompts must be an object');
217
+ const prompts = {};
218
+ for (const [role, text] of Object.entries(input)) {
219
+ if (!ROLES.includes(role) || typeof text !== 'string' || !text.trim() || text.length > 20000 || text.includes('\0'))
220
+ throw new AgentRuntimeConfigError('Role prompts must use architect, developer or reviewer with 1–20000 nonblank characters and no null bytes');
221
+ prompts[role] = text;
222
+ }
223
+ return prompts;
224
+ }
225
+ function loadRuntimeRolePrompts() {
127
226
  try {
128
- node_fs_1.default.writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
129
- node_fs_1.default.renameSync(temporary, file);
227
+ return validateRuntimeRolePrompts(JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(node_os_1.default.homedir(), '.specrails', 'runtime-role-prompts.json'), 'utf8')));
130
228
  }
131
- finally {
132
- node_fs_1.default.rmSync(temporary, { force: true });
229
+ catch (error) {
230
+ if (error.code === 'ENOENT')
231
+ return {};
232
+ throw error;
133
233
  }
134
- return config;
234
+ }
235
+ function saveRuntimeRolePrompts(input) {
236
+ const prompts = validateRuntimeRolePrompts(input);
237
+ atomicJson(node_path_1.default.join(node_os_1.default.homedir(), '.specrails', 'runtime-role-prompts.json'), prompts);
238
+ return prompts;
135
239
  }
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.settleRuntimeContinuation = settleRuntimeContinuation;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_crypto_1 = require("node:crypto");
9
+ const db_1 = require("./db");
10
+ const core_execution_1 = require("./core-execution");
11
+ const core_completion_1 = require("./core-completion");
12
+ const worktree_manager_1 = require("./worktree-manager");
13
+ const rail_pr_store_1 = require("./rail-pr-store");
14
+ const multi_repo_execution_1 = require("./multi-repo-execution");
15
+ /** Complete the host's local settlement after Core recovery. Never pushes or merges. */
16
+ async function settleRuntimeContinuation(input, dependencies = { verify: core_execution_1.checkCoreCompletion, git: worktree_manager_1.defaultGitRunner, commit: worktree_manager_1.commitWorktreeAndVerify }) {
17
+ const { db, runId } = input;
18
+ const verified = dependencies.verify(input.contextPath, input.cwd, input.env, runId);
19
+ if (!verified.valid)
20
+ throw new Error(verified.reason ?? 'Core completion is not verified');
21
+ const frozen = JSON.parse(node_fs_1.default.readFileSync(input.contextPath, 'utf8'));
22
+ if (frozen.runId !== runId)
23
+ throw new Error('Runtime identity changed');
24
+ const all = db.prepare('SELECT * FROM rail_pr_deliveries').all().filter(row => (0, rail_pr_store_1.toPrDeliverySnapshot)(row).runIds.includes(runId));
25
+ const parents = new Set(all.map(row => row.parent_delivery_id).filter((id) => !!id));
26
+ const rows = all.filter(row => !parents.has(row.id) && row.decision === 'implementation_failed');
27
+ if (all.some(row => parents.has(row.id) && ['discarded', 'superseded', 'merged', 'completed'].includes(row.decision)))
28
+ throw new Error('Delivery is no longer available for recovery');
29
+ const token = (0, node_crypto_1.randomUUID)(), claimed = [];
30
+ const updates = [];
31
+ const gitText = async (args, cwd) => {
32
+ const result = await dependencies.git.run(args, cwd);
33
+ if (result.code !== 0)
34
+ throw new Error(`Cannot inspect recovered worktree: ${result.stderr}`);
35
+ return result.stdout.trim();
36
+ };
37
+ try {
38
+ // Claim the entire group before Git effects; a concurrent discard must win or lose atomically.
39
+ db.transaction(() => {
40
+ for (const row of all.filter(row => parents.has(row.id) || rows.includes(row))) {
41
+ if (!(0, rail_pr_store_1.claimPrDeliveryOperation)(db, row.id, row.decision, 'recover-and-retry', token))
42
+ throw new Error('Delivery is busy; retry settlement after its current operation');
43
+ claimed.push(row);
44
+ }
45
+ })();
46
+ for (const row of rows) {
47
+ if (row.pr_url)
48
+ throw new Error('An existing PR requires explicit delivery recovery');
49
+ if (!(0, rail_pr_store_1.toPrDeliverySnapshot)(row).branches.some(unit => unit.runId === runId))
50
+ throw new Error('Delivery has no branch recorded for this runtime');
51
+ for (const unit of (0, rail_pr_store_1.toPrDeliverySnapshot)(row).branches.filter(unit => unit.runId === runId)) {
52
+ if (!unit.worktreePath || !frozen.repositories.some(repo => repo.path === unit.worktreePath) || node_fs_1.default.realpathSync(unit.worktreePath) !== unit.worktreePath)
53
+ throw new Error('Recovered worktree no longer matches the frozen runtime scope');
54
+ if (await gitText(['branch', '--show-current'], unit.worktreePath) !== unit.branch)
55
+ throw new Error('Recovered worktree branch changed');
56
+ }
57
+ }
58
+ for (const row of rows) {
59
+ const branches = (0, rail_pr_store_1.toPrDeliverySnapshot)(row).branches;
60
+ for (const unit of branches.filter(unit => unit.runId === runId)) {
61
+ const result = await dependencies.commit(dependencies.git, unit.worktreePath, `specrails: ticket-${unit.ticketId} recovered (run ${runId})`, unit.overlayExcludes ?? []);
62
+ if (!result.clean || result.error)
63
+ throw new Error(result.error ?? 'Recovered worktree still contains uncommitted changes');
64
+ const sha = await gitText(['rev-parse', 'HEAD'], unit.worktreePath);
65
+ if (!/^[a-f0-9]{40,64}$/i.test(sha))
66
+ throw new Error('Invalid recovered commit');
67
+ Object.assign(unit, { succeeded: true, implementationOutcome: 'succeeded', deliveryOutcome: sha !== unit.initialSha ? 'ready' : 'no_changes', finalSha: sha, changed: sha !== unit.initialSha, failureCode: null });
68
+ }
69
+ updates.push({ row, branches });
70
+ }
71
+ const finalCheck = dependencies.verify(input.contextPath, input.cwd, input.env, runId);
72
+ if (!finalCheck.valid)
73
+ throw new Error(finalCheck.reason ?? 'Candidate changed during settlement');
74
+ const core = await (0, core_completion_1.readCoreCompletion)(input);
75
+ db.transaction(() => {
76
+ for (const { row, branches } of updates) {
77
+ const ready = branches.every(unit => unit.succeeded);
78
+ const noChanges = ready && branches.every(unit => unit.changed === false);
79
+ if (!(0, rail_pr_store_1.transitionClaimedDecision)(db, row.id, row.decision, noChanges ? 'no_changes' : 'on_review', token, {
80
+ branches, implementationOutcome: ready ? 'succeeded' : 'partially_succeeded', deliveryOutcome: noChanges ? 'no_changes' : 'ready',
81
+ statusCode: noChanges ? 'no_changes' : ready ? 'ready_for_review' : 'partial_success', statusDetail: null,
82
+ ...(branches.length === 1 ? { deliverySha: branches[0].finalSha } : {}),
83
+ }))
84
+ throw new Error('Delivery changed during runtime settlement');
85
+ for (const unit of branches.filter(unit => unit.runId === runId))
86
+ db.prepare("UPDATE rail_worktrees SET merge_state = 'built', updated_at = datetime('now') WHERE run_id = ? AND worktree_path = ?").run(runId, unit.worktreePath);
87
+ }
88
+ for (const parentId of parents)
89
+ (0, multi_repo_execution_1.refreshRepositoryDeliveryGroup)(db, parentId);
90
+ db.prepare("UPDATE jobs SET status = 'completed', exit_code = 0 WHERE id = ?").run(runId);
91
+ db.prepare("UPDATE loop_runs SET status = 'completed', final_outcome = 'success' WHERE id = ?").run(runId);
92
+ let seq = db.prepare('SELECT COALESCE(MAX(seq), -1) + 1 AS seq FROM events WHERE job_id = ?').get(runId).seq;
93
+ const lastStep = db.prepare("SELECT payload FROM events WHERE job_id = ? AND event_type = 'loop_step' ORDER BY seq DESC LIMIT 1").get(runId);
94
+ const lastEnd = db.prepare("SELECT payload FROM events WHERE job_id = ? AND event_type = 'loop_step_end' ORDER BY seq DESC LIMIT 1").get(runId);
95
+ if (lastStep && lastEnd) {
96
+ const step = JSON.parse(lastStep.payload), end = JSON.parse(lastEnd.payload);
97
+ // Supersede only this implementation step, never an unrelated shell/decider failure.
98
+ if (step.kind === 'ai-step' && step.template === '{{cmd:implement}}' && end.index === step.index && end.nodeId === step.nodeId && end.status === 'failed') {
99
+ const job = db.prepare('SELECT duration_ms FROM jobs WHERE id = ?').get(runId);
100
+ (0, db_1.appendEvent)(db, runId, seq++, { event_type: 'loop_step_end', source: 'stdout', payload: JSON.stringify({ index: step.index, nodeId: step.nodeId, status: 'ok', exitCode: 0, durationMs: job.duration_ms ?? end.durationMs, recovered: true }) });
101
+ }
102
+ }
103
+ const oldCompletion = db.prepare("SELECT payload FROM events WHERE job_id = ? AND event_type = 'loop_completion' ORDER BY seq DESC LIMIT 1").get(runId);
104
+ let previous = {};
105
+ try {
106
+ previous = oldCompletion ? JSON.parse(oldCompletion.payload) : {};
107
+ }
108
+ catch { /* old event format */ }
109
+ const usage = db.prepare('SELECT total_cost_usd,num_turns FROM jobs WHERE id = ?').get(runId);
110
+ (0, db_1.appendEvent)(db, runId, seq, { event_type: 'loop_completion', source: 'stdout', payload: JSON.stringify({ ...previous, version: 1, execution: 'success', core, turns: usage.num_turns, costUsd: usage.total_cost_usd, recovered: true }) });
111
+ (0, db_1.appendEvent)(db, runId, seq + 1, { event_type: 'log', source: 'stdout', payload: JSON.stringify({ line: '[runtime] Recovery verified and local delivery prepared for review. The original failed attempt remains in history. No PR was published.' }) });
112
+ })();
113
+ }
114
+ finally {
115
+ for (const row of claimed)
116
+ (0, rail_pr_store_1.releasePrDeliveryOperation)(db, row.id, token);
117
+ }
118
+ for (const id of new Set([...updates.map(update => update.row.id), ...parents])) {
119
+ const row = (0, rail_pr_store_1.getPrDelivery)(db, id);
120
+ try {
121
+ if (row)
122
+ input.broadcast?.((0, rail_pr_store_1.toRailPrStateMessage)(input.projectId, (0, rail_pr_store_1.toPrDeliverySnapshot)(row)));
123
+ }
124
+ catch { /* durable state remains authoritative */ }
125
+ }
126
+ const events = db.prepare('SELECT seq,event_type,source,payload,timestamp FROM events WHERE job_id = ? ORDER BY seq DESC LIMIT 3').all(runId);
127
+ for (const event of events.reverse()) {
128
+ try {
129
+ input.broadcast?.({ type: 'event', projectId: input.projectId, jobId: runId, ...event });
130
+ }
131
+ catch { /* replay remains available */ }
132
+ }
133
+ }
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Contract Refine Runner
4
4
  *
5
- * Standalone runner that spawns a single Claude turn to produce the
5
+ * Standalone runner that spawns a single provider 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
8
  * the refine is fire-and-forget with a 60 s budget per invocation and no
@@ -82,12 +82,12 @@ function buildRefineArgs(adapter, model, systemPrompt, sessionId) {
82
82
  model,
83
83
  systemPrompt: foldForResume ? undefined : systemPrompt,
84
84
  sessionId,
85
- toolPolicy: 'none',
85
+ toolPolicy: (0, runtime_1.pureOutputToolPolicy)(adapter) ?? (0, runtime_1.requireToolPolicy)(adapter, 'none'),
86
86
  maxTurns: 1,
87
87
  };
88
88
  return { args: adapter.buildArgs(action, options), options };
89
89
  }
90
- /** Build the pure-output, no-resume invocation used by Quick Refine and by the
90
+ /** Build the restricted pure-output, no-resume invocation used by Quick Refine and by the
91
91
  * one-time compatibility recovery for cwd-scoped Explore sessions. */
92
92
  function buildFreshRefineArgs(adapter, model, title, description) {
93
93
  const systemPrompt = [
@@ -105,7 +105,7 @@ function buildFreshRefineArgs(adapter, model, title, description) {
105
105
  prompt: explore_contract_refine_1.CONTRACT_MARKER_USER_MESSAGE,
106
106
  model,
107
107
  systemPrompt,
108
- toolPolicy: 'none',
108
+ toolPolicy: (0, runtime_1.pureOutputToolPolicy)(adapter) ?? (0, runtime_1.requireToolPolicy)(adapter, 'none'),
109
109
  maxTurns: 1,
110
110
  };
111
111
  return { args: adapter.buildArgs('spec-gen', options), options };
@@ -362,8 +362,8 @@ async function runContractRefine(deps, conversationId, ticketId) {
362
362
  return { ok: false, reason: 'not-explore', ticketId, conversationId };
363
363
  }
364
364
  const adapter = (0, providers_1.getAdapter)(conversation.provider ?? deps.providerId ?? 'claude');
365
- if (adapter.capabilities.structuredActions !== true) {
366
- console.log(`[contract-refine-runner] skip: provider '${adapter.id}' does not support structured actions`);
365
+ if (!(0, runtime_1.supportsContractRefine)(adapter)) {
366
+ console.log(`[contract-refine-runner] skip: provider '${adapter.id}' does not support contract refinement`);
367
367
  return { ok: false, reason: 'provider-unsupported', ticketId, conversationId };
368
368
  }
369
369
  // Per-conversation gating: contractRefine on the conversation's stored
@@ -602,7 +602,7 @@ async function runContractRefineForQuick(deps, ticketId, generatedTitle, generat
602
602
  }
603
603
  const admission = (0, process_admission_1.captureProcessAdmission)(deps.projectId);
604
604
  const adapter = (0, providers_1.getAdapter)(deps.providerId ?? 'claude');
605
- if (adapter.capabilities.structuredActions !== true) {
605
+ if (!(0, runtime_1.supportsContractRefine)(adapter)) {
606
606
  return { ok: false, reason: 'provider-unsupported', ticketId, conversationId: '' };
607
607
  }
608
608
  const resolvedModel = model ?? adapter.defaultModel();
@@ -6,7 +6,8 @@ const node_child_process_1 = require("node:child_process");
6
6
  const node_fs_1 = require("node:fs");
7
7
  const node_path_1 = require("node:path");
8
8
  const node_util_1 = require("node:util");
9
- const path_resolver_1 = require("./path-resolver");
9
+ const core_node_runtime_1 = require("./core-node-runtime");
10
+ const agent_runtime_loader_1 = require("./agent-runtime-loader");
10
11
  const obj = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
11
12
  const strings = (value) => Array.isArray(value) && value.every(item => typeof item === 'string');
12
13
  const count = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
@@ -43,14 +44,16 @@ function parseCoreCompletion(value, runId) {
43
44
  }), };
44
45
  }
45
46
  async function readCoreCompletion(input) {
46
- const runtime = (0, node_path_1.join)(input.cwd, '.specrails', 'runtime', 'pipeline.mjs');
47
- if (!(0, node_fs_1.existsSync)(runtime))
47
+ const programmatic = (0, node_fs_1.existsSync)((0, node_path_1.join)((0, node_path_1.dirname)(input.contextPath), 'agent-runtime-request.json'));
48
+ const runtime = programmatic ? (0, agent_runtime_loader_1.findCoreAgentRuntimeCli)() : (0, node_path_1.join)(input.cwd, '.specrails', 'runtime', 'pipeline.mjs');
49
+ if (!runtime || !(0, node_fs_1.existsSync)(runtime))
48
50
  return null;
49
51
  try {
50
- const { stdout } = await (0, node_util_1.promisify)(node_child_process_1.execFile)((0, path_resolver_1.resolveBundledNodeExe)() ?? process.execPath, [runtime, 'status', '--context', input.contextPath], {
52
+ const { stdout } = await (0, node_util_1.promisify)(node_child_process_1.execFile)((0, core_node_runtime_1.resolveCoreNodeRuntime)(), [runtime, 'status', '--context', input.contextPath], {
51
53
  cwd: input.cwd, env: input.env, encoding: 'utf8', timeout: 15_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
52
54
  });
53
- return parseCoreCompletion(JSON.parse(stdout), input.runId);
55
+ const payload = JSON.parse(stdout);
56
+ return parseCoreCompletion(programmatic ? payload.pipeline : payload, input.runId);
54
57
  }
55
58
  catch {
56
59
  return null;
@@ -4,7 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createDesktopRouter = createDesktopRouter;
7
+ const runtime_role_prompts_router_1 = require("./runtime-role-prompts-router");
7
8
  const express_1 = require("express");
9
+ const agent_runtime_settings_1 = require("./agent-runtime-settings");
8
10
  const crypto_1 = require("crypto");
9
11
  const path_1 = __importDefault(require("path"));
10
12
  const fs_1 = __importDefault(require("fs"));
@@ -1227,6 +1229,30 @@ function createDesktopRouter(registry, broadcast) {
1227
1229
  const monthlyBudgetUsd = Number.isFinite(parsed) && parsed >= 0 ? parsed : 5.0;
1228
1230
  res.json({ language, monthlyBudgetUsd });
1229
1231
  });
1232
+ (0, runtime_role_prompts_router_1.registerRuntimeRolePromptRoutes)(router);
1233
+ router.get('/runtime-providers', (_req, res) => {
1234
+ try {
1235
+ for (const project of (0, desktop_db_1.listProjects)(registry.desktopDb))
1236
+ (0, agent_runtime_settings_1.loadAgentRuntimeConfig)(project);
1237
+ res.json({ providers: (0, agent_runtime_settings_1.loadRuntimeProviders)() });
1238
+ }
1239
+ catch (error) {
1240
+ res.status(422).json({ message: error.message });
1241
+ }
1242
+ });
1243
+ router.put('/runtime-providers', (req, res) => {
1244
+ try {
1245
+ const projects = (0, desktop_db_1.listProjects)(registry.desktopDb).map(project => (0, agent_runtime_settings_1.loadAgentRuntimeConfig)(project));
1246
+ const providers = (0, agent_runtime_settings_1.validateRuntimeProviders)(req.body.providers);
1247
+ for (const config of projects)
1248
+ if (config)
1249
+ (0, agent_runtime_settings_1.validateAgentRuntimeConfig)({ ...config, providers });
1250
+ res.json({ providers: (0, agent_runtime_settings_1.saveRuntimeProviders)(providers) });
1251
+ }
1252
+ catch (error) {
1253
+ res.status(error instanceof agent_runtime_settings_1.AgentRuntimeConfigError ? 400 : 500).json({ message: error.message });
1254
+ }
1255
+ });
1230
1256
  // ─── Specrails Agents defaults (global per-provider agent model/effort) ───
1231
1257
  // App-level layer read AT SPAWN TIME by QueueManager / rails-router / loop
1232
1258
  // executors — a change applies to the next run with zero restart.
@@ -37,7 +37,9 @@ function historyConditions(opts) {
37
37
  const conditions = ["jobs.status <> 'queued'"];
38
38
  const params = [];
39
39
  if (opts.status) {
40
- conditions.push('jobs.status = ?');
40
+ const active = opts.activeRuntimeIds ?? [];
41
+ conditions.push(active.length ? `CASE WHEN jobs.id IN (${active.map(() => '?').join(',')}) THEN 'running' ELSE jobs.status END = ?` : 'jobs.status = ?');
42
+ params.push(...active);
41
43
  params.push(opts.status);
42
44
  }
43
45
  if (opts.from) {
@@ -118,14 +120,16 @@ function listUnifiedJobs(db, opts) {
118
120
  const remaining = limit - queuedRows.length;
119
121
  const historyOffset = Math.max(0, offset - queuedTotal);
120
122
  if (remaining > 0 && historyOffset < historyTotal) {
123
+ const active = opts.activeRuntimeIds ?? [];
124
+ const activeOrder = active.length ? `CASE WHEN jobs.id IN (${active.map(() => '?').join(',')}) THEN 0 ELSE 1 END, ` : '';
121
125
  historyRows = db.prepare(`
122
126
  SELECT jobs.*, jp.profile_name AS profile_name, NULL AS enqueued_at
123
127
  FROM jobs
124
128
  LEFT JOIN job_profiles jp ON jp.job_id = jobs.id
125
129
  ${history.sql}
126
- ORDER BY jobs.started_at DESC, jobs.id ASC
130
+ ORDER BY ${activeOrder}jobs.started_at DESC, jobs.id ASC
127
131
  LIMIT ? OFFSET ?
128
- `).all(...history.params, remaining, historyOffset);
132
+ `).all(...history.params, ...active, remaining, historyOffset);
129
133
  }
130
134
  }
131
135
  return { jobs: [...queuedRows, ...historyRows], total: queuedTotal + historyTotal };
@@ -68,14 +68,14 @@ exports.LOOP_COMMANDS = [
68
68
  {
69
69
  name: 'implement',
70
70
  label: 'implement',
71
- description: "Run the specrails implement pipeline (architect → developer → reviewer) over the rail's tickets, via the native /specrails:implement command.",
71
+ description: "Run Core’s programmatic agent workflow (architect → developer → verification → reviewer → archive) over the rail's tickets.",
72
72
  coreCommand: 'implement',
73
73
  ticketScope: 'all',
74
74
  },
75
75
  {
76
76
  name: 'batch',
77
77
  label: 'batch',
78
- description: "Run the batch-implement pipeline over ALL the rail's tickets in one pass (parallel internally).",
78
+ description: "Run all selected tickets as one frozen candidate through Core’s programmatic agent workflow.",
79
79
  coreCommand: 'batch-implement',
80
80
  ticketScope: 'all',
81
81
  },
@@ -234,17 +234,19 @@ function createLoopExecutors(opts = {}) {
234
234
  // agent self-heal on Windows.
235
235
  const baseEnv = resolveEnv();
236
236
  const existingContext = coreRun ? runtimeContextPath(cwd, coreRun.runId, baseEnv) : undefined;
237
- const programmatic = coreRun && existingContext && ((coreRun.implementation && (0, agent_runtime_bridge_1.selectAgentRuntime)(runtimeConfigPath(cwd), existingContext)) || (coreRun.verificationStep && existsRuntimeRequest(existingContext)));
237
+ const programmatic = coreRun && existingContext && (coreRun.implementation || (coreRun.verificationStep && existsRuntimeRequest(existingContext)));
238
238
  const baseStepEnv = programmatic ? programmaticStepEnv(baseEnv, repoDir, executionManifest) : withProfileEnv(aiStepEnv(baseEnv, repoDir, executionManifest), provider, profileName);
239
239
  const core = coreRun ? (0, core_execution_1.prepareCoreExecution)({ run: coreRun, cwd, repoDir, manifest: executionManifest, env: baseStepEnv }) : undefined;
240
240
  const stepEnv = core?.env ?? baseStepEnv;
241
- if (coreRun?.implementation && core && (0, agent_runtime_bridge_1.selectAgentRuntime)(runtimeConfigPath(cwd), core.contextPath)) {
241
+ if (coreRun?.implementation && core) {
242
+ completionContexts.set(coreRun.runId, { cwd, contextPath: core.contextPath, env: stepEnv, runId: coreRun.runId });
242
243
  const admitted = existsRuntimeRequest(core.contextPath);
243
244
  // Repeated implementation nodes may validate completed work, but may
244
245
  // never silently replay an interrupted mutating phase.
245
246
  return (0, agent_runtime_bridge_1.runAgentRuntimeInvocation)({
246
247
  contextPath: core.contextPath, cwd, env: { ...programmaticStepEnv(resolveEnv(), repoDir, executionManifest), SPECRAILS_EXECUTION_CONTEXT: core.contextPath }, configPath: runtimeConfigPath(cwd),
247
248
  change: (0, agent_runtime_bridge_1.runtimeChangeName)(coreRun.runId), resume: admitted,
249
+ defaultProvider: provider, selectedModel: model,
248
250
  onLine, onRawLine, onSpawn, timeoutMs: aiStepTimeoutMs,
249
251
  });
250
252
  }
@@ -464,7 +466,7 @@ function createLoopExecutors(opts = {}) {
464
466
  const env = resolveEnv();
465
467
  const root = env.SPECRAILS_TICKETS_PATH ? (0, node_path_1.dirname)((0, node_path_1.dirname)(env.SPECRAILS_TICKETS_PATH)) : cwd;
466
468
  const contextPath = (0, node_path_1.join)(root, '.specrails', 'pipeline', coreRun.runId, 'desktop-context.json');
467
- if ((coreRun.implementation && (0, agent_runtime_bridge_1.selectAgentRuntime)(runtimeConfigPath(cwd), contextPath)) || existsRuntimeRequest(contextPath))
469
+ if (coreRun.implementation || existsRuntimeRequest(contextPath))
468
470
  return null;
469
471
  }
470
472
  if (!(0, feature_flags_1.isInteractiveJobsEnabled)())
@@ -1099,7 +1099,7 @@ class LoopRunManager {
1099
1099
  // were noise once the step explorer landed; the log now opens on real
1100
1100
  // output). `template` is omitted when rendering changed nothing (a
1101
1101
  // plain free-text prompt would just duplicate `command`).
1102
- const stepTitle = `🤖 ${nodeLabel || 'AI Step'} (${nodeProvider}/${nodeModel}${nodeEffort ? `, effort: ${nodeEffort}` : ''})`;
1102
+ const stepTitle = requiresCoreCompletion ? '🤖 Implementation (agent runtime)' : `🤖 ${nodeLabel || 'AI Step'} (${nodeProvider}/${nodeModel}${nodeEffort ? `, effort: ${nodeEffort}` : ''})`;
1103
1103
  const stepDetail = {
1104
1104
  ...(rawTemplate && rawTemplate !== base ? { template: capText(rawTemplate, STEP_TEMPLATE_CAP) } : {}),
1105
1105
  ...(base ? { command: capText(base, STEP_COMMAND_CAP) } : {}),
@@ -1691,7 +1691,7 @@ class LoopRunManager {
1691
1691
  // `≥` when any cost-bearing step ended unpriced (timeout/crash) — the figure
1692
1692
  // is a lower bound, not exact. Providers without usage telemetry get an
1693
1693
  // explicit unavailable marker, never a fabricated "$0.0000".
1694
- logLine(`\n■ Loop execution finished: ${outcome} — ${stepNum} step${stepNum === 1 ? '' : 's'}, ${iteration} decider evaluation${iteration === 1 ? '' : 's'}, ${finalJobUsage.numTurns ?? 'unknown'} agent turns, ` +
1694
+ logLine(`\n■ Loop execution finished: ${outcome} — ` +
1695
1695
  (costAvailable
1696
1696
  ? `${costUncertain ? '≥ ' : ''}$${totalCost.toFixed(4)}`
1697
1697
  : 'usage/cost unavailable'));