@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.
- package/config/scheduler.json +10 -5
- package/package.json +1 -1
- package/src/basecamp.js +101 -729
- package/template/.claude/agents/chief-of-staff.md +14 -3
- package/template/.claude/agents/head-hunter.md +436 -0
- package/template/.claude/agents/librarian.md +1 -1
- package/template/.claude/settings.json +4 -1
- package/template/.claude/skills/analyze-cv/SKILL.md +39 -7
- package/template/.claude/skills/draft-emails/SKILL.md +29 -9
- package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +4 -4
- package/template/.claude/skills/draft-emails/scripts/send-email.mjs +41 -6
- package/template/.claude/skills/meeting-prep/SKILL.md +7 -4
- package/template/.claude/skills/process-hyprnote/SKILL.md +17 -8
- package/template/.claude/skills/process-hyprnote/scripts/scan.mjs +246 -0
- package/template/.claude/skills/scan-open-candidates/SKILL.md +476 -0
- package/template/.claude/skills/scan-open-candidates/scripts/state.mjs +396 -0
- package/template/.claude/skills/sync-apple-calendar/SKILL.md +41 -0
- package/template/.claude/skills/sync-apple-calendar/scripts/query.mjs +301 -0
- package/template/.claude/skills/synthesize-deck/SKILL.md +296 -0
- package/template/.claude/skills/synthesize-deck/scripts/extract-pptx.mjs +210 -0
- package/template/.claude/skills/track-candidates/SKILL.md +45 -0
- package/template/.claude/skills/workday-requisition/SKILL.md +86 -53
- package/template/.claude/skills/workday-requisition/scripts/parse-workday.mjs +103 -37
- 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
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
Row
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
|
75
|
-
|
|
|
76
|
-
|
|
|
77
|
-
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
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
|
|
157
|
-
status
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
|
161
|
-
|
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `
|
|
165
|
-
| `
|
|
166
|
-
| `
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
`
|
|
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
|
-
// ---
|
|
110
|
+
// --- Candidates sheet ---
|
|
111
111
|
|
|
112
|
-
// Find the
|
|
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
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
184
|
-
const stage = String(row
|
|
249
|
+
const rawName = String(col(row, "name") || "").trim();
|
|
250
|
+
const stage = String(col(row, "stage") || "").trim();
|
|
185
251
|
|
|
186
|
-
//
|
|
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
|
|
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
|
|
198
|
-
awaitingMe: String(row
|
|
199
|
-
awaitingAction: String(row
|
|
200
|
-
resumeFile: String(row
|
|
201
|
-
dateApplied: fmtDate(row
|
|
202
|
-
currentTitle: String(row
|
|
203
|
-
currentCompany: String(row
|
|
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
|
|
206
|
-
availabilityDate: fmtDate(row
|
|
207
|
-
visaRequirement: String(row
|
|
208
|
-
eligibleToWork: String(row
|
|
209
|
-
relocation: String(row
|
|
210
|
-
salaryExpectations: String(row
|
|
211
|
-
nonCompete: String(row
|
|
212
|
-
location: String(row
|
|
213
|
-
phone: String(row
|
|
214
|
-
email: String(row
|
|
215
|
-
totalYearsExperience: String(row
|
|
216
|
-
allJobTitles: multiline(row
|
|
217
|
-
companies: multiline(row
|
|
218
|
-
degrees: multiline(row
|
|
219
|
-
fieldsOfStudy: multiline(row
|
|
220
|
-
language: multiline(row
|
|
221
|
-
resumeText: String(row
|
|
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
|
}
|
package/template/CLAUDE.md
CHANGED
|
@@ -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
|
|
101
|
-
|
|
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
|
-
|
|
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 |
|