@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.
@@ -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
- `html_url`, `score`.
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
- UK → Europe → Remote → repeat.
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
- `url`, `tag_list`, `published_at`.
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
- wake is fine.
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: `https://hn.algolia.com/api/v1/search?query=%22freelancer+available%22&tags=comment`
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
- data skills)
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
- a block page), handle it gracefully:
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`)