specrails-desktop 2.2.0 → 2.3.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 (63) hide show
  1. package/client/dist/assets/ActivityFeedPage-3Veccrvk.js +1 -0
  2. package/client/dist/assets/AgentsPage-2mFPghP4.js +86 -0
  3. package/client/dist/assets/{AnalyticsPage-D6LE6wG2.js → AnalyticsPage-Dyyz1ht3.js} +1 -1
  4. package/client/dist/assets/{BarChart-B366kDEj.js → BarChart-CMdLa6Es.js} +2 -2
  5. package/client/dist/assets/CodePage-D7Xwjhut.js +2 -0
  6. package/client/dist/assets/{DesktopAnalyticsPage-DG5LA_WO.js → DesktopAnalyticsPage-CTNwb639.js} +1 -1
  7. package/client/dist/assets/{DocsDialog-ChQ1oXLC.js → DocsDialog-D8yoyZDD.js} +2 -2
  8. package/client/dist/assets/{DocsPage-BfGH8NUf.js → DocsPage-CeO-fAxy.js} +2 -2
  9. package/client/dist/assets/{ExportDropdown-9tRrlfM7.js → ExportDropdown-DuoZcdYN.js} +1 -1
  10. package/client/dist/assets/{IntegrationsPage-DANIzihd.js → IntegrationsPage-iIZ0UEzf.js} +3 -3
  11. package/client/dist/assets/JobDetailPage-DgJHAH2m.js +16 -0
  12. package/client/dist/assets/JobsPage-Bv_RpRAE.js +1 -0
  13. package/client/dist/assets/code-BtsmPQLV.js +1 -0
  14. package/client/dist/assets/code-CY85RXZU.js +1 -0
  15. package/client/dist/assets/code-Coa8f2Sh.js +1 -0
  16. package/client/dist/assets/code-D1z-YDt-.js +1 -0
  17. package/client/dist/assets/code-DDU0CRS0.js +1 -0
  18. package/client/dist/assets/code-L35Loak_.js +1 -0
  19. package/client/dist/assets/code-g0qFMzyg.js +1 -0
  20. package/client/dist/assets/code-zCwBt3Uu.js +1 -0
  21. package/client/dist/assets/{dist-js-BvQ52Q67.js → dist-js-4UEGaKhD.js} +1 -1
  22. package/client/dist/assets/{dist-js-XEilFTNz.js → dist-js-H6hyhSuv.js} +1 -1
  23. package/client/dist/assets/{index-CNiaj7Sj.js → index-CGHKpC-N.js} +13 -13
  24. package/client/dist/assets/index-D17R4Cjc.css +2 -0
  25. package/client/dist/assets/{lib-DZJmnErt.js → lib-Cs5FrUJI.js} +1 -1
  26. package/client/dist/assets/{useProjectCache-H0T8Ot9j.js → useProjectCache-BZWYV-w-.js} +1 -1
  27. package/client/dist/index.html +3 -3
  28. package/package.json +1 -1
  29. package/server/dist/agent-refine-manager.js +128 -153
  30. package/server/dist/chat-manager.js +246 -0
  31. package/server/dist/code-explorer-router.js +78 -0
  32. package/server/dist/command-resolver.js +17 -0
  33. package/server/dist/contract-refine-runner.js +42 -10
  34. package/server/dist/db.js +6 -0
  35. package/server/dist/desktop-db.js +3 -0
  36. package/server/dist/explore-stdin-session.js +129 -0
  37. package/server/dist/mobile/mobile-auth.js +16 -0
  38. package/server/dist/project-router-chat.js +218 -0
  39. package/server/dist/project-router-helpers.js +275 -0
  40. package/server/dist/project-router-jobs.js +389 -0
  41. package/server/dist/project-router-settings.js +312 -0
  42. package/server/dist/project-router-setup.js +456 -0
  43. package/server/dist/project-router-spending.js +320 -0
  44. package/server/dist/project-router-terminals.js +312 -0
  45. package/server/dist/project-router-tickets.js +1767 -0
  46. package/server/dist/project-router.js +27 -3943
  47. package/server/dist/providers/claude-adapter.js +58 -17
  48. package/server/dist/providers/codex-adapter.js +6 -0
  49. package/server/dist/spawn-lifecycle.js +117 -0
  50. package/client/dist/assets/ActivityFeedPage-BupGdGjj.js +0 -1
  51. package/client/dist/assets/AgentsPage-F3xksiLd.js +0 -86
  52. package/client/dist/assets/CodePage-DLwCJgQ0.js +0 -2
  53. package/client/dist/assets/JobDetailPage-1RtejIOB.js +0 -16
  54. package/client/dist/assets/JobsPage-NuDf5Zbx.js +0 -1
  55. package/client/dist/assets/code-AL1rVIMb.js +0 -1
  56. package/client/dist/assets/code-C0BKpkht.js +0 -1
  57. package/client/dist/assets/code-C0FTS3ew.js +0 -1
  58. package/client/dist/assets/code-CPcHxzxw.js +0 -1
  59. package/client/dist/assets/code-D3ryDniw.js +0 -1
  60. package/client/dist/assets/code-D3zVVQTj.js +0 -1
  61. package/client/dist/assets/code-PCmfS3dn.js +0 -1
  62. package/client/dist/assets/code-exI0G5Wd.js +0 -1
  63. package/client/dist/assets/index-DgFfrrTX.css +0 -2
