@forwardimpact/basecamp 2.4.2 → 2.6.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 (24) hide show
  1. package/config/scheduler.json +10 -5
  2. package/package.json +1 -1
  3. package/src/basecamp.js +101 -729
  4. package/template/.claude/agents/chief-of-staff.md +14 -3
  5. package/template/.claude/agents/head-hunter.md +436 -0
  6. package/template/.claude/agents/librarian.md +1 -1
  7. package/template/.claude/settings.json +4 -1
  8. package/template/.claude/skills/analyze-cv/SKILL.md +39 -7
  9. package/template/.claude/skills/draft-emails/SKILL.md +29 -9
  10. package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +4 -4
  11. package/template/.claude/skills/draft-emails/scripts/send-email.mjs +41 -6
  12. package/template/.claude/skills/meeting-prep/SKILL.md +7 -4
  13. package/template/.claude/skills/process-hyprnote/SKILL.md +17 -8
  14. package/template/.claude/skills/process-hyprnote/scripts/scan.mjs +246 -0
  15. package/template/.claude/skills/scan-open-candidates/SKILL.md +476 -0
  16. package/template/.claude/skills/scan-open-candidates/scripts/state.mjs +396 -0
  17. package/template/.claude/skills/sync-apple-calendar/SKILL.md +41 -0
  18. package/template/.claude/skills/sync-apple-calendar/scripts/query.mjs +301 -0
  19. package/template/.claude/skills/synthesize-deck/SKILL.md +296 -0
  20. package/template/.claude/skills/synthesize-deck/scripts/extract-pptx.mjs +210 -0
  21. package/template/.claude/skills/track-candidates/SKILL.md +45 -0
  22. package/template/.claude/skills/workday-requisition/SKILL.md +86 -53
  23. package/template/.claude/skills/workday-requisition/scripts/parse-workday.mjs +103 -37
  24. package/template/CLAUDE.md +13 -3
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Manage head-hunter agent state files.
4
+ *
5
+ * Provides atomic operations on the 5 state files used by the head-hunter agent:
6
+ * cursor.tsv — source rotation state (source, last_checked, position)
7
+ * seen.tsv — deduplication index (source, id, date)
8
+ * prospects.tsv — prospect index (name, source, date, strength, level)
9
+ * failures.tsv — consecutive failure counts (source, count)
10
+ * log.md — append-only activity log
11
+ *
12
+ * Usage:
13
+ * node scripts/state.mjs cursor get <source>
14
+ * node scripts/state.mjs cursor set <source> <timestamp> <position>
15
+ * node scripts/state.mjs seen check <source> <id>
16
+ * node scripts/state.mjs seen add <source> <id> [<date>]
17
+ * node scripts/state.mjs prospect add <name> <source> <strength> <level>
18
+ * node scripts/state.mjs prospect list [--limit N]
19
+ * node scripts/state.mjs failure get <source>
20
+ * node scripts/state.mjs failure increment <source>
21
+ * node scripts/state.mjs failure reset <source>
22
+ * node scripts/state.mjs log <message>
23
+ * node scripts/state.mjs log-wake <source> <summary>
24
+ * node scripts/state.mjs summary
25
+ */
26
+
27
+ import {
28
+ appendFileSync,
29
+ existsSync,
30
+ mkdirSync,
31
+ readFileSync,
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 STATE_DIR = join(HOME, ".cache/fit/basecamp/head-hunter");
39
+
40
+ const PATHS = {
41
+ cursor: join(STATE_DIR, "cursor.tsv"),
42
+ seen: join(STATE_DIR, "seen.tsv"),
43
+ prospects: join(STATE_DIR, "prospects.tsv"),
44
+ failures: join(STATE_DIR, "failures.tsv"),
45
+ log: join(STATE_DIR, "log.md"),
46
+ };
47
+
48
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
49
+ console.log(`state — manage head-hunter agent state
50
+
51
+ Usage:
52
+ node scripts/state.mjs <command> [args]
53
+
54
+ Cursor commands (source rotation):
55
+ cursor get <source> Get current cursor for source
56
+ cursor set <source> <timestamp> <position> Update cursor position
57
+ cursor list List all cursors
58
+
59
+ Seen commands (deduplication):
60
+ seen check <source> <id> Check if ID was already seen (exit 0=seen, 1=new)
61
+ seen add <source> <id> [<date>] Mark ID as seen (date defaults to today)
62
+ seen batch <source> <id1> <id2> ... Mark multiple IDs as seen
63
+
64
+ Prospect commands:
65
+ prospect add <name> <source> <strength> <level> Add a new prospect
66
+ prospect list [--limit N] List recent prospects
67
+ prospect count Count total prospects
68
+
69
+ Failure commands:
70
+ failure get <source> Get failure count for source
71
+ failure increment <source> Increment failure count
72
+ failure reset <source> Reset failure count to 0
73
+
74
+ Log commands:
75
+ log <message> Append raw text to log.md
76
+ log-wake <source> <summary> Append a formatted wake cycle entry
77
+
78
+ Summary:
79
+ summary Print state overview
80
+
81
+ State dir: ~/.cache/fit/basecamp/head-hunter/`);
82
+ process.exit(0);
83
+ }
84
+
85
+ // --- Ensure state directory exists ---
86
+
87
+ mkdirSync(STATE_DIR, { recursive: true });
88
+
89
+ // --- File helpers ---
90
+
91
+ function readTsv(file) {
92
+ if (!existsSync(file)) return [];
93
+ return readFileSync(file, "utf8")
94
+ .split("\n")
95
+ .filter((l) => l.trim());
96
+ }
97
+
98
+ function writeTsv(file, lines) {
99
+ writeFileSync(file, lines.join("\n") + (lines.length ? "\n" : ""));
100
+ }
101
+
102
+ function appendTsv(file, line) {
103
+ appendFileSync(file, line + "\n");
104
+ }
105
+
106
+ function today() {
107
+ return new Date().toISOString().slice(0, 10);
108
+ }
109
+
110
+ // --- Cursor operations ---
111
+
112
+ function cursorGet(source) {
113
+ const lines = readTsv(PATHS.cursor);
114
+ const line = lines.find((l) => l.startsWith(source + "\t"));
115
+ if (!line) {
116
+ console.log(`No cursor for source: ${source}`);
117
+ return null;
118
+ }
119
+ const [, timestamp, position] = line.split("\t");
120
+ console.log(`${source}\t${timestamp}\t${position}`);
121
+ return { source, timestamp, position };
122
+ }
123
+
124
+ function cursorSet(source, timestamp, position) {
125
+ const lines = readTsv(PATHS.cursor);
126
+ const newLine = `${source}\t${timestamp}\t${position}`;
127
+ const idx = lines.findIndex((l) => l.startsWith(source + "\t"));
128
+ if (idx !== -1) {
129
+ lines[idx] = newLine;
130
+ } else {
131
+ lines.push(newLine);
132
+ }
133
+ writeTsv(PATHS.cursor, lines);
134
+ console.log(`Cursor updated: ${newLine}`);
135
+ }
136
+
137
+ function cursorList() {
138
+ const lines = readTsv(PATHS.cursor);
139
+ if (lines.length === 0) {
140
+ console.log("No cursors.");
141
+ return;
142
+ }
143
+ for (const line of lines) {
144
+ console.log(line);
145
+ }
146
+ }
147
+
148
+ // --- Seen operations ---
149
+
150
+ function seenCheck(source, id) {
151
+ const lines = readTsv(PATHS.seen);
152
+ const found = lines.some((l) => {
153
+ const parts = l.split("\t");
154
+ return parts[0] === source && parts[1] === id;
155
+ });
156
+ if (found) {
157
+ console.log(`SEEN: ${source}\t${id}`);
158
+ process.exit(0);
159
+ } else {
160
+ console.log(`NEW: ${source}\t${id}`);
161
+ process.exit(1);
162
+ }
163
+ }
164
+
165
+ function seenAdd(source, id, date) {
166
+ appendTsv(PATHS.seen, `${source}\t${id}\t${date || today()}`);
167
+ console.log(`Marked seen: ${source}\t${id}\t${date || today()}`);
168
+ }
169
+
170
+ function seenBatch(source, ids) {
171
+ const d = today();
172
+ const lines = ids.map((id) => `${source}\t${id}\t${d}`);
173
+ appendFileSync(PATHS.seen, lines.join("\n") + "\n");
174
+ console.log(`Marked ${ids.length} IDs as seen for ${source}`);
175
+ }
176
+
177
+ // --- Prospect operations ---
178
+
179
+ function prospectAdd(name, source, strength, level) {
180
+ const d = today();
181
+ appendTsv(PATHS.prospects, `${name}\t${source}\t${d}\t${strength}\t${level}`);
182
+ console.log(`Prospect added: ${name} (${strength}, ${level})`);
183
+ }
184
+
185
+ function prospectList(limit) {
186
+ const lines = readTsv(PATHS.prospects);
187
+ if (lines.length === 0) {
188
+ console.log("No prospects.");
189
+ return;
190
+ }
191
+ // Show most recent first
192
+ const display = lines.slice(-limit).reverse();
193
+ for (const line of display) {
194
+ console.log(line);
195
+ }
196
+ if (lines.length > limit) {
197
+ console.log(`\n... ${lines.length - limit} more (${lines.length} total)`);
198
+ }
199
+ }
200
+
201
+ function prospectCount() {
202
+ const lines = readTsv(PATHS.prospects);
203
+ console.log(lines.length);
204
+ }
205
+
206
+ // --- Failure operations ---
207
+
208
+ function failureGet(source) {
209
+ const lines = readTsv(PATHS.failures);
210
+ const line = lines.find((l) => l.startsWith(source + "\t"));
211
+ if (!line) {
212
+ console.log(`0`);
213
+ return 0;
214
+ }
215
+ const count = parseInt(line.split("\t")[1], 10) || 0;
216
+ console.log(`${count}`);
217
+ return count;
218
+ }
219
+
220
+ function failureIncrement(source) {
221
+ const lines = readTsv(PATHS.failures);
222
+ const idx = lines.findIndex((l) => l.startsWith(source + "\t"));
223
+ if (idx !== -1) {
224
+ const count = parseInt(lines[idx].split("\t")[1], 10) || 0;
225
+ lines[idx] = `${source}\t${count + 1}`;
226
+ } else {
227
+ lines.push(`${source}\t1`);
228
+ }
229
+ writeTsv(PATHS.failures, lines);
230
+ const newCount = parseInt(
231
+ lines.find((l) => l.startsWith(source + "\t")).split("\t")[1],
232
+ 10,
233
+ );
234
+ console.log(`Failures for ${source}: ${newCount}`);
235
+ if (newCount >= 3) {
236
+ console.log(
237
+ `WARNING: ${source} has ${newCount} consecutive failures (suspended at ≥3)`,
238
+ );
239
+ }
240
+ }
241
+
242
+ function failureReset(source) {
243
+ const lines = readTsv(PATHS.failures);
244
+ const idx = lines.findIndex((l) => l.startsWith(source + "\t"));
245
+ if (idx !== -1) {
246
+ lines[idx] = `${source}\t0`;
247
+ writeTsv(PATHS.failures, lines);
248
+ }
249
+ console.log(`Failures reset for ${source}`);
250
+ }
251
+
252
+ // --- Log operations ---
253
+
254
+ function logAppend(message) {
255
+ appendFileSync(PATHS.log, message + "\n");
256
+ console.log("Log entry appended.");
257
+ }
258
+
259
+ function logWake(source, summary) {
260
+ const timestamp = new Date().toISOString().slice(0, 16).replace("T", " ");
261
+ const entry = `\n## ${today()} ${timestamp.slice(11)} — Wake (${source})\n\n${summary}\n\n---\n`;
262
+ appendFileSync(PATHS.log, entry);
263
+ console.log(`Wake cycle logged for ${source}`);
264
+ }
265
+
266
+ // --- Summary ---
267
+
268
+ function summary() {
269
+ const cursors = readTsv(PATHS.cursor);
270
+ const seen = readTsv(PATHS.seen);
271
+ const prospects = readTsv(PATHS.prospects);
272
+ const failures = readTsv(PATHS.failures);
273
+
274
+ console.log("=== Head-Hunter State Summary ===\n");
275
+
276
+ console.log(`Sources: ${cursors.length}`);
277
+ for (const line of cursors) {
278
+ const [source, timestamp, position] = line.split("\t");
279
+ const failLine = failures.find((l) => l.startsWith(source + "\t"));
280
+ const failCount = failLine ? parseInt(failLine.split("\t")[1], 10) : 0;
281
+ const status = failCount >= 3 ? " [SUSPENDED]" : "";
282
+ console.log(` ${source}: last=${timestamp}, pos=${position}${status}`);
283
+ }
284
+
285
+ console.log(`\nSeen: ${seen.length} entries`);
286
+ console.log(`Prospects: ${prospects.length} total`);
287
+
288
+ // Strength breakdown
289
+ const byStrength = {};
290
+ for (const line of prospects) {
291
+ const strength = line.split("\t")[3] || "unknown";
292
+ byStrength[strength] = (byStrength[strength] || 0) + 1;
293
+ }
294
+ for (const [k, v] of Object.entries(byStrength)) {
295
+ console.log(` ${k}: ${v}`);
296
+ }
297
+
298
+ // Recent prospects (last 5)
299
+ if (prospects.length > 0) {
300
+ console.log("\nRecent prospects:");
301
+ for (const line of prospects.slice(-5)) {
302
+ const [name, source, date, strength, level] = line.split("\t");
303
+ console.log(
304
+ ` ${date} | ${name} | ${strength} | ${level} | via ${source}`,
305
+ );
306
+ }
307
+ }
308
+ }
309
+
310
+ // --- CLI Router ---
311
+
312
+ const cliArgs = process.argv.slice(2);
313
+ const cmd = cliArgs[0];
314
+ const sub = cliArgs[1];
315
+
316
+ switch (cmd) {
317
+ case "cursor":
318
+ if (sub === "get" && cliArgs[2]) cursorGet(cliArgs[2]);
319
+ else if (sub === "set" && cliArgs[2] && cliArgs[3] && cliArgs[4])
320
+ cursorSet(cliArgs[2], cliArgs[3], cliArgs[4]);
321
+ else if (sub === "list") cursorList();
322
+ else {
323
+ console.error(
324
+ "Usage: cursor get|set|list <source> [<timestamp> <position>]",
325
+ );
326
+ process.exit(1);
327
+ }
328
+ break;
329
+
330
+ case "seen":
331
+ if (sub === "check" && cliArgs[2] && cliArgs[3])
332
+ seenCheck(cliArgs[2], cliArgs[3]);
333
+ else if (sub === "add" && cliArgs[2] && cliArgs[3])
334
+ seenAdd(cliArgs[2], cliArgs[3], cliArgs[4]);
335
+ else if (sub === "batch" && cliArgs[2] && cliArgs.length > 3)
336
+ seenBatch(cliArgs[2], cliArgs.slice(3));
337
+ else {
338
+ console.error("Usage: seen check|add|batch <source> <id> [...]");
339
+ process.exit(1);
340
+ }
341
+ break;
342
+
343
+ case "prospect":
344
+ if (sub === "add" && cliArgs[2] && cliArgs[3] && cliArgs[4] && cliArgs[5])
345
+ prospectAdd(cliArgs[2], cliArgs[3], cliArgs[4], cliArgs[5]);
346
+ else if (sub === "list") {
347
+ const lIdx = cliArgs.indexOf("--limit");
348
+ const lim = lIdx !== -1 ? parseInt(cliArgs[lIdx + 1], 10) || 10 : 10;
349
+ prospectList(lim);
350
+ } else if (sub === "count") prospectCount();
351
+ else {
352
+ console.error(
353
+ "Usage: prospect add|list|count <name> <source> <strength> <level>",
354
+ );
355
+ process.exit(1);
356
+ }
357
+ break;
358
+
359
+ case "failure":
360
+ if (sub === "get" && cliArgs[2]) failureGet(cliArgs[2]);
361
+ else if (sub === "increment" && cliArgs[2]) failureIncrement(cliArgs[2]);
362
+ else if (sub === "reset" && cliArgs[2]) failureReset(cliArgs[2]);
363
+ else {
364
+ console.error("Usage: failure get|increment|reset <source>");
365
+ process.exit(1);
366
+ }
367
+ break;
368
+
369
+ case "log":
370
+ if (cliArgs.length >= 2) logAppend(cliArgs.slice(1).join(" "));
371
+ else {
372
+ console.error("Usage: log <message>");
373
+ process.exit(1);
374
+ }
375
+ break;
376
+
377
+ case "log-wake":
378
+ if (cliArgs[1] && cliArgs.length >= 3)
379
+ logWake(cliArgs[1], cliArgs.slice(2).join(" "));
380
+ else {
381
+ console.error("Usage: log-wake <source> <summary>");
382
+ process.exit(1);
383
+ }
384
+ break;
385
+
386
+ case "summary":
387
+ summary();
388
+ break;
389
+
390
+ default:
391
+ console.error(
392
+ "Unknown command. Run with --help for usage.\n" +
393
+ "Commands: cursor, seen, prospect, failure, log, log-wake, summary",
394
+ );
395
+ process.exit(1);
396
+ }
@@ -96,6 +96,47 @@ Each `{event_id}.json` file:
96
96
  - Database locked → wait 2 seconds, retry once
