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,218 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerChatRoutes = registerChatRoutes;
4
+ const ids_1 = require("./ids");
5
+ const db_1 = require("./db");
6
+ const context_scope_1 = require("./context-scope");
7
+ const spec_models_1 = require("./spec-models");
8
+ const provider_selection_1 = require("./provider-selection");
9
+ const ticket_store_1 = require("./ticket-store");
10
+ const project_router_helpers_1 = require("./project-router-helpers");
11
+ function registerChatRoutes(deps) {
12
+ const { router, registry, ctx, ticketPath } = deps;
13
+ // ─── Chat routes ─────────────────────────────────────────────────────────────
14
+ router.get('/:projectId/chat/conversations', (req, res) => {
15
+ const conversations = (0, db_1.listConversations)(ctx(req).db);
16
+ res.json({ conversations });
17
+ });
18
+ router.post('/:projectId/chat/conversations', (req, res) => {
19
+ const { db, project } = ctx(req);
20
+ // Multi-provider: an optional aiEngine (alias: provider) picks which engine
21
+ // this conversation runs on. It must be installed on the project; omitting
22
+ // it uses the project's primary provider. The chosen provider drives model
23
+ // validation and is persisted on the conversation so resume turns and
24
+ // ai_invocations attribute to the right engine.
25
+ const requestedEngine = req.body?.aiEngine ?? req.body?.provider;
26
+ const engineCheck = (0, provider_selection_1.validateRequestedProvider)(project, requestedEngine);
27
+ if (!engineCheck.ok) {
28
+ res.status(400).json({ error: engineCheck.error });
29
+ return;
30
+ }
31
+ const provider = engineCheck.provider;
32
+ const rawModel = req.body?.model;
33
+ let model;
34
+ if (rawModel === undefined || rawModel === null || rawModel === '') {
35
+ model = (0, project_router_helpers_1.resolveDefaultSpecModel)({ projectPath: project.path, provider });
36
+ }
37
+ else if ((0, spec_models_1.isValidModelForProvider)(rawModel, provider)) {
38
+ model = rawModel;
39
+ }
40
+ else {
41
+ res.status(400).json({
42
+ error: `Invalid model "${String(rawModel)}" for provider "${provider}"`,
43
+ allowed: (0, spec_models_1.getModelsForProvider)(provider).map((m) => m.value),
44
+ });
45
+ return;
46
+ }
47
+ const rawKind = req.body?.kind;
48
+ const kind = rawKind === 'explore' ? 'explore' : 'sidebar';
49
+ const id = (0, ids_1.newId)();
50
+ const rawScope = req.body?.contextScope;
51
+ if (rawScope !== undefined && kind !== 'explore') {
52
+ res.status(400).json({ error: 'contextScope is only allowed for kind=explore' });
53
+ return;
54
+ }
55
+ let scope;
56
+ if (kind === 'explore') {
57
+ const fallback = (0, context_scope_1.getLastContextScope)(db, 'explore');
58
+ // Defence-in-depth: SMASH / Contract Layer is Claude-only. Strip
59
+ // contractRefine from the scope when the conversation's resolved provider
60
+ // is non-Claude so no downstream code (Contract Refine Runner, SMASH
61
+ // eligibility) ever sees a mismatched flag.
62
+ const safeRawScope = provider !== 'claude' && rawScope != null
63
+ ? { ...rawScope, contractRefine: false }
64
+ : rawScope;
65
+ scope = (0, context_scope_1.normalizeContextScope)(safeRawScope ?? fallback, fallback);
66
+ (0, context_scope_1.setLastContextScope)(db, scope);
67
+ console.log(`[project-router] new explore conv ${id} provider=${provider} scope=${JSON.stringify(scope)} rawScope=${JSON.stringify(rawScope)}`);
68
+ }
69
+ // Only persist provider when the project is multi-provider; single-provider
70
+ // projects leave it NULL so behaviour is byte-identical to before.
71
+ const persistProvider = (0, provider_selection_1.isMultiProvider)(project) ? provider : null;
72
+ (0, db_1.createConversation)(db, { id, model, kind, contextScope: scope, provider: persistProvider });
73
+ const conversation = (0, db_1.getConversation)(db, id);
74
+ res.status(201).json({ conversation });
75
+ });
76
+ router.get('/:projectId/chat/conversations/:id', (req, res) => {
77
+ const { db } = ctx(req);
78
+ const conversation = (0, db_1.getConversation)(db, req.params.id);
79
+ if (!conversation) {
80
+ res.status(404).json({ error: 'Conversation not found' });
81
+ return;
82
+ }
83
+ const messages = (0, db_1.getMessages)(db, req.params.id);
84
+ res.json({ conversation, messages });
85
+ });
86
+ router.delete('/:projectId/chat/conversations/:id', (req, res) => {
87
+ const { db, chatManager, broadcast, project, ticketWatcher } = ctx(req);
88
+ const convId = req.params.id;
89
+ const conversation = (0, db_1.getConversation)(db, convId);
90
+ if (!conversation) {
91
+ res.status(404).json({ error: 'Conversation not found' });
92
+ return;
93
+ }
94
+ (0, db_1.deleteConversation)(db, convId);
95
+ chatManager?.forgetSpecDraft(convId);
96
+ chatManager?.forgetExploreLifecycle(convId);
97
+ // Cascade-clear origin_conversation_id on any ticket that referenced this
98
+ // conversation (application-level "ON DELETE SET NULL").
99
+ try {
100
+ const filePath = ticketPath(req);
101
+ const store = (0, ticket_store_1.mutateStore)(filePath, (s) => {
102
+ for (const id of Object.keys(s.tickets)) {
103
+ if (s.tickets[id].origin_conversation_id === convId) {
104
+ s.tickets[id].origin_conversation_id = null;
105
+ s.tickets[id].updated_at = new Date().toISOString();
106
+ }
107
+ }
108
+ });
109
+ ticketWatcher.notifyDesktopWrite(store.revision);
110
+ // No per-ticket broadcast: the cleared field is metadata-only and the
111
+ // board card visual treatment doesn't depend on it.
112
+ }
113
+ catch (err) {
114
+ console.error('[project-router] conversation-cascade ticket update error:', err);
115
+ }
116
+ res.json({ ok: true });
117
+ });
118
+ router.patch('/:projectId/chat/conversations/:id', (req, res) => {
119
+ const { db } = ctx(req);
120
+ const conversation = (0, db_1.getConversation)(db, req.params.id);
121
+ if (!conversation) {
122
+ res.status(404).json({ error: 'Conversation not found' });
123
+ return;
124
+ }
125
+ const { title, model } = req.body ?? {};
126
+ const patch = {};
127
+ if (title !== undefined)
128
+ patch.title = title;
129
+ if (model !== undefined)
130
+ patch.model = model;
131
+ (0, db_1.updateConversation)(db, req.params.id, patch);
132
+ const updated = (0, db_1.getConversation)(db, req.params.id);
133
+ res.json({ ok: true, conversation: updated });
134
+ });
135
+ router.get('/:projectId/chat/conversations/:id/messages', (req, res) => {
136
+ const { db } = ctx(req);
137
+ const conversation = (0, db_1.getConversation)(db, req.params.id);
138
+ if (!conversation) {
139
+ res.status(404).json({ error: 'Conversation not found' });
140
+ return;
141
+ }
142
+ const messages = (0, db_1.getMessages)(db, req.params.id);
143
+ res.json({ messages });
144
+ });
145
+ // Returns the in-memory spec-draft state Claude has accumulated for this
146
+ // conversation. Used by useSpecDraftStream on mount to rehydrate updates
147
+ // that were broadcast while the client wasn't subscribed (refresh /
148
+ // minimize-and-restore). Returns 200 with `null` draft when no state yet.
149
+ router.get('/:projectId/chat/conversations/:id/spec-draft', (req, res) => {
150
+ const { db, chatManager } = ctx(req);
151
+ const conversation = (0, db_1.getConversation)(db, req.params.id);
152
+ if (!conversation) {
153
+ res.status(404).json({ error: 'Conversation not found' });
154
+ return;
155
+ }
156
+ const state = chatManager.getSpecDraftState(req.params.id);
157
+ if (!state) {
158
+ res.json({ draft: null, ready: false, chips: [] });
159
+ return;
160
+ }
161
+ res.json({
162
+ draft: state.draft,
163
+ ready: state.ready,
164
+ chips: state.chips,
165
+ });
166
+ });
167
+ router.post('/:projectId/chat/conversations/:id/messages', async (req, res) => {
168
+ const { db, chatManager, project } = ctx(req);
169
+ const conversation = (0, db_1.getConversation)(db, req.params.id);
170
+ if (!conversation) {
171
+ res.status(404).json({ error: 'Conversation not found' });
172
+ return;
173
+ }
174
+ const text = req.body?.text;
175
+ if (!text || !text.trim()) {
176
+ res.status(400).json({ error: 'text is required' });
177
+ return;
178
+ }
179
+ if (chatManager.isActive(req.params.id)) {
180
+ res.status(409).json({ error: 'CONVERSATION_BUSY' });
181
+ return;
182
+ }
183
+ const lightweight = req.body?.lightweight === true;
184
+ const maxTurns = typeof req.body?.maxTurns === 'number' ? req.body.maxTurns : undefined;
185
+ let attachments;
186
+ const rawAtt = req.body?.attachments;
187
+ if (rawAtt && typeof rawAtt === 'object' && typeof rawAtt.ticketKey === 'string'
188
+ && Array.isArray(rawAtt.ids)) {
189
+ const ids = rawAtt.ids.filter((x) => typeof x === 'string');
190
+ if (ids.length > 0) {
191
+ attachments = { slug: project.slug, ticketKey: rawAtt.ticketKey, ids };
192
+ }
193
+ }
194
+ res.status(202).json({ ok: true });
195
+ chatManager.sendMessage(req.params.id, text.trim(), { lightweight, maxTurns, attachments }).catch((err) => {
196
+ console.error('[project-router] chat sendMessage error:', err);
197
+ });
198
+ });
199
+ router.delete('/:projectId/chat/conversations/:id/messages/stream', (req, res) => {
200
+ const { chatManager } = ctx(req);
201
+ if (!chatManager.isActive(req.params.id)) {
202
+ res.status(404).json({ error: 'No active stream for this conversation' });
203
+ return;
204
+ }
205
+ chatManager.abort(req.params.id);
206
+ res.json({ ok: true });
207
+ });
208
+ // Explore Spec lifecycle: minimize-to-toast hint and restore-from-toast hint.
209
+ // Idempotent; does not mutate persistent state. See design.md D7.
210
+ router.post('/:projectId/chat/conversations/:id/minimize', (req, res) => {
211
+ ctx(req).chatManager.notifyMinimized(req.params.id);
212
+ res.json({ ok: true });
213
+ });
214
+ router.post('/:projectId/chat/conversations/:id/restore', (req, res) => {
215
+ ctx(req).chatManager.notifyRestored(req.params.id);
216
+ res.json({ ok: true });
217
+ });
218
+ }
@@ -0,0 +1,275 @@
1
+ "use strict";
2
+ // Shared helpers and the deps contract for the project-router domain modules.
3
+ // Extracted verbatim from project-router.ts when that monolith was split into
4
+ // per-domain register functions. Behaviour-preserving — same exports, same code.
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.VALID_MODEL_ALIASES = exports.TERMINAL_PANEL_ENABLED = void 0;
10
+ exports.stripSpecMetadataSections = stripSpecMetadataSections;
11
+ exports.extractShortSummary = extractShortSummary;
12
+ exports.deriveFallbackShortSummary = deriveFallbackShortSummary;
13
+ exports.lightlyStructurePrompt = lightlyStructurePrompt;
14
+ exports.formatDescriptionWithCriteria = formatDescriptionWithCriteria;
15
+ exports.resolveDefaultSpecModel = resolveDefaultSpecModel;
16
+ exports.readAgentModels = readAgentModels;
17
+ exports.applyModelConfig = applyModelConfig;
18
+ exports.serializeInstallConfigYaml = serializeInstallConfigYaml;
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const path_1 = __importDefault(require("path"));
21
+ const core_package_1 = require("./core-package");
22
+ const spec_models_1 = require("./spec-models");
23
+ const ticket_store_1 = require("./ticket-store");
24
+ const TERMINAL_PANEL_ENABLED = process.env.SPECRAILS_TERMINAL_PANEL !== 'false';
25
+ exports.TERMINAL_PANEL_ENABLED = TERMINAL_PANEL_ENABLED;
26
+ // ─── YAML helpers ─────────────────────────────────────────────────────────────
27
+ /**
28
+ * Serialize install config to YAML matching specrails-core's tui-installer format exactly.
29
+ */
30
+ function serializeInstallConfigYaml(config) {
31
+ const c = config;
32
+ const overrides = c.models?.overrides ?? {};
33
+ const overridesEntries = Object.entries(overrides);
34
+ const overridesYaml = overridesEntries.length > 0
35
+ ? '\n' + overridesEntries.map(([k, v]) => ` ${k}: ${v}`).join('\n')
36
+ : ' {}';
37
+ const lines = [
38
+ '# specrails install config — generated by Specrails',
39
+ `# Re-run: npx ${core_package_1.CORE_PACKAGE_SPEC} init to regenerate`,
40
+ `version: ${c.version ?? 1}`,
41
+ `provider: ${c.provider ?? 'claude'}`,
42
+ `tier: ${c.tier ?? 'quick'}`,
43
+ `agents:`,
44
+ ` selected: [${(c.agents?.selected ?? []).join(', ')}]`,
45
+ ` excluded: [${(c.agents?.excluded ?? []).join(', ')}]`,
46
+ `models:`,
47
+ ` preset: ${c.models?.preset ?? 'balanced'}`,
48
+ ` defaults: { model: ${c.models?.defaults?.model ?? 'sonnet'} }`,
49
+ ` overrides:${overridesYaml}`,
50
+ `agent_teams: ${c.agent_teams ?? false}`,
51
+ '',
52
+ ];
53
+ return lines.join('\n');
54
+ }
55
+ // ─── Agent model helpers ──────────────────────────────────────────────────────
56
+ const VALID_MODEL_ALIASES = ['sonnet', 'opus', 'haiku'];
57
+ exports.VALID_MODEL_ALIASES = VALID_MODEL_ALIASES;
58
+ /**
59
+ * Read installed agents from `.claude/agents/*.md` (top-level only, no subdirs).
60
+ * Extracts the `model:` field from YAML frontmatter.
61
+ */
62
+ function readAgentModels(projectPath) {
63
+ const agentsDir = path_1.default.join(projectPath, '.claude', 'agents');
64
+ if (!fs_1.default.existsSync(agentsDir))
65
+ return [];
66
+ let entries;
67
+ try {
68
+ entries = fs_1.default.readdirSync(agentsDir);
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ const agents = [];
74
+ for (const entry of entries) {
75
+ // Top-level .md files only — skip subdirs
76
+ if (!entry.endsWith('.md'))
77
+ continue;
78
+ const filePath = path_1.default.join(agentsDir, entry);
79
+ try {
80
+ const stat = fs_1.default.statSync(filePath);
81
+ if (!stat.isFile())
82
+ continue;
83
+ const content = fs_1.default.readFileSync(filePath, 'utf-8');
84
+ // Extract model from YAML frontmatter between --- markers
85
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
86
+ if (!frontmatterMatch)
87
+ continue;
88
+ const frontmatter = frontmatterMatch[1];
89
+ const modelMatch = frontmatter.match(/^model:\s*(.+)$/m);
90
+ const model = modelMatch ? modelMatch[1].trim() : 'sonnet';
91
+ agents.push({ name: entry.slice(0, -3), model });
92
+ }
93
+ catch {
94
+ // skip unreadable files
95
+ }
96
+ }
97
+ return agents;
98
+ }
99
+ /**
100
+ * Read `.specrails/install-config.yaml` and patch the `model:` line in each
101
+ * `.claude/agents/*.md` frontmatter to match the config's defaults/overrides.
102
+ * No-op if the config file does not exist.
103
+ */
104
+ function applyModelConfig(projectPath) {
105
+ const configPath = path_1.default.join(projectPath, '.specrails', 'install-config.yaml');
106
+ if (!fs_1.default.existsSync(configPath))
107
+ return;
108
+ let configText;
109
+ try {
110
+ configText = fs_1.default.readFileSync(configPath, 'utf-8');
111
+ }
112
+ catch {
113
+ return;
114
+ }
115
+ // Parse defaults.model
116
+ const defaultsMatch = configText.match(/defaults:\s*\{\s*model:\s*(\S+)\s*\}/);
117
+ const defaultModel = defaultsMatch ? defaultsMatch[1] : 'sonnet';
118
+ // Parse overrides block — lines like ` agentname: alias`
119
+ const overrides = {};
120
+ const overridesBlockMatch = configText.match(/overrides:([\s\S]*?)(?:\n\S|$)/);
121
+ if (overridesBlockMatch) {
122
+ const block = overridesBlockMatch[1];
123
+ const overrideLines = block.match(/^ {2,}(\S+):\s*(\S+)/gm) ?? [];
124
+ for (const line of overrideLines) {
125
+ const m = line.match(/^\s+(\S+):\s*(\S+)/);
126
+ if (m)
127
+ overrides[m[1]] = m[2];
128
+ }
129
+ }
130
+ const agentsDir = path_1.default.join(projectPath, '.claude', 'agents');
131
+ if (!fs_1.default.existsSync(agentsDir))
132
+ return;
133
+ let entries;
134
+ try {
135
+ entries = fs_1.default.readdirSync(agentsDir);
136
+ }
137
+ catch {
138
+ return;
139
+ }
140
+ for (const entry of entries) {
141
+ if (!entry.endsWith('.md'))
142
+ continue;
143
+ const filePath = path_1.default.join(agentsDir, entry);
144
+ try {
145
+ const stat = fs_1.default.statSync(filePath);
146
+ if (!stat.isFile())
147
+ continue;
148
+ const agentName = entry.slice(0, -3);
149
+ const targetModel = overrides[agentName] ?? defaultModel;
150
+ const content = fs_1.default.readFileSync(filePath, 'utf-8');
151
+ const patched = content.replace(/^model: .+$/m, `model: ${targetModel}`);
152
+ if (patched !== content) {
153
+ fs_1.default.writeFileSync(filePath, patched, 'utf-8');
154
+ }
155
+ }
156
+ catch {
157
+ // skip unwritable files
158
+ }
159
+ }
160
+ }
161
+ /**
162
+ * Strip the metadata sections (Spec Title, Labels, Estimated Complexity,
163
+ * Short Summary) from a generate-spec LLM response so they don't end up
164
+ * duplicated inside the ticket's description — they're already parsed into
165
+ * dedicated fields and re-rendered by the UI.
166
+ */
167
+ function stripSpecMetadataSections(buffer) {
168
+ return buffer
169
+ .replace(/##\s*Spec Title\s*\n+[^\n]*\n*/i, '')
170
+ .replace(/##\s*Labels\s*\n+(?:[^\n]+(?:\n(?!##)[^\n]+)*)\n*/i, '')
171
+ .replace(/##\s*Estimated Complexity\s*\n+(?:[^\n]+(?:\n(?!##)[^\n]+)*)\n*/i, '')
172
+ .replace(/##\s*Short Summary\s*\n+(?:[^\n]+(?:\n(?!##)[^\n]+)*)\n*/i, '')
173
+ .trim();
174
+ }
175
+ /**
176
+ * Extract the `## Short Summary` section body from a generate-spec response.
177
+ * Returns the raw multi-line string (the ticket-store `clampShortSummary`
178
+ * helper applies trim, control-strip, and the 240-char hard cap before
179
+ * persistence). Returns `null` when the section is missing or empty.
180
+ */
181
+ function extractShortSummary(buffer) {
182
+ const m = buffer.match(/##\s*Short Summary\s*\n+((?:(?!##)[^\n]+(?:\n(?!##)[^\n]+)*))/i);
183
+ if (!m)
184
+ return null;
185
+ const body = m[1].trim();
186
+ return body.length > 0 ? body : null;
187
+ }
188
+ function deriveFallbackShortSummary(title, description) {
189
+ const plain = description
190
+ .replace(/```[\s\S]*?```/g, ' ')
191
+ .replace(/^#{1,6}\s+.*$/gm, ' ')
192
+ .replace(/^\s*[-*]\s+/gm, '')
193
+ .replace(/\[[^\]]+\]\([^)]+\)/g, (m) => m.match(/\[([^\]]+)\]/)?.[1] ?? '')
194
+ .replace(/[`*_>]/g, '')
195
+ .replace(/\s+/g, ' ')
196
+ .trim();
197
+ const source = plain || title.trim();
198
+ if (!source)
199
+ return null;
200
+ const sentence = source.match(/^(.{24,}?[.!?])(?:\s|$)/)?.[1] ?? source;
201
+ const capped = sentence.length > 160 ? `${sentence.slice(0, 157).trimEnd()}...` : sentence;
202
+ return (0, ticket_store_1.clampShortSummary)(capped);
203
+ }
204
+ /**
205
+ * Lightly structure a raw free-form prompt (the "Raw" Add-Spec mode) when the
206
+ * user opts in. We deliberately do NOT fabricate spec sections — a raw prompt
207
+ * stays the user's own words. The only transform: when the body has no leading
208
+ * markdown heading, prefix a single neutral `## Overview` heading so the spec
209
+ * renders with a section title downstream. Returns the (trimmed) input
210
+ * unchanged when it already starts with a heading or is empty.
211
+ */
212
+ function lightlyStructurePrompt(text) {
213
+ const trimmed = text.trim();
214
+ if (!trimmed)
215
+ return trimmed;
216
+ if (/^#{1,6}\s+/.test(trimmed))
217
+ return trimmed;
218
+ return `## Overview\n\n${trimmed}`;
219
+ }
220
+ /**
221
+ * Fold an `acceptanceCriteria` array into a ticket description body, writing
222
+ * (or replacing) a `## Acceptance Criteria` section.
223
+ *
224
+ * - `criteria.length > 0` → append/replace the section with one `- bullet` per item
225
+ * - `criteria.length === 0` → strip any existing `## Acceptance Criteria` section
226
+ *
227
+ * The match is case-insensitive on the heading text but requires `##` exactly
228
+ * to avoid matching other heading levels.
229
+ *
230
+ * Shared by `POST /tickets/from-draft` and `PATCH /tickets/:id`. See
231
+ * openspec/changes/replace-ai-edit-with-continue-editing/design.md D3+D4.
232
+ */
233
+ function formatDescriptionWithCriteria(body, criteria) {
234
+ const sectionRegex = /\n*##\s*Acceptance Criteria\s*\n[\s\S]*?(?=\n##\s|\n*$)/i;
235
+ const withoutExisting = body.replace(sectionRegex, '').replace(/\s+$/, '');
236
+ if (criteria.length === 0)
237
+ return withoutExisting;
238
+ const section = `## Acceptance Criteria\n\n${criteria.map((c) => `- ${c}`).join('\n')}`;
239
+ if (withoutExisting === '')
240
+ return section;
241
+ return `${withoutExisting}\n\n${section}`;
242
+ }
243
+ /**
244
+ * Resolve the default model used by Add Spec for a project.
245
+ *
246
+ * Order:
247
+ * 1. `models.defaults.model` from `<project>/.specrails/install-config.yaml`,
248
+ * if it parses AND is in the provider allow-list.
249
+ * 2. Provider default from `PROVIDER_DEFAULT_MODEL` (`sonnet` / `gpt-5.5`).
250
+ *
251
+ * Logs a warning when the configured value exists but is not valid for the
252
+ * project's provider.
253
+ */
254
+ function resolveDefaultSpecModel(args) {
255
+ const { projectPath, provider } = args;
256
+ const configPath = path_1.default.join(projectPath, '.specrails', 'install-config.yaml');
257
+ if (!fs_1.default.existsSync(configPath))
258
+ return (0, spec_models_1.getProviderDefault)(provider);
259
+ let configText;
260
+ try {
261
+ configText = fs_1.default.readFileSync(configPath, 'utf-8');
262
+ }
263
+ catch {
264
+ return (0, spec_models_1.getProviderDefault)(provider);
265
+ }
266
+ const defaultsMatch = configText.match(/defaults:\s*\{\s*model:\s*(\S+?)\s*\}/);
267
+ const configured = defaultsMatch ? defaultsMatch[1] : null;
268
+ if (!configured)
269
+ return (0, spec_models_1.getProviderDefault)(provider);
270
+ if (!(0, spec_models_1.isValidModelForProvider)(configured, provider)) {
271
+ console.warn(`[project-router] resolveDefaultSpecModel: configured model "${configured}" is not valid for provider "${provider}" — falling back to provider default`);
272
+ return (0, spec_models_1.getProviderDefault)(provider);
273
+ }
274
+ return configured;
275
+ }