hotsheet 0.6.4 → 0.7.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
@@ -129,12 +129,14 @@ var init_connection = __esm({
129
129
  // src/file-settings.ts
130
130
  var file_settings_exports = {};
131
131
  __export(file_settings_exports, {
132
+ ensureSecret: () => ensureSecret,
132
133
  getBackupDir: () => getBackupDir,
133
134
  readFileSettings: () => readFileSettings,
134
135
  writeFileSettings: () => writeFileSettings
135
136
  });
137
+ import { createHash, randomBytes } from "crypto";
136
138
  import { existsSync, readFileSync, writeFileSync } from "fs";
137
- import { join as join2 } from "path";
139
+ import { join as join2, resolve } from "path";
138
140
  function settingsPath(dataDir2) {
139
141
  return join2(dataDir2, "settings.json");
140
142
  }
@@ -157,6 +159,25 @@ function getBackupDir(dataDir2) {
157
159
  const settings = readFileSettings(dataDir2);
158
160
  return settings.backupDir || join2(dataDir2, "backups");
159
161
  }
162
+ function hashPath(dataDir2) {
163
+ const absPath = resolve(settingsPath(dataDir2));
164
+ return createHash("sha256").update(absPath).digest("hex").slice(0, 16);
165
+ }
166
+ function ensureSecret(dataDir2, port2) {
167
+ const settings = readFileSettings(dataDir2);
168
+ const currentPathHash = hashPath(dataDir2);
169
+ if (settings.secret && settings.secretPathHash === currentPathHash) {
170
+ if (settings.port !== port2) {
171
+ writeFileSettings(dataDir2, { port: port2 });
172
+ }
173
+ return settings.secret;
174
+ }
175
+ const random = randomBytes(32).toString("hex");
176
+ const absPath = resolve(settingsPath(dataDir2));
177
+ const secret = createHash("sha256").update(absPath + random).digest("hex").slice(0, 32);
178
+ writeFileSettings(dataDir2, { secret, secretPathHash: currentPathHash, port: port2 });
179
+ return secret;
180
+ }
160
181
  var init_file_settings = __esm({
161
182
  "src/file-settings.ts"() {
162
183
  "use strict";
@@ -430,15 +451,15 @@ __export(channel_config_exports, {
430
451
  unregisterChannel: () => unregisterChannel
431
452
  });
432
453
  import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
433
- import { dirname, join as join8, resolve } from "path";
454
+ import { dirname, join as join8, resolve as resolve2 } from "path";
434
455
  import { fileURLToPath } from "url";
435
456
  function getChannelServerPath() {
436
457
  const thisDir = dirname(fileURLToPath(import.meta.url));
437
- const distPath = resolve(thisDir, "channel.js");
458
+ const distPath = resolve2(thisDir, "channel.js");
438
459
  if (existsSync6(distPath)) {
439
460
  return { command: "node", args: [distPath] };
440
461
  }
441
- const srcPath = resolve(thisDir, "channel.ts");
462
+ const srcPath = resolve2(thisDir, "channel.ts");
442
463
  if (existsSync6(srcPath)) {
443
464
  return { command: "npx", args: ["tsx", srcPath] };
444
465
  }
@@ -524,7 +545,7 @@ var init_channel_config = __esm({
524
545
  // src/cli.ts
525
546
  import { mkdirSync as mkdirSync6 } from "fs";
526
547
  import { tmpdir } from "os";
527
- import { join as join12, resolve as resolve2 } from "path";
548
+ import { join as join12, resolve as resolve3 } from "path";
528
549
 
529
550
  // src/backup.ts
530
551
  init_connection();
@@ -865,6 +886,10 @@ async function createTicket(title, defaults) {
865
886
  cols.push("details");
866
887
  vals.push(defaults.details);
867
888
  }
889
+ if (defaults?.tags !== void 0 && defaults.tags !== "" && defaults.tags !== "[]") {
890
+ cols.push("tags");
891
+ vals.push(defaults.tags);
892
+ }
868
893
  const placeholders = vals.map((_, i) => `$${i + 1}`).join(", ");
869
894
  const result = await db2.query(
870
895
  `INSERT INTO tickets (${cols.join(", ")}) VALUES (${placeholders}) RETURNING *`,
@@ -963,7 +988,7 @@ async function getTickets(filters = {}) {
963
988
  paramIdx++;
964
989
  }
965
990
  if (filters.search !== void 0 && filters.search !== "") {
966
- conditions.push(`(title ILIKE $${paramIdx} OR details ILIKE $${paramIdx} OR ticket_number ILIKE $${paramIdx})`);
991
+ conditions.push(`(title ILIKE $${paramIdx} OR details ILIKE $${paramIdx} OR ticket_number ILIKE $${paramIdx} OR tags ILIKE $${paramIdx})`);
967
992
  values.push(`%${filters.search}%`);
968
993
  paramIdx++;
969
994
  }
@@ -1098,7 +1123,7 @@ function ordinalValue(field, value) {
1098
1123
  if (field === "status") return STATUS_RANK[value] ?? null;
1099
1124
  return null;
1100
1125
  }
1101
- async function queryTickets(logic, conditions, sortBy, sortDir) {
1126
+ async function queryTickets(logic, conditions, sortBy, sortDir, requiredTag) {
1102
1127
  const db2 = await getDb();
1103
1128
  const where = [];
1104
1129
  const values = [];
@@ -1145,6 +1170,11 @@ async function queryTickets(logic, conditions, sortBy, sortDir) {
1145
1170
  break;
1146
1171
  }
1147
1172
  }
1173
+ if (requiredTag) {
1174
+ where[0] += ` AND tags ILIKE $${paramIdx}`;
1175
+ values.push(`%${requiredTag}%`);
1176
+ paramIdx++;
1177
+ }
1148
1178
  const joiner = logic === "any" ? " OR " : " AND ";
1149
1179
  const userConditions = where.slice(1);
1150
1180
  let whereClause = where[0];
@@ -1173,6 +1203,19 @@ async function queryTickets(logic, conditions, sortBy, sortDir) {
1173
1203
  );
1174
1204
  return result.rows;
1175
1205
  }
1206
+ function normalizeTag(input) {
1207
+ return input.replace(/[^a-zA-Z0-9]+/g, " ").trim().toLowerCase();
1208
+ }
1209
+ function extractBracketTags(input) {
1210
+ const tags = [];
1211
+ const cleaned = input.replace(/\[([^\]]*)\]/g, (_match, content) => {
1212
+ const tag = normalizeTag(content);
1213
+ if (tag && !tags.includes(tag)) tags.push(tag);
1214
+ return " ";
1215
+ });
1216
+ const title = cleaned.replace(/\s+/g, " ").trim();
1217
+ return { title, tags };
1218
+ }
1176
1219
  async function getAllTags() {
1177
1220
  const db2 = await getDb();
1178
1221
  const result = await db2.query(`SELECT DISTINCT tags FROM tickets WHERE tags != '[]' AND status != 'deleted'`);
@@ -1182,7 +1225,10 @@ async function getAllTags() {
1182
1225
  const parsed = JSON.parse(row.tags);
1183
1226
  if (Array.isArray(parsed)) {
1184
1227
  for (const tag of parsed) {
1185
- if (typeof tag === "string" && tag.trim()) tagSet.add(tag.trim());
1228
+ if (typeof tag === "string" && tag.trim()) {
1229
+ const norm = normalizeTag(tag);
1230
+ if (norm) tagSet.add(norm);
1231
+ }
1186
1232
  }
1187
1233
  }
1188
1234
  } catch {
@@ -1301,6 +1347,7 @@ async function cleanupAttachments() {
1301
1347
 
1302
1348
  // src/cli.ts
1303
1349
  init_connection();
1350
+ init_file_settings();
1304
1351
 
1305
1352
  // src/lock.ts
1306
1353
  import { existsSync as existsSync3, readFileSync as readFileSync3, rmSync as rmSync4, writeFileSync as writeFileSync3 } from "fs";
@@ -1360,7 +1407,8 @@ var DEMO_SCENARIOS = [
1360
1407
  { id: 5, label: "Batch operations \u2014 multi-select toolbar" },
1361
1408
  { id: 6, label: "Detail panel \u2014 bottom orientation with tags and notes" },
1362
1409
  { id: 7, label: "Column view \u2014 kanban board by status" },
1363
- { id: 8, label: "Dashboard \u2014 stats and charts" }
1410
+ { id: 8, label: "Dashboard \u2014 stats and charts" },
1411
+ { id: 9, label: "Claude Channel \u2014 AI integration with custom commands" }
1364
1412
  ];
1365
1413
  function daysAgo(days) {
1366
1414
  const d = /* @__PURE__ */ new Date();
@@ -2144,6 +2192,104 @@ for (let i = 0; i < 30; i++) {
2144
2192
  verified_ago: verified
2145
2193
  });
2146
2194
  }
2195
+ var SCENARIO_9 = [
2196
+ {
2197
+ title: "Fix race condition in WebSocket message ordering",
2198
+ details: "Messages arriving during reconnect can be delivered out of order. Need to add sequence numbers and a reorder buffer on the client side.\n\nReproduction: disconnect WiFi briefly during a burst of real-time updates, then reconnect \u2014 events appear in wrong order.",
2199
+ category: "bug",
2200
+ priority: "highest",
2201
+ status: "started",
2202
+ up_next: true,
2203
+ tags: ["websocket", "real-time"],
2204
+ notes: notesJson([
2205
+ { text: "Investigating \u2014 the issue is in the reconnect handler. When the socket reconnects, buffered server-side messages are flushed immediately without checking the client sequence counter.", days_ago: 0.1 }
2206
+ ]),
2207
+ days_ago: 3,
2208
+ updated_ago: 0.1
2209
+ },
2210
+ {
2211
+ title: "Add rate limiting to public API endpoints",
2212
+ details: "Implement token bucket rate limiting for all /api/v2/ endpoints. 100 requests per minute per API key, with burst allowance of 20.",
2213
+ category: "feature",
2214
+ priority: "high",
2215
+ status: "not_started",
2216
+ up_next: true,
2217
+ tags: ["api", "security"],
2218
+ notes: "",
2219
+ days_ago: 2,
2220
+ updated_ago: 2
2221
+ },
2222
+ {
2223
+ title: "Migrate user preferences to new schema",
2224
+ details: "The preferences table needs to be migrated from the old key-value format to the new typed JSON column. Write a migration script that preserves existing user settings.",
2225
+ category: "task",
2226
+ priority: "default",
2227
+ status: "not_started",
2228
+ up_next: true,
2229
+ tags: ["database", "migration"],
2230
+ notes: "",
2231
+ days_ago: 4,
2232
+ updated_ago: 4
2233
+ },
2234
+ {
2235
+ title: "Investigate slow query on orders dashboard",
2236
+ details: "The orders dashboard takes 8+ seconds to load for merchants with >10k orders. Need to profile the SQL and add appropriate indexes.",
2237
+ category: "investigation",
2238
+ priority: "high",
2239
+ status: "completed",
2240
+ up_next: false,
2241
+ tags: ["performance", "database"],
2242
+ notes: notesJson([
2243
+ { text: "Root cause: missing composite index on (merchant_id, created_at). The query was doing a full table scan. Added index and query time dropped from 8.2s to 45ms.", days_ago: 1 }
2244
+ ]),
2245
+ days_ago: 5,
2246
+ updated_ago: 1,
2247
+ completed_ago: 1
2248
+ },
2249
+ {
2250
+ title: "Update error handling middleware to use structured logging",
2251
+ details: "Replace console.error calls with structured JSON logging using pino. Include request ID, user context, and stack traces.",
2252
+ category: "task",
2253
+ priority: "default",
2254
+ status: "completed",
2255
+ up_next: false,
2256
+ tags: ["observability", "logging"],
2257
+ notes: notesJson([
2258
+ { text: "Replaced all console.error/warn calls with pino logger. Added request ID propagation via AsyncLocalStorage. Error responses now include a correlationId for support debugging.", days_ago: 2 }
2259
+ ]),
2260
+ days_ago: 7,
2261
+ updated_ago: 2,
2262
+ completed_ago: 2
2263
+ },
2264
+ {
2265
+ title: "Fix CORS headers missing on preflight for webhook endpoints",
2266
+ details: "Third-party integrations sending OPTIONS preflight requests to /webhooks/* get 405 Method Not Allowed.",
2267
+ category: "bug",
2268
+ priority: "default",
2269
+ status: "verified",
2270
+ up_next: false,
2271
+ tags: ["api", "webhooks"],
2272
+ notes: notesJson([
2273
+ { text: "Added CORS preflight handler for webhook routes. Configured allowed origins from the integration settings table.", days_ago: 4 }
2274
+ ]),
2275
+ days_ago: 9,
2276
+ updated_ago: 4,
2277
+ completed_ago: 5,
2278
+ verified_ago: 4
2279
+ },
2280
+ {
2281
+ title: "Design new onboarding flow for team workspaces",
2282
+ details: "The current onboarding drops users into an empty workspace. Design a guided setup that creates sample data and walks through key features.",
2283
+ category: "feature",
2284
+ priority: "low",
2285
+ status: "not_started",
2286
+ up_next: false,
2287
+ tags: ["onboarding", "ux"],
2288
+ notes: "",
2289
+ days_ago: 10,
2290
+ updated_ago: 10
2291
+ }
2292
+ ];
2147
2293
  var SCENARIO_DATA = {
2148
2294
  1: SCENARIO_1,
2149
2295
  2: SCENARIO_2,
@@ -2152,7 +2298,8 @@ var SCENARIO_DATA = {
2152
2298
  5: SCENARIO_5,
2153
2299
  6: SCENARIO_6,
2154
2300
  7: SCENARIO_7,
2155
- 8: SCENARIO_8
2301
+ 8: SCENARIO_8,
2302
+ 9: SCENARIO_9
2156
2303
  };
2157
2304
  var SCENARIO_3_VIEWS = [
2158
2305
  {
@@ -2174,6 +2321,12 @@ var SCENARIO_3_VIEWS = [
2174
2321
  ]
2175
2322
  }
2176
2323
  ];
2324
+ var SCENARIO_9_COMMANDS = [
2325
+ { name: "Commit Changes", prompt: "Make a commit for the recently completed tickets.", icon: "git-commit-horizontal", color: "#6b7280" },
2326
+ { name: "Run Tests", prompt: "Run the test suite and report any failures.", icon: "test-tubes", color: "#3b82f6" },
2327
+ { name: "Code Review", prompt: "Review the recent changes for code quality and potential issues.", icon: "search-code", color: "#8b5cf6" },
2328
+ { name: "Deploy Staging", prompt: "Deploy the current branch to the staging environment.", icon: "rocket", color: "#f97316" }
2329
+ ];
2177
2330
  async function seedDemoData(scenario) {
2178
2331
  const db2 = await getDb();
2179
2332
  const tickets = SCENARIO_DATA[scenario];
@@ -2210,12 +2363,20 @@ async function seedDemoData(scenario) {
2210
2363
  await backfillSnapshots2();
2211
2364
  await recordDailySnapshot2();
2212
2365
  }
2366
+ if (scenario === 9) {
2367
+ await db2.query(`INSERT INTO settings (key, value) VALUES ('channel_enabled', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'`);
2368
+ await db2.query(
2369
+ `INSERT INTO settings (key, value) VALUES ('custom_commands', $1) ON CONFLICT (key) DO UPDATE SET value = $1`,
2370
+ [JSON.stringify(SCENARIO_9_COMMANDS)]
2371
+ );
2372
+ }
2213
2373
  }
2214
2374
 
2215
2375
  // src/cli.ts
2216
2376
  init_gitignore();
2217
2377
 
2218
2378
  // src/server.ts
2379
+ init_file_settings();
2219
2380
  import { serve } from "@hono/node-server";
2220
2381
  import { exec } from "child_process";
2221
2382
  import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
@@ -2229,9 +2390,10 @@ import { Hono } from "hono";
2229
2390
  import { basename, extname, join as join9, relative as relative2 } from "path";
2230
2391
 
2231
2392
  // src/skills.ts
2393
+ init_file_settings();
2232
2394
  import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
2233
2395
  import { join as join6, relative } from "path";
2234
- var SKILL_VERSION = 3;
2396
+ var SKILL_VERSION = 4;
2235
2397
  var skillPort;
2236
2398
  var skillDataDir;
2237
2399
  var skillCategories = DEFAULT_CATEGORIES;
@@ -2270,7 +2432,10 @@ function updateFile(path, content) {
2270
2432
  return true;
2271
2433
  }
2272
2434
  function ticketSkillBody(skill) {
2273
- return [
2435
+ const settings = readFileSettings(skillDataDir);
2436
+ const secret = settings.secret || "";
2437
+ const secretLine = secret ? ` -H "X-Hotsheet-Secret: ${secret}" \\` : "";
2438
+ const lines = [
2274
2439
  `Create a new Hot Sheet **${skill.label}** ticket. ${skill.description}.`,
2275
2440
  "",
2276
2441
  "**Parsing the input:**",
@@ -2280,16 +2445,25 @@ function ticketSkillBody(skill) {
2280
2445
  "**Create the ticket** by running:",
2281
2446
  "```bash",
2282
2447
  `curl -s -X POST http://localhost:${skillPort}/api/tickets \\`,
2283
- ' -H "Content-Type: application/json" \\',
2448
+ ' -H "Content-Type: application/json" \\'
2449
+ ];
2450
+ if (secretLine) lines.push(secretLine);
2451
+ lines.push(
2284
2452
  ` -d '{"title": "<TITLE>", "defaults": {"category": "${skill.category}", "up_next": <true|false>}}'`,
2285
2453
  "```",
2286
2454
  "",
2455
+ `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.`,
2456
+ "",
2287
2457
  "Report the created ticket number and title to the user."
2288
- ].join("\n");
2458
+ );
2459
+ return lines.join("\n");
2289
2460
  }
2290
2461
  function mainSkillBody() {
2291
2462
  const worklistRel = relative(process.cwd(), join6(skillDataDir, "worklist.md"));
2463
+ const settingsRel = relative(process.cwd(), join6(skillDataDir, "settings.json"));
2292
2464
  return [
2465
+ `Base directory for this skill: ${join6(process.cwd(), ".claude", "skills", "hotsheet")}`,
2466
+ "",
2293
2467
  `Read \`${worklistRel}\` and work through the tickets in priority order.`,
2294
2468
  "",
2295
2469
  "For each ticket:",
@@ -2297,7 +2471,9 @@ function mainSkillBody() {
2297
2471
  "2. Implement the work described",
2298
2472
  "3. When complete, mark it done via the Hot Sheet UI",
2299
2473
  "",
2300
- "Work through them in order of priority, where reasonable."
2474
+ "Work through them in order of priority, where reasonable.",
2475
+ "",
2476
+ `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.`
2301
2477
  ].join("\n");
2302
2478
  }
2303
2479
  var HOTSHEET_ALLOW_PATTERNS = [
@@ -2478,6 +2654,7 @@ function consumeSkillsCreatedFlag() {
2478
2654
  // src/sync/markdown.ts
2479
2655
  import { writeFileSync as writeFileSync5 } from "fs";
2480
2656
  import { join as join7 } from "path";
2657
+ init_file_settings();
2481
2658
  var dataDir;
2482
2659
  var port;
2483
2660
  var worklistTimeout = null;
@@ -2514,7 +2691,7 @@ function parseTicketNotes(raw) {
2514
2691
  if (raw.trim()) return [{ text: raw, created_at: "" }];
2515
2692
  return [];
2516
2693
  }
2517
- async function formatTicket(ticket) {
2694
+ async function formatTicket(ticket, autoContext) {
2518
2695
  const attachments = await getAttachments(ticket.id);
2519
2696
  const lines = [];
2520
2697
  lines.push(`TICKET ${ticket.ticket_number}:`);
@@ -2523,15 +2700,24 @@ async function formatTicket(ticket) {
2523
2700
  lines.push(`- Priority: ${ticket.priority}`);
2524
2701
  lines.push(`- Status: ${ticket.status.replace("_", " ")}`);
2525
2702
  lines.push(`- Title: ${ticket.title}`);
2703
+ let ticketTags = [];
2526
2704
  try {
2527
2705
  const tags = JSON.parse(ticket.tags);
2528
2706
  if (Array.isArray(tags) && tags.length > 0) {
2529
- lines.push(`- Tags: ${tags.join(", ")}`);
2707
+ ticketTags = tags;
2708
+ const display = tags.map((t) => t.replace(/\b\w/g, (c) => c.toUpperCase()));
2709
+ lines.push(`- Tags: ${display.join(", ")}`);
2530
2710
  }
2531
2711
  } catch {
2532
2712
  }
2533
- if (ticket.details.trim()) {
2534
- const detailLines = ticket.details.split("\n");
2713
+ const contextParts = [];
2714
+ const catContext = autoContext.find((ac) => ac.type === "category" && ac.key === ticket.category);
2715
+ if (catContext) contextParts.push(catContext.text);
2716
+ const tagContexts = autoContext.filter((ac) => ac.type === "tag" && ticketTags.some((t) => t.toLowerCase() === ac.key.toLowerCase())).sort((a, b) => a.key.localeCompare(b.key));
2717
+ for (const tc of tagContexts) contextParts.push(tc.text);
2718
+ const fullDetails = contextParts.length > 0 ? contextParts.join("\n\n") + (ticket.details.trim() ? "\n\n" + ticket.details : "") : ticket.details;
2719
+ if (fullDetails.trim()) {
2720
+ const detailLines = fullDetails.split("\n");
2535
2721
  lines.push(`- Details: ${detailLines[0]}`);
2536
2722
  for (let i = 1; i < detailLines.length; i++) {
2537
2723
  lines.push(` ${detailLines[i]}`);
@@ -2553,6 +2739,17 @@ async function formatTicket(ticket) {
2553
2739
  }
2554
2740
  return lines.join("\n");
2555
2741
  }
2742
+ async function loadAutoContext() {
2743
+ try {
2744
+ const settings = await getSettings();
2745
+ if (settings.auto_context) {
2746
+ const parsed = JSON.parse(settings.auto_context);
2747
+ if (Array.isArray(parsed)) return parsed;
2748
+ }
2749
+ } catch {
2750
+ }
2751
+ return [];
2752
+ }
2556
2753
  async function formatCategoryDescriptions(usedCategories) {
2557
2754
  const allCategories = await getCategories();
2558
2755
  const descMap = Object.fromEntries(allCategories.map((c) => [c.id, c.description]));
@@ -2573,18 +2770,21 @@ async function syncWorklist() {
2573
2770
  sections.push("");
2574
2771
  sections.push("## Workflow");
2575
2772
  sections.push("");
2773
+ const settings = readFileSettings(dataDir);
2774
+ const secret = settings.secret || "";
2775
+ const secretHeader = secret ? ` -H "X-Hotsheet-Secret: ${secret}"` : "";
2576
2776
  sections.push(`The Hot Sheet API is available at http://localhost:${port}/api. **You MUST update ticket status** as you work \u2014 this is required, not optional.`);
2577
2777
  sections.push("");
2578
2778
  sections.push('- **BEFORE starting work on a ticket**, set its status to "started":');
2579
- sections.push(` \`curl -s -X PATCH http://localhost:${port}/api/tickets/{id} -H "Content-Type: application/json" -d '{"status": "started"}'\``);
2779
+ sections.push(` \`curl -s -X PATCH http://localhost:${port}/api/tickets/{id} -H "Content-Type: application/json"${secretHeader} -d '{"status": "started"}'\``);
2580
2780
  sections.push("");
2581
2781
  sections.push('- **AFTER completing work on a ticket**, set its status to "completed" and **include notes** describing what was done:');
2582
- sections.push(` \`curl -s -X PATCH http://localhost:${port}/api/tickets/{id} -H "Content-Type: application/json" -d '{"status": "completed", "notes": "Describe the specific changes made"}'\``);
2782
+ sections.push(` \`curl -s -X PATCH http://localhost:${port}/api/tickets/{id} -H "Content-Type: application/json"${secretHeader} -d '{"status": "completed", "notes": "Describe the specific changes made"}'\``);
2583
2783
  sections.push("");
2584
2784
  sections.push("**IMPORTANT:**");
2585
2785
  sections.push('- Update status for EVERY ticket \u2014 "started" when you begin, "completed" when you finish.');
2586
2786
  sections.push('- The "notes" field is REQUIRED when completing a ticket. Describe the specific work done.');
2587
- sections.push("- If an API call fails (e.g. connection refused, error response), log a visible warning to the user and continue your work. Do NOT silently skip status updates.");
2787
+ sections.push("- If an API call fails (e.g. connection refused, 403 secret mismatch, or error response), **re-read `.hotsheet/settings.json`** to get the correct `port` and `secret` values \u2014 you may be connecting to the wrong Hot Sheet instance. Log a visible warning to the user and continue your work. Do NOT silently skip status updates.");
2588
2788
  sections.push('- Do NOT set tickets to "verified" \u2014 that status is reserved for human review.');
2589
2789
  sections.push("");
2590
2790
  sections.push("## Creating Tickets");
@@ -2598,7 +2798,7 @@ async function syncWorklist() {
2598
2798
  sections.push("To create a ticket:");
2599
2799
  const allCats = await getCategories();
2600
2800
  const catIds = allCats.map((c) => c.id).join("|");
2601
- sections.push(` \`curl -s -X POST http://localhost:${port}/api/tickets -H "Content-Type: application/json" -d '{"title": "Title", "defaults": {"category": "${catIds}", "up_next": false}}'\``);
2801
+ sections.push(` \`curl -s -X POST http://localhost:${port}/api/tickets -H "Content-Type: application/json"${secretHeader} -d '{"title": "Title", "defaults": {"category": "${catIds}", "up_next": false}}'\``);
2602
2802
  sections.push("");
2603
2803
  sections.push('You can also include `"details"` in the defaults object for longer descriptions.');
2604
2804
  sections.push("Set `up_next: true` only for items that should be prioritized immediately.");
@@ -2606,11 +2806,12 @@ async function syncWorklist() {
2606
2806
  if (tickets.length === 0) {
2607
2807
  sections.push("No items in the Up Next list.");
2608
2808
  } else {
2809
+ const autoContext = await loadAutoContext();
2609
2810
  for (const ticket of tickets) {
2610
2811
  categories.add(ticket.category);
2611
2812
  sections.push("---");
2612
2813
  sections.push("");
2613
- const formatted = await formatTicket(ticket);
2814
+ const formatted = await formatTicket(ticket, autoContext);
2614
2815
  sections.push(formatted);
2615
2816
  sections.push("");
2616
2817
  }
@@ -2635,12 +2836,13 @@ async function syncOpenTickets() {
2635
2836
  sections.push("");
2636
2837
  const started = tickets.filter((t) => t.status === "started");
2637
2838
  const notStarted = tickets.filter((t) => t.status === "not_started");
2839
+ const autoContext = await loadAutoContext();
2638
2840
  if (started.length > 0) {
2639
2841
  sections.push(`## Started (${started.length})`);
2640
2842
  sections.push("");
2641
2843
  for (const ticket of started) {
2642
2844
  categories.add(ticket.category);
2643
- const formatted = await formatTicket(ticket);
2845
+ const formatted = await formatTicket(ticket, autoContext);
2644
2846
  sections.push(formatted);
2645
2847
  sections.push("");
2646
2848
  }
@@ -2650,7 +2852,7 @@ async function syncOpenTickets() {
2650
2852
  sections.push("");
2651
2853
  for (const ticket of notStarted) {
2652
2854
  categories.add(ticket.category);
2653
- const formatted = await formatTicket(ticket);
2855
+ const formatted = await formatTicket(ticket, autoContext);
2654
2856
  sections.push(formatted);
2655
2857
  sections.push("");
2656
2858
  }
@@ -2677,8 +2879,8 @@ function notifyChange() {
2677
2879
  changeVersion++;
2678
2880
  const waiters = pollWaiters;
2679
2881
  pollWaiters = [];
2680
- for (const resolve3 of waiters) {
2681
- resolve3(changeVersion);
2882
+ for (const resolve4 of waiters) {
2883
+ resolve4(changeVersion);
2682
2884
  }
2683
2885
  }
2684
2886
  apiRoutes.get("/poll", async (c) => {
@@ -2687,11 +2889,11 @@ apiRoutes.get("/poll", async (c) => {
2687
2889
  return c.json({ version: changeVersion });
2688
2890
  }
2689
2891
  const version = await Promise.race([
2690
- new Promise((resolve3) => {
2691
- pollWaiters.push(resolve3);
2892
+ new Promise((resolve4) => {
2893
+ pollWaiters.push(resolve4);
2692
2894
  }),
2693
- new Promise((resolve3) => {
2694
- setTimeout(() => resolve3(changeVersion), 3e4);
2895
+ new Promise((resolve4) => {
2896
+ setTimeout(() => resolve4(changeVersion), 3e4);
2695
2897
  })
2696
2898
  ]);
2697
2899
  return c.json({ version });
@@ -2717,7 +2919,24 @@ apiRoutes.get("/tickets", async (c) => {
2717
2919
  });
2718
2920
  apiRoutes.post("/tickets", async (c) => {
2719
2921
  const body = await c.req.json();
2720
- const ticket = await createTicket(body.title || "", body.defaults);
2922
+ let title = body.title || "";
2923
+ const defaults = body.defaults || {};
2924
+ const { title: cleanTitle, tags: bracketTags } = extractBracketTags(title);
2925
+ if (bracketTags.length > 0) {
2926
+ title = cleanTitle || title;
2927
+ let existingTags = [];
2928
+ if (defaults.tags) {
2929
+ try {
2930
+ existingTags = JSON.parse(defaults.tags);
2931
+ } catch {
2932
+ }
2933
+ }
2934
+ for (const tag of bracketTags) {
2935
+ if (!existingTags.some((t) => t.toLowerCase() === tag.toLowerCase())) existingTags.push(tag);
2936
+ }
2937
+ defaults.tags = JSON.stringify(existingTags);
2938
+ }
2939
+ const ticket = await createTicket(title, defaults);
2721
2940
  scheduleAllSync();
2722
2941
  notifyChange();
2723
2942
  return c.json(ticket, 201);
@@ -2939,7 +3158,7 @@ apiRoutes.get("/attachments/file/*", async (c) => {
2939
3158
  });
2940
3159
  apiRoutes.post("/tickets/query", async (c) => {
2941
3160
  const body = await c.req.json();
2942
- const tickets = await queryTickets(body.logic, body.conditions, body.sort_by, body.sort_dir);
3161
+ const tickets = await queryTickets(body.logic, body.conditions, body.sort_by, body.sort_dir, body.required_tag);
2943
3162
  return c.json(tickets);
2944
3163
  });
2945
3164
  apiRoutes.get("/tags", async (c) => {
@@ -3708,6 +3927,16 @@ pageRoutes.get("/", (c) => {
3708
3927
  ] }),
3709
3928
  /* @__PURE__ */ jsx("span", { children: "Backups" })
3710
3929
  ] }),
3930
+ /* @__PURE__ */ jsx("button", { className: "settings-tab", "data-tab": "context", children: [
3931
+ /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3932
+ /* @__PURE__ */ jsx("path", { d: "M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" }),
3933
+ /* @__PURE__ */ jsx("polyline", { points: "14 2 14 8 20 8" }),
3934
+ /* @__PURE__ */ jsx("line", { x1: "16", x2: "8", y1: "13", y2: "13" }),
3935
+ /* @__PURE__ */ jsx("line", { x1: "16", x2: "8", y1: "17", y2: "17" }),
3936
+ /* @__PURE__ */ jsx("line", { x1: "10", x2: "8", y1: "9", y2: "9" })
3937
+ ] }),
3938
+ /* @__PURE__ */ jsx("span", { children: "Context" })
3939
+ ] }),
3711
3940
  /* @__PURE__ */ jsx("button", { className: "settings-tab", "data-tab": "experimental", id: "settings-tab-experimental", style: "display:none", children: [
3712
3941
  /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3713
3942
  /* @__PURE__ */ jsx("path", { d: "M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2" }),
@@ -3739,6 +3968,22 @@ pageRoutes.get("/", (c) => {
3739
3968
  /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
3740
3969
  /* @__PURE__ */ jsx("label", { children: "Auto-clear verified after (days)" }),
3741
3970
  /* @__PURE__ */ jsx("input", { type: "number", id: "settings-verified-days", min: "1", value: "30" })
3971
+ ] }),
3972
+ /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
3973
+ /* @__PURE__ */ jsx("label", { children: "When Claude needs permission" }),
3974
+ /* @__PURE__ */ jsx("select", { id: "settings-notify-permission", children: [
3975
+ /* @__PURE__ */ jsx("option", { value: "none", children: "Don't notify" }),
3976
+ /* @__PURE__ */ jsx("option", { value: "once", children: "Notify once" }),
3977
+ /* @__PURE__ */ jsx("option", { value: "persistent", selected: true, children: "Notify until focused" })
3978
+ ] })
3979
+ ] }),
3980
+ /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
3981
+ /* @__PURE__ */ jsx("label", { children: "When Claude finishes work" }),
3982
+ /* @__PURE__ */ jsx("select", { id: "settings-notify-completed", children: [
3983
+ /* @__PURE__ */ jsx("option", { value: "none", children: "Don't notify" }),
3984
+ /* @__PURE__ */ jsx("option", { value: "once", selected: true, children: "Notify once" }),
3985
+ /* @__PURE__ */ jsx("option", { value: "persistent", children: "Notify until focused" })
3986
+ ] })
3742
3987
  ] })
3743
3988
  ] }),
3744
3989
  /* @__PURE__ */ jsx("div", { className: "settings-tab-panel", "data-panel": "categories", children: [
@@ -3761,6 +4006,14 @@ pageRoutes.get("/", (c) => {
3761
4006
  ] }),
3762
4007
  /* @__PURE__ */ jsx("div", { id: "backup-list", className: "backup-list", children: "Loading backups..." })
3763
4008
  ] }),
4009
+ /* @__PURE__ */ jsx("div", { className: "settings-tab-panel", "data-panel": "context", children: [
4010
+ /* @__PURE__ */ jsx("div", { className: "settings-section-header", children: [
4011
+ /* @__PURE__ */ jsx("h3", { children: "Auto-Context" }),
4012
+ /* @__PURE__ */ jsx("button", { className: "btn btn-sm", id: "auto-context-add-btn", children: "+ Add" })
4013
+ ] }),
4014
+ /* @__PURE__ */ jsx("span", { className: "settings-hint", style: "margin-bottom:12px;display:block", children: "Automatically prepend instructions to ticket details in the worklist, based on category or tag. Category context appears first, then tag context in alphabetical order." }),
4015
+ /* @__PURE__ */ jsx("div", { id: "auto-context-list" })
4016
+ ] }),
3764
4017
  /* @__PURE__ */ jsx("div", { className: "settings-tab-panel", "data-panel": "experimental", id: "settings-experimental-panel", style: "display:none", children: [
3765
4018
  /* @__PURE__ */ jsx("div", { className: "settings-field", children: [
3766
4019
  /* @__PURE__ */ jsx("label", { className: "settings-checkbox-label", children: [
@@ -3809,10 +4062,10 @@ pageRoutes.get("/", (c) => {
3809
4062
 
3810
4063
  // src/server.ts
3811
4064
  function tryServe(fetch2, port2) {
3812
- return new Promise((resolve3, reject) => {
4065
+ return new Promise((resolve4, reject) => {
3813
4066
  const server = serve({ fetch: fetch2, port: port2 });
3814
4067
  server.on("listening", () => {
3815
- resolve3(port2);
4068
+ resolve4(port2);
3816
4069
  });
3817
4070
  server.on("error", (err) => {
3818
4071
  reject(err);
@@ -3844,6 +4097,19 @@ async function startServer(port2, dataDir2, options) {
3844
4097
  const mimeTypes = { png: "image/png", jpg: "image/jpeg", svg: "image/svg+xml" };
3845
4098
  return new Response(content, { headers: { "Content-Type": mimeTypes[ext || ""] || "application/octet-stream", "Cache-Control": "max-age=86400" } });
3846
4099
  });
4100
+ app.use("/api/*", async (c, next) => {
4101
+ const headerSecret = c.req.header("X-Hotsheet-Secret");
4102
+ if (headerSecret) {
4103
+ const settings = readFileSettings(dataDir2);
4104
+ if (settings.secret && headerSecret !== settings.secret) {
4105
+ return c.json({
4106
+ error: "Secret mismatch \u2014 you may be connecting to the wrong Hot Sheet instance.",
4107
+ recovery: "Re-read .hotsheet/settings.json to get the correct port and secret, and re-read your skill files (e.g. .claude/skills/hotsheet/SKILL.md) for updated instructions."
4108
+ }, 403);
4109
+ }
4110
+ }
4111
+ await next();
4112
+ });
3847
4113
  app.route("/api", apiRoutes);
3848
4114
  app.route("/api/backups", backupRoutes);
3849
4115
  app.route("/", pageRoutes);
@@ -3920,10 +4186,10 @@ function isFirstUseToday() {
3920
4186
  return last !== today;
3921
4187
  }
3922
4188
  function fetchLatestVersion() {
3923
- return new Promise((resolve3) => {
4189
+ return new Promise((resolve4) => {
3924
4190
  const req = get(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { timeout: 5e3 }, (res) => {
3925
4191
  if (res.statusCode !== 200) {
3926
- resolve3(null);
4192
+ resolve4(null);
3927
4193
  return;
3928
4194
  }
3929
4195
  let data = "";
@@ -3932,18 +4198,18 @@ function fetchLatestVersion() {
3932
4198
  });
3933
4199
  res.on("end", () => {
3934
4200
  try {
3935
- resolve3(JSON.parse(data).version);
4201
+ resolve4(JSON.parse(data).version);
3936
4202
  } catch {
3937
- resolve3(null);
4203
+ resolve4(null);
3938
4204
  }
3939
4205
  });
3940
4206
  });
3941
4207
  req.on("error", () => {
3942
- resolve3(null);
4208
+ resolve4(null);
3943
4209
  });
3944
4210
  req.on("timeout", () => {
3945
4211
  req.destroy();
3946
- resolve3(null);
4212
+ resolve4(null);
3947
4213
  });
3948
4214
  });
3949
4215
  }
@@ -4045,7 +4311,7 @@ function parseArgs(argv) {
4045
4311
  }
4046
4312
  break;
4047
4313
  case "--data-dir":
4048
- dataDir2 = resolve2(args[++i]);
4314
+ dataDir2 = resolve3(args[++i]);
4049
4315
  break;
4050
4316
  case "--check-for-updates":
4051
4317
  forceUpdateCheck = true;
@@ -4103,6 +4369,7 @@ async function main() {
4103
4369
  }
4104
4370
  console.log(` Data directory: ${dataDir2}`);
4105
4371
  const actualPort = await startServer(port2, dataDir2, { noOpen, strictPort });
4372
+ ensureSecret(dataDir2, actualPort);
4106
4373
  initMarkdownSync(dataDir2, actualPort);
4107
4374
  scheduleAllSync();
4108
4375
  initSkills(actualPort, dataDir2);