@workscribe/cli 1.11.0 → 1.13.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/index.js CHANGED
@@ -27,37 +27,64 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
27
  var import_commander = require("commander");
28
28
 
29
29
  // src/utils/node-version.ts
30
+ function detectVersionManager() {
31
+ if (process.env.NVM_DIR) return "nvm";
32
+ if (process.env.VOLTA_HOME) return "volta";
33
+ if (process.env.FNM_DIR) return "fnm";
34
+ if (process.env.MISE_DATA_DIR || process.env.MISE_SHELL) return "mise";
35
+ if (process.env.ASDF_DIR || process.env.ASDF_DATA_DIR) return "asdf";
36
+ if (process.env.NODENV_ROOT) return "nodenv";
37
+ return null;
38
+ }
39
+ var UPGRADE_COMMANDS = {
40
+ nvm: "nvm install 22 && nvm use 22",
41
+ volta: "volta install node@22",
42
+ fnm: "fnm install 22 && fnm use 22",
43
+ mise: "mise use --global node@22",
44
+ asdf: "asdf install nodejs 22 && asdf global nodejs 22",
45
+ nodenv: "nodenv install 22 && nodenv global 22"
46
+ };
30
47
  function assertNodeVersion(nodeVersion) {
31
48
  const major = Number(nodeVersion.split(".")[0]);
32
49
  if (major < 22) {
33
- process.stderr.write(
34
- `workscribe requires Node.js 22 or later (you have v${nodeVersion}).
35
- Upgrade at https://nodejs.org or via your version manager:
36
- nvm install 22 && nvm use 22
37
- `
38
- );
50
+ const manager = detectVersionManager();
51
+ const upgradeHint = manager ? `Upgrade with ${manager}:
52
+ ${UPGRADE_COMMANDS[manager]}` : "Download the latest version from https://nodejs.org";
53
+ process.stderr.write(`workscribe requires Node.js 22 or later (you have v${nodeVersion}).
54
+
55
+ ${upgradeHint}
56
+ `);
39
57
  process.exit(1);
40
58
  }
41
59
  }
42
60
 
43
61
  // src/capture/index.ts
44
- var import_fs9 = __toESM(require("fs"));
62
+ var import_fs10 = __toESM(require("fs"));
45
63
 
46
64
  // src/db/index.ts
47
65
  var import_node_sqlite = require("node:sqlite");
48
- var import_fs = __toESM(require("fs"));
66
+ var import_fs2 = __toESM(require("fs"));
49
67
 
50
68
  // src/utils/paths.ts
51
69
  var import_path = __toESM(require("path"));
52
70
  var import_os = __toESM(require("os"));
53
- var WORKSCRIBE_DIR = import_path.default.join(import_os.default.homedir(), ".workscribe");
54
- var DB_PATH = import_path.default.join(WORKSCRIBE_DIR, "workscribe.db");
55
- var CONFIG_PATH = import_path.default.join(WORKSCRIBE_DIR, "config.json");
56
- var DEBUG_LOG_PATH = import_path.default.join(WORKSCRIBE_DIR, "debug.log");
57
- var EXPORTS_DIR = import_path.default.join(WORKSCRIBE_DIR, "exports");
58
- var PAUSE_FLAG_PATH = import_path.default.join(WORKSCRIBE_DIR, "paused");
59
- var WEEKLY_CACHE_DIR = import_path.default.join(WORKSCRIBE_DIR, "weekly-cache");
60
- var PROFILES_DIR = import_path.default.join(WORKSCRIBE_DIR, "profiles");
71
+ var import_fs = __toESM(require("fs"));
72
+ var LEGACY_DIR = import_path.default.join(import_os.default.homedir(), ".workscribe");
73
+ var USE_XDG = !import_fs.default.existsSync(LEGACY_DIR);
74
+ var xdgDataDir = USE_XDG ? import_path.default.join(process.env.XDG_DATA_HOME ?? import_path.default.join(import_os.default.homedir(), ".local", "share"), "workscribe") : LEGACY_DIR;
75
+ var xdgConfigDir = USE_XDG ? import_path.default.join(process.env.XDG_CONFIG_HOME ?? import_path.default.join(import_os.default.homedir(), ".config"), "workscribe") : LEGACY_DIR;
76
+ var xdgStateDir = USE_XDG ? import_path.default.join(process.env.XDG_STATE_HOME ?? import_path.default.join(import_os.default.homedir(), ".local", "state"), "workscribe") : LEGACY_DIR;
77
+ var REQUIRED_DIRS = [.../* @__PURE__ */ new Set([xdgDataDir, xdgConfigDir, xdgStateDir, LEGACY_DIR])];
78
+ var WORKSCRIBE_DIR = xdgDataDir;
79
+ var DB_PATH = import_path.default.join(xdgDataDir, "workscribe.db");
80
+ var CONFIG_PATH = import_path.default.join(xdgConfigDir, "config.json");
81
+ var DEBUG_LOG_PATH = import_path.default.join(xdgStateDir, "debug.log");
82
+ var UPDATE_CHECK_PATH = import_path.default.join(xdgStateDir, "update-check.json");
83
+ var EXPORTS_DIR = import_path.default.join(xdgDataDir, "exports");
84
+ var PAUSE_FLAG_PATH = import_path.default.join(xdgStateDir, "paused");
85
+ var WEEKLY_CACHE_DIR = import_path.default.join(xdgDataDir, "weekly-cache");
86
+ var PROFILES_DIR = import_path.default.join(xdgDataDir, "profiles");
87
+ var ALERTS_PENDING_PATH = import_path.default.join(LEGACY_DIR, "alerts.pending");
61
88
 
62
89
  // src/db/migrations/001_initial_schema.ts
63
90
  var initial_schema_default = {
@@ -119,19 +146,49 @@ var migration = {
119
146
  };
120
147
  var semantic_categories_default = migration;
121
148
 
149
+ // src/db/migrations/003_alerts.ts
150
+ var migration2 = {
151
+ version: 3,
152
+ description: "Add alerts table for AI session secret detection",
153
+ sql: `
154
+ CREATE TABLE IF NOT EXISTS alerts (
155
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
156
+ created_at INTEGER NOT NULL,
157
+ tool TEXT NOT NULL,
158
+ session_id TEXT,
159
+ context TEXT NOT NULL,
160
+ cleared INTEGER NOT NULL DEFAULT 0
161
+ );
162
+ CREATE INDEX IF NOT EXISTS alerts_cleared ON alerts (cleared);
163
+ `
164
+ };
165
+ var alerts_default = migration2;
166
+
167
+ // src/db/migrations/004_ide_columns.ts
168
+ var migration3 = {
169
+ version: 4,
170
+ description: "Add source and file_path columns to events for IDE integration",
171
+ sql: `
172
+ ALTER TABLE events ADD COLUMN source TEXT;
173
+ ALTER TABLE events ADD COLUMN file_path TEXT;
174
+ CREATE INDEX IF NOT EXISTS idx_events_file_path ON events (file_path, cwd);
175
+ `
176
+ };
177
+ var ide_columns_default = migration3;
178
+
122
179
  // src/db/migrations/index.ts
123
- var MIGRATIONS = [initial_schema_default, semantic_categories_default];
180
+ var MIGRATIONS = [initial_schema_default, semantic_categories_default, alerts_default, ide_columns_default];
124
181
 
125
182
  // src/db/index.ts
126
183
  var _db = null;
127
184
  function getDb() {
128
185
  if (_db) return _db;
129
- if (!import_fs.default.existsSync(WORKSCRIBE_DIR)) {
130
- import_fs.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true });
186
+ if (!import_fs2.default.existsSync(WORKSCRIBE_DIR)) {
187
+ import_fs2.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true });
131
188
  }
132
189
  const db = new import_node_sqlite.DatabaseSync(DB_PATH);
133
190
  try {
134
- import_fs.default.chmodSync(DB_PATH, 384);
191
+ import_fs2.default.chmodSync(DB_PATH, 384);
135
192
  } catch {
136
193
  }
137
194
  db.exec("PRAGMA journal_mode = WAL");
