specrails-desktop 2.2.1 → 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 (61) 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-BD0paa75.js → AnalyticsPage-Dyyz1ht3.js} +1 -1
  4. package/client/dist/assets/{BarChart-D8ZZRab3.js → BarChart-CMdLa6Es.js} +2 -2
  5. package/client/dist/assets/CodePage-D7Xwjhut.js +2 -0
  6. package/client/dist/assets/{DesktopAnalyticsPage-mwd8460_.js → DesktopAnalyticsPage-CTNwb639.js} +1 -1
  7. package/client/dist/assets/{DocsDialog-D_dyF2h9.js → DocsDialog-D8yoyZDD.js} +2 -2
  8. package/client/dist/assets/{DocsPage-C9-Ru8wE.js → DocsPage-CeO-fAxy.js} +2 -2
  9. package/client/dist/assets/{ExportDropdown-CLYmQhic.js → ExportDropdown-DuoZcdYN.js} +1 -1
  10. package/client/dist/assets/{IntegrationsPage-3WWtx9hi.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-BOu_cXw3.js → dist-js-4UEGaKhD.js} +1 -1
  22. package/client/dist/assets/{dist-js-D3MxtOYa.js → dist-js-H6hyhSuv.js} +1 -1
  23. package/client/dist/assets/{index-D9G_K4L-.js → index-CGHKpC-N.js} +11 -11
  24. package/client/dist/assets/{lib-DQ2hrj8m.js → lib-Cs5FrUJI.js} +1 -1
  25. package/client/dist/assets/{useProjectCache-BxY4aTjd.js → useProjectCache-BZWYV-w-.js} +1 -1
  26. package/client/dist/index.html +2 -2
  27. package/package.json +1 -1
  28. package/server/dist/agent-refine-manager.js +128 -153
  29. package/server/dist/chat-manager.js +242 -0
  30. package/server/dist/code-explorer-router.js +78 -0
  31. package/server/dist/command-resolver.js +17 -0
  32. package/server/dist/contract-refine-runner.js +42 -10
  33. package/server/dist/db.js +6 -0
  34. package/server/dist/desktop-db.js +3 -0
  35. package/server/dist/explore-stdin-session.js +129 -0
  36. package/server/dist/mobile/mobile-auth.js +16 -0
  37. package/server/dist/project-router-chat.js +218 -0
  38. package/server/dist/project-router-helpers.js +275 -0
  39. package/server/dist/project-router-jobs.js +389 -0
  40. package/server/dist/project-router-settings.js +312 -0
  41. package/server/dist/project-router-setup.js +456 -0
  42. package/server/dist/project-router-spending.js +320 -0
  43. package/server/dist/project-router-terminals.js +312 -0
  44. package/server/dist/project-router-tickets.js +1767 -0
  45. package/server/dist/project-router.js +27 -3950
  46. package/server/dist/providers/claude-adapter.js +23 -0
  47. package/server/dist/providers/codex-adapter.js +6 -0
  48. package/server/dist/spawn-lifecycle.js +117 -0
  49. package/client/dist/assets/ActivityFeedPage-BpjXuX2H.js +0 -1
  50. package/client/dist/assets/AgentsPage-D-7fDbTc.js +0 -86
  51. package/client/dist/assets/CodePage-B6q6CiYJ.js +0 -2
  52. package/client/dist/assets/JobDetailPage-DgN-79s-.js +0 -16
  53. package/client/dist/assets/JobsPage-Du8_w1ob.js +0 -1
  54. package/client/dist/assets/code-AL1rVIMb.js +0 -1
  55. package/client/dist/assets/code-C0BKpkht.js +0 -1
  56. package/client/dist/assets/code-C0FTS3ew.js +0 -1
  57. package/client/dist/assets/code-CPcHxzxw.js +0 -1
  58. package/client/dist/assets/code-D3ryDniw.js +0 -1
  59. package/client/dist/assets/code-D3zVVQTj.js +0 -1
  60. package/client/dist/assets/code-PCmfS3dn.js +0 -1
  61. package/client/dist/assets/code-exI0G5Wd.js +0 -1
