hotsheet 0.8.0 → 0.10.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/dist/cli.js CHANGED
@@ -315,7 +315,7 @@ async function getDashboardStats(days) {
315
315
  ticket_number: r.ticket_number,
316
316
  title: r.title,
317
317
  completed_at: r.completed_at,
318
- days: Math.max(0, Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 864e5))
318
+ hours: Math.max(0, (new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 36e5)
319
319
  }));
320
320
  const catResult = await db2.query(
321
321
  `SELECT category, COUNT(*) as count FROM tickets
@@ -356,8 +356,8 @@ async function getDashboardStats(days) {
356
356
  `SELECT COUNT(*) as count FROM tickets WHERE created_at >= $1`,
357
357
  [weekStart.toISOString()]
358
358
  );
359
- const cycleDays = cycleTime.map((c) => c.days).sort((a, b) => a - b);
360
- const medianCycleTimeDays = cycleDays.length > 0 ? cycleDays[Math.floor(cycleDays.length / 2)] : null;
359
+ const cycleHours = cycleTime.map((c) => c.hours).sort((a, b) => a - b);
360
+ const medianCycleTimeDays = cycleHours.length > 0 ? Math.round(cycleHours[Math.floor(cycleHours.length / 2)] / 24) : null;
361
361
  return {
362
362
  throughput,
363
363
  cycleTime,
@@ -450,16 +450,16 @@ __export(channel_config_exports, {
450
450
  triggerChannel: () => triggerChannel,
451
451
  unregisterChannel: () => unregisterChannel
452
452
  });
453
- import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
454
- import { dirname, join as join8, resolve as resolve2 } from "path";
453
+ import { existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
454
+ import { dirname, join as join8, resolve as resolve3 } from "path";
455
455
  import { fileURLToPath } from "url";
456
456
  function getChannelServerPath() {
457
457
  const thisDir = dirname(fileURLToPath(import.meta.url));
458
- const distPath = resolve2(thisDir, "channel.js");
458
+ const distPath = resolve3(thisDir, "channel.js");
459
459
  if (existsSync6(distPath)) {
460
460
  return { command: "node", args: [distPath] };
461
461
  }
462
- const srcPath = resolve2(thisDir, "channel.ts");
462
+ const srcPath = resolve3(thisDir, "channel.ts");
463
463
  if (existsSync6(srcPath)) {
464
464
  return { command: "npx", args: ["tsx", srcPath] };
465
465
  }
@@ -472,7 +472,7 @@ function registerChannel(dataDir2) {
472
472
  let config = {};
473
473
  if (existsSync6(mcpPath)) {
474
474
  try {
475
- config = JSON.parse(readFileSync6(mcpPath, "utf-8"));
475
+ config = JSON.parse(readFileSync5(mcpPath, "utf-8"));
476
476
  } catch {
477
477
  }
478
478
  }
@@ -481,24 +481,24 @@ function registerChannel(dataDir2) {
481
481
  command,
482
482
  args: [...args, "--data-dir", dataDir2]
483
483
  };
484
- writeFileSync6(mcpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
484
+ writeFileSync5(mcpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
485
485
  }
486
486
  function unregisterChannel() {
487
487
  const cwd = process.cwd();
488
488
  const mcpPath = join8(cwd, ".mcp.json");
489
489
  if (!existsSync6(mcpPath)) return;
490
490
  try {
491
- const config = JSON.parse(readFileSync6(mcpPath, "utf-8"));
491
+ const config = JSON.parse(readFileSync5(mcpPath, "utf-8"));
492
492
  if (config.mcpServers?.[MCP_SERVER_KEY]) {
493
493
  delete config.mcpServers[MCP_SERVER_KEY];
494
- writeFileSync6(mcpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
494
+ writeFileSync5(mcpPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
495
495
  }
496
496
  } catch {
497
497
  }
498
498
  }
499
499
  function getChannelPort(dataDir2) {
500
500
  try {
501
- const portStr = readFileSync6(join8(dataDir2, "channel-port"), "utf-8").trim();
501
+ const portStr = readFileSync5(join8(dataDir2, "channel-port"), "utf-8").trim();
502
502
  const port2 = parseInt(portStr, 10);
503
503
  return isNaN(port2) ? null : port2;
504
504
  } catch {
@@ -552,7 +552,7 @@ var init_channel_config = __esm({
552
552
  // src/cli.ts
553
553
  import { mkdirSync as mkdirSync6 } from "fs";
554
554
  import { tmpdir } from "os";
555
- import { join as join12, resolve as resolve3 } from "path";
555
+ import { join as join13, resolve as resolve4 } from "path";
556
556
 
557
557
  // src/backup.ts
558
558
  init_connection();
@@ -751,76 +751,10 @@ function initBackupScheduler(dataDir2) {
751
751
  // src/cleanup.ts
752
752
  import { rmSync as rmSync3 } from "fs";
753
753
 
754
- // src/types.ts
755
- var DEFAULT_CATEGORIES = [
756
- { id: "issue", label: "Issue", shortLabel: "ISS", color: "#6b7280", shortcutKey: "i", description: "General issues that need attention" },
757
- { id: "bug", label: "Bug", shortLabel: "BUG", color: "#ef4444", shortcutKey: "b", description: "Bugs that should be fixed in the codebase" },
758
- { id: "feature", label: "Feature", shortLabel: "FEA", color: "#22c55e", shortcutKey: "f", description: "New features to be implemented" },
759
- { id: "requirement_change", label: "Req Change", shortLabel: "REQ", color: "#f97316", shortcutKey: "r", description: "Changes to existing requirements" },
760
- { id: "task", label: "Task", shortLabel: "TSK", color: "#3b82f6", shortcutKey: "k", description: "General tasks to complete" },
761
- { id: "investigation", label: "Investigation", shortLabel: "INV", color: "#8b5cf6", shortcutKey: "g", description: "Items requiring research or analysis" }
762
- ];
763
- var CATEGORY_PRESETS = [
764
- {
765
- id: "software",
766
- name: "Software Development",
767
- categories: DEFAULT_CATEGORIES
768
- },
769
- {
770
- id: "design",
771
- name: "Design / Creative",
772
- categories: [
773
- { id: "concept", label: "Concept", shortLabel: "CON", color: "#8b5cf6", shortcutKey: "c", description: "Design concepts and explorations" },
774
- { id: "revision", label: "Revision", shortLabel: "REV", color: "#f97316", shortcutKey: "r", description: "Revisions to existing designs" },
775
- { id: "feedback", label: "Feedback", shortLabel: "FDB", color: "#3b82f6", shortcutKey: "f", description: "Client or stakeholder feedback to address" },
776
- { id: "asset", label: "Asset", shortLabel: "AST", color: "#22c55e", shortcutKey: "a", description: "Assets to produce or deliver" },
777
- { id: "research", label: "Research", shortLabel: "RSC", color: "#6b7280", shortcutKey: "s", description: "User research or competitive analysis" },
778
- { id: "bug", label: "Bug", shortLabel: "BUG", color: "#ef4444", shortcutKey: "b", description: "Visual or UI bugs" }
779
- ]
780
- },
781
- {
782
- id: "product",
783
- name: "Product Management",
784
- categories: [
785
- { id: "epic", label: "Epic", shortLabel: "EPC", color: "#8b5cf6", shortcutKey: "e", description: "Large initiatives spanning multiple stories" },
786
- { id: "story", label: "Story", shortLabel: "STY", color: "#3b82f6", shortcutKey: "s", description: "User stories describing desired functionality" },
787
- { id: "bug", label: "Bug", shortLabel: "BUG", color: "#ef4444", shortcutKey: "b", description: "Bugs that need to be fixed" },
788
- { id: "task", label: "Task", shortLabel: "TSK", color: "#22c55e", shortcutKey: "t", description: "Tasks to complete" },
789
- { id: "spike", label: "Spike", shortLabel: "SPK", color: "#f97316", shortcutKey: "k", description: "Research or investigation spikes" },
790
- { id: "debt", label: "Tech Debt", shortLabel: "DBT", color: "#6b7280", shortcutKey: "d", description: "Technical debt to address" }
791
- ]
792
- },
793
- {
794
- id: "marketing",
795
- name: "Marketing",
796
- categories: [
797
- { id: "campaign", label: "Campaign", shortLabel: "CMP", color: "#8b5cf6", shortcutKey: "c", description: "Marketing campaigns" },
798
- { id: "content", label: "Content", shortLabel: "CNT", color: "#3b82f6", shortcutKey: "n", description: "Content to create or publish" },
799
- { id: "design", label: "Design", shortLabel: "DES", color: "#22c55e", shortcutKey: "d", description: "Design requests and assets" },
800
- { id: "analytics", label: "Analytics", shortLabel: "ANL", color: "#f97316", shortcutKey: "a", description: "Analytics and reporting tasks" },
801
- { id: "outreach", label: "Outreach", shortLabel: "OUT", color: "#6b7280", shortcutKey: "o", description: "Outreach and partnership activities" },
802
- { id: "event", label: "Event", shortLabel: "EVT", color: "#ef4444", shortcutKey: "e", description: "Events to plan or manage" }
803
- ]
804
- },
805
- {
806
- id: "personal",
807
- name: "Personal",
808
- categories: [
809
- { id: "task", label: "Task", shortLabel: "TSK", color: "#3b82f6", shortcutKey: "t", description: "Things to do" },
810
- { id: "idea", label: "Idea", shortLabel: "IDA", color: "#22c55e", shortcutKey: "i", description: "Ideas to explore" },
811
- { id: "note", label: "Note", shortLabel: "NTE", color: "#6b7280", shortcutKey: "n", description: "Notes and references" },
812
- { id: "errand", label: "Errand", shortLabel: "ERR", color: "#f97316", shortcutKey: "e", description: "Errands and appointments" },
813
- { id: "project", label: "Project", shortLabel: "PRJ", color: "#8b5cf6", shortcutKey: "p", description: "Larger projects" },
814
- { id: "urgent", label: "Urgent", shortLabel: "URG", color: "#ef4444", shortcutKey: "u", description: "Urgent items" }
815
- ]
816
- }
817
- ];
818
- var CATEGORIES = DEFAULT_CATEGORIES.map((c) => ({ value: c.id, label: c.label, color: c.color }));
819
- var CATEGORY_DESCRIPTIONS = Object.fromEntries(
820
- DEFAULT_CATEGORIES.map((c) => [c.id, c.description])
821
- );
754
+ // src/db/tickets.ts
755
+ init_connection();
822
756
 
823
- // src/db/queries.ts
757
+ // src/db/notes.ts
824
758
  init_connection();
825
759
  var noteCounter = 0;
826
760
  function generateNoteId() {
@@ -863,6 +797,8 @@ async function deleteNote(ticketId, noteId2) {
863
797
  await db2.query(`UPDATE tickets SET notes = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(notes), ticketId]);
864
798
  return notes;
865
799
  }
800
+
801
+ // src/db/tickets.ts
866
802
  async function nextTicketNumber() {
867
803
  const db2 = await getDb();
868
804
  const result = await db2.query("SELECT nextval('ticket_seq')");
@@ -1074,38 +1010,6 @@ async function toggleUpNext(id) {
1074
1010
  );
1075
1011
  return result.rows[0] ?? null;
1076
1012
  }