@@ -157,13 +214,13 @@ function applyMigrations(db) {
157
214
  const applied = new Set(
158
215
  db.prepare("SELECT version FROM schema_migrations").all().map((row) => row.version)
159
216
  );
160
- for (const migration2 of MIGRATIONS) {
161
- if (applied.has(migration2.version)) continue;
217
+ for (const migration4 of MIGRATIONS) {
218
+ if (applied.has(migration4.version)) continue;
162
219
  db.exec("BEGIN");
163
220
  try {
164
- db.exec(migration2.sql);
221
+ db.exec(migration4.sql);
165
222
  db.prepare("INSERT INTO schema_migrations (version, applied_at) VALUES ($version, datetime('now'))").run({
166
- version: migration2.version
223
+ version: migration4.version
167
224
  });
168
225
  db.exec("COMMIT");
169
226
  } catch (err) {
@@ -171,18 +228,48 @@ function applyMigrations(db) {
171
228
  db.exec("ROLLBACK");
172
229
  } catch {
173
230
  }
174
- throw new Error(`Migration ${migration2.version} (${migration2.description}) failed: ${err.message}`);
231
+ throw new Error(`Migration ${migration4.version} (${migration4.description}) failed: ${err.message}`);
175
232
  }
176
233
  }
177
234
  }
178
235
  function insertEvent(params) {
179
236
  const db = getDb();
180
237
  const stmt = db.prepare(`
181
- INSERT INTO events (captured_at, command, cwd, exit_code, duration_ms, repo_name, branch, category, metadata)
182
- VALUES ($captured_at, $command, $cwd, $exit_code, $duration_ms, $repo_name, $branch, $category, $metadata)
238
+ INSERT INTO events (captured_at, command, cwd, exit_code, duration_ms, repo_name, branch, category, metadata, source, file_path)
239
+ VALUES ($captured_at, $command, $cwd, $exit_code, $duration_ms, $repo_name, $branch, $category, $metadata, $source, $file_path)
183
240
  `);
184
241
  stmt.run(params);
185
242
  }
243
+ function getRecentSessionsByRepo(repoName, limit) {
244
+ const db = getDb();
245
+ return db.prepare("SELECT * FROM sessions WHERE repo_name = $repo_name ORDER BY started_at DESC LIMIT $limit").all({ repo_name: repoName, limit });
246
+ }
247
+ function getLastSessionTouchingFile(filePath) {
248
+ const db = getDb();
249
+ return db.prepare(
250
+ `SELECT s.* FROM sessions s
251
+ INNER JOIN session_events se ON se.session_id = s.id
252
+ INNER JOIN events e ON e.id = se.event_id
253
+ WHERE e.file_path = $file_path
254
+ ORDER BY s.started_at DESC
255
+ LIMIT 1`
256
+ ).get({ file_path: filePath });
257
+ }
258
+ function getSessionBranch(sessionId) {
259
+ const db = getDb();
260
+ const row = db.prepare(
261
+ `SELECT e.branch FROM events e
262
+ INNER JOIN session_events se ON se.event_id = e.id
263
+ WHERE se.session_id = $session_id AND e.branch IS NOT NULL
264
+ ORDER BY e.captured_at DESC
265
+ LIMIT 1`
266
+ ).get({ session_id: sessionId });
267
+ return row?.branch ?? null;
268
+ }
269
+ function getLastEventForFile(filePath, cwd) {
270
+ const db = getDb();
271
+ return db.prepare("SELECT * FROM events WHERE file_path = $file_path AND cwd = $cwd ORDER BY id DESC LIMIT 1").get({ file_path: filePath, cwd });
272
+ }
186
273
  function getLastEvent() {
187
274
  const db = getDb();
188
275
  const stmt = db.prepare("SELECT * FROM events ORDER BY id DESC LIMIT 1");
@@ -324,7 +411,10 @@ var BUILTIN_PATTERNS = [
324
411
  /(?<=export\s+(?:[A-Z_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z_]*)=)\S+/gi,
325
412
  // Inline assignment: VAR_WITH_SENSITIVE_KEYWORD=value cmd (e.g. API_KEY=secret npm start)
326
413
  // Lookbehind matches only the variable-name= prefix, so only the value is replaced
327
- /(?<=[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z0-9_]*=)\S+/gi
414
+ /(?<=[A-Z_][A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASS|AUTH|CREDENTIAL)[A-Z0-9_]*=)\S+/gi,
415
+ // Credentials embedded in connection string URLs (e.g. DATABASE_URL=postgres://user:pass@host/db)
416
+ // Matches the password segment between :// user : password @ — preserves scheme and host
417
+ /(?<=[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^:@\s/]+:)[^@\s]+(?=@)/g
328
418
  ];
329
419
  function redact(command, extraPatterns = []) {
330
420
  let result = command;
@@ -345,7 +435,7 @@ function redact(command, extraPatterns = []) {
345
435
  // src/capture/git.ts
346
436
  var import_child_process = require("child_process");
347
437
  var import_path2 = __toESM(require("path"));
348
- var import_fs2 = __toESM(require("fs"));
438
+ var import_fs3 = __toESM(require("fs"));
349
439
  function nameFromRemote(root) {
350
440
  try {
351
441
  const url = (0, import_child_process.execSync)("git remote get-url origin", {
@@ -361,7 +451,7 @@ function nameFromRemote(root) {
361
451
  }
362
452
  function nameFromPackageJson(root) {
363
453
  try {
364
- const raw = import_fs2.default.readFileSync(import_path2.default.join(root, "package.json"), "utf-8");
454
+ const raw = import_fs3.default.readFileSync(import_path2.default.join(root, "package.json"), "utf-8");
365
455
  const pkg = JSON.parse(raw);
366
456
  if (!pkg.name) return null;
367
457
  return pkg.name.replace(/^@[^/]+\//, "");
@@ -405,15 +495,15 @@ function getCommitsSince(sha, cwd) {
405
495
  }
406
496
 
407
497
  // src/capture/aider.ts
408
- var import_fs3 = __toESM(require("fs"));
498
+ var import_fs4 = __toESM(require("fs"));
409
499
  var import_path3 = __toESM(require("path"));
410
500
  var HISTORY_FILE = ".aider.chat.history.md";
411
501
  var PROMPT_MAX_LENGTH = 200;
412
502
  function readLastAiderPrompt(cwd) {
413
503
  try {
414
504
  const histPath = import_path3.default.join(cwd, HISTORY_FILE);
415
- const content = import_fs3.default.readFileSync(histPath, "utf-8");
416
- const matches = [...content.matchAll(/^> (.+)$/gm)];
505
+ const content = import_fs4.default.readFileSync(histPath, "utf-8");
506
+ const matches = [...content.matchAll(/^#### (.+)$/gm)];
417
507
  if (!matches.length) return null;
418
508
  return matches[matches.length - 1][1].trim().slice(0, PROMPT_MAX_LENGTH);
419
509
  } catch {
@@ -422,7 +512,7 @@ function readLastAiderPrompt(cwd) {
422
512
  }
423
513
 
424
514
  // src/config/registry.ts
425
- var import_fs5 = __toESM(require("fs"));
515
+ var import_fs6 = __toESM(require("fs"));
426
516
  var import_os2 = __toESM(require("os"));
427
517
  var import_path4 = __toESM(require("path"));
428
518
 
@@ -433,62 +523,114 @@ var ai_tools_default = [
433
523
  displayName: "Claude Code",
434
524
  pattern: "^claude\\b",
435
525
  hook: "claude-code",
436
- contextSource: "hook"
526
+ contextSource: "hook",
527
+ historyFormat: null,
528
+ securityWarning: null
437
529
  },
438
530
  {
439
531
  tool: "aider",
440
532
  displayName: "Aider",
441
533
  pattern: "^aider\\b",
442
534
  hook: null,
443
- contextSource: "history-file"
535
+ contextSource: "history-file",
536
+ historyFormat: "markdown",
537
+ securityWarning: null
538
+ },
539
+ {
540
+ tool: "codex",
541
+ displayName: "Codex CLI",
542
+ pattern: "^codex\\b",
543
+ hook: null,
544
+ contextSource: "history-file",
545
+ historyFormat: "jsonl",
546
+ securityWarning: null
444
547
  },
445
548
  {
446
549
  tool: "gemini",
447
- displayName: "Gemini",
550
+ displayName: "Gemini CLI",
448
551
  pattern: "^gemini\\b",
449
552
  hook: null,
450
- contextSource: null
553
+ contextSource: "history-file",
554
+ historyFormat: "json",
555
+ securityWarning: null
451
556
  },
452
557
  {
453
558
  tool: "gh copilot",
454
559
  displayName: "GitHub Copilot",
455
560
  pattern: "^gh\\s+copilot\\b",
456
561
  hook: null,
457
- contextSource: null
458
- },
459
- {
460
- tool: "cody",
461
- displayName: "Cody",
462
- pattern: "^cody\\b",
463
- hook: null,
464
- contextSource: null
562
+ contextSource: "history-file",
563
+ historyFormat: "jsonl",
564
+ securityWarning: null
465
565
  },
466
566
  {
467
567
  tool: "amazon-q",
468
568
  displayName: "Amazon Q",
469
569
  pattern: "^q\\s+(chat|dev)\\b",
470
570
  hook: null,
471
- contextSource: null
571
+ contextSource: "history-file",
572
+ historyFormat: "json",
573
+ securityWarning: null
574
+ },
575
+ {
576
+ tool: "opencode",
577
+ displayName: "OpenCode",
578
+ pattern: "^opencode\\b",
579
+ hook: null,
580
+ contextSource: "sqlite",
581
+ historyFormat: "sqlite",
582
+ securityWarning: null
583
+ },
584
+ {
585
+ tool: "kilo",
586
+ displayName: "Kilo Code",
587
+ pattern: "^kilo\\b",
588
+ hook: null,
589
+ contextSource: null,
590
+ historyFormat: null,
591
+ securityWarning: null
592
+ },
593
+ {
594
+ tool: "grok",
595
+ displayName: "Grok Build",
596
+ pattern: "^grok\\b",
597
+ hook: null,
598
+ contextSource: null,
599
+ historyFormat: null,
600
+ securityWarning: "Grok Build was found to silently upload entire local git repositories \u2014 including .env files and untracked secrets \u2014 to Google Cloud Storage without user disclosure (July 2026 incident). Workscribe does not read Grok Build session files. Consider whether this tool is appropriate for use in repositories containing sensitive credentials."
472
601
  },
473
602
  {
474
603
  tool: "continue",
475
604
  displayName: "Continue",
476
605
  pattern: "^continue\\b",
477
606
  hook: null,
478
- contextSource: null
607
+ contextSource: null,
608
+ historyFormat: null,
609
+ securityWarning: null
610
+ },
611
+ {
612
+ tool: "cody",
613
+ displayName: "Cody",
614
+ pattern: "^cody\\b",
615
+ hook: null,
616
+ contextSource: null,
617
+ historyFormat: null,
618
+ securityWarning: null
479
619
  },
480
620
  {
481
621
  tool: "cursor",
482
622
  displayName: "Cursor",
483
623
  pattern: "^cursor\\b",
484
624
  hook: null,
485
- contextSource: null
625
+ contextSource: null,
626
+ historyFormat: null,
627
+ securityWarning: null
486
628
  }
487
629
  ];
488
630
 
489
631
  // src/utils/logger.ts
490
632
  var import_chalk = __toESM(require("chalk"));
491
- var import_fs4 = __toESM(require("fs"));
633
+ var import_fs5 = __toESM(require("fs"));
492
634
  var isDebug = process.env.WORKSCRIBE_DEBUG === "1";
493
635
  var log = {
494
636
  info: (msg) => console.log(import_chalk.default.cyan("\u2192"), msg),
@@ -502,7 +644,7 @@ function debugLog(context, error) {
502
644
  const entry = `[${(/* @__PURE__ */ new Date()).toISOString()}] [${context}] ${error instanceof Error ? error.stack : String(error)}
503
645
  `;
504
646
  try {
505
- import_fs4.default.appendFileSync(DEBUG_LOG_PATH, entry, { mode: 384 });
647
+ import_fs5.default.appendFileSync(DEBUG_LOG_PATH, entry, { mode: 384 });
506
648
  } catch {
507
649
  }
508
650
  }
@@ -512,8 +654,8 @@ var USER_REGISTRY_PATH = import_path4.default.join(import_os2.default.homedir(),
512
654
  function loadAIToolRegistry() {
513
655
  let userEntries = [];
514
656
  try {
515
- if (import_fs5.default.existsSync(USER_REGISTRY_PATH)) {
516
- const raw = import_fs5.default.readFileSync(USER_REGISTRY_PATH, "utf-8");
657
+ if (import_fs6.default.existsSync(USER_REGISTRY_PATH)) {
658
+ const raw = import_fs6.default.readFileSync(USER_REGISTRY_PATH, "utf-8");
517
659
  const parsed = JSON.parse(raw);
518
660
  if (Array.isArray(parsed)) {
519
661
  userEntries = parsed.filter((entry) => typeof entry.tool === "string" && typeof entry.pattern === "string").map((entry) => ({
@@ -521,7 +663,9 @@ function loadAIToolRegistry() {
521
663
  displayName: entry.displayName ?? entry.tool,
522
664
  pattern: entry.pattern,
523
665
  hook: entry.hook ?? null,
524
- contextSource: entry.contextSource ?? null
666
+ contextSource: entry.contextSource ?? null,
667
+ historyFormat: entry.historyFormat ?? null,
668
+ securityWarning: entry.securityWarning ?? null
525
669
  }));
526
670
  }
527
671
  }
@@ -535,11 +679,11 @@ function loadAIToolRegistry() {
535
679
  }
536
680
 
537
681
  // src/profiles/loader.ts
538
- var import_fs8 = __toESM(require("fs"));
682
+ var import_fs9 = __toESM(require("fs"));
539
683
  var import_path6 = __toESM(require("path"));
540
684
 
541
685
  // src/config/index.ts
542
- var import_fs6 = __toESM(require("fs"));
686
+ var import_fs7 = __toESM(require("fs"));
543
687
  var DEFAULTS = {
544
688
  ai: {
545
689
  provider: "",
@@ -564,9 +708,9 @@ var DEFAULTS = {
564
708
  }
565
709
  };
566
710
  function readConfig() {
567
- if (!import_fs6.default.existsSync(CONFIG_PATH)) return { ...DEFAULTS };
711
+ if (!import_fs7.default.existsSync(CONFIG_PATH)) return { ...DEFAULTS };
568
712
  try {
569
- const raw = import_fs6.default.readFileSync(CONFIG_PATH, "utf-8");
713
+ const raw = import_fs7.default.readFileSync(CONFIG_PATH, "utf-8");
570
714
  const parsed = JSON.parse(raw);
571
715
  return deepMerge(DEFAULTS, parsed);
572
716
  } catch {
@@ -574,9 +718,9 @@ function readConfig() {
574
718
  }
575
719
  }
576
720
  function writeConfig(config) {
577
- import_fs6.default.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
721
+ import_fs7.default.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 384 });
578
722
  try {
579
- import_fs6.default.chmodSync(CONFIG_PATH, 384);
723
+ import_fs7.default.chmodSync(CONFIG_PATH, 384);
580
724
  } catch {
581
725
  }
582
726
  }
@@ -641,21 +785,57 @@ function isValidManifest(m) {
641
785
  }
642
786
 
643
787
  // src/profiles/fetcher.ts
644
- var import_fs7 = __toESM(require("fs"));
788
+ var import_fs8 = __toESM(require("fs"));
645
789
  var import_path5 = __toESM(require("path"));
790
+
791
+ // src/utils/fetch.ts
792
+ var DEFAULT_TIMEOUT_MS = 3e4;
793
+ var NETWORK_ERROR_CODES = /* @__PURE__ */ new Set([
794
+ "ENOTFOUND",
795
+ "ECONNREFUSED",
796
+ "ECONNRESET",
797
+ "ETIMEDOUT",
798
+ "EHOSTUNREACH",
799
+ "ENETUNREACH"
800
+ ]);
801
+ function isNetworkError(err) {
802
+ if (!(err instanceof Error)) return false;
803
+ const cause = err.cause;
804
+ const code = err.code ?? (cause instanceof Error ? cause.code : void 0);
805
+ return NETWORK_ERROR_CODES.has(code ?? "");
806
+ }
807
+ async function fetchWithTimeout(url, init, timeoutMs = DEFAULT_TIMEOUT_MS) {
808
+ const controller = new AbortController();
809
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
810
+ try {
811
+ return await fetch(url, { ...init, signal: controller.signal });
812
+ } catch (err) {
813
+ if (err.name === "AbortError") {
814
+ throw new Error(`Request timed out after ${timeoutMs / 1e3}s`);
815
+ }
816
+ if (isNetworkError(err)) {
817
+ throw new Error("No internet connection \u2014 try again when you're online");
818
+ }
819
+ throw err;
820
+ } finally {
821
+ clearTimeout(timer);
822
+ }
823
+ }
824
+
825
+ // src/profiles/fetcher.ts
646
826
  var REGISTRY_BASE = "https://raw.githubusercontent.com/workscribe/profiles/main";
647
827
  function manifestCachePath() {
648
828
  return import_path5.default.join(PROFILES_DIR, "manifest.json");
649
829
  }
650
830
  async function fetchManifest() {
651
- const response = await fetch(`${REGISTRY_BASE}/manifest.json`);
831
+ const response = await fetchWithTimeout(`${REGISTRY_BASE}/manifest.json`, {});
652
832
  if (!response.ok) throw new Error(`Registry unreachable (${response.status})`);
653
833
  const data = await response.json();
654
834
  if (!isValidManifest(data)) throw new Error("Manifest format unrecognised");
655
835
  return data;
656
836
  }
657
837
  async function fetchProfile(name) {
658
- const response = await fetch(`${REGISTRY_BASE}/${name}.json`);
838
+ const response = await fetchWithTimeout(`${REGISTRY_BASE}/${name}.json`, {});
659
839
  if (response.status === 404) throw new Error(`Profile "${name}" not found in registry`);
660
840
  if (!response.ok) throw new Error(`Registry unreachable (${response.status})`);
661
841
  const data = await response.json();
@@ -666,27 +846,27 @@ async function installProfile(name) {
666
846
  ensureProfilesDir();
667
847
  const profile = await fetchProfile(name);
668
848
  const dest = import_path5.default.join(PROFILES_DIR, `${name}.json`);
669
- import_fs7.default.writeFileSync(dest, JSON.stringify(profile, null, 2), { encoding: "utf-8", mode: 384 });
849
+ import_fs8.default.writeFileSync(dest, JSON.stringify(profile, null, 2), { encoding: "utf-8", mode: 384 });
670
850
  debugLog("profiles", `Installed profile "${name}" to ${dest}`);
671
851
  }
672
852
  async function cacheManifest() {
673
853
  ensureProfilesDir();
674
854
  const manifest = await fetchManifest();
675
- import_fs7.default.writeFileSync(manifestCachePath(), JSON.stringify(manifest, null, 2), { encoding: "utf-8", mode: 384 });
855
+ import_fs8.default.writeFileSync(manifestCachePath(), JSON.stringify(manifest, null, 2), { encoding: "utf-8", mode: 384 });
676
856
  return manifest;
677
857
  }
678
858
  function readCachedManifest() {
679
- if (!import_fs7.default.existsSync(manifestCachePath())) return null;
859
+ if (!import_fs8.default.existsSync(manifestCachePath())) return null;
680
860
  try {
681
- const data = JSON.parse(import_fs7.default.readFileSync(manifestCachePath(), "utf-8"));
861
+ const data = JSON.parse(import_fs8.default.readFileSync(manifestCachePath(), "utf-8"));
682
862
  return isValidManifest(data) ? data : null;
683
863
  } catch {
684
864
  return null;
685
865
  }
686
866
  }
687
867
  function ensureProfilesDir() {
688
- if (!import_fs7.default.existsSync(PROFILES_DIR)) {
689
- import_fs7.default.mkdirSync(PROFILES_DIR, { recursive: true, mode: 448 });
868
+ if (!import_fs8.default.existsSync(PROFILES_DIR)) {
869
+ import_fs8.default.mkdirSync(PROFILES_DIR, { recursive: true, mode: 448 });
690
870
  }
691
871
  }
692
872
 
@@ -713,13 +893,13 @@ function compileRule(profileName, rule) {
713
893
  }
714
894
  function loadProfileFromDisk(name) {
715
895
  const filePath = import_path6.default.join(PROFILES_DIR, `${name}.json`);
716
- if (!import_fs8.default.existsSync(filePath)) {
896
+ if (!import_fs9.default.existsSync(filePath)) {
717
897
  debugLog("profiles", `Profile "${name}" not found in cache \u2014 run: workscribe profile add ${name}`);
718
898
  return null;
719
899
  }
720
900
  let candidate;
721
901
  try {
722
- candidate = JSON.parse(import_fs8.default.readFileSync(filePath, "utf-8"));
902
+ candidate = JSON.parse(import_fs9.default.readFileSync(filePath, "utf-8"));
723
903
  } catch {
724
904
  debugLog("profiles", `Profile "${name}" contains invalid JSON \u2014 skipping`);
725
905
  return null;
@@ -751,8 +931,8 @@ function getActiveProfileAiContexts() {
751
931
  });
752
932
  }
753
933
  function getCachedProfiles() {
754
- if (!import_fs8.default.existsSync(PROFILES_DIR)) return [];
755
- return import_fs8.default.readdirSync(PROFILES_DIR).filter((file) => file.endsWith(".json") && file !== "manifest.json").flatMap((file) => {
934
+ if (!import_fs9.default.existsSync(PROFILES_DIR)) return [];
935
+ return import_fs9.default.readdirSync(PROFILES_DIR).filter((file) => file.endsWith(".json") && file !== "manifest.json").flatMap((file) => {
756
936
  const profile = loadProfileFromDisk(file.replace(/\.json$/, ""));
757
937
  return profile ? [profile] : [];
758
938
  });
@@ -764,7 +944,7 @@ function detectProfilesForDirectory(dir) {
764
944
  return manifest.filter((entry) => {
765
945
  if (activeNames.has(entry.name)) return false;
766
946
  const indicatorFiles = entry.detect?.files ?? [];
767
- return indicatorFiles.length > 0 && indicatorFiles.every((file) => import_fs8.default.existsSync(import_path6.default.join(dir, file)));
947
+ return indicatorFiles.length > 0 && indicatorFiles.every((file) => import_fs9.default.existsSync(import_path6.default.join(dir, file)));
768
948
  });
769
949
  }
770
950
 
@@ -1030,11 +1210,41 @@ function isDuplicate(command, cwd, lastEvent, dedupWindowMs = 5e3, now = Date.no
1030
1210
  return lastEvent.command === normalizeForFilter(command) && lastEvent.cwd === cwd && now - lastTime < dedupWindowMs;
1031
1211
  }
1032
1212
 
1213
+ // src/capture/ide.ts
1214
+ var IDE_DEDUP_WINDOW_MS = 3e4;
1215
+
1033
1216
  // src/capture/index.ts
1034
1217
  function captureCommand(opts) {
1035
- if (import_fs9.default.existsSync(PAUSE_FLAG_PATH)) return;
1218
+ if (import_fs10.default.existsSync(PAUSE_FLAG_PATH)) return;
1036
1219
  const config = readConfig();
1037
- const { command, cwd, exitCode, durationMs } = opts;
1220
+ const { cwd, exitCode, durationMs } = opts;
1221
+ if (opts.type) {
1222
+ if (opts.type === "code_edit" && opts.file) {
1223
+ const lastForFile = getLastEventForFile(opts.file, cwd);
1224
+ if (lastForFile) {
1225
+ const lastTime = new Date(lastForFile.captured_at).getTime();
1226
+ if (Date.now() - lastTime < IDE_DEDUP_WINDOW_MS) return;
1227
+ }
1228
+ }
1229
+ const command2 = opts.command ?? opts.file ?? opts.type;
1230
+ const nameOverride2 = config.projects?.[cwd] ?? null;
1231
+ const { repoName: repoName2, branch: branch2 } = getGitContext(cwd, nameOverride2);
1232
+ insertEvent({
1233
+ captured_at: (/* @__PURE__ */ new Date()).toISOString(),
1234
+ command: command2,
1235
+ cwd,
1236
+ exit_code: exitCode,
1237
+ duration_ms: durationMs,
1238
+ repo_name: repoName2,
1239
+ branch: branch2,
1240
+ category: opts.type,
1241
+ metadata: null,
1242
+ source: opts.source ?? null,
1243
+ file_path: opts.file ?? null
1244
+ });
1245
+ return;
1246
+ }
1247
+ const command = opts.command;
1038
1248
  if (shouldFilter(command, config.capture.ignore)) return;
1039
1249
  const last = getLastEvent();
1040
1250
  if (isDuplicate(command, cwd, last ?? void 0)) return;
@@ -1048,7 +1258,6 @@ function captureCommand(opts) {
1048
1258
  if (firstToken) metadata.tool = firstToken;
1049
1259
  metadata.hint = safeCommand.trim().slice(0, 120);
1050
1260
  }
1051
- if (opts.source) metadata.source = opts.source;
1052
1261
  if (category === "ai_session") {
1053
1262
  if (opts.gitBefore) {
1054
1263
  const commits = getCommitsSince(opts.gitBefore, cwd);
@@ -1071,24 +1280,30 @@ function captureCommand(opts) {
1071
1280
  repo_name: repoName,
1072
1281
  branch,
1073
1282
  category,
1074
- metadata: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null
1283
+ metadata: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : null,
1284
+ source: opts.source ?? null,
1285
+ file_path: null
1075
1286
  });
1076
1287
  }
1077
1288
 
1078
1289
  // src/cli/capture.ts
1079
1290
  var CAPTURE_HANG_TIMEOUT_MS = 5e3;
1080
1291
  function registerCapture(program2) {
1081
- program2.command("_capture").description("Internal: capture a shell command event (called by shell hook)").option("--cmd <command>", "command that was executed").option("--cwd <path>", "working directory").option("--exit <code>", "exit code", parseInt).option("--duration <ms>", "duration in milliseconds", parseInt).option("--git-before <sha>", "git HEAD SHA before this command ran").action((opts) => {
1292
+ program2.command("_capture").description("Internal: capture a shell command or IDE event (called by shell hook or IDE plugin)").option("--cmd <command>", "command that was executed (shell events)").option("--cwd <path>", "working directory").option("--exit <code>", "exit code", parseInt).option("--duration <ms>", "duration in milliseconds", parseInt).option("--git-before <sha>", "git HEAD SHA before this command ran").option("--type <type>", "event type override \u2014 skips normalization (IDE integrations)").option("--source <source>", "event source identifier (e.g. vscode, neovim, jupyter)").option("--file <path>", "file path for code_edit events").action((opts) => {
1082
1293
  const hangGuard = setTimeout(() => process.exit(0), CAPTURE_HANG_TIMEOUT_MS);
1083
1294
  hangGuard.unref();
1084
1295
  try {
1085
- if (!opts.cmd || !opts.cwd) return;
1296
+ if (!opts.cwd) return;
1297
+ if (!opts.cmd && !opts.type) return;
1086
1298
  captureCommand({
1087
1299
  command: opts.cmd,
1088
1300
  cwd: opts.cwd,
1089
1301
  exitCode: opts.exit ?? null,
1090
1302
  durationMs: opts.duration ?? null,
1091
- gitBefore: opts.gitBefore ?? void 0
1303
+ gitBefore: opts.gitBefore ?? void 0,
1304
+ source: opts.source ?? void 0,
1305
+ type: opts.type ?? void 0,
1306
+ file: opts.file ?? void 0
1092
1307
  });
1093
1308
  } catch (err) {
1094
1309
  debugLog("_capture", err);
@@ -1096,6 +1311,38 @@ function registerCapture(program2) {
1096
1311
  });
1097
1312
  }
1098
1313
 
1314
+ // src/capture/detect-secrets.ts
1315
+ function containsSecrets(text) {
1316
+ return redact(text) !== text;
1317
+ }
1318
+
1319
+ // src/db/alerts.ts
1320
+ var import_fs11 = __toESM(require("fs"));
1321
+ var import_path7 = __toESM(require("path"));
1322
+ function insertAlert(tool, sessionId, context) {
1323
+ getDb().prepare(
1324
+ `INSERT INTO alerts (created_at, tool, session_id, context)
1325
+ VALUES ($created_at, $tool, $session_id, $context)`
1326
+ ).run({ created_at: Date.now(), tool, session_id: sessionId ?? null, context });
1327
+ import_fs11.default.mkdirSync(import_path7.default.dirname(ALERTS_PENDING_PATH), { recursive: true });
1328
+ import_fs11.default.writeFileSync(ALERTS_PENDING_PATH, "", { flag: "w" });
1329
+ }
1330
+ function getPendingAlerts() {
1331
+ return getDb().prepare("SELECT * FROM alerts WHERE cleared = 0 ORDER BY created_at DESC").all();
1332
+ }
1333
+ function hasPendingAlerts() {
1334
+ const row = getDb().prepare("SELECT COUNT(*) as count FROM alerts WHERE cleared = 0").get();
1335
+ return Number(row.count) > 0;
1336
+ }
1337
+ function clearAlerts() {
1338
+ const result = getDb().prepare("UPDATE alerts SET cleared = 1 WHERE cleared = 0").run();
1339
+ try {
1340
+ import_fs11.default.unlinkSync(ALERTS_PENDING_PATH);
1341
+ } catch {
1342
+ }
1343
+ return Number(result.changes);
1344
+ }
1345
+
1099
1346
  // src/cli/hook.ts
1100
1347
  function parseHookPayload(raw) {
1101
1348
  if (!raw.trim()) return null;
@@ -1106,8 +1353,17 @@ function parseHookPayload(raw) {
1106
1353
  if (!command || !cwd) return null;
1107
1354
  return { command, cwd };
1108
1355
  }
1356
+ function parseUserPromptPayload(raw) {
1357
+ if (!raw.trim()) return null;
1358
+ const payload = JSON.parse(raw);
1359
+ const prompt = typeof payload.prompt === "string" ? payload.prompt : null;
1360
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
1361
+ const cwd = typeof payload.cwd === "string" ? payload.cwd : "";
1362
+ if (!prompt) return null;
1363
+ return { session_id: sessionId, prompt, cwd };
1364
+ }
1109
1365
  function registerHook(program2) {
1110
- program2.command("_hook").description("Internal: capture events from AI tool hooks (stdin JSON payload)").option("--source <source>", "hook source identifier (e.g. claude-code)").action((opts) => {
1366
+ program2.command("_hook").description("Internal: capture events from AI tool hooks (stdin JSON payload)").option("--source <source>", "hook source identifier (e.g. claude-code)").option("--event <event>", "hook event type (PostToolUse or UserPromptSubmit)", "PostToolUse").action((opts) => {
1111
1367
  let raw = "";
1112
1368
  process.stdin.setEncoding("utf-8");
1113
1369
  process.stdin.on("data", (chunk) => {
@@ -1115,6 +1371,23 @@ function registerHook(program2) {
1115
1371
  });
1116
1372
  process.stdin.on("end", () => {
1117
1373
  try {
1374
+ if (opts.event === "UserPromptSubmit") {
1375
+ const parsed2 = parseUserPromptPayload(raw);
1376
+ if (!parsed2) return;
1377
+ if (containsSecrets(parsed2.prompt)) {
1378
+ insertAlert(
1379
+ opts.source ?? "unknown",
1380
+ parsed2.session_id || null,
1381
+ "Possible credential detected in prompt submitted to AI tool"
1382
+ );
1383
+ process.stdout.write(
1384
+ JSON.stringify({
1385
+ systemMessage: "Workscribe detected a possible credential in your prompt. Run `workscribe alerts` for details."
1386
+ })
1387
+ );
1388
+ }
1389
+ return;
1390
+ }
1118
1391
  if (readConfig().capture.aiTools === false) return;
1119
1392
  const parsed = parseHookPayload(raw);
1120
1393
  if (!parsed) return;
@@ -1133,9 +1406,9 @@ function registerHook(program2) {
1133
1406
  }
1134
1407
 
1135
1408
  // src/cli/init.ts
1136
- var import_fs12 = __toESM(require("fs"));
1137
- var import_path9 = __toESM(require("path"));
1138
- var import_os4 = __toESM(require("os"));
1409
+ var import_fs14 = __toESM(require("fs"));
1410
+ var import_path10 = __toESM(require("path"));
1411
+ var import_os5 = __toESM(require("os"));
1139
1412
  var import_prompts = __toESM(require("prompts"));
1140
1413
  var import_chalk2 = __toESM(require("chalk"));
1141
1414
 
@@ -1180,6 +1453,17 @@ function eachDayOfRange(start, end) {
1180
1453
  }
1181
1454
  return days;
1182
1455
  }
1456
+ function timeAgo(isoString, now = Date.now()) {
1457
+ const elapsedMs = now - new Date(isoString).getTime();
1458
+ const minutes = Math.floor(elapsedMs / 6e4);
1459
+ if (minutes < 2) return "just now";
1460
+ if (minutes < 60) return `${minutes} minutes ago`;
1461
+ const hours = Math.floor(minutes / 60);
1462
+ if (hours < 24) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
1463
+ const days = Math.floor(hours / 24);
1464
+ if (days === 1) return "yesterday";
1465
+ return `${days} days ago`;
1466
+ }
1183
1467
  function isoWeekKey(dateStr) {
1184
1468
  const d = /* @__PURE__ */ new Date(dateStr + "T00:00:00");
1185
1469
  const jan4 = new Date(d.getFullYear(), 0, 4);
@@ -1228,6 +1512,8 @@ RULES \u2014 follow these exactly:
1228
1512
 
1229
1513
  7. Stick to what the events show. Do not infer subject matter from the branch name. Do not add details not derivable from the events.
1230
1514
 
1515
+ 8. For code_edit events: these represent file saves from an IDE integration. Describe what was being built based on repository context (branch name, commit messages). Never list individual filenames.
1516
+
1231
1517
  Maximum 5 bullets. Output ONLY the bullet points, nothing else.`;
1232
1518
  function getSystemPrompt() {
1233
1519
  const contexts = getActiveProfileAiContexts();
@@ -1269,7 +1555,9 @@ function buildUserMessage(payload, includeCategories = []) {
1269
1555
  if (excluded.has(event.category)) continue;
1270
1556
  const failed = event.exitCode !== null && event.exitCode !== 0 ? " [failed]" : "";
1271
1557
  let key;
1272
- if (event.hint) {
1558
+ if (event.category === "code_edit") {
1559
+ key = `code_edit${failed}`;
1560
+ } else if (event.hint) {
1273
1561
  key = `other [${event.hint}]${failed}`;
1274
1562
  } else {
1275
1563
  const meta = Object.keys(event.metadata).length > 0 ? ` ${JSON.stringify(event.metadata)}` : "";
@@ -1337,7 +1625,9 @@ function buildWeeklyMessage(payload) {
1337
1625
  if (excluded.has(event.category)) continue;
1338
1626
  const failed = event.exitCode !== null && event.exitCode !== 0 ? " [failed]" : "";
1339
1627
  let key;
1340
- if (event.hint) {
1628
+ if (event.category === "code_edit") {
1629
+ key = `code_edit${failed}`;
1630
+ } else if (event.hint) {
1341
1631
  key = `other [${event.hint}]${failed}`;
1342
1632
  } else {
1343
1633
  const meta = Object.keys(event.metadata).length > 0 ? ` ${JSON.stringify(event.metadata)}` : "";
@@ -1353,24 +1643,7 @@ function buildWeeklyMessage(payload) {
1353
1643
  return lines.join("\n");
1354
1644
  }
1355
1645
 
1356
- // src/ai/fetch.ts
1357
- var DEFAULT_TIMEOUT_MS = 3e4;
1358
- async function fetchWithTimeout(url, init, timeoutMs = DEFAULT_TIMEOUT_MS) {
1359
- const controller = new AbortController();
1360
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1361
- try {
1362
- return await fetch(url, { ...init, signal: controller.signal });
1363
- } catch (err) {
1364
- if (err.name === "AbortError") {
1365
- throw new Error(`Request timed out after ${timeoutMs / 1e3}s`);
1366
- }
1367
- throw err;
1368
- } finally {
1369
- clearTimeout(timer);
1370
- }
1371
- }
1372
-
1373
- // src/ai/providers/index.ts
1646
+ // src/ai/generic-provider.ts
1374
1647
  var GenericProvider = class {
1375
1648
  constructor(config, model, apiKey, baseUrl, includeCategories = []) {
1376
1649
  this.config = config;
@@ -1385,7 +1658,7 @@ var GenericProvider = class {
1385
1658
  baseUrl;
1386
1659
  includeCategories;
1387
1660
  async call(system, userMsg, maxTokens) {
1388
- const response = await fetchWithTimeout(this.config.chatUrl(this.baseUrl), {
1661
+ const response = await fetchWithTimeout(this.config.chatUrl(this.baseUrl, this.apiKey, this.model), {
1389
1662
  method: "POST",
1390
1663
  headers: this.config.headers(this.apiKey),
1391
1664
  body: JSON.stringify(this.config.buildChatBody(this.model, system, userMsg, maxTokens))
@@ -1403,7 +1676,7 @@ var GenericProvider = class {
1403
1676
  }
1404
1677
  async validate() {
1405
1678
  const isPost = this.config.validateMethod === "POST";
1406
- const response = await fetchWithTimeout(this.config.validateUrl(this.baseUrl), {
1679
+ const response = await fetchWithTimeout(this.config.validateUrl(this.baseUrl, this.apiKey, this.model), {
1407
1680
  method: this.config.validateMethod,
1408
1681
  headers: isPost ? this.config.headers(this.apiKey) : void 0,
1409
1682
  body: isPost && this.config.buildValidateBody ? JSON.stringify(this.config.buildValidateBody(this.model)) : void 0
@@ -1414,7 +1687,7 @@ var GenericProvider = class {
1414
1687
  }
1415
1688
  };
1416
1689
 
1417
- // src/ai/providers/config.ts
1690
+ // src/ai/provider-config.ts
1418
1691
  var PROVIDER_CONFIGS = {
1419
1692
  anthropic: {
1420
1693
  defaultModel: "claude-haiku-4-5-20251001",
@@ -1502,6 +1775,59 @@ var PROVIDER_CONFIGS = {
1502
1775
  },
1503
1776
  chatError: (status, body) => `Ollama error ${status}: ${body}. Is Ollama running? Try: ollama serve`,
1504
1777
  validateError: (_status, _body, baseUrl) => `Ollama is not reachable at ${baseUrl}. Is it running? Try: ollama serve`
1778
+ },
1779
+ gemini: {
1780
+ defaultModel: "gemini-2.0-flash-lite",
1781
+ defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
1782
+ dailyTokens: 1024,
1783
+ weeklyTokens: 1024,
1784
+ // API key and model are embedded in the URL — not in headers
1785
+ chatUrl: (baseUrl, apiKey, model) => `${baseUrl}/models/${model}:generateContent?key=${apiKey}`,
1786
+ validateUrl: (baseUrl, apiKey, model) => `${baseUrl}/models/${model}:generateContent?key=${apiKey}`,
1787
+ validateMethod: "POST",
1788
+ headers: () => ({ "Content-Type": "application/json" }),
1789
+ buildChatBody: (_model, system, userMsg, maxTokens) => ({
1790
+ system_instruction: { parts: [{ text: system }] },
1791
+ contents: [{ role: "user", parts: [{ text: userMsg }] }],
1792
+ generationConfig: { maxOutputTokens: maxTokens, temperature: 0.3 }
1793
+ }),
1794
+ buildValidateBody: () => ({
1795
+ contents: [{ role: "user", parts: [{ text: "." }] }],
1796
+ generationConfig: { maxOutputTokens: 1 }
1797
+ }),
1798
+ parseText: (json) => {
1799
+ const data = json;
1800
+ return data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
1801
+ },
1802
+ chatError: (status, body) => `Gemini API error ${status}: ${body}`,
1803
+ validateError: (status, body) => `Gemini API error ${status}: ${body}`
1804
+ },
1805
+ groq: {
1806
+ defaultModel: "llama-3.1-8b-instant",
1807
+ defaultBaseUrl: "https://api.groq.com/openai/v1",
1808
+ dailyTokens: 1024,
1809
+ weeklyTokens: 1024,
1810
+ chatUrl: (baseUrl) => `${baseUrl}/chat/completions`,
1811
+ validateUrl: (baseUrl) => `${baseUrl}/models`,
1812
+ validateMethod: "GET",
1813
+ headers: (apiKey) => ({
1814
+ "Content-Type": "application/json",
1815
+ Authorization: `Bearer ${apiKey}`
1816
+ }),
1817
+ buildChatBody: (model, system, userMsg, maxTokens) => ({
1818
+ model,
1819
+ messages: [
1820
+ { role: "system", content: system },
1821
+ { role: "user", content: userMsg }
1822
+ ],
1823
+ max_tokens: maxTokens
1824
+ }),
1825
+ parseText: (json) => {
1826
+ const data = json;
1827
+ return data.choices?.[0]?.message?.content ?? "";
1828
+ },
1829
+ chatError: (status, body) => `Groq API error ${status}: ${body}`,
1830
+ validateError: (status, body) => `Groq API error ${status}: ${body}`
1505
1831
  }
1506
1832
  };
1507
1833
 
@@ -1510,7 +1836,7 @@ function getProvider(config) {
1510
1836
  const { provider, apiKey = "", model, baseUrl } = config.ai;
1511
1837
  if (!provider) {
1512
1838
  throw new Error(
1513
- "No AI provider configured.\nRun: workscribe config set ai.provider <anthropic|openai|ollama|openai-compatible>"
1839
+ "No AI provider configured.\nRun: workscribe config set ai.provider <anthropic|openai|gemini|groq|ollama|openai-compatible>"
1514
1840
  );
1515
1841
  }
1516
1842
  const configKey = provider === "openai-compatible" ? "openai" : provider;
@@ -1518,7 +1844,7 @@ function getProvider(config) {
1518
1844
  if (!providerConfig) {
1519
1845
  throw new Error(
1520
1846
  `Unknown provider: "${provider}"
1521
- Supported providers: anthropic, openai, ollama, openai-compatible`
1847
+ Supported providers: anthropic, openai, gemini, groq, ollama, openai-compatible`
1522
1848
  );
1523
1849
  }
1524
1850
  if (provider !== "ollama" && !apiKey) {
@@ -1538,14 +1864,73 @@ Run: workscribe config set ai.apiKey <your-key>`);
1538
1864
  }
1539
1865
 
1540
1866
  // src/capture/shell-hook.ts
1541
- var import_fs10 = __toESM(require("fs"));
1542
- var import_path7 = __toESM(require("path"));
1867
+ var import_fs12 = __toESM(require("fs"));
1868
+ var import_path8 = __toESM(require("path"));
1869
+ var import_os3 = __toESM(require("os"));
1870
+ var import_child_process3 = require("child_process");
1871
+
1872
+ // src/utils/completion-scripts.ts
1873
+ function bashCompletion(commands) {
1874
+ const names = commands.map((command) => command.name).join(" ");
1875
+ return `_workscribe_completion() {
1876
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
1877
+ local prev="\${COMP_WORDS[COMP_CWORD-1]}"
1878
+ local commands="${names}"
1879
+ case "$prev" in
1880
+ completions) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ); return ;;
1881
+ config) COMPREPLY=( $(compgen -W "get set list" -- "$cur") ); return ;;
1882
+ profile) COMPREPLY=( $(compgen -W "list add remove update detect suggest" -- "$cur") ); return ;;
1883
+ esac
1884
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
1885
+ }
1886
+ complete -F _workscribe_completion workscribe`;
1887
+ }
1888
+ function zshCompletion(commands) {
1889
+ const commandList = commands.map((command) => ` '${command.name}:${command.description}'`).join("\n");
1890
+ return `#compdef workscribe
1891
+ _workscribe() {
1892
+ local state
1893
+ _arguments '1: :->command' '*: :->args'
1894
+ case $state in
1895
+ command)
1896
+ local -a commands
1897
+ commands=(
1898
+ ${commandList}
1899
+ )
1900
+ _describe 'command' commands
1901
+ ;;
1902
+ args)
1903
+ case $words[2] in
1904
+ completions) local -a shells; shells=('bash' 'zsh' 'fish'); _describe 'shell' shells ;;
1905
+ config) local -a sub; sub=('get:Get a value' 'set:Set a value' 'list:List all values'); _describe 'subcommand' sub ;;
1906
+ profile) local -a sub; sub=('list' 'add' 'remove' 'update' 'detect' 'suggest'); _describe 'subcommand' sub ;;
1907
+ esac
1908
+ ;;
1909
+ esac
1910
+ }
1911
+ _workscribe`;
1912
+ }
1913
+ function fishCompletion(commands) {
1914
+ const lines = [
1915
+ "complete -c workscribe -f",
1916
+ ...commands.map(
1917
+ (command) => `complete -c workscribe -n "__fish_use_subcommand" -a ${command.name.padEnd(12)} -d "${command.description}"`
1918
+ ),
1919
+ 'complete -c workscribe -n "__fish_seen_subcommand_from completions" -a "bash zsh fish"',
1920
+ 'complete -c workscribe -n "__fish_seen_subcommand_from config" -a "get set list"',
1921
+ 'complete -c workscribe -n "__fish_seen_subcommand_from profile" -a "list add remove update detect suggest"'
1922
+ ];
1923
+ return lines.join("\n");
1924
+ }
1925
+
1926
+ // src/capture/shell-hook.ts
1927
+ var FISH_COMPLETIONS_DIR = import_path8.default.join(import_os3.default.homedir(), ".config", "fish", "completions");
1543
1928
  var HOOK_START = "# workscribe:hook:start";
1544
1929
  var HOOK_END = "# workscribe:hook:end";
1545
1930
  var HOOK_PREAMBLE = "# Workscribe \u2014 developer work journal";
1546
1931
  function getInstalledHookBlock(rcFile) {
1547
- if (!import_fs10.default.existsSync(rcFile)) return null;
1548
- const content = import_fs10.default.readFileSync(rcFile, "utf-8");
1932
+ if (!import_fs12.default.existsSync(rcFile)) return null;
1933
+ const content = import_fs12.default.readFileSync(rcFile, "utf-8");
1549
1934
  const startIdx = content.indexOf(HOOK_START);
1550
1935
  const endIdx = content.indexOf(HOOK_END);
1551
1936
  if (startIdx === -1 || endIdx === -1) return null;
@@ -1561,45 +1946,88 @@ function isHookOutdated(rcFile, hookContent) {
1561
1946
  }
1562
1947
  function installHook(rcFile, hookContent) {
1563
1948
  const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
1564
- import_fs10.default.copyFileSync(rcFile, backup);
1565
- log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1566
- import_fs10.default.appendFileSync(rcFile, `
1949
+ import_fs12.default.copyFileSync(rcFile, backup);
1950
+ log.info(`Backed up ${rcFile} \u2192 ${import_path8.default.basename(backup)}`);
1951
+ import_fs12.default.appendFileSync(rcFile, `
1567
1952
  ${hookContent}
1568
1953
  `);
1569
1954
  }
1570
1955
  function updateHook(rcFile, hookContent) {
1571
1956
  const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
1572
- import_fs10.default.copyFileSync(rcFile, backup);
1573
- log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1574
- const content = import_fs10.default.readFileSync(rcFile, "utf-8");
1957
+ import_fs12.default.copyFileSync(rcFile, backup);
1958
+ log.info(`Backed up ${rcFile} \u2192 ${import_path8.default.basename(backup)}`);
1959
+ const content = import_fs12.default.readFileSync(rcFile, "utf-8");
1575
1960
  const startIdx = content.indexOf(HOOK_START);
1576
1961
  const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
1577
- import_fs10.default.writeFileSync(rcFile, content.slice(0, startIdx) + hookContent + content.slice(endIdx));
1962
+ import_fs12.default.writeFileSync(rcFile, content.slice(0, startIdx) + hookContent + content.slice(endIdx));
1578
1963
  }
1579
1964
  function removeHook(rcFile) {
1580
1965
  const backup = `${rcFile}.workscribe-backup-${Date.now()}`;
1581
- import_fs10.default.copyFileSync(rcFile, backup);
1582
- log.info(`Backed up ${rcFile} \u2192 ${import_path7.default.basename(backup)}`);
1583
- const content = import_fs10.default.readFileSync(rcFile, "utf-8");
1966
+ import_fs12.default.copyFileSync(rcFile, backup);
1967
+ log.info(`Backed up ${rcFile} \u2192 ${import_path8.default.basename(backup)}`);
1968
+ const content = import_fs12.default.readFileSync(rcFile, "utf-8");
1584
1969
  const startIdx = content.indexOf(HOOK_START);
1585
1970
  const endIdx = content.indexOf(HOOK_END) + HOOK_END.length;
1586
1971
  const preambleIdx = content.lastIndexOf(HOOK_PREAMBLE, startIdx);
1587
1972
  const blockStart = preambleIdx !== -1 ? preambleIdx : startIdx;
1588
1973
  const before = content.slice(0, blockStart).replace(/\n+$/, "\n");
1589
1974
  const after = content.slice(endIdx).replace(/^\n/, "");
1590
- import_fs10.default.writeFileSync(rcFile, before + after);
1975
+ import_fs12.default.writeFileSync(rcFile, before + after);
1976
+ }
1977
+ function resolveWorkscribeBinary() {
1978
+ try {
1979
+ const resolved = (0, import_child_process3.execFileSync)("which", ["workscribe"], { encoding: "utf-8" }).trim();
1980
+ return resolved || null;
1981
+ } catch {
1982
+ return null;
1983
+ }
1984
+ }
1985
+ function injectBinaryPath(hookContent, binaryPath) {
1986
+ return hookContent.replace("command -v workscribe > /dev/null 2>&1 || return 0", `[[ -x "${binaryPath}" ]] || return 0`).replace("command -v workscribe &>/dev/null || return 0", `[[ -x "${binaryPath}" ]] || return 0`).replace("type -q workscribe\n or return 0", `test -x "${binaryPath}"
1987
+ or return 0`).replaceAll("command workscribe _capture", `"${binaryPath}" _capture`).replaceAll("command workscribe alerts", `"${binaryPath}" alerts`);
1988
+ }
1989
+ function isCompletionsInstalled(shell) {
1990
+ if (shell.name === "fish") {
1991
+ return import_fs12.default.existsSync(import_path8.default.join(FISH_COMPLETIONS_DIR, "workscribe.fish"));
1992
+ }
1993
+ try {
1994
+ return import_fs12.default.readFileSync(shell.rcFile, "utf-8").includes("workscribe completions");
1995
+ } catch {
1996
+ return false;
1997
+ }
1998
+ }
1999
+ function installCompletions(shell, commands) {
2000
+ if (shell.name === "fish") {
2001
+ if (!import_fs12.default.existsSync(FISH_COMPLETIONS_DIR)) {
2002
+ import_fs12.default.mkdirSync(FISH_COMPLETIONS_DIR, { recursive: true });
2003
+ }
2004
+ import_fs12.default.writeFileSync(import_path8.default.join(FISH_COMPLETIONS_DIR, "workscribe.fish"), fishCompletion(commands), { mode: 420 });
2005
+ } else {
2006
+ const script = shell.name === "zsh" ? zshCompletion(commands) : bashCompletion(commands);
2007
+ const cacheFile = import_path8.default.join(WORKSCRIBE_DIR, `completion.${shell.name}`);
2008
+ import_fs12.default.writeFileSync(cacheFile, script, { mode: 420 });
2009
+ const sourceLine = shell.name === "zsh" ? `
2010
+ # Workscribe completions
2011
+ autoload -Uz compinit && compinit
2012
+ source "${cacheFile}"
2013
+ ` : `
2014
+ # Workscribe completions
2015
+ source "${cacheFile}"
2016
+ `;
2017
+ import_fs12.default.appendFileSync(shell.rcFile, sourceLine);
2018
+ }
1591
2019
  }
1592
2020
 
1593
2021
  // src/capture/hooks/claude-code.ts
1594
- var import_fs11 = __toESM(require("fs"));
1595
- var import_path8 = __toESM(require("path"));
1596
- var import_os3 = __toESM(require("os"));
2022
+ var import_fs13 = __toESM(require("fs"));
2023
+ var import_path9 = __toESM(require("path"));
2024
+ var import_os4 = __toESM(require("os"));
1597
2025
 
1598
2026
  // src/capture/hooks/utils.ts
1599
- var import_child_process3 = require("child_process");
2027
+ var import_child_process4 = require("child_process");
1600
2028
  function isCommandInstalled(cmd) {
1601
2029
  try {
1602
- (0, import_child_process3.execSync)(`which ${cmd}`, { stdio: "ignore" });
2030
+ (0, import_child_process4.execSync)(`which ${cmd}`, { stdio: "ignore" });
1603
2031
  return true;
1604
2032
  } catch {
1605
2033
  return false;
@@ -1607,11 +2035,11 @@ function isCommandInstalled(cmd) {
1607
2035
  }
1608
2036
 
1609
2037
  // src/capture/hooks/claude-code.ts
1610
- var CLAUDE_SETTINGS_PATH = import_path8.default.join(import_os3.default.homedir(), ".claude", "settings.json");
2038
+ var CLAUDE_SETTINGS_PATH = import_path9.default.join(import_os4.default.homedir(), ".claude", "settings.json");
1611
2039
  function readSettings() {
1612
- if (!import_fs11.default.existsSync(CLAUDE_SETTINGS_PATH)) return {};
2040
+ if (!import_fs13.default.existsSync(CLAUDE_SETTINGS_PATH)) return {};
1613
2041
  try {
1614
- return JSON.parse(import_fs11.default.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
2042
+ return JSON.parse(import_fs13.default.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
1615
2043
  } catch {
1616
2044
  return {};
1617
2045
  }
@@ -1623,19 +2051,30 @@ var claudeCodeIntegration = {
1623
2051
  },
1624
2052
  isHookInstalled() {
1625
2053
  const settings = readSettings();
1626
- const postToolUse = settings.hooks?.PostToolUse ?? [];
1627
- return postToolUse.some((entry) => {
1628
- const hooks = entry.hooks ?? [];
1629
- return hooks.some((hookItem) => {
2054
+ const hooks = settings.hooks ?? {};
2055
+ const postToolUse = hooks.PostToolUse ?? [];
2056
+ const hasPostToolUse = postToolUse.some((entry) => {
2057
+ const entryHooks = entry.hooks ?? [];
2058
+ return entryHooks.some((hookItem) => {
1630
2059
  const hook = hookItem;
1631
2060
  return typeof hook.command === "string" && hook.command.includes("workscribe _hook");
1632
2061
  });
1633
2062
  });
2063
+ const userPromptSubmit = hooks.UserPromptSubmit ?? [];
2064
+ const hasUserPromptSubmit = userPromptSubmit.some((entry) => {
2065
+ const entryHooks = entry.hooks ?? [];
2066
+ return entryHooks.some((hookItem) => {
2067
+ const hook = hookItem;
2068
+ return typeof hook.command === "string" && hook.command.includes("workscribe _hook");
2069
+ });
2070
+ });
2071
+ return hasPostToolUse && hasUserPromptSubmit;
1634
2072
  },
1635
2073
  install() {
1636
2074
  const settings = readSettings();
1637
2075
  const existingHooks = settings.hooks ?? {};
1638
2076
  const existingPostToolUse = existingHooks.PostToolUse ?? [];
2077
+ const existingUserPromptSubmit = existingHooks.UserPromptSubmit ?? [];
1639
2078
  settings.hooks = {
1640
2079
  ...existingHooks,
1641
2080
  PostToolUse: [
@@ -1644,10 +2083,21 @@ var claudeCodeIntegration = {
1644
2083
  matcher: "Bash",
1645
2084
  hooks: [{ type: "command", command: "workscribe _hook --source claude-code", async: true }]
1646
2085
  }
2086
+ ],
2087
+ UserPromptSubmit: [
2088
+ ...existingUserPromptSubmit,
2089
+ {
2090
+ hooks: [
2091
+ {
2092
+ type: "command",
2093
+ command: "workscribe _hook --source claude-code --event UserPromptSubmit"
2094
+ }
2095
+ ]
2096
+ }
1647
2097
  ]
1648
2098
  };
1649
- import_fs11.default.mkdirSync(import_path8.default.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
1650
- import_fs11.default.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
2099
+ import_fs13.default.mkdirSync(import_path9.default.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
2100
+ import_fs13.default.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
1651
2101
  }
1652
2102
  };
1653
2103
 
@@ -1656,11 +2106,34 @@ var AI_HOOK_INTEGRATIONS = {
1656
2106
  "claude-code": claudeCodeIntegration
1657
2107
  };
1658
2108
 
2109
+ // src/cli/completions.ts
2110
+ var HIDDEN_COMMANDS = /* @__PURE__ */ new Set(["_capture", "_hook"]);
2111
+ function getPublicCommands(program2) {
2112
+ return program2.commands.filter((command) => !HIDDEN_COMMANDS.has(command.name())).map((command) => ({ name: command.name(), description: command.description() }));
2113
+ }
2114
+ function registerCompletions(program2) {
2115
+ program2.command("completions [shell]").description("Print shell completion script (bash, zsh, fish)").action((shell) => {
2116
+ const detected = shell ?? (process.env.SHELL ?? "").split("/").at(-1);
2117
+ if (!detected || !["bash", "zsh", "fish"].includes(detected)) {
2118
+ log.error(`Unknown shell: "${detected ?? ""}". Supported: bash, zsh, fish`);
2119
+ log.plain(` Usage: workscribe completions <bash|zsh|fish>`);
2120
+ process.exit(1);
2121
+ }
2122
+ const commands = getPublicCommands(program2);
2123
+ const script = detected === "bash" ? bashCompletion(commands) : detected === "zsh" ? zshCompletion(commands) : fishCompletion(commands);
2124
+ process.stdout.write(script + "\n");
2125
+ });
2126
+ }
2127
+
1659
2128
  // src/cli/init.ts
1660
2129
  function detectShell() {
1661
2130
  const shell = process.env.SHELL ?? "";
1662
- if (shell.includes("zsh")) return { name: "zsh", rcFile: import_path9.default.join(import_os4.default.homedir(), ".zshrc") };
1663
- if (shell.includes("bash")) return { name: "bash", rcFile: import_path9.default.join(import_os4.default.homedir(), ".bashrc") };
2131
+ if (shell.includes("zsh")) return { name: "zsh", rcFile: import_path10.default.join(import_os5.default.homedir(), ".zshrc") };
2132
+ if (shell.includes("bash")) return { name: "bash", rcFile: import_path10.default.join(import_os5.default.homedir(), ".bashrc") };
2133
+ if (shell.includes("fish")) {
2134
+ const confDir = import_path10.default.join(import_os5.default.homedir(), ".config", "fish", "conf.d");
2135
+ return { name: "fish", rcFile: import_path10.default.join(confDir, "workscribe.fish") };
2136
+ }
1664
2137
  return null;
1665
2138
  }
1666
2139
  function step(current, total, label) {
@@ -1671,19 +2144,21 @@ function registerInit(program2) {
1671
2144
  program2.command("init").description("Set up Workscribe and install or update shell hooks").action(async () => {
1672
2145
  const TOTAL_STEPS = 5;
1673
2146
  log.plain(import_chalk2.default.bold("\n Workscribe \u2014 Developer Work Journal\n"));
1674
- step(1, TOTAL_STEPS, "Creating data directory");
1675
- if (!import_fs12.default.existsSync(WORKSCRIBE_DIR)) {
1676
- import_fs12.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true, mode: 448 });
1677
- log.success(`Created ${WORKSCRIBE_DIR}`);
1678
- } else {
1679
- try {
1680
- import_fs12.default.chmodSync(WORKSCRIBE_DIR, 448);
1681
- } catch {
2147
+ step(1, TOTAL_STEPS, "Creating data directories");
2148
+ for (const dir of REQUIRED_DIRS) {
2149
+ if (!import_fs14.default.existsSync(dir)) {
2150
+ import_fs14.default.mkdirSync(dir, { recursive: true, mode: 448 });
2151
+ log.success(`Created ${dir}`);
2152
+ } else {
2153
+ try {
2154
+ import_fs14.default.chmodSync(dir, 448);
2155
+ } catch {
2156
+ }
2157
+ log.info(`${dir} already exists`);
1682
2158
  }
1683
- log.info(`${WORKSCRIBE_DIR} already exists`);
1684
2159
  }
1685
- if (!import_fs12.default.existsSync(PROFILES_DIR)) {
1686
- import_fs12.default.mkdirSync(PROFILES_DIR, { recursive: true, mode: 448 });
2160
+ if (!import_fs14.default.existsSync(PROFILES_DIR)) {
2161
+ import_fs14.default.mkdirSync(PROFILES_DIR, { recursive: true, mode: 448 });
1687
2162
  }
1688
2163
  step(2, TOTAL_STEPS, "Initialising database");
1689
2164
  getDb();
@@ -1691,15 +2166,23 @@ function registerInit(program2) {
1691
2166
  step(3, TOTAL_STEPS, "Installing shell hook");
1692
2167
  const shell = detectShell();
1693
2168
  if (!shell) {
1694
- log.warn("Could not detect shell. Supported: zsh, bash");
1695
- log.plain(`Manually add the hook from: ${import_chalk2.default.cyan("shell/hook.zsh")} or ${import_chalk2.default.cyan("shell/hook.bash")}`);
2169
+ log.warn("Could not detect shell. Supported: zsh, bash, fish");
2170
+ log.plain(
2171
+ ` Manually add the hook from: ${import_chalk2.default.cyan("shell/hook.zsh")}, ${import_chalk2.default.cyan("shell/hook.bash")}, or ${import_chalk2.default.cyan("shell/hook.fish")}`
2172
+ );
1696
2173
  } else {
1697
- const hookFile = import_path9.default.join(__dirname, "..", "shell", `hook.${shell.name}`);
1698
- if (!import_fs12.default.existsSync(hookFile)) {
2174
+ const hookFile = import_path10.default.join(__dirname, "..", "shell", `hook.${shell.name}`);
2175
+ if (!import_fs14.default.existsSync(hookFile)) {
1699
2176
  log.warn(`Hook template not found at ${hookFile}`);
1700
2177
  } else {
1701
- const hookContent = import_fs12.default.readFileSync(hookFile, "utf-8");
1702
- if (!import_fs12.default.existsSync(shell.rcFile)) import_fs12.default.writeFileSync(shell.rcFile, "");
2178
+ const hookContent = import_fs14.default.readFileSync(hookFile, "utf-8");
2179
+ const binaryPath = resolveWorkscribeBinary();
2180
+ const hookWithPath = binaryPath ? injectBinaryPath(hookContent, binaryPath) : hookContent;
2181
+ if (shell.name === "fish") {
2182
+ const confDir = import_path10.default.dirname(shell.rcFile);
2183
+ if (!import_fs14.default.existsSync(confDir)) import_fs14.default.mkdirSync(confDir, { recursive: true });
2184
+ }
2185
+ if (!import_fs14.default.existsSync(shell.rcFile)) import_fs14.default.writeFileSync(shell.rcFile, "");
1703
2186
  if (!isHookInstalled(shell.rcFile)) {
1704
2187
  log.plain("");
1705
2188
  log.plain(import_chalk2.default.dim(" Workscribe adds a lightweight hook to your shell that captures each"));
@@ -1707,9 +2190,9 @@ function registerInit(program2) {
1707
2190
  log.plain(import_chalk2.default.dim(" It runs in the background and adds no latency to your terminal."));
1708
2191
  log.plain(import_chalk2.default.dim(" Credentials and secrets are redacted before anything is stored."));
1709
2192
  log.plain("");
1710
- installHook(shell.rcFile, hookContent);
2193
+ installHook(shell.rcFile, hookWithPath);
1711
2194
  log.success(`Shell hook installed in ${shell.rcFile}`);
1712
- } else if (isHookOutdated(shell.rcFile, hookContent)) {
2195
+ } else if (isHookOutdated(shell.rcFile, hookWithPath)) {
1713
2196
  const { update } = await (0, import_prompts.default)({
1714
2197
  type: "confirm",
1715
2198
  name: "update",
@@ -1717,7 +2200,7 @@ function registerInit(program2) {
1717
2200
  initial: true
1718
2201
  });
1719
2202
  if (update) {
1720
- updateHook(shell.rcFile, hookContent);
2203
+ updateHook(shell.rcFile, hookWithPath);
1721
2204
  log.success(`Shell hook updated in ${shell.rcFile}`);
1722
2205
  } else {
1723
2206
  log.info("Shell hook update skipped");
@@ -1725,6 +2208,24 @@ function registerInit(program2) {
1725
2208
  } else {
1726
2209
  log.info(`Shell hook up to date in ${shell.rcFile}`);
1727
2210
  }
2211
+ if (isCompletionsInstalled(shell)) {
2212
+ log.info("Shell completions already installed");
2213
+ } else {
2214
+ const { installComps } = await (0, import_prompts.default)({
2215
+ type: "confirm",
2216
+ name: "installComps",
2217
+ message: "Install shell completions (tab-complete workscribe commands)?",
2218
+ initial: true
2219
+ });
2220
+ if (installComps) {
2221
+ try {
2222
+ installCompletions(shell, getPublicCommands(program2));
2223
+ log.success("Shell completions installed");
2224
+ } catch (error) {
2225
+ log.warn(`Completions install failed: ${error.message}`);
2226
+ }
2227
+ }
2228
+ }
1728
2229
  }
1729
2230
  }
1730
2231
  step(4, TOTAL_STEPS, "Detecting AI coding tools");
@@ -1855,7 +2356,7 @@ function registerInit(program2) {
1855
2356
  }
1856
2357
  }
1857
2358
  }
1858
- if (!import_fs12.default.existsSync(CONFIG_PATH)) {
2359
+ if (!import_fs14.default.existsSync(CONFIG_PATH)) {
1859
2360
  writeConfig(config);
1860
2361
  }
1861
2362
  log.plain("");
@@ -1863,8 +2364,13 @@ function registerInit(program2) {
1863
2364
  log.plain("");
1864
2365
  if (shell) {
1865
2366
  log.plain(` Activate capture now:`);
1866
- log.plain(` ${import_chalk2.default.cyan(`source ${shell.rcFile}`)}`);
1867
- log.plain(` Or restart your terminal.`);
2367
+ if (shell.name === "fish") {
2368
+ log.plain(` Restart your terminal`);
2369
+ log.plain(` ${import_chalk2.default.dim(`or run: source ${shell.rcFile}`)}`);
2370
+ } else {
2371
+ log.plain(` ${import_chalk2.default.cyan(`source ${shell.rcFile}`)}`);
2372
+ log.plain(` Or restart your terminal.`);
2373
+ }
1868
2374
  log.plain("");
1869
2375
  }
1870
2376
  if (providerConfigured) {
@@ -1886,7 +2392,7 @@ function registerInit(program2) {
1886
2392
  }
1887
2393
 
1888
2394
  // src/cli/status.ts
1889
- var import_fs13 = __toESM(require("fs"));
2395
+ var import_fs15 = __toESM(require("fs"));
1890
2396
  var import_chalk3 = __toESM(require("chalk"));
1891
2397
  function registerStatus(program2) {
1892
2398
  program2.command("status").description("Show capture status and event counts").action(() => {
@@ -1894,7 +2400,7 @@ function registerStatus(program2) {
1894
2400
  const total = getEventCount();
1895
2401
  const todayEvents = getEventsForDate(todayDate());
1896
2402
  const last = getLastEvent();
1897
- const isPaused = import_fs13.default.existsSync(PAUSE_FLAG_PATH);
2403
+ const isPaused = import_fs15.default.existsSync(PAUSE_FLAG_PATH);
1898
2404
  log.plain("");
1899
2405
  log.plain(import_chalk3.default.bold(" Workscribe Status"));
1900
2406
  log.plain("");
@@ -1917,6 +2423,13 @@ function registerStatus(program2) {
1917
2423
  log.plain(
1918
2424
  ` Profiles ${activeProfiles.length > 0 ? import_chalk3.default.cyan(activeProfiles.join(", ")) : import_chalk3.default.dim("none")}`
1919
2425
  );
2426
+ const pendingAlerts = getPendingAlerts();
2427
+ if (pendingAlerts.length > 0) {
2428
+ log.plain("");
2429
+ log.plain(
2430
+ ` ${import_chalk3.default.yellow("!")} ${import_chalk3.default.yellow(String(pendingAlerts.length))} pending security alert${pendingAlerts.length === 1 ? "" : "s"}` + import_chalk3.default.dim(" (run: workscribe alerts)")
2431
+ );
2432
+ }
1920
2433
  const { total: weekTotal, otherCount } = getRecentOtherRate(7);
1921
2434
  if (weekTotal > 0) {
1922
2435
  const otherPct = Math.round(otherCount / weekTotal * 100);
@@ -2010,13 +2523,16 @@ function registerEvents(program2) {
2010
2523
  var import_chalk5 = __toESM(require("chalk"));
2011
2524
 
2012
2525
  // src/sessions/grouper.ts
2526
+ var LONG_RUNNING_THRESHOLD_MS = 5 * 60 * 1e3;
2013
2527
  function splitByIdleGap(events, idleMs) {
2014
2528
  if (events.length === 0) return [];
2015
2529
  const groups = [[events[0]]];
2016
2530
  for (let i = 1; i < events.length; i++) {
2017
2531
  const prev = new Date(events[i - 1].captured_at).getTime();
2018
- const curr = new Date(events[i].captured_at).getTime();
2019
- if (curr - prev >= idleMs) {
2532
+ const currCompleted = new Date(events[i].captured_at).getTime();
2533
+ const currDuration = events[i].duration_ms ?? 0;
2534
+ const currEffective = currDuration >= LONG_RUNNING_THRESHOLD_MS ? currCompleted - currDuration : currCompleted;
2535
+ if (currEffective - prev >= idleMs) {
2020
2536
  groups.push([events[i]]);
2021
2537
  } else {
2022
2538
  groups[groups.length - 1].push(events[i]);
@@ -2024,13 +2540,17 @@ function splitByIdleGap(events, idleMs) {
2024
2540
  }
2025
2541
  return groups;
2026
2542
  }
2027
- function splitByRepo(events) {
2543
+ var REPO_SWITCH_GAP_MS = 10 * 60 * 1e3;
2544
+ function splitByRepo(events, gapMs = 0) {
2028
2545
  if (events.length === 0) return [];
2029
2546
  const groups = [[events[0]]];
2030
2547
  for (let i = 1; i < events.length; i++) {
2031
2548
  const prevRepo = events[i - 1].repo_name;
2032
2549
  const currRepo = events[i].repo_name;
2033
- if (currRepo !== prevRepo) {
2550
+ const prevTime = new Date(events[i - 1].captured_at).getTime();
2551
+ const currTime = new Date(events[i].captured_at).getTime();
2552
+ const gap = currTime - prevTime;
2553
+ if (currRepo !== prevRepo && gap >= gapMs) {
2034
2554
  groups.push([events[i]]);
2035
2555
  } else {
2036
2556
  groups[groups.length - 1].push(events[i]);
@@ -2075,7 +2595,7 @@ function buildSessions(date, events, savedSummaries = /* @__PURE__ */ new Map())
2075
2595
  const timeGroups = splitByIdleGap(events, idleMs);
2076
2596
  for (const group of timeGroups) {
2077
2597
  if (group.length === 0) continue;
2078
- const repoGroups = splitByRepo(group);
2598
+ const repoGroups = splitByRepo(group, REPO_SWITCH_GAP_MS);
2079
2599
  for (const repoGroup of repoGroups) {
2080
2600
  if (repoGroup.length === 0) continue;
2081
2601
  const first = repoGroup[0];
@@ -2154,9 +2674,9 @@ function registerSessions(program2) {
2154
2674
 
2155
2675
  // src/cli/summary.ts
2156
2676
  var import_chalk6 = __toESM(require("chalk"));
2157
- var import_fs14 = __toESM(require("fs"));
2158
- var import_os5 = __toESM(require("os"));
2159
- var import_path10 = __toESM(require("path"));
2677
+ var import_fs16 = __toESM(require("fs"));
2678
+ var import_os6 = __toESM(require("os"));
2679
+ var import_path11 = __toESM(require("path"));
2160
2680
 
2161
2681
  // src/integrations/webhook.ts
2162
2682
  async function postToWebhook(url, text) {
@@ -2224,7 +2744,7 @@ async function handleWebhook(opts, config, summary) {
2224
2744
  if (webhookUrl) {
2225
2745
  try {
2226
2746
  log.info("Posting to webhook\u2026");
2227
- const username = import_os5.default.userInfo().username;
2747
+ const username = import_os6.default.userInfo().username;
2228
2748
  const body = isSlackWebhookUrl(webhookUrl) ? formatForSlack(summary, username) : summary;
2229
2749
  await postToWebhook(webhookUrl, body);
2230
2750
  log.success("Posted.");
@@ -2261,9 +2781,9 @@ function registerSummary(program2) {
2261
2781
  return;
2262
2782
  }
2263
2783
  const cacheKey = isoWeekKey(today);
2264
- const cacheFile = import_path10.default.join(WEEKLY_CACHE_DIR, `${cacheKey}.md`);
2265
- if (!opts.fresh && import_fs14.default.existsSync(cacheFile)) {
2266
- const cached = import_fs14.default.readFileSync(cacheFile, "utf-8");
2784
+ const cacheFile = import_path11.default.join(WEEKLY_CACHE_DIR, `${cacheKey}.md`);
2785
+ if (!opts.fresh && import_fs16.default.existsSync(cacheFile)) {
2786
+ const cached = import_fs16.default.readFileSync(cacheFile, "utf-8");
2267
2787
  log.plain("");
2268
2788
  log.plain(cached);
2269
2789
  log.plain("");
@@ -2279,8 +2799,8 @@ function registerSummary(program2) {
2279
2799
  log.plain("");
2280
2800
  log.plain(summary);
2281
2801
  log.plain("");
2282
- import_fs14.default.mkdirSync(WEEKLY_CACHE_DIR, { recursive: true });
2283
- import_fs14.default.writeFileSync(cacheFile, summary, { encoding: "utf-8", mode: 384 });
2802
+ import_fs16.default.mkdirSync(WEEKLY_CACHE_DIR, { recursive: true });
2803
+ import_fs16.default.writeFileSync(cacheFile, summary, { encoding: "utf-8", mode: 384 });
2284
2804
  await handleWebhook(opts, config, summary);
2285
2805
  } catch (error) {
2286
2806
  log.error(`Weekly summary failed [${config.ai.provider}]: ${error.message}`);
@@ -2398,8 +2918,8 @@ ${bullets.trim()}`;
2398
2918
  }
2399
2919
 
2400
2920
  // src/cli/export.ts
2401
- var import_fs15 = __toESM(require("fs"));
2402
- var import_path11 = __toESM(require("path"));
2921
+ var import_fs17 = __toESM(require("fs"));
2922
+ var import_path12 = __toESM(require("path"));
2403
2923
  function eventRowToPayload2(row) {
2404
2924
  let metadata = {};
2405
2925
  try {
@@ -2455,9 +2975,9 @@ function registerExport(program2) {
2455
2975
  const payload = { date, sessions: sessionPayloads };
2456
2976
  log.info(`Generating summary via ${config.ai.provider}\u2026`);
2457
2977
  const summary = await provider.summarize(payload);
2458
- const outputPath = opts.output ?? import_path11.default.join(EXPORTS_DIR, `workscribe-${date}.md`);
2459
- if (!import_fs15.default.existsSync(EXPORTS_DIR)) import_fs15.default.mkdirSync(EXPORTS_DIR, { recursive: true, mode: 448 });
2460
- import_fs15.default.writeFileSync(outputPath, `# Work Summary \u2014 ${date}
2978
+ const outputPath = opts.output ?? import_path12.default.join(EXPORTS_DIR, `workscribe-${date}.md`);
2979
+ if (!import_fs17.default.existsSync(EXPORTS_DIR)) import_fs17.default.mkdirSync(EXPORTS_DIR, { recursive: true, mode: 448 });
2980
+ import_fs17.default.writeFileSync(outputPath, `# Work Summary \u2014 ${date}
2461
2981
 
2462
2982
  ${summary}
2463
2983
  `, { encoding: "utf-8", mode: 384 });
@@ -2529,22 +3049,22 @@ function printConfig(obj, indent) {
2529
3049
  }
2530
3050
 
2531
3051
  // src/cli/pause.ts
2532
- var import_fs16 = __toESM(require("fs"));
3052
+ var import_fs18 = __toESM(require("fs"));
2533
3053
  function registerPause(program2) {
2534
3054
  program2.command("pause").description("Pause capture temporarily").action(() => {
2535
- if (import_fs16.default.existsSync(PAUSE_FLAG_PATH)) {
3055
+ if (import_fs18.default.existsSync(PAUSE_FLAG_PATH)) {
2536
3056
  log.info("Capture is already paused. Run workscribe resume to restart.");
2537
3057
  return;
2538
3058
  }
2539
- import_fs16.default.writeFileSync(PAUSE_FLAG_PATH, "", { mode: 384 });
3059
+ import_fs18.default.writeFileSync(PAUSE_FLAG_PATH, "", { mode: 384 });
2540
3060
  log.success("Capture paused. Run workscribe resume to restart.");
2541
3061
  });
2542
3062
  program2.command("resume").description("Resume capture after a pause").action(() => {
2543
- if (!import_fs16.default.existsSync(PAUSE_FLAG_PATH)) {
3063
+ if (!import_fs18.default.existsSync(PAUSE_FLAG_PATH)) {
2544
3064
  log.info("Capture is not paused.");
2545
3065
  return;
2546
3066
  }
2547
- import_fs16.default.rmSync(PAUSE_FLAG_PATH);
3067
+ import_fs18.default.rmSync(PAUSE_FLAG_PATH);
2548
3068
  log.success("Capture resumed.");
2549
3069
  });
2550
3070
  }
@@ -2629,18 +3149,19 @@ function registerClear(program2) {
2629
3149
  }
2630
3150
 
2631
3151
  // src/cli/uninstall.ts
2632
- var import_fs17 = __toESM(require("fs"));
2633
- var import_path12 = __toESM(require("path"));
2634
- var import_os6 = __toESM(require("os"));
3152
+ var import_fs19 = __toESM(require("fs"));
3153
+ var import_path13 = __toESM(require("path"));
3154
+ var import_os7 = __toESM(require("os"));
2635
3155
  var import_prompts3 = __toESM(require("prompts"));
2636
3156
  var import_chalk9 = __toESM(require("chalk"));
2637
3157
  function detectShellRcFiles() {
2638
3158
  const candidates = [
2639
- { name: "zsh", rcFile: import_path12.default.join(import_os6.default.homedir(), ".zshrc") },
2640
- { name: "bash", rcFile: import_path12.default.join(import_os6.default.homedir(), ".bashrc") },
2641
- { name: "bash", rcFile: import_path12.default.join(import_os6.default.homedir(), ".bash_profile") }
3159
+ { name: "zsh", rcFile: import_path13.default.join(import_os7.default.homedir(), ".zshrc") },
3160
+ { name: "bash", rcFile: import_path13.default.join(import_os7.default.homedir(), ".bashrc") },
3161
+ { name: "bash", rcFile: import_path13.default.join(import_os7.default.homedir(), ".bash_profile") },
3162
+ { name: "fish", rcFile: import_path13.default.join(import_os7.default.homedir(), ".config", "fish", "conf.d", "workscribe.fish") }
2642
3163
  ];
2643
- return candidates.filter((candidate) => import_fs17.default.existsSync(candidate.rcFile) && isHookInstalled(candidate.rcFile));
3164
+ return candidates.filter((candidate) => import_fs19.default.existsSync(candidate.rcFile) && isHookInstalled(candidate.rcFile));
2644
3165
  }
2645
3166
  function registerUninstall(program2) {
2646
3167
  program2.command("uninstall").description("Remove shell hooks and optionally delete all Workscribe data").option("--hooks-only", "remove shell hooks only, skip data deletion prompt (used by npm preuninstall)").action(async (opts) => {
@@ -2650,16 +3171,23 @@ function registerUninstall(program2) {
2650
3171
  log.info("No shell hooks found");
2651
3172
  } else {
2652
3173
  for (const { name, rcFile } of hooked) {
2653
- removeHook(rcFile);
2654
- log.success(`Shell hook removed from ${rcFile}`);
2655
- log.plain(` ${import_chalk9.default.dim(`Run: source ${rcFile}`)} (or restart your terminal to take effect)`);
2656
- log.plain(
2657
- ` ${import_chalk9.default.dim(`${name === "zsh" ? "unfunction" : "unset -f"} _workscribe_preexec _workscribe_precmd`)}`
2658
- );
3174
+ if (name === "fish") {
3175
+ import_fs19.default.unlinkSync(rcFile);
3176
+ log.success(`Shell hook removed: ${rcFile}`);
3177
+ log.plain(` ${import_chalk9.default.dim("Restart your terminal to take effect")}`);
3178
+ } else {
3179
+ removeHook(rcFile);
3180
+ log.success(`Shell hook removed from ${rcFile}`);
3181
+ log.plain(` ${import_chalk9.default.dim(`Run: source ${rcFile}`)} (or restart your terminal to take effect)`);
3182
+ log.plain(
3183
+ ` ${import_chalk9.default.dim(`${name === "zsh" ? "unfunction" : "unset -f"} _workscribe_preexec _workscribe_precmd`)}`
3184
+ );
3185
+ }
2659
3186
  }
2660
3187
  }
2661
3188
  if (opts.hooksOnly) return;
2662
- if (import_fs17.default.existsSync(WORKSCRIBE_DIR)) {
3189
+ const existingDirs = [...new Set(REQUIRED_DIRS)].filter((dir) => import_fs19.default.existsSync(dir));
3190
+ if (existingDirs.length > 0) {
2663
3191
  log.plain("");
2664
3192
  const { deleteData } = await (0, import_prompts3.default)({
2665
3193
  type: "confirm",
@@ -2668,8 +3196,10 @@ function registerUninstall(program2) {
2668
3196
  initial: false
2669
3197
  });
2670
3198
  if (deleteData) {
2671
- import_fs17.default.rmSync(WORKSCRIBE_DIR, { recursive: true, force: true });
2672
- log.success(`Deleted ${WORKSCRIBE_DIR}`);
3199
+ for (const dir of existingDirs) {
3200
+ import_fs19.default.rmSync(dir, { recursive: true, force: true });
3201
+ log.success(`Deleted ${dir}`);
3202
+ }
2673
3203
  } else {
2674
3204
  log.info(`Data kept at ${WORKSCRIBE_DIR}`);
2675
3205
  }
@@ -2681,8 +3211,8 @@ function registerUninstall(program2) {
2681
3211
  }
2682
3212
 
2683
3213
  // src/cli/profile.ts
2684
- var import_fs18 = __toESM(require("fs"));
2685
- var import_path13 = __toESM(require("path"));
3214
+ var import_fs20 = __toESM(require("fs"));
3215
+ var import_path14 = __toESM(require("path"));
2686
3216
  var import_chalk10 = __toESM(require("chalk"));
2687
3217
  var MIN_SUGGEST_COUNT = 5;
2688
3218
  function registerProfile(program2) {
@@ -2728,7 +3258,7 @@ function registerProfile(program2) {
2728
3258
  log.plain(import_chalk10.default.dim(" " + "\u2500".repeat(60)));
2729
3259
  for (const entry of entries) {
2730
3260
  const badge = activeNames.has(entry.name) ? import_chalk10.default.green(" [active]") : "";
2731
- const installed = import_fs18.default.existsSync(import_path13.default.join(PROFILES_DIR, `${entry.name}.json`)) ? import_chalk10.default.dim(" installed") : "";
3261
+ const installed = import_fs20.default.existsSync(import_path14.default.join(PROFILES_DIR, `${entry.name}.json`)) ? import_chalk10.default.dim(" installed") : "";
2732
3262
  log.plain(` ${import_chalk10.default.cyan(entry.name.padEnd(18))} ${entry.displayName.padEnd(28)}${badge}${installed}`);
2733
3263
  if (entry.description) log.plain(` ${import_chalk10.default.dim(" ".repeat(18) + entry.description)}`);
2734
3264
  }
@@ -2764,8 +3294,8 @@ function registerProfile(program2) {
2764
3294
  continue;
2765
3295
  }
2766
3296
  activeNames.delete(name);
2767
- const cachePath = import_path13.default.join(PROFILES_DIR, `${name}.json`);
2768
- if (import_fs18.default.existsSync(cachePath)) import_fs18.default.unlinkSync(cachePath);
3297
+ const cachePath = import_path14.default.join(PROFILES_DIR, `${name}.json`);
3298
+ if (import_fs20.default.existsSync(cachePath)) import_fs20.default.unlinkSync(cachePath);
2769
3299
  log.success(`Profile "${name}" removed`);
2770
3300
  }
2771
3301
  config.capture.profiles = [...activeNames];
@@ -2829,14 +3359,37 @@ function registerProfile(program2) {
2829
3359
  const suggestions = [];
2830
3360
  for (const entry of manifest) {
2831
3361
  if (activeNames.has(entry.name)) continue;
2832
- const matchedCount = toolCounts.filter((tc) => entry.keywords.some((kw) => tc.tool.toLowerCase().includes(kw.toLowerCase()))).reduce((sum, tc) => sum + tc.count, 0);
3362
+ const matchedCount = toolCounts.filter(
3363
+ (toolCount) => entry.keywords.some((keyword) => toolCount.tool.toLowerCase().includes(keyword.toLowerCase()))
3364
+ ).reduce((total, toolCount) => total + toolCount.count, 0);
2833
3365
  if (matchedCount >= MIN_SUGGEST_COUNT) {
2834
3366
  suggestions.push({ entry, count: matchedCount });
2835
3367
  }
2836
3368
  }
2837
3369
  suggestions.sort((a, b) => b.count - a.count);
2838
3370
  if (suggestions.length === 0) {
2839
- log.plain("No profile matches found. Your commands are well covered.");
3371
+ const activeEntries = manifest.filter((entry) => activeNames.has(entry.name));
3372
+ const isToolCoveredByActiveProfile = (tool) => activeEntries.some(
3373
+ (entry) => entry.keywords.some((keyword) => tool.toLowerCase().includes(keyword.toLowerCase()))
3374
+ );
3375
+ const uncoveredTools = toolCounts.filter((toolCount) => !isToolCoveredByActiveProfile(toolCount.tool));
3376
+ if (uncoveredTools.length === 0) {
3377
+ log.plain("");
3378
+ log.plain("Your active profiles cover all unclassified commands.");
3379
+ log.plain(import_chalk10.default.dim(" Future captures will be classified correctly."));
3380
+ log.plain("");
3381
+ } else {
3382
+ log.plain("");
3383
+ log.plain("No profile matches found for your unclassified commands.");
3384
+ log.plain("");
3385
+ log.plain(import_chalk10.default.dim(" Top unclassified tools:"));
3386
+ for (const { tool, count } of uncoveredTools.slice(0, 5)) {
3387
+ log.plain(import_chalk10.default.dim(` ${tool.padEnd(24)} ${count} event${count === 1 ? "" : "s"}`));
3388
+ }
3389
+ log.plain("");
3390
+ log.plain(import_chalk10.default.dim(" Run workscribe events --category other to review them."));
3391
+ log.plain("");
3392
+ }
2840
3393
  return;
2841
3394
  }
2842
3395
  log.plain("");
@@ -2848,8 +3401,25 @@ function registerProfile(program2) {
2848
3401
  );
2849
3402
  }
2850
3403
  log.plain("");
2851
- log.plain(` Run: ${import_chalk10.default.cyan("workscribe profile add " + suggestions.map((s) => s.entry.name).join(" "))}`);
3404
+ log.plain(
3405
+ ` Run: ${import_chalk10.default.cyan("workscribe profile add " + suggestions.map((suggestion) => suggestion.entry.name).join(" "))}`
3406
+ );
2852
3407
  log.plain("");
3408
+ const allCoveredEntries = [
3409
+ ...manifest.filter((entry) => activeNames.has(entry.name)),
3410
+ ...suggestions.map((suggestion) => suggestion.entry)
3411
+ ];
3412
+ const isToolCoveredAfterInstall = (tool) => allCoveredEntries.some(
3413
+ (entry) => entry.keywords.some((keyword) => tool.toLowerCase().includes(keyword.toLowerCase()))
3414
+ );
3415
+ const stillUncovered = toolCounts.filter((toolCount) => !isToolCoveredAfterInstall(toolCount.tool)).slice(0, 5);
3416
+ if (stillUncovered.length > 0) {
3417
+ log.plain(import_chalk10.default.dim(" Still uncovered after installing:"));
3418
+ for (const { tool, count } of stillUncovered) {
3419
+ log.plain(import_chalk10.default.dim(` ${tool.padEnd(24)} ${count} event${count === 1 ? "" : "s"}`));
3420
+ }
3421
+ log.plain("");
3422
+ }
2853
3423
  });
2854
3424
  }
2855
3425
 
@@ -2866,7 +3436,9 @@ function registerNote(program2) {
2866
3436
  repo_name: repoName,
2867
3437
  branch,
2868
3438
  category: "note",
2869
- metadata: JSON.stringify({ text })
3439
+ metadata: JSON.stringify({ text }),
3440
+ source: null,
3441
+ file_path: null
2870
3442
  });
2871
3443
  log.success(`Note saved${repoName ? ` to ${repoName}` : ""}`);
2872
3444
  });
@@ -2874,9 +3446,9 @@ function registerNote(program2) {
2874
3446
 
2875
3447
  // src/cli/week.ts
2876
3448
  var import_chalk11 = __toESM(require("chalk"));
2877
- var import_fs19 = __toESM(require("fs"));
2878
- var import_path14 = __toESM(require("path"));
2879
- var import_os7 = __toESM(require("os"));
3449
+ var import_fs21 = __toESM(require("fs"));
3450
+ var import_path15 = __toESM(require("path"));
3451
+ var import_os8 = __toESM(require("os"));
2880
3452
  function eventRowToPayload3(row) {
2881
3453
  let metadata = {};
2882
3454
  try {
@@ -2915,7 +3487,7 @@ async function postSummary(opts, config, summary) {
2915
3487
  }
2916
3488
  try {
2917
3489
  log.info("Posting to webhook\u2026");
2918
- const username = import_os7.default.userInfo().username;
3490
+ const username = import_os8.default.userInfo().username;
2919
3491
  const body = isSlackWebhookUrl(webhookUrl) ? formatForSlack(summary, username) : summary;
2920
3492
  await postToWebhook(webhookUrl, body);
2921
3493
  log.success("Posted.");
@@ -2948,9 +3520,9 @@ function registerWeek(program2) {
2948
3520
  return;
2949
3521
  }
2950
3522
  const cacheKey = isoWeekKey(today);
2951
- const cacheFile = import_path14.default.join(WEEKLY_CACHE_DIR, `${cacheKey}.md`);
2952
- if (!opts.fresh && import_fs19.default.existsSync(cacheFile)) {
2953
- const cached = import_fs19.default.readFileSync(cacheFile, "utf-8");
3523
+ const cacheFile = import_path15.default.join(WEEKLY_CACHE_DIR, `${cacheKey}.md`);
3524
+ if (!opts.fresh && import_fs21.default.existsSync(cacheFile)) {
3525
+ const cached = import_fs21.default.readFileSync(cacheFile, "utf-8");
2954
3526
  log.plain("");
2955
3527
  log.plain(cached);
2956
3528
  log.plain("");
@@ -2966,8 +3538,8 @@ function registerWeek(program2) {
2966
3538
  log.plain("");
2967
3539
  log.plain(summary);
2968
3540
  log.plain("");
2969
- import_fs19.default.mkdirSync(WEEKLY_CACHE_DIR, { recursive: true });
2970
- import_fs19.default.writeFileSync(cacheFile, summary, { encoding: "utf-8", mode: 384 });
3541
+ import_fs21.default.mkdirSync(WEEKLY_CACHE_DIR, { recursive: true });
3542
+ import_fs21.default.writeFileSync(cacheFile, summary, { encoding: "utf-8", mode: 384 });
2971
3543
  await postSummary(opts, config, summary);
2972
3544
  } catch (error) {
2973
3545
  log.error(`Weekly digest failed [${config.ai.provider}]: ${error.message}`);
@@ -2976,10 +3548,236 @@ function registerWeek(program2) {
2976
3548
  });
2977
3549
  }
2978
3550
 
3551
+ // src/cli/bug-report.ts
3552
+ var import_fs22 = __toESM(require("fs"));
3553
+ var import_os9 = __toESM(require("os"));
3554
+ var import_path16 = __toESM(require("path"));
3555
+ var import_chalk12 = __toESM(require("chalk"));
3556
+ var GITHUB_ISSUES_URL = "https://github.com/workscribe/workscribe/issues/new";
3557
+ var DEBUG_LOG_TAIL_LINES = 100;
3558
+ function tailFile(filePath, lines) {
3559
+ if (!import_fs22.default.existsSync(filePath)) return "(no debug log found \u2014 set WORKSCRIBE_DEBUG=1 to enable)";
3560
+ const content = import_fs22.default.readFileSync(filePath, "utf-8");
3561
+ const allLines = content.split("\n");
3562
+ return allLines.slice(-lines).join("\n");
3563
+ }
3564
+ function redactConfig() {
3565
+ try {
3566
+ const config = readConfig();
3567
+ if (config.ai.apiKey) config.ai.apiKey = "<redacted>";
3568
+ if (config.integrations?.slackWebhook) config.integrations.slackWebhook = "<redacted>";
3569
+ if (config.integrations?.webhook) config.integrations.webhook = "<redacted>";
3570
+ return JSON.stringify(config, null, 2);
3571
+ } catch {
3572
+ return "(could not read config)";
3573
+ }
3574
+ }
3575
+ function registerBugReport(program2) {
3576
+ program2.command("bug-report").description("Generate a diagnostic report for filing a bug").action(() => {
3577
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3578
+ const reportPath = import_path16.default.join(WORKSCRIBE_DIR, `bug-report-${timestamp}.txt`);
3579
+ const systemInfo = [
3580
+ `Workscribe version : ${"1.13.0"}`,
3581
+ `Node.js version : ${process.version}`,
3582
+ `Platform : ${process.platform}`,
3583
+ `Architecture : ${process.arch}`,
3584
+ `OS release : ${import_os9.default.release()}`,
3585
+ `Config path : ${CONFIG_PATH}`,
3586
+ `Data directory : ${WORKSCRIBE_DIR}`
3587
+ ].join("\n");
3588
+ const report = [
3589
+ "=== Workscribe Bug Report ===",
3590
+ `Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
3591
+ "",
3592
+ "--- System ---",
3593
+ systemInfo,
3594
+ "",
3595
+ "--- Config (secrets redacted) ---",
3596
+ redactConfig(),
3597
+ "",
3598
+ `--- Debug log (last ${DEBUG_LOG_TAIL_LINES} lines) ---`,
3599
+ tailFile(DEBUG_LOG_PATH, DEBUG_LOG_TAIL_LINES)
3600
+ ].join("\n");
3601
+ try {
3602
+ if (!import_fs22.default.existsSync(WORKSCRIBE_DIR)) {
3603
+ import_fs22.default.mkdirSync(WORKSCRIBE_DIR, { recursive: true, mode: 448 });
3604
+ }
3605
+ import_fs22.default.writeFileSync(reportPath, report, { mode: 384 });
3606
+ } catch (error) {
3607
+ log.error(`Could not write report: ${error.message}`);
3608
+ process.exit(1);
3609
+ }
3610
+ const params = new URLSearchParams({
3611
+ title: `Bug report \u2014 workscribe ${"1.13.0"}`,
3612
+ body: `<!-- Attach ${import_path16.default.basename(reportPath)} or paste the relevant sections below -->
3613
+
3614
+ **Describe the issue:**
3615
+
3616
+ **Steps to reproduce:**
3617
+
3618
+ **Expected behaviour:**`
3619
+ });
3620
+ log.success(`Report written to: ${import_chalk12.default.cyan(reportPath)}`);
3621
+ log.plain("");
3622
+ log.plain(` Open a GitHub issue:`);
3623
+ log.plain(` ${import_chalk12.default.cyan(`${GITHUB_ISSUES_URL}?${params}`)}`);
3624
+ log.plain("");
3625
+ log.plain(import_chalk12.default.dim(` Attach the report or paste the relevant sections into the issue.`));
3626
+ log.plain("");
3627
+ });
3628
+ }
3629
+
3630
+ // src/cli/alerts.ts
3631
+ var import_chalk13 = __toESM(require("chalk"));
3632
+ function formatTimestamp(ms) {
3633
+ return new Date(ms).toLocaleString([], {
3634
+ month: "short",
3635
+ day: "numeric",
3636
+ hour: "2-digit",
3637
+ minute: "2-digit"
3638
+ });
3639
+ }
3640
+ function registerAlerts(program2) {
3641
+ const alertsCmd = program2.command("alerts").description("Show and manage security alerts from AI session monitoring").option("--check", "Print warning if alerts are pending (used by shell hooks)").action((opts) => {
3642
+ if (opts.check) {
3643
+ if (hasPendingAlerts()) {
3644
+ log.plain(
3645
+ import_chalk13.default.yellow(" [workscribe] Possible credentials detected in an AI session.") + import_chalk13.default.dim(" Run workscribe alerts for details.")
3646
+ );
3647
+ }
3648
+ return;
3649
+ }
3650
+ const alerts = getPendingAlerts();
3651
+ if (alerts.length === 0) {
3652
+ log.plain("");
3653
+ log.plain(" No pending security alerts.");
3654
+ log.plain("");
3655
+ return;
3656
+ }
3657
+ log.plain("");
3658
+ log.plain(import_chalk13.default.bold(" Security Alerts") + import_chalk13.default.dim(` (${alerts.length} pending)`));
3659
+ log.plain("");
3660
+ for (const alert of alerts) {
3661
+ log.plain(` ${import_chalk13.default.yellow("!")} ${import_chalk13.default.dim(formatTimestamp(alert.created_at))} ${import_chalk13.default.cyan(alert.tool)}`);
3662
+ log.plain(` ${alert.context}`);
3663
+ if (alert.session_id) log.plain(` ${import_chalk13.default.dim("Session: " + alert.session_id)}`);
3664
+ log.plain("");
3665
+ }
3666
+ log.plain(import_chalk13.default.dim(" Run workscribe alerts clear to dismiss all alerts"));
3667
+ log.plain("");
3668
+ });
3669
+ alertsCmd.command("clear").description("Dismiss all pending security alerts").action(() => {
3670
+ const count = clearAlerts();
3671
+ if (count === 0) {
3672
+ log.plain(" No pending alerts to clear.");
3673
+ } else {
3674
+ log.plain(` ${count} alert${count === 1 ? "" : "s"} cleared.`);
3675
+ }
3676
+ });
3677
+ }
3678
+
3679
+ // src/cli/context.ts
3680
+ var import_path17 = __toESM(require("path"));
3681
+ var import_chalk14 = __toESM(require("chalk"));
3682
+ function printSessionContext(session, heading) {
3683
+ const ago = timeAgo(session.started_at);
3684
+ const branch = getSessionBranch(session.id);
3685
+ const durationMinutes = (new Date(session.ended_at).getTime() - new Date(session.started_at).getTime()) / 6e4;
3686
+ log.plain("");
3687
+ log.plain(` ${import_chalk14.default.bold(heading)}: ${import_chalk14.default.cyan(ago)}`);
3688
+ log.plain("");
3689
+ log.plain(` Date ${session.date}`);
3690
+ log.plain(` Duration ${formatDuration(durationMinutes)}`);
3691
+ if (branch) log.plain(` Branch ${import_chalk14.default.dim(branch)}`);
3692
+ log.plain(` Events ${session.event_count}`);
3693
+ if (session.summary) {
3694
+ log.plain("");
3695
+ for (const line of session.summary.split("\n")) {
3696
+ log.plain(` ${line}`);
3697
+ }
3698
+ } else {
3699
+ log.plain("");
3700
+ log.plain(import_chalk14.default.dim(` No summary \u2014 run: workscribe summary ${session.date}`));
3701
+ }
3702
+ log.plain("");
3703
+ }
3704
+ function registerContext(program2) {
3705
+ program2.command("context").description("Show the last session for the current repo").option("--file <path>", "narrow to last session that touched this file").action((opts) => {
3706
+ if (opts.file) {
3707
+ const normalizedFile = import_path17.default.normalize(opts.file);
3708
+ const session = getLastSessionTouchingFile(normalizedFile);
3709
+ if (!session) {
3710
+ log.plain(` No sessions found touching ${normalizedFile}`);
3711
+ return;
3712
+ }
3713
+ printSessionContext(session, `Last session touching ${import_path17.default.basename(normalizedFile)}`);
3714
+ return;
3715
+ }
3716
+ const { repoName } = getGitContext(process.cwd());
3717
+ if (!repoName) {
3718
+ log.plain(" Not in a git repository. Run workscribe context from a project directory.");
3719
+ return;
3720
+ }
3721
+ const sessions = getRecentSessionsByRepo(repoName, 1);
3722
+ if (sessions.length === 0) {
3723
+ log.plain(` No sessions found for ${repoName}`);
3724
+ return;
3725
+ }
3726
+ printSessionContext(sessions[0], `Last session on ${repoName}`);
3727
+ });
3728
+ }
3729
+
3730
+ // src/utils/update-check.ts
3731
+ var import_fs23 = __toESM(require("fs"));
3732
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
3733
+ function readCache() {
3734
+ try {
3735
+ return JSON.parse(import_fs23.default.readFileSync(UPDATE_CHECK_PATH, "utf-8"));
3736
+ } catch {
3737
+ return null;
3738
+ }
3739
+ }
3740
+ function writeCache(cache) {
3741
+ try {
3742
+ import_fs23.default.writeFileSync(UPDATE_CHECK_PATH, JSON.stringify(cache), { mode: 384 });
3743
+ } catch {
3744
+ }
3745
+ }
3746
+ function isNewer(latest, current) {
3747
+ const parse = (version) => version.replace(/^v/, "").split(".").map(Number);
3748
+ const [latestMajor, latestMinor, latestPatch] = parse(latest);
3749
+ const [currentMajor, currentMinor, currentPatch] = parse(current);
3750
+ if (latestMajor !== currentMajor) return latestMajor > currentMajor;
3751
+ if (latestMinor !== currentMinor) return latestMinor > currentMinor;
3752
+ return latestPatch > currentPatch;
3753
+ }
3754
+ function checkForUpdate() {
3755
+ const current = "1.13.0";
3756
+ const cache = readCache();
3757
+ if (cache && isNewer(cache.latestVersion, current)) {
3758
+ process.stderr.write(
3759
+ `
3760
+ Update available ${current} \u2192 ${cache.latestVersion}
3761
+ Run: npm install -g @workscribe/cli
3762
+
3763
+ `
3764
+ );
3765
+ }
3766
+ const now = Date.now();
3767
+ if (!cache || now - cache.lastChecked > CHECK_INTERVAL_MS) {
3768
+ fetch("https://registry.npmjs.org/@workscribe/cli/latest").then((response) => response.json()).then((data) => {
3769
+ const latest = data.version;
3770
+ if (latest) writeCache({ lastChecked: now, latestVersion: latest });
3771
+ }).catch(() => {
3772
+ });
3773
+ }
3774
+ }
3775
+
2979
3776
  // src/index.ts
2980
3777
  assertNodeVersion(process.versions.node);
3778
+ checkForUpdate();
2981
3779
  var program = new import_commander.Command();
2982
- program.name("workscribe").description("Automatic developer work journal").version("1.11.0");
3780
+ program.name("workscribe").description("Automatic developer work journal").version("1.13.0");
2983
3781
  registerCapture(program);
2984
3782
  registerHook(program);
2985
3783
  registerInit(program);
@@ -2996,5 +3794,9 @@ registerUninstall(program);
2996
3794
  registerProfile(program);
2997
3795
  registerNote(program);
2998
3796
  registerWeek(program);
3797
+ registerBugReport(program);
3798
+ registerCompletions(program);
3799
+ registerAlerts(program);
3800
+ registerContext(program);
2999
3801
  program.parse();
3000
3802
  //# sourceMappingURL=index.js.map