superintent 0.0.1

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +226 -0
  3. package/bin/superintent.js +2 -0
  4. package/dist/commands/extract.d.ts +2 -0
  5. package/dist/commands/extract.js +66 -0
  6. package/dist/commands/init.d.ts +2 -0
  7. package/dist/commands/init.js +56 -0
  8. package/dist/commands/knowledge.d.ts +2 -0
  9. package/dist/commands/knowledge.js +647 -0
  10. package/dist/commands/search.d.ts +2 -0
  11. package/dist/commands/search.js +153 -0
  12. package/dist/commands/spec.d.ts +2 -0
  13. package/dist/commands/spec.js +283 -0
  14. package/dist/commands/status.d.ts +2 -0
  15. package/dist/commands/status.js +43 -0
  16. package/dist/commands/ticket.d.ts +4 -0
  17. package/dist/commands/ticket.js +942 -0
  18. package/dist/commands/ui.d.ts +2 -0
  19. package/dist/commands/ui.js +954 -0
  20. package/dist/db/client.d.ts +4 -0
  21. package/dist/db/client.js +26 -0
  22. package/dist/db/init-schema.d.ts +2 -0
  23. package/dist/db/init-schema.js +28 -0
  24. package/dist/db/parsers.d.ts +24 -0
  25. package/dist/db/parsers.js +79 -0
  26. package/dist/db/schema.d.ts +7 -0
  27. package/dist/db/schema.js +64 -0
  28. package/dist/db/usage.d.ts +8 -0
  29. package/dist/db/usage.js +24 -0
  30. package/dist/embed/model.d.ts +5 -0
  31. package/dist/embed/model.js +34 -0
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.js +31 -0
  34. package/dist/types.d.ts +120 -0
  35. package/dist/types.js +1 -0
  36. package/dist/ui/components/index.d.ts +6 -0
  37. package/dist/ui/components/index.js +13 -0
  38. package/dist/ui/components/knowledge.d.ts +33 -0
  39. package/dist/ui/components/knowledge.js +238 -0
  40. package/dist/ui/components/layout.d.ts +1 -0
  41. package/dist/ui/components/layout.js +323 -0
  42. package/dist/ui/components/search.d.ts +15 -0
  43. package/dist/ui/components/search.js +114 -0
  44. package/dist/ui/components/spec.d.ts +11 -0
  45. package/dist/ui/components/spec.js +253 -0
  46. package/dist/ui/components/ticket.d.ts +90 -0
  47. package/dist/ui/components/ticket.js +604 -0
  48. package/dist/ui/components/utils.d.ts +26 -0
  49. package/dist/ui/components/utils.js +34 -0
  50. package/dist/ui/styles.css +2 -0
  51. package/dist/utils/cli.d.ts +21 -0
  52. package/dist/utils/cli.js +31 -0
  53. package/dist/utils/config.d.ts +12 -0
  54. package/dist/utils/config.js +116 -0
  55. package/dist/utils/id.d.ts +6 -0
  56. package/dist/utils/id.js +13 -0
  57. package/dist/utils/io.d.ts +8 -0
  58. package/dist/utils/io.js +15 -0
  59. package/package.json +60 -0