@@ -0,0 +1,456 @@
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.registerSetupRoutes = registerSetupRoutes;
7
+ // Domain routes extracted from project-router.ts (setup).
8
+ // Registered on the shared router by createProjectRouter — behaviour-preserving.
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const ids_1 = require("./ids");
12
+ const db_1 = require("./db");
13
+ const desktop_db_1 = require("./desktop-db");
14
+ const queue_manager_1 = require("./queue-manager");
15
+ const command_resolver_1 = require("./command-resolver");
16
+ const changes_reader_1 = require("./changes-reader");
17
+ const project_router_helpers_1 = require("./project-router-helpers");
18
+ function registerSetupRoutes(deps) {
19
+ const { router, registry, ctx, ticketPath } = deps;
20
+ // ─── Install-config route ─────────────────────────────────────────────────────
21
+ router.post('/:projectId/setup/install-config', (req, res) => {
22
+ const { project } = ctx(req);
23
+ const config = req.body ?? {};
24
+ if (typeof config !== 'object' || Array.isArray(config)) {
25
+ res.status(400).json({ error: 'Request body must be a config object' });
26
+ return;
27
+ }
28
+ const configDir = path_1.default.join(project.path, '.specrails');
29
+ const configPath = path_1.default.join(configDir, 'install-config.yaml');
30
+ try {
31
+ fs_1.default.mkdirSync(configDir, { recursive: true });
32
+ const yaml = (0, project_router_helpers_1.serializeInstallConfigYaml)(config);
33
+ fs_1.default.writeFileSync(configPath, yaml, 'utf-8');
34
+ res.json({ ok: true, path: configPath });
35
+ }
36
+ catch (err) {
37
+ res.status(500).json({ error: `Failed to write install-config.yaml: ${err}` });
38
+ }
39
+ });
40
+ // ─── Enrich routes (v3) + Setup aliases (v1/v2 backward compat) ──────────────
41
+ router.post('/:projectId/setup/install', (req, res) => {
42
+ const { project, setupManager } = ctx(req);
43
+ if (setupManager.isInstalling(project.id)) {
44
+ res.status(409).json({ error: 'Install already in progress' });
45
+ return;
46
+ }
47
+ res.status(202).json({ ok: true });
48
+ setupManager.startInstall(project.id, project.path);
49
+ });
50
+ router.post('/:projectId/enrich/start', (req, res) => {
51
+ const { project, setupManager } = ctx(req);
52
+ if (setupManager.isEnriching(project.id)) {
53
+ res.status(409).json({ error: 'Enrich already in progress' });
54
+ return;
55
+ }
56
+ res.status(202).json({ ok: true });
57
+ setupManager.startEnrich(project.id, project.path, project.provider);
58
+ });
59
+ // Legacy alias: /setup/start → /enrich/start
60
+ router.post('/:projectId/setup/start', (req, res) => {
61
+ const { project, setupManager } = ctx(req);
62
+ if (setupManager.isEnriching(project.id)) {
63
+ res.status(409).json({ error: 'Setup already in progress' });
64
+ return;
65
+ }
66
+ res.status(202).json({ ok: true });
67
+ setupManager.startEnrich(project.id, project.path, project.provider);
68
+ });
69
+ router.post('/:projectId/enrich/message', (req, res) => {
70
+ const { project, setupManager } = ctx(req);
71
+ const { sessionId, message } = req.body ?? {};
72
+ if (!sessionId || typeof sessionId !== 'string') {
73
+ res.status(400).json({ error: 'sessionId is required' });
74
+ return;
75
+ }
76
+ if (!message || typeof message !== 'string' || !message.trim()) {
77
+ res.status(400).json({ error: 'message is required' });
78
+ return;
79
+ }
80
+ if (setupManager.isEnriching(project.id)) {
81
+ res.status(409).json({ error: 'Enrich already in progress' });
82
+ return;
83
+ }
84
+ res.status(202).json({ ok: true });
85
+ setupManager.resumeEnrich(project.id, project.path, sessionId, message.trim(), project.provider);
86
+ });
87
+ // Legacy alias: /setup/message → /enrich/message
88
+ router.post('/:projectId/setup/message', (req, res) => {
89
+ const { project, setupManager } = ctx(req);
90
+ const { sessionId, message } = req.body ?? {};
91
+ if (!sessionId || typeof sessionId !== 'string') {
92
+ res.status(400).json({ error: 'sessionId is required' });
93
+ return;
94
+ }
95
+ if (!message || typeof message !== 'string' || !message.trim()) {
96
+ res.status(400).json({ error: 'message is required' });
97
+ return;
98
+ }
99
+ if (setupManager.isEnriching(project.id)) {
100
+ res.status(409).json({ error: 'Setup already in progress' });
101
+ return;
102
+ }
103
+ res.status(202).json({ ok: true });
104
+ setupManager.resumeEnrich(project.id, project.path, sessionId, message.trim(), project.provider);
105
+ });
106
+ router.get('/:projectId/setup/checkpoints', (req, res) => {
107
+ const { project, setupManager } = ctx(req);
108
+ const checkpoints = setupManager.getCheckpointStatus(project.id, project.path);
109
+ const savedSessionId = (0, desktop_db_1.getProjectSetupSession)(registry.desktopDb, project.id);
110
+ res.json({
111
+ checkpoints,
112
+ isInstalling: setupManager.isInstalling(project.id),
113
+ isSettingUp: setupManager.isEnriching(project.id),
114
+ isEnriching: setupManager.isEnriching(project.id),
115
+ tier: setupManager.getInstallTier(project.id) ?? null,
116
+ savedSessionId: savedSessionId ?? null,
117
+ logLines: setupManager.getInstallLog(project.id),
118
+ summary: setupManager.getSummary(project.path),
119
+ });
120
+ });
121
+ router.post('/:projectId/setup/abort', (req, res) => {
122
+ const { project, setupManager } = ctx(req);
123
+ setupManager.abort(project.id);
124
+ res.json({ ok: true });
125
+ });
126
+ // ─── Proposal routes ──────────────────────────────────────────────────────
127
+ router.get('/:projectId/propose', (req, res) => {
128
+ const limit = Math.min(parseInt(String(req.query.limit ?? '20'), 10) || 20, 100);
129
+ const offset = parseInt(String(req.query.offset ?? '0'), 10) || 0;
130
+ const result = (0, db_1.listProposals)(ctx(req).db, { limit, offset });
131
+ res.json(result);
132
+ });
133
+ router.post('/:projectId/propose', async (req, res) => {
134
+ const { idea } = req.body ?? {};
135
+ if (!idea || typeof idea !== 'string' || !idea.trim()) {
136
+ res.status(400).json({ error: 'idea is required' });
137
+ return;
138
+ }
139
+ // Pre-check: does the propose-feature command exist in this project?
140
+ const testCmd = `/specrails:propose-feature test`;
141
+ const resolved = (0, command_resolver_1.resolveCommand)(testCmd, ctx(req).project.path);
142
+ if (resolved === testCmd) {
143
+ res.status(400).json({ error: 'This project does not have the /specrails:propose-feature command installed. Run "npx specrails-core@latest" to update.' });
144
+ return;
145
+ }
146
+ const id = (0, ids_1.newId)();
147
+ (0, db_1.createProposal)(ctx(req).db, { id, idea: idea.trim() });
148
+ res.status(202).json({ proposalId: id });
149
+ ctx(req).proposalManager.startExploration(id, idea.trim()).catch((err) => {
150
+ console.error('[project-router] proposal startExploration error:', err);
151
+ });
152
+ });
153
+ router.get('/:projectId/propose/:id', (req, res) => {
154
+ const proposal = (0, db_1.getProposal)(ctx(req).db, req.params.id);
155
+ if (!proposal) {
156
+ res.status(404).json({ error: 'Proposal not found' });
157
+ return;
158
+ }
159
+ res.json({ proposal });
160
+ });
161
+ router.post('/:projectId/propose/:id/refine', async (req, res) => {
162
+ const proposal = (0, db_1.getProposal)(ctx(req).db, req.params.id);
163
+ if (!proposal) {
164
+ res.status(404).json({ error: 'Proposal not found' });
165
+ return;
166
+ }
167
+ const { feedback } = req.body ?? {};
168
+ if (!feedback || typeof feedback !== 'string' || !feedback.trim()) {
169
+ res.status(400).json({ error: 'feedback is required' });
170
+ return;
171
+ }
172
+ if (ctx(req).proposalManager.isActive(req.params.id)) {
173
+ res.status(409).json({ error: 'PROPOSAL_BUSY' });
174
+ return;
175
+ }
176
+ if (proposal.status !== 'review') {
177
+ res.status(409).json({ error: 'Proposal is not in review state' });
178
+ return;
179
+ }
180
+ res.status(202).json({ ok: true });
181
+ ctx(req).proposalManager.sendRefinement(req.params.id, feedback.trim()).catch((err) => {
182
+ console.error('[project-router] proposal sendRefinement error:', err);
183
+ });
184
+ });
185
+ router.post('/:projectId/propose/:id/create-issue', async (req, res) => {
186
+ const proposal = (0, db_1.getProposal)(ctx(req).db, req.params.id);
187
+ if (!proposal) {
188
+ res.status(404).json({ error: 'Proposal not found' });
189
+ return;
190
+ }
191
+ if (ctx(req).proposalManager.isActive(req.params.id)) {
192
+ res.status(409).json({ error: 'PROPOSAL_BUSY' });
193
+ return;
194
+ }
195
+ if (proposal.status !== 'review') {
196
+ res.status(409).json({ error: 'Proposal is not in review state' });
197
+ return;
198
+ }
199
+ res.status(202).json({ ok: true });
200
+ ctx(req).proposalManager.createIssue(req.params.id).catch((err) => {
201
+ console.error('[project-router] proposal createIssue error:', err);
202
+ });
203
+ });
204
+ router.delete('/:projectId/propose/:id', (req, res) => {
205
+ const proposal = (0, db_1.getProposal)(ctx(req).db, req.params.id);
206
+ if (!proposal) {
207
+ res.status(404).json({ error: 'Proposal not found' });
208
+ return;
209
+ }
210
+ ctx(req).proposalManager.cancel(req.params.id);
211
+ res.json({ ok: true });
212
+ });
213
+ // ─── Feature Funnel ─────────────────────────────────────────────────────────
214
+ router.get('/:projectId/changes', (req, res) => {
215
+ const { project, queueManager } = ctx(req);
216
+ const activeCommands = queueManager.getJobs()
217
+ .filter((j) => j.status === 'running' || j.status === 'queued')
218
+ .map((j) => j.command);
219
+ const changes = (0, changes_reader_1.readChanges)(project.path, activeCommands);
220
+ res.json({ changes });
221
+ });
222
+ // ─── Change Artifact Browser ─────────────────────────────────────────────────
223
+ const ALLOWED_ARTIFACTS = new Set(['proposal.md', 'design.md', 'tasks.md', 'delta-spec.md', 'context-bundle.md']);
224
+ router.get('/:projectId/changes/:changeId/artifacts/:artifact', (req, res) => {
225
+ const changeId = req.params.changeId;
226
+ const artifact = req.params.artifact;
227
+ if (!ALLOWED_ARTIFACTS.has(artifact)) {
228
+ res.status(400).json({ error: 'Invalid artifact name' });
229
+ return;
230
+ }
231
+ // Sanitize changeId to prevent path traversal
232
+ if (!/^[\w-]+$/.test(changeId)) {
233
+ res.status(400).json({ error: 'Invalid change ID' });
234
+ return;
235
+ }
236
+ const { project } = ctx(req);
237
+ const changesRoot = path_1.default.join(project.path, 'openspec', 'changes');
238
+ // Check active dir first, then archive
239
+ let filePath = path_1.default.join(changesRoot, changeId, artifact);
240
+ if (!fs_1.default.existsSync(filePath)) {
241
+ filePath = path_1.default.join(changesRoot, 'archive', changeId, artifact);
242
+ }
243
+ if (!fs_1.default.existsSync(filePath)) {
244
+ res.status(404).json({ error: 'Artifact not found' });
245
+ return;
246
+ }
247
+ try {
248
+ const content = fs_1.default.readFileSync(filePath, 'utf-8');
249
+ res.json({ content, artifact, changeId });
250
+ }
251
+ catch {
252
+ res.status(500).json({ error: 'Failed to read artifact' });
253
+ }
254
+ });
255
+ // ─── Spec Launcher ───────────────────────────────────────────────────────────
256
+ router.post('/:projectId/spec-launcher/start', (req, res) => {
257
+ const { description } = req.body ?? {};
258
+ if (!description || typeof description !== 'string' || !description.trim()) {
259
+ res.status(400).json({ error: 'description is required' });
260
+ return;
261
+ }
262
+ const launchId = (0, ids_1.newId)();
263
+ res.status(202).json({ launchId });
264
+ ctx(req).specLauncherManager.launch(launchId, description.trim()).catch((err) => {
265
+ console.error('[project-router] spec-launcher error:', err);
266
+ });
267
+ });
268
+ router.delete('/:projectId/spec-launcher/:launchId', (req, res) => {
269
+ const { specLauncherManager } = ctx(req);
270
+ if (!specLauncherManager.isActive(req.params.launchId)) {
271
+ res.status(404).json({ error: 'No active launch with that ID' });
272
+ return;
273
+ }
274
+ specLauncherManager.cancel(req.params.launchId);
275
+ res.json({ ok: true });
276
+ });
277
+ // ─── Job Templates ────────────────────────────────────────────────────────
278
+ function templateToPublic(row) {
279
+ if (!row)
280
+ return null;
281
+ return {
282
+ id: row.id,
283
+ name: row.name,
284
+ description: row.description,
285
+ commands: JSON.parse(row.commands),
286
+ created_at: row.created_at,
287
+ updated_at: row.updated_at,
288
+ };
289
+ }
290
+ router.get('/:projectId/templates', (req, res) => {
291
+ const rows = (0, db_1.listTemplates)(ctx(req).db);
292
+ const templates = rows.map((r) => templateToPublic(r));
293
+ res.json({ templates });
294
+ });
295
+ router.post('/:projectId/templates', (req, res) => {
296
+ const { name, description, commands } = req.body ?? {};
297
+ if (!name || typeof name !== 'string' || !name.trim()) {
298
+ res.status(400).json({ error: 'name is required' });
299
+ return;
300
+ }
301
+ if (!Array.isArray(commands) || commands.length === 0) {
302
+ res.status(400).json({ error: 'commands must be a non-empty array' });
303
+ return;
304
+ }
305
+ if (commands.some((c) => typeof c !== 'string' || !String(c).trim())) {
306
+ res.status(400).json({ error: 'each command must be a non-empty string' });
307
+ return;
308
+ }
309
+ const id = (0, ids_1.newId)();
310
+ try {
311
+ (0, db_1.createTemplate)(ctx(req).db, {
312
+ id,
313
+ name: name.trim(),
314
+ description: description && typeof description === 'string' ? description.trim() : undefined,
315
+ commands: commands.map((c) => c.trim()),
316
+ });
317
+ }
318
+ catch (err) {
319
+ const msg = err instanceof Error ? err.message : '';
320
+ if (msg.includes('UNIQUE constraint failed')) {
321
+ res.status(409).json({ error: 'A template with that name already exists' });
322
+ return;
323
+ }
324
+ console.error('[project-router] create template error:', err);
325
+ res.status(500).json({ error: 'Internal server error' });
326
+ return;
327
+ }
328
+ const created = templateToPublic((0, db_1.getTemplate)(ctx(req).db, id));
329
+ res.status(201).json({ template: created });
330
+ });
331
+ router.get('/:projectId/templates/:templateId', (req, res) => {
332
+ const row = (0, db_1.getTemplate)(ctx(req).db, req.params.templateId);
333
+ if (!row) {
334
+ res.status(404).json({ error: 'Template not found' });
335
+ return;
336
+ }
337
+ res.json({ template: templateToPublic(row) });
338
+ });
339
+ router.patch('/:projectId/templates/:templateId', (req, res) => {
340
+ const { db } = ctx(req);
341
+ const templateId = req.params.templateId;
342
+ const row = (0, db_1.getTemplate)(db, templateId);
343
+ if (!row) {
344
+ res.status(404).json({ error: 'Template not found' });
345
+ return;
346
+ }
347
+ const { name, description, commands } = req.body ?? {};
348
+ const patch = {};
349
+ if (name !== undefined) {
350
+ if (typeof name !== 'string' || !name.trim()) {
351
+ res.status(400).json({ error: 'name must be a non-empty string' });
352
+ return;
353
+ }
354
+ patch.name = name.trim();
355
+ }
356
+ if (description !== undefined) {
357
+ patch.description = description === null ? null : String(description).trim() || null;
358
+ }
359
+ if (commands !== undefined) {
360
+ if (!Array.isArray(commands) || commands.length === 0) {
361
+ res.status(400).json({ error: 'commands must be a non-empty array' });
362
+ return;
363
+ }
364
+ if (commands.some((c) => typeof c !== 'string' || !String(c).trim())) {
365
+ res.status(400).json({ error: 'each command must be a non-empty string' });
366
+ return;
367
+ }
368
+ patch.commands = commands.map((c) => c.trim());
369
+ }
370
+ try {
371
+ (0, db_1.updateTemplate)(db, templateId, patch);
372
+ }
373
+ catch (err) {
374
+ const msg = err instanceof Error ? err.message : '';
375
+ if (msg.includes('UNIQUE constraint failed')) {
376
+ res.status(409).json({ error: 'A template with that name already exists' });
377
+ return;
378
+ }
379
+ console.error('[project-router] update template error:', err);
380
+ res.status(500).json({ error: 'Internal server error' });
381
+ return;
382
+ }
383
+ const updated = templateToPublic((0, db_1.getTemplate)(db, templateId));
384
+ res.json({ ok: true, template: updated });
385
+ });
386
+ router.delete('/:projectId/templates/:templateId', (req, res) => {
387
+ const { db } = ctx(req);
388
+ const row = (0, db_1.getTemplate)(db, req.params.templateId);
389
+ if (!row) {
390
+ res.status(404).json({ error: 'Template not found' });
391
+ return;
392
+ }
393
+ (0, db_1.deleteTemplate)(db, req.params.templateId);
394
+ res.json({ ok: true });
395
+ });
396
+ router.post('/:projectId/templates/:templateId/run', (req, res) => {
397
+ const { db, queueManager } = ctx(req);
398
+ const row = (0, db_1.getTemplate)(db, req.params.templateId);
399
+ if (!row) {
400
+ res.status(404).json({ error: 'Template not found' });
401
+ return;
402
+ }
403
+ const commands = JSON.parse(row.commands);
404
+ const chain = req.body?.chain !== false; // default: chain jobs as pipeline
405
+ const jobIds = [];
406
+ try {
407
+ const pipelineId = chain && commands.length > 1 ? (0, ids_1.newId)() : undefined;
408
+ let prevJobId = null;
409
+ for (const command of commands) {
410
+ const job = queueManager.enqueue(command, 'normal', {
411
+ dependsOnJobId: chain ? (prevJobId ?? undefined) : undefined,
412
+ pipelineId,
413
+ });
414
+ jobIds.push(job.id);
415
+ prevJobId = job.id;
416
+ }
417
+ }
418
+ catch (err) {
419
+ if (err instanceof queue_manager_1.ClaudeNotFoundError) {
420
+ res.status(400).json({ error: err.message });
421
+ return;
422
+ }
423
+ console.error('[project-router] template run error:', err);
424
+ res.status(500).json({ error: 'Internal server error' });
425
+ return;
426
+ }
427
+ res.status(202).json({ ok: true, jobIds, templateId: row.id, templateName: row.name });
428
+ });
429
+ // ─── Integration contract ──────────────────────────────────────────────────
430
+ const DEFAULT_TICKET_CAPABILITIES = ['crud', 'labels', 'status', 'priorities', 'dependencies'];
431
+ const DEFAULT_TICKET_STORAGE_PATH = '.specrails/local-tickets.json';
432
+ // GET /:projectId/integration-contract — Return the project's integration contract with ticketProvider
433
+ router.get('/:projectId/integration-contract', (req, res) => {
434
+ const projectPath = ctx(req).project.path;
435
+ const contractFile = path_1.default.join(projectPath, '.claude', 'integration-contract.json');
436
+ let rawContract = {};
437
+ let source = 'default';
438
+ if (fs_1.default.existsSync(contractFile)) {
439
+ try {
440
+ rawContract = JSON.parse(fs_1.default.readFileSync(contractFile, 'utf-8'));
441
+ source = 'contract';
442
+ }
443
+ catch {
444
+ // malformed contract — fall back to defaults
445
+ }
446
+ }
447
+ const rawProvider = rawContract.ticketProvider;
448
+ const storagePath = rawProvider?.storagePath ?? DEFAULT_TICKET_STORAGE_PATH;
449
+ const ticketProvider = {
450
+ type: rawProvider?.type ?? 'local',
451
+ storagePath: path_1.default.resolve(projectPath, storagePath),
452
+ capabilities: rawProvider?.capabilities ?? DEFAULT_TICKET_CAPABILITIES,
453
+ };
454
+ res.json({ ticketProvider, source });
455
+ });
456
+ }