@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
@@ -1,57 +1,46 @@
1
1
  ---
2
2
  name: draft-emails
3
- description: Draft email responses using the knowledge base and calendar for full context on every person and conversation. Use when the user asks to draft, reply to, or respond to an email. Looks up people and organizations in the knowledge base before drafting.
3
+ description: Draft and send email responses using the knowledge base and calendar for context. Use when the user asks to draft, reply to, respond to, or send an email.
4
4
  ---
5
5
 
6
6
  # Draft Emails
7
7
 
8
- Help the user draft email responses. Uses the knowledge base and calendar for
9
- full context on every person and conversation. This is an interactive skill —
10
- the user triggers it by asking to draft or reply to emails.
8
+ Draft and send email responses. Uses the knowledge base and calendar for full
9
+ context on every person and conversation. All drafts require explicit user
10
+ approval before sending.
11
11
 
12
12
  ## Trigger
13
13
 
14
- Run when the user asks to draft, reply to, or respond to an email.
14
+ Run when the user asks to draft, reply to, respond to, or send an email.
15
15
 
16
16
  ## Prerequisites
17
17
 
18
18
  - Knowledge base populated (from `extract-entities` skill)
19
- - Synced email data in `~/.cache/fit/basecamp/apple_mail/` or
20
- `~/.cache/fit/basecamp/gmail/`
19
+ - Synced email data in `~/.cache/fit/basecamp/apple_mail/`
21
20
 
22
- ## Inputs
21
+ ## Data Locations
23
22
 
24
- - `knowledge/People/*.md` — person context
25
- - `knowledge/Organizations/*.md` organization context
26
- - `~/.cache/fit/basecamp/apple_mail/*.md` or `~/.cache/fit/basecamp/gmail/*.md`
27
- email threads
28
- - `~/.cache/fit/basecamp/apple_calendar/*.json` — calendar events (for
29
- scheduling)
30
- - `drafts/last_processed` timestamp of last processing run
31
- - `drafts/drafted` — list of drafted email IDs (one per line)
32
- - `drafts/ignored` — list of ignored email IDs (one per line)
33
-
34
- ## Outputs
35
-
36
- - `drafts/{email_id}_draft.md` — draft email files
37
- - `drafts/last_processed` — updated timestamp
38
- - `drafts/drafted` — updated with newly drafted IDs
39
- - `drafts/ignored` — updated with newly ignored IDs
23
+ | Data | Location |
24
+ | --------------- | --------------------------------------------- |
25
+ | People | `knowledge/People/*.md` |
26
+ | Organizations | `knowledge/Organizations/*.md` |
27
+ | Email threads | `~/.cache/fit/basecamp/apple_mail/*.md` |
28
+ | Calendar events | `~/.cache/fit/basecamp/apple_calendar/*.json` |
29
+ | Drafted IDs | `drafts/drafted` (one ID per line) |
30
+ | Ignored IDs | `drafts/ignored` (one ID per line) |
31
+ | Draft files | `drafts/{email_id}_draft.md` |
40
32
 
41
33
  ---
42
34
 
43
- ## Critical: Always Look Up Context First
44
-
45
- **BEFORE drafting any email, you MUST look up the person/organization in the
46
- knowledge base.**
35
+ ## Always Look Up Context First
47
36
 
48
- When the user says "draft an email to Monica" or mentions ANY person:
37
+ **BEFORE drafting any email, look up the person/organization in the knowledge
38
+ base.**
49
39
 
50
- 1. **STOP** — Do not draft anything yet
51
- 2. **SEARCH** — Look them up: `rg -l "Monica" knowledge/`
52
- 3. **READ** — Read their note: `cat "knowledge/People/Monica Smith.md"`
53
- 4. **UNDERSTAND** — Extract role, organization, relationship history, open items
54
- 5. **THEN DRAFT** — Only now draft the email, using this context
40
+ 1. **Search** — `rg -l "Name" knowledge/`
41
+ 2. **Read** — `cat "knowledge/People/Name.md"`
42
+ 3. **Understand** — Extract role, organization, relationship history, open items
43
+ 4. **Draft** — Only now draft the email, using this context
55
44
 
