@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.
@@ -107,20 +107,90 @@ 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 (
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[1] || "").trim(); // Column B (index 1)
184
- const stage = String(row[2] || "").trim(); // Column C (index 2)
253
+ const rawName = String(col(row, "name") || "").trim();
254
+ const stage = String(col(row, "stage") || "").trim();
185
255
 
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;
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[10] || "").trim();
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[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(),
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[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(),
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
  }
@@ -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 three triage files to synthesize daily
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
- └── 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 |