@@ -0,0 +1,1767 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.registerTicketsRoutes = registerTicketsRoutes;
40
+ // Domain routes extracted from project-router.ts (tickets).
41
+ // Registered on the shared router by createProjectRouter — behaviour-preserving.
42
+ const fs_1 = __importDefault(require("fs"));
43
+ const ids_1 = require("./ids");
44
+ const db_1 = require("./db");
45
+ const providers_1 = require("./providers");
46
+ const contract_refine_runner_1 = require("./contract-refine-runner");
47
+ const explore_contract_refine_1 = require("./explore-contract-refine");
48
+ const smash_runner_1 = require("./smash-runner");
49
+ const explore_smash_1 = require("./explore-smash");
50
+ const ai_invocations_1 = require("./ai-invocations");
51
+ const context_scope_1 = require("./context-scope");
52
+ const result_event_1 = require("./result-event");
53
+ const crypto_1 = require("crypto");
54
+ const spec_models_1 = require("./spec-models");
55
+ const provider_selection_1 = require("./provider-selection");
56
+ const ticket_store_1 = require("./ticket-store");
57
+ const explore_draft_title_1 = require("./explore-draft-title");
58
+ const cli_prompt_1 = require("./util/cli-prompt");
59
+ const readline_1 = require("readline");
60
+ const tree_kill_1 = __importDefault(require("tree-kill"));
61
+ const multer_1 = __importDefault(require("multer"));
62
+ const attachment_manager_1 = require("./attachment-manager");
63
+ const project_router_helpers_1 = require("./project-router-helpers");
64
+ function registerTicketsRoutes(deps) {
65
+ const { router, registry, ctx, ticketPath } = deps;
66
+ // ─── Tickets ──────────────────────────────────────────────────────────────────
67
+ /** Resolve the ticket storage file path for a project */
68
+ // GET /:projectId/tickets — List all tickets with optional filters
69
+ router.get('/:projectId/tickets', (req, res) => {
70
+ try {
71
+ const filePath = ticketPath(req);
72
+ const store = (0, ticket_store_1.readStore)(filePath);
73
+ const allTickets = Object.values(store.tickets);
74
+ const filtered = (0, ticket_store_1.filterTickets)(allTickets, {
75
+ status: req.query.status,
76
+ label: req.query.label,
77
+ q: req.query.q,
78
+ });
79
+ // Sort by updated_at descending
80
+ filtered.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
81
+ res.json({ tickets: filtered, revision: store.revision, total: allTickets.length });
82
+ }
83
+ catch (err) {
84
+ console.error('[project-router] ticket list error:', err);
85
+ res.status(500).json({ error: 'Failed to read tickets' });
86
+ }
87
+ });
88
+ // GET /:projectId/tickets/:id — Get single ticket
89
+ router.get('/:projectId/tickets/:id', (req, res) => {
90
+ const ticketId = req.params.id;
91
+ if (!/^\d+$/.test(ticketId)) {
92
+ res.status(400).json({ error: 'Invalid ticket ID' });
93
+ return;
94
+ }
95
+ try {
96
+ const store = (0, ticket_store_1.readStore)(ticketPath(req));
97
+ const ticket = store.tickets[ticketId];
98
+ if (!ticket) {
99
+ res.status(404).json({ error: 'Ticket not found' });
100
+ return;
101
+ }
102
+ res.json({ ticket, revision: store.revision });
103
+ }
104
+ catch (err) {
105
+ console.error('[project-router] ticket get error:', err);
106
+ res.status(500).json({ error: 'Failed to read ticket' });
107
+ }
108
+ });
109
+ // POST /:projectId/tickets/generate-spec — Fast AI spec generation (no codebase exploration)
110
+ router.post('/:projectId/tickets/generate-spec', async (req, res) => {
111
+ const idea = req.body?.idea;
112
+ if (!idea?.trim()) {
113
+ res.status(400).json({ error: 'idea is required' });
114
+ return;
115
+ }
116
+ const attachmentIds = Array.isArray(req.body?.attachmentIds)
117
+ ? req.body.attachmentIds.filter((x) => typeof x === 'string')
118
+ : [];
119
+ const pendingSpecId = typeof req.body?.pendingSpecId === 'string' ? req.body.pendingSpecId : null;
120
+ if (attachmentIds.length > 0 && !pendingSpecId) {
121
+ res.status(400).json({ error: 'pendingSpecId is required when attachmentIds are provided' });
122
+ return;
123
+ }
124
+ const { project, broadcast, ticketWatcher } = ctx(req);
125
+ // Multi-provider: optional aiEngine (alias provider) picks the engine for
126
+ // this Quick spec; must be installed on the project. Omitting it uses the
127
+ // primary provider.
128
+ const requestedEngine = req.body?.aiEngine ?? req.body?.provider;
129
+ const engineCheck = (0, provider_selection_1.validateRequestedProvider)(project, requestedEngine);
130
+ if (!engineCheck.ok) {
131
+ res.status(400).json({ error: engineCheck.error });
132
+ return;
133
+ }
134
+ const provider = engineCheck.provider;
135
+ // Resolve and validate the model. Order:
136
+ // - Body had a `model` and it's valid → use it.
137
+ // - Body had a `model` and it's invalid → 400 with the allow-list.
138
+ // - Body had no `model` → fall back to project default.
139
+ const rawModel = req.body?.model;
140
+ let resolvedModel;
141
+ if (rawModel === undefined || rawModel === null || rawModel === '') {
142
+ resolvedModel = (0, project_router_helpers_1.resolveDefaultSpecModel)({ projectPath: project.path, provider });
143
+ }
144
+ else if ((0, spec_models_1.isValidModelForProvider)(rawModel, provider)) {
145
+ resolvedModel = rawModel;
146
+ }
147
+ else {
148
+ res.status(400).json({
149
+ error: `Invalid model "${String(rawModel)}" for provider "${provider}"`,
150
+ allowed: (0, spec_models_1.getModelsForProvider)(provider).map((m) => m.value),
151
+ });
152
+ return;
153
+ }
154
+ const requestId = (0, ids_1.newId)();
155
+ const projectId = project.id;
156
+ const filePath = ticketPath(req);
157
+ let hasAttachments = false;
158
+ let baseUserPrompt = `Generate a spec for the following idea:\n\n${idea.trim()}`;
159
+ let imageFlags = [];
160
+ if (attachmentIds.length > 0 && pendingSpecId) {
161
+ try {
162
+ const extracted = await attachment_manager_1.attachmentManager.getClaudeArgs(project.slug, pendingSpecId, attachmentIds);
163
+ imageFlags = extracted.imageFlags;
164
+ if (extracted.textBlocks.length > 0) {
165
+ hasAttachments = true;
166
+ baseUserPrompt = `${baseUserPrompt}\n\n## Attached Resources\n\n${extracted.textBlocks.join('\n\n')}`;
167
+ }
168
+ }
169
+ catch (err) {
170
+ console.error('[project-router] generate-spec attachment extraction error:', err);
171
+ }
172
+ }
173
+ // Parse contextScope from body. Quick and Explore share the same Context
174
+ // Awareness controls; Quick still keeps Contract Refine as a top-level
175
+ // field for the refine scheduler.
176
+ const rawScope = req.body?.contextScope;
177
+ // Contract Layer is Claude-only — force it off for any non-claude engine
178
+ // (defence-in-depth; the Quick UI hides the toggle for those).
179
+ const quickContractRefine = provider !== 'claude'
180
+ ? false
181
+ : typeof req.body?.contractRefine === 'boolean'
182
+ ? req.body.contractRefine
183
+ : typeof rawScope?.contractRefine === 'boolean'
184
+ ? rawScope.contractRefine
185
+ : false;
186
+ const quickScope = {
187
+ specrails: typeof rawScope?.specrails === 'boolean' ? rawScope.specrails : false,
188
+ openspec: typeof rawScope?.openspec === 'boolean' ? rawScope.openspec : false,
189
+ full: typeof rawScope?.full === 'boolean' ? rawScope.full : false,
190
+ // Quick spawns from project.path, so project `.mcp.json` (the `mcp`
191
+ // toggle) is discovered natively. `userMcp` additionally loads the
192
+ // developer's user-scope/plugin/connector MCP servers via the claude
193
+ // adapter's `loadUserEnv` (see below).
194
+ mcp: typeof rawScope?.mcp === 'boolean' ? rawScope.mcp : false,
195
+ contractRefine: quickContractRefine,
196
+ userMcp: typeof rawScope?.userMcp === 'boolean' ? rawScope.userMcp : false,
197
+ };
198
+ // Persist Quick mode Contract Refine choice (per-project last value).
199
+ (0, db_1.setQuickContractRefineLast)(ctx(req).db, quickContractRefine);
200
+ const specsPrefix = (0, context_scope_1.buildScopedSystemPromptPrefix)(quickScope, project.path);
201
+ const codebaseRule = quickScope.full
202
+ ? `- You MAY use Read, Grep, and Glob to inspect the project codebase. Bash is not available.`
203
+ : hasAttachments
204
+ ? `- Do NOT explore the project codebase. The resources inside <user-attachment> blocks below are pre-loaded context the user intentionally provided — read and use them freely.`
205
+ : `- Do NOT read any files or explore the codebase. Work purely from the user's description.`;
206
+ // The specrails-tickets prefix (when scope.specrails is toggled on)
207
+ // dumps every ticket into the prompt as informational context. Without
208
+ // an explicit dedup instruction the model treats it as background and
209
+ // still proposes a near-duplicate of something already in the backlog.
210
+ // Adding the rule here, gated on `quickScope.specrails`, keeps the
211
+ // "toggle is the only gate" contract the user asked for.
212
+ const dedupRule = quickScope.specrails
213
+ ? `- The "Specrails Tickets" section above lists every ticket already in the backlog. Do NOT propose a duplicate or a near-duplicate of any of them. If the user's idea is already covered by an existing ticket, say so in "Problem Statement" and pick a *different* angle / sub-feature / next step that builds on the existing one — do not repeat it.\n`
214
+ : '';
215
+ const backlogRecommendationRule = quickScope.specrails
216
+ ? `- If the user's idea asks for the "next best spec" or a backlog recommendation, use the existing tickets and OpenSpec context to choose one concrete next spec. Do not respond with generic product directions.\n`
217
+ : '';
218
+ let baseSystemPrompt = `You are a senior product engineer generating a structured spec proposal.\n\n` +
219
+ (specsPrefix ? `${specsPrefix}\n\n` : '') +
220
+ `RULES:\n` +
221
+ `${codebaseRule}\n` +
222
+ dedupRule +
223
+ backlogRecommendationRule +
224
+ `- Do NOT create files, tickets, or issues.\n` +
225
+ `- Output ONLY the structured markdown below. No preamble, no explanation.\n\n` +
226
+ `REQUIRED FORMAT:\n` +
227
+ `## Spec Title\n[Concise, action-oriented title]\n\n` +
228
+ `## Labels\n[2-4 short kebab-case tags categorising the spec — comma-separated on one line, e.g. "ui, settings, dark-mode". Lowercase, no spaces inside a tag.]\n\n` +
229
+ `## Problem Statement\n[2-3 sentences]\n\n` +
230
+ `## Proposed Solution\n[3-5 sentences]\n\n` +
231
+ `## Out of Scope\n[Bullet list]\n\n` +
232
+ `## Acceptance Criteria\n[Numbered list of testable outcomes]\n\n` +
233
+ `## Technical Considerations\n[Bullet list]\n\n` +
234
+ `## Estimated Complexity\n[Low/Medium/High/Very High + one sentence justification]\n\n` +
235
+ `## Short Summary\n[One or two plain-language sentences, max 120 characters total, that capture the essence of this spec for a dashboard postit. No markdown, no bullets, no headings.]`;
236
+ if (hasAttachments)
237
+ baseSystemPrompt = `${baseSystemPrompt}\n\n${attachment_manager_1.USER_ATTACHMENT_SYSTEM_NOTE}`;
238
+ const systemPrompt = baseSystemPrompt;
239
+ const userPrompt = baseUserPrompt;
240
+ // Generate-spec spawn args are adapter-driven. For Claude the `--tools`
241
+ // flag set comes from `toolFlagsForScope(quickScope)` which the adapter
242
+ // doesn't model — pass them through `extraArgs` so they slot in after
243
+ // the standard COMMON_FLAGS. `imageFlags` (also Claude-only) goes the
244
+ // same way. For codex the system prompt folds into the user prompt
245
+ // (no --system-prompt flag) and the extra Claude-only flags are ignored
246
+ // by the codex adapter (it doesn't read extraArgs that don't apply).
247
+ const adapter = (0, providers_1.getAdapter)(provider);
248
+ const toolFlags = provider === 'claude' ? (0, context_scope_1.toolFlagsForScope)(quickScope) : { args: [] };
249
+ // Full scope grants Read/Grep/Glob. The model spends turns exploring the
250
+ // repo before it writes the spec; 6 was too tight (a few tool calls on a
251
+ // sparse/empty repo hit error_max_turns → exit 1 → opaque failure). 15
252
+ // leaves comfortable headroom while --max-turns still bounds runaway loops.
253
+ const claudeMaxTurns = quickScope.full ? 15 : (hasAttachments ? 3 : 1);
254
+ const args = adapter.buildArgs('spec-gen', {
255
+ prompt: userPrompt,
256
+ systemPrompt,
257
+ model: resolvedModel,
258
+ maxTurns: provider === 'claude' ? claudeMaxTurns : undefined,
259
+ extraArgs: provider === 'claude' ? [...toolFlags.args, ...imageFlags] : undefined,
260
+ // "My approved MCPs" (scope.userMcp) loads the developer's user-scope,
261
+ // plugin, and connector MCP servers (claude-only). Quick already spawns
262
+ // from project.path so project `.mcp.json` is discovered without a flag.
263
+ loadUserEnv: provider === 'claude' && quickScope.userMcp,
264
+ });
265
+ const binary = adapter.binary;
266
+ // spawnAiCli reroutes multi-line argv values through stdin on Windows;
267
+ // POSIX argv path unchanged.
268
+ console.log(`[project-router] spec-gen spawn: ${binary} (cwd=${project.path}, requestId=${requestId})`);
269
+ const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
270
+ env: process.env,
271
+ stdio: ['ignore', 'pipe', 'pipe'],
272
+ cwd: project.path,
273
+ });
274
+ // Watchdog: unlike ai-edit, generate-spec keeps no cancellable handle, so a
275
+ // hung CLI (network stall, model never emitting a terminating event) would
276
+ // otherwise leak this child + its readline for the app's lifetime. Cap is
277
+ // generous — the 'full' scope can legitimately run minutes and --max-turns
278
+ // bounds turns, not wall-clock. Cleared on close/error.
279
+ const GENERATE_SPEC_TIMEOUT_MS = 8 * 60 * 1000;
280
+ let specGenWatchdog = setTimeout(() => {
281
+ specGenWatchdog = null;
282
+ if (child.pid) {
283
+ try {
284
+ (0, tree_kill_1.default)(child.pid, 'SIGTERM');
285
+ }
286
+ catch { /* best-effort */ }
287
+ }
288
+ broadcast({
289
+ type: 'spec_gen_error', projectId, requestId,
290
+ error: `Spec generation timed out after ${Math.round(GENERATE_SPEC_TIMEOUT_MS / 1000)}s`,
291
+ timestamp: new Date().toISOString(),
292
+ });
293
+ }, GENERATE_SPEC_TIMEOUT_MS);
294
+ if (typeof specGenWatchdog.unref === 'function')
295
+ specGenWatchdog.unref();
296
+ const clearSpecGenWatchdog = () => {
297
+ if (specGenWatchdog) {
298
+ clearTimeout(specGenWatchdog);
299
+ specGenWatchdog = null;
300
+ }
301
+ };
302
+ // Capture stderr so failures (auth missing, model errors, etc.) surface
303
+ // in the server log instead of being swallowed.
304
+ let stderrBuf = '';
305
+ /* c8 ignore start -- diagnostic-only; fires only when claude writes stderr */
306
+ child.stderr?.on('data', (chunk) => {
307
+ const s = chunk.toString();
308
+ stderrBuf += s;
309
+ console.error(`[project-router] spec-gen stderr (${requestId}): ${s.trimEnd()}`);
310
+ });
311
+ /* c8 ignore stop */
312
+ // Without this listener, ENOENT (binary missing on PATH) propagates as
313
+ // an unhandled 'error' event and crashes the entire app process.
314
+ /* c8 ignore start -- spawn-failure path; exercised manually, not in CI */
315
+ child.on('error', (err) => {
316
+ clearSpecGenWatchdog();
317
+ console.error(`[project-router] spec-gen spawn failed (${binary}): ${err.message}`);
318
+ const errMsg = {
319
+ type: 'spec_gen_error', projectId, requestId,
320
+ error: `Failed to launch ${binary}: ${err.message}`,
321
+ timestamp: new Date().toISOString(),
322
+ };
323
+ broadcast(errMsg);
324
+ });
325
+ /* c8 ignore stop */
326
+ res.status(202).json({ requestId });
327
+ let buffer = '';
328
+ let lastResultEvent = null;
329
+ // Canonical adapter events feed finaliseInvocationResult on close, giving
330
+ // codex a real pricing-table cost estimate (+ estimated flag) and tokens,
331
+ // instead of the legacy hardcoded $0. Accumulated ALONGSIDE the existing
332
+ // buffer/delta plumbing below — never in place of it.
333
+ const adapterEvents = [];
334
+ const turnStartedAt = new Date().toISOString();
335
+ const stdoutReader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
336
+ stdoutReader.on('line', (line) => {
337
+ const adapterEv = adapter.parseStreamLine(line);
338
+ if (adapterEv)
339
+ adapterEvents.push(adapterEv);
340
+ let parsed = null;
341
+ try {
342
+ parsed = JSON.parse(line);
343
+ }
344
+ catch { /* skip */ }
345
+ if (!parsed)
346
+ return;
347
+ if (provider === 'codex') {
348
+ // Codex `exec --json` emits one event per line. Capture the final
349
+ // `turn.completed` for usage extraction, and accumulate ONLY the
350
+ // assistant_message text — never the command_execution items or
351
+ // wrapper events, otherwise the raw JSONL ends up in the ticket
352
+ // description.
353
+ if (parsed.type === 'turn.completed') {
354
+ lastResultEvent = parsed;
355
+ return;
356
+ }
357
+ if (parsed.type !== 'item.completed')
358
+ return;
359
+ const item = parsed.item;
360
+ if (!item || item.type !== 'agent_message')
361
+ return;
362
+ const newText = (item.text ?? '').trim();
363
+ if (!newText)
364
+ return;
365
+ // Each agent_message is a complete chunk — separate with a blank
366
+ // line so the parser regexes match cleanly across chunks.
367
+ buffer += (buffer.endsWith('\n') || buffer.length === 0 ? '' : '\n') + newText + '\n';
368
+ const msg = {
369
+ type: 'spec_gen_stream', projectId, requestId,
370
+ delta: newText + '\n', timestamp: new Date().toISOString(),
371
+ };
372
+ broadcast(msg);
373
+ return;
374
+ }
375
+ // Claude path.
376
+ if (parsed.type === 'result') {
377
+ lastResultEvent = parsed;
378
+ }
379
+ if (parsed.type === 'assistant') {
380
+ const msg = parsed.message;
381
+ const texts = (msg?.content ?? [])
382
+ .filter((c) => c.type === 'text')
383
+ .map((c) => c.text ?? '');
384
+ const newText = texts.join('');
385
+ if (newText) {
386
+ buffer += newText;
387
+ const wsMsg = {
388
+ type: 'spec_gen_stream', projectId, requestId,
389
+ delta: newText, timestamp: new Date().toISOString(),
390
+ };
391
+ broadcast(wsMsg);
392
+ }
393
+ }
394
+ });
395
+ child.on('close', async (code) => {
396
+ clearSpecGenWatchdog();
397
+ let createdTicketId = null;
398
+ // When claude burns its whole --max-turns budget it exits non-zero with
399
+ // a result event of subtype:error_max_turns — but it may already have
400
+ // emitted a complete spec. Salvage that usable output instead of failing
401
+ // the whole request on an exit code.
402
+ const resultSubtype = lastResultEvent?.subtype ?? null;
403
+ const hasUsableSpec = buffer.trim().length > 0 && /##\s*Spec Title/i.test(buffer);
404
+ const salvageMaxTurns = code !== 0 && resultSubtype === 'error_max_turns' && hasUsableSpec;
405
+ if ((code === 0 && buffer.trim()) || salvageMaxTurns) {
406
+ if (salvageMaxTurns) {
407
+ console.warn(`[project-router] spec-gen salvaged partial output after error_max_turns (${requestId}); ` +
408
+ `consider raising --max-turns if this recurs`);
409
+ }
410
+ // Extract title from generated spec
411
+ const titleMatch = buffer.match(/##\s*Spec Title\s*\n+(.+)/);
412
+ const specTitle = titleMatch ? titleMatch[1].trim() : idea.trim().slice(0, 80);
413
+ // Extract complexity for priority mapping
414
+ const complexityMatch = buffer.match(/##\s*Estimated Complexity\s*\n+(\w+)/);
415
+ const complexity = complexityMatch ? complexityMatch[1].toLowerCase() : 'medium';
416
+ const priority = complexity === 'low' ? 'low' : complexity === 'high' || complexity === 'very' ? 'high' : 'medium';
417
+ // Extract labels from the `## Labels` section. Comma- or
418
+ // newline-separated tags, normalised to lowercase kebab-case.
419
+ // `spec-proposal` is always retained as the marker label.
420
+ const labelsMatch = buffer.match(/##\s*Labels\s*\n+([^\n]+(?:\n(?!##)[^\n]+)*)/);
421
+ const claudeLabels = labelsMatch
422
+ ? labelsMatch[1]
423
+ .replace(/[\[\]]/g, '')
424
+ .split(/[,\n]/)
425
+ .map((s) => s.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''))
426
+ .filter((s) => s.length > 0 && s.length <= 32)
427
+ .slice(0, 6)
428
+ : [];
429
+ const finalLabels = Array.from(new Set(['spec-proposal', ...claudeLabels]));
430
+ const shortSummary = (0, ticket_store_1.clampShortSummary)((0, project_router_helpers_1.extractShortSummary)(buffer));
431
+ const description = (0, project_router_helpers_1.stripSpecMetadataSections)(buffer);
432
+ // Create ticket directly
433
+ try {
434
+ const now = new Date().toISOString();
435
+ let created;
436
+ const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
437
+ const id = s.next_id++;
438
+ const ticket = {
439
+ id,
440
+ title: specTitle,
441
+ description,
442
+ status: 'todo',
443
+ priority: priority,
444
+ labels: finalLabels,
445
+ assignee: null,
446
+ prerequisites: [],
447
+ metadata: {},
448
+ comments: [],
449
+ origin_conversation_id: null,
450
+ is_epic: false,
451
+ parent_epic_id: null,
452
+ execution_order: null,
453
+ short_summary: shortSummary,
454
+ created_at: now,
455
+ updated_at: now,
456
+ created_by: 'sr-product-engineer',
457
+ source: 'propose-spec',
458
+ };
459
+ s.tickets[String(id)] = ticket;
460
+ created = ticket;
461
+ });
462
+ ticketWatcher.notifyDesktopWrite(store.revision);
463
+ if (created)
464
+ createdTicketId = created.id;
465
+ // Migrate attachments from pendingSpecId → real ticket id (if any were uploaded).
466
+ // Must complete BEFORE broadcasting ticket_created so WS listeners see the populated attachments[].
467
+ if (pendingSpecId && created) {
468
+ try {
469
+ const migrated = await attachment_manager_1.attachmentManager.renameTicketDir({
470
+ slug: project.slug,
471
+ pendingId: pendingSpecId,
472
+ realTicketId: created.id,
473
+ projectPath: project.path,
474
+ });
475
+ if (migrated.length > 0) {
476
+ created.attachments = migrated;
477
+ }
478
+ }
479
+ catch (err) {
480
+ console.error('[project-router] generate-spec attachment migration error:', err);
481
+ }
482
+ }
483
+ const ticketMsg = {
484
+ type: 'ticket_created', ticket: created,
485
+ projectId, timestamp: new Date().toISOString(),
486
+ };
487
+ broadcast(ticketMsg);
488
+ const doneMsg = {
489
+ type: 'spec_gen_done', projectId, requestId,
490
+ ticket: created, timestamp: new Date().toISOString(),
491
+ };
492
+ broadcast(doneMsg);
493
+ // Quick mode Contract Refine: when toggle is on in the request body
494
+ // AND the project setting + kill switch permit it, fire the no-resume
495
+ // Quick refine path asynchronously. Claude-only today — codex
496
+ // contract refine isn't wired (the spawn hardcodes the `claude`
497
+ // binary). Skip silently on codex projects so the ticket lands
498
+ // without the misleading "Contract layer skipped — model_error"
499
+ // toast that the refine kill-switch would otherwise emit.
500
+ if (quickContractRefine && created && provider === 'claude') {
501
+ const refineTicketId = created.id;
502
+ const refineTitle = created.title;
503
+ const refineDescription = created.description;
504
+ const refineModel = req.body?.model ?? null;
505
+ process.nextTick(() => {
506
+ void (0, contract_refine_runner_1.runContractRefineForQuick)({
507
+ db: ctx(req).db,
508
+ projectId: project.id,
509
+ projectSlug: project.slug,
510
+ projectPath: project.path,
511
+ projectName: project.name,
512
+ broadcast: broadcast,
513
+ }, refineTicketId, refineTitle, refineDescription, refineModel).catch((err) => {
514
+ console.error('[project-router] runContractRefineForQuick error:', err);
515
+ });
516
+ });
517
+ }
518
+ else if (quickContractRefine && created && provider === 'codex') {
519
+ console.log(`[project-router] quick contract refine skipped for codex project (ticket #${created.id}); ` +
520
+ `feature is claude-only today`);
521
+ }
522
+ }
523
+ catch (err) {
524
+ console.error('[project-router] generate-spec ticket creation error:', err);
525
+ const errMsg = {
526
+ type: 'spec_gen_error', projectId, requestId,
527
+ error: 'Failed to create ticket', timestamp: new Date().toISOString(),
528
+ };
529
+ broadcast(errMsg);
530
+ }
531
+ }
532
+ else {
533
+ const reason = code === 0
534
+ ? 'Empty response from AI'
535
+ : resultSubtype === 'error_max_turns'
536
+ ? 'AI hit its turn limit before finishing the spec. Try again, or narrow the idea / turn off full-codebase context.'
537
+ : `Process exited with code ${code}`;
538
+ console.error(`[project-router] spec-gen failed (${requestId}): ${reason}` +
539
+ (stderrBuf.trim() ? `\n stderr: ${stderrBuf.trim()}` : '') +
540
+ (buffer.trim() ? `\n stdout-buffer: ${buffer.trim().slice(0, 500)}` : ''));
541
+ const msg = {
542
+ type: 'spec_gen_error', projectId, requestId,
543
+ error: reason,
544
+ timestamp: new Date().toISOString(),
545
+ };
546
+ broadcast(msg);
547
+ }
548
+ // ai_invocations capture (surface='quick-spec'). Always emit a row, success or fail.
549
+ try {
550
+ // Adapter-driven finalisation: claude passes its native total_cost_usd
551
+ // through untouched; codex (nativeCostUsd:false) gets a pricing-table
552
+ // estimate from its captured token usage + estimated=true. This
553
+ // replaces the legacy normaliseResultEvent path that hardcoded codex
554
+ // cost to $0 and never set the estimated flag.
555
+ const { result: normalised, estimated } = (0, result_event_1.finaliseInvocationResult)(adapter, adapterEvents, { fallbackModel: resolvedModel });
556
+ // extractCodexResult does not surface duration (codex stream carries
557
+ // none); stamp wall-clock so the row's duration isn't lost. Claude's
558
+ // result event already provides duration_ms, so prefer it.
559
+ const wallMs = Date.now() - new Date(turnStartedAt).getTime();
560
+ (0, ai_invocations_1.recordInvocation)(ctx(req).db, {
561
+ id: (0, crypto_1.randomUUID)(),
562
+ project_id: projectId,
563
+ provider: adapter.id,
564
+ surface: 'quick-spec',
565
+ surface_ref_id: requestId,
566
+ ticket_id: createdTicketId,
567
+ status: code === 0 && buffer.trim() ? 'success' : 'failed',
568
+ started_at: turnStartedAt,
569
+ finished_at: new Date().toISOString(),
570
+ total_cost_usd_estimated: estimated,
571
+ ...normalised,
572
+ duration_ms: normalised.duration_ms ?? wallMs,
573
+ });
574
+ broadcast({ type: 'spending.invalidated', projectId });
575
+ }
576
+ catch (err) {
577
+ console.error('[project-router] generate-spec recordInvocation failed:', err);
578
+ }
579
+ });
580
+ });
581
+ // POST /:projectId/tickets/save-as-draft — Persist an in-progress Explore session as a draft ticket
582
+ router.post('/:projectId/tickets/save-as-draft', (req, res) => {
583
+ const body = req.body ?? {};
584
+ const conversationId = typeof body.conversationId === 'string' ? body.conversationId.trim() : '';
585
+ if (!conversationId) {
586
+ res.status(400).json({ error: 'conversationId is required' });
587
+ return;
588
+ }
589
+ const providedTitle = typeof body.title === 'string' ? body.title.trim() : '';
590
+ const labels = Array.isArray(body.labels)
591
+ ? body.labels.filter((l) => typeof l === 'string')
592
+ : [];
593
+ const description = typeof body.description === 'string' ? body.description : '';
594
+ // Optional editTicketId — when present, demote that specific ticket in
595
+ // place instead of looking up by conversationId. Drives the
596
+ // Continue-Editing-on-non-draft flow.
597
+ let editTicketId;
598
+ if (body.editTicketId !== undefined && body.editTicketId !== null) {
599
+ if (typeof body.editTicketId !== 'number' || !Number.isFinite(body.editTicketId)) {
600
+ res.status(400).json({ error: 'editTicketId must be a number' });
601
+ return;
602
+ }
603
+ editTicketId = body.editTicketId;
604
+ }
605
+ try {
606
+ const { db, project, broadcast, ticketWatcher } = ctx(req);
607
+ // Require at least one user-submitted turn before accepting a save
608
+ const messages = (0, db_1.getMessages)(db, conversationId);
609
+ const hasUserTurn = messages.some((m) => m.role === 'user' && (m.content ?? '').trim().length > 0);
610
+ if (!hasUserTurn) {
611
+ res.status(400).json({ error: 'conversation has no user-submitted turn yet' });
612
+ return;
613
+ }
614
+ const filePath = ticketPath(req);
615
+ const now = new Date().toISOString();
616
+ let saved;
617
+ let flippedInPlace = false;
618
+ let notFound = false;
619
+ const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
620
+ if (editTicketId !== undefined) {
621
+ const target = s.tickets[String(editTicketId)];
622
+ if (!target) {
623
+ notFound = true;
624
+ return;
625
+ }
626
+ const title = providedTitle || target.title || (0, explore_draft_title_1.generateAutoTitle)(messages.map((m) => ({ role: m.role, content: m.content ?? '' })));
627
+ target.title = title;
628
+ if (description)
629
+ target.description = description;
630
+ if (labels.length > 0)
631
+ target.labels = labels;
632
+ target.status = 'draft';
633
+ target.priority = null;
634
+ target.origin_conversation_id = conversationId;
635
+ target.updated_at = now;
636
+ saved = target;
637
+ flippedInPlace = true;
638
+ return;
639
+ }
640
+ // Idempotent on conversationId: if a draft ticket already references this
641
+ // conversation, update in place rather than create a second one.
642
+ const existing = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId && t.status === 'draft');
643
+ const title = providedTitle || existing?.title || (0, explore_draft_title_1.generateAutoTitle)(messages.map((m) => ({ role: m.role, content: m.content ?? '' })));
644
+ if (existing) {
645
+ existing.title = title;
646
+ if (description)
647
+ existing.description = description;
648
+ if (labels.length > 0)
649
+ existing.labels = labels;
650
+ existing.updated_at = now;
651
+ saved = existing;
652
+ return;
653
+ }
654
+ const id = s.next_id++;
655
+ const ticket = {
656
+ id,
657
+ title,
658
+ description,
659
+ status: 'draft',
660
+ priority: null,
661
+ labels,
662
+ assignee: null,
663
+ prerequisites: [],
664
+ metadata: {},
665
+ comments: [],
666
+ origin_conversation_id: conversationId,
667
+ is_epic: false,
668
+ parent_epic_id: null,
669
+ execution_order: null,
670
+ short_summary: null,
671
+ created_at: now,
672
+ updated_at: now,
673
+ created_by: 'sr-explore-spec',
674
+ source: 'explore-draft',
675
+ };
676
+ s.tickets[String(id)] = ticket;
677
+ saved = ticket;
678
+ });
679
+ if (notFound) {
680
+ res.status(404).json({ error: 'ticket not found' });
681
+ return;
682
+ }
683
+ ticketWatcher.notifyDesktopWrite(store.revision);
684
+ if (flippedInPlace) {
685
+ const msg = {
686
+ type: 'ticket_updated',
687
+ ticket: saved,
688
+ projectId: project.id,
689
+ timestamp: now,
690
+ };
691
+ broadcast(msg);
692
+ res.status(200).json({ ticket: saved, revision: store.revision });
693
+ return;
694
+ }
695
+ const msg = saved.created_at === saved.updated_at
696
+ ? { type: 'ticket_created', ticket: saved, projectId: project.id, timestamp: now }
697
+ : { type: 'ticket_updated', ticket: saved, projectId: project.id, timestamp: now };
698
+ broadcast(msg);
699
+ res.status(201).json({ ticket: saved, revision: store.revision });
700
+ }
701
+ catch (err) {
702
+ console.error('[project-router] save-as-draft error:', err);
703
+ res.status(500).json({ error: 'Failed to save draft' });
704
+ }
705
+ });
706
+ // POST /:projectId/tickets/from-draft — Commit an Explore Spec draft as a real ticket
707
+ // Two paths:
708
+ // (1) Legacy: payload has no `draftTicketId` → create a brand-new ticket (status='todo').
709
+ // (2) Flip in place: payload has `draftTicketId` referencing an existing
710
+ // status='draft' ticket → update that ticket in place to status='todo',
711
+ // set priority, replace title/description, preserve origin_conversation_id.
712
+ router.post('/:projectId/tickets/from-draft', async (req, res) => {
713
+ const body = req.body ?? {};
714
+ const rawTitle = typeof body.title === 'string' ? body.title.trim() : '';
715
+ if (!rawTitle) {
716
+ res.status(400).json({ error: 'title is required' });
717
+ return;
718
+ }
719
+ const draftTicketId = typeof body.draftTicketId === 'number' ? body.draftTicketId : null;
720
+ const pendingSpecId = typeof body.pendingSpecId === 'string' ? body.pendingSpecId : null;
721
+ const conversationId = typeof body.conversationId === 'string' ? body.conversationId : null;
722
+ const baseDescription = typeof body.description === 'string' ? body.description.trim() : '';
723
+ const labels = Array.isArray(body.labels)
724
+ ? body.labels.filter((l) => typeof l === 'string')
725
+ : [];
726
+ const acceptanceCriteria = Array.isArray(body.acceptanceCriteria)
727
+ ? body.acceptanceCriteria
728
+ .filter((c) => typeof c === 'string')
729
+ .map((c) => c.trim())
730
+ .filter((c) => c.length > 0)
731
+ : [];
732
+ const priority = (0, ticket_store_1.isValidPriority)(body.priority) ? body.priority : 'medium';
733
+ // Compose the final ticket body. The title is already its own ticket
734
+ // field, so we deliberately do NOT echo it as a `## Spec Title` heading
735
+ // inside the description. The body is just the structured sections from
736
+ // Claude (Problem Statement / Proposed Solution / Out of Scope /
737
+ // Technical Considerations / Estimated Complexity) followed by the
738
+ // Acceptance Criteria bullets.
739
+ const description = (0, project_router_helpers_1.formatDescriptionWithCriteria)(baseDescription, acceptanceCriteria);
740
+ // Short summary: explicit body field wins; otherwise try extracting a
741
+ // `## Short Summary` section from the description and stripping it.
742
+ let bodyShortSummary = null;
743
+ let descriptionForStore = description;
744
+ if (typeof body.shortSummary === 'string') {
745
+ bodyShortSummary = (0, ticket_store_1.clampShortSummary)(body.shortSummary);
746
+ }
747
+ else {
748
+ const extracted = (0, project_router_helpers_1.extractShortSummary)(description);
749
+ if (extracted !== null) {
750
+ bodyShortSummary = (0, ticket_store_1.clampShortSummary)(extracted);
751
+ descriptionForStore = description
752
+ .replace(/##\s*Short Summary\s*\n+(?:[^\n]+(?:\n(?!##)[^\n]+)*)\n*/i, '')
753
+ .trim();
754
+ }
755
+ }
756
+ if (bodyShortSummary === null) {
757
+ bodyShortSummary = (0, project_router_helpers_1.deriveFallbackShortSummary)(rawTitle, descriptionForStore);
758
+ }
759
+ try {
760
+ const filePath = ticketPath(req);
761
+ const now = new Date().toISOString();
762
+ let created;
763
+ let wasFlip = false;
764
+ let explicitDraftMissing = false;
765
+ const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
766
+ // Resolve flip target: explicit `draftTicketId` wins; otherwise look up
767
+ // an existing draft ticket whose origin_conversation_id matches the
768
+ // current conversation so a resumed session commits in place even when
769
+ // the client doesn't track the draft id explicitly.
770
+ let flipTarget;
771
+ if (draftTicketId !== null) {
772
+ flipTarget = s.tickets[String(draftTicketId)];
773
+ if (!flipTarget || flipTarget.status !== 'draft') {
774
+ explicitDraftMissing = true;
775
+ return;
776
+ }
777
+ }
778
+ else if (conversationId) {
779
+ flipTarget = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId && t.status === 'draft');
780
+ }
781
+ if (flipTarget) {
782
+ flipTarget.status = 'todo';
783
+ flipTarget.priority = priority;
784
+ flipTarget.title = rawTitle;
785
+ flipTarget.description = descriptionForStore;
786
+ if (labels.length > 0)
787
+ flipTarget.labels = labels;
788
+ flipTarget.updated_at = now;
789
+ // Preserve prior short_summary on flip when the model/body omits one;
790
+ // overwrite only when a non-null value is provided.
791
+ if (bodyShortSummary !== null) {
792
+ flipTarget.short_summary = bodyShortSummary;
793
+ }
794
+ // origin_conversation_id is intentionally preserved
795
+ created = flipTarget;
796
+ wasFlip = true;
797
+ return;
798
+ }
799
+ // B62: from-draft is idempotent only while the ticket is still a draft.
800
+ // After a successful commit the draft is 'todo', so the draft lookup above
801
+ // no longer matches and a second from-draft for the same conversation
802
+ // would insert a DUPLICATE ticket. If a (now non-draft) ticket already
803
+ // originates from this conversation, return it instead of re-inserting.
804
+ if (conversationId) {
805
+ const alreadyCommitted = Object.values(s.tickets).find((t) => t.origin_conversation_id === conversationId);
806
+ if (alreadyCommitted) {
807
+ created = alreadyCommitted;
808
+ wasFlip = true; // treat as in-place: broadcast ticket_updated, not created
809
+ return;
810
+ }
811
+ }
812
+ // Legacy: insert new ticket
813
+ const id = s.next_id++;
814
+ const ticket = {
815
+ id,
816
+ title: rawTitle,
817
+ description: descriptionForStore,
818
+ status: 'todo',
819
+ priority,
820
+ labels,
821
+ assignee: null,
822
+ prerequisites: [],
823
+ metadata: {},
824
+ comments: [],
825
+ origin_conversation_id: conversationId,
826
+ is_epic: false,
827
+ parent_epic_id: null,
828
+ execution_order: null,
829
+ short_summary: bodyShortSummary,
830
+ created_at: now,
831
+ updated_at: now,
832
+ created_by: 'sr-explore-spec',
833
+ source: 'propose-spec',
834
+ };
835
+ s.tickets[String(id)] = ticket;
836
+ created = ticket;
837
+ });
838
+ if (explicitDraftMissing) {
839
+ res.status(404).json({ error: 'Draft ticket not found or not in draft status' });
840
+ return;
841
+ }
842
+ const { broadcast, ticketWatcher, project } = ctx(req);
843
+ ticketWatcher.notifyDesktopWrite(store.revision);
844
+ // Migrate attachments from pendingSpecId → real ticket id (mirrors the
845
+ // generate-spec flow). Must complete before broadcasting ticket_created
846
+ // so listeners see the populated attachments[].
847
+ if (pendingSpecId && created) {
848
+ try {
849
+ const migrated = await attachment_manager_1.attachmentManager.renameTicketDir({
850
+ slug: project.slug,
851
+ pendingId: pendingSpecId,
852
+ realTicketId: created.id,
853
+ projectPath: project.path,
854
+ });
855
+ if (migrated.length > 0) {
856
+ created.attachments = migrated;
857
+ }
858
+ }
859
+ catch (err) {
860
+ console.error('[project-router] from-draft attachment migration error:', err);
861
+ }
862
+ }
863
+ const msg = wasFlip
864
+ ? {
865
+ type: 'ticket_updated',
866
+ ticket: created,
867
+ projectId: project.id,
868
+ timestamp: new Date().toISOString(),
869
+ }
870
+ : {
871
+ type: 'ticket_created',
872
+ ticket: created,
873
+ projectId: project.id,
874
+ timestamp: new Date().toISOString(),
875
+ };
876
+ broadcast(msg);
877
+ // Back-fill ticket_id on the conversation's prior ai_invocations rows.
878
+ if (conversationId && created) {
879
+ try {
880
+ const changes = (0, ai_invocations_1.updateTicketIdForConversation)(ctx(req).db, conversationId, created.id);
881
+ if (changes > 0) {
882
+ broadcast({ type: 'spending.invalidated', projectId: project.id });
883
+ }
884
+ }
885
+ catch (err) {
886
+ console.error('[project-router] from-draft ai_invocations back-fill failed:', err);
887
+ }
888
+ }
889
+ res.status(201).json({ ticket: created, revision: store.revision });
890
+ // Fire Contract Refine post-commit (fire-and-forget). Toggle + kill-switch
891
+ // are checked inside runContractRefine. Claude-only today — codex
892
+ // contract refine isn't wired (the spawn hardcodes the `claude`
893
+ // binary). Skip silently on codex projects.
894
+ if (conversationId && created && project.provider === 'claude') {
895
+ const createdTicketId = created.id;
896
+ const convoId = conversationId;
897
+ console.log(`[project-router] from-draft hook: scheduling refine ticket=${createdTicketId} conv=${convoId}`);
898
+ process.nextTick(() => {
899
+ void (0, contract_refine_runner_1.runContractRefine)({
900
+ db: ctx(req).db,
901
+ projectId: project.id,
902
+ projectSlug: project.slug,
903
+ projectPath: project.path,
904
+ projectName: project.name,
905
+ broadcast: broadcast,
906
+ }, convoId, createdTicketId).catch((err) => {
907
+ console.error('[project-router] runContractRefine error:', err);
908
+ });
909
+ });
910
+ }
911
+ else if (conversationId && created && project.provider === 'codex') {
912
+ console.log(`[project-router] from-draft contract refine skipped for codex project (ticket #${created.id})`);
913
+ }
914
+ }
915
+ catch (err) {
916
+ console.error('[project-router] from-draft create error:', err);
917
+ res.status(500).json({ error: 'Failed to create ticket' });
918
+ }
919
+ });
920
+ // POST /:projectId/tickets/from-prompt — Create a spec directly from a
921
+ // free-form prompt (the "Raw" Add-Spec mode). NO AI is invoked: the user's
922
+ // text becomes the ticket description verbatim. The ticket lands as
923
+ // status='todo' (ready for rails) with source='free-prompt'. There is no
924
+ // ai_invocations row (nothing was billed) and no contract-refine (no origin
925
+ // conversation, no description format to refine).
926
+ router.post('/:projectId/tickets/from-prompt', async (req, res) => {
927
+ const body = req.body ?? {};
928
+ const rawDescription = typeof body.description === 'string' ? body.description.trim() : '';
929
+ if (!rawDescription) {
930
+ res.status(400).json({ error: 'description is required' });
931
+ return;
932
+ }
933
+ // Optional light-structuring (v1: the client always sends `false`; the flag
934
+ // keeps the contract stable for a future non-generative structuring pass).
935
+ const structured = body.structured === true;
936
+ const description = structured ? (0, project_router_helpers_1.lightlyStructurePrompt)(rawDescription) : rawDescription;
937
+ // Title: explicit value wins; otherwise derive a single-line summary from
938
+ // the body (reusing the deterministic Explore-draft summarizer).
939
+ const providedTitle = typeof body.title === 'string' ? body.title.trim() : '';
940
+ const title = providedTitle || (0, explore_draft_title_1.generateAutoTitle)([{ role: 'user', content: rawDescription }]);
941
+ const labels = Array.isArray(body.labels)
942
+ ? body.labels.filter((l) => typeof l === 'string')
943
+ : [];
944
+ // Priority: validate against the allowed set; default 'medium'. A
945
+ // status='todo' ticket MUST carry a non-null priority (see
946
+ // validatePriorityForStatus), so we never accept null here.
947
+ const priority = (0, ticket_store_1.isValidPriority)(body.priority) ? body.priority : 'medium';
948
+ const validationError = (0, ticket_store_1.validatePriorityForStatus)('todo', priority);
949
+ if (validationError) {
950
+ res.status(400).json({ error: validationError });
951
+ return;
952
+ }
953
+ const pendingSpecId = typeof body.pendingSpecId === 'string' ? body.pendingSpecId : null;
954
+ const shortSummary = (0, project_router_helpers_1.deriveFallbackShortSummary)(title, description);
955
+ try {
956
+ const filePath = ticketPath(req);
957
+ const now = new Date().toISOString();
958
+ let created;
959
+ const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
960
+ const id = s.next_id++;
961
+ const ticket = {
962
+ id,
963
+ title,
964
+ description,
965
+ status: 'todo',
966
+ priority,
967
+ labels,
968
+ assignee: null,
969
+ prerequisites: [],
970
+ metadata: {},
971
+ comments: [],
972
+ origin_conversation_id: null,
973
+ is_epic: false,
974
+ parent_epic_id: null,
975
+ execution_order: null,
976
+ short_summary: shortSummary,
977
+ created_at: now,
978
+ updated_at: now,
979
+ created_by: 'hub', // legacy on-disk wire value (tickets.json, shared with specrails-core) — do not rename
980
+ source: 'free-prompt',
981
+ };
982
+ s.tickets[String(id)] = ticket;
983
+ created = ticket;
984
+ });
985
+ const { broadcast, ticketWatcher, project } = ctx(req);
986
+ ticketWatcher.notifyDesktopWrite(store.revision);
987
+ // Migrate attachments from pendingSpecId → real ticket id (mirrors the
988
+ // generate-spec / from-draft flow). Must complete before broadcasting so
989
+ // listeners see the populated attachments[].
990
+ if (pendingSpecId && created) {
991
+ try {
992
+ const migrated = await attachment_manager_1.attachmentManager.renameTicketDir({
993
+ slug: project.slug,
994
+ pendingId: pendingSpecId,
995
+ realTicketId: created.id,
996
+ projectPath: project.path,
997
+ });
998
+ if (migrated.length > 0) {
999
+ created.attachments = migrated;
1000
+ }
1001
+ }
1002
+ catch (err) {
1003
+ console.error('[project-router] from-prompt attachment migration error:', err);
1004
+ }
1005
+ }
1006
+ const msg = {
1007
+ type: 'ticket_created',
1008
+ ticket: created,
1009
+ projectId: project.id,
1010
+ timestamp: new Date().toISOString(),
1011
+ };
1012
+ broadcast(msg);
1013
+ res.status(201).json({ ticket: created, revision: store.revision });
1014
+ }
1015
+ catch (err) {
1016
+ console.error('[project-router] from-prompt create error:', err);
1017
+ res.status(500).json({ error: 'Failed to create ticket' });
1018
+ }
1019
+ });
1020
+ // POST /:projectId/tickets/:id/contract-refine — Manually re-fire refine
1021
+ router.post('/:projectId/tickets/:id/contract-refine', async (req, res) => {
1022
+ const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
1023
+ if (!Number.isFinite(ticketId)) {
1024
+ res.status(400).json({ error: 'invalid ticket id' });
1025
+ return;
1026
+ }
1027
+ const { project, db, broadcast } = ctx(req);
1028
+ if ((0, explore_contract_refine_1.isExploreContractRefineKillSwitchActive)()) {
1029
+ res.status(409).json({ error: 'feature_disabled_by_env' });
1030
+ return;
1031
+ }
1032
+ if (project.provider === 'codex') {
1033
+ res.status(409).json({ error: 'contract_refine_unsupported_for_codex' });
1034
+ return;
1035
+ }
1036
+ // Validate the ticket exists.
1037
+ try {
1038
+ const filePath = ticketPath(req);
1039
+ const { withLock } = await Promise.resolve().then(() => __importStar(require('./ticket-store')));
1040
+ const ticket = withLock(filePath, (s) => s.tickets[String(ticketId)]);
1041
+ if (!ticket) {
1042
+ res.status(404).json({ error: 'ticket not found' });
1043
+ return;
1044
+ }
1045
+ if (!ticket.origin_conversation_id) {
1046
+ res.status(409).json({ error: 'ticket has no origin conversation' });
1047
+ return;
1048
+ }
1049
+ const convoId = ticket.origin_conversation_id;
1050
+ res.status(202).json({ scheduled: true });
1051
+ process.nextTick(() => {
1052
+ void (0, contract_refine_runner_1.runContractRefine)({
1053
+ db,
1054
+ projectId: project.id,
1055
+ projectSlug: project.slug,
1056
+ projectPath: project.path,
1057
+ projectName: project.name,
1058
+ broadcast: broadcast,
1059
+ ignoreConversationScope: true,
1060
+ }, convoId, ticketId).catch((err) => {
1061
+ console.error('[project-router] retry runContractRefine error:', err);
1062
+ });
1063
+ });
1064
+ }
1065
+ catch (err) {
1066
+ console.error('[project-router] retry endpoint error:', err);
1067
+ res.status(500).json({ error: 'Failed to schedule retry' });
1068
+ }
1069
+ });
1070
+ // POST /:projectId/tickets/:id/smash — Decompose ticket into N children
1071
+ router.post('/:projectId/tickets/:id/smash', async (req, res) => {
1072
+ const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
1073
+ if (!Number.isFinite(ticketId)) {
1074
+ res.status(400).json({ error: 'invalid ticket id' });
1075
+ return;
1076
+ }
1077
+ if ((0, explore_smash_1.isSpecsSmashKillSwitchActive)()) {
1078
+ res.status(409).json({ error: 'feature_disabled_by_env', reason: 'disabled' });
1079
+ return;
1080
+ }
1081
+ const { project, db, broadcast } = ctx(req);
1082
+ try {
1083
+ const filePath = ticketPath(req);
1084
+ const { readStore } = await Promise.resolve().then(() => __importStar(require('./ticket-store')));
1085
+ const store = readStore(filePath);
1086
+ const gate = (0, smash_runner_1.checkSmashEligibility)(store, ticketId);
1087
+ if (!gate.ok) {
1088
+ const statusCode = gate.reason === 'ticket-not-found' ? 404 : 409;
1089
+ res.status(statusCode).json({ error: 'ineligible', reason: gate.reason });
1090
+ return;
1091
+ }
1092
+ const rawMode = typeof req.body?.mode === 'string' ? req.body.mode : 'simple';
1093
+ const mode = rawMode === 'full' ? 'full' : 'simple';
1094
+ const model = typeof req.body?.model === 'string' && req.body.model.length > 0 ? req.body.model : null;
1095
+ res.status(202).json({ scheduled: true, mode });
1096
+ process.nextTick(() => {
1097
+ void (0, smash_runner_1.runSmash)({
1098
+ db,
1099
+ projectId: project.id,
1100
+ projectSlug: project.slug,
1101
+ projectPath: project.path,
1102
+ projectName: project.name,
1103
+ broadcast: broadcast,
1104
+ mode,
1105
+ model,
1106
+ }, ticketId).catch((err) => {
1107
+ console.error('[project-router] runSmash error:', err);
1108
+ });
1109
+ });
1110
+ }
1111
+ catch (err) {
1112
+ console.error('[project-router] smash endpoint error:', err);
1113
+ res.status(500).json({ error: 'Failed to schedule SMASH' });
1114
+ }
1115
+ });
1116
+ // POST /:projectId/tickets/:id/smash/undo — Reverse a prior SMASH
1117
+ router.post('/:projectId/tickets/:id/smash/undo', async (req, res) => {
1118
+ const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
1119
+ if (!Number.isFinite(ticketId)) {
1120
+ res.status(400).json({ error: 'invalid ticket id' });
1121
+ return;
1122
+ }
1123
+ if ((0, explore_smash_1.isSpecsSmashKillSwitchActive)()) {
1124
+ res.status(409).json({ error: 'feature_disabled_by_env', reason: 'disabled' });
1125
+ return;
1126
+ }
1127
+ const smashedAt = typeof req.body?.smashedAt === 'string' ? req.body.smashedAt : null;
1128
+ if (!smashedAt) {
1129
+ res.status(400).json({ error: 'smashedAt timestamp required' });
1130
+ return;
1131
+ }
1132
+ const { project, db, broadcast } = ctx(req);
1133
+ try {
1134
+ const result = await (0, smash_runner_1.runSmashUndo)({
1135
+ db,
1136
+ projectId: project.id,
1137
+ projectSlug: project.slug,
1138
+ projectPath: project.path,
1139
+ projectName: project.name,
1140
+ broadcast: broadcast,
1141
+ }, ticketId, smashedAt);
1142
+ if (!result.ok) {
1143
+ const statusCode = result.reason === 'ticket-not-found' ? 404 : 409;
1144
+ res.status(statusCode).json({ error: 'undo_failed', reason: result.reason });
1145
+ return;
1146
+ }
1147
+ res.json({ ok: true, deletedChildren: result.deletedChildren });
1148
+ }
1149
+ catch (err) {
1150
+ console.error('[project-router] smash/undo endpoint error:', err);
1151
+ res.status(500).json({ error: 'Failed to undo SMASH' });
1152
+ }
1153
+ });
1154
+ // DELETE /:projectId/tickets/:id/children — Delete all children of an épica
1155
+ router.delete('/:projectId/tickets/:id/children', (req, res) => {
1156
+ const ticketId = Number.parseInt(String(req.params.id ?? ''), 10);
1157
+ if (!Number.isFinite(ticketId)) {
1158
+ res.status(400).json({ error: 'invalid ticket id' });
1159
+ return;
1160
+ }
1161
+ if ((0, explore_smash_1.isSpecsSmashKillSwitchActive)()) {
1162
+ res.status(409).json({ error: 'feature_disabled_by_env', reason: 'disabled' });
1163
+ return;
1164
+ }
1165
+ const { project, broadcast, ticketWatcher } = ctx(req);
1166
+ try {
1167
+ const filePath = ticketPath(req);
1168
+ const result = (0, smash_runner_1.applyDeleteEpicChildren)(filePath, ticketId);
1169
+ // Pass the real post-write revision (not 0) so the chokidar echo is
1170
+ // suppressed; a hardcoded 0 never matches the on-disk revision and
1171
+ // triggers a spurious full-refresh broadcast to every client.
1172
+ ticketWatcher.notifyDesktopWrite(result.revision);
1173
+ const now = new Date().toISOString();
1174
+ for (const id of result.deletedChildren) {
1175
+ broadcast({
1176
+ type: 'ticket_deleted',
1177
+ ticketId: id,
1178
+ projectId: project.id,
1179
+ timestamp: now,
1180
+ });
1181
+ }
1182
+ res.json({ ok: true, deletedChildren: result.deletedChildren });
1183
+ }
1184
+ catch (err) {
1185
+ console.error('[project-router] delete-children error:', err);
1186
+ res.status(500).json({ error: 'Failed to delete children' });
1187
+ }
1188
+ });
1189
+ // POST /:projectId/tickets — Create new ticket
1190
+ router.post('/:projectId/tickets', (req, res) => {
1191
+ const { title, description, status, priority, labels, assignee, prerequisites, metadata, source } = req.body ?? {};
1192
+ if (!title || typeof title !== 'string' || !title.trim()) {
1193
+ res.status(400).json({ error: 'title is required' });
1194
+ return;
1195
+ }
1196
+ if (status !== undefined && !(0, ticket_store_1.isValidStatus)(status)) {
1197
+ res.status(400).json({ error: 'status must be one of: draft, todo, in_progress, done, cancelled' });
1198
+ return;
1199
+ }
1200
+ const finalStatus = (status ?? 'todo');
1201
+ const finalPriority = priority === undefined ? (finalStatus === 'draft' ? null : 'medium') : (priority === null ? null : priority);
1202
+ const priorityError = (0, ticket_store_1.validatePriorityForStatus)(finalStatus, finalPriority);
1203
+ if (priorityError) {
1204
+ res.status(400).json({ error: priorityError });
1205
+ return;
1206
+ }
1207
+ try {
1208
+ const filePath = ticketPath(req);
1209
+ const now = new Date().toISOString();
1210
+ let created;
1211
+ const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
1212
+ const id = s.next_id++;
1213
+ const ticket = {
1214
+ id,
1215
+ title: title.trim(),
1216
+ description: typeof description === 'string' ? description : '',
1217
+ status: finalStatus,
1218
+ priority: finalPriority,
1219
+ labels: Array.isArray(labels) ? labels.filter((l) => typeof l === 'string') : [],
1220
+ assignee: typeof assignee === 'string' ? assignee : null,
1221
+ prerequisites: Array.isArray(prerequisites) ? prerequisites.filter((p) => typeof p === 'number') : [],
1222
+ metadata: typeof metadata === 'object' && metadata !== null ? metadata : {},
1223
+ comments: [],
1224
+ origin_conversation_id: null,
1225
+ is_epic: false,
1226
+ parent_epic_id: null,
1227
+ execution_order: null,
1228
+ short_summary: null,
1229
+ created_at: now,
1230
+ updated_at: now,
1231
+ created_by: 'hub', // legacy on-disk wire value (tickets.json, shared with specrails-core) — do not rename
1232
+ source: source === 'product-backlog' || source === 'propose-spec' || source === 'manual' ? source : 'hub', // legacy on-disk wire value — do not rename
1233
+ };
1234
+ s.tickets[String(id)] = ticket;
1235
+ created = ticket;
1236
+ });
1237
+ const { broadcast, ticketWatcher } = ctx(req);
1238
+ ticketWatcher.notifyDesktopWrite(store.revision);
1239
+ const msg = { type: 'ticket_created', ticket: created, projectId: ctx(req).project.id, timestamp: new Date().toISOString() };
1240
+ broadcast(msg);
1241
+ res.status(201).json({ ticket: created, revision: store.revision });
1242
+ }
1243
+ catch (err) {
1244
+ console.error('[project-router] ticket create error:', err);
1245
+ res.status(500).json({ error: 'Failed to create ticket' });
1246
+ }
1247
+ });
1248
+ // PATCH /:projectId/tickets/:id — Update ticket fields
1249
+ router.patch('/:projectId/tickets/:id', (req, res) => {
1250
+ const ticketId = req.params.id;
1251
+ if (!/^\d+$/.test(ticketId)) {
1252
+ res.status(400).json({ error: 'Invalid ticket ID' });
1253
+ return;
1254
+ }
1255
+ const { title, description, status, priority, labels, assignee, prerequisites, metadata, acceptanceCriteria, short_summary } = req.body ?? {};
1256
+ if (status !== undefined && !(0, ticket_store_1.isValidStatus)(status)) {
1257
+ res.status(400).json({ error: 'status must be one of: draft, todo, in_progress, done, cancelled' });
1258
+ return;
1259
+ }
1260
+ if (priority !== undefined && priority !== null && !(0, ticket_store_1.isValidPriority)(priority)) {
1261
+ res.status(400).json({ error: 'priority must be one of: critical, high, medium, low' });
1262
+ return;
1263
+ }
1264
+ if (title !== undefined && (typeof title !== 'string' || !title.trim())) {
1265
+ res.status(400).json({ error: 'title cannot be empty' });
1266
+ return;
1267
+ }
1268
+ if (acceptanceCriteria !== undefined) {
1269
+ if (!Array.isArray(acceptanceCriteria) || !acceptanceCriteria.every((c) => typeof c === 'string')) {
1270
+ res.status(400).json({ error: 'acceptanceCriteria must be an array of strings' });
1271
+ return;
1272
+ }
1273
+ }
1274
+ try {
1275
+ const filePath = ticketPath(req);
1276
+ let updated;
1277
+ let validationError = null;
1278
+ const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
1279
+ const ticket = s.tickets[ticketId];
1280
+ if (!ticket)
1281
+ return;
1282
+ const nextStatus = (status ?? ticket.status);
1283
+ const nextPriority = priority === undefined ? ticket.priority : (priority === null ? null : priority);
1284
+ const err = (0, ticket_store_1.validatePriorityForStatus)(nextStatus, nextPriority);
1285
+ if (err) {
1286
+ validationError = err;
1287
+ return;
1288
+ }
1289
+ if (title !== undefined)
1290
+ ticket.title = title.trim();
1291
+ if (description !== undefined)
1292
+ ticket.description = description;
1293
+ if (acceptanceCriteria !== undefined) {
1294
+ // Fold criteria into the description body under a `## Acceptance Criteria`
1295
+ // section, replacing any existing one. Use the just-set description if
1296
+ // present, otherwise the ticket's current description.
1297
+ ticket.description = (0, project_router_helpers_1.formatDescriptionWithCriteria)(ticket.description ?? '', acceptanceCriteria);
1298
+ }
1299
+ if (status !== undefined)
1300
+ ticket.status = status;
1301
+ if (priority !== undefined)
1302
+ ticket.priority = nextPriority;
1303
+ if (labels !== undefined && Array.isArray(labels))
1304
+ ticket.labels = labels.filter((l) => typeof l === 'string');
1305
+ if (assignee !== undefined)
1306
+ ticket.assignee = typeof assignee === 'string' ? assignee : null;
1307
+ if (prerequisites !== undefined && Array.isArray(prerequisites))
1308
+ ticket.prerequisites = prerequisites.filter((p) => typeof p === 'number');
1309
+ if (metadata !== undefined && typeof metadata === 'object' && metadata !== null) {
1310
+ ticket.metadata = { ...ticket.metadata, ...metadata };
1311
+ }
1312
+ // Short summary: explicit non-empty overwrites; explicit null clears;
1313
+ // omitted leaves the existing value untouched (preserves prior summary
1314
+ // when AI Refine omits it for a partial edit).
1315
+ if (short_summary === null) {
1316
+ ticket.short_summary = null;
1317
+ }
1318
+ else if (typeof short_summary === 'string') {
1319
+ ticket.short_summary = (0, ticket_store_1.clampShortSummary)(short_summary);
1320
+ }
1321
+ ticket.updated_at = new Date().toISOString();
1322
+ updated = ticket;
1323
+ });
1324
+ if (validationError) {
1325
+ res.status(400).json({ error: validationError });
1326
+ return;
1327
+ }
1328
+ if (!updated) {
1329
+ res.status(404).json({ error: 'Ticket not found' });
1330
+ return;
1331
+ }
1332
+ const { broadcast, ticketWatcher } = ctx(req);
1333
+ ticketWatcher.notifyDesktopWrite(store.revision);
1334
+ const msg = { type: 'ticket_updated', ticket: updated, projectId: ctx(req).project.id, timestamp: new Date().toISOString() };
1335
+ broadcast(msg);
1336
+ res.json({ ticket: updated, revision: store.revision });
1337
+ }
1338
+ catch (err) {
1339
+ console.error('[project-router] ticket update error:', err);
1340
+ res.status(500).json({ error: 'Failed to update ticket' });
1341
+ }
1342
+ });
1343
+ // POST /:projectId/tickets/:id/ai-edit — AI-powered description editing
1344
+ const _aiEditProcesses = new Map();
1345
+ router.post('/:projectId/tickets/:id/ai-edit', async (req, res) => {
1346
+ const ticketId = req.params.id;
1347
+ if (!/^\d+$/.test(ticketId)) {
1348
+ res.status(400).json({ error: 'Invalid ticket ID' });
1349
+ return;
1350
+ }
1351
+ const instructions = req.body?.instructions;
1352
+ const currentDescription = req.body?.description;
1353
+ const currentTitle = typeof req.body?.title === 'string' ? req.body.title : '';
1354
+ if (!instructions?.trim()) {
1355
+ res.status(400).json({ error: 'instructions is required' });
1356
+ return;
1357
+ }
1358
+ if (!currentDescription) {
1359
+ res.status(400).json({ error: 'description is required' });
1360
+ return;
1361
+ }
1362
+ const attachmentIds = Array.isArray(req.body?.attachmentIds)
1363
+ ? req.body.attachmentIds.filter((x) => typeof x === 'string')
1364
+ : [];
1365
+ const priorInstructions = Array.isArray(req.body?.priorInstructions)
1366
+ ? req.body.priorInstructions.filter((x) => typeof x === 'string')
1367
+ : [];
1368
+ const priorProposalRaw = req.body?.priorProposal;
1369
+ const priorProposal = typeof priorProposalRaw === 'string' && priorProposalRaw.length > 0 ? priorProposalRaw : null;
1370
+ const isRefinement = priorProposal !== null;
1371
+ const { project, broadcast } = ctx(req);
1372
+ const provider = project.provider ?? 'claude';
1373
+ const requestId = (0, ids_1.newId)();
1374
+ const projectId = project.id;
1375
+ // Build the focused pre-prompt
1376
+ const baseRules = `- Output format MUST be exactly:\n` +
1377
+ ` TITLE: <one-line spec title>\n` +
1378
+ ` SHORT-SUMMARY: <one or two plain-language sentences, max 120 chars, summarising the spec for a dashboard postit. No markdown, no bullets.>\n` +
1379
+ ` \n` +
1380
+ ` <markdown description body>\n` +
1381
+ ` The first line MUST start with "TITLE: " followed by the refined title.\n` +
1382
+ ` The second line MUST start with "SHORT-SUMMARY: " followed by the summary.\n` +
1383
+ ` Then exactly one blank line. Then the markdown description.\n` +
1384
+ `- Keep the title concise (under 80 characters) and reflective of the latest description.\n` +
1385
+ ` If the user's refinement does not affect the title's intent, you may keep it unchanged — but always emit the TITLE line.\n` +
1386
+ `- The SHORT-SUMMARY line MUST always be present. If the user's refinement does not change what the spec is about, keep the previous summary verbatim. Never omit the line.\n` +
1387
+ `- After the SHORT-SUMMARY line and blank line, output ONLY the modified description in markdown. No preamble, no explanation, no wrapping.\n` +
1388
+ `- Preserve the existing markdown structure and section headings in the description.\n` +
1389
+ `- If the user asks to add technical details, briefly check CLAUDE.md and the project directory structure (ls, not deep reads) to ground your edits.\n` +
1390
+ `- Keep it concise and actionable.\n` +
1391
+ `- Do NOT create files, tickets, or issues. Only output text.`;
1392
+ const refinementRule = isRefinement
1393
+ ? `\n- You are editing an in-progress draft, not the saved description. Apply the new refinement to the Latest Draft below.`
1394
+ : '';
1395
+ let systemPrompt = `You are a spec editor. You will receive a ticket title and description plus user instructions for how to modify them. ` +
1396
+ `Your job is to produce an improved version of BOTH the title and the description.\n\n` +
1397
+ `RULES:\n` +
1398
+ `${baseRules}${refinementRule}`;
1399
+ let userPrompt = isRefinement
1400
+ ? `## Current Title (saved baseline)\n\n${currentTitle}\n\n` +
1401
+ `## Current Description (saved baseline — do not rewrite)\n\n${currentDescription}\n\n` +
1402
+ `## Prior Refinement Turns\n\n${priorInstructions.map((s, i) => `${i + 1}. ${s}`).join('\n')}\n\n` +
1403
+ `## Latest Draft (from previous turn — apply the new refinement to this; the draft already includes a TITLE: line)\n\n${priorProposal}\n\n` +
1404
+ `## New Refinement\n\n${instructions.trim()}\n\n` +
1405
+ `Output the updated TITLE line followed by the updated description now.`
1406
+ : `## Current Title\n\n${currentTitle}\n\n` +
1407
+ `## Current Description\n\n${currentDescription}\n\n` +
1408
+ `## User Instructions\n\n${instructions.trim()}\n\n` +
1409
+ `Output the modified TITLE line followed by the modified description now.`;
1410
+ let imageFlags = [];
1411
+ if (attachmentIds.length > 0) {
1412
+ try {
1413
+ const extracted = await attachment_manager_1.attachmentManager.getClaudeArgs(project.slug, ticketId, attachmentIds);
1414
+ imageFlags = extracted.imageFlags;
1415
+ if (extracted.textBlocks.length > 0) {
1416
+ systemPrompt = `${systemPrompt}\n\n${attachment_manager_1.USER_ATTACHMENT_SYSTEM_NOTE}`;
1417
+ userPrompt = `${userPrompt}\n\n## Attached Files\n\n${extracted.textBlocks.join('\n\n')}`;
1418
+ }
1419
+ }
1420
+ catch (err) {
1421
+ console.error('[project-router] ai-edit attachment extraction error:', err);
1422
+ }
1423
+ }
1424
+ let binary;
1425
+ let args;
1426
+ if (provider === 'codex') {
1427
+ binary = 'codex';
1428
+ // Use gpt-5.5 (default for Codex per CODEX_MODELS/PRESET_DEFAULTS in ModelSelector); never hardcode o4-mini
1429
+ args = ['exec', `${systemPrompt}\n\n${userPrompt}`, '--model', 'gpt-5.5'];
1430
+ }
1431
+ else {
1432
+ binary = 'claude';
1433
+ args = [
1434
+ '--dangerously-skip-permissions',
1435
+ '--tools', 'default',
1436
+ '--output-format', 'stream-json',
1437
+ '--verbose',
1438
+ '--max-turns', '4',
1439
+ ...imageFlags,
1440
+ '--system-prompt', systemPrompt,
1441
+ '-p', userPrompt,
1442
+ ];
1443
+ }
1444
+ // spawnAiCli reroutes multi-line argv values through stdin on Windows.
1445
+ console.log(`[project-router] ai-edit spawn: ${binary} (cwd=${project.path}, requestId=${requestId})`);
1446
+ const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
1447
+ env: process.env,
1448
+ stdio: ['ignore', 'pipe', 'pipe'],
1449
+ cwd: project.path,
1450
+ });
1451
+ _aiEditProcesses.set(requestId, child);
1452
+ // Pipe stderr to server log so failures surface for debugging.
1453
+ let aiEditStderrBuf = '';
1454
+ /* c8 ignore start -- diagnostic-only; fires only when claude writes stderr */
1455
+ child.stderr?.on('data', (chunk) => {
1456
+ const s = chunk.toString();
1457
+ aiEditStderrBuf += s;
1458
+ console.error(`[project-router] ai-edit stderr (${requestId}): ${s.trimEnd()}`);
1459
+ });
1460
+ /* c8 ignore stop */
1461
+ // Without this listener, ENOENT (binary missing on PATH) propagates as
1462
+ // an unhandled 'error' event and crashes the entire app process.
1463
+ /* c8 ignore start -- spawn-failure path; exercised manually, not in CI */
1464
+ child.on('error', (err) => {
1465
+ console.error(`[project-router] ai-edit spawn failed (${binary}): ${err.message}`);
1466
+ _aiEditProcesses.delete(requestId);
1467
+ const errMsg = {
1468
+ type: 'ticket_ai_edit_error', projectId, ticketId: Number(ticketId),
1469
+ requestId, error: `Failed to launch ${binary}: ${err.message}`,
1470
+ timestamp: new Date().toISOString(),
1471
+ };
1472
+ broadcast(errMsg);
1473
+ });
1474
+ /* c8 ignore stop */
1475
+ res.status(202).json({ requestId });
1476
+ let buffer = '';
1477
+ const stdoutReader = (0, readline_1.createInterface)({ input: child.stdout, crlfDelay: Infinity });
1478
+ stdoutReader.on('line', (line) => {
1479
+ if (provider === 'codex') {
1480
+ if (line) {
1481
+ buffer += line + '\n';
1482
+ const msg = {
1483
+ type: 'ticket_ai_edit_stream', projectId, ticketId: Number(ticketId),
1484
+ requestId, delta: line + '\n', timestamp: new Date().toISOString(),
1485
+ };
1486
+ broadcast(msg);
1487
+ }
1488
+ }
1489
+ else {
1490
+ let parsed = null;
1491
+ try {
1492
+ parsed = JSON.parse(line);
1493
+ }
1494
+ catch { /* skip */ }
1495
+ if (!parsed)
1496
+ return;
1497
+ if (parsed.type === 'assistant') {
1498
+ const msg = parsed.message;
1499
+ const texts = (msg?.content ?? [])
1500
+ .filter((c) => c.type === 'text')
1501
+ .map((c) => c.text ?? '');
1502
+ const newText = texts.join('');
1503
+ if (newText) {
1504
+ buffer += newText;
1505
+ const wsMsg = {
1506
+ type: 'ticket_ai_edit_stream', projectId, ticketId: Number(ticketId),
1507
+ requestId, delta: newText, timestamp: new Date().toISOString(),
1508
+ };
1509
+ broadcast(wsMsg);
1510
+ }
1511
+ }
1512
+ }
1513
+ });
1514
+ child.on('close', (code) => {
1515
+ _aiEditProcesses.delete(requestId);
1516
+ if (code === 0 && buffer.trim()) {
1517
+ const msg = {
1518
+ type: 'ticket_ai_edit_done', projectId, ticketId: Number(ticketId),
1519
+ requestId, fullText: buffer.trim(), timestamp: new Date().toISOString(),
1520
+ };
1521
+ broadcast(msg);
1522
+ }
1523
+ else {
1524
+ const reason = code === 0 ? 'Empty response from AI' : `Process exited with code ${code}`;
1525
+ console.error(`[project-router] ai-edit failed (${requestId}): ${reason}` +
1526
+ (aiEditStderrBuf.trim() ? `\n stderr: ${aiEditStderrBuf.trim()}` : '') +
1527
+ (buffer.trim() ? `\n stdout-buffer: ${buffer.trim().slice(0, 500)}` : ''));
1528
+ const msg = {
1529
+ type: 'ticket_ai_edit_error', projectId, ticketId: Number(ticketId),
1530
+ requestId, error: reason,
1531
+ timestamp: new Date().toISOString(),
1532
+ };
1533
+ broadcast(msg);
1534
+ }
1535
+ });
1536
+ });
1537
+ router.delete('/:projectId/tickets/:id/ai-edit', (req, res) => {
1538
+ const requestId = req.query.requestId;
1539
+ if (!requestId) {
1540
+ res.status(400).json({ error: 'requestId query param required' });
1541
+ return;
1542
+ }
1543
+ const child = _aiEditProcesses.get(requestId);
1544
+ if (!child?.pid) {
1545
+ res.status(404).json({ error: 'No active AI edit for this request' });
1546
+ return;
1547
+ }
1548
+ (0, tree_kill_1.default)(child.pid, 'SIGTERM');
1549
+ _aiEditProcesses.delete(requestId);
1550
+ res.json({ ok: true });
1551
+ });
1552
+ // DELETE /:projectId/tickets/:id — Delete ticket
1553
+ router.delete('/:projectId/tickets/:id', (req, res) => {
1554
+ const ticketId = req.params.id;
1555
+ if (!/^\d+$/.test(ticketId)) {
1556
+ res.status(400).json({ error: 'Invalid ticket ID' });
1557
+ return;
1558
+ }
1559
+ try {
1560
+ const filePath = ticketPath(req);
1561
+ let found = false;
1562
+ let orphanedConversationId = null;
1563
+ const orphanedChildren = [];
1564
+ const numericId = Number(ticketId);
1565
+ const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
1566
+ const t = s.tickets[ticketId];
1567
+ if (!t)
1568
+ return;
1569
+ // If this is a draft and no other ticket references the same
1570
+ // origin_conversation_id, mark it for cascade delete.
1571
+ if (t.status === 'draft' && t.origin_conversation_id) {
1572
+ const otherRefs = Object.values(s.tickets).some((other) => other.id !== t.id && other.origin_conversation_id === t.origin_conversation_id);
1573
+ if (!otherRefs)
1574
+ orphanedConversationId = t.origin_conversation_id;
1575
+ }
1576
+ // SMASH: when deleting an épica, orphan its children (set
1577
+ // parent_epic_id/execution_order to null) rather than cascade-delete.
1578
+ if (t.is_epic) {
1579
+ const now = new Date().toISOString();
1580
+ for (const childId of Object.keys(s.tickets)) {
1581
+ const child = s.tickets[childId];
1582
+ if (child.parent_epic_id === numericId) {
1583
+ child.parent_epic_id = null;
1584
+ child.execution_order = null;
1585
+ child.updated_at = now;
1586
+ orphanedChildren.push(child);
1587
+ }
1588
+ }
1589
+ }
1590
+ delete s.tickets[ticketId];
1591
+ found = true;
1592
+ });
1593
+ if (!found) {
1594
+ res.status(404).json({ error: 'Ticket not found' });
1595
+ return;
1596
+ }
1597
+ const { broadcast, ticketWatcher, db, chatManager } = ctx(req);
1598
+ ticketWatcher.notifyDesktopWrite(store.revision);
1599
+ // Cascade-delete attachments for this ticket
1600
+ attachment_manager_1.attachmentManager.deleteAll(ctx(req).project.slug, ticketId).catch((e) => {
1601
+ console.error('[project-router] attachment cascade delete failed:', e);
1602
+ });
1603
+ // Cascade-delete the orphaned Explore conversation, if any.
1604
+ if (orphanedConversationId) {
1605
+ try {
1606
+ const conv = (0, db_1.getConversation)(db, orphanedConversationId);
1607
+ if (conv && conv.kind === 'explore') {
1608
+ (0, db_1.deleteConversation)(db, orphanedConversationId);
1609
+ chatManager?.forgetSpecDraft(orphanedConversationId);
1610
+ chatManager?.forgetExploreLifecycle(orphanedConversationId);
1611
+ }
1612
+ }
1613
+ catch (err) {
1614
+ console.error('[project-router] orphan conversation cleanup failed:', err);
1615
+ }
1616
+ }
1617
+ // Broadcast ticket_updated for each orphaned child so observers see them
1618
+ // as regular tickets (no longer attached to the deleted épica).
1619
+ for (const child of orphanedChildren) {
1620
+ broadcast({
1621
+ type: 'ticket_updated',
1622
+ ticket: child,
1623
+ projectId: ctx(req).project.id,
1624
+ timestamp: new Date().toISOString(),
1625
+ });
1626
+ }
1627
+ const msg = { type: 'ticket_deleted', ticketId: Number(ticketId), projectId: ctx(req).project.id, timestamp: new Date().toISOString() };
1628
+ broadcast(msg);
1629
+ res.json({ ok: true, revision: store.revision });
1630
+ }
1631
+ catch (err) {
1632
+ console.error('[project-router] ticket delete error:', err);
1633
+ res.status(500).json({ error: 'Failed to delete ticket' });
1634
+ }
1635
+ });
1636
+ // ─── Ticket attachments ─────────────────────────────────────────────────────
1637
+ const attachmentUpload = (0, multer_1.default)({
1638
+ storage: multer_1.default.memoryStorage(),
1639
+ limits: { fileSize: 25 * 1024 * 1024 }, // 25 MB per file
1640
+ fileFilter: (_req, file, cb) => {
1641
+ if ((0, attachment_manager_1.isSupportedUploadedFile)({ mimetype: file.mimetype, originalname: file.originalname }))
1642
+ cb(null, true);
1643
+ else
1644
+ cb(null, false);
1645
+ },
1646
+ });
1647
+ /** A ticket key is either a numeric real id or a UUID (pendingSpecId). */
1648
+ function parseTicketKey(raw) {
1649
+ if (/^\d+$/.test(raw))
1650
+ return { key: raw, isPending: false };
1651
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
1652
+ return { key: raw, isPending: true };
1653
+ }
1654
+ return null;
1655
+ }
1656
+ router.post('/:projectId/tickets/:ticketId/attachments', attachmentUpload.single('file'), async (req, res) => {
1657
+ const parsed = parseTicketKey(req.params.ticketId);
1658
+ if (!parsed) {
1659
+ res.status(400).json({ error: 'Invalid ticketId (must be numeric id or UUID)' });
1660
+ return;
1661
+ }
1662
+ const file = req.file;
1663
+ if (!file) {
1664
+ res.status(400).json({ error: 'No file uploaded or file type unsupported' });
1665
+ return;
1666
+ }
1667
+ if (!parsed.isPending) {
1668
+ const store = (0, ticket_store_1.readStore)(ticketPath(req));
1669
+ if (!store.tickets[parsed.key]) {
1670
+ res.status(404).json({ error: 'Ticket not found' });
1671
+ return;
1672
+ }
1673
+ }
1674
+ try {
1675
+ const attachment = await attachment_manager_1.attachmentManager.upload({
1676
+ slug: ctx(req).project.slug,
1677
+ ticketKey: parsed.key,
1678
+ projectPath: parsed.isPending ? null : ctx(req).project.path,
1679
+ file: {
1680
+ buffer: file.buffer,
1681
+ originalname: file.originalname,
1682
+ mimetype: file.mimetype,
1683
+ size: file.size,
1684
+ },
1685
+ });
1686
+ res.status(201).json({ attachment });
1687
+ }
1688
+ catch (err) {
1689
+ const status = err.status ?? 500;
1690
+ const message = err instanceof Error ? err.message : 'Upload failed';
1691
+ console.error('[project-router] attachment upload error:', err);
1692
+ res.status(status).json({ error: message });
1693
+ }
1694
+ });
1695
+ router.get('/:projectId/tickets/:ticketId/attachments', (req, res) => {
1696
+ const parsed = parseTicketKey(req.params.ticketId);
1697
+ if (!parsed) {
1698
+ res.status(400).json({ error: 'Invalid ticketId' });
1699
+ return;
1700
+ }
1701
+ const attachments = attachment_manager_1.attachmentManager.list(ctx(req).project.slug, parsed.key);
1702
+ res.json({ attachments });
1703
+ });
1704
+ router.get('/:projectId/tickets/:ticketId/attachments/:attachmentId', (req, res) => {
1705
+ const parsed = parseTicketKey(req.params.ticketId);
1706
+ if (!parsed) {
1707
+ res.status(400).json({ error: 'Invalid ticketId' });
1708
+ return;
1709
+ }
1710
+ const attachmentId = req.params.attachmentId;
1711
+ const slug = ctx(req).project.slug;
1712
+ const meta = attachment_manager_1.attachmentManager.getMeta(slug, parsed.key, attachmentId);
1713
+ const abs = meta ? attachment_manager_1.attachmentManager.getFilePath(slug, parsed.key, attachmentId) : null;
1714
+ if (!meta || !abs) {
1715
+ res.status(404).json({ error: 'Attachment not found' });
1716
+ return;
1717
+ }
1718
+ res.setHeader('Content-Type', meta.mimeType);
1719
+ // Strip quotes AND CR/LF: a newline in the stored (raw) original filename
1720
+ // makes Node's setHeader throw ERR_INVALID_CHAR after Content-Type is
1721
+ // already set, 500-ing the download. Also emit an RFC 5987 filename* so
1722
+ // non-ASCII names survive.
1723
+ const asciiName = meta.filename.replace(/[\r\n"]/g, '_');
1724
+ res.setHeader('Content-Disposition', `inline; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(meta.filename)}`);
1725
+ fs_1.default.createReadStream(abs).pipe(res);
1726
+ });
1727
+ router.delete('/:projectId/tickets/:ticketId/attachments/:attachmentId', async (req, res) => {
1728
+ const parsed = parseTicketKey(req.params.ticketId);
1729
+ if (!parsed) {
1730
+ res.status(400).json({ error: 'Invalid ticketId' });
1731
+ return;
1732
+ }
1733
+ const attachmentId = req.params.attachmentId;
1734
+ try {
1735
+ const ok = await attachment_manager_1.attachmentManager.delete({
1736
+ slug: ctx(req).project.slug,
1737
+ ticketKey: parsed.key,
1738
+ attachmentId,
1739
+ projectPath: parsed.isPending ? null : ctx(req).project.path,
1740
+ });
1741
+ if (!ok) {
1742
+ res.status(404).json({ error: 'Attachment not found' });
1743
+ return;
1744
+ }
1745
+ res.status(204).end();
1746
+ }
1747
+ catch (err) {
1748
+ console.error('[project-router] attachment delete error:', err);
1749
+ res.status(500).json({ error: 'Delete failed' });
1750
+ }
1751
+ });
1752
+ router.delete('/:projectId/tickets/:ticketId/attachments', async (req, res) => {
1753
+ const parsed = parseTicketKey(req.params.ticketId);
1754
+ if (!parsed) {
1755
+ res.status(400).json({ error: 'Invalid ticketId' });
1756
+ return;
1757
+ }
1758
+ try {
1759
+ await attachment_manager_1.attachmentManager.deleteAll(ctx(req).project.slug, parsed.key);
1760
+ res.status(204).end();
1761
+ }
1762
+ catch (err) {
1763
+ console.error('[project-router] attachment bulk delete error:', err);
1764
+ res.status(500).json({ error: 'Bulk delete failed' });
1765
+ }
1766
+ });
1767
+ }