56
45
  ## Key Principles
57
46
 
@@ -59,55 +48,46 @@ When the user says "draft an email to Monica" or mentions ANY person:
59
48
 
60
49
  - If intent is unclear, ASK what the email should be about
61
50
  - If a person has multiple contexts, ASK which one
62
- - **WRONG:** "Here are three variants — pick one"
63
- - **RIGHT:** "I see Akhilesh is involved in Rowboat and banking. Which topic?"
64
51
 
65
- **Be decisive, not generic:**
52
+ **Be decisive:**
66
53
 
67
- - Once you know the context, draft ONE email — no multiple versions
68
- - Every draft must be personalized from knowledge base context
69
- - Infer tone and approach from context
70
-
71
- ## Processing Flow
72
-
73
- ### Step 1: Scan for New Emails
54
+ - Draft ONE email — no multiple versions
55
+ - Personalize from knowledge base context
56
+ - Match the tone of the incoming email
74
57
 
75
- Find unprocessed emails using the scan script:
58
+ **User approves before sending:**
76
59
 
77
- bash scripts/scan-emails.sh
60
+ - Always present the draft for review before sending
61
+ - Never send without explicit approval
78
62
 
79
- This outputs tab-separated `email_id<TAB>subject` for each email not yet in
80
- `drafts/drafted` or `drafts/ignored`.
63
+ ## Workflow
81
64
 
82
- ### Step 2: Parse Email
65
+ ### 1. Scan for New Emails
83
66
 
84
- Each email file is markdown with headers:
67
+ ```bash
68
+ node scripts/scan-emails.mjs
69
+ ```
85
70
 
86
- - `# Subject Line`
87
- - `**Thread ID:** <id>`
88
- - `**Message Count:** <count>`
89
- - `### From: Name <email@example.com>`
90
- - `**Date:** <date>`
71
+ Outputs tab-separated `email_id<TAB>subject` for unprocessed emails.
91
72
 
92
- ### Step 3: Classify Email
73
+ ### 2. Classify
93
74
 
94
- **IGNORE** (append ID to `drafts/ignored`):
75
+ **Ignore** (append ID to `drafts/ignored`):
95
76
 
96
77
  - Newsletters, marketing, automated notifications
97
78
  - Spam or irrelevant cold outreach
98
79
  - Outbound emails from user with no reply
99
80
 
100
- **DRAFT response for:**
81
+ **Draft response for:**
101
82
 
102
83
  - Meeting requests or scheduling
103
84
  - Personal emails from known contacts
104
- - Business inquiries
105
- - Follow-ups on existing conversations
85
+ - Business inquiries or follow-ups
106
86
  - Emails requesting information or action
107
87
 
108
- ### Step 4: Gather Context
88
+ ### 3. Gather Context
109
89
 
110
- **Knowledge Base (REQUIRED for every draft):**
90
+ **Knowledge base** (required for every draft):
111
91
 
112
92
  ```bash
113
93
  rg -l "sender_name" knowledge/
@@ -115,106 +95,88 @@ cat "knowledge/People/Sender Name.md"
115
95
  cat "knowledge/Organizations/Company Name.md"
116
96
  ```
117
97
 
118
- **Calendar (for scheduling emails):**
98
+ **Calendar** (for scheduling emails):
119
99
 
120
100
  ```bash
121
- ls ~/.cache/fit/basecamp/apple_calendar/ ~/.cache/fit/basecamp/google_calendar/ 2>/dev/null
101
+ ls ~/.cache/fit/basecamp/apple_calendar/ 2>/dev/null
122
102
  cat "$HOME/.cache/fit/basecamp/apple_calendar/event123.json"
123
103
  ```
124
104
 
125
- ### Step 5: Create Draft
105
+ ### 4. Write Draft
126
106
 
127
- Write draft to `drafts/{email_id}_draft.md`:
107
+ Save to `drafts/{email_id}_draft.md`:
128
108
 
