whose-turn 0.0.0-stage → 1.0.0-alpha.2

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 (54) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -2
  4. package/config.example.json +144 -0
  5. package/connectors/demo.mjs +146 -0
  6. package/connectors/github.mjs +316 -0
  7. package/connectors/index.mjs +38 -0
  8. package/connectors/jira.mjs +155 -0
  9. package/connectors/linear.mjs +113 -0
  10. package/dist/assets/_plugin-vue_export-helper-BKveAJb2.css +1 -0
  11. package/dist/assets/_plugin-vue_export-helper-DHr5RHTx.js +18 -0
  12. package/dist/assets/bem-puYdOas_.js +1 -0
  13. package/dist/assets/index-DR8StBp8.js +3 -0
  14. package/dist/assets/index-d0wlPvUz.css +1 -0
  15. package/dist/assets/report-Xynr6yGL.js +1 -0
  16. package/dist/assets/report-xVOo7qip.css +1 -0
  17. package/dist/assets/scheme-DJ3WNw60.js +1 -0
  18. package/dist/assets/sheet-CBYGM-FG.js +1 -0
  19. package/dist/assets/sheet-DYpaJaXO.css +1 -0
  20. package/dist/index.html +19 -0
  21. package/dist/report.html +16 -0
  22. package/dist/sheet.html +16 -0
  23. package/dist/vendor/popper.min.js +6 -0
  24. package/dist/vendor/tippy-bundle.umd.min.js +2 -0
  25. package/install.sh +69 -0
  26. package/lib/api.mjs +399 -0
  27. package/lib/backoff.mjs +2 -0
  28. package/lib/compose.mjs +67 -0
  29. package/lib/config.mjs +290 -0
  30. package/lib/default-table.mjs +58 -0
  31. package/lib/errors.mjs +53 -0
  32. package/lib/facts.mjs +128 -0
  33. package/lib/feedback.mjs +145 -0
  34. package/lib/handoff.mjs +127 -0
  35. package/lib/health.mjs +35 -0
  36. package/lib/http-client.mjs +44 -0
  37. package/lib/inbox.mjs +60 -0
  38. package/lib/linear-api.mjs +12 -0
  39. package/lib/notify.mjs +36 -0
  40. package/lib/palette.mjs +213 -0
  41. package/lib/platform/credential.ps1 +84 -0
  42. package/lib/registry.mjs +61 -0
  43. package/lib/rules.mjs +241 -0
  44. package/lib/secrets.mjs +69 -0
  45. package/lib/static.mjs +36 -0
  46. package/lib/store.mjs +61 -0
  47. package/lib/table.mjs +148 -0
  48. package/lib/text.mjs +46 -0
  49. package/lib/token-cache.mjs +12 -0
  50. package/lib/window.mjs +33 -0
  51. package/lib/workspace.mjs +294 -0
  52. package/open.sh +40 -0
  53. package/package.json +67 -4
  54. package/server.mjs +80 -0
