@forwardimpact/basecamp 2.4.2 → 2.5.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 +5 -0
- package/package.json +1 -1
- package/template/.claude/agents/chief-of-staff.md +14 -3
- package/template/.claude/agents/head-hunter.md +435 -0
- package/template/.claude/settings.json +4 -1
- 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/scan-open-candidates/SKILL.md +386 -0
- package/template/.claude/skills/workday-requisition/SKILL.md +86 -53
- package/template/.claude/skills/workday-requisition/scripts/parse-workday.mjs +107 -37
- package/template/CLAUDE.md +12 -2
|
@@ -107,20 +107,90 @@ 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 (
|
|
126
|
+
candRows[i].some(
|
|
127
|
+
(c) => String(c).trim().toLowerCase() === "stage",
|
|
128
|
+
)
|
|
129
|
+
) {
|
|
130
|
+
HEADER_ROW = i;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const DATA_START = HEADER_ROW + 1;
|
|
135
|
+
|
|
136
|
+
// --- Build header-driven column index map ---
|
|
137
|
+
// Column layout varies between Workday exports (extra columns like "Jobs Applied to"
|
|
138
|
+
// or "Referred by" shift indices). Map by header name to be resilient.
|
|
139
|
+
|
|
140
|
+
const headerRow = candRows[HEADER_ROW] || [];
|
|
141
|
+
const colMap = {};
|
|
142
|
+
const HEADER_ALIASES = {
|
|
143
|
+
"job application": "name", // second "Job Application" column (index 1) has candidate name
|
|
144
|
+
stage: "stage",
|
|
145
|
+
"step / disposition": "step",
|
|
146
|
+
"awaiting me": "awaitingMe",
|
|
147
|
+
"awaiting action": "awaitingAction",
|
|
148
|
+
resume: "resumeFile",
|
|
149
|
+
"date applied": "dateApplied",
|
|
150
|
+
"current job title": "currentTitle",
|
|
151
|
+
"current company": "currentCompany",
|
|
152
|
+
source: "source",
|
|
153
|
+
"referred by": "referredBy",
|
|
154
|
+
"availability date": "availabilityDate",
|
|
155
|
+
"visa requirement": "visaRequirement",
|
|
156
|
+
"eligible to work": "eligibleToWork",
|
|
157
|
+
relocation: "relocation",
|
|
158
|
+
"salary expectations": "salaryExpectations",
|
|
159
|
+
"non-compete": "nonCompete",
|
|
160
|
+
"candidate location": "location",
|
|
161
|
+
phone: "phone",
|
|
162
|
+
email: "email",
|
|
163
|
+
"total years experience": "totalYearsExperience",
|
|
164
|
+
"all job titles": "allJobTitles",
|
|
165
|
+
companies: "companies",
|
|
166
|
+
degrees: "degrees",
|
|
167
|
+
"fields of study": "fieldsOfStudy",
|
|
168
|
+
language: "language",
|
|
169
|
+
"resume text": "resumeText",
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Skip columns we don't need (e.g. "Jobs Applied to", "Create Candidate Home Account URL")
|
|
173
|
+
for (let i = 0; i < headerRow.length; i++) {
|
|
174
|
+
const hdr = String(headerRow[i]).trim().toLowerCase();
|
|
175
|
+
const field = HEADER_ALIASES[hdr];
|
|
176
|
+
if (field) {
|
|
177
|
+
// "Job Application" appears twice (cols A and B) — always take the latest
|
|
178
|
+
// occurrence so we end up with the second one (index 1) which has the name
|
|
179
|
+
colMap[field] = i;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Fallback: if "name" wasn't mapped, use index 0 (new format) or 1 (old format)
|
|
184
|
+
if (colMap.name === undefined) colMap.name = 1;
|
|
185
|
+
// In new format there's only one "Job Application" column (index 0) — the
|
|
186
|
+
// "always take latest" logic already handles this correctly.
|
|
187
|
+
|
|
188
|
+
/** Get a cell value by field name, with fallback to empty string. */
|
|
189
|
+
function col(row, field) {
|
|
190
|
+
const idx = colMap[field];
|
|
191
|
+
if (idx === undefined) return "";
|
|
192
|
+
return row[idx] ?? "";
|
|
193
|
+
}
|
|
124
194
|
|
|
125
195
|
/**
|
|
126
196
|
* Clean a candidate name by stripping annotations like (Prior Worker),
|
|
@@ -180,45 +250,45 @@ const candidates = [];
|
|
|
180
250
|
|
|
181
251
|
for (let i = DATA_START; i < candRows.length; i++) {
|
|
182
252
|
const row = candRows[i];
|
|
183
|
-
const rawName = String(row
|
|
184
|
-
const stage = String(row
|
|
253
|
+
const rawName = String(col(row, "name") || "").trim();
|
|
254
|
+
const stage = String(col(row, "stage") || "").trim();
|
|
185
255
|
|
|
186
|
-
//
|
|
187
|
-
if (!rawName || (!stage && String(row[0] || "").trim())) break;
|
|
256
|
+
// Skip empty rows; stop at stage-summary rows (name present but no stage)
|
|
188
257
|
if (!rawName) continue;
|
|
258
|
+
if (!stage) break;
|
|
189
259
|
|
|
190
260
|
const { cleanName, internalExternal: nameIE } = parseName(rawName);
|
|
191
|
-
const source = String(row
|
|
261
|
+
const source = String(col(row, "source") || "").trim();
|
|
192
262
|
|
|
193
263
|
candidates.push({
|
|
194
264
|
name: rawName,
|
|
195
265
|
cleanName,
|
|
196
266
|
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
|
|
267
|
+
step: String(col(row, "step") || "").trim(),
|
|
268
|
+
awaitingMe: String(col(row, "awaitingMe") || "").trim(),
|
|
269
|
+
awaitingAction: String(col(row, "awaitingAction") || "").trim(),
|
|
270
|
+
resumeFile: String(col(row, "resumeFile") || "").trim(),
|
|
271
|
+
dateApplied: fmtDate(col(row, "dateApplied")),
|
|
272
|
+
currentTitle: String(col(row, "currentTitle") || "").trim(),
|
|
273
|
+
currentCompany: String(col(row, "currentCompany") || "").trim(),
|
|
204
274
|
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
|
|
275
|
+
referredBy: String(col(row, "referredBy") || "").trim(),
|
|
276
|
+
availabilityDate: fmtDate(col(row, "availabilityDate")),
|
|
277
|
+
visaRequirement: String(col(row, "visaRequirement") || "").trim(),
|
|
278
|
+
eligibleToWork: String(col(row, "eligibleToWork") || "").trim(),
|
|
279
|
+
relocation: String(col(row, "relocation") || "").trim(),
|
|
280
|
+
salaryExpectations: String(col(row, "salaryExpectations") || "").trim(),
|
|
281
|
+
nonCompete: String(col(row, "nonCompete") || "").trim(),
|
|
282
|
+
location: String(col(row, "location") || "").trim(),
|
|
283
|
+
phone: String(col(row, "phone") || "").trim(),
|
|
284
|
+
email: String(col(row, "email") || "").trim(),
|
|
285
|
+
totalYearsExperience: String(col(row, "totalYearsExperience") || "").trim(),
|
|
286
|
+
allJobTitles: multiline(col(row, "allJobTitles")),
|
|
287
|
+
companies: multiline(col(row, "companies")),
|
|
288
|
+
degrees: multiline(col(row, "degrees")),
|
|
289
|
+
fieldsOfStudy: multiline(col(row, "fieldsOfStudy")),
|
|
290
|
+
language: multiline(col(row, "language")),
|
|
291
|
+
resumeText: String(col(row, "resumeText") || "").trim(),
|
|
222
292
|
internalExternal: inferInternalExternal(source, nameIE),
|
|
223
293
|
});
|
|
224
294
|
}
|
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,8 +97,9 @@ 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
|
|
102
|
+
The **chief-of-staff** reads all five triage files to synthesize daily
|
|
101
103
|
briefings in `knowledge/Briefings/`.
|
|
102
104
|
|
|
103
105
|
## Cache Directory (`~/.cache/fit/basecamp/`)
|
|
@@ -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 |
|