1077
- async function addAttachment(ticketId, originalFilename, storedPath) {
1078
- const db2 = await getDb();
1079
- const result = await db2.query(
1080
- `INSERT INTO attachments (ticket_id, original_filename, stored_path) VALUES ($1, $2, $3) RETURNING *`,
1081
- [ticketId, originalFilename, storedPath]
1082
- );
1083
- return result.rows[0];
1084
- }
1085
- async function getAttachments(ticketId) {
1086
- const db2 = await getDb();
1087
- const result = await db2.query(
1088
- `SELECT * FROM attachments WHERE ticket_id = $1 ORDER BY created_at ASC`,
1089
- [ticketId]
1090
- );
1091
- return result.rows;
1092
- }
1093
- async function getAttachment(id) {
1094
- const db2 = await getDb();
1095
- const result = await db2.query(
1096
- `SELECT * FROM attachments WHERE id = $1`,
1097
- [id]
1098
- );
1099
- return result.rows[0] ?? null;
1100
- }
1101
- async function deleteAttachment(id) {
1102
- const db2 = await getDb();
1103
- const result = await db2.query(
1104
- `DELETE FROM attachments WHERE id = $1 RETURNING *`,
1105
- [id]
1106
- );
1107
- return result.rows[0] ?? null;
1108
- }
1109
1013
  async function getTicketsForCleanup(verifiedDays = 30, trashDays = 3) {
1110
1014
  const db2 = await getDb();
1111
1015
  const result = await db2.query(`
@@ -1210,69 +1114,6 @@ async function queryTickets(logic, conditions, sortBy, sortDir, requiredTag) {
1210
1114
  );
1211
1115
  return result.rows;
1212
1116
  }
1213
- function normalizeTag(input) {
1214
- return input.replace(/[^a-zA-Z0-9]+/g, " ").trim().toLowerCase();
1215
- }
1216
- function extractBracketTags(input) {
1217
- const tags = [];
1218
- const cleaned = input.replace(/\[([^\]]*)\]/g, (_match, content) => {
1219
- const tag = normalizeTag(content);
1220
- if (tag && !tags.includes(tag)) tags.push(tag);
1221
- return " ";
1222
- });
1223
- const title = cleaned.replace(/\s+/g, " ").trim();
1224
- return { title, tags };
1225
- }
1226
- async function getAllTags() {
1227
- const db2 = await getDb();
1228
- const result = await db2.query(`SELECT DISTINCT tags FROM tickets WHERE tags != '[]' AND status != 'deleted'`);
1229
- const tagSet = /* @__PURE__ */ new Set();
1230
- for (const row of result.rows) {
1231
- try {
1232
- const parsed = JSON.parse(row.tags);
1233
- if (Array.isArray(parsed)) {
1234
- for (const tag of parsed) {
1235
- if (typeof tag === "string" && tag.trim()) {
1236
- const norm = normalizeTag(tag);
1237
- if (norm) tagSet.add(norm);
1238
- }
1239
- }
1240
- }
1241
- } catch {
1242
- }
1243
- }
1244
- return Array.from(tagSet).sort();
1245
- }
1246
- async function getCategories() {
1247
- const settings = await getSettings();
1248
- if (settings.categories) {
1249
- try {
1250
- const parsed = JSON.parse(settings.categories);
1251
- if (Array.isArray(parsed) && parsed.length > 0) return parsed;
1252
- } catch {
1253
- }
1254
- }
1255
- return DEFAULT_CATEGORIES;
1256
- }
1257
- async function saveCategories(categories) {
1258
- await updateSetting("categories", JSON.stringify(categories));
1259
- }
1260
- async function getSettings() {
1261
- const db2 = await getDb();
1262
- const result = await db2.query("SELECT key, value FROM settings");
1263
- const settings = {};
1264
- for (const row of result.rows) {
1265
- settings[row.key] = row.value;
1266
- }
1267
- return settings;
1268
- }
1269
- async function updateSetting(key, value) {
1270
- const db2 = await getDb();
1271
- await db2.query(
1272
- `INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2`,
1273
- [key, value]
1274
- );
1275
- }
1276
1117
  async function restoreTicket(id) {
1277
1118
  return updateTicket(id, { status: "not_started" });
1278
1119
  }
@@ -1324,86 +1165,259 @@ async function getTicketStats() {
1324
1165
  };
1325
1166
  }
1326
1167
 
1327
- // src/cleanup.ts
1328
- async function cleanupAttachments() {
1329
- try {
1330
- const settings = await getSettings();
1331
- const verifiedDays = parseInt(settings.verified_cleanup_days, 10) || 30;
1332
- const trashDays = parseInt(settings.trash_cleanup_days, 10) || 3;
1333
- const tickets = await getTicketsForCleanup(verifiedDays, trashDays);
1334
- if (tickets.length === 0) return;
1335
- let cleaned = 0;
1336
- for (const ticket of tickets) {
1337
- const attachments = await getAttachments(ticket.id);
1338
- for (const att of attachments) {
1339
- try {
1340
- rmSync3(att.stored_path, { force: true });
1341
- } catch {
1342
- }
1343
- }
1344
- await hardDeleteTicket(ticket.id);
1345
- cleaned++;
1346
- }
1347
- if (cleaned > 0) {
1348
- console.log(` Cleaned up ${cleaned} old ticket(s) and their attachments.`);
1349
- }
1350
- } catch (err) {
1351
- console.error("Attachment cleanup failed:", err);
1352
- }
1353
- }
1354
-
1355
- // src/cli.ts
1168
+ // src/db/attachments.ts
1356
1169
  init_connection();
1357
- init_file_settings();
1358
-
1359
- // src/lock.ts
1360
- import { existsSync as existsSync3, readFileSync as readFileSync3, rmSync as rmSync4, writeFileSync as writeFileSync3 } from "fs";
1361
- import { join as join4 } from "path";
1362
- var lockPath = null;
1363
- function acquireLock(dataDir2) {
1364
- lockPath = join4(dataDir2, "hotsheet.lock");
1365
- if (existsSync3(lockPath)) {
1366
- try {
1367
- const contents = JSON.parse(readFileSync3(lockPath, "utf-8"));
1368
- const pid = contents.pid;
1369
- try {
1370
- process.kill(pid, 0);
1371
- console.error(`
1372
- Error: Another Hot Sheet instance (PID ${pid}) is already using this data directory.`);
1373
- console.error(` Directory: ${dataDir2}`);
1374
- console.error(` Stop that instance first, or use --data-dir to point to a different location.
1375
- `);
1376
- process.exit(1);
1377
- } catch {
1378
- console.log(` Removing stale lock from PID ${pid}`);
1379
- rmSync4(lockPath, { force: true });
1380
- }
1381
- } catch {
1382
- rmSync4(lockPath, { force: true });
1383
- }
1384
- }
1385
- writeFileSync3(lockPath, JSON.stringify({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1386
- const cleanup = () => releaseLock();
1387
- process.on("exit", cleanup);
1388
- process.on("SIGINT", () => {
1389
- cleanup();
1390
- process.exit(0);
1391
- });
1392
- process.on("SIGTERM", () => {
1393
- cleanup();
1394
- process.exit(0);
1395
- });
1170
+ async function addAttachment(ticketId, originalFilename, storedPath) {
1171
+ const db2 = await getDb();
1172
+ const result = await db2.query(
1173
+ `INSERT INTO attachments (ticket_id, original_filename, stored_path) VALUES ($1, $2, $3) RETURNING *`,
1174
+ [ticketId, originalFilename, storedPath]
1175
+ );
1176
+ return result.rows[0];
1396
1177
  }
1397
- function releaseLock() {
1398
- if (lockPath) {
1399
- try {
1400
- rmSync4(lockPath, { force: true });
1401
- } catch {
1402
- }
1403
- lockPath = null;
1404
- }
1178
+ async function getAttachments(ticketId) {
1179
+ const db2 = await getDb();
1180
+ const result = await db2.query(
1181
+ `SELECT * FROM attachments WHERE ticket_id = $1 ORDER BY created_at ASC`,
1182
+ [ticketId]
1183
+ );
1184
+ return result.rows;
1405
1185
  }
1406
-
1186
+ async function getAttachment(id) {
1187
+ const db2 = await getDb();
1188
+ const result = await db2.query(
1189
+ `SELECT * FROM attachments WHERE id = $1`,
1190
+ [id]
1191
+ );
1192
+ return result.rows[0] ?? null;
1193
+ }
1194
+ async function deleteAttachment(id) {
1195
+ const db2 = await getDb();
1196
+ const result = await db2.query(
1197
+ `DELETE FROM attachments WHERE id = $1 RETURNING *`,
1198
+ [id]
1199
+ );
1200
+ return result.rows[0] ?? null;
1201
+ }
1202
+
1203
+ // src/types.ts
1204
+ var DEFAULT_CATEGORIES = [
1205
+ { id: "issue", label: "Issue", shortLabel: "ISS", color: "#6b7280", shortcutKey: "i", description: "General issues that need attention" },
1206
+ { id: "bug", label: "Bug", shortLabel: "BUG", color: "#ef4444", shortcutKey: "b", description: "Bugs that should be fixed in the codebase" },
1207
+ { id: "feature", label: "Feature", shortLabel: "FEA", color: "#22c55e", shortcutKey: "f", description: "New features to be implemented" },
1208
+ { id: "requirement_change", label: "Req Change", shortLabel: "REQ", color: "#f97316", shortcutKey: "r", description: "Changes to existing requirements" },
1209
+ { id: "task", label: "Task", shortLabel: "TSK", color: "#3b82f6", shortcutKey: "k", description: "General tasks to complete" },
1210
+ { id: "investigation", label: "Investigation", shortLabel: "INV", color: "#8b5cf6", shortcutKey: "g", description: "Items requiring research or analysis" }
1211
+ ];
1212
+ var CATEGORY_PRESETS = [
1213
+ {
1214
+ id: "software",
1215
+ name: "Software Development",
1216
+ categories: DEFAULT_CATEGORIES
1217
+ },
1218
+ {
1219
+ id: "design",
1220
+ name: "Design / Creative",
1221
+ categories: [
1222
+ { id: "concept", label: "Concept", shortLabel: "CON", color: "#8b5cf6", shortcutKey: "c", description: "Design concepts and explorations" },
1223
+ { id: "revision", label: "Revision", shortLabel: "REV", color: "#f97316", shortcutKey: "r", description: "Revisions to existing designs" },
1224
+ { id: "feedback", label: "Feedback", shortLabel: "FDB", color: "#3b82f6", shortcutKey: "f", description: "Client or stakeholder feedback to address" },
1225
+ { id: "asset", label: "Asset", shortLabel: "AST", color: "#22c55e", shortcutKey: "a", description: "Assets to produce or deliver" },
1226
+ { id: "research", label: "Research", shortLabel: "RSC", color: "#6b7280", shortcutKey: "s", description: "User research or competitive analysis" },
1227
+ { id: "bug", label: "Bug", shortLabel: "BUG", color: "#ef4444", shortcutKey: "b", description: "Visual or UI bugs" }
1228
+ ]
1229
+ },
1230
+ {
1231
+ id: "product",
1232
+ name: "Product Management",
1233
+ categories: [
1234
+ { id: "epic", label: "Epic", shortLabel: "EPC", color: "#8b5cf6", shortcutKey: "e", description: "Large initiatives spanning multiple stories" },
1235
+ { id: "story", label: "Story", shortLabel: "STY", color: "#3b82f6", shortcutKey: "s", description: "User stories describing desired functionality" },
1236
+ { id: "bug", label: "Bug", shortLabel: "BUG", color: "#ef4444", shortcutKey: "b", description: "Bugs that need to be fixed" },
1237
+ { id: "task", label: "Task", shortLabel: "TSK", color: "#22c55e", shortcutKey: "t", description: "Tasks to complete" },
1238
+ { id: "spike", label: "Spike", shortLabel: "SPK", color: "#f97316", shortcutKey: "k", description: "Research or investigation spikes" },
1239
+ { id: "debt", label: "Tech Debt", shortLabel: "DBT", color: "#6b7280", shortcutKey: "d", description: "Technical debt to address" }
1240
+ ]
1241
+ },
1242
+ {
1243
+ id: "marketing",
1244
+ name: "Marketing",
1245
+ categories: [
1246
+ { id: "campaign", label: "Campaign", shortLabel: "CMP", color: "#8b5cf6", shortcutKey: "c", description: "Marketing campaigns" },
1247
+ { id: "content", label: "Content", shortLabel: "CNT", color: "#3b82f6", shortcutKey: "n", description: "Content to create or publish" },
1248
+ { id: "design", label: "Design", shortLabel: "DES", color: "#22c55e", shortcutKey: "d", description: "Design requests and assets" },
1249
+ { id: "analytics", label: "Analytics", shortLabel: "ANL", color: "#f97316", shortcutKey: "a", description: "Analytics and reporting tasks" },
1250
+ { id: "outreach", label: "Outreach", shortLabel: "OUT", color: "#6b7280", shortcutKey: "o", description: "Outreach and partnership activities" },
1251
+ { id: "event", label: "Event", shortLabel: "EVT", color: "#ef4444", shortcutKey: "e", description: "Events to plan or manage" }
1252
+ ]
1253
+ },
1254
+ {
1255
+ id: "personal",
1256
+ name: "Personal",
1257
+ categories: [
1258
+ { id: "task", label: "Task", shortLabel: "TSK", color: "#3b82f6", shortcutKey: "t", description: "Things to do" },
1259
+ { id: "idea", label: "Idea", shortLabel: "IDA", color: "#22c55e", shortcutKey: "i", description: "Ideas to explore" },
1260
+ { id: "note", label: "Note", shortLabel: "NTE", color: "#6b7280", shortcutKey: "n", description: "Notes and references" },
1261
+ { id: "errand", label: "Errand", shortLabel: "ERR", color: "#f97316", shortcutKey: "e", description: "Errands and appointments" },
1262
+ { id: "project", label: "Project", shortLabel: "PRJ", color: "#8b5cf6", shortcutKey: "p", description: "Larger projects" },
1263
+ { id: "urgent", label: "Urgent", shortLabel: "URG", color: "#ef4444", shortcutKey: "u", description: "Urgent items" }
1264
+ ]
1265
+ }
1266
+ ];
1267
+ var CATEGORIES = DEFAULT_CATEGORIES.map((c) => ({ value: c.id, label: c.label, color: c.color }));
1268
+ var CATEGORY_DESCRIPTIONS = Object.fromEntries(
1269
+ DEFAULT_CATEGORIES.map((c) => [c.id, c.description])
1270
+ );
1271
+
1272
+ // src/db/settings.ts
1273
+ init_connection();
1274
+ async function getSettings() {
1275
+ const db2 = await getDb();
1276
+ const result = await db2.query("SELECT key, value FROM settings");
1277
+ const settings = {};
1278
+ for (const row of result.rows) {
1279
+ settings[row.key] = row.value;
1280
+ }
1281
+ return settings;
1282
+ }
1283
+ async function updateSetting(key, value) {
1284
+ const db2 = await getDb();
1285
+ await db2.query(
1286
+ `INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2`,
1287
+ [key, value]
1288
+ );
1289
+ }
1290
+ async function getCategories() {
1291
+ const settings = await getSettings();
1292
+ if (settings.categories) {
1293
+ try {
1294
+ const parsed = JSON.parse(settings.categories);
1295
+ if (Array.isArray(parsed) && parsed.length > 0) return parsed;
1296
+ } catch {
1297
+ }
1298
+ }
1299
+ return DEFAULT_CATEGORIES;
1300
+ }
1301
+ async function saveCategories(categories) {
1302
+ await updateSetting("categories", JSON.stringify(categories));
1303
+ }
1304
+
1305
+ // src/db/tags.ts
1306
+ init_connection();
1307
+ function normalizeTag(input) {
1308
+ return input.replace(/[^a-zA-Z0-9]+/g, " ").trim().toLowerCase();
1309
+ }
1310
+ function extractBracketTags(input) {
1311
+ const tags = [];
1312
+ const cleaned = input.replace(/\[([^\]]*)\]/g, (_match, content) => {
1313
+ const tag = normalizeTag(content);
1314
+ if (tag && !tags.includes(tag)) tags.push(tag);
1315
+ return " ";
1316
+ });
1317
+ const title = cleaned.replace(/\s+/g, " ").trim();
1318
+ return { title, tags };
1319
+ }
1320
+ async function getAllTags() {
1321
+ const db2 = await getDb();
1322
+ const result = await db2.query(`SELECT DISTINCT tags FROM tickets WHERE tags != '[]' AND status != 'deleted'`);
1323
+ const tagSet = /* @__PURE__ */ new Set();
1324
+ for (const row of result.rows) {
1325
+ try {
1326
+ const parsed = JSON.parse(row.tags);
1327
+ if (Array.isArray(parsed)) {
1328
+ for (const tag of parsed) {
1329
+ if (typeof tag === "string" && tag.trim()) {
1330
+ const norm = normalizeTag(tag);
1331
+ if (norm) tagSet.add(norm);
1332
+ }
1333
+ }
1334
+ }
1335
+ } catch {
1336
+ }
1337
+ }
1338
+ return Array.from(tagSet).sort();
1339
+ }
1340
+
1341
+ // src/cleanup.ts
1342
+ async function cleanupAttachments() {
1343
+ try {
1344
+ const settings = await getSettings();
1345
+ const verifiedDays = parseInt(settings.verified_cleanup_days, 10) || 30;
1346
+ const trashDays = parseInt(settings.trash_cleanup_days, 10) || 3;
1347
+ const tickets = await getTicketsForCleanup(verifiedDays, trashDays);
1348
+ if (tickets.length === 0) return;
1349
+ let cleaned = 0;
1350
+ for (const ticket of tickets) {
1351
+ const attachments = await getAttachments(ticket.id);
1352
+ for (const att of attachments) {
1353
+ try {
1354
+ rmSync3(att.stored_path, { force: true });
1355
+ } catch {
1356
+ }
1357
+ }
1358
+ await hardDeleteTicket(ticket.id);
1359
+ cleaned++;
1360
+ }
1361
+ if (cleaned > 0) {
1362
+ console.log(` Cleaned up ${cleaned} old ticket(s) and their attachments.`);
1363
+ }
1364
+ } catch (err) {
1365
+ console.error("Attachment cleanup failed:", err);
1366
+ }
1367
+ }
1368
+
1369
+ // src/cli.ts
1370
+ init_connection();
1371
+ init_file_settings();
1372
+
1373
+ // src/lock.ts
1374
+ import { existsSync as existsSync3, readFileSync as readFileSync3, rmSync as rmSync4, writeFileSync as writeFileSync3 } from "fs";
1375
+ import { join as join4 } from "path";
1376
+ var lockPath = null;
1377
+ function acquireLock(dataDir2) {
1378
+ lockPath = join4(dataDir2, "hotsheet.lock");
1379
+ if (existsSync3(lockPath)) {
1380
+ try {
1381
+ const contents = JSON.parse(readFileSync3(lockPath, "utf-8"));
1382
+ const pid = contents.pid;
1383
+ try {
1384
+ process.kill(pid, 0);
1385
+ console.error(`
1386
+ Error: Another Hot Sheet instance (PID ${pid}) is already using this data directory.`);
1387
+ console.error(` Directory: ${dataDir2}`);
1388
+ console.error(` Stop that instance first, or use --data-dir to point to a different location.
1389
+ `);
1390
+ process.exit(1);
1391
+ } catch {
1392
+ console.log(` Removing stale lock from PID ${pid}`);
1393
+ rmSync4(lockPath, { force: true });
1394
+ }
1395
+ } catch {
1396
+ rmSync4(lockPath, { force: true });
1397
+ }
1398
+ }
1399
+ writeFileSync3(lockPath, JSON.stringify({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString() }));
1400
+ const cleanup = () => releaseLock();
1401
+ process.on("exit", cleanup);
1402
+ process.on("SIGINT", () => {
1403
+ cleanup();
1404
+ process.exit(0);
1405
+ });
1406
+ process.on("SIGTERM", () => {
1407
+ cleanup();
1408
+ process.exit(0);
1409
+ });
1410
+ }
1411
+ function releaseLock() {
1412
+ if (lockPath) {
1413
+ try {
1414
+ rmSync4(lockPath, { force: true });
1415
+ } catch {
1416
+ }
1417
+ lockPath = null;
1418
+ }
1419
+ }
1420
+
1407
1421
  // src/demo.ts
1408
1422
  init_connection();
1409
1423
  var DEMO_SCENARIOS = [
@@ -2387,290 +2401,31 @@ init_file_settings();
2387
2401
  import { serve } from "@hono/node-server";
2388
2402
  import { exec } from "child_process";
2389
2403
  import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
2390
- import { Hono as Hono4 } from "hono";
2391
- import { dirname as dirname2, join as join10 } from "path";
2404
+ import { Hono as Hono9 } from "hono";
2405
+ import { dirname as dirname2, join as join11 } from "path";
2392
2406
  import { fileURLToPath as fileURLToPath2 } from "url";
2393
2407
 
2394
2408
  // src/routes/api.ts
2395
- import { existsSync as existsSync7, mkdirSync as mkdirSync4, rmSync as rmSync5 } from "fs";
2409
+ import { Hono as Hono6 } from "hono";
2410
+
2411
+ // src/routes/attachments.ts
2412
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, rmSync as rmSync5 } from "fs";
2396
2413
  import { Hono } from "hono";
2397
- import { basename, extname, join as join9, relative as relative2 } from "path";
2414
+ import { basename, extname, join as join7, resolve as resolve2 } from "path";
2398
2415
 
2399
- // src/skills.ts
2416
+ // src/sync/markdown.ts
2417
+ import { writeFileSync as writeFileSync4 } from "fs";
2418
+ import { join as join6 } from "path";
2400
2419
  init_file_settings();
2401
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2402
- import { join as join6, relative } from "path";
2403
- var SKILL_VERSION = 4;
2404
- var skillPort;
2405
- var skillDataDir;
2406
- var skillCategories = DEFAULT_CATEGORIES;
2407
- function initSkills(port2, dataDir2) {
2408
- skillPort = port2;
2409
- skillDataDir = dataDir2;
2410
- }
2411
- function setSkillCategories(categories) {
2412
- skillCategories = categories;
2413
- }
2414
- function buildTicketSkills() {
2415
- return skillCategories.map((cat) => ({
2416
- name: `hs-${cat.id.replace(/_/g, "-")}`,
2417
- category: cat.id,
2418
- label: cat.label.toLowerCase(),
2419
- description: cat.description
2420
- }));
2421
- }
2422
- function versionHeader() {
2423
- return `<!-- hotsheet-skill-version: ${SKILL_VERSION} -->`;
2424
- }
2425
- function parseVersionHeader(content) {
2426
- const match = content.match(/<!-- hotsheet-skill-version: (\d+)(?: port: \d+)? -->/);
2427
- if (!match) return null;
2428
- return parseInt(match[1], 10);
2429
- }
2430
- function updateFile(path, content) {
2431
- if (existsSync5(path)) {
2432
- const existing = readFileSync5(path, "utf-8");
2433
- const version = parseVersionHeader(existing);
2434
- if (version !== null && version >= SKILL_VERSION) {
2435
- return false;
2436
- }
2437
- }
2438
- writeFileSync4(path, content, "utf-8");
2439
- return true;
2440
- }
2441
- function ticketSkillBody(skill) {
2442
- const settings = readFileSettings(skillDataDir);
2443
- const secret = settings.secret || "";
2444
- const secretLine = secret ? ` -H "X-Hotsheet-Secret: ${secret}" \\` : "";
2445
- const lines = [
2446
- `Create a new Hot Sheet **${skill.label}** ticket. ${skill.description}.`,
2447
- "",
2448
- "**Parsing the input:**",
2449
- '- If the input starts with "next", "up next", or "do next" (case-insensitive), set `up_next` to `true` and use the remaining text as the title',
2450
- "- Otherwise, use the entire input as the title",
2451
- "",
2452
- "**Create the ticket** by running:",
2453
- "```bash",
2454
- `curl -s -X POST http://localhost:${skillPort}/api/tickets \\`,
2455
- ' -H "Content-Type: application/json" \\'
2456
- ];
2457
- if (secretLine) lines.push(secretLine);
2458
- lines.push(
2459
- ` -d '{"title": "<TITLE>", "defaults": {"category": "${skill.category}", "up_next": <true|false>}}'`,
2460
- "```",
2461
- "",
2462
- `If the request fails (connection refused or 403), re-read \`.hotsheet/settings.json\` for the current \`port\` and \`secret\` values \u2014 you may be connecting to the wrong Hot Sheet instance.`,
2463
- "",
2464
- "Report the created ticket number and title to the user."
2465
- );
2466
- return lines.join("\n");
2467
- }
2468
- function mainSkillBody() {
2469
- const worklistRel = relative(process.cwd(), join6(skillDataDir, "worklist.md"));
2470
- const settingsRel = relative(process.cwd(), join6(skillDataDir, "settings.json"));
2471
- return [
2472
- `Base directory for this skill: ${join6(process.cwd(), ".claude", "skills", "hotsheet")}`,
2473
- "",
2474
- `Read \`${worklistRel}\` and work through the tickets in priority order.`,
2475
- "",
2476
- "For each ticket:",
2477
- "1. Read the ticket details carefully",
2478
- "2. Implement the work described",
2479
- "3. When complete, mark it done via the Hot Sheet UI",
2480
- "",
2481
- "Work through them in order of priority, where reasonable.",
2482
- "",
2483
- `If API calls fail (connection refused or 403), re-read \`${settingsRel}\` for the current \`port\` and \`secret\` values \u2014 you may be connecting to the wrong Hot Sheet instance.`
2484
- ].join("\n");
2485
- }
2486
- var HOTSHEET_ALLOW_PATTERNS = [
2487
- "Bash(curl * http://localhost:417*/api/*)",
2488
- "Bash(curl * http://localhost:418*/api/*)"
2489
- ];
2490
- var HOTSHEET_CURL_RE = /^Bash\(curl \* http:\/\/localhost:\d+\/api\/\*\)$|^Bash\(curl \* http:\/\/localhost:41[78]\*\/api\/\*\)$/;
2491
- function ensureClaudePermissions(cwd) {
2492
- if (skillPort < 4170 || skillPort > 4189) return false;
2493
- const settingsPath2 = join6(cwd, ".claude", "settings.json");
2494
- let settings = {};
2495
- if (existsSync5(settingsPath2)) {
2496
- try {
2497
- settings = JSON.parse(readFileSync5(settingsPath2, "utf-8"));
2498
- } catch {
2499
- }
2500
- }
2501
- if (!settings.permissions) settings.permissions = {};
2502
- if (!settings.permissions.allow) settings.permissions.allow = [];
2503
- const allow = settings.permissions.allow;
2504
- if (HOTSHEET_ALLOW_PATTERNS.every((p) => allow.includes(p))) return false;
2505
- settings.permissions.allow = allow.filter((p) => !HOTSHEET_CURL_RE.test(p));
2506
- settings.permissions.allow.push(...HOTSHEET_ALLOW_PATTERNS);
2507
- writeFileSync4(settingsPath2, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2508
- return true;
2509
- }
2510
- function ensureClaudeSkills(cwd) {
2511
- let updated = false;
2512
- const skillsDir = join6(cwd, ".claude", "skills");
2513
- if (ensureClaudePermissions(cwd)) updated = true;
2514
- const mainDir = join6(skillsDir, "hotsheet");
2515
- mkdirSync3(mainDir, { recursive: true });
2516
- const mainContent = [
2517
- "---",
2518
- "name: hotsheet",
2519
- "description: Read the Hot Sheet worklist and work through the current priority items",
2520
- "allowed-tools: Read, Grep, Glob, Edit, Write, Bash",
2521
- "---",
2522
- versionHeader(),
2523
- "",
2524
- mainSkillBody(),
2525
- ""
2526
- ].join("\n");
2527
- if (updateFile(join6(mainDir, "SKILL.md"), mainContent)) updated = true;
2528
- for (const skill of buildTicketSkills()) {
2529
- const dir = join6(skillsDir, skill.name);
2530
- mkdirSync3(dir, { recursive: true });
2531
- const content = [
2532
- "---",
2533
- `name: ${skill.name}`,
2534
- `description: Create a new ${skill.label} ticket in Hot Sheet`,
2535
- "allowed-tools: Bash",
2536
- "---",
2537
- versionHeader(),
2538
- "",
2539
- ticketSkillBody(skill),
2540
- ""
2541
- ].join("\n");
2542
- if (updateFile(join6(dir, "SKILL.md"), content)) updated = true;
2543
- }
2544
- return updated;
2545
- }
2546
- function ensureCursorRules(cwd) {
2547
- let updated = false;
2548
- const rulesDir = join6(cwd, ".cursor", "rules");
2549
- mkdirSync3(rulesDir, { recursive: true });
2550
- const mainContent = [
2551
- "---",
2552
- "description: Read the Hot Sheet worklist and work through the current priority items",
2553
- "alwaysApply: false",
2554
- "---",
2555
- versionHeader(),
2556
- "",
2557
- mainSkillBody(),
2558
- ""
2559
- ].join("\n");
2560
- if (updateFile(join6(rulesDir, "hotsheet.mdc"), mainContent)) updated = true;
2561
- for (const skill of buildTicketSkills()) {
2562
- const content = [
2563
- "---",
2564
- `description: Create a new ${skill.label} ticket in Hot Sheet`,
2565
- "alwaysApply: false",
2566
- "---",
2567
- versionHeader(),
2568
- "",
2569
- ticketSkillBody(skill),
2570
- ""
2571
- ].join("\n");
2572
- if (updateFile(join6(rulesDir, `${skill.name}.mdc`), content)) updated = true;
2573
- }
2574
- return updated;
2575
- }
2576
- function ensureCopilotPrompts(cwd) {
2577
- let updated = false;
2578
- const promptsDir = join6(cwd, ".github", "prompts");
2579
- mkdirSync3(promptsDir, { recursive: true });
2580
- const mainContent = [
2581
- "---",
2582
- "description: Read the Hot Sheet worklist and work through the current priority items",
2583
- "---",
2584
- versionHeader(),
2585
- "",
2586
- mainSkillBody(),
2587
- ""
2588
- ].join("\n");
2589
- if (updateFile(join6(promptsDir, "hotsheet.prompt.md"), mainContent)) updated = true;
2590
- for (const skill of buildTicketSkills()) {
2591
- const content = [
2592
- "---",
2593
- `description: Create a new ${skill.label} ticket in Hot Sheet`,
2594
- "---",
2595
- versionHeader(),
2596
- "",
2597
- ticketSkillBody(skill),
2598
- ""
2599
- ].join("\n");
2600
- if (updateFile(join6(promptsDir, `${skill.name}.prompt.md`), content)) updated = true;
2601
- }
2602
- return updated;
2603
- }
2604
- function ensureWindsurfRules(cwd) {
2605
- let updated = false;
2606
- const rulesDir = join6(cwd, ".windsurf", "rules");
2607
- mkdirSync3(rulesDir, { recursive: true });
2608
- const mainContent = [
2609
- "---",
2610
- "trigger: manual",
2611
- "description: Read the Hot Sheet worklist and work through the current priority items",
2612
- "---",
2613
- versionHeader(),
2614
- "",
2615
- mainSkillBody(),
2616
- ""
2617
- ].join("\n");
2618
- if (updateFile(join6(rulesDir, "hotsheet.md"), mainContent)) updated = true;
2619
- for (const skill of buildTicketSkills()) {
2620
- const content = [
2621
- "---",
2622
- "trigger: manual",
2623
- `description: Create a new ${skill.label} ticket in Hot Sheet`,
2624
- "---",
2625
- versionHeader(),
2626
- "",
2627
- ticketSkillBody(skill),
2628
- ""
2629
- ].join("\n");
2630
- if (updateFile(join6(rulesDir, `${skill.name}.md`), content)) updated = true;
2631
- }
2632
- return updated;
2633
- }
2634
- var pendingCreatedFlag = false;
2635
- function ensureSkills() {
2636
- const cwd = process.cwd();
2637
- const platforms = [];
2638
- if (existsSync5(join6(cwd, ".claude"))) {
2639
- if (ensureClaudeSkills(cwd)) platforms.push("Claude Code");
2640
- }
2641
- if (existsSync5(join6(cwd, ".cursor"))) {
2642
- if (ensureCursorRules(cwd)) platforms.push("Cursor");
2643
- }
2644
- if (existsSync5(join6(cwd, ".github", "prompts")) || existsSync5(join6(cwd, ".github", "copilot-instructions.md"))) {
2645
- if (ensureCopilotPrompts(cwd)) platforms.push("GitHub Copilot");
2646
- }
2647
- if (existsSync5(join6(cwd, ".windsurf"))) {
2648
- if (ensureWindsurfRules(cwd)) platforms.push("Windsurf");
2649
- }
2650
- if (platforms.length > 0) {
2651
- pendingCreatedFlag = true;
2652
- }
2653
- return platforms;
2654
- }
2655
- function consumeSkillsCreatedFlag() {
2656
- const result = pendingCreatedFlag;
2657
- pendingCreatedFlag = false;
2658
- return result;
2659
- }
2660
-
2661
- // src/sync/markdown.ts
2662
- import { writeFileSync as writeFileSync5 } from "fs";
2663
- import { join as join7 } from "path";
2664
- init_file_settings();
2665
- var dataDir;
2666
- var port;
2667
- var worklistTimeout = null;
2668
- var openTicketsTimeout = null;
2669
- var WORKLIST_DEBOUNCE = 500;
2670
- var OPEN_TICKETS_DEBOUNCE = 5e3;
2671
- function initMarkdownSync(dir, serverPort) {
2672
- dataDir = dir;
2673
- port = serverPort;
2420
+ var dataDir;
2421
+ var port;
2422
+ var worklistTimeout = null;
2423
+ var openTicketsTimeout = null;
2424
+ var WORKLIST_DEBOUNCE = 500;
2425
+ var OPEN_TICKETS_DEBOUNCE = 5e3;
2426
+ function initMarkdownSync(dir, serverPort) {
2427
+ dataDir = dir;
2428
+ port = serverPort;
2674
2429
  }
2675
2430
  function scheduleWorklistSync() {
2676
2431
  if (worklistTimeout) clearTimeout(worklistTimeout);
@@ -2811,7 +2566,23 @@ async function syncWorklist() {
2811
2566
  sections.push("Set `up_next: true` only for items that should be prioritized immediately.");
2812
2567
  sections.push("");
2813
2568
  if (tickets.length === 0) {
2814
- sections.push("No items in the Up Next list.");
2569
+ const dbSettings = await getSettings();
2570
+ const autoOrder = dbSettings.auto_order !== "false";
2571
+ if (autoOrder) {
2572
+ sections.push("## Auto-Prioritize");
2573
+ sections.push("");
2574
+ sections.push("No items are in the Up Next list, but **auto-prioritize is enabled**. Before doing anything else:");
2575
+ sections.push("");
2576
+ sections.push("1. Read `.hotsheet/open-tickets.md` to see all open tickets.");
2577
+ sections.push("2. Evaluate them by priority, urgency, and dependencies.");
2578
+ sections.push("3. Choose the most important ticket(s) to work on next.");
2579
+ sections.push(`4. Mark them as Up Next: \`curl -s -X PATCH http://localhost:${port}/api/tickets/{id} -H "Content-Type: application/json"${secretHeader} -d '{"up_next": true}'\``);
2580
+ sections.push('5. Then work through them as normal (set status to "started", implement, set to "completed" with notes).');
2581
+ sections.push("");
2582
+ sections.push("If there are no open tickets at all, there is nothing to do.");
2583
+ } else {
2584
+ sections.push("No items in the Up Next list.");
2585
+ }
2815
2586
  } else {
2816
2587
  const autoContext = await loadAutoContext();
2817
2588
  for (const ticket of tickets) {
@@ -2827,7 +2598,7 @@ async function syncWorklist() {
2827
2598
  sections.push(await formatCategoryDescriptions(categories));
2828
2599
  }
2829
2600
  sections.push("");
2830
- writeFileSync5(join7(dataDir, "worklist.md"), sections.join("\n"), "utf-8");
2601
+ writeFileSync4(join6(dataDir, "worklist.md"), sections.join("\n"), "utf-8");
2831
2602
  } catch (err) {
2832
2603
  console.error("Failed to sync worklist.md:", err);
2833
2604
  }
@@ -2872,234 +2643,49 @@ async function syncOpenTickets() {
2872
2643
  sections.push(await formatCategoryDescriptions(categories));
2873
2644
  }
2874
2645
  sections.push("");
2875
- writeFileSync5(join7(dataDir, "open-tickets.md"), sections.join("\n"), "utf-8");
2646
+ writeFileSync4(join6(dataDir, "open-tickets.md"), sections.join("\n"), "utf-8");
2876
2647
  } catch (err) {
2877
2648
  console.error("Failed to sync open-tickets.md:", err);
2878
2649
  }
2879
2650
  }
2880
2651
 
2881
- // src/routes/api.ts
2882
- var apiRoutes = new Hono();
2652
+ // src/routes/notify.ts
2883
2653
  var changeVersion = 0;
2884
2654
  var pollWaiters = [];
2885
2655
  function notifyChange() {
2886
2656
  changeVersion++;
2887
2657
  const waiters = pollWaiters;
2888
2658
  pollWaiters = [];
2889
- for (const resolve4 of waiters) {
2890
- resolve4(changeVersion);
2659
+ for (const resolve5 of waiters) {
2660
+ resolve5(changeVersion);
2891
2661
  }
2892
2662
  }
2893
- apiRoutes.get("/poll", async (c) => {
2894
- const clientVersion = parseInt(c.req.query("version") || "0", 10);
2895
- if (changeVersion > clientVersion) {
2896
- return c.json({ version: changeVersion });
2897
- }
2898
- const version = await Promise.race([
2899
- new Promise((resolve4) => {
2900
- pollWaiters.push(resolve4);
2901
- }),
2902
- new Promise((resolve4) => {
2903
- setTimeout(() => resolve4(changeVersion), 3e4);
2904
- })
2905
- ]);
2906
- return c.json({ version });
2907
- });
2908
- apiRoutes.get("/tickets", async (c) => {
2909
- const filters = {};
2910
- const category = c.req.query("category");
2911
- if (category !== void 0 && category !== "") filters.category = category;
2912
- const priority = c.req.query("priority");
2913
- if (priority !== void 0 && priority !== "") filters.priority = priority;
2914
- const status = c.req.query("status");
2915
- if (status !== void 0 && status !== "") filters.status = status;
2916
- const upNext = c.req.query("up_next");
2917
- if (upNext !== void 0) filters.up_next = upNext === "true";
2918
- const search = c.req.query("search");
2919
- if (search !== void 0 && search !== "") filters.search = search;
2920
- const sortBy = c.req.query("sort_by");
2921
- if (sortBy !== void 0 && sortBy !== "") filters.sort_by = sortBy;
2922
- const sortDir = c.req.query("sort_dir");
2923
- if (sortDir !== void 0 && sortDir !== "") filters.sort_dir = sortDir;
2924
- const tickets = await getTickets(filters);
2925
- return c.json(tickets);
2926
- });
2927
- apiRoutes.post("/tickets", async (c) => {
2928
- const body = await c.req.json();
2929
- let title = body.title || "";
2930
- const defaults = body.defaults || {};
2931
- const { title: cleanTitle, tags: bracketTags } = extractBracketTags(title);
2932
- if (bracketTags.length > 0) {
2933
- title = cleanTitle || title;
2934
- let existingTags = [];
2935
- if (defaults.tags) {
2936
- try {
2937
- existingTags = JSON.parse(defaults.tags);
2938
- } catch {
2939
- }
2940
- }
2941
- for (const tag of bracketTags) {
2942
- if (!existingTags.some((t) => t.toLowerCase() === tag.toLowerCase())) existingTags.push(tag);
2943
- }
2944
- defaults.tags = JSON.stringify(existingTags);
2945
- }
2946
- const ticket = await createTicket(title, defaults);
2947
- scheduleAllSync();
2948
- notifyChange();
2949
- return c.json(ticket, 201);
2950
- });
2951
- apiRoutes.get("/tickets/:id", async (c) => {
2952
- const id = parseInt(c.req.param("id"), 10);
2953
- const ticket = await getTicket(id);
2954
- if (!ticket) return c.json({ error: "Not found" }, 404);
2955
- const attachments = await getAttachments(id);
2956
- const notes = parseNotes(ticket.notes);
2957
- return c.json({ ...ticket, notes: JSON.stringify(notes), attachments });
2958
- });
2959
- apiRoutes.patch("/tickets/:id", async (c) => {
2960
- const id = parseInt(c.req.param("id"), 10);
2961
- const body = await c.req.json();
2962
- const ticket = await updateTicket(id, body);
2963
- if (!ticket) return c.json({ error: "Not found" }, 404);
2964
- scheduleAllSync();
2965
- notifyChange();
2966
- return c.json(ticket);
2967
- });
2968
- apiRoutes.delete("/tickets/:id", async (c) => {
2969
- const id = parseInt(c.req.param("id"), 10);
2970
- await deleteTicket(id);
2971
- scheduleAllSync();
2972
- notifyChange();
2973
- return c.json({ ok: true });
2974
- });
2975
- apiRoutes.put("/tickets/:id/notes-bulk", async (c) => {
2976
- const id = parseInt(c.req.param("id"), 10);
2977
- const body = await c.req.json();
2978
- const db2 = await Promise.resolve().then(() => (init_connection(), connection_exports)).then((m) => m.getDb());
2979
- const result = await (await db2).query(
2980
- `UPDATE tickets SET notes = $1, updated_at = NOW() WHERE id = $2 RETURNING id`,
2981
- [body.notes, id]
2982
- );
2983
- if (result.rows.length === 0) return c.json({ error: "Not found" }, 404);
2984
- scheduleAllSync();
2985
- notifyChange();
2986
- return c.json({ ok: true });
2987
- });
2988
- apiRoutes.patch("/tickets/:id/notes/:noteId", async (c) => {
2989
- const id = parseInt(c.req.param("id"), 10);
2990
- const noteId2 = c.req.param("noteId");
2991
- const body = await c.req.json();
2992
- const notes = await editNote(id, noteId2, body.text);
2993
- if (!notes) return c.json({ error: "Not found" }, 404);
2994
- scheduleAllSync();
2995
- notifyChange();
2996
- return c.json(notes);
2997
- });
2998
- apiRoutes.delete("/tickets/:id/notes/:noteId", async (c) => {
2999
- const id = parseInt(c.req.param("id"), 10);
3000
- const noteId2 = c.req.param("noteId");
3001
- const notes = await deleteNote(id, noteId2);
3002
- if (!notes) return c.json({ error: "Not found" }, 404);
3003
- scheduleAllSync();
3004
- notifyChange();
3005
- return c.json(notes);
3006
- });
3007
- apiRoutes.delete("/tickets/:id/hard", async (c) => {
3008
- const id = parseInt(c.req.param("id"), 10);
3009
- const attachments = await getAttachments(id);
3010
- for (const att of attachments) {
3011
- try {
3012
- rmSync5(att.stored_path, { force: true });
3013
- } catch {
3014
- }
3015
- }
3016
- await hardDeleteTicket(id);
3017
- scheduleAllSync();
3018
- notifyChange();
3019
- return c.json({ ok: true });
3020
- });
3021
- apiRoutes.post("/tickets/batch", async (c) => {
3022
- const body = await c.req.json();
3023
- switch (body.action) {
3024
- case "delete":
3025
- await batchDeleteTickets(body.ids);
3026
- break;
3027
- case "restore":
3028
- await batchRestoreTickets(body.ids);
3029
- break;
3030
- case "category":
3031
- await batchUpdateTickets(body.ids, { category: body.value });
3032
- break;
3033
- case "priority":
3034
- await batchUpdateTickets(body.ids, { priority: body.value });
3035
- break;
3036
- case "status":
3037
- await batchUpdateTickets(body.ids, { status: body.value });
3038
- break;
3039
- case "up_next":
3040
- await batchUpdateTickets(body.ids, { up_next: body.value });
3041
- break;
3042
- }
3043
- scheduleAllSync();
3044
- notifyChange();
3045
- return c.json({ ok: true });
3046
- });
3047
- apiRoutes.post("/tickets/duplicate", async (c) => {
3048
- const body = await c.req.json();
3049
- const created = await duplicateTickets(body.ids);
3050
- scheduleAllSync();
3051
- notifyChange();
3052
- return c.json(created, 201);
3053
- });
3054
- apiRoutes.post("/tickets/:id/restore", async (c) => {
3055
- const id = parseInt(c.req.param("id"), 10);
3056
- const ticket = await restoreTicket(id);
3057
- if (!ticket) return c.json({ error: "Not found" }, 404);
3058
- scheduleAllSync();
3059
- notifyChange();
3060
- return c.json(ticket);
3061
- });
3062
- apiRoutes.post("/trash/empty", async (c) => {
3063
- const deleted = await getTickets({ status: "deleted" });
3064
- for (const ticket of deleted) {
3065
- const attachments = await getAttachments(ticket.id);
3066
- for (const att of attachments) {
3067
- try {
3068
- rmSync5(att.stored_path, { force: true });
3069
- } catch {
3070
- }
3071
- }
3072
- }
3073
- await emptyTrash();
3074
- scheduleAllSync();
3075
- notifyChange();
3076
- return c.json({ ok: true });
3077
- });
3078
- apiRoutes.post("/tickets/:id/up-next", async (c) => {
3079
- const id = parseInt(c.req.param("id"), 10);
3080
- const ticket = await toggleUpNext(id);
3081
- if (!ticket) return c.json({ error: "Not found" }, 404);
3082
- scheduleAllSync();
3083
- notifyChange();
3084
- return c.json(ticket);
3085
- });
3086
- apiRoutes.post("/tickets/:id/attachments", async (c) => {
3087
- const id = parseInt(c.req.param("id"), 10);
3088
- const ticket = await getTicket(id);
3089
- if (!ticket) return c.json({ error: "Ticket not found" }, 404);
3090
- const dataDir2 = c.get("dataDir");
3091
- const body = await c.req.parseBody();
3092
- const file = body["file"];
3093
- if (typeof file === "string") {
3094
- return c.json({ error: "No file uploaded" }, 400);
2663
+ function getChangeVersion() {
2664
+ return changeVersion;
2665
+ }
2666
+ function addPollWaiter(resolve5) {
2667
+ pollWaiters.push(resolve5);
2668
+ }
2669
+
2670
+ // src/routes/attachments.ts
2671
+ var attachmentRoutes = new Hono();
2672
+ attachmentRoutes.post("/tickets/:id/attachments", async (c) => {
2673
+ const id = parseInt(c.req.param("id"), 10);
2674
+ const ticket = await getTicket(id);
2675
+ if (!ticket) return c.json({ error: "Ticket not found" }, 404);
2676
+ const dataDir2 = c.get("dataDir");
2677
+ const body = await c.req.parseBody();
2678
+ const file = body["file"];
2679
+ if (typeof file === "string") {
2680
+ return c.json({ error: "No file uploaded" }, 400);
3095
2681
  }
3096
2682
  const originalName = file.name;
3097
2683
  const ext = extname(originalName);
3098
2684
  const baseName = basename(originalName, ext);
3099
2685
  const storedName = `${ticket.ticket_number}_${baseName}${ext}`;
3100
- const attachDir = join9(dataDir2, "attachments");
3101
- mkdirSync4(attachDir, { recursive: true });
3102
- const storedPath = join9(attachDir, storedName);
2686
+ const attachDir = join7(dataDir2, "attachments");
2687
+ mkdirSync3(attachDir, { recursive: true });
2688
+ const storedPath = join7(attachDir, storedName);
3103
2689
  const buffer = Buffer.from(await file.arrayBuffer());
3104
2690
  const { writeFileSync: writeFileSync8 } = await import("fs");
3105
2691
  writeFileSync8(storedPath, buffer);
@@ -3108,7 +2694,7 @@ apiRoutes.post("/tickets/:id/attachments", async (c) => {
3108
2694
  notifyChange();
3109
2695
  return c.json(attachment, 201);
3110
2696
  });
3111
- apiRoutes.delete("/attachments/:id", async (c) => {
2697
+ attachmentRoutes.delete("/attachments/:id", async (c) => {
3112
2698
  const id = parseInt(c.req.param("id"), 10);
3113
2699
  const attachment = await deleteAttachment(id);
3114
2700
  if (!attachment) return c.json({ error: "Not found" }, 404);
@@ -3120,11 +2706,11 @@ apiRoutes.delete("/attachments/:id", async (c) => {
3120
2706
  notifyChange();
3121
2707
  return c.json({ ok: true });
3122
2708
  });
3123
- apiRoutes.post("/attachments/:id/reveal", async (c) => {
2709
+ attachmentRoutes.post("/attachments/:id/reveal", async (c) => {
3124
2710
  const id = parseInt(c.req.param("id"), 10);
3125
2711
  const attachment = await getAttachment(id);
3126
2712
  if (!attachment) return c.json({ error: "Not found" }, 404);
3127
- if (!existsSync7(attachment.stored_path)) return c.json({ error: "File not found on disk" }, 404);
2713
+ if (!existsSync5(attachment.stored_path)) return c.json({ error: "File not found on disk" }, 404);
3128
2714
  const { execFile } = await import("child_process");
3129
2715
  const { dirname: dirname4 } = await import("path");
3130
2716
  const platform = process.platform;
@@ -3137,11 +2723,15 @@ apiRoutes.post("/attachments/:id/reveal", async (c) => {
3137
2723
  }
3138
2724
  return c.json({ ok: true });
3139
2725
  });
3140
- apiRoutes.get("/attachments/file/*", async (c) => {
2726
+ attachmentRoutes.get("/attachments/file/*", async (c) => {
3141
2727
  const filePath = c.req.path.replace("/api/attachments/file/", "");
3142
2728
  const dataDir2 = c.get("dataDir");
3143
- const fullPath = join9(dataDir2, "attachments", filePath);
3144
- if (!existsSync7(fullPath)) {
2729
+ const attachDir = resolve2(join7(dataDir2, "attachments"));
2730
+ const fullPath = resolve2(join7(attachDir, filePath));
2731
+ if (!fullPath.startsWith(attachDir + "/") && fullPath !== attachDir) {
2732
+ return c.json({ error: "Invalid path" }, 403);
2733
+ }
2734
+ if (!existsSync5(fullPath)) {
3145
2735
  return c.json({ error: "File not found" }, 404);
3146
2736
  }
3147
2737
  const { readFileSync: readFileSync9 } = await import("fs");
@@ -3163,225 +2753,712 @@ apiRoutes.get("/attachments/file/*", async (c) => {
3163
2753
  headers: { "Content-Type": contentType }
3164
2754
  });
3165
2755
  });
3166
- apiRoutes.post("/tickets/query", async (c) => {
3167
- const body = await c.req.json();
3168
- const tickets = await queryTickets(body.logic, body.conditions, body.sort_by, body.sort_dir, body.required_tag);
3169
- return c.json(tickets);
2756
+
2757
+ // src/routes/channel.ts
2758
+ import { Hono as Hono2 } from "hono";
2759
+ var channelRoutes = new Hono2();
2760
+ var channelDoneFlag = false;
2761
+ channelRoutes.get("/channel/claude-check", async (c) => {
2762
+ const { execFileSync } = await import("child_process");
2763
+ try {
2764
+ const version = execFileSync("claude", ["--version"], { timeout: 5e3, encoding: "utf-8" }).trim();
2765
+ const match = version.match(/(\d+\.\d+\.\d+)/);
2766
+ const versionNum = match ? match[1] : null;
2767
+ const parts = versionNum ? versionNum.split(".").map(Number) : [];
2768
+ const meetsMinimum = parts.length === 3 && (parts[0] > 2 || parts[0] === 2 && parts[1] > 1 || parts[0] === 2 && parts[1] === 1 && parts[2] >= 80);
2769
+ return c.json({ installed: true, version: versionNum, meetsMinimum });
2770
+ } catch {
2771
+ return c.json({ installed: false, version: null, meetsMinimum: false });
2772
+ }
3170
2773
  });
3171
- apiRoutes.get("/tags", async (c) => {
3172
- const tags = await getAllTags();
3173
- return c.json(tags);
2774
+ channelRoutes.get("/channel/status", async (c) => {
2775
+ const { isChannelAlive: isChannelAlive2, getChannelPort: getChannelPort2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
2776
+ const dataDir2 = c.get("dataDir");
2777
+ const settings = await getSettings();
2778
+ const enabled = settings.channel_enabled === "true";
2779
+ const port2 = getChannelPort2(dataDir2);
2780
+ const alive = enabled ? await isChannelAlive2(dataDir2) : false;
2781
+ const done = channelDoneFlag;
2782
+ if (done) channelDoneFlag = false;
2783
+ return c.json({ enabled, alive, port: port2, done });
3174
2784
  });
3175
- apiRoutes.get("/categories", async (c) => {
3176
- const categories = await getCategories();
3177
- return c.json(categories);
2785
+ channelRoutes.post("/channel/trigger", async (c) => {
2786
+ const { triggerChannel: triggerChannel2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
2787
+ const dataDir2 = c.get("dataDir");
2788
+ const serverPort = parseInt(new URL(c.req.url).port || "4174", 10);
2789
+ const body = await c.req.json().catch(() => ({ message: void 0 }));
2790
+ channelDoneFlag = false;
2791
+ const ok = await triggerChannel2(dataDir2, serverPort, body.message);
2792
+ return c.json({ ok });
3178
2793
  });
3179
- apiRoutes.put("/categories", async (c) => {
3180
- const categories = await c.req.json();
3181
- await saveCategories(categories);
3182
- scheduleAllSync();
3183
- notifyChange();
3184
- return c.json(categories);
2794
+ channelRoutes.get("/channel/permission", async (c) => {
2795
+ const { getChannelPort: getChannelPort2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
2796
+ const dataDir2 = c.get("dataDir");
2797
+ const port2 = getChannelPort2(dataDir2);
2798
+ if (!port2) return c.json({ pending: null });
2799
+ try {
2800
+ const res = await fetch(`http://127.0.0.1:${port2}/permission`);
2801
+ const data = await res.json();
2802
+ return c.json(data);
2803
+ } catch {
2804
+ return c.json({ pending: null });
2805
+ }
3185
2806
  });
3186
- apiRoutes.get("/category-presets", (c) => {
3187
- return c.json(CATEGORY_PRESETS);
2807
+ channelRoutes.post("/channel/permission/respond", async (c) => {
2808
+ const { getChannelPort: getChannelPort2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
2809
+ const dataDir2 = c.get("dataDir");
2810
+ const port2 = getChannelPort2(dataDir2);
2811
+ if (!port2) return c.json({ error: "Channel not available" }, 503);
2812
+ const body = await c.req.json();
2813
+ try {
2814
+ const res = await fetch(`http://127.0.0.1:${port2}/permission/respond`, {
2815
+ method: "POST",
2816
+ headers: { "Content-Type": "application/json" },
2817
+ body: JSON.stringify(body)
2818
+ });
2819
+ return c.json(await res.json());
2820
+ } catch {
2821
+ return c.json({ error: "Failed to reach channel server" }, 503);
2822
+ }
3188
2823
  });
3189
- apiRoutes.get("/stats", async (c) => {
3190
- const stats = await getTicketStats();
3191
- return c.json(stats);
2824
+ channelRoutes.post("/channel/permission/dismiss", async (c) => {
2825
+ const { getChannelPort: getChannelPort2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
2826
+ const dataDir2 = c.get("dataDir");
2827
+ const port2 = getChannelPort2(dataDir2);
2828
+ if (!port2) return c.json({ ok: true });
2829
+ try {
2830
+ await fetch(`http://127.0.0.1:${port2}/permission/dismiss`, { method: "POST" });
2831
+ } catch {
2832
+ }
2833
+ return c.json({ ok: true });
3192
2834
  });
3193
- apiRoutes.get("/dashboard", async (c) => {
3194
- const { getDashboardStats: getDashboardStats2, getSnapshots: getSnapshots2 } = await Promise.resolve().then(() => (init_stats(), stats_exports));
3195
- const days = parseInt(c.req.query("days") || "30", 10);
3196
- const [stats, snapshots] = await Promise.all([
3197
- getDashboardStats2(days),
3198
- getSnapshots2(days)
3199
- ]);
3200
- return c.json({ ...stats, snapshots });
2835
+ channelRoutes.post("/channel/done", async (_c) => {
2836
+ channelDoneFlag = true;
2837
+ notifyChange();
2838
+ return _c.json({ ok: true });
3201
2839
  });
3202
- apiRoutes.get("/settings", async (c) => {
3203
- const settings = await getSettings();
3204
- return c.json(settings);
2840
+ channelRoutes.post("/channel/enable", async (c) => {
2841
+ const { registerChannel: registerChannel2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
2842
+ const dataDir2 = c.get("dataDir");
2843
+ await updateSetting("channel_enabled", "true");
2844
+ registerChannel2(dataDir2);
2845
+ notifyChange();
2846
+ return c.json({ ok: true });
2847
+ });
2848
+ channelRoutes.post("/channel/disable", async (c) => {
2849
+ const { unregisterChannel: unregisterChannel2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
2850
+ await updateSetting("channel_enabled", "false");
2851
+ unregisterChannel2();
2852
+ notifyChange();
2853
+ return c.json({ ok: true });
2854
+ });
2855
+
2856
+ // src/routes/dashboard.ts
2857
+ import { Hono as Hono3 } from "hono";
2858
+ import { join as join10, relative as relative2 } from "path";
2859
+
2860
+ // src/skills.ts
2861
+ init_file_settings();
2862
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
2863
+ import { join as join9, relative } from "path";
2864
+ var SKILL_VERSION = 5;
2865
+ var skillPort;
2866
+ var skillDataDir;
2867
+ var skillCategories = DEFAULT_CATEGORIES;
2868
+ function initSkills(port2, dataDir2) {
2869
+ skillPort = port2;
2870
+ skillDataDir = dataDir2;
2871
+ }
2872
+ function setSkillCategories(categories) {
2873
+ skillCategories = categories;
2874
+ }
2875
+ function buildTicketSkills() {
2876
+ return skillCategories.map((cat) => ({
2877
+ name: `hs-${cat.id.replace(/_/g, "-")}`,
2878
+ category: cat.id,
2879
+ label: cat.label.toLowerCase(),
2880
+ description: cat.description
2881
+ }));
2882
+ }
2883
+ function versionHeader() {
2884
+ return `<!-- hotsheet-skill-version: ${SKILL_VERSION} -->`;
2885
+ }
2886
+ function parseVersionHeader(content) {
2887
+ const match = content.match(/<!-- hotsheet-skill-version: (\d+)(?: port: \d+)? -->/);
2888
+ if (!match) return null;
2889
+ return parseInt(match[1], 10);
2890
+ }
2891
+ function updateFile(path, content) {
2892
+ if (existsSync7(path)) {
2893
+ const existing = readFileSync6(path, "utf-8");
2894
+ const version = parseVersionHeader(existing);
2895
+ if (version !== null && version >= SKILL_VERSION) {
2896
+ return false;
2897
+ }
2898
+ }
2899
+ writeFileSync6(path, content, "utf-8");
2900
+ return true;
2901
+ }
2902
+ function ticketSkillBody(skill) {
2903
+ const settings = readFileSettings(skillDataDir);
2904
+ const secret = settings.secret || "";
2905
+ const secretLine = secret ? ` -H "X-Hotsheet-Secret: ${secret}" \\` : "";
2906
+ const lines = [
2907
+ `Create a new Hot Sheet **${skill.label}** ticket. ${skill.description}.`,
2908
+ "",
2909
+ "**Parsing the input:**",
2910
+ '- If the input starts with "next", "up next", or "do next" (case-insensitive), set `up_next` to `true` and use the remaining text as the title',
2911
+ "- Otherwise, use the entire input as the title",
2912
+ "",
2913
+ "**Create the ticket** by running:",
2914
+ "```bash",
2915
+ `curl -s -X POST http://localhost:${skillPort}/api/tickets \\`,
2916
+ ' -H "Content-Type: application/json" \\'
2917
+ ];
2918
+ if (secretLine) lines.push(secretLine);
2919
+ lines.push(
2920
+ ` -d '{"title": "<TITLE>", "defaults": {"category": "${skill.category}", "up_next": <true|false>}}'`,
2921
+ "```",
2922
+ "",
2923
+ `If the request fails (connection refused or 403), re-read \`.hotsheet/settings.json\` for the current \`port\` and \`secret\` values \u2014 you may be connecting to the wrong Hot Sheet instance.`,
2924
+ "",
2925
+ "Report the created ticket number and title to the user."
2926
+ );
2927
+ return lines.join("\n");
2928
+ }
2929
+ function mainSkillBody() {
2930
+ const worklistRel = relative(process.cwd(), join9(skillDataDir, "worklist.md"));
2931
+ const settingsRel = relative(process.cwd(), join9(skillDataDir, "settings.json"));
2932
+ return [
2933
+ `Base directory for this skill: ${join9(process.cwd(), ".claude", "skills", "hotsheet")}`,
2934
+ "",
2935
+ `Read \`${worklistRel}\` and work through the tickets in priority order.`,
2936
+ "",
2937
+ "For each ticket:",
2938
+ "1. Read the ticket details carefully",
2939
+ "2. Implement the work described",
2940
+ "3. When complete, mark it done via the Hot Sheet UI",
2941
+ "",
2942
+ "Work through them in order of priority, where reasonable.",
2943
+ "",
2944
+ 'If the worklist says "Auto-Prioritize", follow those instructions to choose and mark tickets as Up Next before working on them.',
2945
+ "",
2946
+ `If API calls fail (connection refused or 403), re-read \`${settingsRel}\` for the current \`port\` and \`secret\` values \u2014 you may be connecting to the wrong Hot Sheet instance.`
2947
+ ].join("\n");
2948
+ }
2949
+ var HOTSHEET_ALLOW_PATTERNS = [
2950
+ "Bash(curl * http://localhost:417*/api/*)",
2951
+ "Bash(curl * http://localhost:418*/api/*)"
2952
+ ];
2953
+ var HOTSHEET_CURL_RE = /^Bash\(curl \* http:\/\/localhost:\d+\/api\/\*\)$|^Bash\(curl \* http:\/\/localhost:41[78]\*\/api\/\*\)$/;
2954
+ function ensureClaudePermissions(cwd) {
2955
+ if (skillPort < 4170 || skillPort > 4189) return false;
2956
+ const settingsPath2 = join9(cwd, ".claude", "settings.json");
2957
+ let settings = {};
2958
+ if (existsSync7(settingsPath2)) {
2959
+ try {
2960
+ settings = JSON.parse(readFileSync6(settingsPath2, "utf-8"));
2961
+ } catch {
2962
+ }
2963
+ }
2964
+ if (!settings.permissions) settings.permissions = {};
2965
+ if (!settings.permissions.allow) settings.permissions.allow = [];
2966
+ const allow = settings.permissions.allow;
2967
+ if (HOTSHEET_ALLOW_PATTERNS.every((p) => allow.includes(p))) return false;
2968
+ settings.permissions.allow = allow.filter((p) => !HOTSHEET_CURL_RE.test(p));
2969
+ settings.permissions.allow.push(...HOTSHEET_ALLOW_PATTERNS);
2970
+ writeFileSync6(settingsPath2, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2971
+ return true;
2972
+ }
2973
+ function ensureClaudeSkills(cwd) {
2974
+ let updated = false;
2975
+ const skillsDir = join9(cwd, ".claude", "skills");
2976
+ if (ensureClaudePermissions(cwd)) updated = true;
2977
+ const mainDir = join9(skillsDir, "hotsheet");
2978
+ mkdirSync4(mainDir, { recursive: true });
2979
+ const mainContent = [
2980
+ "---",
2981
+ "name: hotsheet",
2982
+ "description: Read the Hot Sheet worklist and work through the current priority items",
2983
+ "allowed-tools: Read, Grep, Glob, Edit, Write, Bash",
2984
+ "---",
2985
+ versionHeader(),
2986
+ "",
2987
+ mainSkillBody(),
2988
+ ""
2989
+ ].join("\n");
2990
+ if (updateFile(join9(mainDir, "SKILL.md"), mainContent)) updated = true;
2991
+ for (const skill of buildTicketSkills()) {
2992
+ const dir = join9(skillsDir, skill.name);
2993
+ mkdirSync4(dir, { recursive: true });
2994
+ const content = [
2995
+ "---",
2996
+ `name: ${skill.name}`,
2997
+ `description: Create a new ${skill.label} ticket in Hot Sheet`,
2998
+ "allowed-tools: Bash",
2999
+ "---",
3000
+ versionHeader(),
3001
+ "",
3002
+ ticketSkillBody(skill),
3003
+ ""
3004
+ ].join("\n");
3005
+ if (updateFile(join9(dir, "SKILL.md"), content)) updated = true;
3006
+ }
3007
+ return updated;
3008
+ }
3009
+ function ensureCursorRules(cwd) {
3010
+ let updated = false;
3011
+ const rulesDir = join9(cwd, ".cursor", "rules");
3012
+ mkdirSync4(rulesDir, { recursive: true });
3013
+ const mainContent = [
3014
+ "---",
3015
+ "description: Read the Hot Sheet worklist and work through the current priority items",
3016
+ "alwaysApply: false",
3017
+ "---",
3018
+ versionHeader(),
3019
+ "",
3020
+ mainSkillBody(),
3021
+ ""
3022
+ ].join("\n");
3023
+ if (updateFile(join9(rulesDir, "hotsheet.mdc"), mainContent)) updated = true;
3024
+ for (const skill of buildTicketSkills()) {
3025
+ const content = [
3026
+ "---",
3027
+ `description: Create a new ${skill.label} ticket in Hot Sheet`,
3028
+ "alwaysApply: false",
3029
+ "---",
3030
+ versionHeader(),
3031
+ "",
3032
+ ticketSkillBody(skill),
3033
+ ""
3034
+ ].join("\n");
3035
+ if (updateFile(join9(rulesDir, `${skill.name}.mdc`), content)) updated = true;
3036
+ }
3037
+ return updated;
3038
+ }
3039
+ function ensureCopilotPrompts(cwd) {
3040
+ let updated = false;
3041
+ const promptsDir = join9(cwd, ".github", "prompts");
3042
+ mkdirSync4(promptsDir, { recursive: true });
3043
+ const mainContent = [
3044
+ "---",
3045
+ "description: Read the Hot Sheet worklist and work through the current priority items",
3046
+ "---",
3047
+ versionHeader(),
3048
+ "",
3049
+ mainSkillBody(),
3050
+ ""
3051
+ ].join("\n");
3052
+ if (updateFile(join9(promptsDir, "hotsheet.prompt.md"), mainContent)) updated = true;
3053
+ for (const skill of buildTicketSkills()) {
3054
+ const content = [
3055
+ "---",
3056
+ `description: Create a new ${skill.label} ticket in Hot Sheet`,
3057
+ "---",
3058
+ versionHeader(),
3059
+ "",
3060
+ ticketSkillBody(skill),
3061
+ ""
3062
+ ].join("\n");
3063
+ if (updateFile(join9(promptsDir, `${skill.name}.prompt.md`), content)) updated = true;
3064
+ }
3065
+ return updated;
3066
+ }
3067
+ function ensureWindsurfRules(cwd) {
3068
+ let updated = false;
3069
+ const rulesDir = join9(cwd, ".windsurf", "rules");
3070
+ mkdirSync4(rulesDir, { recursive: true });
3071
+ const mainContent = [
3072
+ "---",
3073
+ "trigger: manual",
3074
+ "description: Read the Hot Sheet worklist and work through the current priority items",
3075
+ "---",
3076
+ versionHeader(),
3077
+ "",
3078
+ mainSkillBody(),
3079
+ ""
3080
+ ].join("\n");
3081
+ if (updateFile(join9(rulesDir, "hotsheet.md"), mainContent)) updated = true;
3082
+ for (const skill of buildTicketSkills()) {
3083
+ const content = [
3084
+ "---",
3085
+ "trigger: manual",
3086
+ `description: Create a new ${skill.label} ticket in Hot Sheet`,
3087
+ "---",
3088
+ versionHeader(),
3089
+ "",
3090
+ ticketSkillBody(skill),
3091
+ ""
3092
+ ].join("\n");
3093
+ if (updateFile(join9(rulesDir, `${skill.name}.md`), content)) updated = true;
3094
+ }
3095
+ return updated;
3096
+ }
3097
+ var pendingCreatedFlag = false;
3098
+ function ensureSkills() {
3099
+ const cwd = process.cwd();
3100
+ const platforms = [];
3101
+ if (existsSync7(join9(cwd, ".claude"))) {
3102
+ if (ensureClaudeSkills(cwd)) platforms.push("Claude Code");
3103
+ }
3104
+ if (existsSync7(join9(cwd, ".cursor"))) {
3105
+ if (ensureCursorRules(cwd)) platforms.push("Cursor");
3106
+ }
3107
+ if (existsSync7(join9(cwd, ".github", "prompts")) || existsSync7(join9(cwd, ".github", "copilot-instructions.md"))) {
3108
+ if (ensureCopilotPrompts(cwd)) platforms.push("GitHub Copilot");
3109
+ }
3110
+ if (existsSync7(join9(cwd, ".windsurf"))) {
3111
+ if (ensureWindsurfRules(cwd)) platforms.push("Windsurf");
3112
+ }
3113
+ if (platforms.length > 0) {
3114
+ pendingCreatedFlag = true;
3115
+ }
3116
+ return platforms;
3117
+ }
3118
+ function consumeSkillsCreatedFlag() {
3119
+ const result = pendingCreatedFlag;
3120
+ pendingCreatedFlag = false;
3121
+ return result;
3122
+ }
3123
+
3124
+ // src/routes/dashboard.ts
3125
+ var dashboardRoutes = new Hono3();
3126
+ dashboardRoutes.get("/poll", async (c) => {
3127
+ const clientVersion = parseInt(c.req.query("version") || "0", 10);
3128
+ const changeVersion2 = getChangeVersion();
3129
+ if (changeVersion2 > clientVersion) {
3130
+ return c.json({ version: changeVersion2 });
3131
+ }
3132
+ const version = await Promise.race([
3133
+ new Promise((resolve5) => {
3134
+ addPollWaiter(resolve5);
3135
+ }),
3136
+ new Promise((resolve5) => {
3137
+ setTimeout(() => resolve5(getChangeVersion()), 3e4);
3138
+ })
3139
+ ]);
3140
+ return c.json({ version });
3141
+ });
3142
+ dashboardRoutes.get("/stats", async (c) => {
3143
+ const stats = await getTicketStats();
3144
+ return c.json(stats);
3145
+ });
3146
+ dashboardRoutes.get("/dashboard", async (c) => {
3147
+ const { getDashboardStats: getDashboardStats2, getSnapshots: getSnapshots2 } = await Promise.resolve().then(() => (init_stats(), stats_exports));
3148
+ const days = parseInt(c.req.query("days") || "30", 10);
3149
+ const [stats, snapshots] = await Promise.all([
3150
+ getDashboardStats2(days),
3151
+ getSnapshots2(days)
3152
+ ]);
3153
+ return c.json({ ...stats, snapshots });
3154
+ });
3155
+ dashboardRoutes.get("/worklist-info", (c) => {
3156
+ const dataDir2 = c.get("dataDir");
3157
+ const cwd = process.cwd();
3158
+ const worklistRel = relative2(cwd, join10(dataDir2, "worklist.md"));
3159
+ const prompt = `Read ${worklistRel} for current work items.`;
3160
+ ensureSkills();
3161
+ const skillCreated = consumeSkillsCreatedFlag();
3162
+ return c.json({ prompt, skillCreated });
3163
+ });
3164
+ var glassboxAvailable = null;
3165
+ dashboardRoutes.get("/glassbox/status", async (c) => {
3166
+ if (glassboxAvailable === null) {
3167
+ const { execFileSync } = await import("child_process");
3168
+ try {
3169
+ execFileSync("which", ["glassbox"], { stdio: "ignore" });
3170
+ glassboxAvailable = true;
3171
+ } catch {
3172
+ glassboxAvailable = false;
3173
+ }
3174
+ }
3175
+ return c.json({ available: glassboxAvailable });
3176
+ });
3177
+ dashboardRoutes.post("/glassbox/launch", async (c) => {
3178
+ if (!glassboxAvailable) return c.json({ error: "Glassbox not available" }, 404);
3179
+ const { spawn } = await import("child_process");
3180
+ spawn("glassbox", [], {
3181
+ cwd: process.cwd(),
3182
+ detached: true,
3183
+ stdio: "ignore"
3184
+ }).unref();
3185
+ return c.json({ ok: true });
3186
+ });
3187
+ dashboardRoutes.get("/gitignore/status", async (c) => {
3188
+ const { isGitRepo: isGitRepo2, isHotsheetGitignored: isHotsheetGitignored2 } = await Promise.resolve().then(() => (init_gitignore(), gitignore_exports));
3189
+ const cwd = process.cwd();
3190
+ if (!isGitRepo2(cwd)) return c.json({ inGitRepo: false, ignored: false });
3191
+ return c.json({ inGitRepo: true, ignored: isHotsheetGitignored2(cwd) });
3192
+ });
3193
+ dashboardRoutes.post("/gitignore/add", async (c) => {
3194
+ const { ensureGitignore: ensureGitignore2 } = await Promise.resolve().then(() => (init_gitignore(), gitignore_exports));
3195
+ ensureGitignore2(process.cwd());
3196
+ return c.json({ ok: true });
3197
+ });
3198
+ dashboardRoutes.post("/print", async (c) => {
3199
+ const { html } = await c.req.json();
3200
+ const { writeFileSync: writeFileSync8 } = await import("fs");
3201
+ const { tmpdir: tmpdir2 } = await import("os");
3202
+ const { join: pathJoin } = await import("path");
3203
+ const { execFile } = await import("child_process");
3204
+ const tmpPath = pathJoin(tmpdir2(), `hotsheet-print-${Date.now()}.html`);
3205
+ writeFileSync8(tmpPath, html, "utf-8");
3206
+ const platform = process.platform;
3207
+ if (platform === "darwin") {
3208
+ execFile("open", [tmpPath]);
3209
+ } else if (platform === "win32") {
3210
+ execFile("start", ["", tmpPath], { shell: true });
3211
+ } else {
3212
+ execFile("xdg-open", [tmpPath]);
3213
+ }
3214
+ return c.json({ ok: true, path: tmpPath });
3215
+ });
3216
+
3217
+ // src/routes/settings.ts
3218
+ import { Hono as Hono4 } from "hono";
3219
+ var settingsRoutes = new Hono4();
3220
+ settingsRoutes.get("/tags", async (c) => {
3221
+ const tags = await getAllTags();
3222
+ return c.json(tags);
3223
+ });
3224
+ settingsRoutes.get("/categories", async (c) => {
3225
+ const categories = await getCategories();
3226
+ return c.json(categories);
3227
+ });
3228
+ settingsRoutes.put("/categories", async (c) => {
3229
+ const categories = await c.req.json();
3230
+ await saveCategories(categories);
3231
+ scheduleAllSync();
3232
+ notifyChange();
3233
+ return c.json(categories);
3234
+ });
3235
+ settingsRoutes.get("/category-presets", (c) => {
3236
+ return c.json(CATEGORY_PRESETS);
3237
+ });
3238
+ settingsRoutes.get("/settings", async (c) => {
3239
+ const settings = await getSettings();
3240
+ return c.json(settings);
3241
+ });
3242
+ settingsRoutes.patch("/settings", async (c) => {
3243
+ const body = await c.req.json();
3244
+ for (const [key, value] of Object.entries(body)) {
3245
+ await updateSetting(key, value);
3246
+ }
3247
+ return c.json({ ok: true });
3248
+ });
3249
+ settingsRoutes.get("/file-settings", async (c) => {
3250
+ const { readFileSettings: readFileSettings2 } = await Promise.resolve().then(() => (init_file_settings(), file_settings_exports));
3251
+ const dataDir2 = c.get("dataDir");
3252
+ const { secret, secretPathHash, port: port2, ...safe } = readFileSettings2(dataDir2);
3253
+ return c.json(safe);
3254
+ });
3255
+ settingsRoutes.patch("/file-settings", async (c) => {
3256
+ const { writeFileSettings: writeFileSettings2 } = await Promise.resolve().then(() => (init_file_settings(), file_settings_exports));
3257
+ const dataDir2 = c.get("dataDir");
3258
+ const body = await c.req.json();
3259
+ const updated = writeFileSettings2(dataDir2, body);
3260
+ return c.json(updated);
3261
+ });
3262
+
3263
+ // src/routes/tickets.ts
3264
+ import { rmSync as rmSync6 } from "fs";
3265
+ import { Hono as Hono5 } from "hono";
3266
+ var ticketRoutes = new Hono5();
3267
+ ticketRoutes.get("/tickets", async (c) => {
3268
+ const filters = {};
3269
+ const category = c.req.query("category");
3270
+ if (category !== void 0 && category !== "") filters.category = category;
3271
+ const priority = c.req.query("priority");
3272
+ if (priority !== void 0 && priority !== "") filters.priority = priority;
3273
+ const status = c.req.query("status");
3274
+ if (status !== void 0 && status !== "") filters.status = status;
3275
+ const upNext = c.req.query("up_next");
3276
+ if (upNext !== void 0) filters.up_next = upNext === "true";
3277
+ const search = c.req.query("search");
3278
+ if (search !== void 0 && search !== "") filters.search = search;
3279
+ const sortBy = c.req.query("sort_by");
3280
+ if (sortBy !== void 0 && sortBy !== "") filters.sort_by = sortBy;
3281
+ const sortDir = c.req.query("sort_dir");
3282
+ if (sortDir !== void 0 && sortDir !== "") filters.sort_dir = sortDir;
3283
+ const tickets = await getTickets(filters);
3284
+ return c.json(tickets);
3285
+ });
3286
+ ticketRoutes.post("/tickets", async (c) => {
3287
+ const body = await c.req.json();
3288
+ let title = body.title || "";
3289
+ const defaults = body.defaults || {};
3290
+ const { title: cleanTitle, tags: bracketTags } = extractBracketTags(title);
3291
+ if (bracketTags.length > 0) {
3292
+ title = cleanTitle || title;
3293
+ let existingTags = [];
3294
+ if (defaults.tags) {
3295
+ try {
3296
+ existingTags = JSON.parse(defaults.tags);
3297
+ } catch {
3298
+ }
3299
+ }
3300
+ for (const tag of bracketTags) {
3301
+ if (!existingTags.some((t) => t.toLowerCase() === tag.toLowerCase())) existingTags.push(tag);
3302
+ }
3303
+ defaults.tags = JSON.stringify(existingTags);
3304
+ }
3305
+ const ticket = await createTicket(title, defaults);
3306
+ scheduleAllSync();
3307
+ notifyChange();
3308
+ return c.json(ticket, 201);
3309
+ });
3310
+ ticketRoutes.get("/tickets/:id", async (c) => {
3311
+ const id = parseInt(c.req.param("id"), 10);
3312
+ const ticket = await getTicket(id);
3313
+ if (!ticket) return c.json({ error: "Not found" }, 404);
3314
+ const attachments = await getAttachments(id);
3315
+ const notes = parseNotes(ticket.notes);
3316
+ return c.json({ ...ticket, notes: JSON.stringify(notes), attachments });
3205
3317
  });
3206
- apiRoutes.patch("/settings", async (c) => {
3318
+ ticketRoutes.patch("/tickets/:id", async (c) => {
3319
+ const id = parseInt(c.req.param("id"), 10);
3207
3320
  const body = await c.req.json();
3208
- for (const [key, value] of Object.entries(body)) {
3209
- await updateSetting(key, value);
3210
- }
3321
+ const ticket = await updateTicket(id, body);
3322
+ if (!ticket) return c.json({ error: "Not found" }, 404);
3323
+ scheduleAllSync();
3324
+ notifyChange();
3325
+ return c.json(ticket);
3326
+ });
3327
+ ticketRoutes.delete("/tickets/:id", async (c) => {
3328
+ const id = parseInt(c.req.param("id"), 10);
3329
+ await deleteTicket(id);
3330
+ scheduleAllSync();
3331
+ notifyChange();
3211
3332
  return c.json({ ok: true });
3212
3333
  });
3213
- apiRoutes.get("/file-settings", async (c) => {
3214
- const { readFileSettings: readFileSettings2 } = await Promise.resolve().then(() => (init_file_settings(), file_settings_exports));
3215
- const dataDir2 = c.get("dataDir");
3216
- return c.json(readFileSettings2(dataDir2));
3334
+ ticketRoutes.put("/tickets/:id/notes-bulk", async (c) => {
3335
+ const id = parseInt(c.req.param("id"), 10);
3336
+ const body = await c.req.json();
3337
+ const db2 = await Promise.resolve().then(() => (init_connection(), connection_exports)).then((m) => m.getDb());
3338
+ const result = await (await db2).query(
3339
+ `UPDATE tickets SET notes = $1, updated_at = NOW() WHERE id = $2 RETURNING id`,
3340
+ [body.notes, id]
3341
+ );
3342
+ if (result.rows.length === 0) return c.json({ error: "Not found" }, 404);
3343
+ scheduleAllSync();
3344
+ notifyChange();
3345
+ return c.json({ ok: true });
3217
3346
  });
3218
- apiRoutes.patch("/file-settings", async (c) => {
3219
- const { writeFileSettings: writeFileSettings2 } = await Promise.resolve().then(() => (init_file_settings(), file_settings_exports));
3220
- const dataDir2 = c.get("dataDir");
3347
+ ticketRoutes.patch("/tickets/:id/notes/:noteId", async (c) => {
3348
+ const id = parseInt(c.req.param("id"), 10);
3349
+ const noteId2 = c.req.param("noteId");
3221
3350
  const body = await c.req.json();
3222
- const updated = writeFileSettings2(dataDir2, body);
3223
- return c.json(updated);
3351
+ const notes = await editNote(id, noteId2, body.text);
3352
+ if (!notes) return c.json({ error: "Not found" }, 404);
3353
+ scheduleAllSync();
3354
+ notifyChange();
3355
+ return c.json(notes);
3224
3356
  });
3225
- apiRoutes.get("/worklist-info", (c) => {
3226
- const dataDir2 = c.get("dataDir");
3227
- const cwd = process.cwd();
3228
- const worklistRel = relative2(cwd, join9(dataDir2, "worklist.md"));
3229
- const prompt = `Read ${worklistRel} for current work items.`;
3230
- ensureSkills();
3231
- const skillCreated = consumeSkillsCreatedFlag();
3232
- return c.json({ prompt, skillCreated });
3357
+ ticketRoutes.delete("/tickets/:id/notes/:noteId", async (c) => {
3358
+ const id = parseInt(c.req.param("id"), 10);
3359
+ const noteId2 = c.req.param("noteId");
3360
+ const notes = await deleteNote(id, noteId2);
3361
+ if (!notes) return c.json({ error: "Not found" }, 404);
3362
+ scheduleAllSync();
3363
+ notifyChange();
3364
+ return c.json(notes);
3233
3365
  });
3234
- var glassboxAvailable = null;
3235
- apiRoutes.get("/glassbox/status", async (c) => {
3236
- if (glassboxAvailable === null) {
3237
- const { execFileSync } = await import("child_process");
3366
+ ticketRoutes.delete("/tickets/:id/hard", async (c) => {
3367
+ const id = parseInt(c.req.param("id"), 10);
3368
+ const attachments = await getAttachments(id);
3369
+ for (const att of attachments) {
3238
3370
  try {
3239
- execFileSync("which", ["glassbox"], { stdio: "ignore" });
3240
- glassboxAvailable = true;
3371
+ rmSync6(att.stored_path, { force: true });
3241
3372
  } catch {
3242
- glassboxAvailable = false;
3243
3373
  }
3244
3374
  }
3245
- return c.json({ available: glassboxAvailable });
3246
- });
3247
- apiRoutes.post("/glassbox/launch", async (c) => {
3248
- if (!glassboxAvailable) return c.json({ error: "Glassbox not available" }, 404);
3249
- const { spawn } = await import("child_process");
3250
- spawn("glassbox", [], {
3251
- cwd: process.cwd(),
3252
- detached: true,
3253
- stdio: "ignore"
3254
- }).unref();
3255
- return c.json({ ok: true });
3256
- });
3257
- apiRoutes.get("/gitignore/status", async (c) => {
3258
- const { isGitRepo: isGitRepo2, isHotsheetGitignored: isHotsheetGitignored2 } = await Promise.resolve().then(() => (init_gitignore(), gitignore_exports));
3259
- const cwd = process.cwd();
3260
- if (!isGitRepo2(cwd)) return c.json({ inGitRepo: false, ignored: false });
3261
- return c.json({ inGitRepo: true, ignored: isHotsheetGitignored2(cwd) });
3262
- });
3263
- apiRoutes.post("/gitignore/add", async (c) => {
3264
- const { ensureGitignore: ensureGitignore2 } = await Promise.resolve().then(() => (init_gitignore(), gitignore_exports));
3265
- ensureGitignore2(process.cwd());
3375
+ await hardDeleteTicket(id);
3376
+ scheduleAllSync();
3377
+ notifyChange();
3266
3378
  return c.json({ ok: true });
3267
3379
  });
3268
- apiRoutes.get("/channel/claude-check", async (c) => {
3269
- const { execFileSync } = await import("child_process");
3270
- try {
3271
- const version = execFileSync("claude", ["--version"], { timeout: 5e3, encoding: "utf-8" }).trim();
3272
- const match = version.match(/(\d+\.\d+\.\d+)/);
3273
- const versionNum = match ? match[1] : null;
3274
- const parts = versionNum ? versionNum.split(".").map(Number) : [];
3275
- const meetsMinimum = parts.length === 3 && (parts[0] > 2 || parts[0] === 2 && parts[1] > 1 || parts[0] === 2 && parts[1] === 1 && parts[2] >= 80);
3276
- return c.json({ installed: true, version: versionNum, meetsMinimum });
3277
- } catch {
3278
- return c.json({ installed: false, version: null, meetsMinimum: false });
3279
- }
3280
- });
3281
- var channelDoneFlag = false;
3282
- apiRoutes.get("/channel/status", async (c) => {
3283
- const { isChannelAlive: isChannelAlive2, getChannelPort: getChannelPort2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
3284
- const dataDir2 = c.get("dataDir");
3285
- const settings = await getSettings();
3286
- const enabled = settings.channel_enabled === "true";
3287
- const port2 = getChannelPort2(dataDir2);
3288
- const alive = enabled ? await isChannelAlive2(dataDir2) : false;
3289
- const done = channelDoneFlag;
3290
- if (done) channelDoneFlag = false;
3291
- return c.json({ enabled, alive, port: port2, done });
3292
- });
3293
- apiRoutes.post("/channel/trigger", async (c) => {
3294
- const { triggerChannel: triggerChannel2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
3295
- const dataDir2 = c.get("dataDir");
3296
- const serverPort = parseInt(new URL(c.req.url).port || "4174", 10);
3297
- const body = await c.req.json().catch(() => ({ message: void 0 }));
3298
- channelDoneFlag = false;
3299
- const ok = await triggerChannel2(dataDir2, serverPort, body.message);
3300
- return c.json({ ok });
3301
- });
3302
- apiRoutes.get("/channel/permission", async (c) => {
3303
- const { getChannelPort: getChannelPort2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
3304
- const dataDir2 = c.get("dataDir");
3305
- const port2 = getChannelPort2(dataDir2);
3306
- if (!port2) return c.json({ pending: null });
3307
- try {
3308
- const res = await fetch(`http://127.0.0.1:${port2}/permission`);
3309
- const data = await res.json();
3310
- return c.json(data);
3311
- } catch {
3312
- return c.json({ pending: null });
3313
- }
3314
- });
3315
- apiRoutes.post("/channel/permission/respond", async (c) => {
3316
- const { getChannelPort: getChannelPort2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
3317
- const dataDir2 = c.get("dataDir");
3318
- const port2 = getChannelPort2(dataDir2);
3319
- if (!port2) return c.json({ error: "Channel not available" }, 503);
3380
+ ticketRoutes.post("/tickets/batch", async (c) => {
3320
3381
  const body = await c.req.json();
3321
- try {
3322
- const res = await fetch(`http://127.0.0.1:${port2}/permission/respond`, {
3323
- method: "POST",
3324
- headers: { "Content-Type": "application/json" },
3325
- body: JSON.stringify(body)
3326
- });
3327
- return c.json(await res.json());
3328
- } catch {
3329
- return c.json({ error: "Failed to reach channel server" }, 503);
3330
- }
3331
- });
3332
- apiRoutes.post("/channel/permission/dismiss", async (c) => {
3333
- const { getChannelPort: getChannelPort2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
3334
- const dataDir2 = c.get("dataDir");
3335
- const port2 = getChannelPort2(dataDir2);
3336
- if (!port2) return c.json({ ok: true });
3337
- try {
3338
- await fetch(`http://127.0.0.1:${port2}/permission/dismiss`, { method: "POST" });
3339
- } catch {
3382
+ switch (body.action) {
3383
+ case "delete":
3384
+ await batchDeleteTickets(body.ids);
3385
+ break;
3386
+ case "restore":
3387
+ await batchRestoreTickets(body.ids);
3388
+ break;
3389
+ case "category":
3390
+ await batchUpdateTickets(body.ids, { category: body.value });
3391
+ break;
3392
+ case "priority":
3393
+ await batchUpdateTickets(body.ids, { priority: body.value });
3394
+ break;
3395
+ case "status":
3396
+ await batchUpdateTickets(body.ids, { status: body.value });
3397
+ break;
3398
+ case "up_next":
3399
+ await batchUpdateTickets(body.ids, { up_next: body.value });
3400
+ break;
3340
3401
  }
3402
+ scheduleAllSync();
3403
+ notifyChange();
3341
3404
  return c.json({ ok: true });
3342
3405
  });
3343
- apiRoutes.post("/channel/done", async (_c) => {
3344
- channelDoneFlag = true;
3406
+ ticketRoutes.post("/tickets/duplicate", async (c) => {
3407
+ const body = await c.req.json();
3408
+ const created = await duplicateTickets(body.ids);
3409
+ scheduleAllSync();
3345
3410
  notifyChange();
3346
- return _c.json({ ok: true });
3411
+ return c.json(created, 201);
3347
3412
  });
3348
- apiRoutes.post("/channel/enable", async (c) => {
3349
- const { registerChannel: registerChannel2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
3350
- const dataDir2 = c.get("dataDir");
3351
- await updateSetting("channel_enabled", "true");
3352
- registerChannel2(dataDir2);
3413
+ ticketRoutes.post("/tickets/:id/restore", async (c) => {
3414
+ const id = parseInt(c.req.param("id"), 10);
3415
+ const ticket = await restoreTicket(id);
3416
+ if (!ticket) return c.json({ error: "Not found" }, 404);
3417
+ scheduleAllSync();
3353
3418
  notifyChange();
3354
- return c.json({ ok: true });
3419
+ return c.json(ticket);
3355
3420
  });
3356
- apiRoutes.post("/channel/disable", async (c) => {
3357
- const { unregisterChannel: unregisterChannel2 } = await Promise.resolve().then(() => (init_channel_config(), channel_config_exports));
3358
- await updateSetting("channel_enabled", "false");
3359
- unregisterChannel2();
3421
+ ticketRoutes.post("/trash/empty", async (c) => {
3422
+ const deleted = await getTickets({ status: "deleted" });
3423
+ for (const ticket of deleted) {
3424
+ const attachments = await getAttachments(ticket.id);
3425
+ for (const att of attachments) {
3426
+ try {
3427
+ rmSync6(att.stored_path, { force: true });
3428
+ } catch {
3429
+ }
3430
+ }
3431
+ }
3432
+ await emptyTrash();
3433
+ scheduleAllSync();
3360
3434
  notifyChange();
3361
3435
  return c.json({ ok: true });
3362
3436
  });
3363
- apiRoutes.post("/print", async (c) => {
3364
- const { html } = await c.req.json();
3365
- const { writeFileSync: writeFileSync8 } = await import("fs");
3366
- const { tmpdir: tmpdir2 } = await import("os");
3367
- const { join: pathJoin } = await import("path");
3368
- const { execFile } = await import("child_process");
3369
- const tmpPath = pathJoin(tmpdir2(), `hotsheet-print-${Date.now()}.html`);
3370
- writeFileSync8(tmpPath, html, "utf-8");
3371
- const platform = process.platform;
3372
- if (platform === "darwin") {
3373
- execFile("open", [tmpPath]);
3374
- } else if (platform === "win32") {
3375
- execFile("start", ["", tmpPath], { shell: true });
3376
- } else {
3377
- execFile("xdg-open", [tmpPath]);
3378
- }
3379
- return c.json({ ok: true, path: tmpPath });
3437
+ ticketRoutes.post("/tickets/:id/up-next", async (c) => {
3438
+ const id = parseInt(c.req.param("id"), 10);
3439
+ const ticket = await toggleUpNext(id);
3440
+ if (!ticket) return c.json({ error: "Not found" }, 404);
3441
+ scheduleAllSync();
3442
+ notifyChange();
3443
+ return c.json(ticket);
3444
+ });
3445
+ ticketRoutes.post("/tickets/query", async (c) => {
3446
+ const body = await c.req.json();
3447
+ const tickets = await queryTickets(body.logic, body.conditions, body.sort_by, body.sort_dir, body.required_tag);
3448
+ return c.json(tickets);
3380
3449
  });
3381
3450
 
3451
+ // src/routes/api.ts
3452
+ var apiRoutes = new Hono6();
3453
+ apiRoutes.route("/", ticketRoutes);
3454
+ apiRoutes.route("/", attachmentRoutes);
3455
+ apiRoutes.route("/", channelRoutes);
3456
+ apiRoutes.route("/", settingsRoutes);
3457
+ apiRoutes.route("/", dashboardRoutes);
3458
+
3382
3459
  // src/routes/backups.ts
3383
- import { Hono as Hono2 } from "hono";
3384
- var backupRoutes = new Hono2();
3460
+ import { Hono as Hono7 } from "hono";
3461
+ var backupRoutes = new Hono7();
3385
3462
  backupRoutes.get("/", (c) => {
3386
3463
  const dataDir2 = c.get("dataDir");
3387
3464
  const backups = listBackups(dataDir2);
@@ -3430,7 +3507,7 @@ backupRoutes.post("/restore", async (c) => {
3430
3507
  });
3431
3508
 
3432
3509
  // src/routes/pages.tsx
3433
- import { Hono as Hono3 } from "hono";
3510
+ import { Hono as Hono8 } from "hono";
3434
3511
 
3435
3512
  // src/utils/escapeHtml.ts
3436
3513
  function escapeHtml(str) {
@@ -3625,7 +3702,7 @@ function Layout({ title, children }) {
3625
3702
  }
3626
3703
 
3627
3704
  // src/routes/pages.tsx
3628
- var pageRoutes = new Hono3();
3705
+ var pageRoutes = new Hono8();
3629
3706
  pageRoutes.get("/", (c) => {
3630
3707
  const html = /* @__PURE__ */ jsx(Layout, { title: "Hot Sheet", children: [
3631
3708
  /* @__PURE__ */ jsx("div", { className: "app", children: [
@@ -3727,17 +3804,66 @@ pageRoutes.get("/", (c) => {
3727
3804
  /* @__PURE__ */ jsx("path", { d: "M12 8v8" })
3728
3805
  ] }) })
3729
3806
  ] }),
3730
- /* @__PURE__ */ jsx("button", { className: "sidebar-item active", "data-view": "all", children: "All Tickets" }),
3731
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "non-verified", children: "Non-Verified" }),
3732
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "up-next", children: "Up Next" }),
3733
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "open", children: "Open" }),
3734
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "completed", children: "Completed" }),
3735
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "verified", children: "Verified" }),
3807
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item active", "data-view": "all", children: [
3808
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3809
+ /* @__PURE__ */ jsx("path", { d: "M3 12h18" }),
3810
+ /* @__PURE__ */ jsx("path", { d: "M3 6h18" }),
3811
+ /* @__PURE__ */ jsx("path", { d: "M3 18h18" })
3812
+ ] }) }),
3813
+ " All Tickets"
3814
+ ] }),
3815
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "non-verified", children: [
3816
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", children: "\u25D4" }),
3817
+ " Non-Verified"
3818
+ ] }),
3819
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "up-next", children: [
3820
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", children: "\u2605" }),
3821
+ " Up Next"
3822
+ ] }),
3823
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "open", children: [
3824
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", children: "\u25CB" }),
3825
+ " Open"
3826
+ ] }),
3827
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "completed", children: [
3828
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", children: "\u2713" }),
3829
+ " Completed"
3830
+ ] }),
3831
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "verified", children: [
3832
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3833
+ /* @__PURE__ */ jsx("path", { d: "M18 6 7 17l-5-5" }),
3834
+ /* @__PURE__ */ jsx("path", { d: "m22 10-7.5 7.5L13 16" })
3835
+ ] }) }),
3836
+ " Verified"
3837
+ ] }),
3736
3838
  /* @__PURE__ */ jsx("div", { id: "custom-views-container" }),
3737
3839
  /* @__PURE__ */ jsx("div", { className: "sidebar-divider" }),
3738
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "backlog", children: "Backlog" }),
3739
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "archive", children: "Archive" }),
3740
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "trash", children: "Trash" })
3840
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "backlog", children: [
3841
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3842
+ /* @__PURE__ */ jsx("rect", { width: "18", height: "18", x: "3", y: "4", rx: "2" }),
3843
+ /* @__PURE__ */ jsx("path", { d: "M16 2v4" }),
3844
+ /* @__PURE__ */ jsx("path", { d: "M8 2v4" }),
3845
+ /* @__PURE__ */ jsx("path", { d: "M3 10h18" })
3846
+ ] }) }),
3847
+ " Backlog"
3848
+ ] }),
3849
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "archive", children: [
3850
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3851
+ /* @__PURE__ */ jsx("rect", { width: "20", height: "5", x: "2", y: "3", rx: "1" }),
3852
+ /* @__PURE__ */ jsx("path", { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" }),
3853
+ /* @__PURE__ */ jsx("path", { d: "M10 12h4" })
3854
+ ] }) }),
3855
+ " Archive"
3856
+ ] }),
3857
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "trash", children: [
3858
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3859
+ /* @__PURE__ */ jsx("path", { d: "M3 6h18" }),
3860
+ /* @__PURE__ */ jsx("path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }),
3861
+ /* @__PURE__ */ jsx("path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" }),
3862
+ /* @__PURE__ */ jsx("line", { x1: "10", x2: "10", y1: "11", y2: "17" }),
3863
+ /* @__PURE__ */ jsx("line", { x1: "14", x2: "14", y1: "11", y2: "17" })
3864
+ ] }) }),
3865
+ " Trash"
3866
+ ] })
3741
3867
  ] }),
3742
3868
  /* @__PURE__ */ jsx("div", { className: "sidebar-section", id: "sidebar-categories", children: [
3743
3869
  /* @__PURE__ */ jsx("div", { className: "sidebar-label", children: "Category" }),
@@ -3768,11 +3894,35 @@ pageRoutes.get("/", (c) => {
3768
3894
  ] }),
3769
3895
  /* @__PURE__ */ jsx("div", { className: "sidebar-section", children: [
3770
3896
  /* @__PURE__ */ jsx("div", { className: "sidebar-label", children: "Priority" }),
3771
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:highest", children: "Highest" }),
3772
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:high", children: "High" }),
3773
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:default", children: "Default" }),
3774
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:low", children: "Low" }),
3775
- /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:lowest", children: "Lowest" })
3897
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:highest", children: [
3898
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", style: "color:#ef4444", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3899
+ /* @__PURE__ */ jsx("path", { d: "m7 11 5-5 5 5" }),
3900
+ /* @__PURE__ */ jsx("path", { d: "m7 17 5-5 5 5" })
3901
+ ] }) }),
3902
+ " Highest"
3903
+ ] }),
3904
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:high", children: [
3905
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", style: "color:#f97316", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsx("path", { d: "m18 15-6-6-6 6" }) }) }),
3906
+ " High"
3907
+ ] }),
3908
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:default", children: [
3909
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", style: "color:#6b7280", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3910
+ /* @__PURE__ */ jsx("path", { d: "m7 15 5 5 5-5" }),
3911
+ /* @__PURE__ */ jsx("path", { d: "m7 9 5-5 5 5" })
3912
+ ] }) }),
3913
+ " Default"
3914
+ ] }),
3915
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:low", children: [
3916
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", style: "color:#3b82f6", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" }) }) }),
3917
+ " Low"
3918
+ ] }),
3919
+ /* @__PURE__ */ jsx("button", { className: "sidebar-item", "data-view": "priority:lowest", children: [
3920
+ /* @__PURE__ */ jsx("span", { className: "sidebar-icon", style: "color:#94a3b8", children: /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3921
+ /* @__PURE__ */ jsx("path", { d: "m7 7 5 5 5-5" }),
3922
+ /* @__PURE__ */ jsx("path", { d: "m7 13 5 5 5-5" })
3923
+ ] }) }),
3924
+ " Lowest"
3925
+ ] })
3776
3926
  ] }),
