@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
@@ -3,7 +3,7 @@
3
3
  * Scan for unprocessed emails and output their IDs and subjects.
4
4
  *
5
5
  * Checks ~/.cache/fit/basecamp/apple_mail/ for email thread markdown files not
6
- * yet listed in drafts/drafted or drafts/ignored. Outputs one tab-separated
6
+ * yet listed in drafts/handled or drafts/ignored. Outputs one tab-separated
7
7
  * line per unprocessed thread: email_id<TAB>subject. Used by the draft-emails
8
8
  * skill to identify threads that need a reply.
9
9
  */
@@ -17,7 +17,7 @@ const HELP = `scan-emails — list unprocessed email threads
17
17
  Usage: node scripts/scan-emails.mjs [-h|--help]
18
18
 
19
19
  Scans ~/.cache/fit/basecamp/apple_mail/ for .md thread files not yet
20
- recorded in drafts/drafted or drafts/ignored. Outputs one line per
20
+ recorded in drafts/handled or drafts/ignored. Outputs one line per
21
21
  unprocessed thread as: email_id<TAB>subject`;
22
22
 
23
23
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
@@ -49,14 +49,14 @@ function extractSubject(filePath) {
49
49
  function main() {
50
50
  if (!existsSync(MAIL_DIR)) return;
51
51
 
52
- const drafted = loadIdSet("drafts/drafted");
52
+ const handled = loadIdSet("drafts/handled");
53
53
  const ignored = loadIdSet("drafts/ignored");
54
54
 
55
55
  for (const name of readdirSync(MAIL_DIR).sort()) {
56
56
  if (!name.endsWith(".md")) continue;
57
57
 
58
58
  const emailId = basename(name, ".md");
59
- if (drafted.has(emailId) || ignored.has(emailId)) continue;
59
+ if (handled.has(emailId) || ignored.has(emailId)) continue;
60
60
 
61
61
  const subject = extractSubject(join(MAIL_DIR, name));
62
62
  console.log(`${emailId}\t${subject}`);
@@ -6,13 +6,18 @@
6
6
  * Apple Mail. The script writes a temporary .scpt file, executes it with
7
7
  * osascript, and cleans up afterwards. Mail.app must be running.
8
8
  *
9
- * The body should be plain text — no HTML. Do NOT include an email signature;
10
- * Apple Mail appends the user's configured signature automatically.
9
+ * The body should be plain text — no HTML. Do NOT include an email signature
10
+ * or sign-off; Apple Mail appends the user's configured signature automatically.
11
11
  */
12
12
 
13
13
  import { execFileSync } from "node:child_process";
14
- import { mkdtempSync, writeFileSync, unlinkSync } from "node:fs";
15
- import { join } from "node:path";
14
+ import {
15
+ appendFileSync,
16
+ mkdtempSync,
17
+ writeFileSync,
18
+ unlinkSync,
19
+ } from "node:fs";
20
+ import { basename, join } from "node:path";
16
21
  import { tmpdir } from "node:os";
17
22
 
18
23
  const HELP = `send-email — send an email via Apple Mail
@@ -25,9 +30,10 @@ Options:
25
30
  --bcc <addrs> Comma-separated BCC recipients
26
31
  --subject <subj> Email subject line (required)
27
32
  --body <text> Plain-text email body (required)
33
+ --draft <path> Draft file — deleted after send, ID appended to drafts/handled
28
34
  -h, --help Show this help message and exit
29
35
 
30
- Mail.app must be running. No email signature needed — Apple Mail appends it.`;
36
+ Mail.app must be running. No signature or sign-off needed — Apple Mail appends it.`;
31
37
 
32
38
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
33
39
  console.log(HELP);
@@ -59,7 +65,8 @@ function main() {
59
65
  cc = "",
60
66
  bcc = "",
61
67
  subject = "",
62
- body = "";
68
+ body = "",
69
+ draft = "";
63
70
 
64
71
  for (let i = 0; i < args.length; i++) {
65
72
  switch (args[i]) {
@@ -78,6 +85,9 @@ function main() {
78
85
  case "--body":
79
86
  body = args[++i] ?? "";
80
87
  break;
88
+ case "--draft":
89
+ draft = args[++i] ?? "";
90
+ break;
81
91
  }
82
92
  }
83
93
 
@@ -87,6 +97,13 @@ function main() {
87
97
  process.exit(1);
88
98
  }
89
99
 
100
+ // Strip leading two-space padding from each line and trim overall whitespace
101
+ body = body
102
+ .split("\n")
103
+ .map((line) => line.replace(/^ {2}/, ""))
104
+ .join("\n")
105
+ .trim();
106
+
90
107
  const lines = [
91
108
  'tell application "Mail"',
92
109
  ` set newMessage to make new outgoing message with properties {subject:"${escapeAS(subject)}", content:"${escapeAS(body)}", visible:false}`,
@@ -106,6 +123,24 @@ function main() {
106
123
  writeFileSync(tmp, lines);
107
124
  execFileSync("osascript", [tmp], { stdio: "inherit" });
108
125
  console.log(`Sent: ${subject}`);
126
+
127
+ // Clean up draft and mark thread as handled
128
+ if (draft) {
129
+ try {
130
+ unlinkSync(draft);
131
+ console.log(`Removed draft: ${draft}`);
132
+ } catch {
133
+ // ignore if draft already gone
134
+ }
135
+
136
+ // Extract email ID from draft filename (e.g. "drafts/12345_draft.md" → "12345")
137
+ const draftBasename = basename(draft, ".md");
138
+ const emailId = draftBasename.replace(/_draft$/, "");
139
+ if (emailId) {
140
+ appendFileSync("drafts/handled", emailId + "\n");
141
+ console.log(`Marked as handled: ${emailId}`);
142
+ }
143
+ }
109
144
  } finally {
110
145
  try {
111
146
  unlinkSync(tmp);
@@ -63,16 +63,19 @@ When the user asks to prep for a meeting:
63
63
 
64
64
  ### Step 1: Identify the Meeting
65
65
 
66
- If specified, look it up in calendar:
66
+ Use the calendar query script to find upcoming meetings:
67
67
 
68
68
  ```bash
69
- ls ~/.cache/fit/basecamp/apple_calendar/ 2>/dev/null
70
- cat "$HOME/.cache/fit/basecamp/apple_calendar/event123.json"
69
+ # Next 2 hours of meetings as JSON
70
+ node .claude/skills/sync-apple-calendar/scripts/query.mjs --upcoming 2h --json
71
+
72
+ # Today's full schedule
73
+ node .claude/skills/sync-apple-calendar/scripts/query.mjs --today
71
74
  ```
72
75
 
73
76
  If "prep me for my next meeting":
74
77
 
75
- - List upcoming events
78
+ - Query upcoming events with `--upcoming 2h`
76
79
  - Find the next meeting with external attendees
77
80
  - Confirm with user if unclear
78
81
 
@@ -51,27 +51,36 @@ Run this skill:
51
51
  ## Before Starting
52
52
 
53
53
  1. Read `USER.md` to get the user's name, email, and domain.
54
- 2. List all session directories:
54
+ 2. **Scan for unprocessed sessions** using the scan script:
55
55
 
56
56
  ```bash
57
- ls "$HOME/Library/Application Support/hyprnote/sessions/"
57
+ node .claude/skills/process-hyprnote/scripts/scan.mjs
58
58
  ```
59
59
 
60
- 3. For each session, check if it needs processing by looking up its key files in
61
- the graph state:
60
+ This checks all sessions against the `graph_processed` state file and reports
61
+ which need processing, with titles, dates, and content previews.
62
62
 
63
- ```bash
64
- grep -F "{file_path}" ~/.cache/fit/basecamp/state/graph_processed
65
- ```
63
+ **Options:**
64
+
65
+ | Flag | Description |
66
+ | ----------- | -------------------------------------------------------- |
67
+ | `--changed` | Also detect sessions whose memo/summary hash has changed |
68
+ | `--json` | Output as JSON (for programmatic use) |
69
+ | `--count` | Just print the count (for quick checks) |
70
+ | `--limit N` | Max sessions to display (default: 20) |
66
71
 
67
72
  A session needs processing if:
68
73
 
69
74
  - Its `_memo.md` path is **not** in `graph_processed`, OR
70
- - Its `_memo.md` hash has changed (compute SHA-256 and compare), OR
75
+ - Its `_memo.md` hash has changed (use `--changed` to detect this), OR
71
76
  - Its `_summary.md` exists and is not in `graph_processed` or has changed
72
77
 
73
78
  **Process all unprocessed sessions in one run** (typically few sessions).
74
79
 
80
+ **Do NOT write bespoke scripts to scan for unprocessed sessions.** Use this
81
+ script — it handles all edge cases (empty memos, missing summaries, metadata
82
+ fallback).
83
+
75
84
  ## Step 0: Build Knowledge Index
76
85
 
77
86
  Scan existing notes to avoid duplicates and resolve entities:
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Scan for unprocessed Hyprnote sessions.
4
+ *
5
+ * Compares session _memo.md and _summary.md files against the graph_processed
6
+ * state file to identify sessions that need processing. Reports unprocessed
7
+ * sessions with title, date, and content preview.
8
+ *
9
+ * Usage:
10
+ * node scripts/scan.mjs List unprocessed sessions
11
+ * node scripts/scan.mjs --changed Also detect changed (re-edited) sessions
12
+ * node scripts/scan.mjs --json Output as JSON
13
+ * node scripts/scan.mjs --count Just print the count
14
+ */
15
+
16
+ import { createHash } from "node:crypto";
17
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { homedir } from "node:os";
20
+
21
+ const HOME = homedir();
22
+ const SESSIONS_DIR = join(
23
+ HOME,
24
+ "Library/Application Support/hyprnote/sessions",
25
+ );
26
+ const STATE_FILE = join(HOME, ".cache/fit/basecamp/state/graph_processed");
27
+
28
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
29
+ console.log(`scan — find unprocessed Hyprnote sessions
30
+
31
+ Usage:
32
+ node scripts/scan.mjs [options]
33
+
34
+ Options:
35
+ --changed Also detect sessions whose memo/summary hash has changed
36
+ --json Output as JSON array
37
+ --count Just print the unprocessed count (for scripting)
38
+ --limit N Max sessions to display (default: 20)
39
+ -h, --help Show this help message
40
+
41
+ Sessions dir: ~/Library/Application Support/hyprnote/sessions/
42
+ State file: ~/.cache/fit/basecamp/state/graph_processed`);
43
+ process.exit(0);
44
+ }
45
+
46
+ const args = process.argv.slice(2);
47
+ const detectChanged = args.includes("--changed");
48
+ const jsonOutput = args.includes("--json");
49
+ const countOnly = args.includes("--count");
50
+ const limitIdx = args.indexOf("--limit");
51
+ const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) || 20 : 20;
52
+
53
+ // --- Load state ---
54
+
55
+ const state = new Map();
56
+ if (existsSync(STATE_FILE)) {
57
+ const text = readFileSync(STATE_FILE, "utf8");
58
+ for (const line of text.split("\n")) {
59
+ if (!line) continue;
60
+ const idx = line.indexOf("\t");
61
+ if (idx === -1) continue;
62
+ state.set(line.slice(0, idx), line.slice(idx + 1));
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Compute SHA-256 hash of file contents.
68
+ */
69
+ function fileHash(filePath) {
70
+ return createHash("sha256").update(readFileSync(filePath)).digest("hex");
71
+ }
72
+
73
+ /**
74
+ * Check if a file needs processing (new or changed).
75
+ */
76
+ function needsProcessing(filePath) {
77
+ const storedHash = state.get(filePath);
78
+ if (!storedHash) return { needed: true, reason: "new" };
79
+ if (detectChanged) {
80
+ const currentHash = fileHash(filePath);
81
+ if (currentHash !== storedHash) return { needed: true, reason: "changed" };
82
+ }
83
+ return { needed: false, reason: null };
84
+ }
85
+
86
+ /**
87
+ * Extract title and date from a memo file.
88
+ */
89
+ function parseMemo(memoPath) {
90
+ try {
91
+ const content = readFileSync(memoPath, "utf8");
92
+
93
+ // Skip empty/whitespace-only memos
94
+ const body = content.replace(/---[\s\S]*?---/, "").trim();
95
+ if (!body || body === "&nbsp;") return null;
96
+
97
+ // Extract title from first H1
98
+ const titleMatch = content.match(/^#\s+(.+)/m);
99
+ const title = titleMatch ? titleMatch[1].trim() : null;
100
+
101
+ // Extract date from content or fall back to file mtime
102
+ const dateMatch = content.match(/\d{4}-\d{2}-\d{2}/);
103
+ let date = dateMatch ? dateMatch[0] : null;
104
+
105
+ if (!date) {
106
+ const stat = statSync(memoPath);
107
+ date = stat.mtime.toISOString().slice(0, 10);
108
+ }
109
+
110
+ return { title, date, preview: body.slice(0, 150).replace(/\n/g, " ") };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Read _meta.json for session metadata.
118
+ */
119
+ function readMeta(sessionDir) {
120
+ const metaPath = join(sessionDir, "_meta.json");
121
+ if (!existsSync(metaPath)) return null;
122
+ try {
123
+ return JSON.parse(readFileSync(metaPath, "utf8"));
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ // --- Scan sessions ---
130
+
131
+ if (!existsSync(SESSIONS_DIR)) {
132
+ console.error(`Hyprnote sessions directory not found: ${SESSIONS_DIR}`);
133
+ process.exit(1);
134
+ }
135
+
136
+ const sessionIds = readdirSync(SESSIONS_DIR);
137
+ const unprocessed = [];
138
+ let totalWithMemos = 0;
139
+ let processedCount = 0;
140
+
141
+ for (const uuid of sessionIds) {
142
+ const sessionPath = join(SESSIONS_DIR, uuid);
143
+ const stat = statSync(sessionPath, { throwIfNoEntry: false });
144
+ if (!stat || !stat.isDirectory()) continue;
145
+
146
+ const memoPath = join(sessionPath, "_memo.md");
147
+ const summaryPath = join(sessionPath, "_summary.md");
148
+ const hasMemo = existsSync(memoPath);
149
+ const hasSummary = existsSync(summaryPath);
150
+
151
+ if (!hasMemo && !hasSummary) continue;
152
+
153
+ totalWithMemos++;
154
+
155
+ // Check memo
156
+ const memoCheck = hasMemo
157
+ ? needsProcessing(memoPath)
158
+ : { needed: false, reason: null };
159
+
160
+ // Check summary
161
+ const summaryCheck = hasSummary
162
+ ? needsProcessing(summaryPath)
163
+ : { needed: false, reason: null };
164
+
165
+ if (!memoCheck.needed && !summaryCheck.needed) {
166
+ processedCount++;
167
+ continue;
168
+ }
169
+
170
+ // Parse memo for display info
171
+ const memo = hasMemo ? parseMemo(memoPath) : null;
172
+ if (hasMemo && !memo) {
173
+ // Empty memo, no summary → skip
174
+ if (!hasSummary) continue;
175
+ }
176
+
177
+ // Read meta for title fallback
178
+ const meta = readMeta(sessionPath);
179
+
180
+ const title = memo?.title || meta?.title || uuid.slice(0, 8);
181
+ const date =
182
+ memo?.date ||
183
+ (meta?.created_at ? meta.created_at.slice(0, 10) : null) ||
184
+ statSync(sessionPath).mtime.toISOString().slice(0, 10);
185
+
186
+ unprocessed.push({
187
+ uuid,
188
+ title,
189
+ date,
190
+ hasMemo,
191
+ hasSummary,
192
+ memoReason: memoCheck.reason,
193
+ summaryReason: summaryCheck.reason,
194
+ preview: memo?.preview || "(summary only)",
195
+ memoPath: hasMemo ? memoPath : null,
196
+ summaryPath: hasSummary ? summaryPath : null,
197
+ });
198
+ }
199
+
200
+ // Sort by date descending (newest first)
201
+ unprocessed.sort((a, b) => b.date.localeCompare(a.date));
202
+
203
+ // --- Output ---
204
+
205
+ if (countOnly) {
206
+ console.log(unprocessed.length);
207
+ process.exit(0);
208
+ }
209
+
210
+ if (jsonOutput) {
211
+ console.log(JSON.stringify(unprocessed.slice(0, limit), null, 2));
212
+ process.exit(0);
213
+ }
214
+
215
+ // Formatted output
216
+ console.log(
217
+ `Sessions: ${totalWithMemos} total, ${processedCount} processed, ${unprocessed.length} unprocessed`,
218
+ );
219
+
220
+ if (unprocessed.length === 0) {
221
+ console.log("\nAll sessions are up to date.");
222
+ process.exit(0);
223
+ }
224
+
225
+ console.log("");
226
+ const display = unprocessed.slice(0, limit);
227
+ for (const s of display) {
228
+ const flags = [];
229
+ if (s.memoReason) flags.push(`memo:${s.memoReason}`);
230
+ if (s.summaryReason) flags.push(`summary:${s.summaryReason}`);
231
+ const sources = [];
232
+ if (s.hasMemo) sources.push("memo");
233
+ if (s.hasSummary) sources.push("summary");
234
+
235
+ console.log(
236
+ `${s.date} | ${s.title} | ${sources.join("+")} | ${flags.join(", ")}`,
237
+ );
238
+ console.log(` ${s.uuid}`);
239
+ if (s.preview && s.preview !== "(summary only)") {
240
+ console.log(` ${s.preview.slice(0, 100)}…`);
241
+ }
242
+ }
243
+
244
+ if (unprocessed.length > limit) {
245
+ console.log(`\n... and ${unprocessed.length - limit} more`);
246
+ }