@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,210 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Extract text from PowerPoint (.pptx) slides.
4
+ *
5
+ * PPTX files are ZIP archives containing XML. This script extracts all text
6
+ * from each slide and outputs it as structured markdown with slide headings.
7
+ * Handles multiple files and outputs to stdout or a file.
8
+ *
9
+ * Usage:
10
+ * node scripts/extract-pptx.mjs <path-to-pptx>
11
+ * node scripts/extract-pptx.mjs <path-to-pptx> -o /tmp/extract.txt
12
+ * node scripts/extract-pptx.mjs file1.pptx file2.pptx
13
+ * node scripts/extract-pptx.mjs -h|--help
14
+ *
15
+ * No external dependencies — uses Node.js built-in modules only.
16
+ */
17
+
18
+ import { readFileSync, writeFileSync } from "node:fs";
19
+ import { basename } from "node:path";
20
+
21
+ const HELP = `extract-pptx — extract slide text from .pptx files
22
+
23
+ Usage:
24
+ node scripts/extract-pptx.mjs <file.pptx> [file2.pptx ...]
25
+ node scripts/extract-pptx.mjs <file.pptx> -o <output.txt>
26
+ node scripts/extract-pptx.mjs -h|--help
27
+
28
+ Options:
29
+ -o <path> Write output to file instead of stdout
30
+ -h, --help Show this help
31
+
32
+ Output: Markdown-formatted text with ## Slide N headings per slide.
33
+ Multiple files get # Deck: filename.pptx headings.`;
34
+
35
+ if (
36
+ process.argv.includes("-h") ||
37
+ process.argv.includes("--help") ||
38
+ process.argv.length < 3
39
+ ) {
40
+ console.log(HELP);
41
+ process.exit(process.argv.length < 3 ? 1 : 0);
42
+ }
43
+
44
+ // --- Parse arguments ---
45
+
46
+ const args = process.argv.slice(2);
47
+ let outputPath = null;
48
+ const files = [];
49
+
50
+ for (let i = 0; i < args.length; i++) {
51
+ if (args[i] === "-o" && i + 1 < args.length) {
52
+ outputPath = args[++i];
53
+ } else if (!args[i].startsWith("-")) {
54
+ files.push(args[i]);
55
+ }
56
+ }
57
+
58
+ if (files.length === 0) {
59
+ console.error("Error: no .pptx files provided");
60
+ process.exit(1);
61
+ }
62
+
63
+ // --- ZIP parsing (no dependencies) ---
64
+
65
+ /**
66
+ * Parse a ZIP file's central directory to extract file entries.
67
+ * @param {Buffer} buf
68
+ * @returns {Array<{name: string, offset: number, compressedSize: number, compressionMethod: number}>}
69
+ */
70
+ function parseZipEntries(buf) {
71
+ // Find End of Central Directory record (signature 0x06054b50)
72
+ let eocdOffset = -1;
73
+ for (let i = buf.length - 22; i >= 0; i--) {
74
+ if (
75
+ buf[i] === 0x50 &&
76
+ buf[i + 1] === 0x4b &&
77
+ buf[i + 2] === 0x05 &&
78
+ buf[i + 3] === 0x06
79
+ ) {
80
+ eocdOffset = i;
81
+ break;
82
+ }
83
+ }
84
+ if (eocdOffset === -1) throw new Error("Not a valid ZIP file");
85
+
86
+ const cdOffset = buf.readUInt32LE(eocdOffset + 16);
87
+ const cdEntries = buf.readUInt16LE(eocdOffset + 10);
88
+
89
+ const entries = [];
90
+ let pos = cdOffset;
91
+
92
+ for (let e = 0; e < cdEntries; e++) {
93
+ // Central directory file header signature: 0x02014b50
94
+ if (buf.readUInt32LE(pos) !== 0x02014b50) break;
95
+
96
+ const compressionMethod = buf.readUInt16LE(pos + 10);
97
+ const compressedSize = buf.readUInt32LE(pos + 20);
98
+ const nameLen = buf.readUInt16LE(pos + 28);
99
+ const extraLen = buf.readUInt16LE(pos + 30);
100
+ const commentLen = buf.readUInt16LE(pos + 32);
101
+ const localHeaderOffset = buf.readUInt32LE(pos + 42);
102
+ const name = buf.toString("utf8", pos + 46, pos + 46 + nameLen);
103
+
104
+ entries.push({
105
+ name,
106
+ offset: localHeaderOffset,
107
+ compressedSize,
108
+ compressionMethod,
109
+ });
110
+ pos += 46 + nameLen + extraLen + commentLen;
111
+ }
112
+
113
+ return entries;
114
+ }
115
+
116
+ const { inflateRawSync } = await import("node:zlib");
117
+
118
+ /**
119
+ * Read the uncompressed content of a ZIP entry.
120
+ * @param {Buffer} buf
121
+ * @param {{offset: number, compressedSize: number, compressionMethod: number}} entry
122
+ * @returns {Buffer}
123
+ */
124
+ function readEntry(buf, entry) {
125
+ const pos = entry.offset;
126
+ const nameLen = buf.readUInt16LE(pos + 26);
127
+ const extraLen = buf.readUInt16LE(pos + 28);
128
+ const dataStart = pos + 30 + nameLen + extraLen;
129
+ const raw = buf.subarray(dataStart, dataStart + entry.compressedSize);
130
+
131
+ if (entry.compressionMethod === 0) return raw;
132
+ if (entry.compressionMethod === 8) return inflateRawSync(raw);
133
+ throw new Error(
134
+ `Unsupported compression method ${entry.compressionMethod} for ${entry.name}`,
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Extract all text content from slide XML using the DrawingML namespace.
140
+ * Matches <a:t>text</a:t> elements used by PowerPoint.
141
+ * @param {string} xml
142
+ * @returns {string[]}
143
+ */
144
+ function extractTextFromXml(xml) {
145
+ const texts = [];
146
+ const re = /<a:t>([^<]*)<\/a:t>/g;
147
+ let match;
148
+ while ((match = re.exec(xml)) !== null) {
149
+ const text = match[1].trim();
150
+ if (text) texts.push(text);
151
+ }
152
+ return texts;
153
+ }
154
+
155
+ /**
156
+ * Extract slide text from a .pptx file.
157
+ * @param {string} filePath
158
+ * @returns {string} Markdown-formatted slide text
159
+ */
160
+ function extractPptx(filePath) {
161
+ const buf = readFileSync(filePath);
162
+ const entries = parseZipEntries(buf);
163
+
164
+ // Find slide XML files and sort by slide number
165
+ const slideEntries = entries
166
+ .filter(
167
+ (e) => e.name.startsWith("ppt/slides/slide") && e.name.endsWith(".xml"),
168
+ )
169
+ .sort((a, b) => {
170
+ const numA = parseInt(a.name.match(/(\d+)/)?.[1] || "0", 10);
171
+ const numB = parseInt(b.name.match(/(\d+)/)?.[1] || "0", 10);
172
+ return numA - numB;
173
+ });
174
+
175
+ const lines = [];
176
+
177
+ for (const entry of slideEntries) {
178
+ const xml = readEntry(buf, entry).toString("utf8");
179
+ const texts = extractTextFromXml(xml);
180
+
181
+ if (texts.length > 0) {
182
+ const num = entry.name.match(/(\d+)/)?.[1] || "?";
183
+ lines.push(`## Slide ${num}`);
184
+ lines.push(texts.join("\n"));
185
+ lines.push("");
186
+ }
187
+ }
188
+
189
+ return lines.join("\n");
190
+ }
191
+
192
+ // --- Main ---
193
+
194
+ const outputs = [];
195
+
196
+ for (const file of files) {
197
+ if (files.length > 1) {
198
+ outputs.push(`# Deck: ${basename(file)}\n`);
199
+ }
200
+ outputs.push(extractPptx(file));
201
+ }
202
+
203
+ const result = outputs.join("\n");
204
+
205
+ if (outputPath) {
206
+ writeFileSync(outputPath, result);
207
+ console.log(`Extracted ${files.length} deck(s) → ${outputPath}`);
208
+ } else {
209
+ process.stdout.write(result);
210
+ }
@@ -36,6 +36,7 @@ Run this skill:
36
36
 
37
37
  - `knowledge/Candidates/{Full Name}/brief.md` — candidate profile note
38
38
  - `knowledge/Candidates/{Full Name}/CV.pdf` — local copy of CV (or `CV.docx`)
39
+ - `knowledge/Candidates/{Full Name}/headshot.jpeg` — candidate headshot photo
39
40
  - `~/.cache/fit/basecamp/state/graph_processed` — updated with processed threads
40
41
 
41
42
  ---
@@ -192,6 +193,48 @@ cp "~/.cache/fit/basecamp/apple_mail/attachments/{thread_id}/{filename}" \
192
193
  Use `CV.pdf` for PDF files and `CV.docx` for Word documents. The `## CV` link in
193
194
  the brief uses a relative path: `./CV.pdf`.
194
195
 
196
+ ### Headshot Discovery
197
+
198
+ Search two locations for candidate headshot photos:
199
+
200
+ 1. **Email attachments** —
201
+ `~/.cache/fit/basecamp/apple_mail/attachments/{thread_id}/` may contain
202
+ headshot images sent by recruiters alongside CVs. Look for `.jpg`, `.jpeg`,
203
+ or `.png` files with candidate name fragments in the filename or that are
204
+ clearly portrait photos (not logos, signatures, or email decorations like
205
+ `image001.png`).
206
+
207
+ 2. **Downloads folder** — search `~/Downloads/` recursively (including
208
+ subdirectories) for headshot images:
209
+
210
+ ```bash
211
+ find ~/Downloads -maxdepth 3 -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.heic" \) 2>/dev/null
212
+ ```
213
+
214
+ Match images to candidates by name similarity in the filename (e.g.
215
+ `vitalii.jpeg` matches "Vitalii Huliai", `qazi.jpeg` matches "Qazi Rehman"). Use
216
+ first name, last name, or full name matching — case-insensitive.
217
+
218
+ When a headshot is found, copy it into the candidate directory with a
219
+ standardized name:
220
+
221
+ ```bash
222
+ cp "{source_path}" "knowledge/Candidates/{Full Name}/headshot.jpeg"
223
+ ```
224
+
225
+ Always use `headshot.jpeg` as the filename regardless of the source format. If
226
+ the source is PNG or HEIC, convert it first:
227
+
228
+ ```bash
229
+ # PNG to JPEG
230
+ magick "{source}.png" "knowledge/Candidates/{Full Name}/headshot.jpeg"
231
+ # HEIC to JPEG
232
+ magick "{source}.heic" "knowledge/Candidates/{Full Name}/headshot.jpeg"
233
+ ```
234
+
235
+ If headshots exist in both locations, prefer the Downloads folder version (more
236
+ likely to be a curated, high-quality photo).
237
+
195
238
  ## Step 3: Determine Pipeline Status
196
239
 
197
240
  Assign a status based on the email context:
@@ -430,3 +473,5 @@ produces a full framework-aligned assessment.
430
473
  - [ ] Skills tagged using framework skill IDs where possible
431
474
  - [ ] Gender field populated only from explicit pronouns/titles (never
432
475
  name-inferred)
476
+ - [ ] Headshots searched in email attachments and `~/Downloads/` (recursive)
477
+ - [ ] Found headshots copied as `headshot.jpeg` into candidate directory
@@ -49,11 +49,13 @@ Run this skill:
49
49
 
50
50
  ## Workday Export Format
51
51
 
52
- The Workday requisition export contains multiple sheets. This skill uses:
52
+ The Workday export format varies between versions. The parser handles both
53
+ automatically using header-driven column mapping and dynamic header row
54
+ detection.
53
55
 
54
56
  ### Sheet 1 — Requisition Metadata
55
57
 
56
- Key-value pairs, one per row:
58
+ **Old format** — key-value pairs with Hiring Manager, Recruiter, Location:
57
59
 
58
60
  | Row | Field | Example |
59
61
  | --- | --------------------- | -------------------------------------- |
@@ -66,38 +68,58 @@ Key-value pairs, one per row:
66
68
  | 7 | Recruiter Title | `Recruiter` |
67
69
  | 8 | Recruiter | Name |
68
70
 
69
- ### Sheet 3 Candidates
70
-
71
- Row 3 contains column headers. Data rows start at row 4. After the last
72
- candidate, stage-summary rows appear (these are not candidates).
73
-
74
- | Column | Field | Maps to brief field… |
75
- | ------ | ---------------------- | ------------------------ |
76
- | B | Candidate name | `# {Name}` |
77
- | C | Stage | Status derivation |
78
- | D | Step / Disposition | Status derivation |
79
- | G | Resume filename | Reference only (no file) |
80
- | H | Date Applied | **First seen** |
81
- | I | Current Job Title | **Current title**, Title |
82
- | J | Current Company | **Current title** suffix |
83
- | K | Source | **Source** |
84
- | L | Referred by | **Source** suffix |
85
- | N | Availability Date | **Availability** |
86
- | O | Visa Requirement | Notes |
87
- | P | Eligible to Work | Notes |
88
- | Q | Relocation | Notes |
89
- | R | Salary Expectations | **Rate** |
90
- | S | Non-Compete | Notes |
91
- | T | Candidate Location | **Location** |
92
- | U | Phone | **Phone** |
93
- | V | Email | **Email** |
94
- | W | Total Years Experience | Summary context |
95
- | X | All Job Titles | Work History context |
96
- | Y | Companies | Work History context |
97
- | Z | Degrees | Education |
98
- | AA | Fields of Study | Education |
99
- | AB | Language | **English** / Language |
100
- | AC | Resume Text | `CV.md` content |
71
+ **New format**stage-count summary (no HM/Recruiter/Location):
72
+
73
+ | Row | Field | Example |
74
+ | --- | ----------------- | -------------------------------------- |
75
+ | 1 | Title header | `4951493 Principal Software Engineer…` |
76
+ | 2 | Active Candidates | `74 of 74` |
77
+ | 3 | Active Referrals | `3 of 3` |
78
+ | 4 | Active Internal | `4 of 4` |
79
+ | 7+ | Stage counts | `56 → Considered` |
80
+
81
+ ### Candidates Sheet
82
+
83
+ The parser auto-detects the candidates sheet and header row:
84
+
85
+ - **Old format**: 3+ sheets; candidates on "Candidates" sheet or Sheet3; header
86
+ at row 3 (index 2); two "Job Application" columns
87
+ - **New format**: 2 sheets; candidates on Sheet2; header at row 8 (index 7);
88
+ single "Job Application" column
89
+
90
+ Column mapping is header-driven — the parser reads the header row and maps
91
+ columns by name, not position. Columns that vary between exports (e.g. "Jobs
92
+ Applied to", "Referred by", "Convenience Task") are handled automatically.
93
+
94
+ **Core columns** (present in all formats):
95
+
96
+ | Header | Maps to brief field… |
97
+ | ---------------------- | ---------------------------------------- |
98
+ | Job Application | `# {Name}` |
99
+ | Stage | Row detection only (not used for status) |
100
+ | Step / Disposition | **Workday step** status derivation |
101
+ | Resume | Reference only (no file) |
102
+ | Date Applied | **First seen** |
103
+ | Current Job Title | **Current title**, Title |
104
+ | Current Company | **Current title** suffix |
105
+ | Source | **Source** |
106
+ | Referred by | **Source** suffix |
107
+ | Candidate Location | **Location** |
108
+ | Phone | **Phone** |
109
+ | Email | **Email** |
110
+ | Availability Date | **Availability** |
111
+ | Visa Requirement | Notes |
112
+ | Eligible to Work | Notes |
113
+ | Relocation | Notes |
114
+ | Salary Expectations | **Rate** |
115
+ | Non-Compete | Notes |
116
+ | Total Years Experience | Summary context |
117
+ | All Job Titles | Work History context |
118
+ | Companies | Work History context |
119
+ | Degrees | Education |
120
+ | Fields of Study | Education |
121
+ | Language | **English** / Language |
122
+ | Resume Text | `CV.md` content |
101
123
 
102
124
  #### Name Annotations
103
125
 
@@ -153,25 +175,36 @@ Use fuzzy matching — the Workday name may differ slightly from an existing not
153
175
 
154
176
  ## Step 3: Determine Pipeline Status
155
177
 
156
- Map Workday stage and step/disposition to the `track-candidates` pipeline
157
- status:
158
-
159
- | Workday Step / Disposition | Pipeline Status |
160
- | ---------------------------- | ------------------ |
161
- | `Considered` | `new` |
162
- | `Manager Resume Screen` | `screening` |
163
- | `Assessment` | `screening` |
164
- | `Interview` / `Phone Screen` | `first-interview` |
165
- | `Second Interview` | `second-interview` |
166
- | `Reference Check` | `second-interview` |
167
- | `Offer` | `offer` |
168
- | `Employment Agreement` | `offer` |
169
- | `Background Check` | `hired` |
170
- | `Ready for Hire` | `hired` |
171
- | `Rejected` / `Declined` | `rejected` |
172
-
173
- If the step is identical to the stage (e.g. both "Considered"), default to
174
- `new`.
178
+ Map the **Step / Disposition** column to the `track-candidates` pipeline status.
179
+ Do NOT use the Stage column for status — it is only used for row detection (stop
180
+ condition):
181
+
182
+ | Workday Step / Disposition | Pipeline Status |
183
+ | -------------------------------------- | ------------------ |
184
+ | `Considered` | `new` |
185
+ | `Review` | `new` |
186
+ | `Manager Resume Screen` | `screening` |
187
+ | `Schedule Recruiter Phone Screen` | `screening` |
188
+ | `Manager Request to Move Forward (HS)` | `screening` |
189
+ | `Proposed Interview Slate` | `screening` |
190
+ | `Assessment` | `screening` |
191
+ | `Manager Request to Decline (HS)` | `rejected` |
192
+ | `Interview` / `Phone Screen` | `first-interview` |
193
+ | `Second Interview` | `second-interview` |
194
+ | `Reference Check` | `second-interview` |
195
+ | `Offer` | `offer` |
196
+ | `Employment Agreement` | `offer` |
197
+ | `Background Check` | `hired` |
198
+ | `Ready for Hire` | `hired` |
199
+ | `Rejected` / `Declined` | `rejected` |
200
+
201
+ If the step value is empty or not recognized, default to `new`.
202
+
203
+ **Important:** The raw `step` value is always preserved in the JSON output and
204
+ should be stored in the candidate brief's **Pipeline** section (e.g.
205
+ `Applied via LinkedIn — Step: Manager Request to Move Forward (HS)`). This
206
+ allows the user to filter and query candidates by their exact Workday
207
+ disposition.
175
208
 
176
209
  ## Step 4: Create CV.md from Resume Text
177
210
 
@@ -107,20 +107,86 @@ const requisition = {
107
107
  recruiter: reqMeta["Recruiter"] || "",
108
108
  };
109
109
 
110
- // --- Sheet 3: Candidates ---
110
+ // --- Candidates sheet ---
111
111
 
112
- // Find the "Candidates" sheet (usually index 2, but search by name to be safe)
112
+ // Find the candidates sheet. Workday exports vary:
113
+ // - Old format: 3+ sheets, candidates on a sheet named "Candidates" or Sheet3
114
+ // - New format: 2 sheets, candidates on Sheet2
113
115
  const candSheetName =
114
116
  wb.SheetNames.find((n) => n.toLowerCase() === "candidates") ||
115
- wb.SheetNames[2];
117
+ wb.SheetNames[Math.min(2, wb.SheetNames.length - 1)];
116
118
  const ws3 = wb.Sheets[candSheetName];
117
119
  const candRows = XLSX.utils.sheet_to_json(ws3, { header: 1, defval: "" });
118
120
 
119
- // Row 3 (index 2) has column headers. Data starts at row 4 (index 3).
120
- // Stage summary rows start when column A has a non-empty value that looks like
121
- // a label or number — detect by checking if column C (Stage) is empty and
122
- // column A has a value.
123
- const DATA_START = 3;
121
+ // Find the header row dynamically look for a row containing "Stage"
122
+ // Old format: row 3 (index 2). New format: row 8 (index 7).
123
+ let HEADER_ROW = 2;
124
+ for (let i = 0; i < Math.min(15, candRows.length); i++) {
125
+ if (candRows[i].some((c) => String(c).trim().toLowerCase() === "stage")) {
126
+ HEADER_ROW = i;
127
+ break;
128
+ }
129
+ }
130
+ const DATA_START = HEADER_ROW + 1;
131
+
132
+ // --- Build header-driven column index map ---
133
+ // Column layout varies between Workday exports (extra columns like "Jobs Applied to"
134
+ // or "Referred by" shift indices). Map by header name to be resilient.
135
+
136
+ const headerRow = candRows[HEADER_ROW] || [];
137
+ const colMap = {};
138
+ const HEADER_ALIASES = {
139
+ "job application": "name", // second "Job Application" column (index 1) has candidate name
140
+ stage: "stage",
141
+ "step / disposition": "step",
142
+ "awaiting me": "awaitingMe",
143
+ "awaiting action": "awaitingAction",
144
+ resume: "resumeFile",
145
+ "date applied": "dateApplied",
146
+ "current job title": "currentTitle",
147
+ "current company": "currentCompany",
148
+ source: "source",
149
+ "referred by": "referredBy",
150
+ "availability date": "availabilityDate",
151
+ "visa requirement": "visaRequirement",
152
+ "eligible to work": "eligibleToWork",
153
+ relocation: "relocation",
154
+ "salary expectations": "salaryExpectations",
155
+ "non-compete": "nonCompete",
156
+ "candidate location": "location",
157
+ phone: "phone",
158
+ email: "email",
159
+ "total years experience": "totalYearsExperience",
160
+ "all job titles": "allJobTitles",
161
+ companies: "companies",
162
+ degrees: "degrees",
163
+ "fields of study": "fieldsOfStudy",
164
+ language: "language",
165
+ "resume text": "resumeText",
166
+ };
167
+
168
+ // Skip columns we don't need (e.g. "Jobs Applied to", "Create Candidate Home Account URL")
169
+ for (let i = 0; i < headerRow.length; i++) {
170
+ const hdr = String(headerRow[i]).trim().toLowerCase();
171
+ const field = HEADER_ALIASES[hdr];
172
+ if (field) {
173
+ // "Job Application" appears twice (cols A and B) — always take the latest
174
+ // occurrence so we end up with the second one (index 1) which has the name
175
+ colMap[field] = i;
176
+ }
177
+ }
178
+
179
+ // Fallback: if "name" wasn't mapped, use index 0 (new format) or 1 (old format)
180
+ if (colMap.name === undefined) colMap.name = 1;
181
+ // In new format there's only one "Job Application" column (index 0) — the
182
+ // "always take latest" logic already handles this correctly.
183
+
184
+ /** Get a cell value by field name, with fallback to empty string. */
185
+ function col(row, field) {
186
+ const idx = colMap[field];
187
+ if (idx === undefined) return "";
188
+ return row[idx] ?? "";
189
+ }
124
190
 
125
191
  /**
126
192
  * Clean a candidate name by stripping annotations like (Prior Worker),
@@ -180,45 +246,45 @@ const candidates = [];
180
246
 
181
247
  for (let i = DATA_START; i < candRows.length; i++) {
182
248
  const row = candRows[i];
183
- const rawName = String(row[1] || "").trim(); // Column B (index 1)
184
- const stage = String(row[2] || "").trim(); // Column C (index 2)
249
+ const rawName = String(col(row, "name") || "").trim();
250
+ const stage = String(col(row, "stage") || "").trim();
185
251
 
186
- // Stop at stage-summary rows: column A has a value, column C (stage) is empty
187
- if (!rawName || (!stage && String(row[0] || "").trim())) break;
252
+ // Skip empty rows; stop at stage-summary rows (name present but no stage)
188
253
  if (!rawName) continue;
254
+ if (!stage) break;
189
255
 
190
256
  const { cleanName, internalExternal: nameIE } = parseName(rawName);
191
- const source = String(row[10] || "").trim();
257
+ const source = String(col(row, "source") || "").trim();
192
258
 
193
259
  candidates.push({
194
260
  name: rawName,
195
261
  cleanName,
196
262
  stage,
197
- step: String(row[3] || "").trim(),
198
- awaitingMe: String(row[4] || "").trim(),
199
- awaitingAction: String(row[5] || "").trim(),
200
- resumeFile: String(row[6] || "").trim(),
201
- dateApplied: fmtDate(row[7]),
202
- currentTitle: String(row[8] || "").trim(),
203
- currentCompany: String(row[9] || "").trim(),
263
+ step: String(col(row, "step") || "").trim(),
264
+ awaitingMe: String(col(row, "awaitingMe") || "").trim(),
265
+ awaitingAction: String(col(row, "awaitingAction") || "").trim(),
266
+ resumeFile: String(col(row, "resumeFile") || "").trim(),
267
+ dateApplied: fmtDate(col(row, "dateApplied")),
268
+ currentTitle: String(col(row, "currentTitle") || "").trim(),
269
+ currentCompany: String(col(row, "currentCompany") || "").trim(),
204
270
  source,
205
- referredBy: String(row[11] || "").trim(),
206
- availabilityDate: fmtDate(row[13]),
207
- visaRequirement: String(row[14] || "").trim(),
208
- eligibleToWork: String(row[15] || "").trim(),
209
- relocation: String(row[16] || "").trim(),
210
- salaryExpectations: String(row[17] || "").trim(),
211
- nonCompete: String(row[18] || "").trim(),
212
- location: String(row[19] || "").trim(),
213
- phone: String(row[20] || "").trim(),
214
- email: String(row[21] || "").trim(),
215
- totalYearsExperience: String(row[22] || "").trim(),
216
- allJobTitles: multiline(row[23]),
217
- companies: multiline(row[24]),
218
- degrees: multiline(row[25]),
219
- fieldsOfStudy: multiline(row[26]),
220
- language: multiline(row[27]),
221
- resumeText: String(row[28] || "").trim(),
271
+ referredBy: String(col(row, "referredBy") || "").trim(),
272
+ availabilityDate: fmtDate(col(row, "availabilityDate")),
273
+ visaRequirement: String(col(row, "visaRequirement") || "").trim(),
274
+ eligibleToWork: String(col(row, "eligibleToWork") || "").trim(),
275
+ relocation: String(col(row, "relocation") || "").trim(),
276
+ salaryExpectations: String(col(row, "salaryExpectations") || "").trim(),
277
+ nonCompete: String(col(row, "nonCompete") || "").trim(),
278
+ location: String(col(row, "location") || "").trim(),
279
+ phone: String(col(row, "phone") || "").trim(),
280
+ email: String(col(row, "email") || "").trim(),
281
+ totalYearsExperience: String(col(row, "totalYearsExperience") || "").trim(),
282
+ allJobTitles: multiline(col(row, "allJobTitles")),
283
+ companies: multiline(col(row, "companies")),
284
+ degrees: multiline(col(row, "degrees")),
285
+ fieldsOfStudy: multiline(col(row, "fieldsOfStudy")),
286
+ language: multiline(col(row, "language")),
287
+ resumeText: String(col(row, "resumeText") || "").trim(),
222
288
  internalExternal: inferInternalExternal(source, nameIE),
223
289
  });
224
290
  }
@@ -87,6 +87,7 @@ wake, they observe KB state, decide the most valuable action, and execute.
87
87
  | **concierge** | Meeting prep and transcripts | Every 10 min | sync-apple-calendar, meeting-prep, process-hyprnote |
88
88
  | **librarian** | Knowledge graph maintenance | Every 15 min | extract-entities, organize-files, manage-tasks |
89
89
  | **recruiter** | Engineering recruitment | Every 30 min | track-candidates, analyze-cv, workday-requisition, right-to-be-forgotten, fit-pathway, fit-map |
90
+ | **head-hunter** | Passive talent scouting | Every 60 min | scan-open-candidates, fit-pathway, fit-map |
90
91
  | **chief-of-staff** | Daily briefings and priorities | 7am, Mon 7:30am | weekly-update _(Mon)_, _(reads all state for daily briefings)_ |
91
92
 
92
93
  Each agent writes a triage file to `~/.cache/fit/basecamp/state/` every wake
@@ -96,9 +97,10 @@ cycle. The naming convention is `{agent}_triage.md`:
96
97
  - `concierge_triage.md` — schedule, meeting prep status, unprocessed transcripts
97
98
  - `librarian_triage.md` — unprocessed files, knowledge graph size
98
99
  - `recruiter_triage.md` — candidate pipeline, assessments, track distribution
100
+ - `head_hunter_triage.md` — prospect pipeline, source rotation, match strength
99
101
 
100
- The **chief-of-staff** reads all three triage files to synthesize daily
101
- briefings in `knowledge/Briefings/`.
102
+ The **chief-of-staff** reads all five triage files to synthesize daily briefings
103
+ in `knowledge/Briefings/`.
102
104
 
103
105
  ## Cache Directory (`~/.cache/fit/basecamp/`)
104
106
 
@@ -110,13 +112,20 @@ Synced data and runtime state live outside the knowledge base in
110
112
  ├── apple_mail/ # Synced Apple Mail threads (.md files)
111
113
  │ └── attachments/ # Copied email attachments by thread
112
114
  ├── apple_calendar/ # Synced Apple Calendar events (.json files)
115
+ ├── head-hunter/ # Head hunter agent memory
116
+ │ ├── cursor.tsv # Source rotation state
117
+ │ ├── failures.tsv # Consecutive failure tracking
118
+ │ ├── seen.tsv # Deduplication index
119
+ │ ├── prospects.tsv # Prospect index
120
+ │ └── log.md # Append-only activity log
113
121
  └── state/ # Runtime state
114
122
  ├── apple_mail_last_sync # ISO timestamp of last mail sync
115
123
  ├── graph_processed # TSV of processed files (path<TAB>hash)
116
124
  ├── postman_triage.md # Agent triage files ({agent}_triage.md)
117
125
  ├── concierge_triage.md
118
126
  ├── librarian_triage.md
119
- └── recruiter_triage.md
127
+ ├── recruiter_triage.md
128
+ └── head_hunter_triage.md
120
129
  ```
121
130
 
122
131
  This separation keeps the knowledge base clean — only the parsed knowledge
@@ -208,6 +217,7 @@ Available skills (grouped by function):
208
217
  | `workday-requisition` | Import candidates from Workday XLSX |
209
218
  | `analyze-cv` | CV assessment against career framework |
210
219
  | `right-to-be-forgotten` | GDPR data erasure with audit trail |
220
+ | `scan-open-candidates` | Scan public sources for open-for-hire |
211
221
  | `weekly-update` | Weekly priorities from tasks + calendar |
212
222
  | `process-hyprnote` | Extract entities from Hyprnote sessions |
213
223
  | `organize-files` | Tidy Desktop/Downloads, chain to extract |