@forwardimpact/basecamp 2.0.0 → 2.2.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.
Files changed (39) hide show
  1. package/config/scheduler.json +5 -0
  2. package/package.json +1 -1
  3. package/src/basecamp.js +288 -57
  4. package/template/.claude/agents/chief-of-staff.md +6 -2
  5. package/template/.claude/agents/concierge.md +2 -3
  6. package/template/.claude/agents/librarian.md +4 -6
  7. package/template/.claude/agents/recruiter.md +222 -0
  8. package/template/.claude/settings.json +0 -4
  9. package/template/.claude/skills/analyze-cv/SKILL.md +267 -0
  10. package/template/.claude/skills/create-presentations/SKILL.md +2 -2
  11. package/template/.claude/skills/create-presentations/references/slide.css +1 -1
  12. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.mjs +47 -0
  13. package/template/.claude/skills/draft-emails/SKILL.md +85 -123
  14. package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
  15. package/template/.claude/skills/draft-emails/scripts/send-email.mjs +118 -0
  16. package/template/.claude/skills/extract-entities/SKILL.md +2 -2
  17. package/template/.claude/skills/extract-entities/scripts/state.mjs +130 -0
  18. package/template/.claude/skills/manage-tasks/SKILL.md +242 -0
  19. package/template/.claude/skills/organize-files/SKILL.md +3 -3
  20. package/template/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
  21. package/template/.claude/skills/organize-files/scripts/summarize.mjs +84 -0
  22. package/template/.claude/skills/process-hyprnote/SKILL.md +2 -2
  23. package/template/.claude/skills/send-chat/SKILL.md +170 -0
  24. package/template/.claude/skills/sync-apple-calendar/SKILL.md +5 -5
  25. package/template/.claude/skills/sync-apple-calendar/scripts/sync.mjs +325 -0
  26. package/template/.claude/skills/sync-apple-mail/SKILL.md +6 -6
  27. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +374 -0
  28. package/template/.claude/skills/sync-apple-mail/scripts/sync.mjs +629 -0
  29. package/template/.claude/skills/track-candidates/SKILL.md +375 -0
  30. package/template/.claude/skills/weekly-update/SKILL.md +250 -0
  31. package/template/CLAUDE.md +63 -40
  32. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +0 -32
  33. package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +0 -34
  34. package/template/.claude/skills/extract-entities/scripts/state.py +0 -100
  35. package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +0 -42
  36. package/template/.claude/skills/organize-files/scripts/summarize.sh +0 -21
  37. package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +0 -242
  38. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +0 -104
  39. package/template/.claude/skills/sync-apple-mail/scripts/sync.py +0 -455
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Sync Apple Calendar events to ~/.cache/fit/basecamp/apple_calendar/ as JSON.
4
+ *
5
+ * Queries the macOS Calendar SQLite database (via node:sqlite) for events in a
6
+ * sliding window — N days in the past through 14 days in the future. Writes one
7
+ * JSON file per event and removes files for events that fall outside the window.
8
+ * Attendee details (name, email, status, role) are batch-fetched and included.
9
+ *
10
+ * Requires macOS with Calendar app configured and Full Disk Access granted.
11
+ */
12
+
13
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
14
+ console.log(`sync-apple-calendar — sync calendar events to JSON
15
+
16
+ Usage: node scripts/sync.mjs [--days N] [-h|--help]
17
+
18
+ Options:
19
+ --days N Days back to sync (default: 30)
20
+ -h, --help Show this help message and exit
21
+
22
+ Requires macOS with Calendar configured and Full Disk Access granted.`);
23
+ process.exit(0);
24
+ }
25
+
26
+ import { DatabaseSync } from "node:sqlite";
27
+ import {
28
+ existsSync,
29
+ mkdirSync,
30
+ readdirSync,
31
+ unlinkSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { homedir } from "node:os";
36
+
37
+ const HOME = homedir();
38
+ const OUTDIR = join(HOME, ".cache/fit/basecamp/apple_calendar");
39
+
40
+ /** Core Data epoch: 2001-01-01T00:00:00Z */
41
+ const EPOCH_MS = Date.UTC(2001, 0, 1);
42
+
43
+ const DB_PATHS = [
44
+ join(
45
+ HOME,
46
+ "Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb",
47
+ ),
48
+ join(HOME, "Library/Calendars/Calendar.sqlitedb"),
49
+ ];
50
+
51
+ const STATUS_MAP = {
52
+ 0: "unknown",
53
+ 1: "pending",
54
+ 2: "accepted",
55
+ 3: "declined",
56
+ 4: "tentative",
57
+ 5: "delegated",
58
+ 6: "completed",
59
+ 7: "in-process",
60
+ };
61
+
62
+ const ROLE_MAP = {
63
+ 0: "unknown",
64
+ 1: "required",
65
+ 2: "optional",
66
+ 3: "chair",
67
+ };
68
+
69
+ /**
70
+ * Find the Apple Calendar database.
71
+ * @returns {string}
72
+ */
73
+ function findDb() {
74
+ const db = DB_PATHS.find((p) => existsSync(p));
75
+ if (!db) {
76
+ console.error(
77
+ "Error: Apple Calendar database not found. Is Calendar configured?",
78
+ );
79
+ process.exit(1);
80
+ }
81
+ return db;
82
+ }
83
+
84
+ /**
85
+ * Open the database in read-only mode with retry on lock.
86
+ * @param {string} dbPath
87
+ * @returns {import("node:sqlite").DatabaseSync}
88
+ */
89
+ function openDb(dbPath) {
90
+ try {
91
+ return new DatabaseSync(dbPath, { readOnly: true });
92
+ } catch (err) {
93
+ if (err.message.includes("locked")) {
94
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
95
+ return new DatabaseSync(dbPath, { readOnly: true });
96
+ }
97
+ throw err;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Execute a read-only query and return results.
103
+ * @param {import("node:sqlite").DatabaseSync} db
104
+ * @param {string} sql
105
+ * @returns {Array<Record<string, any>>}
106
+ */
107
+ function query(db, sql) {
108
+ try {
109
+ return db.prepare(sql).all();
110
+ } catch (err) {
111
+ console.error(`SQLite error: ${err.message}`);
112
+ return [];
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Convert Core Data timestamp to ISO 8601.
118
+ * @param {number | null} ts - Seconds since 2001-01-01
119
+ * @param {string | null} tzName
120
+ * @returns {string | null}
121
+ */
122
+ function coredataToIso(ts, tzName) {
123
+ if (ts == null) return null;
124
+ const ms = EPOCH_MS + ts * 1000;
125
+ const dt = new Date(ms);
126
+
127
+ if (tzName && tzName !== "_float") {
128
+ try {
129
+ return (
130
+ dt.toLocaleString("sv-SE", { timeZone: tzName }).replace(" ", "T") +
131
+ getUtcOffset(dt, tzName)
132
+ );
133
+ } catch {
134
+ // Fall through to UTC
135
+ }
136
+ }
137
+ return dt.toISOString();
138
+ }
139
+
140
+ /**
141
+ * Get UTC offset string for a timezone at a given instant.
142
+ * @param {Date} dt
143
+ * @param {string} tzName
144
+ * @returns {string} e.g. "+02:00" or "-05:00"
145
+ */
146
+ function getUtcOffset(dt, tzName) {
147
+ try {
148
+ const parts = new Intl.DateTimeFormat("en-US", {
149
+ timeZone: tzName,
150
+ timeZoneName: "longOffset",
151
+ }).formatToParts(dt);
152
+ const tzPart = parts.find((p) => p.type === "timeZoneName");
153
+ if (tzPart) {
154
+ // "GMT+2" → "+02:00", "GMT-5:30" → "-05:30", "GMT" → "+00:00"
155
+ const match = tzPart.value.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
156
+ if (match) {
157
+ const sign = match[1];
158
+ const hours = match[2].padStart(2, "0");
159
+ const mins = (match[3] ?? "00").padStart(2, "0");
160
+ return `${sign}${hours}:${mins}`;
161
+ }
162
+ return "+00:00"; // GMT with no offset
163
+ }
164
+ } catch {
165
+ // Fallback
166
+ }
167
+ return "Z";
168
+ }
169
+
170
+ // --- Main ---
171
+
172
+ function main() {
173
+ let daysBack = 30;
174
+ const daysIdx = process.argv.indexOf("--days");
175
+ if (daysIdx !== -1 && process.argv[daysIdx + 1]) {
176
+ daysBack = parseInt(process.argv[daysIdx + 1], 10);
177
+ }
178
+
179
+ const dbPath = findDb();
180
+ mkdirSync(OUTDIR, { recursive: true });
181
+
182
+ const now = Date.now();
183
+ const start = new Date(now - daysBack * 86400000);
184
+ const end = new Date(now + 14 * 86400000);
185
+ const startTs = (start.getTime() - EPOCH_MS) / 1000;
186
+ const endTs = (end.getTime() - EPOCH_MS) / 1000;
187
+
188
+ const db = openDb(dbPath);
189
+
190
+ try {
191
+ // Fetch events with a single query
192
+ const events = query(
193
+ db,
194
+ `
195
+ SELECT
196
+ ci.ROWID AS id,
197
+ ci.summary,
198
+ ci.start_date,
199
+ ci.end_date,
200
+ ci.start_tz,
201
+ ci.end_tz,
202
+ ci.all_day,
203
+ ci.description,
204
+ ci.has_attendees,
205
+ ci.conference_url,
206
+ loc.title AS location,
207
+ cal.title AS calendar_name,
208
+ org.address AS organizer_email,
209
+ org.display_name AS organizer_name
210
+ FROM CalendarItem ci
211
+ LEFT JOIN Location loc ON loc.ROWID = ci.location_id
212
+ LEFT JOIN Calendar cal ON cal.ROWID = ci.calendar_id
213
+ LEFT JOIN Identity org ON org.ROWID = ci.organizer_id
214
+ WHERE ci.start_date <= ${endTs}
215
+ AND COALESCE(ci.end_date, ci.start_date) >= ${startTs}
216
+ AND ci.summary IS NOT NULL
217
+ AND ci.summary != ''
218
+ ORDER BY ci.start_date ASC
219
+ LIMIT 1000;
220
+ `,
221
+ );
222
+
223
+ // Collect event IDs for batch attendee query
224
+ const eventIds = events.map((ev) => String(ev.id));
225
+
226
+ // Batch-fetch all attendees in one query
227
+ const attendeesByEvent = {};
228
+ if (eventIds.length > 0) {
229
+ const idList = eventIds.join(",");
230
+ const attendeesRaw = query(
231
+ db,
232
+ `
233
+ SELECT
234
+ p.owner_id,
235
+ p.email,
236
+ p.status,
237
+ p.role,
238
+ p.is_self,
239
+ p.entity_type,
240
+ i.display_name
241
+ FROM Participant p
242
+ LEFT JOIN Identity i ON i.ROWID = p.identity_id
243
+ WHERE p.owner_id IN (${idList})
244
+ AND p.entity_type = 7;
245
+ `,
246
+ );
247
+ for (const a of attendeesRaw) {
248
+ attendeesByEvent[a.owner_id] ??= [];
249
+ attendeesByEvent[a.owner_id].push(a);
250
+ }
251
+ }
252
+
253
+ // Write event JSON files
254
+ const writtenIds = new Set();
255
+ for (const ev of events) {
256
+ const eid = ev.id;
257
+
258
+ // Organizer — strip mailto: prefix
259
+ let orgEmail = ev.organizer_email ?? null;
260
+ if (orgEmail?.startsWith("mailto:")) orgEmail = orgEmail.slice(7);
261
+
262
+ // Attendees
263
+ const attendees = [];
264
+ for (const a of attendeesByEvent[eid] ?? []) {
265
+ if (!a.email) continue;
266
+ attendees.push({
267
+ email: a.email,
268
+ name: (a.display_name ?? "").trim() || null,
269
+ status: STATUS_MAP[a.status] ?? "unknown",
270
+ role: ROLE_MAP[a.role] ?? "unknown",
271
+ self: Boolean(a.is_self),
272
+ });
273
+ }
274
+
275
+ const isAllDay = Boolean(ev.all_day);
276
+
277
+ const eventJson = {
278
+ id: `apple_cal_${eid}`,
279
+ summary: ev.summary,
280
+ start: {
281
+ dateTime: coredataToIso(ev.start_date, ev.start_tz),
282
+ timeZone: ev.start_tz !== "_float" ? ev.start_tz : null,
283
+ },
284
+ end: {
285
+ dateTime: coredataToIso(ev.end_date ?? ev.start_date, ev.end_tz),
286
+ timeZone: ev.end_tz !== "_float" ? ev.end_tz : null,
287
+ },
288
+ allDay: isAllDay,
289
+ location: ev.location || null,
290
+ description: ev.description || null,
291
+ conferenceUrl: ev.conference_url || null,
292
+ calendar: ev.calendar_name || null,
293
+ organizer: orgEmail
294
+ ? { email: orgEmail, name: (ev.organizer_name ?? "").trim() || null }
295
+ : null,
296
+ attendees: attendees.length > 0 ? attendees : null,
297
+ };
298
+
299
+ const filename = `${eid}.json`;
300
+ writeFileSync(join(OUTDIR, filename), JSON.stringify(eventJson, null, 2));
301
+ writtenIds.add(filename);
302
+ }
303
+
304
+ // Clean up events outside the window
305
+ let removed = 0;
306
+ for (const fname of readdirSync(OUTDIR)) {
307
+ if (fname.endsWith(".json") && !writtenIds.has(fname)) {
308
+ unlinkSync(join(OUTDIR, fname));
309
+ removed++;
310
+ }
311
+ }
312
+
313
+ console.log("Apple Calendar Sync Complete");
314
+ console.log(`Events synced: ${writtenIds.size}`);
315
+ console.log(
316
+ `Time window: ${start.toISOString().slice(0, 10)} to ${end.toISOString().slice(0, 10)}`,
317
+ );
318
+ console.log(`Files cleaned up: ${removed} (outside window)`);
319
+ console.log(`Output: ${OUTDIR}`);
320
+ } finally {
321
+ db.close();
322
+ }
323
+ }
324
+
325
+ main();
@@ -41,10 +41,10 @@ their email.
41
41
 
42
42
  ## Implementation
43
43
 
44
- Run the sync as a single Python script. This avoids N+1 shell invocations and
45
- handles all data transformation in one pass:
44
+ Run the sync as a single Node.js script with embedded SQLite. This avoids N+1
45
+ process invocations and handles all data transformation in one pass:
46
46
 
47
- python3 scripts/sync.py [--days N]
47
+ node scripts/sync.mjs [--days N]
48
48
 
49
49
  - `--days N` — how many days back to look on first sync (default: 30)
50
50
 
@@ -61,7 +61,7 @@ The script:
61
61
  7. Updates sync state timestamp
62
62
  8. Reports summary (threads processed, files written)
63
63
 
64
- The script calls `scripts/parse-emlx.py` to extract plain text bodies from
64
+ The script imports `scripts/parse-emlx.mjs` to extract plain text bodies from
65
65
  `.emlx` / `.partial.emlx` files (handles HTML-only emails by stripping tags).
66
66
 
67
67
  ## Database Schema
@@ -128,7 +128,7 @@ Rules:
128
128
  - `.emlx` / `.partial.emlx` not found → fall back to database summary field
129
129
  - `.emlx` parse error → fall back to database summary field
130
130
  - HTML-only email → strip tags and use as plain text body (handled by
131
- parse-emlx.py)
131
+ parse-emlx.mjs)
132
132
  - `find` timeout → skip that message's body, use summary; attachment index empty
133
133
  - Attachment file not found on disk → listed as `*(not available)*` in markdown
134
134
  - Attachment copy fails (permissions, disk full) → listed as `*(not available)*`
@@ -137,7 +137,7 @@ Rules:
137
137
 
138
138
  ## Constraints
139
139
 
140
- - Open database read-only (`-readonly`)
140
+ - Open database read-only (`readOnly: true`)
141
141
  - Only sync Inbox and Sent folders
142
142
  - Limit to 500 threads per run
143
143
  - Incremental: only threads with new messages since last sync