3777
3927
  /* @__PURE__ */ jsx("div", { className: "sidebar-stats", id: "stats-bar" })
3778
3928
  ] }),
@@ -3979,6 +4129,13 @@ pageRoutes.get("/", (c) => {
3979
4129
  /* @__PURE__ */ jsx("label", { children: "Auto-clear verified after (days)" }),
3980
4130
  /* @__PURE__ */ jsx("input", { type: "number", id: "settings-verified-days", min: "1", value: "30" })
3981
4131
  ] }),
4132
+ /* @__PURE__ */ jsx("div", { className: "settings-field settings-field-checkbox", children: [
4133
+ /* @__PURE__ */ jsx("label", { children: [
4134
+ /* @__PURE__ */ jsx("input", { type: "checkbox", id: "settings-auto-order", checked: true }),
4135
+ " Auto-prioritize tickets"
4136
+ ] }),
4137
+ /* @__PURE__ */ jsx("span", { className: "settings-hint", children: "When no Up Next items exist, the AI will evaluate open tickets and choose what to work on next." })
4138
+ ] }),
3982
4139
  /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
3983
4140
  /* @__PURE__ */ jsx("label", { children: "When Claude needs permission" }),
3984
4141
  /* @__PURE__ */ jsx("select", { id: "settings-notify-permission", children: [
@@ -4072,10 +4229,10 @@ pageRoutes.get("/", (c) => {
4072
4229
 
4073
4230
  // src/server.ts
4074
4231
  function tryServe(fetch2, port2) {
4075
- return new Promise((resolve4, reject) => {
4232
+ return new Promise((resolve5, reject) => {
4076
4233
  const server = serve({ fetch: fetch2, port: port2 });
4077
4234
  server.on("listening", () => {
4078
- resolve4(port2);
4235
+ resolve5(port2);
4079
4236
  });
4080
4237
  server.on("error", (err) => {
4081
4238
  reject(err);
@@ -4083,24 +4240,24 @@ function tryServe(fetch2, port2) {
4083
4240
  });
4084
4241
  }
4085
4242
  async function startServer(port2, dataDir2, options) {
4086
- const app = new Hono4();
4243
+ const app = new Hono9();
4087
4244
  app.use("*", async (c, next) => {
4088
4245
  c.set("dataDir", dataDir2);
4089
4246
  await next();
4090
4247
  });
4091
4248
  const selfDir = dirname2(fileURLToPath2(import.meta.url));
4092
- const distDir = existsSync8(join10(selfDir, "client", "styles.css")) ? join10(selfDir, "client") : join10(selfDir, "..", "dist", "client");
4249
+ const distDir = existsSync8(join11(selfDir, "client", "styles.css")) ? join11(selfDir, "client") : join11(selfDir, "..", "dist", "client");
4093
4250
  app.get("/static/styles.css", (c) => {
4094
- const css = readFileSync7(join10(distDir, "styles.css"), "utf-8");
4251
+ const css = readFileSync7(join11(distDir, "styles.css"), "utf-8");
4095
4252
  return c.text(css, 200, { "Content-Type": "text/css", "Cache-Control": "no-cache" });
4096
4253
  });
4097
4254
  app.get("/static/app.js", (c) => {
4098
- const js = readFileSync7(join10(distDir, "app.global.js"), "utf-8");
4255
+ const js = readFileSync7(join11(distDir, "app.global.js"), "utf-8");
4099
4256
  return c.text(js, 200, { "Content-Type": "application/javascript", "Cache-Control": "no-cache" });
4100
4257
  });
4101
4258
  app.get("/static/assets/:filename", (c) => {
4102
4259
  const filename = c.req.param("filename");
4103
- const filePath = join10(distDir, "assets", filename);
4260
+ const filePath = join11(distDir, "assets", filename);
4104
4261
  if (!existsSync8(filePath)) return c.notFound();
4105
4262
  const content = readFileSync7(filePath);
4106
4263
  const ext = filename.split(".").pop();
@@ -4127,8 +4284,9 @@ async function startServer(port2, dataDir2, options) {
4127
4284
  } else if (isMutation) {
4128
4285
  const origin = c.req.header("Origin");
4129
4286
  const referer = c.req.header("Referer");
4130
- const isBrowser = !!(origin || referer);
4131
- if (!isBrowser) {
4287
+ const localhostPattern = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/;
4288
+ const isSameOrigin = origin && localhostPattern.test(origin) || referer && localhostPattern.test(referer);
4289
+ if (!isSameOrigin) {
4132
4290
  return c.json({
4133
4291
  error: "Missing X-Hotsheet-Secret header. Read .hotsheet/settings.json for the correct port and secret.",
4134
4292
  recovery: "Re-read .hotsheet/settings.json to get the correct port and secret, and re-read your skill files for updated instructions."
@@ -4179,15 +4337,15 @@ async function startServer(port2, dataDir2, options) {
4179
4337
  import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
4180
4338
  import { get } from "https";
4181
4339
  import { homedir } from "os";
4182
- import { dirname as dirname3, join as join11 } from "path";
4340
+ import { dirname as dirname3, join as join12 } from "path";
4183
4341
  import { fileURLToPath as fileURLToPath3 } from "url";
4184
- var DATA_DIR = join11(homedir(), ".hotsheet");
4185
- var CHECK_FILE = join11(DATA_DIR, "last-update-check");
4342
+ var DATA_DIR = join12(homedir(), ".hotsheet");
4343
+ var CHECK_FILE = join12(DATA_DIR, "last-update-check");
4186
4344
  var PACKAGE_NAME = "hotsheet";
4187
4345
  function getCurrentVersion() {
4188
4346
  try {
4189
4347
  const dir = dirname3(fileURLToPath3(import.meta.url));
4190
- const pkg = JSON.parse(readFileSync8(join11(dir, "..", "package.json"), "utf-8"));
4348
+ const pkg = JSON.parse(readFileSync8(join12(dir, "..", "package.json"), "utf-8"));
4191
4349
  return pkg.version;
4192
4350
  } catch {
4193
4351
  return "0.0.0";
@@ -4213,10 +4371,10 @@ function isFirstUseToday() {
4213
4371
  return last !== today;
4214
4372
  }
4215
4373
  function fetchLatestVersion() {
4216
- return new Promise((resolve4) => {
4374
+ return new Promise((resolve5) => {
4217
4375
  const req = get(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { timeout: 5e3 }, (res) => {
4218
4376
  if (res.statusCode !== 200) {
4219
- resolve4(null);
4377
+ resolve5(null);
4220
4378
  return;
4221
4379
  }
4222
4380
  let data = "";
@@ -4225,18 +4383,18 @@ function fetchLatestVersion() {
4225
4383
  });
4226
4384
  res.on("end", () => {
4227
4385
  try {
4228
- resolve4(JSON.parse(data).version);
4386
+ resolve5(JSON.parse(data).version);
4229
4387
  } catch {
4230
- resolve4(null);
4388
+ resolve5(null);
4231
4389
  }
4232
4390
  });
4233
4391
  });
4234
4392
  req.on("error", () => {
4235
- resolve4(null);
4393
+ resolve5(null);
4236
4394
  });
4237
4395
  req.on("timeout", () => {
4238
4396
  req.destroy();
4239
- resolve4(null);
4397
+ resolve5(null);
4240
4398
  });
4241
4399
  });
4242
4400
  }
@@ -4309,7 +4467,7 @@ Examples:
4309
4467
  function parseArgs(argv) {
4310
4468
  const args = argv.slice(2);
4311
4469
  let port2 = 4174;
4312
- let dataDir2 = join12(process.cwd(), ".hotsheet");
4470
+ let dataDir2 = join13(process.cwd(), ".hotsheet");
4313
4471
  let demo = null;
4314
4472
  let forceUpdateCheck = false;
4315
4473
  let noOpen = false;
@@ -4338,7 +4496,7 @@ function parseArgs(argv) {
4338
4496
  }
4339
4497
  break;
4340
4498
  case "--data-dir":
4341
- dataDir2 = resolve3(args[++i]);
4499
+ dataDir2 = resolve4(args[++i]);
4342
4500
  break;
4343
4501
  case "--check-for-updates":
4344
4502
  forceUpdateCheck = true;
@@ -4376,7 +4534,7 @@ async function main() {
4376
4534
  }
4377
4535
  process.exit(1);
4378
4536
  }
4379
- dataDir2 = join12(tmpdir(), `hotsheet-demo-${demo}-${Date.now()}`);
4537
+ dataDir2 = join13(tmpdir(), `hotsheet-demo-${demo}-${Date.now()}`);
4380
4538
  console.log(`
4381
4539
  DEMO MODE: ${scenario.label}
4382
4540
  `);