@@ -0,0 +1,954 @@
1
+ import { Command } from 'commander';
2
+ import { Hono } from 'hono';
3
+ import { serve } from '@hono/node-server';
4
+ import open from 'open';
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { dirname, join } from 'node:path';
8
+ import { getClient, closeClient } from '../db/client.js';
9
+ import { parseTicketRow, parseKnowledgeRow, parseSearchRow, parseSpecRow } from '../db/parsers.js';
10
+ import { trackUsage } from '../db/usage.js';
11
+ import { loadConfig, getProjectNamespace } from '../utils/config.js';
12
+ import { embed } from '../embed/model.js';
13
+ import { getHtml, renderKanbanView, renderKanbanColumns, renderColumnMore, renderSearchView, renderSearchResults, renderKnowledgeView, renderKnowledgeList, renderTicketModal, renderKnowledgeModal, renderNewTicketModal, renderEditTicketModal, renderSpecView, renderSpecList, renderSpecModal, renderNewSpecModal, renderEditSpecModal, } from '../ui/components/index.js';
14
+ export const uiCommand = new Command('ui')
15
+ .description('Start web UI for ticket and knowledge management')
16
+ .option('-p, --port <port>', 'Server port', '3456')
17
+ .option('-o, --open', 'Auto-open browser')
18
+ .action(async (options) => {
19
+ const port = parseInt(options.port, 10);
20
+ const namespace = getProjectNamespace();
21
+ const app = new Hono();
22
+ // ============ STATIC ASSETS ============
23
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
24
+ const cssPath = join(packageRoot, 'dist', 'ui', 'styles.css');
25
+ let cssContent;
26
+ try {
27
+ cssContent = readFileSync(cssPath, 'utf-8');
28
+ }
29
+ catch {
30
+ cssContent = '/* Tailwind CSS not built. Run: npm run build:css */';
31
+ }
32
+ app.get('/styles.css', (c) => {
33
+ c.header('Content-Type', 'text/css');
34
+ c.header('Cache-Control', 'public, max-age=3600');
35
+ return c.body(cssContent);
36
+ });
37
+ // ============ MAIN HTML ============
38
+ app.get('/', (c) => c.html(getHtml(namespace)));
39
+ // ============ API ROUTES (JSON) ============
40
+ // List all tickets
41
+ app.get('/api/tickets', async (c) => {
42
+ try {
43
+ const client = await getClient();
44
+ const result = await client.execute({
45
+ sql: 'SELECT * FROM tickets ORDER BY created_at DESC',
46
+ args: [],
47
+ });
48
+ const tickets = result.rows.map((row) => parseTicketRow(row));
49
+ return c.json({ success: true, data: tickets });
50
+ }
51
+ catch (error) {
52
+ return c.json({ success: false, error: error.message }, 500);
53
+ }
54
+ });
55
+ // Get single ticket
56
+ app.get('/api/tickets/:id', async (c) => {
57
+ try {
58
+ const id = c.req.param('id');
59
+ const client = await getClient();
60
+ const result = await client.execute({
61
+ sql: 'SELECT * FROM tickets WHERE id = ?',
62
+ args: [id],
63
+ });
64
+ if (result.rows.length === 0) {
65
+ return c.json({ success: false, error: 'Ticket not found' }, 404);
66
+ }
67
+ const ticket = parseTicketRow(result.rows[0]);
68
+ return c.json({ success: true, data: ticket });
69
+ }
70
+ catch (error) {
71
+ return c.json({ success: false, error: error.message }, 500);
72
+ }
73
+ });
74
+ // Update ticket status
75
+ app.patch('/api/tickets/:id/status', async (c) => {
76
+ try {
77
+ const id = c.req.param('id');
78
+ const body = await c.req.parseBody();
79
+ const newStatus = body.status;
80
+ const validStatuses = ['Backlog', 'In Progress', 'In Review', 'Done', 'Blocked', 'Paused', 'Abandoned', 'Superseded'];
81
+ if (!validStatuses.includes(newStatus)) {
82
+ return c.json({ success: false, error: 'Invalid status' }, 400);
83
+ }
84
+ const client = await getClient();
85
+ // If setting to Done, auto-complete all tasks and DoD
86
+ if (newStatus === 'Done') {
87
+ const current = await client.execute({
88
+ sql: 'SELECT tasks, definition_of_done FROM tickets WHERE id = ?',
89
+ args: [id],
90
+ });
91
+ if (current.rows.length > 0) {
92
+ const row = current.rows[0];
93
+ const tasks = row.tasks ? JSON.parse(row.tasks) : [];
94
+ const dod = row.definition_of_done ? JSON.parse(row.definition_of_done) : [];
95
+ const completedTasks = tasks.map((t) => ({ ...t, done: true }));
96
+ const completedDod = dod.map((d) => ({ ...d, done: true }));
97
+ await client.execute({
98
+ sql: `UPDATE tickets SET status = ?, tasks = ?, definition_of_done = ?, updated_at = datetime('now') WHERE id = ?`,
99
+ args: [newStatus, JSON.stringify(completedTasks), JSON.stringify(completedDod), id],
100
+ });
101
+ return c.json({ success: true, data: { id, status: newStatus } });
102
+ }
103
+ }
104
+ await client.execute({
105
+ sql: `UPDATE tickets SET status = ?, updated_at = datetime('now') WHERE id = ?`,
106
+ args: [newStatus, id],
107
+ });
108
+ return c.json({ success: true, data: { id, status: newStatus } });
109
+ }
110
+ catch (error) {
111
+ return c.json({ success: false, error: error.message }, 500);
112
+ }
113
+ });
114
+ // Toggle task completion
115
+ app.patch('/api/tickets/:id/task/:index', async (c) => {
116
+ try {
117
+ const id = c.req.param('id');
118
+ const index = parseInt(c.req.param('index'), 10);
119
+ const client = await getClient();
120
+ const result = await client.execute({
121
+ sql: 'SELECT tasks FROM tickets WHERE id = ?',
122
+ args: [id],
123
+ });
124
+ if (result.rows.length === 0) {
125
+ return c.json({ success: false, error: 'Ticket not found' }, 404);
126
+ }
127
+ const row = result.rows[0];
128
+ const tasks = row.tasks ? JSON.parse(row.tasks) : [];
129
+ if (index >= 0 && index < tasks.length) {
130
+ tasks[index].done = !tasks[index].done;
131
+ await client.execute({
132
+ sql: `UPDATE tickets SET tasks = ?, updated_at = datetime('now') WHERE id = ?`,
133
+ args: [JSON.stringify(tasks), id],
134
+ });
135
+ }
136
+ return c.json({ success: true });
137
+ }
138
+ catch (error) {
139
+ return c.json({ success: false, error: error.message }, 500);
140
+ }
141
+ });
142
+ // Toggle DoD completion
143
+ app.patch('/api/tickets/:id/dod/:index', async (c) => {
144
+ try {
145
+ const id = c.req.param('id');
146
+ const index = parseInt(c.req.param('index'), 10);
147
+ const client = await getClient();
148
+ const result = await client.execute({
149
+ sql: 'SELECT definition_of_done FROM tickets WHERE id = ?',
150
+ args: [id],
151
+ });
152
+ if (result.rows.length === 0) {
153
+ return c.json({ success: false, error: 'Ticket not found' }, 404);
154
+ }
155
+ const row = result.rows[0];
156
+ const dod = row.definition_of_done ? JSON.parse(row.definition_of_done) : [];
157
+ if (index >= 0 && index < dod.length) {
158
+ dod[index].done = !dod[index].done;
159
+ await client.execute({
160
+ sql: `UPDATE tickets SET definition_of_done = ?, updated_at = datetime('now') WHERE id = ?`,
161
+ args: [JSON.stringify(dod), id],
162
+ });
163
+ }
164
+ return c.json({ success: true });
165
+ }
166
+ catch (error) {
167
+ return c.json({ success: false, error: error.message }, 500);
168
+ }
169
+ });
170
+ // Quick create ticket (from UI form)
171
+ app.post('/api/tickets/quick', async (c) => {
172
+ try {
173
+ const formData = await c.req.parseBody();
174
+ const title = formData.title?.trim();
175
+ const type = formData.type || 'feature';
176
+ const intent = formData.intent?.trim();
177
+ if (!title) {
178
+ return c.html('<div class="text-red-500 p-2">Title is required</div>', 400);
179
+ }
180
+ if (!intent) {
181
+ return c.html('<div class="text-red-500 p-2">Intent is required</div>', 400);
182
+ }
183
+ // Generate ID: TICKET-YYYYMMDD-HHMMSS-manual (manual ticket format)
184
+ // The -manual suffix signals AI to enrich ticket before execution
185
+ const now = new Date();
186
+ const date = now.toISOString().slice(0, 10).replace(/-/g, '');
187
+ const time = now.toISOString().slice(11, 19).replace(/:/g, '');
188
+ const id = `TICKET-${date}-${time}-manual`;
189
+ const client = await getClient();
190
+ await client.execute({
191
+ sql: `INSERT INTO tickets (id, type, title, intent, status) VALUES (?, ?, ?, ?, 'Backlog')`,
192
+ args: [id, type, title, intent],
193
+ });
194
+ // Return refreshed kanban columns
195
+ const statuses = ['Backlog', 'In Progress', 'In Review', 'Done'];
196
+ const archiveStatuses = ['Blocked', 'Paused', 'Abandoned', 'Superseded'];
197
+ const limit = 20;
198
+ const columnData = await Promise.all(statuses.map(async (status) => {
199
+ const result = await client.execute({
200
+ sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
201
+ FROM tickets WHERE status = ? ORDER BY created_at DESC LIMIT ?`,
202
+ args: [status, limit + 1],
203
+ });
204
+ const hasMore = result.rows.length > limit;
205
+ const tickets = result.rows.slice(0, limit).map((row) => parseTicketRow(row));
206
+ return { status, tickets, hasMore };
207
+ }));
208
+ // Add Archive column
209
+ const archiveResult = await client.execute({
210
+ sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
211
+ FROM tickets WHERE status IN (?, ?, ?, ?) ORDER BY created_at DESC LIMIT ?`,
212
+ args: [...archiveStatuses, limit + 1],
213
+ });
214
+ const archiveHasMore = archiveResult.rows.length > limit;
215
+ const archiveTickets = archiveResult.rows.slice(0, limit).map((row) => parseTicketRow(row));
216
+ columnData.push({ status: 'Archive', tickets: archiveTickets, hasMore: archiveHasMore });
217
+ return c.html(renderKanbanColumns(columnData));
218
+ }
219
+ catch (error) {
220
+ return c.html(`<div class="text-red-500 p-2">Error: ${error.message}</div>`, 500);
221
+ }
222
+ });
223
+ // Delete ticket
224
+ app.delete('/api/tickets/:id', async (c) => {
225
+ try {
226
+ const id = c.req.param('id');
227
+ const client = await getClient();
228
+ // First, orphan any derived knowledge (set origin_ticket_id to NULL)
229
+ await client.execute({
230
+ sql: `UPDATE knowledge SET origin_ticket_id = NULL WHERE origin_ticket_id = ?`,
231
+ args: [id],
232
+ });
233
+ // Delete the ticket
234
+ const result = await client.execute({
235
+ sql: `DELETE FROM tickets WHERE id = ?`,
236
+ args: [id],
237
+ });
238
+ if (result.rowsAffected === 0) {
239
+ return c.json({ success: false, error: 'Ticket not found' }, 404);
240
+ }
241
+ return c.json({ success: true });
242
+ }
243
+ catch (error) {
244
+ return c.json({ success: false, error: error.message }, 500);
245
+ }
246
+ });
247
+ // Edit ticket (title, type, intent)
248
+ app.patch('/api/tickets/:id', async (c) => {
249
+ try {
250
+ const id = c.req.param('id');
251
+ const formData = await c.req.parseBody();
252
+ const title = formData.title?.trim();
253
+ const type = formData.type || 'feature';
254
+ const intent = formData.intent?.trim();
255
+ if (!title) {
256
+ return c.html('<div class="text-red-500 p-2">Title is required</div>', 400);
257
+ }
258
+ if (!intent) {
259
+ return c.html('<div class="text-red-500 p-2">Intent is required</div>', 400);
260
+ }
261
+ const client = await getClient();
262
+ await client.execute({
263
+ sql: `UPDATE tickets SET title = ?, type = ?, intent = ?, updated_at = datetime('now') WHERE id = ?`,
264
+ args: [title, type, intent, id],
265
+ });
266
+ // Fetch updated ticket and return detail modal
267
+ const result = await client.execute({
268
+ sql: `SELECT id, type, title, status, intent, context, constraints_use, constraints_avoid,
269
+ assumptions, tasks, definition_of_done, change_class, change_class_reason,
270
+ origin_spec_id, plan, derived_knowledge, comments, created_at, updated_at FROM tickets WHERE id = ?`,
271
+ args: [id],
272
+ });
273
+ if (result.rows.length === 0) {
274
+ return c.html('<div class="p-6 text-red-500">Ticket not found</div>', 404);
275
+ }
276
+ const ticket = parseTicketRow(result.rows[0]);
277
+ // Trigger kanban refresh in the background
278
+ c.header('HX-Trigger', 'refresh');
279
+ return c.html(renderTicketModal(ticket));
280
+ }
281
+ catch (error) {
282
+ return c.html(`<div class="text-red-500 p-2">Error: ${error.message}</div>`, 500);
283
+ }
284
+ });
285
+ // Toggle knowledge active status
286
+ app.patch('/api/knowledge/:id/active', async (c) => {
287
+ try {
288
+ const id = c.req.param('id');
289
+ const body = await c.req.parseBody();
290
+ // hx-vals sends string "true"/"false"
291
+ const active = body.active === 'true' ? 1 : 0;
292
+ const client = await getClient();
293
+ await client.execute({
294
+ sql: 'UPDATE knowledge SET active = ? WHERE id = ?',
295
+ args: [active, id],
296
+ });
297
+ // Fetch updated knowledge and return modal HTML
298
+ const result = await client.execute({
299
+ sql: `SELECT id, namespace, chunk_index, title, content,
300
+ category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
301
+ usage_count, last_used_at, created_at, updated_at
302
+ FROM knowledge WHERE id = ?`,
303
+ args: [id],
304
+ });
305
+ if (result.rows.length === 0) {
306
+ return c.html('<div class="p-6 text-red-500">Knowledge not found</div>', 404);
307
+ }
308
+ const knowledge = parseKnowledgeRow(result.rows[0]);
309
+ return c.html(renderKnowledgeModal(knowledge));
310
+ }
311
+ catch (error) {
312
+ return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`, 500);
313
+ }
314
+ });
315
+ // List knowledge
316
+ app.get('/api/knowledge', async (c) => {
317
+ try {
318
+ const client = await getClient();
319
+ const category = c.req.query('category');
320
+ const namespace = c.req.query('namespace');
321
+ const scope = c.req.query('scope');
322
+ const status = c.req.query('status') || 'active';
323
+ const conditions = [];
324
+ const args = [];
325
+ // Status filter
326
+ if (status === 'active') {
327
+ conditions.push('active = 1');
328
+ }
329
+ else if (status === 'inactive') {
330
+ conditions.push('active = 0');
331
+ }
332
+ // 'all' = no filter
333
+ if (category) {
334
+ conditions.push('category = ?');
335
+ args.push(category);
336
+ }
337
+ if (namespace) {
338
+ conditions.push('namespace = ?');
339
+ args.push(namespace);
340
+ }
341
+ if (scope) {
342
+ conditions.push('decision_scope = ?');
343
+ args.push(scope);
344
+ }
345
+ const sql = `SELECT id, namespace, chunk_index, title, content,
346
+ category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
347
+ usage_count, last_used_at, created_at, updated_at
348
+ FROM knowledge WHERE ${conditions.join(' AND ')} ORDER BY created_at DESC LIMIT 50`;
349
+ const result = await client.execute({ sql, args });
350
+ const knowledge = result.rows.map((row) => parseKnowledgeRow(row));
351
+ return c.json({ success: true, data: knowledge });
352
+ }
353
+ catch (error) {
354
+ return c.json({ success: false, error: error.message }, 500);
355
+ }
356
+ });
357
+ // Semantic search
358
+ app.post('/api/search', async (c) => {
359
+ try {
360
+ const body = await c.req.json();
361
+ const query = body.query;
362
+ const limit = body.limit || 5;
363
+ const namespace = body.namespace;
364
+ const category = body.category;
365
+ if (!query || query.trim().length < 2) {
366
+ return c.json({ success: true, data: { query: '', results: [] } });
367
+ }
368
+ const client = await getClient();
369
+ const queryEmbedding = await embed(query);
370
+ const topK = limit * 2;
371
+ const conditions = ['k.active = 1'];
372
+ const args = [
373
+ JSON.stringify(queryEmbedding),
374
+ JSON.stringify(queryEmbedding),
375
+ ];
376
+ if (namespace) {
377
+ conditions.push('k.namespace = ?');
378
+ args.push(namespace);
379
+ }
380
+ if (category) {
381
+ conditions.push('k.category = ?');
382
+ args.push(category);
383
+ }
384
+ // Try indexed search first (k must be literal, not bound parameter)
385
+ try {
386
+ const sql = `
387
+ SELECT
388
+ k.id, k.namespace, k.chunk_index, k.title, k.content,
389
+ k.category, k.tags, k.source, k.origin_ticket_id, k.origin_ticket_type, k.confidence, k.active, k.decision_scope,
390
+ k.usage_count, k.last_used_at, k.created_at,
391
+ vector_distance_cos(k.embedding, vector32(?)) as distance
392
+ FROM vector_top_k('knowledge_embedding_idx', vector32(?), ${topK}) AS v
393
+ JOIN knowledge k ON k.rowid = v.id
394
+ WHERE ${conditions.join(' AND ')}
395
+ ORDER BY distance ASC
396
+ LIMIT ${limit}
397
+ `;
398
+ const result = await client.execute({ sql, args });
399
+ const results = result.rows.map((row) => parseSearchRow(row));
400
+ // Track usage for returned results
401
+ await trackUsage(results.map(r => r.id));
402
+ return c.json({ success: true, data: { query, results } });
403
+ }
404
+ catch {
405
+ // Fallback to non-indexed search
406
+ const fallbackConditions = ['active = 1'];
407
+ const fallbackArgs = [JSON.stringify(queryEmbedding)];
408
+ if (namespace) {
409
+ fallbackConditions.push('namespace = ?');
410
+ fallbackArgs.push(namespace);
411
+ }
412
+ if (category) {
413
+ fallbackConditions.push('category = ?');
414
+ fallbackArgs.push(category);
415
+ }
416
+ const fallbackSql = `
417
+ SELECT
418
+ id, namespace, chunk_index, title, content,
419
+ category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
420
+ usage_count, last_used_at, created_at,
421
+ vector_distance_cos(embedding, vector32(?)) as distance
422
+ FROM knowledge
423
+ WHERE ${fallbackConditions.join(' AND ')}
424
+ ORDER BY distance ASC
425
+ LIMIT ?
426
+ `;
427
+ fallbackArgs.push(limit);
428
+ const result = await client.execute({ sql: fallbackSql, args: fallbackArgs });
429
+ const results = result.rows.map((row) => parseSearchRow(row));
430
+ // Track usage for returned results
431
+ await trackUsage(results.map(r => r.id));
432
+ return c.json({ success: true, data: { query, results } });
433
+ }
434
+ }
435
+ catch (error) {
436
+ return c.json({ success: false, error: error.message }, 500);
437
+ }
438
+ });
439
+ // ============ HTMX PARTIAL ROUTES (HTML) ============
440
+ // Kanban view
441
+ app.get('/partials/kanban-view', (c) => {
442
+ return c.html(renderKanbanView());
443
+ });
444
+ // Kanban columns - paginated per status (20 tickets per column initially)
445
+ app.get('/partials/kanban-columns', async (c) => {
446
+ try {
447
+ const client = await getClient();
448
+ const statuses = ['Backlog', 'In Progress', 'In Review', 'Done'];
449
+ const archiveStatuses = ['Blocked', 'Paused', 'Abandoned', 'Superseded'];
450
+ const limit = 20;
451
+ const columnData = await Promise.all(statuses.map(async (status) => {
452
+ const result = await client.execute({
453
+ sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
454
+ FROM tickets WHERE status = ? ORDER BY created_at DESC LIMIT ?`,
455
+ args: [status, limit + 1],
456
+ });
457
+ const hasMore = result.rows.length > limit;
458
+ const tickets = result.rows.slice(0, limit).map((row) => parseTicketRow(row));
459
+ return { status, tickets, hasMore };
460
+ }));
461
+ // Add Archive column (Blocked, Paused, Abandoned, Superseded)
462
+ const archiveResult = await client.execute({
463
+ sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
464
+ FROM tickets WHERE status IN (?, ?, ?, ?) ORDER BY created_at DESC LIMIT ?`,
465
+ args: [...archiveStatuses, limit + 1],
466
+ });
467
+ const archiveHasMore = archiveResult.rows.length > limit;
468
+ const archiveTickets = archiveResult.rows.slice(0, limit).map((row) => parseTicketRow(row));
469
+ columnData.push({ status: 'Archive', tickets: archiveTickets, hasMore: archiveHasMore });
470
+ return c.html(renderKanbanColumns(columnData));
471
+ }
472
+ catch (error) {
473
+ return c.html(`<div class="text-red-500 p-4">Error: ${error.message}</div>`);
474
+ }
475
+ });
476
+ // Load more tickets for a specific column
477
+ app.get('/partials/kanban-column/:status', async (c) => {
478
+ try {
479
+ const status = decodeURIComponent(c.req.param('status'));
480
+ const offset = parseInt(c.req.query('offset') || '0', 10);
481
+ const limit = 20;
482
+ const client = await getClient();
483
+ let result;
484
+ // Handle Archive column specially (multiple statuses)
485
+ if (status === 'Archive') {
486
+ const archiveStatuses = ['Blocked', 'Paused', 'Abandoned', 'Superseded'];
487
+ result = await client.execute({
488
+ sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
489
+ FROM tickets WHERE status IN (?, ?, ?, ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`,
490
+ args: [...archiveStatuses, limit + 1, offset],
491
+ });
492
+ }
493
+ else {
494
+ result = await client.execute({
495
+ sql: `SELECT id, type, title, status, intent, change_class, change_class_reason, tasks
496
+ FROM tickets WHERE status = ? ORDER BY created_at DESC LIMIT ? OFFSET ?`,
497
+ args: [status, limit + 1, offset],
498
+ });
499
+ }
500
+ const hasMore = result.rows.length > limit;
501
+ const tickets = result.rows.slice(0, limit).map((row) => parseTicketRow(row));
502
+ const nextOffset = offset + limit;
503
+ return c.html(renderColumnMore(tickets, status, nextOffset, hasMore));
504
+ }
505
+ catch (error) {
506
+ return c.html(`<div class="text-red-500 p-4">Error: ${error.message}</div>`);
507
+ }
508
+ });
509
+ // Ticket modal - optimized query selecting only needed fields
510
+ app.get('/partials/ticket-modal/:id', async (c) => {
511
+ try {
512
+ const id = c.req.param('id');
513
+ const client = await getClient();
514
+ const result = await client.execute({
515
+ sql: `SELECT id, type, title, status, intent, context, constraints_use, constraints_avoid,
516
+ assumptions, tasks, definition_of_done, change_class, change_class_reason,
517
+ origin_spec_id, plan, derived_knowledge, comments, created_at, updated_at FROM tickets WHERE id = ?`,
518
+ args: [id],
519
+ });
520
+ if (result.rows.length === 0) {
521
+ return c.html('<div class="p-6 text-red-500">Ticket not found</div>');
522
+ }
523
+ const ticket = parseTicketRow(result.rows[0]);
524
+ return c.html(renderTicketModal(ticket));
525
+ }
526
+ catch (error) {
527
+ return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`);
528
+ }
529
+ });
530
+ // New ticket modal
531
+ app.get('/partials/new-ticket-modal', (c) => {
532
+ return c.html(renderNewTicketModal());
533
+ });
534
+ // Edit ticket modal
535
+ app.get('/partials/edit-ticket-modal/:id', async (c) => {
536
+ try {
537
+ const id = c.req.param('id');
538
+ const client = await getClient();
539
+ const result = await client.execute({
540
+ sql: `SELECT id, type, title, intent FROM tickets WHERE id = ?`,
541
+ args: [id],
542
+ });
543
+ if (result.rows.length === 0) {
544
+ return c.html('<div class="p-6 text-red-500">Ticket not found</div>');
545
+ }
546
+ const ticket = parseTicketRow(result.rows[0]);
547
+ return c.html(renderEditTicketModal(ticket));
548
+ }
549
+ catch (error) {
550
+ return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`);
551
+ }
552
+ });
553
+ // Search view
554
+ app.get('/partials/search-view', (c) => {
555
+ return c.html(renderSearchView());
556
+ });
557
+ // Search results
558
+ app.get('/partials/search-results', async (c) => {
559
+ try {
560
+ const query = c.req.query('query');
561
+ const namespace = c.req.query('namespace');
562
+ const category = c.req.query('category');
563
+ const limit = parseInt(c.req.query('limit') || '5', 10);
564
+ if (!query || query.trim().length < 2) {
565
+ return c.html('<p class="text-gray-500 text-center py-8">Enter at least 2 characters to search</p>');
566
+ }
567
+ const client = await getClient();
568
+ const queryEmbedding = await embed(query);
569
+ const topK = limit * 2;
570
+ const conditions = ['k.active = 1'];
571
+ const args = [
572
+ JSON.stringify(queryEmbedding),
573
+ JSON.stringify(queryEmbedding),
574
+ ];
575
+ if (namespace) {
576
+ conditions.push('k.namespace = ?');
577
+ args.push(namespace);
578
+ }
579
+ if (category) {
580
+ conditions.push('k.category = ?');
581
+ args.push(category);
582
+ }
583
+ // Try indexed search (k must be literal, not bound parameter)
584
+ try {
585
+ const sql = `
586
+ SELECT
587
+ k.id, k.namespace, k.chunk_index, k.title, k.content,
588
+ k.category, k.tags, k.source, k.origin_ticket_id, k.origin_ticket_type, k.confidence, k.active, k.decision_scope,
589
+ k.usage_count, k.last_used_at, k.created_at,
590
+ vector_distance_cos(k.embedding, vector32(?)) as distance
591
+ FROM vector_top_k('knowledge_embedding_idx', vector32(?), ${topK}) AS v
592
+ JOIN knowledge k ON k.rowid = v.id
593
+ WHERE ${conditions.join(' AND ')}
594
+ ORDER BY distance ASC
595
+ LIMIT ${limit}
596
+ `;
597
+ const result = await client.execute({ sql, args });
598
+ const results = result.rows.map((row) => parseSearchRow(row));
599
+ // Track usage for returned results
600
+ await trackUsage(results.map(r => r.id));
601
+ return c.html(renderSearchResults(results));
602
+ }
603
+ catch {
604
+ // Fallback to non-indexed search
605
+ const fallbackConditions = ['active = 1'];
606
+ const fallbackArgs = [JSON.stringify(queryEmbedding)];
607
+ if (namespace) {
608
+ fallbackConditions.push('namespace = ?');
609
+ fallbackArgs.push(namespace);
610
+ }
611
+ if (category) {
612
+ fallbackConditions.push('category = ?');
613
+ fallbackArgs.push(category);
614
+ }
615
+ const fallbackSql = `
616
+ SELECT
617
+ id, namespace, chunk_index, title, content,
618
+ category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
619
+ usage_count, last_used_at, created_at,
620
+ vector_distance_cos(embedding, vector32(?)) as distance
621
+ FROM knowledge
622
+ WHERE ${fallbackConditions.join(' AND ')}
623
+ ORDER BY distance ASC
624
+ LIMIT ?
625
+ `;
626
+ fallbackArgs.push(limit);
627
+ const result = await client.execute({ sql: fallbackSql, args: fallbackArgs });
628
+ const results = result.rows.map((row) => parseSearchRow(row));
629
+ // Track usage for returned results
630
+ await trackUsage(results.map(r => r.id));
631
+ return c.html(renderSearchResults(results));
632
+ }
633
+ }
634
+ catch (error) {
635
+ return c.html(`<div class="text-red-500 p-4">Search error: ${error.message}</div>`);
636
+ }
637
+ });
638
+ // Knowledge view
639
+ app.get('/partials/knowledge-view', (c) => {
640
+ return c.html(renderKnowledgeView());
641
+ });
642
+ // Knowledge list
643
+ app.get('/partials/knowledge-list', async (c) => {
644
+ try {
645
+ const client = await getClient();
646
+ const category = c.req.query('k-category');
647
+ const namespace = c.req.query('k-namespace');
648
+ const scope = c.req.query('k-scope');
649
+ const sourceFilter = c.req.query('k-origin');
650
+ const status = c.req.query('k-status') || 'active';
651
+ const conditions = [];
652
+ const args = [];
653
+ // Status filter
654
+ if (status === 'active') {
655
+ conditions.push('active = 1');
656
+ }
657
+ else if (status === 'inactive') {
658
+ conditions.push('active = 0');
659
+ }
660
+ // 'all' = no filter
661
+ if (category) {
662
+ conditions.push('category = ?');
663
+ args.push(category);
664
+ }
665
+ if (namespace) {
666
+ conditions.push('namespace = ?');
667
+ args.push(namespace);
668
+ }
669
+ if (scope) {
670
+ conditions.push('decision_scope = ?');
671
+ args.push(scope);
672
+ }
673
+ if (sourceFilter) {
674
+ conditions.push('source = ?');
675
+ args.push(sourceFilter);
676
+ }
677
+ let sql = `SELECT id, namespace, chunk_index, title, content,
678
+ category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
679
+ usage_count, last_used_at, created_at, updated_at
680
+ FROM knowledge`;
681
+ if (conditions.length > 0) {
682
+ sql += ` WHERE ${conditions.join(' AND ')}`;
683
+ }
684
+ sql += ' ORDER BY created_at DESC LIMIT 50';
685
+ const result = await client.execute({ sql, args });
686
+ const knowledge = result.rows.map((row) => parseKnowledgeRow(row));
687
+ return c.html(renderKnowledgeList(knowledge));
688
+ }
689
+ catch (error) {
690
+ return c.html(`<div class="text-red-500 p-4">Error: ${error.message}</div>`);
691
+ }
692
+ });
693
+ // Knowledge modal
694
+ app.get('/partials/knowledge-modal/:id', async (c) => {
695
+ try {
696
+ const id = c.req.param('id');
697
+ const client = await getClient();
698
+ const result = await client.execute({
699
+ sql: `SELECT id, namespace, chunk_index, title, content,
700
+ category, tags, source, origin_ticket_id, origin_ticket_type, confidence, active, decision_scope,
701
+ usage_count, last_used_at, created_at, updated_at
702
+ FROM knowledge WHERE id = ?`,
703
+ args: [id],
704
+ });
705
+ if (result.rows.length === 0) {
706
+ return c.html('<div class="p-6 text-red-500">Knowledge entry not found</div>');
707
+ }
708
+ const knowledge = parseKnowledgeRow(result.rows[0]);
709
+ return c.html(renderKnowledgeModal(knowledge));
710
+ }
711
+ catch (error) {
712
+ return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`);
713
+ }
714
+ });
715
+ // ============ SPEC PARTIALS ============
716
+ // Spec view
717
+ app.get('/partials/spec-view', (c) => {
718
+ return c.html(renderSpecView());
719
+ });
720
+ // New spec modal
721
+ app.get('/partials/new-spec-modal', (c) => {
722
+ return c.html(renderNewSpecModal());
723
+ });
724
+ // Spec list
725
+ app.get('/partials/spec-list', async (c) => {
726
+ try {
727
+ const client = await getClient();
728
+ const result = await client.execute({
729
+ sql: 'SELECT id, title, content, created_at, updated_at FROM specs ORDER BY created_at DESC LIMIT 50',
730
+ args: [],
731
+ });
732
+ const specs = result.rows.map((row) => parseSpecRow(row));
733
+ // Get ticket counts per spec
734
+ const countResult = await client.execute({
735
+ sql: 'SELECT origin_spec_id, COUNT(*) as cnt FROM tickets WHERE origin_spec_id IS NOT NULL GROUP BY origin_spec_id',
736
+ args: [],
737
+ });
738
+ const ticketCounts = {};
739
+ for (const row of countResult.rows) {
740
+ ticketCounts[row.origin_spec_id] = Number(row.cnt);
741
+ }
742
+ return c.html(renderSpecList(specs, ticketCounts));
743
+ }
744
+ catch (error) {
745
+ return c.html(`<div class="text-red-500 p-4">Error: ${error.message}</div>`);
746
+ }
747
+ });
748
+ // Spec modal
749
+ app.get('/partials/spec-modal/:id', async (c) => {
750
+ try {
751
+ const id = c.req.param('id');
752
+ const client = await getClient();
753
+ const result = await client.execute({
754
+ sql: 'SELECT id, title, content, created_at, updated_at FROM specs WHERE id = ?',
755
+ args: [id],
756
+ });
757
+ if (result.rows.length === 0) {
758
+ return c.html('<div class="p-6 text-red-500">Spec not found</div>');
759
+ }
760
+ const spec = parseSpecRow(result.rows[0]);
761
+ // Get related tickets
762
+ const ticketResult = await client.execute({
763
+ sql: 'SELECT id, title, status FROM tickets WHERE origin_spec_id = ? ORDER BY created_at DESC',
764
+ args: [id],
765
+ });
766
+ const relatedTickets = ticketResult.rows.map(row => ({
767
+ id: row.id,
768
+ title: row.title,
769
+ status: row.status,
770
+ }));
771
+ return c.html(renderSpecModal(spec, relatedTickets));
772
+ }
773
+ catch (error) {
774
+ return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`);
775
+ }
776
+ });
777
+ // Edit spec modal
778
+ app.get('/partials/edit-spec-modal/:id', async (c) => {
779
+ try {
780
+ const id = c.req.param('id');
781
+ const client = await getClient();
782
+ const result = await client.execute({
783
+ sql: 'SELECT id, title, content, created_at, updated_at FROM specs WHERE id = ?',
784
+ args: [id],
785
+ });
786
+ if (result.rows.length === 0) {
787
+ return c.html('<div class="p-6 text-red-500">Spec not found</div>');
788
+ }
789
+ const spec = parseSpecRow(result.rows[0]);
790
+ return c.html(renderEditSpecModal(spec));
791
+ }
792
+ catch (error) {
793
+ return c.html(`<div class="p-6 text-red-500">Error: ${error.message}</div>`);
794
+ }
795
+ });
796
+ // Update spec
797
+ app.patch('/api/specs/:id', async (c) => {
798
+ try {
799
+ const id = c.req.param('id');
800
+ const formData = await c.req.parseBody();
801
+ const title = formData.title?.trim();
802
+ const content = formData.content?.trim();
803
+ if (!title) {
804
+ return c.html('<div class="text-red-500 p-2">Title is required</div>', 400);
805
+ }
806
+ if (!content) {
807
+ return c.html('<div class="text-red-500 p-2">Content is required</div>', 400);
808
+ }
809
+ const client = await getClient();
810
+ await client.execute({
811
+ sql: `UPDATE specs SET title = ?, content = ?, updated_at = datetime('now') WHERE id = ?`,
812
+ args: [title, content, id],
813
+ });
814
+ // Fetch updated spec and return detail modal
815
+ const result = await client.execute({
816
+ sql: 'SELECT id, title, content, created_at, updated_at FROM specs WHERE id = ?',
817
+ args: [id],
818
+ });
819
+ if (result.rows.length === 0) {
820
+ return c.html('<div class="p-6 text-red-500">Spec not found</div>', 404);
821
+ }
822
+ const spec = parseSpecRow(result.rows[0]);
823
+ // Get related tickets
824
+ const ticketResult = await client.execute({
825
+ sql: 'SELECT id, title, status FROM tickets WHERE origin_spec_id = ? ORDER BY created_at DESC',
826
+ args: [id],
827
+ });
828
+ const relatedTickets = ticketResult.rows.map(row => ({
829
+ id: row.id,
830
+ title: row.title,
831
+ status: row.status,
832
+ }));
833
+ c.header('HX-Trigger', 'refresh');
834
+ return c.html(renderSpecModal(spec, relatedTickets));
835
+ }
836
+ catch (error) {
837
+ return c.html(`<div class="text-red-500 p-2">Error: ${error.message}</div>`, 500);
838
+ }
839
+ });
840
+ // Quick-create spec (manual)
841
+ app.post('/api/specs/quick', async (c) => {
842
+ try {
843
+ const formData = await c.req.parseBody();
844
+ const title = formData.title?.trim();
845
+ const content = formData.content?.trim();
846
+ if (!title) {
847
+ return c.html('<div class="text-red-500 p-2">Title is required</div>', 400);
848
+ }
849
+ if (!content) {
850
+ return c.html('<div class="text-red-500 p-2">Content is required</div>', 400);
851
+ }
852
+ // Generate ID: SPEC-YYYYMMDD-HHMMSS
853
+ const now = new Date();
854
+ const date = now.toISOString().slice(0, 10).replace(/-/g, '');
855
+ const time = now.toISOString().slice(11, 19).replace(/:/g, '');
856
+ const id = `SPEC-${date}-${time}`;
857
+ const client = await getClient();
858
+ await client.execute({
859
+ sql: `INSERT INTO specs (id, title, content) VALUES (?, ?, ?)`,
860
+ args: [id, title, content],
861
+ });
862
+ // Return refreshed spec list
863
+ const result = await client.execute({
864
+ sql: 'SELECT id, title, content, created_at, updated_at FROM specs ORDER BY created_at DESC LIMIT 50',
865
+ args: [],
866
+ });
867
+ const specs = result.rows.map((row) => parseSpecRow(row));
868
+ const countResult = await client.execute({
869
+ sql: 'SELECT origin_spec_id, COUNT(*) as cnt FROM tickets WHERE origin_spec_id IS NOT NULL GROUP BY origin_spec_id',
870
+ args: [],
871
+ });
872
+ const ticketCounts = {};
873
+ for (const row of countResult.rows) {
874
+ ticketCounts[row.origin_spec_id] = Number(row.cnt);
875
+ }
876
+ return c.html(renderSpecList(specs, ticketCounts));
877
+ }
878
+ catch (error) {
879
+ return c.html(`<div class="text-red-500 p-2">Error: ${error.message}</div>`, 500);
880
+ }
881
+ });
882
+ // Delete spec
883
+ app.delete('/api/specs/:id', async (c) => {
884
+ try {
885
+ const id = c.req.param('id');
886
+ const client = await getClient();
887
+ await client.execute({
888
+ sql: 'DELETE FROM specs WHERE id = ?',
889
+ args: [id],
890
+ });
891
+ // Return updated spec list
892
+ const result = await client.execute({
893
+ sql: 'SELECT id, title, content, created_at, updated_at FROM specs ORDER BY created_at DESC LIMIT 50',
894
+ args: [],
895
+ });
896
+ const specs = result.rows.map((row) => parseSpecRow(row));
897
+ const countResult = await client.execute({
898
+ sql: 'SELECT origin_spec_id, COUNT(*) as cnt FROM tickets WHERE origin_spec_id IS NOT NULL GROUP BY origin_spec_id',
899
+ args: [],
900
+ });
901
+ const ticketCounts = {};
902
+ for (const row of countResult.rows) {
903
+ ticketCounts[row.origin_spec_id] = Number(row.cnt);
904
+ }
905
+ return c.html(renderSpecList(specs, ticketCounts));
906
+ }
907
+ catch (error) {
908
+ return c.html(`<div class="text-red-500 p-4">Error: ${error.message}</div>`);
909
+ }
910
+ });
911
+ // ============ START SERVER ============
912
+ const config = loadConfig();
913
+ const isLocal = config.url.startsWith('file:');
914
+ const dbMode = isLocal ? 'Local' : 'Cloud';
915
+ const banner = `
916
+ \x1b[36m ███████╗███╗ ███╗ █████╗ ██████╗ ████████╗
917
+ ██╔════╝████╗ ████║██╔══██╗██╔══██╗╚══██╔══╝
918
+ ███████╗██╔████╔██║███████║██████╔╝ ██║
919
+ ╚════██║██║╚██╔╝██║██╔══██║██╔══██╗ ██║
920
+ ███████║██║ ╚═╝ ██║██║ ██║██║ ██║ ██║
921
+ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝
922
+ ██╗███╗ ██╗████████╗███████╗███╗ ██╗████████╗
923
+ ██║████╗ ██║╚══██╔══╝██╔════╝████╗ ██║╚══██╔══╝
924
+ ██║██╔██╗ ██║ ██║ █████╗ ██╔██╗ ██║ ██║
925
+ ██║██║╚██╗██║ ██║ ██╔══╝ ██║╚██╗██║ ██║
926
+ ██║██║ ╚████║ ██║ ███████╗██║ ╚████║ ██║
927
+ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝\x1b[0m
928
+
929
+ \x1b[32m*\x1b[0m Ready at \x1b[1mhttp://localhost:${port}\x1b[0m
930
+ \x1b[90m>\x1b[0m Using \x1b[33mTurso ${dbMode}\x1b[0m
931
+
932
+ \x1b[90mPress Ctrl+C to stop\x1b[0m
933
+ `;
934
+ console.log(banner);
935
+ const server = serve({
936
+ fetch: app.fetch,
937
+ port,
938
+ });
939
+ if (options.open) {
940
+ setTimeout(() => {
941
+ open(`http://localhost:${port}`);
942
+ }, 500);
943
+ }
944
+ // Handle graceful shutdown
945
+ const shutdown = () => {
946
+ console.log('\n\x1b[90m Goodbye!\x1b[0m\n');
947
+ server.close(() => {
948
+ closeClient();
949
+ process.exit(0);
950
+ });
951
+ };
952
+ process.on('SIGINT', shutdown);
953
+ process.on('SIGTERM', shutdown);
954
+ });