package/lib/api.mjs ADDED
@@ -0,0 +1,399 @@
1
+ /** The local HTTP API and the window's static files; every error leaves as the google.rpc.Status envelope from lib/errors.mjs. */
2
+ import { createServer } from 'node:http';
3
+ import { join } from 'node:path';
4
+ import { createStatic } from './static.mjs';
5
+ import { ApiError, toApiError } from './errors.mjs';
6
+ import { connectorCatalogue, secretTarget, secretsRequired } from '../connectors/index.mjs';
7
+ import { entryShapeViolations, normaliseWorkspaces, WORKSPACE_FIELDS } from './config.mjs';
8
+ import { FACTS } from './facts.mjs';
9
+ import { createFeedback, isConfigured } from './feedback.mjs';
10
+ import { composeIssue, DEFAULT_COMPOSER } from './compose.mjs';
11
+ import { readSize, saveSize, checkMode, spawnOpener } from './window.mjs';
12
+ import { frameHandOff } from './handoff.mjs';
13
+
14
+ const send = (response, code, body, contentType = 'application/json', cacheControl = 'no-store') => {
15
+ const data = Buffer.isBuffer(body) ? body : Buffer.from(contentType === 'application/json' ? JSON.stringify(body) : body);
16
+ response.writeHead(code, { 'Content-Type': `${contentType}; charset=utf-8`, 'Cache-Control': cacheControl, 'Content-Length': data.length });
17
+ response.end(data);
18
+ };
19
+
20
+ const readBody = (request) => new Promise((resolve, reject) => {
21
+ const chunks = [];
22
+ request.on('data', (chunk) => chunks.push(chunk));
23
+ request.on('end', () => {
24
+ try {
25
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
26
+ } catch {
27
+ reject(new ApiError('INVALID_BODY'));
28
+ }
29
+ });
30
+ request.on('error', reject);
31
+ });
32
+
33
+ const assertLocal = (request, port) => {
34
+ const allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
35
+ if (!allowedHosts.includes(request.headers.host || '')) {
36
+ throw new ApiError('FORBIDDEN_ORIGIN', { host: request.headers.host || '' });
37
+ }
38
+ const origin = request.headers.origin;
39
+ if (origin && !allowedHosts.map((host) => `http://${host}`).includes(origin)) {
40
+ throw new ApiError('FORBIDDEN_ORIGIN', { origin });
41
+ }
42
+ if (request.method === 'POST' && !/^application\/json/.test(request.headers['content-type'] || '')) {
43
+ throw new ApiError('INVALID_BODY', { content_type: request.headers['content-type'] || '' });
44
+ }
45
+ };
46
+
47
+ const CLOCK_SKEW_MS = 60 * 1000;
48
+
49
+ /** Names the first wrong field in a marks write, or returns '' when the body is fine. */
50
+ const marksProblem = (body) => {
51
+ if (body.seen !== undefined && !(Array.isArray(body.seen) && body.seen.every((id) => typeof id === 'string'))) {
52
+ return 'seen must be a list of strings';
53
+ }
54
+ if ('base' in body && !(Number.isFinite(body.base) && body.base <= Date.now() + CLOCK_SKEW_MS)) {
55
+ return 'base must be a number of milliseconds, not in the future';
56
+ }
57
+ if (body.handled === undefined) {
58
+ return '';
59
+ }
60
+ if (!body.handled || typeof body.handled !== 'object' || Array.isArray(body.handled)) {
61
+ return 'handled must map row ids to an ISO date or null';
62
+ }
63
+ const wrong = Object.entries(body.handled).find(([, value]) => value !== null && !(typeof value === 'string' && Number.isFinite(Date.parse(value))));
64
+ return wrong ? `handled.${wrong[0]} must be an ISO date or null` : '';
65
+ };
66
+
67
+ const aggregateSummary = (workspaces) => {
68
+ const parts = workspaces.map((workspace) => workspace.summary());
69
+ const errors = {};
70
+ for (const part of parts) {
71
+ for (const [source, message] of Object.entries(part.errors)) {
72
+ errors[parts.length > 1 ? `${part.id}.${source}` : source] = message;
73
+ }
74
+ }
75
+ return {
76
+ moves: parts.flatMap((part) => part.moves.map((move) => ({ ...move, ws: part.id }))),
77
+ new_events: parts.reduce((sum, part) => sum + part.new_events, 0),
78
+ todos: parts.flatMap((part) => part.todos.map((todo) => ({ ...todo, ws: part.id }))),
79
+ polled_at: parts.map((part) => part.polled_at).filter(Boolean).sort()[0] || '',
80
+ errors,
81
+ health: {
82
+ ok: parts.every((part) => part.health.ok),
83
+ alerts: parts.flatMap((part) => part.health.alerts.map((alert) => (parts.length > 1 ? `${part.name}: ${alert.text}` : alert.text))),
84
+ },
85
+ workspaces: parts,
86
+ };
87
+ };
88
+
89
+ const OPEN_COOLDOWN_MS = 5000;
90
+
91
+ export const startApi = async ({ port, appDir, registry, requestPoll, saveConfig, secrets, inbox, http, reports, dataDir, opener = spawnOpener(appDir) }) => {
92
+ const windowFile = createStatic(join(appDir, 'dist'));
93
+ const feedbackCache = {};
94
+ const pick = (id) => {
95
+ const workspaces = registry.workspaces;
96
+ if (!workspaces.length) {
97
+ throw new ApiError('NO_WORKSPACE');
98
+ }
99
+ if (!id) {
100
+ return workspaces[0];
101
+ }
102
+ const found = workspaces.find((workspace) => workspace.id === id);
103
+ if (!found) {
104
+ throw new ApiError('NO_SUCH_WORKSPACE', { ws: id });
105
+ }
106
+ return found;
107
+ };
108
+ // README.md § API; RMA-115
109
+ const pickForWrite = (id) => {
110
+ if (!id) {
111
+ throw new ApiError('INVALID_ARGUMENT', { detail: 'ws names the workspace this write belongs to' });
112
+ }
113
+ return pick(id);
114
+ };
115
+
116
+ const stream = (response) => {
117
+ response.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' });
118
+ const write = (event) => response.write(`data: ${JSON.stringify(event)}\n\n`);
119
+ for (const workspace of registry.workspaces) {
120
+ write({ ws: workspace.id, version: workspace.store.state.version, polled_at: workspace.store.state.polled_at });
121
+ }
122
+ const unsubscribe = registry.subscribe(write);
123
+ const keepAlive = setInterval(() => response.write(': keep-alive\n\n'), 25000);
124
+ response.on('close', () => {
125
+ clearInterval(keepAlive);
126
+ unsubscribe();
127
+ });
128
+ };
129
+
130
+ const handleGet = async (url, request, response) => {
131
+ const file = url.pathname.startsWith('/api/') ? null : await windowFile(url.pathname);
132
+ if (file) {
133
+ return send(response, 200, file.body, file.type, file.cacheControl);
134
+ }
135
+ if (url.pathname === '/api/workspaces') {
136
+ return send(response, 200, registry.workspaces.map((workspace) => workspace.describe()));
137
+ }
138
+ if (url.pathname === '/api/state') {
139
+ if (!registry.workspaces.length) {
140
+ return send(response, 200, { workspaces: [], problems: registry.problems });
141
+ }
142
+ const workspace = pick(url.searchParams.get('ws'));
143
+ return send(response, 200, { ...workspace.snapshot(url.searchParams.get('full') === '1'), workspaces: registry.workspaces.map((item) => item.describe()), problems: registry.problems });
144
+ }
145
+ if (url.pathname === '/api/summary') {
146
+ return send(response, 200, { ...aggregateSummary(registry.workspaces), problems: registry.problems, handed: inbox.pending().length });
147
+ }
148
+ if (url.pathname === '/api/config') {
149
+ const tables = Object.fromEntries(registry.workspaces.map((workspace) => [workspace.id, workspace.rulesTable]));
150
+ return send(response, 200, { workspaces: registry.rawWorkspaces, problems: registry.problems, connectors: connectorCatalogue(), fields: WORKSPACE_FIELDS, facts: FACTS, tables, config_file: registry.configFile });
151
+ }
152
+ if (url.pathname === '/api/inbox') {
153
+ return send(response, 200, { items: inbox.pending(url.searchParams.get('consumer') || '') });
154
+ }
155
+ if (url.pathname === '/api/feedback') {
156
+ const id = url.searchParams.get('id');
157
+ if (id) {
158
+ return send(response, 200, { report: await reports.detail(reportDeps(), id) });
159
+ }
160
+ const list = await reports.states(reportDeps());
161
+ return send(response, 200, { configured: isConfigured(registry.feedback), project: registry.feedback ? registry.feedback.project : '', reports: list });
162
+ }
163
+ if (url.pathname === '/api/handoff') {
164
+ if (url.searchParams.get('all') === '1') {
165
+ return send(response, 200, frameHandOff('all workspaces', registry.workspaces.map((workspace) => workspace.handOffBody('workspace', workspace.id))), 'text/markdown');
166
+ }
167
+ const workspace = pick(url.searchParams.get('ws'));
168
+ const kind = url.searchParams.get('kind') || 'workspace';
169
+ const text = workspace.handOffText(kind, kind === 'workspace' ? workspace.id : url.searchParams.get('ref') || '');
170
+ if (!text) {
171
+ throw new ApiError('NOT_FOUND', { kind, ref: url.searchParams.get('ref') || '' });
172
+ }
173
+ return send(response, 200, text, 'text/markdown');
174
+ }
175
+ if (url.pathname === '/api/window') {
176
+ return send(response, 200, readSize(dataDir));
177
+ }
178
+ if (url.pathname === '/api/stream') {
179
+ return stream(response);
180
+ }
181
+ throw new ApiError('NOT_FOUND', { path: url.pathname });
182
+ };
183
+
184
+ const handleTodos = (workspace, body) => {
185
+ if (body.action === 'add') {
186
+ if (!String(body.text || '').trim()) {
187
+ throw new ApiError('INVALID_ARGUMENT', { detail: 'text is empty' }, [{ field: 'text', description: 'A to-do needs some text.' }]);
188
+ }
189
+ workspace.addTodo(body);
190
+ } else {
191
+ const todo = workspace.store.todos.find((item) => item.id === body.id);
192
+ if (!todo) {
193
+ throw new ApiError('NO_SUCH_TODO', { id: body.id || '' });
194
+ }
195
+ if (!workspace.changeTodo(todo, body)) {
196
+ throw new ApiError('UNKNOWN_ACTION', { action: body.action || '' });
197
+ }
198
+ }
199
+ workspace.saveTodos();
200
+ return workspace.store.todos;
201
+ };
202
+
203
+ const resolveSecrets = (workspaces, pending) => pending.map((item) => {
204
+ const workspace = workspaces.find((candidate) => candidate.id === item.ws);
205
+ const entry = workspace?.connectors.find((candidate) => candidate.id === item.connector);
206
+ const target = entry ? secretTarget(entry, item.key) : null;
207
+ if (!target || !String(item.value || '').trim()) {
208
+ throw new ApiError('INVALID_ARGUMENT', { detail: `no secret field ${item.key || ''} on connector ${item.connector || ''} of workspace ${item.ws || ''}, or the account for it is empty` });
209
+ }
210
+ return { ...target, value: String(item.value).trim() };
211
+ });
212
+
213
+ const handleConfig = async (body) => {
214
+ if (!Array.isArray(body.workspaces)) {
215
+ throw new ApiError('INVALID_ARGUMENT', { detail: 'workspaces must be a list' });
216
+ }
217
+ if (!body.workspaces.length) {
218
+ throw new ApiError('INVALID_ARGUMENT', { detail: 'keep at least one workspace' });
219
+ }
220
+ const shape = entryShapeViolations(body.workspaces);
221
+ if (shape.length) {
222
+ throw new ApiError('INVALID_CONFIG', { count: String(shape.length) }, shape);
223
+ }
224
+ const { workspaces, problems } = normaliseWorkspaces(body.workspaces);
225
+ if (problems.length) {
226
+ throw new ApiError('INVALID_CONFIG', { count: String(problems.length) }, problems);
227
+ }
228
+ const stored = resolveSecrets(workspaces, Array.isArray(body.secrets) ? body.secrets : []);
229
+ const currentEntries = normaliseWorkspaces(registry.rawWorkspaces).workspaces.flatMap((workspace) => workspace.connectors);
230
+ const missing = [];
231
+ for (const workspace of workspaces) {
232
+ for (const entry of workspace.connectors) {
233
+ for (const key of secretsRequired(currentEntries, entry)) {
234
+ const supplied = (body.secrets || []).some((item) => item.ws === workspace.id && item.connector === entry.id && item.key === key && String(item.value || '').trim());
235
+ if (!supplied) {
236
+ missing.push(`Workspace "${workspace.id}", connector "${entry.id}": the ${key} must be entered again because its account, store entry or endpoint changed.`);
237
+ }
238
+ }
239
+ }
240
+ }
241
+ if (missing.length) {
242
+ throw new ApiError('INVALID_CONFIG', { count: String(missing.length) }, missing);
243
+ }
244
+ for (const secret of stored) {
245
+ await secrets.set(secret.service, secret.account, secret.value);
246
+ }
247
+ await saveConfig(body.workspaces);
248
+ return { workspaces: registry.workspaces.map((workspace) => workspace.describe()), problems: registry.problems, secrets_stored: stored.map(({ service, account }) => ({ service, account })) };
249
+ };
250
+
251
+ const handleSecret = async (body) => {
252
+ const service = String(body.service || '').trim();
253
+ const account = String(body.account || '').trim();
254
+ const value = String(body.value || '').trim();
255
+ if (!/^[\w.-]{1,80}$/.test(service) || !account || !value) {
256
+ throw new ApiError('INVALID_ARGUMENT', { detail: 'service, account and value are required' });
257
+ }
258
+ await secrets.set(service, account, value);
259
+ return { ok: true, service, account };
260
+ };
261
+
262
+ const withContext = (item) => {
263
+ const workspace = registry.workspaces.find((candidate) => candidate.id === item.ws);
264
+ return { ...item, context: workspace ? workspace.handOff(item.kind, item.ref) : null };
265
+ };
266
+
267
+ const handleInbox = (body) => {
268
+ if (body.action === 'clear') {
269
+ inbox.clear();
270
+ return { items: [] };
271
+ }
272
+ if (body.action === 'take') {
273
+ const consumer = String(body.consumer || '').trim();
274
+ if (!consumer) {
275
+ throw new ApiError('INVALID_ARGUMENT', { detail: 'consumer names who takes the items' });
276
+ }
277
+ return { items: inbox.take(consumer).map(withContext) };
278
+ }
279
+ const workspace = pickForWrite(body.ws);
280
+ const kind = String(body.kind || 'workspace');
281
+ const ref = kind === 'workspace' ? workspace.id : String(body.ref || '');
282
+ const snapshot = workspace.handOffSnapshot(kind, ref);
283
+ if (!snapshot) {
284
+ throw new ApiError('NOT_FOUND', { kind, ref });
285
+ }
286
+ return { item: inbox.add({ ws: workspace.id, ws_name: workspace.name, kind, ref, snapshot }) };
287
+ };
288
+
289
+ const reportDeps = () => ({ settings: registry.feedback, secrets, http });
290
+
291
+ let lastRemark = { text: '', at: 0 };
292
+ const handleFeedback = async (body) => {
293
+ if (!isConfigured(registry.feedback)) {
294
+ throw new ApiError('NOT_CONFIGURED');
295
+ }
296
+ const remark = typeof body.text === 'string' ? body.text.trim() : '';
297
+ if (!remark && !String(body.title || '').trim()) {
298
+ throw new ApiError('INVALID_ARGUMENT', { detail: 'text is empty' }, [{ field: 'text', description: 'Write the remark, or pass a title.' }]);
299
+ }
300
+ if (remark && remark === lastRemark.text && Date.now() - lastRemark.at < 60_000) {
301
+ throw new ApiError('INVALID_ARGUMENT', { detail: 'the same remark was just sent' });
302
+ }
303
+ if (remark) {
304
+ lastRemark = { text: remark, at: Date.now() };
305
+ }
306
+ let context = null;
307
+ if (body.ws && body.kind_ref) {
308
+ const workspace = pick(body.ws);
309
+ context = workspace.handOffText(body.kind_ref.kind, body.kind_ref.ref);
310
+ }
311
+ try {
312
+ const composer = registry.feedback && registry.feedback.composer && registry.feedback.composer.command ? registry.feedback.composer : DEFAULT_COMPOSER;
313
+ const shaped = remark ? await composeIssue(remark, { about: context || '', composer, bugWords: registry.feedback && registry.feedback.bug_words }) : { kind: body.kind, title: body.title, body: body.body, composed_by: body.from || 'caller' };
314
+ const issue = await createFeedback({ settings: registry.feedback, secrets, http, cache: feedbackCache, reports }, { kind: shaped.kind, title: shaped.title, body: shaped.body, from: body.from, context: context || body.context || null });
315
+ return { issue, composed_by: shaped.composed_by, reports: await reports.states(reportDeps()) };
316
+ } catch (error) {
317
+ lastRemark = { text: '', at: 0 };
318
+ throw error;
319
+ }
320
+ };
321
+
322
+ const lastOpen = new Map();
323
+
324
+ const handlePost = async (url, request, response) => {
325
+ const body = await readBody(request);
326
+ if (url.pathname === '/api/feedback') {
327
+ return send(response, 200, await handleFeedback(body));
328
+ }
329
+ if (url.pathname === '/api/inbox') {
330
+ return send(response, 200, handleInbox(body));
331
+ }
332
+ if (url.pathname === '/api/config') {
333
+ return send(response, 200, await handleConfig(body));
334
+ }
335
+ if (url.pathname === '/api/secrets') {
336
+ return send(response, 200, await handleSecret(body));
337
+ }
338
+ if (url.pathname === '/api/window') {
339
+ return send(response, 200, saveSize(dataDir, body));
340
+ }
341
+ if (url.pathname === '/api/open') {
342
+ const mode = checkMode(body.mode);
343
+ const wait = OPEN_COOLDOWN_MS - (Date.now() - (lastOpen.get(mode) || -Infinity));
344
+ if (wait > 0) {
345
+ throw new ApiError('OPEN_TOO_SOON', { retry_after_seconds: Math.ceil(wait / 1000) });
346
+ }
347
+ lastOpen.set(mode, Date.now());
348
+ opener({ mode, port });
349
+ return send(response, 200, { ok: true });
350
+ }
351
+ if (url.pathname === '/api/refresh') {
352
+ requestPoll();
353
+ return send(response, 200, { ok: true });
354
+ }
355
+ const workspace = pickForWrite(body.ws);
356
+ if (url.pathname === '/api/marks') {
357
+ const detail = marksProblem(body);
358
+ if (detail) {
359
+ throw new ApiError('INVALID_ARGUMENT', { detail });
360
+ }
361
+ return send(response, 200, workspace.updateMarks(body));
362
+ }
363
+ if (url.pathname === '/api/todos') {
364
+ return send(response, 200, handleTodos(workspace, body));
365
+ }
366
+ throw new ApiError('NOT_FOUND', { path: url.pathname });
367
+ };
368
+
369
+ const server = createServer(async (request, response) => {
370
+ const url = new URL(request.url, `http://127.0.0.1:${port}`);
371
+ try {
372
+ assertLocal(request, port);
373
+ if (request.method === 'GET') {
374
+ await handleGet(url, request, response);
375
+ } else if (request.method === 'POST') {
376
+ await handlePost(url, request, response);
377
+ } else {
378
+ response.setHeader('Allow', 'GET, POST');
379
+ throw new ApiError('METHOD_NOT_ALLOWED', { method: request.method || '' });
380
+ }
381
+ } catch (error) {
382
+ if (!(error instanceof ApiError)) {
383
+ console.error(error);
384
+ }
385
+ const apiError = toApiError(error);
386
+ send(response, apiError.code, apiError.toEnvelope());
387
+ }
388
+ });
389
+ await new Promise((resolve, reject) => {
390
+ const refuse = (error) => reject(error.code === 'EADDRINUSE' ? new Error(`Port ${port} is already in use: stop the server holding it, or set WHOSE_TURN_PORT to a free port.`, { cause: error }) : error);
391
+ server.once('error', refuse);
392
+ server.listen(port, '127.0.0.1', () => {
393
+ server.off('error', refuse);
394
+ resolve(undefined);
395
+ });
396
+ });
397
+ port = /** @type {import('node:net').AddressInfo} */ (server.address()).port;
398
+ return server;
399
+ };
@@ -0,0 +1,2 @@
1
+ /** The next poll delay in seconds: doubled up to 15 minutes while any source is rate limited, else the normal interval. */
2
+ export const nextDelay = (outcomes, delay, pollSeconds) => (outcomes.some((outcome) => outcome.rateLimited) ? Math.min(delay * 2, 15 * 60) : pollSeconds);
@@ -0,0 +1,67 @@
1
+ /** Turns a remark into an issue (title, kind, body) with the user's own agent CLI, `claude -p` unless the config names another; falls back to the first line as the title. RMA-109. */
2
+ import { execFile } from 'node:child_process';
3
+ import { escapeRegex } from './text.mjs';
4
+ import { untrustedBlock } from './handoff.mjs';
5
+
6
+ const KINDS = ['bug', 'improvement', 'idea'];
7
+ export const DEFAULT_COMPOSER = { command: 'claude', args: ['-p', '--output-format', 'json', '--model', 'claude-haiku-5-5'] };
8
+
9
+ const PROMPT = (text, about) => `You turn a user's remark about a tool called Whose Turn (a local monitor of tickets and pull requests) into an issue for its backlog.
10
+ Answer with one JSON object only, no prose, no code fence: {"title": string, "kind": "bug" | "improvement" | "idea", "body": string}.
11
+ Rules: the title and the body are in English whatever language the remark is in; translate faithfully, keep every detail the user gave, add nothing the user did not say. The title is one line under 80 characters; kind is bug when something misbehaves, improvement when something that exists should be better, idea when it is new; the body restates the remark clearly and may end with one line "Context: …" taken from the context below if it helps a developer find the place, and, when the remark was not in English, with one line "Original: …" quoting it as written.
12
+ Remark:
13
+ ${text}
14
+ ${about ? `\nContext of the window at that moment:\n${untrustedBlock(about.slice(0, 3000))}` : ''}`;
15
+
16
+ const DEFAULT_BUG_WORDS = ['bug', 'broken', 'crash', 'error', 'fail', 'wrong'];
17
+ const fallback = (text, bugWords) => {
18
+ const [first, ...rest] = String(text).trim().split('\n');
19
+ const words = [...DEFAULT_BUG_WORDS, ...[].concat(bugWords || [])].map(escapeRegex).join('|');
20
+ const kind = new RegExp(`(^|[^\\p{L}\\p{N}])(${words})`, 'iu').test(text) ? 'bug' : 'improvement';
21
+ return { title: first.trim().slice(0, 120), kind, body: rest.join('\n').trim(), composed_by: 'fallback' };
22
+ };
23
+
24
+ const issueFrom = (text) => {
25
+ const match = /\{[\s\S]*\}/.exec(text);
26
+ const issue = JSON.parse(match ? match[0] : text);
27
+ if (!issue.title || !KINDS.includes(issue.kind)) {
28
+ throw new Error('incomplete answer');
29
+ }
30
+ return { title: String(issue.title).slice(0, 120), kind: issue.kind, body: String(issue.body || '') };
31
+ };
32
+
33
+ const parse = (output, label) => {
34
+ let text = output;
35
+ try {
36
+ const payload = JSON.parse(output);
37
+ if (payload && typeof payload.result === 'string') {
38
+ text = payload.result;
39
+ } else if (payload && payload.title) {
40
+ return { ...issueFrom(output), composed_by: label };
41
+ }
42
+ } catch {
43
+ /* not a wrapper; the output is the answer itself */
44
+ }
45
+ return { ...issueFrom(text), composed_by: label };
46
+ };
47
+
48
+ const runCommand = (composer) => (prompt, timeoutMs) => new Promise((resolve, reject) => {
49
+ const child = execFile(composer.command, composer.args || [], { timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (error, stdout) => (error ? reject(error) : resolve(stdout)));
50
+ child.stdin.end(prompt);
51
+ });
52
+
53
+ /** @param {string} text @param {{ about?: string, composer?: object, bugWords?: string[], run?: Function, timeoutMs?: number }} [options] */
54
+ export const composeIssue = async (text, { about = '', composer = DEFAULT_COMPOSER, bugWords = [], run, timeoutMs = 45000 } = {}) => {
55
+ const clean = String(text || '').trim();
56
+ if (!clean) {
57
+ throw new Error('Write at least one line');
58
+ }
59
+ const args = composer.args || [];
60
+ const model = args[args.indexOf('--model') + 1];
61
+ const label = composer.label || (args.includes('--model') && model ? `${composer.command} ${model}` : composer.command);
62
+ try {
63
+ return parse(await (run || runCommand(composer))(PROMPT(clean, about), timeoutMs), label);
64
+ } catch {
65
+ return fallback(clean, bugWords);
66
+ }
67
+ };