@forwardimpact/basecamp 0.2.0 → 1.0.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.
@@ -7,8 +7,6 @@
7
7
  // node basecamp.js --daemon Run continuously (poll every 60s)
8
8
  // node basecamp.js --run <task> Run a specific task immediately
9
9
  // node basecamp.js --init <path> Initialize a new knowledge base
10
- // node basecamp.js --install-launchd Install macOS LaunchAgent
11
- // node basecamp.js --uninstall-launchd Remove macOS LaunchAgent
12
10
  // node basecamp.js --validate Validate agents and skills exist
13
11
  // node basecamp.js --status Show task status
14
12
  // node basecamp.js --help Show this help
@@ -18,7 +16,6 @@ import {
18
16
  writeFileSync,
19
17
  existsSync,
20
18
  mkdirSync,
21
- readdirSync,
22
19
  unlinkSync,
23
20
  chmodSync,
24
21
  } from "node:fs";
@@ -34,16 +31,25 @@ const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
34
31
  const CONFIG_PATH = join(BASECAMP_HOME, "scheduler.json");
35
32
  const STATE_PATH = join(BASECAMP_HOME, "state.json");
36
33
  const LOG_DIR = join(BASECAMP_HOME, "logs");
37
- const PLIST_NAME = "com.fit-basecamp.scheduler";
38
- const PLIST_PATH = join(HOME, "Library", "LaunchAgents", `${PLIST_NAME}.plist`);
39
34
  const __dirname =
40
35
  import.meta.dirname || dirname(fileURLToPath(import.meta.url));
41
- const KB_TEMPLATE_DIR = join(__dirname, "template");
36
+ const SHARE_DIR = "/usr/local/share/fit-basecamp";
42
37
  const SOCKET_PATH = join(BASECAMP_HOME, "basecamp.sock");
43
- const IS_COMPILED =
44
- typeof Deno !== "undefined" &&
45
- Deno.execPath &&
46
- !Deno.execPath().endsWith("deno");
38
+
39
+ // --- posix_spawn (macOS app bundle only) ------------------------------------
40
+
41
+ const USE_POSIX_SPAWN = !!process.env.BASECAMP_BUNDLE;
42
+ let posixSpawn;
43
+ if (USE_POSIX_SPAWN) {
44
+ try {
45
+ posixSpawn = await import("./posix-spawn.js");
46
+ } catch (err) {
47
+ console.error(
48
+ "Failed to load posix-spawn, falling back to child_process:",
49
+ err.message,
50
+ );
51
+ }
52
+ }
47
53
 
48
54
  let daemonStartedAt = null;
49
55
 
@@ -87,23 +93,36 @@ function log(msg) {
87
93
  }
88
94
 
89
95
  function findClaude() {
90
- for (const c of [
91
- "claude",
96
+ const paths = [
92
97
  "/usr/local/bin/claude",
93
98
  join(HOME, ".claude", "bin", "claude"),
94
99
  join(HOME, ".local", "bin", "claude"),
95
- ]) {
96
- try {
97
- execSync(`which "${c}" 2>/dev/null || command -v "${c}" 2>/dev/null`, {
98
- encoding: "utf8",
99
- });
100
- return c;
101
- } catch {}
102
- if (existsSync(c)) return c;
103
- }
100
+ "/opt/homebrew/bin/claude",
101
+ ];
102
+ for (const p of paths) if (existsSync(p)) return p;
104
103
  return "claude";
105
104
  }
106
105
 
106
+ /**
107
+ * Detect if running from inside a macOS .app bundle.
108
+ * The binary is at Basecamp.app/Contents/MacOS/fit-basecamp.
109
+ * @returns {{ bundle: string, resources: string } | null}
110
+ */
111
+ function getBundlePath() {
112
+ try {
113
+ const exe = process.execPath || "";
114
+ const macosDir = dirname(exe);
115
+ const contentsDir = dirname(macosDir);
116
+ const resourcesDir = join(contentsDir, "Resources");
117
+ if (existsSync(join(resourcesDir, "config"))) {
118
+ return { bundle: dirname(contentsDir), resources: resourcesDir };
119
+ }
120
+ } catch {
121
+ /* not in bundle */
122
+ }
123
+ return null;
124
+ }
125
+
107
126
  function loadConfig() {
108
127
  return readJSON(CONFIG_PATH, { tasks: {} });
109
128
  }
