@forwardimpact/basecamp 2.5.0 → 2.6.1
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 +6 -6
- package/package.json +1 -1
- package/src/basecamp.js +101 -729
- package/template/.claude/agents/head-hunter.md +35 -34
- package/template/.claude/agents/librarian.md +1 -1
- package/template/.claude/skills/analyze-cv/SKILL.md +39 -7
- package/template/.claude/skills/draft-emails/scripts/send-email.mjs +1 -1
- 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 +103 -13
- 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 +60 -60
- package/template/.claude/skills/workday-requisition/scripts/parse-workday.mjs +1 -5
- package/template/CLAUDE.md +2 -2
|
@@ -96,6 +96,7 @@ WebFetch URL: https://api.github.com/search/users?q=%22looking+for+work%22+locat
|
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
Alternative bio phrases to search (rotate across wakes):
|
|
99
|
+
|
|
99
100
|
- `"available for hire"`
|
|
100
101
|
- `"seeking opportunities"`
|
|
101
102
|
- `"seeking new role"`
|
|
@@ -106,8 +107,8 @@ Alternative bio phrases to search (rotate across wakes):
|
|
|
106
107
|
- `"on the market"`
|
|
107
108
|
- `"open to opportunities"`
|
|
108
109
|
|
|
109
|
-
Response has `total_count` and `items` array. Each item has `login`,
|
|
110
|
-
`
|
|
110
|
+
Response has `total_count` and `items` array. Each item has `login`, `html_url`,
|
|
111
|
+
`score`.
|
|
111
112
|
|
|
112
113
|
**Fetch full profile for promising candidates:**
|
|
113
114
|
|
|
@@ -116,6 +117,7 @@ WebFetch URL: https://api.github.com/users/{login}
|
|
|
116
117
|
```
|
|
117
118
|
|
|
118
119
|
Profile fields:
|
|
120
|
+
|
|
119
121
|
- `name` — display name
|
|
120
122
|
- `bio` — bio text (contains open-to-work signals)
|
|
121
123
|
- `location` — geographic location
|
|
@@ -125,8 +127,8 @@ Profile fields:
|
|
|
125
127
|
- `public_repos` — repository count (technical depth indicator)
|
|
126
128
|
- `created_at` — account age (experience proxy)
|
|
127
129
|
|
|
128
|
-
**Cursor:** Store the location query last used and page number. Rotate:
|
|
129
|
-
|
|
130
|
+
**Cursor:** Store the location query last used and page number. Rotate: UK →
|
|
131
|
+
Europe → Remote → repeat.
|
|
130
132
|
|
|
131
133
|
**Rate limit:** 10 requests/minute unauthenticated. Fetch at most 5 full
|
|
132
134
|
profiles per wake cycle (1 search + 5 profile fetches = 6 requests).
|
|
@@ -142,12 +144,13 @@ WebFetch URL: https://dev.to/api/articles?tag=opentowork&per_page=25
|
|
|
142
144
|
WebFetch URL: https://dev.to/api/articles?tag=lookingforwork&per_page=25
|
|
143
145
|
```
|
|
144
146
|
|
|
145
|
-
For articles, parse `title`, `description`, `user.name`, `user.username`,
|
|
146
|
-
`
|
|
147
|
+
For articles, parse `title`, `description`, `user.name`, `user.username`, `url`,
|
|
148
|
+
`tag_list`, `published_at`.
|
|
147
149
|
|
|
148
150
|
Skip articles older than 90 days — the candidate may no longer be looking.
|
|
149
151
|
|
|
150
152
|
Additional tags to try when primary tags yield no results:
|
|
153
|
+
|
|
151
154
|
- `jobsearch`
|
|
152
155
|
- `career`
|
|
153
156
|
- `hiring`
|
|
@@ -156,8 +159,8 @@ Additional tags to try when primary tags yield no results:
|
|
|
156
159
|
|
|
157
160
|
**Cursor:** Store the `id` of the most recent article processed.
|
|
158
161
|
|
|
159
|
-
**Rate limit:** dev.to API allows 30 requests per 30 seconds. One fetch per
|
|
160
|
-
|
|
162
|
+
**Rate limit:** dev.to API allows 30 requests per 30 seconds. One fetch per wake
|
|
163
|
+
is fine.
|
|
161
164
|
|
|
162
165
|
---
|
|
163
166
|
|
|
@@ -172,13 +175,16 @@ Each source has multiple query variations. If the first query returns nothing
|
|
|
172
175
|
new, try the next variation:
|
|
173
176
|
|
|
174
177
|
**HN:**
|
|
178
|
+
|
|
175
179
|
- Check the previous month's "Who Wants to Be Hired?" thread (candidates post
|
|
176
180
|
late or threads stay active)
|
|
177
181
|
- Search for `"Who is hiring"` threads — candidates sometimes post in the wrong
|
|
178
182
|
thread, and comments may link to candidate profiles
|
|
179
|
-
- Try:
|
|
183
|
+
- Try:
|
|
184
|
+
`https://hn.algolia.com/api/v1/search?query=%22freelancer+available%22&tags=comment`
|
|
180
185
|
|
|
181
186
|
**GitHub:**
|
|
187
|
+
|
|
182
188
|
- Search by skill + availability instead of just bio phrases:
|
|
183
189
|
```
|
|
184
190
|
WebFetch URL: https://api.github.com/search/users?q=%22data+engineering%22+%22open+to+work%22&per_page=30&sort=joined&order=desc
|
|
@@ -193,6 +199,7 @@ new, try the next variation:
|
|
|
193
199
|
`Sofia`, `Manchester`, `Edinburgh`
|
|
194
200
|
|
|
195
201
|
**dev.to:**
|
|
202
|
+
|
|
196
203
|
- Try broader tags: `jobsearch`, `career`, `remotework`
|
|
197
204
|
- Search articles directly:
|
|
198
205
|
```
|
|
@@ -203,20 +210,23 @@ new, try the next variation:
|
|
|
203
210
|
### Strategy 2: Relax Filters
|
|
204
211
|
|
|
205
212
|
If geographic filtering eliminated all candidates:
|
|
213
|
+
|
|
206
214
|
- Re-scan the same results without the location filter
|
|
207
215
|
- Candidates without stated locations may still be open to target regions
|
|
208
216
|
- Mark these as "location unconfirmed" in the prospect note
|
|
209
217
|
|
|
210
218
|
If skill alignment filtered everyone out:
|
|
219
|
+
|
|
211
220
|
- Lower the minimum bar from 2 framework skills to 1
|
|
212
221
|
- Look for transferable skills (e.g., strong Python → likely data integration
|
|
213
222
|
capability)
|
|
214
|
-
- Consider adjacent skill indicators (e.g., "machine learning" implies
|
|
215
|
-
|
|
223
|
+
- Consider adjacent skill indicators (e.g., "machine learning" implies data
|
|
224
|
+
skills)
|
|
216
225
|
|
|
217
226
|
### Strategy 3: Cross-Reference
|
|
218
227
|
|
|
219
228
|
When a source yields very few results, cross-reference what you do find:
|
|
229
|
+
|
|
220
230
|
- If a GitHub profile links to a blog or portfolio, check it for more detail
|
|
221
231
|
(via WebFetch) before deciding on skill fit
|
|
222
232
|
- If an HN post mentions a GitHub username, fetch their GitHub profile for
|
|
@@ -240,10 +250,11 @@ Stopped after 2 alternatives (1 prospect found)
|
|
|
240
250
|
|
|
241
251
|
## Failure Handling
|
|
242
252
|
|
|
243
|
-
When a WebFetch fails (HTTP 4xx, 5xx, timeout, empty response, or redirect to
|
|
244
|
-
|
|
253
|
+
When a WebFetch fails (HTTP 4xx, 5xx, timeout, empty response, or redirect to a
|
|
254
|
+
block page), handle it gracefully:
|
|
245
255
|
|
|
246
256
|
1. **Record the failure** in `failures.tsv`:
|
|
257
|
+
|
|
247
258
|
```bash
|
|
248
259
|
sed -i '' "s/^{source}\t.*/&/" ~/.cache/fit/basecamp/head-hunter/failures.tsv
|
|
249
260
|
# Or increment the count and update last_error
|
|
@@ -307,6 +318,7 @@ Run `npx fit-pathway skill --list` to get the framework skill inventory. Check
|
|
|
307
318
|
whether the candidate mentions skills that map to framework capabilities:
|
|
308
319
|
|
|
309
320
|
**Strong signals (forward-deployed track):**
|
|
321
|
+
|
|
310
322
|
- Multiple industries or domains in background
|
|
311
323
|
- Customer-facing project experience
|
|
312
324
|
- Data integration, analytics, visualization
|
|
@@ -315,6 +327,7 @@ whether the candidate mentions skills that map to framework capabilities:
|
|
|
315
327
|
- AI/ML tool proficiency (Claude, GPT, Cursor, "vibe coding")
|
|
316
328
|
|
|
317
329
|
**Strong signals (platform track):**
|
|
330
|
+
|
|
318
331
|
- Infrastructure, cloud platforms, DevOps
|
|
319
332
|
- Architecture and system design
|
|
320
333
|
- API design, shared services
|
|
@@ -371,6 +384,83 @@ combinations, anything noteworthy for the user}
|
|
|
371
384
|
**Naming:** Use the candidate's display name as given. If only a username is
|
|
372
385
|
available, use the username. Never fabricate real names.
|
|
373
386
|
|
|
387
|
+
## State Management Script
|
|
388
|
+
|
|
389
|
+
**Use the state script for ALL state file operations.** Do NOT write bespoke
|
|
390
|
+
scripts to update cursor, seen, prospects, failures, or log files.
|
|
391
|
+
|
|
392
|
+
node .claude/skills/scan-open-candidates/scripts/state.mjs <command> [args]
|
|
393
|
+
|
|
394
|
+
### Commands
|
|
395
|
+
|
|
396
|
+
**Cursor** (source rotation):
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
# Check which source to scan next
|
|
400
|
+
node scripts/state.mjs cursor list
|
|
401
|
+
|
|
402
|
+
# Get cursor for a specific source
|
|
403
|
+
node scripts/state.mjs cursor get github_open_to_work
|
|
404
|
+
|
|
405
|
+
# Update cursor after scanning
|
|
406
|
+
node scripts/state.mjs cursor set github_open_to_work "2026-03-09T22:00:00Z" "UK-done_next:Europe"
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
**Seen** (deduplication):
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
# Check if a candidate was already seen (exit 0=seen, 1=new)
|
|
413
|
+
node scripts/state.mjs seen check github_open_to_work mxmxmx333
|
|
414
|
+
|
|
415
|
+
# Mark one ID as seen
|
|
416
|
+
node scripts/state.mjs seen add github_open_to_work mxmxmx333
|
|
417
|
+
|
|
418
|
+
# Mark multiple IDs as seen in one call
|
|
419
|
+
node scripts/state.mjs seen batch github_open_to_work id1 id2 id3 id4
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**Prospects**:
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
# Add a new prospect
|
|
426
|
+
node scripts/state.mjs prospect add "Hasan Cam" github_open_to_work strong "J060-J070 platform"
|
|
427
|
+
|
|
428
|
+
# List recent prospects
|
|
429
|
+
node scripts/state.mjs prospect list --limit 10
|
|
430
|
+
|
|
431
|
+
# Count total prospects
|
|
432
|
+
node scripts/state.mjs prospect count
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
**Failures**:
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
# Check failure count (for source selection — skip if ≥3)
|
|
439
|
+
node scripts/state.mjs failure get mastodon_hachyderm
|
|
440
|
+
|
|
441
|
+
# Record a fetch failure
|
|
442
|
+
node scripts/state.mjs failure increment mastodon_hachyderm
|
|
443
|
+
|
|
444
|
+
# Reset after successful fetch
|
|
445
|
+
node scripts/state.mjs failure reset github_open_to_work
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
**Logging**:
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
# Append a formatted wake cycle entry
|
|
452
|
+
node scripts/state.mjs log-wake github_open_to_work "Primary query: 'open to work' location:UK — 30 results, 2 new prospects"
|
|
453
|
+
|
|
454
|
+
# Append raw text
|
|
455
|
+
node scripts/state.mjs log "Manual note about source rotation"
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**Summary** (state overview):
|
|
459
|
+
|
|
460
|
+
```bash
|
|
461
|
+
node scripts/state.mjs summary
|
|
462
|
+
```
|
|
463
|
+
|
|
374
464
|
## Quality Checklist
|
|
375
465
|
|
|
376
466
|
- [ ] Selected the least-recently-checked source from cursor.tsv
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Manage head-hunter agent state files.
|
|
4
|
+
*
|
|
5
|
+
* Provides atomic operations on the 5 state files used by the head-hunter agent:
|
|
6
|
+
* cursor.tsv — source rotation state (source, last_checked, position)
|
|
7
|
+
* seen.tsv — deduplication index (source, id, date)
|
|
8
|
+
* prospects.tsv — prospect index (name, source, date, strength, level)
|
|
9
|
+
* failures.tsv — consecutive failure counts (source, count)
|
|
10
|
+
* log.md — append-only activity log
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node scripts/state.mjs cursor get <source>
|
|
14
|
+
* node scripts/state.mjs cursor set <source> <timestamp> <position>
|
|
15
|
+
* node scripts/state.mjs seen check <source> <id>
|
|
16
|
+
* node scripts/state.mjs seen add <source> <id> [<date>]
|
|
17
|
+
* node scripts/state.mjs prospect add <name> <source> <strength> <level>
|
|
18
|
+
* node scripts/state.mjs prospect list [--limit N]
|
|
19
|
+
* node scripts/state.mjs failure get <source>
|
|
20
|
+
* node scripts/state.mjs failure increment <source>
|
|
21
|
+
* node scripts/state.mjs failure reset <source>
|
|
22
|
+
* node scripts/state.mjs log <message>
|
|
23
|
+
* node scripts/state.mjs log-wake <source> <summary>
|
|
24
|
+
* node scripts/state.mjs summary
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
appendFileSync,
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
|
|
37
|
+
const HOME = homedir();
|
|
38
|
+
const STATE_DIR = join(HOME, ".cache/fit/basecamp/head-hunter");
|
|
39
|
+
|
|
40
|
+
const PATHS = {
|
|
41
|
+
cursor: join(STATE_DIR, "cursor.tsv"),
|
|
42
|
+
seen: join(STATE_DIR, "seen.tsv"),
|
|
43
|
+
prospects: join(STATE_DIR, "prospects.tsv"),
|
|
44
|
+
failures: join(STATE_DIR, "failures.tsv"),
|
|
45
|
+
log: join(STATE_DIR, "log.md"),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
49
|
+
console.log(`state — manage head-hunter agent state
|
|
50
|
+
|
|
51
|
+
Usage:
|
|
52
|
+
node scripts/state.mjs <command> [args]
|
|
53
|
+
|
|
54
|
+
Cursor commands (source rotation):
|
|
55
|
+
cursor get <source> Get current cursor for source
|
|
56
|
+
cursor set <source> <timestamp> <position> Update cursor position
|
|
57
|
+
cursor list List all cursors
|
|
58
|
+
|
|
59
|
+
Seen commands (deduplication):
|
|
60
|
+
seen check <source> <id> Check if ID was already seen (exit 0=seen, 1=new)
|
|
61
|
+
seen add <source> <id> [<date>] Mark ID as seen (date defaults to today)
|
|
62
|
+
seen batch <source> <id1> <id2> ... Mark multiple IDs as seen
|
|
63
|
+
|
|
64
|
+
Prospect commands:
|
|
65
|
+
prospect add <name> <source> <strength> <level> Add a new prospect
|
|
66
|
+
prospect list [--limit N] List recent prospects
|
|
67
|
+
prospect count Count total prospects
|
|
68
|
+
|
|
69
|
+
Failure commands:
|
|
70
|
+
failure get <source> Get failure count for source
|
|
71
|
+
failure increment <source> Increment failure count
|
|
72
|
+
failure reset <source> Reset failure count to 0
|
|
73
|
+
|
|
74
|
+
Log commands:
|
|
75
|
+
log <message> Append raw text to log.md
|
|
76
|
+
log-wake <source> <summary> Append a formatted wake cycle entry
|
|
77
|
+
|
|
78
|
+
Summary:
|
|
79
|
+
summary Print state overview
|
|
80
|
+
|
|
81
|
+
State dir: ~/.cache/fit/basecamp/head-hunter/`);
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- Ensure state directory exists ---
|
|
86
|
+
|
|
87
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
88
|
+
|
|
89
|
+
// --- File helpers ---
|
|
90
|
+
|
|
91
|
+
function readTsv(file) {
|
|
92
|
+
if (!existsSync(file)) return [];
|
|
93
|
+
return readFileSync(file, "utf8")
|
|
94
|
+
.split("\n")
|
|
95
|
+
.filter((l) => l.trim());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function writeTsv(file, lines) {
|
|
99
|
+
writeFileSync(file, lines.join("\n") + (lines.length ? "\n" : ""));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function appendTsv(file, line) {
|
|
103
|
+
appendFileSync(file, line + "\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function today() {
|
|
107
|
+
return new Date().toISOString().slice(0, 10);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Cursor operations ---
|
|
111
|
+
|
|
112
|
+
function cursorGet(source) {
|
|
113
|
+
const lines = readTsv(PATHS.cursor);
|
|
114
|
+
const line = lines.find((l) => l.startsWith(source + "\t"));
|
|
115
|
+
if (!line) {
|
|
116
|
+
console.log(`No cursor for source: ${source}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const [, timestamp, position] = line.split("\t");
|
|
120
|
+
console.log(`${source}\t${timestamp}\t${position}`);
|
|
121
|
+
return { source, timestamp, position };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cursorSet(source, timestamp, position) {
|
|
125
|
+
const lines = readTsv(PATHS.cursor);
|
|
126
|
+
const newLine = `${source}\t${timestamp}\t${position}`;
|
|
127
|
+
const idx = lines.findIndex((l) => l.startsWith(source + "\t"));
|
|
128
|
+
if (idx !== -1) {
|
|
129
|
+
lines[idx] = newLine;
|
|
130
|
+
} else {
|
|
131
|
+
lines.push(newLine);
|
|
132
|
+
}
|
|
133
|
+
writeTsv(PATHS.cursor, lines);
|
|
134
|
+
console.log(`Cursor updated: ${newLine}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function cursorList() {
|
|
138
|
+
const lines = readTsv(PATHS.cursor);
|
|
139
|
+
if (lines.length === 0) {
|
|
140
|
+
console.log("No cursors.");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
console.log(line);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Seen operations ---
|
|
149
|
+
|
|
150
|
+
function seenCheck(source, id) {
|
|
151
|
+
const lines = readTsv(PATHS.seen);
|
|
152
|
+
const found = lines.some((l) => {
|
|
153
|
+
const parts = l.split("\t");
|
|
154
|
+
return parts[0] === source && parts[1] === id;
|
|
155
|
+
});
|
|
156
|
+
if (found) {
|
|
157
|
+
console.log(`SEEN: ${source}\t${id}`);
|
|
158
|
+
process.exit(0);
|
|
159
|
+
} else {
|
|
160
|
+
console.log(`NEW: ${source}\t${id}`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function seenAdd(source, id, date) {
|
|
166
|
+
appendTsv(PATHS.seen, `${source}\t${id}\t${date || today()}`);
|
|
167
|
+
console.log(`Marked seen: ${source}\t${id}\t${date || today()}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function seenBatch(source, ids) {
|
|
171
|
+
const d = today();
|
|
172
|
+
const lines = ids.map((id) => `${source}\t${id}\t${d}`);
|
|
173
|
+
appendFileSync(PATHS.seen, lines.join("\n") + "\n");
|
|
174
|
+
console.log(`Marked ${ids.length} IDs as seen for ${source}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- Prospect operations ---
|
|
178
|
+
|
|
179
|
+
function prospectAdd(name, source, strength, level) {
|
|
180
|
+
const d = today();
|
|
181
|
+
appendTsv(PATHS.prospects, `${name}\t${source}\t${d}\t${strength}\t${level}`);
|
|
182
|
+
console.log(`Prospect added: ${name} (${strength}, ${level})`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function prospectList(limit) {
|
|
186
|
+
const lines = readTsv(PATHS.prospects);
|
|
187
|
+
if (lines.length === 0) {
|
|
188
|
+
console.log("No prospects.");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Show most recent first
|
|
192
|
+
const display = lines.slice(-limit).reverse();
|
|
193
|
+
for (const line of display) {
|
|
194
|
+
console.log(line);
|
|
195
|
+
}
|
|
196
|
+
if (lines.length > limit) {
|
|
197
|
+
console.log(`\n... ${lines.length - limit} more (${lines.length} total)`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function prospectCount() {
|
|
202
|
+
const lines = readTsv(PATHS.prospects);
|
|
203
|
+
console.log(lines.length);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Failure operations ---
|
|
207
|
+
|
|
208
|
+
function failureGet(source) {
|
|
209
|
+
const lines = readTsv(PATHS.failures);
|
|
210
|
+
const line = lines.find((l) => l.startsWith(source + "\t"));
|
|
211
|
+
if (!line) {
|
|
212
|
+
console.log(`0`);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
const count = parseInt(line.split("\t")[1], 10) || 0;
|
|
216
|
+
console.log(`${count}`);
|
|
217
|
+
return count;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function failureIncrement(source) {
|
|
221
|
+
const lines = readTsv(PATHS.failures);
|
|
222
|
+
const idx = lines.findIndex((l) => l.startsWith(source + "\t"));
|
|
223
|
+
if (idx !== -1) {
|
|
224
|
+
const count = parseInt(lines[idx].split("\t")[1], 10) || 0;
|
|
225
|
+
lines[idx] = `${source}\t${count + 1}`;
|
|
226
|
+
} else {
|
|
227
|
+
lines.push(`${source}\t1`);
|
|
228
|
+
}
|
|
229
|
+
writeTsv(PATHS.failures, lines);
|
|
230
|
+
const newCount = parseInt(
|
|
231
|
+
lines.find((l) => l.startsWith(source + "\t")).split("\t")[1],
|
|
232
|
+
10,
|
|
233
|
+
);
|
|
234
|
+
console.log(`Failures for ${source}: ${newCount}`);
|
|
235
|
+
if (newCount >= 3) {
|
|
236
|
+
console.log(
|
|
237
|
+
`WARNING: ${source} has ${newCount} consecutive failures (suspended at ≥3)`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function failureReset(source) {
|
|
243
|
+
const lines = readTsv(PATHS.failures);
|
|
244
|
+
const idx = lines.findIndex((l) => l.startsWith(source + "\t"));
|
|
245
|
+
if (idx !== -1) {
|
|
246
|
+
lines[idx] = `${source}\t0`;
|
|
247
|
+
writeTsv(PATHS.failures, lines);
|
|
248
|
+
}
|
|
249
|
+
console.log(`Failures reset for ${source}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- Log operations ---
|
|
253
|
+
|
|
254
|
+
function logAppend(message) {
|
|
255
|
+
appendFileSync(PATHS.log, message + "\n");
|
|
256
|
+
console.log("Log entry appended.");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function logWake(source, summary) {
|
|
260
|
+
const timestamp = new Date().toISOString().slice(0, 16).replace("T", " ");
|
|
261
|
+
const entry = `\n## ${today()} ${timestamp.slice(11)} — Wake (${source})\n\n${summary}\n\n---\n`;
|
|
262
|
+
appendFileSync(PATHS.log, entry);
|
|
263
|
+
console.log(`Wake cycle logged for ${source}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- Summary ---
|
|
267
|
+
|
|
268
|
+
function summary() {
|
|
269
|
+
const cursors = readTsv(PATHS.cursor);
|
|
270
|
+
const seen = readTsv(PATHS.seen);
|
|
271
|
+
const prospects = readTsv(PATHS.prospects);
|
|
272
|
+
const failures = readTsv(PATHS.failures);
|
|
273
|
+
|
|
274
|
+
console.log("=== Head-Hunter State Summary ===\n");
|
|
275
|
+
|
|
276
|
+
console.log(`Sources: ${cursors.length}`);
|
|
277
|
+
for (const line of cursors) {
|
|
278
|
+
const [source, timestamp, position] = line.split("\t");
|
|
279
|
+
const failLine = failures.find((l) => l.startsWith(source + "\t"));
|
|
280
|
+
const failCount = failLine ? parseInt(failLine.split("\t")[1], 10) : 0;
|
|
281
|
+
const status = failCount >= 3 ? " [SUSPENDED]" : "";
|
|
282
|
+
console.log(` ${source}: last=${timestamp}, pos=${position}${status}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
console.log(`\nSeen: ${seen.length} entries`);
|
|
286
|
+
console.log(`Prospects: ${prospects.length} total`);
|
|
287
|
+
|
|
288
|
+
// Strength breakdown
|
|
289
|
+
const byStrength = {};
|
|
290
|
+
for (const line of prospects) {
|
|
291
|
+
const strength = line.split("\t")[3] || "unknown";
|
|
292
|
+
byStrength[strength] = (byStrength[strength] || 0) + 1;
|
|
293
|
+
}
|
|
294
|
+
for (const [k, v] of Object.entries(byStrength)) {
|
|
295
|
+
console.log(` ${k}: ${v}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Recent prospects (last 5)
|
|
299
|
+
if (prospects.length > 0) {
|
|
300
|
+
console.log("\nRecent prospects:");
|
|
301
|
+
for (const line of prospects.slice(-5)) {
|
|
302
|
+
const [name, source, date, strength, level] = line.split("\t");
|
|
303
|
+
console.log(
|
|
304
|
+
` ${date} | ${name} | ${strength} | ${level} | via ${source}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// --- CLI Router ---
|
|
311
|
+
|
|
312
|
+
const cliArgs = process.argv.slice(2);
|
|
313
|
+
const cmd = cliArgs[0];
|
|
314
|
+
const sub = cliArgs[1];
|
|
315
|
+
|
|
316
|
+
switch (cmd) {
|
|
317
|
+
case "cursor":
|
|
318
|
+
if (sub === "get" && cliArgs[2]) cursorGet(cliArgs[2]);
|
|
319
|
+
else if (sub === "set" && cliArgs[2] && cliArgs[3] && cliArgs[4])
|
|
320
|
+
cursorSet(cliArgs[2], cliArgs[3], cliArgs[4]);
|
|
321
|
+
else if (sub === "list") cursorList();
|
|
322
|
+
else {
|
|
323
|
+
console.error(
|
|
324
|
+
"Usage: cursor get|set|list <source> [<timestamp> <position>]",
|
|
325
|
+
);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
|
|
330
|
+
case "seen":
|
|
331
|
+
if (sub === "check" && cliArgs[2] && cliArgs[3])
|
|
332
|
+
seenCheck(cliArgs[2], cliArgs[3]);
|
|
333
|
+
else if (sub === "add" && cliArgs[2] && cliArgs[3])
|
|
334
|
+
seenAdd(cliArgs[2], cliArgs[3], cliArgs[4]);
|
|
335
|
+
else if (sub === "batch" && cliArgs[2] && cliArgs.length > 3)
|
|
336
|
+
seenBatch(cliArgs[2], cliArgs.slice(3));
|
|
337
|
+
else {
|
|
338
|
+
console.error("Usage: seen check|add|batch <source> <id> [...]");
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
342
|
+
|
|
343
|
+
case "prospect":
|
|
344
|
+
if (sub === "add" && cliArgs[2] && cliArgs[3] && cliArgs[4] && cliArgs[5])
|
|
345
|
+
prospectAdd(cliArgs[2], cliArgs[3], cliArgs[4], cliArgs[5]);
|
|
346
|
+
else if (sub === "list") {
|
|
347
|
+
const lIdx = cliArgs.indexOf("--limit");
|
|
348
|
+
const lim = lIdx !== -1 ? parseInt(cliArgs[lIdx + 1], 10) || 10 : 10;
|
|
349
|
+
prospectList(lim);
|
|
350
|
+
} else if (sub === "count") prospectCount();
|
|
351
|
+
else {
|
|
352
|
+
console.error(
|
|
353
|
+
"Usage: prospect add|list|count <name> <source> <strength> <level>",
|
|
354
|
+
);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
break;
|
|
358
|
+
|
|
359
|
+
case "failure":
|
|
360
|
+
if (sub === "get" && cliArgs[2]) failureGet(cliArgs[2]);
|
|
361
|
+
else if (sub === "increment" && cliArgs[2]) failureIncrement(cliArgs[2]);
|
|
362
|
+
else if (sub === "reset" && cliArgs[2]) failureReset(cliArgs[2]);
|
|
363
|
+
else {
|
|
364
|
+
console.error("Usage: failure get|increment|reset <source>");
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
|
|
369
|
+
case "log":
|
|
370
|
+
if (cliArgs.length >= 2) logAppend(cliArgs.slice(1).join(" "));
|
|
371
|
+
else {
|
|
372
|
+
console.error("Usage: log <message>");
|
|
373
|
+
process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
break;
|
|
376
|
+
|
|
377
|
+
case "log-wake":
|
|
378
|
+
if (cliArgs[1] && cliArgs.length >= 3)
|
|
379
|
+
logWake(cliArgs[1], cliArgs.slice(2).join(" "));
|
|
380
|
+
else {
|
|
381
|
+
console.error("Usage: log-wake <source> <summary>");
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
|
|
386
|
+
case "summary":
|
|
387
|
+
summary();
|
|
388
|
+
break;
|
|
389
|
+
|
|
390
|
+
default:
|
|
391
|
+
console.error(
|
|
392
|
+
"Unknown command. Run with --help for usage.\n" +
|
|
393
|
+
"Commands: cursor, seen, prospect, failure, log, log-wake, summary",
|
|
394
|
+
);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
@@ -96,6 +96,47 @@ Each `{event_id}.json` file:
|
|
|
96
96
|
- Database locked → wait 2 seconds, retry once
|
|
97
97
|
- Skip events with no summary (likely cancelled or placeholder)
|
|
98
98
|
|
|
99
|
+
## Querying Events
|
|
100
|
+
|
|
101
|
+
After syncing, use the query script to filter events by date or time window.
|
|
102
|
+
**Agents should use this script instead of writing bespoke calendar parsers.**
|
|
103
|
+
|
|
104
|
+
node scripts/query.mjs [options]
|
|
105
|
+
|
|
106
|
+
### Time filters (combinable)
|
|
107
|
+
|
|
108
|
+
| Flag | Description |
|
|
109
|
+
| ------------------------------- | --------------------------------------------------------- |
|
|
110
|
+
| `--today` | Events starting today (default if no filter given) |
|
|
111
|
+
| `--tomorrow` | Events starting tomorrow |
|
|
112
|
+
| `--upcoming 2h` | Events starting within interval (e.g., `2h`, `30m`, `1d`) |
|
|
113
|
+
| `--date 2026-03-09` | Events on a specific date |
|
|
114
|
+
| `--range 2026-03-09 2026-03-11` | Events between two dates (inclusive) |
|
|
115
|
+
|
|
116
|
+
### Output options
|
|
117
|
+
|
|
118
|
+
| Flag | Description |
|
|
119
|
+
| ------------------- | ----------------------------------------------- |
|
|
120
|
+
| `--json` | Output as JSON array (default: formatted table) |
|
|
121
|
+
| `--include-all-day` | Include all-day events (excluded by default) |
|
|
122
|
+
| `--no-attendees` | Omit attendee names from output |
|
|
123
|
+
|
|
124
|
+
### Examples
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Concierge: get today + tomorrow for triage
|
|
128
|
+
node scripts/query.mjs --today --tomorrow
|
|
129
|
+
|
|
130
|
+
# Meeting-prep: get upcoming meetings in next 2 hours as JSON
|
|
131
|
+
node scripts/query.mjs --upcoming 2h --json
|
|
132
|
+
|
|
133
|
+
# Chief-of-staff: get this week's events
|
|
134
|
+
node scripts/query.mjs --range 2026-03-09 2026-03-13
|
|
135
|
+
|
|
136
|
+
# Quick count check
|
|
137
|
+
node scripts/query.mjs --today --json | node -e "process.stdin.on('data',d=>console.log(JSON.parse(d).length+' events today'))"
|
|
138
|
+
```
|
|
139
|
+
|
|
99
140
|
## Constraints
|
|
100
141
|
|
|
101
142
|
- Open database read-only (`readOnly: true`)
|