129
109
  ```markdown
130
110
  # Draft Response
131
111
 
132
- **Original Email ID:** {id}
133
- **Original Subject:** {subject}
134
- **From:** {sender}
135
- **Date Processed:** {date}
136
-
137
- ---
138
-
139
- ## Context Used
140
- - Calendar: {relevant info or N/A}
141
- - Knowledge: {relevant notes or N/A}
112
+ **To:** recipient@example.com
113
+ **CC:** other@example.com
114
+ **Subject:** Re: {subject}
142
115
 
143
116
  ---
144
117
 
145
- ## Draft Response
146
-
147
- Subject: Re: {subject}
148
-
149
118
  {personalized draft body}
150
119
 
151
120
  ---
152
121
 
153
122
  ## Notes
154
- {why this response was crafted this way}
123
+ - **Original Email ID:** {id}
124
+ - **From:** {sender}
125
+ - **Context:** {knowledge base notes used}
155
126
  ```
156
127
 
157
- **Guidelines:**
128
+ Guidelines:
158
129
 
159
- - Draft ONE email — no multiple versions
160
- - Reference past interactions naturally
161
- - Match the tone of the incoming email
162
- - For scheduling: propose specific times from calendar
163
- - If unsure about intent, ask a clarifying question
130
+ - Draft ONE email — reference past interactions naturally
131
+ - For scheduling: propose specific times from calendar availability
132
+ - If unsure about intent, ask a clarifying question instead of drafting
133
+
134
+ ### 5. Present for Review
135
+
136
+ Show the draft to the user. Wait for explicit approval before sending. The user
137
+ may request edits — apply them and present again.
164
138
 
165
- ### Step 6: Update State
139
+ ### 6. Send
166
140
 
167
- After each email, update the state files:
141
+ After the user approves, send via Apple Mail:
168
142
 
169
143
  ```bash
170
- echo "$EMAIL_ID" >> drafts/drafted # or drafts/ignored
171
- date -u '+%Y-%m-%dT%H:%M:%SZ' > drafts/last_processed
144
+ node scripts/send-email.mjs \
145
+ --to "recipient@example.com" \
146
+ --cc "other@example.com" \
147
+ --subject "Re: Subject" \
148
+ --body "Plain text body"
172
149
  ```
173
150
 
174
- ### Step 7: Summary
151
+ Options: `--to` (required), `--cc` (optional), `--bcc` (optional), `--subject`
152
+ (required), `--body` (required, plain text only).
175
153
 
