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.
- package/client/dist/assets/ActivityFeedPage-3Veccrvk.js +1 -0
- package/client/dist/assets/AgentsPage-2mFPghP4.js +86 -0
- package/client/dist/assets/{AnalyticsPage-BD0paa75.js → AnalyticsPage-Dyyz1ht3.js} +1 -1
- package/client/dist/assets/{BarChart-D8ZZRab3.js → BarChart-CMdLa6Es.js} +2 -2
- package/client/dist/assets/CodePage-D7Xwjhut.js +2 -0
- package/client/dist/assets/{DesktopAnalyticsPage-mwd8460_.js → DesktopAnalyticsPage-CTNwb639.js} +1 -1
- package/client/dist/assets/{DocsDialog-D_dyF2h9.js → DocsDialog-D8yoyZDD.js} +2 -2
- package/client/dist/assets/{DocsPage-C9-Ru8wE.js → DocsPage-CeO-fAxy.js} +2 -2
- package/client/dist/assets/{ExportDropdown-CLYmQhic.js → ExportDropdown-DuoZcdYN.js} +1 -1
- package/client/dist/assets/{IntegrationsPage-3WWtx9hi.js → IntegrationsPage-iIZ0UEzf.js} +3 -3
- package/client/dist/assets/JobDetailPage-DgJHAH2m.js +16 -0
- package/client/dist/assets/JobsPage-Bv_RpRAE.js +1 -0
- package/client/dist/assets/code-BtsmPQLV.js +1 -0
- package/client/dist/assets/code-CY85RXZU.js +1 -0
- package/client/dist/assets/code-Coa8f2Sh.js +1 -0
- package/client/dist/assets/code-D1z-YDt-.js +1 -0
- package/client/dist/assets/code-DDU0CRS0.js +1 -0
- package/client/dist/assets/code-L35Loak_.js +1 -0
- package/client/dist/assets/code-g0qFMzyg.js +1 -0
- package/client/dist/assets/code-zCwBt3Uu.js +1 -0
- package/client/dist/assets/{dist-js-BOu_cXw3.js → dist-js-4UEGaKhD.js} +1 -1
- package/client/dist/assets/{dist-js-D3MxtOYa.js → dist-js-H6hyhSuv.js} +1 -1
- package/client/dist/assets/{index-D9G_K4L-.js → index-CGHKpC-N.js} +11 -11
- package/client/dist/assets/{lib-DQ2hrj8m.js → lib-Cs5FrUJI.js} +1 -1
- package/client/dist/assets/{useProjectCache-BxY4aTjd.js → useProjectCache-BZWYV-w-.js} +1 -1
- package/client/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/dist/agent-refine-manager.js +128 -153
- package/server/dist/chat-manager.js +242 -0
- package/server/dist/code-explorer-router.js +78 -0
- package/server/dist/command-resolver.js +17 -0
- package/server/dist/contract-refine-runner.js +42 -10
- package/server/dist/db.js +6 -0
- package/server/dist/desktop-db.js +3 -0
- package/server/dist/explore-stdin-session.js +129 -0
- package/server/dist/mobile/mobile-auth.js +16 -0
- package/server/dist/project-router-chat.js +218 -0
- package/server/dist/project-router-helpers.js +275 -0
- package/server/dist/project-router-jobs.js +389 -0
- package/server/dist/project-router-settings.js +312 -0
- package/server/dist/project-router-setup.js +456 -0
- package/server/dist/project-router-spending.js +320 -0
- package/server/dist/project-router-terminals.js +312 -0
- package/server/dist/project-router-tickets.js +1767 -0
- package/server/dist/project-router.js +27 -3950
- package/server/dist/providers/claude-adapter.js +23 -0
- package/server/dist/providers/codex-adapter.js +6 -0
- package/server/dist/spawn-lifecycle.js +117 -0
- package/client/dist/assets/ActivityFeedPage-BpjXuX2H.js +0 -1
- package/client/dist/assets/AgentsPage-D-7fDbTc.js +0 -86
- package/client/dist/assets/CodePage-B6q6CiYJ.js +0 -2
- package/client/dist/assets/JobDetailPage-DgN-79s-.js +0 -16
- package/client/dist/assets/JobsPage-Du8_w1ob.js +0 -1
- package/client/dist/assets/code-AL1rVIMb.js +0 -1
- package/client/dist/assets/code-C0BKpkht.js +0 -1
- package/client/dist/assets/code-C0FTS3ew.js +0 -1
- package/client/dist/assets/code-CPcHxzxw.js +0 -1
- package/client/dist/assets/code-D3ryDniw.js +0 -1
- package/client/dist/assets/code-D3zVVQTj.js +0 -1
- package/client/dist/assets/code-PCmfS3dn.js +0 -1
- 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
|
+
}
|