97
97
  - Skip events with no summary (likely cancelled or placeholder)
98
98
 
99
+ ## Querying Events
100
+
101
+ After syncing, use the query script to filter events by date or time window.
102
+ **Agents should use this script instead of writing bespoke calendar parsers.**
103
+
104
+ node scripts/query.mjs [options]
105
+
106
+ ### Time filters (combinable)
107
+
108
+ | Flag | Description |
109
+ | ------------------------------- | --------------------------------------------------------- |
110
+ | `--today` | Events starting today (default if no filter given) |
111
+ | `--tomorrow` | Events starting tomorrow |
112
+ | `--upcoming 2h` | Events starting within interval (e.g., `2h`, `30m`, `1d`) |
113
+ | `--date 2026-03-09` | Events on a specific date |
114
+ | `--range 2026-03-09 2026-03-11` | Events between two dates (inclusive) |
115
+
116
+ ### Output options
117
+
118
+ | Flag | Description |
119
+ | ------------------- | ----------------------------------------------- |
120
+ | `--json` | Output as JSON array (default: formatted table) |
121
+ | `--include-all-day` | Include all-day events (excluded by default) |
122
+ | `--no-attendees` | Omit attendee names from output |
123
+
124
+ ### Examples
125
+
126
+ ```bash
127
+ # Concierge: get today + tomorrow for triage
128
+ node scripts/query.mjs --today --tomorrow
129
+
130
+ # Meeting-prep: get upcoming meetings in next 2 hours as JSON
131
+ node scripts/query.mjs --upcoming 2h --json
132
+
133
+ # Chief-of-staff: get this week's events
134
+ node scripts/query.mjs --range 2026-03-09 2026-03-13
135
+
136
+ # Quick count check
137
+ node scripts/query.mjs --today --json | node -e "process.stdin.on('data',d=>console.log(JSON.parse(d).length+' events today'))"
138
+ ```
139
+
99
140
  ## Constraints
100
141
 
101
142
  - Open database read-only (`readOnly: true`)