@@ -180,7 +199,7 @@ function shouldRun(task, taskState, now) {
180
199
 
181
200
  // --- Task execution ---------------------------------------------------------
182
201
 
183
- function runTask(taskName, task, _config, state) {
202
+ async function runTask(taskName, task, _config, state) {
184
203
  if (!task.kb) {
185
204
  log(`Task ${taskName}: no "kb" specified, skipping.`);
186
205
  return;
@@ -209,6 +228,58 @@ function runTask(taskName, task, _config, state) {
209
228
  if (task.agent) spawnArgs.push("--agent", task.agent);
210
229
  spawnArgs.push("-p", prompt);
211
230
 
231
+ // Use posix_spawn when running inside the app bundle for TCC inheritance.
232
+ // Fall back to child_process.spawn for dev mode and other platforms.
233
+ if (posixSpawn) {
234
+ try {
235
+ const { pid, stdoutFd, stderrFd } = posixSpawn.spawn(
236
+ claude,
237
+ spawnArgs,
238
+ undefined,
239
+ kbPath,
240
+ );
241
+
242
+ // Read stdout and stderr concurrently to avoid pipe deadlocks,
243
+ // then wait for the child to exit.
244
+ const [stdout, stderr] = await Promise.all([
245
+ posixSpawn.readAll(stdoutFd),
246
+ posixSpawn.readAll(stderrFd),
247
+ ]);
248
+ const exitCode = await posixSpawn.waitForExit(pid);
249
+
250
+ if (exitCode === 0) {
251
+ log(`Task ${taskName} completed. Output: ${stdout.slice(0, 200)}...`);
252
+ Object.assign(ts, {
253
+ status: "finished",
254
+ startedAt: null,
255
+ lastRunAt: new Date().toISOString(),
256
+ lastError: null,
257
+ runCount: (ts.runCount || 0) + 1,
258
+ });
259
+ } else {
260
+ const errMsg = stderr || stdout || `Exit code ${exitCode}`;
261
+ log(`Task ${taskName} failed: ${errMsg.slice(0, 300)}`);
262
+ Object.assign(ts, {
263
+ status: "failed",
264
+ startedAt: null,
265
+ lastRunAt: new Date().toISOString(),
266
+ lastError: errMsg.slice(0, 500),
267
+ });
268
+ }
269
+ saveState(state);
270
+ } catch (err) {
271
+ log(`Task ${taskName} failed: ${err.message}`);
272
+ Object.assign(ts, {
273
+ status: "failed",
274
+ startedAt: null,
275
+ lastRunAt: new Date().toISOString(),
276
+ lastError: err.message.slice(0, 500),
277
+ });
278
+ saveState(state);
279
+ }
280
+ return;
281
+ }
282
+
212
283
  return new Promise((resolve) => {
213
284
  const child = spawn(claude, spawnArgs, {
214
285
  cwd: kbPath,
@@ -345,36 +416,6 @@ function handleStatusRequest(socket) {
345
416
  });
346
417
  }
347
418
 
348
- function handleRestartRequest(socket) {
349
- send(socket, { type: "ack", command: "restart" });
350
- const uid = execSync("id -u", { encoding: "utf8" }).trim();
351
- setTimeout(() => {
352
- try {
353
- execSync(`launchctl kickstart -k gui/${uid}/${PLIST_NAME}`);
354
- } catch {
355
- process.exit(0);
356
- }
357
- }, 100);
358
- }
359
-
360
- function handleRunRequest(socket, taskName) {
361
- if (!taskName) {
362
- send(socket, { type: "error", message: "Missing task name" });
363
- return;
364
- }
365
- const config = loadConfig();
366
- const task = config.tasks[taskName];
367
- if (!task) {
368
- send(socket, { type: "error", message: `Task not found: ${taskName}` });
369
- return;
370
- }
371
- send(socket, { type: "ack", command: "run", task: taskName });
372
- const state = loadState();
373
- runTask(taskName, task, config, state).catch((err) => {
374
- console.error(`[socket] runTask error for ${taskName}:`, err.message);
375
- });
376
- }
377
-
378
419
  function handleMessage(socket, line) {
379
420
  let request;
380
421
  try {
@@ -384,21 +425,32 @@ function handleMessage(socket, line) {
384
425
  return;
385
426
  }
386
427
 
387
- const handlers = {
388
- status: () => handleStatusRequest(socket),
389
- restart: () => handleRestartRequest(socket),
390
- run: () => handleRunRequest(socket, request.task),
391
- };
428
+ if (request.type === "status") return handleStatusRequest(socket);
392
429
 
393
- const handler = handlers[request.type];
394
- if (handler) {
395
- handler();
396
- } else {
397
- send(socket, {
398
- type: "error",
399
- message: `Unknown request type: ${request.type}`,
400
- });
430
+ if (request.type === "run") {
431
+ if (!request.task) {
432
+ send(socket, { type: "error", message: "Missing task name" });
433
+ return;
434
+ }
435
+ const config = loadConfig();
436
+ const task = config.tasks[request.task];
437
+ if (!task) {
438
+ send(socket, {
439
+ type: "error",
440
+ message: `Task not found: ${request.task}`,
441
+ });
442
+ return;
443
+ }
444
+ send(socket, { type: "ack", command: "run", task: request.task });
445
+ const state = loadState();
446
+ runTask(request.task, task, config, state).catch(() => {});
447
+ return;
401
448
  }
449
+
450
+ send(socket, {
451
+ type: "error",
452
+ message: `Unknown request type: ${request.type}`,
453
+ });
402
454
  }
403
455
 
404
456
  function startSocketServer() {
@@ -431,9 +483,6 @@ function startSocketServer() {
431
483
 
432
484
  const cleanup = () => {
433
485
  server.close();
434
- try {
435
- unlinkSync(SOCKET_PATH);
436
- } catch {}
437
486
  process.exit(0);
438
487
  };
439
488
  process.on("SIGTERM", cleanup);
@@ -449,7 +498,7 @@ function daemon() {
449
498
  log("Scheduler daemon started. Polling every 60 seconds.");
450
499
  log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
451
500
  startSocketServer();
452
- runDueTasks();
501
+ runDueTasks().catch((err) => log(`Error: ${err.message}`));
453
502
  setInterval(async () => {
454
503
  try {
455
504
  await runDueTasks();
@@ -461,17 +510,18 @@ function daemon() {
461
510
 
462
511
  // --- Init knowledge base ----------------------------------------------------
463
512
 
464
- function copyDirRecursive(src, dest) {
465
- for (const entry of readdirSync(src, { withFileTypes: true })) {
466
- const s = join(src, entry.name),
467
- d = join(dest, entry.name);
468
- if (entry.isDirectory()) {
469
- ensureDir(d);
470
- copyDirRecursive(s, d);
471
- } else if (!existsSync(d)) {
472
- writeFileSync(d, readFileSync(s));
473
- }
513
+ function findTemplateDir() {
514
+ const bundle = getBundlePath();
515
+ if (bundle) {
516
+ const tpl = join(bundle.resources, "template");
517
+ if (existsSync(tpl)) return tpl;
474
518
  }
519
+ for (const d of [
520
+ join(SHARE_DIR, "template"),
521
+ join(__dirname, "..", "template"),
522
+ ])
523
+ if (existsSync(d)) return d;
524
+ return null;
475
525
  }
476
526
 
477
527
  function initKB(targetPath) {
@@ -480,6 +530,11 @@ function initKB(targetPath) {
480
530
  console.error(`Knowledge base already exists at ${dest}`);
481
531
  process.exit(1);
482
532
  }
533
+ const tpl = findTemplateDir();
534
+ if (!tpl) {
535
+ console.error("Template not found. Reinstall fit-basecamp.");
536
+ process.exit(1);
537
+ }
483
538
 
484
539
  ensureDir(dest);
485
540
  for (const d of [
@@ -487,80 +542,16 @@ function initKB(targetPath) {
487
542
  "knowledge/Organizations",
488
543
  "knowledge/Projects",
489
544
  "knowledge/Topics",
490
- ".claude/skills",
491
545
  ])
492
546
  ensureDir(join(dest, d));
493
547
 
494
- if (existsSync(KB_TEMPLATE_DIR)) copyDirRecursive(KB_TEMPLATE_DIR, dest);
548
+ execSync(`cp -R "${tpl}/." "${dest}/"`);
495
549
 
496
550
  console.log(
497
551
  `Knowledge base initialized at ${dest}\n\nNext steps:\n 1. Edit ${dest}/USER.md with your name, email, and domain\n 2. cd ${dest} && claude`,
498
552
  );
499
553
  }
500
554
 
501
- // --- LaunchAgent ------------------------------------------------------------
502
-
503
- function installLaunchd() {
504
- const execPath =
505
- typeof Deno !== "undefined" ? Deno.execPath() : process.execPath;
506
- const isCompiled = IS_COMPILED || !execPath.includes("node");
507
- const progArgs = isCompiled
508
- ? ` <string>${execPath}</string>\n <string>--daemon</string>`
509
- : ` <string>${execPath}</string>\n <string>${join(__dirname, "basecamp.js")}</string>\n <string>--daemon</string>`;
510
-
511
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
512
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
513
- <plist version="1.0">
514
- <dict>
515
- <key>Label</key>
516
- <string>${PLIST_NAME}</string>
517
- <key>ProgramArguments</key>
518
- <array>
519
- ${progArgs}
520
- </array>
521
- <key>RunAtLoad</key>
522
- <true/>
523
- <key>KeepAlive</key>
524
- <true/>
525
- <key>StandardOutPath</key>
526
- <string>${join(LOG_DIR, "launchd-stdout.log")}</string>
527
- <key>StandardErrorPath</key>
528
- <string>${join(LOG_DIR, "launchd-stderr.log")}</string>
529
- <key>WorkingDirectory</key>
530
- <string>${BASECAMP_HOME}</string>
531
- <key>EnvironmentVariables</key>
532
- <dict>
533
- <key>PATH</key>
534
- <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${join(HOME, ".local", "bin")}</string>
535
- </dict>
536
- </dict>
537
- </plist>`;
538
-
539
- ensureDir(dirname(PLIST_PATH));
540
- ensureDir(LOG_DIR);
541
- writeFileSync(PLIST_PATH, plist);
542
-
543
- try {
544
- execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, {
545
- stdio: "ignore",
546
- });
547
- } catch {}
548
- execSync(`launchctl load "${PLIST_PATH}"`);
549
- console.log(
550
- `LaunchAgent installed and loaded.\n Plist: ${PLIST_PATH}\n Logs: ${LOG_DIR}/\n Config: ${CONFIG_PATH}`,
551
- );
552
- }
553
-
554
- function uninstallLaunchd() {
555
- try {
556
- execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`);
557
- } catch {}
558
- try {
559
- execSync(`rm -f "${PLIST_PATH}"`);
560
- } catch {}
561
- console.log("LaunchAgent uninstalled.");
562
- }
563
-
564
555
  // --- Status -----------------------------------------------------------------
565
556
 
566
557
  function showStatus() {
@@ -570,35 +561,23 @@ function showStatus() {
570
561
 
571
562
  const tasks = Object.entries(config.tasks || {});
572
563
  if (tasks.length === 0) {
573
- console.log(
574
- `Tasks: (none configured)\n\nEdit ${CONFIG_PATH} to add tasks.`,
575
- );
564
+ console.log(`No tasks configured.\n\nEdit ${CONFIG_PATH} to add tasks.`);
576
565
  return;
577
566
  }
578
567
 
579
568
  console.log("Tasks:");
580
569
  for (const [name, task] of tasks) {
581
570
  const s = state.tasks[name] || {};
582
- const kbPath = task.kb ? expandPath(task.kb) : null;
583
- const kbStatus = kbPath ? (existsSync(kbPath) ? "" : " (not found)") : "";
584
- const lines = [
585
- ` ${task.enabled !== false ? "+" : "-"} ${name}`,
586
- ` KB: ${task.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(task.schedule)}`,
587
- ` Status: ${s.status || "never-run"} Last run: ${s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : "never"} Runs: ${s.runCount || 0}`,
588
- ];
589
- if (task.agent) lines.push(` Agent: ${task.agent}`);
590
- if (task.skill) lines.push(` Skill: ${task.skill}`);
591
- if (s.lastError) lines.push(` Error: ${s.lastError.slice(0, 80)}`);
592
- console.log(lines.join("\n"));
593
- }
594
-
595
- try {
596
- execSync(`launchctl list 2>/dev/null | grep ${PLIST_NAME}`, {
597
- encoding: "utf8",
598
- });
599
- console.log("\nLaunchAgent: loaded");
600
- } catch {
601
- console.log("\nLaunchAgent: not loaded (run --install-launchd to start)");
571
+ const kbStatus =
572
+ task.kb && !existsSync(expandPath(task.kb)) ? " (not found)" : "";
573
+ console.log(
574
+ ` ${task.enabled !== false ? "+" : "-"} ${name}\n` +
575
+ ` KB: ${task.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(task.schedule)}\n` +
576
+ ` Status: ${s.status || "never-run"} Last: ${s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : "never"} Runs: ${s.runCount || 0}` +
577
+ (task.agent ? `\n Agent: ${task.agent}` : "") +
578
+ (task.skill ? `\n Skill: ${task.skill}` : "") +
579
+ (s.lastError ? `\n Error: ${s.lastError.slice(0, 80)}` : ""),
580
+ );
602
581
  }
603
582
  }
604
583
 
@@ -631,7 +610,7 @@ function validate() {
631
610
  }
632
611
  const kbPath = expandPath(task.kb);
633
612
  if (!existsSync(kbPath)) {
634
- console.log(` [FAIL] ${name}: path does not exist: ${kbPath}`);
613
+ console.log(` [FAIL] ${name}: path not found: ${kbPath}`);
635
614
  errors++;
636
615
  continue;
637
616
  }
@@ -646,25 +625,17 @@ function validate() {
646
625
  ? join("agents", sub.endsWith(".md") ? sub : sub + ".md")
647
626
  : join("skills", sub, "SKILL.md");
648
627
  const found = findInLocalOrGlobal(kbPath, relPath);
649
- if (found) {
650
- console.log(` [OK] ${name}: ${kind} "${sub}" found at ${found}`);
651
- } else {
652
- console.log(
653
- ` [FAIL] ${name}: ${kind} "${sub}" not found in ${join(kbPath, ".claude", relPath)} or ${join(HOME, ".claude", relPath)}`,
654
- );
655
- errors++;
656
- }
628
+ console.log(
629
+ ` [${found ? "OK" : "FAIL"}] ${name}: ${kind} "${sub}"${found ? "" : " not found"}`,
630
+ );
631
+ if (!found) errors++;
657
632
  }
658
633
 
659
634
  if (!task.agent && !task.skill)
660
635
  console.log(` [OK] ${name}: no agent or skill to validate`);
661
636
  }
662
637
 
663
- console.log(
664
- errors > 0
665
- ? `\nValidation failed: ${errors} error(s) found.`
666
- : "\nAll tasks validated successfully.",
667
- );
638
+ console.log(errors > 0 ? `\n${errors} error(s).` : "\nAll OK.");
668
639
  if (errors > 0) process.exit(1);
669
640
  }
670
641
 
@@ -673,39 +644,19 @@ function validate() {
673
644
  function showHelp() {
674
645
  const bin = "fit-basecamp";
675
646
  console.log(`
676
- Basecamp Scheduler — Run scheduled tasks across multiple knowledge bases.
647
+ Basecamp — Run scheduled Claude tasks across knowledge bases.
677
648
 
678
649
  Usage:
679
650
  ${bin} Run due tasks once and exit
680
651
  ${bin} --daemon Run continuously (poll every 60s)
681
652
  ${bin} --run <task> Run a specific task immediately
682
653
  ${bin} --init <path> Initialize a new knowledge base
683
- ${bin} --install-launchd Install macOS LaunchAgent for auto-start
684
- ${bin} --uninstall-launchd Remove macOS LaunchAgent
685
654
  ${bin} --validate Validate agents and skills exist
686
655
  ${bin} --status Show task status
687
- ${bin} --help Show this help
688
656
 
689
657
  Config: ~/.fit/basecamp/scheduler.json
690
658
  State: ~/.fit/basecamp/state.json
691
659
  Logs: ~/.fit/basecamp/logs/
692
-
693
- Config format:
694
- {
695
- "tasks": {
696
- "sync-mail": {
697
- "kb": "~/Documents/Personal",
698
- "schedule": { "type": "interval", "minutes": 5 },
699
- "prompt": "Sync Apple Mail.", "skill": "sync-apple-mail",
700
- "agent": null, "enabled": true
701
- }
702
- }
703
- }
704
-
705
- Schedule types:
706
- interval: { "type": "interval", "minutes": 5 }
707
- cron: { "type": "cron", "expression": "0 8 * * *" }
708
- once: { "type": "once", "runAt": "2025-02-12T10:00:00Z" }
709
660
  `);
710
661
  }
711
662
 
@@ -719,8 +670,6 @@ const commands = {
719
670
  "--help": showHelp,
720
671
  "-h": showHelp,
721
672
  "--daemon": daemon,
722
- "--install-launchd": installLaunchd,
723
- "--uninstall-launchd": uninstallLaunchd,
724
673
  "--validate": validate,
725
674
  "--status": showStatus,
726
675
  "--init": () => {
@@ -1,10 +1,47 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
3
3
  "permissions": {
4
+ "defaultMode": "acceptEdits",
4
5
  "allow": [
6
+ "Bash(python3 *)",
7
+ "Bash(sqlite3 *)",
8
+ "Bash(bash scripts/*)",
9
+ "Bash(sh scripts/*)",
10
+ "Bash(node *)",
11
+ "Bash(npm install *)",
12
+ "Bash(npx *)",
13
+ "Bash(rg *)",
14
+ "Bash(find *)",
15
+ "Bash(cat *)",
16
+ "Bash(head *)",
17
+ "Bash(tail *)",
18
+ "Bash(ls *)",
19
+ "Bash(ls)",
20
+ "Bash(grep *)",
21
+ "Bash(echo *)",
22
+ "Bash(date *)",
23
+ "Bash(mkdir *)",
24
+ "Bash(mv *)",
25
+ "Bash(cp *)",
26
+ "Bash(touch *)",
27
+ "Bash(sort *)",
28
+ "Bash(wc *)",
29
+ "Bash(cut *)",
30
+ "Bash(tr *)",
31
+ "Bash(uniq *)",
32
+ "Bash(awk *)",
33
+ "Bash(sed *)",
34
+ "Bash(basename *)",
35
+ "Bash(dirname *)",
36
+ "Bash(realpath *)",
37
+ "Bash(pwd)",
38
+ "Bash(which *)",
39
+ "Bash(textutil *)",
40
+ "Bash(pdftotext *)",
5
41
  "Read(~/Library/Mail/**)",
6
42
  "Read(~/Library/Group Containers/group.com.apple.calendar/**)",
7
43
  "Read(~/Library/Calendars/**)",
44
+ "Read(~/Library/Application Support/hyprnote/**)",
8
45
  "Read(~/Desktop/**)",
9
46
  "Read(~/Documents/**)",
10
47
  "Read(~/Downloads/**)",
@@ -15,6 +52,17 @@
15
52
  "Edit(~/.cache/fit/basecamp/**)"
16
53
  ],
17
54
  "deny": [
55
+ "Bash(curl *)",
56
+ "Bash(wget *)",
57
+ "Bash(open *)",
58
+ "Bash(osascript *)",
59
+ "Bash(sudo *)",
60
+ "Bash(rm -rf *)",
61
+ "Bash(chmod *)",
62
+ "Bash(chown *)",
63
+ "Bash(killall *)",
64
+ "Bash(launchctl *)",
65
+ "Bash(brew *)",
18
66
  "Edit(~/Library/**)",
19
67
  "Read(~/Pictures/**)",
20
68
  "Read(~/Music/**)",
@@ -31,6 +79,7 @@
31
79
  "~/Library/Mail",
32
80
  "~/Library/Group Containers/group.com.apple.calendar",
33
81
  "~/Library/Calendars",
82
+ "~/Library/Application Support/hyprnote",
34
83
  "~/Desktop",
35
84
  "~/Documents",
36
85
  "~/Downloads",
@@ -16,13 +16,15 @@ Run when the user asks to draft, reply to, or respond to an email.
16
16
  ## Prerequisites
17
17
 
18
18
  - Knowledge base populated (from `extract-entities` skill)
19
- - Synced email data in `~/.cache/fit/basecamp/apple_mail/`
19
+ - Synced email data in `~/.cache/fit/basecamp/apple_mail/` or
20
+ `~/.cache/fit/basecamp/gmail/`
20
21
 
21
22
  ## Inputs
22
23
 
23
24
  - `knowledge/People/*.md` — person context
24
25
  - `knowledge/Organizations/*.md` — organization context
25
- - `~/.cache/fit/basecamp/apple_mail/*.md` email threads
26
+ - `~/.cache/fit/basecamp/apple_mail/*.md` or `~/.cache/fit/basecamp/gmail/*.md`
27
+ — email threads
26
28
  - `~/.cache/fit/basecamp/apple_calendar/*.json` — calendar events (for
27
29
  scheduling)
28
30
  - `drafts/last_processed` — timestamp of last processing run
@@ -116,7 +118,7 @@ cat "knowledge/Organizations/Company Name.md"
116
118
  **Calendar (for scheduling emails):**
117
119
 
118
120
  ```bash
119
- ls ~/.cache/fit/basecamp/apple_calendar/ 2>/dev/null
121
+ ls ~/.cache/fit/basecamp/apple_calendar/ ~/.cache/fit/basecamp/google_calendar/ 2>/dev/null
120
122
  cat "$HOME/.cache/fit/basecamp/apple_calendar/event123.json"
121
123
  ```
122
124
 
@@ -184,6 +186,33 @@ date -u '+%Y-%m-%dT%H:%M:%SZ' > drafts/last_processed
184
186
  - {id}: {subject} — {reason}
185
187
  ```
186
188
 
189
+ ## Recruitment & Staffing Emails
190
+
191
+ **CRITICAL: Candidates must NEVER be copied on internal emails about them.**
192
+
193
+ When an email involves recruitment, staffing, or hiring:
194
+
195
+ 1. **Identify the candidate** — Determine who the candidate is from the email
196
+ thread and knowledge base (`knowledge/Candidates/`, `knowledge/People/`)
197
+ 2. **Strip the candidate from recipients** — The draft must ONLY be addressed to
198
+ internal stakeholders (hiring managers, recruiters, interview panel, etc.).
199
+ The candidate’s email address must NOT appear in To, CC, or BCC
200
+ 3. **Only recruiters email candidates directly** — If the email is a direct
201
+ reply TO a candidate (e.g., scheduling an interview, extending an offer),
202
+ flag it clearly so only the recruiter sends it. Add a note:
203
+ `⚠️ RECRUITER ONLY — This email goes directly to the candidate.`
204
+
205
+ **Examples of internal recruitment emails (candidate must NOT be copied):**
206
+
207
+ - Interview feedback or debrief
208
+ - Candidate evaluation or comparison
209
+ - Hiring decision discussions
210
+ - Compensation/offer discussions
211
+ - Reference check follow-ups between colleagues
212
+
213
+ **When in doubt:** If an email thread mentions a candidate by name and involves
214
+ multiple internal recipients, treat it as internal and exclude the candidate.
215
+
187
216
  ## Constraints
188
217
 
189
218
  - Never actually send emails — only create drafts
@@ -3,14 +3,15 @@
3
3
  #
4
4
  # Usage: bash scripts/scan-emails.sh
5
5
  #
6
- # Checks ~/.cache/fit/basecamp/apple_mail/ for email files
7
- # not yet listed in drafts/drafted or drafts/ignored.
6
+ # Checks ~/.cache/fit/basecamp/apple_mail/ for email files not yet
7
+ # listed in drafts/drafted or drafts/ignored.
8
8
  # Outputs tab-separated: email_id<TAB>subject
9
9
 
10
10
  set -euo pipefail
11
11
 
12
12
  MAIL_DIRS=(
13
13
  "$HOME/.cache/fit/basecamp/apple_mail"
14
+ "$HOME/.cache/fit/basecamp/gmail"
14
15
  )
15
16
 
16
17
  for dir in "${MAIL_DIRS[@]}"; do
@@ -264,7 +264,6 @@ person?"** test:
264
264
  - Transactional service providers (bank employees, support reps)
265
265
  - One-time administrative contacts
266
266
  - Large group meeting attendees you didn't interact with
267
- - Internal colleagues (@user.domain)
268
267
  - Assistants handling only logistics
269
268
 
270
269
  For people who don't get their own note, add to the Organization note's