176
- ```
177
- ## Processing Summary
178
- **Emails Scanned:** X
179
- **Drafts Created:** Y
180
- **Ignored:** Z
154
+ Do NOT include an email signature — Apple Mail appends the configured signature
155
+ automatically.
181
156
 
182
- ### Drafts Created:
183
- - {id}: {subject} — {reason}
157
+ ### 7. Update State
184
158
 
185
- ### Ignored:
186
- - {id}: {subject} {reason}
159
+ ```bash
160
+ echo "$EMAIL_ID" >> drafts/drafted # or drafts/ignored
187
161
  ```
188
162
 
189
163
  ## Recruitment & Staffing Emails
190
164
 
191
- **CRITICAL: Candidates must NEVER be copied on internal emails about them.**
192
-
193
- When an email involves recruitment, staffing, or hiring:
165
+ **Candidates must NEVER be copied on internal emails about them.**
194
166
 
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:
167
+ 1. **Identify the candidate** from the thread and knowledge base
168
+ 2. **Strip the candidate from recipients** — draft to internal stakeholders only
169
+ 3. **Direct-to-candidate emails** — flag with:
203
170
  `⚠️ RECRUITER ONLY — This email goes directly to the candidate.`
204
171
 
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
172
+ Internal recruitment emails (candidate excluded): interview feedback, candidate
173
+ evaluation, hiring decisions, compensation discussions, reference checks.
212
174
 
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.
175
+ **When in doubt:** If an email thread mentions a candidate and involves multiple
176
+ internal recipients, treat it as internal and exclude the candidate.
215
177
 
216
178
  ## Constraints
217
179
 
218
- - Never actually send emails only create drafts
180
+ - Never send without explicit user approval
219
181
  - Be conservative with ignore — when in doubt, create a draft
220
- - For ambiguous emails, create a draft with a note explaining the ambiguity
182
+ - For ambiguous emails, draft with a note explaining the ambiguity
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Scan for unprocessed emails and output their IDs and subjects.
4
+ *
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
7
+ * line per unprocessed thread: email_id<TAB>subject. Used by the draft-emails
8
+ * skill to identify threads that need a reply.
9
+ */
10
+
11
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
12
+ import { basename, join } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ const HELP = `scan-emails — list unprocessed email threads
16
+
17
+ Usage: node scripts/scan-emails.mjs [-h|--help]
18
+
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
21
+ unprocessed thread as: email_id<TAB>subject`;
22
+
23
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
24
+ console.log(HELP);
25
+ process.exit(0);
26
+ }
27
+
28
+ const HOME = homedir();
29
+ const MAIL_DIR = join(HOME, ".cache/fit/basecamp/apple_mail");
30
+
31
+ /** Load a file of IDs (one per line) into a Set. */
32
+ function loadIdSet(path) {
33
+ const ids = new Set();
34
+ if (!existsSync(path)) return ids;
35
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
36
+ const trimmed = line.trim();
37
+ if (trimmed) ids.add(trimmed);
38
+ }
39
+ return ids;
40
+ }
41
+
42
+ /** Extract the first H1 heading from a markdown file. */
43
+ function extractSubject(filePath) {
44
+ const text = readFileSync(filePath, "utf-8");
45
+ const match = text.match(/^# (.+)$/m);
46
+ return match ? match[1] : "";
47
+ }
48
+
49
+ function main() {
50
+ if (!existsSync(MAIL_DIR)) return;
51
+
52
+ const drafted = loadIdSet("drafts/drafted");
53
+ const ignored = loadIdSet("drafts/ignored");
54
+
55
+ for (const name of readdirSync(MAIL_DIR).sort()) {
56
+ if (!name.endsWith(".md")) continue;
57
+
58
+ const emailId = basename(name, ".md");
59
+ if (drafted.has(emailId) || ignored.has(emailId)) continue;
60
+
61
+ const subject = extractSubject(join(MAIL_DIR, name));
62
+ console.log(`${emailId}\t${subject}`);
63
+ }
64
+ }
65
+
66
+ main();
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Send an email via Apple Mail using AppleScript.
4
+ *
5
+ * Builds an AppleScript command to create and send an outgoing message through
6
+ * Apple Mail. The script writes a temporary .scpt file, executes it with
7
+ * osascript, and cleans up afterwards. Mail.app must be running.
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.
11
+ */
12
+
13
+ import { execFileSync } from "node:child_process";
14
+ import { mkdtempSync, writeFileSync, unlinkSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+
18
+ const HELP = `send-email — send an email via Apple Mail
19
+
20
+ Usage: node scripts/send-email.mjs --to <addrs> --subject <subj> --body <text> [options]
21
+
22
+ Options:
23
+ --to <addrs> Comma-separated To recipients (required)
24
+ --cc <addrs> Comma-separated CC recipients
25
+ --bcc <addrs> Comma-separated BCC recipients
26
+ --subject <subj> Email subject line (required)
27
+ --body <text> Plain-text email body (required)
28
+ -h, --help Show this help message and exit
29
+
30
+ Mail.app must be running. No email signature needed — Apple Mail appends it.`;
31
+
32
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
33
+ console.log(HELP);
34
+ process.exit(0);
35
+ }
36
+
37
+ /** Escape a string for AppleScript double-quoted context. */
38
+ function escapeAS(s) {
39
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
40
+ }
41
+
42
+ /** Build recipient lines for AppleScript. */
43
+ function recipientLines(type, addrs) {
44
+ if (!addrs) return "";
45
+ return addrs
46
+ .split(",")
47
+ .map((a) => a.trim())
48
+ .filter(Boolean)
49
+ .map(
50
+ (addr) =>
51
+ ` make new ${type} at end of ${type}s with properties {address:"${escapeAS(addr)}"}`,
52
+ )
53
+ .join("\n");
54
+ }
55
+
56
+ function main() {
57
+ const args = process.argv.slice(2);
58
+ let to = "",
59
+ cc = "",
60
+ bcc = "",
61
+ subject = "",
62
+ body = "";
63
+
64
+ for (let i = 0; i < args.length; i++) {
65
+ switch (args[i]) {
66
+ case "--to":
67
+ to = args[++i] ?? "";
68
+ break;
69
+ case "--cc":
70
+ cc = args[++i] ?? "";
71
+ break;
72
+ case "--bcc":
73
+ bcc = args[++i] ?? "";
74
+ break;
75
+ case "--subject":
76
+ subject = args[++i] ?? "";
77
+ break;
78
+ case "--body":
79
+ body = args[++i] ?? "";
80
+ break;
81
+ }
82
+ }
83
+
84
+ if (!to || !subject || !body) {
85
+ console.error("Error: --to, --subject, and --body are required.");
86
+ console.error("Run with --help for usage info.");
87
+ process.exit(1);
88
+ }
89
+
90
+ const lines = [
91
+ 'tell application "Mail"',
92
+ ` set newMessage to make new outgoing message with properties {subject:"${escapeAS(subject)}", content:"${escapeAS(body)}", visible:false}`,
93
+ " tell newMessage",
94
+ recipientLines("to recipient", to),
95
+ cc ? recipientLines("cc recipient", cc) : "",
96
+ bcc ? recipientLines("bcc recipient", bcc) : "",
97
+ " end tell",
98
+ " send newMessage",
99
+ "end tell",
100
+ ]
101
+ .filter(Boolean)
102
+ .join("\n");
103
+
104
+ const tmp = join(mkdtempSync(join(tmpdir(), "send-email-")), "mail.scpt");
105
+ try {
106
+ writeFileSync(tmp, lines);
107
+ execFileSync("osascript", [tmp], { stdio: "inherit" });
108
+ console.log(`Sent: ${subject}`);
109
+ } finally {
110
+ try {
111
+ unlinkSync(tmp);
112
+ } catch {
113
+ // ignore cleanup errors
114
+ }
115
+ }
116
+ }
117
+
118
+ main();
@@ -68,7 +68,7 @@ Run this skill:
68
68
  1. Read `USER.md` to get the user's name, email, and domain
69
69
  2. Find new/changed files to process:
70
70
 
71
- python3 scripts/state.py check
71
+ node scripts/state.mjs check
72
72
 
73
73
  This outputs one file path per line for all source files that are new or
74
74
  have changed since last processing.
@@ -434,7 +434,7 @@ Always use absolute links: `[[People/Sarah Chen]]`,
434
434
 
435
435
  After processing each file, update the state:
436
436
 
437
- python3 scripts/state.py update "$FILE"
437
+ node scripts/state.mjs update "$FILE"
438
438
 
439
439
  ## Source Type Rules Summary
440
440
 
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Manage graph_processed state for entity extraction.
4
+ *
5
+ * Tracks which source files (synced emails and calendar events) have already
6
+ * been processed by the extract-entities skill. The `check` command lists files
7
+ * that are new or changed since their last recorded hash; `update` marks files
8
+ * as processed by storing their current SHA-256 hash.
9
+ *
10
+ * State is persisted as a TSV file at
11
+ * ~/.cache/fit/basecamp/state/graph_processed (path<TAB>hash).
12
+ */
13
+
14
+ if (process.argv.includes("-h") || process.argv.includes("--help")) {
15
+ console.log(`state — manage graph_processed state for entity extraction
16
+
17
+ Usage:
18
+ node scripts/state.mjs check List new/changed files
19
+ node scripts/state.mjs update <path> [<path>…] Mark files as processed
20
+ node scripts/state.mjs -h|--help Show this help message
21
+
22
+ State file: ~/.cache/fit/basecamp/state/graph_processed (TSV: path<TAB>hash)`);
23
+ process.exit(0);
24
+ }
25
+
26
+ import { createHash } from "node:crypto";
27
+ import {
28
+ existsSync,
29
+ mkdirSync,
30
+ readFileSync,
31
+ readdirSync,
32
+ statSync,
33
+ writeFileSync,
34
+ } from "node:fs";
35
+ import { homedir } from "node:os";
36
+ import { join } from "node:path";
37
+
38
+ const HOME = homedir();
39
+ const STATE_FILE = join(HOME, ".cache/fit/basecamp/state/graph_processed");
40
+ const SOURCE_DIRS = [
41
+ join(HOME, ".cache/fit/basecamp/apple_mail"),
42
+ join(HOME, ".cache/fit/basecamp/apple_calendar"),
43
+ ];
44
+
45
+ /** Compute SHA-256 hash of a file. */
46
+ function fileHash(filePath) {
47
+ const data = readFileSync(filePath);
48
+ return createHash("sha256").update(data).digest("hex");
49
+ }
50
+
51
+ /** Load the state file into a Map of {path → hash}. */
52
+ function loadState() {
53
+ const state = new Map();
54
+ if (!existsSync(STATE_FILE)) return state;
55
+ const text = readFileSync(STATE_FILE, "utf-8");
56
+ for (const line of text.split("\n")) {
57
+ if (!line) continue;
58
+ const idx = line.indexOf("\t");
59
+ if (idx === -1) continue;
60
+ state.set(line.slice(0, idx), line.slice(idx + 1));
61
+ }
62
+ return state;
63
+ }
64
+
65
+ /** Write the full state Map back to the state file. */
66
+ function saveState(state) {
67
+ const dir = join(HOME, ".cache/fit/basecamp/state");
68
+ mkdirSync(dir, { recursive: true });
69
+ const entries = [...state.entries()].sort((a, b) => a[0].localeCompare(b[0]));
70
+ const text = entries.length
71
+ ? entries.map(([p, h]) => `${p}\t${h}`).join("\n") + "\n"
72
+ : "";
73
+ writeFileSync(STATE_FILE, text);
74
+ }
75
+
76
+ /** Find source files that are new or have changed since last processing. */
77
+ function check() {
78
+ const state = loadState();
79
+ const newFiles = [];
80
+ for (const dir of SOURCE_DIRS) {
81
+ if (!existsSync(dir)) continue;
82
+ for (const name of readdirSync(dir)) {
83
+ const filePath = join(dir, name);
84
+ const stat = statSync(filePath, { throwIfNoEntry: false });
85
+ if (!stat || !stat.isFile()) continue;
86
+ const h = fileHash(filePath);
87
+ if (state.get(filePath) !== h) {
88
+ newFiles.push(filePath);
89
+ }
90
+ }
91
+ }
92
+ newFiles.sort();
93
+ for (const f of newFiles) {
94
+ console.log(f);
95
+ }
96
+ return newFiles.length;
97
+ }
98
+
99
+ /** Mark files as processed by updating their hashes in state. */
100
+ function update(filePaths) {
101
+ const state = loadState();
102
+ for (const fp of filePaths) {
103
+ if (!existsSync(fp)) {
104
+ console.error(`Warning: File not found: ${fp}`);
105
+ continue;
106
+ }
107
+ state.set(fp, fileHash(fp));
108
+ }
109
+ saveState(state);
110
+ console.log(`Updated ${filePaths.length} file(s) in graph state`);
111
+ }
112
+
113
+ // --- CLI ---
114
+
115
+ const args = process.argv.slice(2);
116
+ const cmd = args[0];
117
+
118
+ if (cmd === "check") {
119
+ const count = check();
120
+ console.error(`\n${count} file(s) to process`);
121
+ } else if (cmd === "update" && args.length >= 2) {
122
+ update(args.slice(1));
123
+ } else {
124
+ console.error(
125
+ "Usage:\n" +
126
+ " node scripts/state.mjs check\n" +
127
+ " node scripts/state.mjs update <file-path> [<file-path> …]",
128
+ );
129
+ process.exit(1);
130
+ }