@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.
Files changed (24) hide show
  1. package/config/scheduler.json +10 -5
  2. package/package.json +1 -1
  3. package/src/basecamp.js +101 -729
  4. package/template/.claude/agents/chief-of-staff.md +14 -3
  5. package/template/.claude/agents/head-hunter.md +436 -0
  6. package/template/.claude/agents/librarian.md +1 -1
  7. package/template/.claude/settings.json +4 -1
  8. package/template/.claude/skills/analyze-cv/SKILL.md +39 -7
  9. package/template/.claude/skills/draft-emails/SKILL.md +29 -9
  10. package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +4 -4
  11. package/template/.claude/skills/draft-emails/scripts/send-email.mjs +41 -6
  12. package/template/.claude/skills/meeting-prep/SKILL.md +7 -4
  13. package/template/.claude/skills/process-hyprnote/SKILL.md +17 -8
  14. package/template/.claude/skills/process-hyprnote/scripts/scan.mjs +246 -0
  15. package/template/.claude/skills/scan-open-candidates/SKILL.md +476 -0
  16. package/template/.claude/skills/scan-open-candidates/scripts/state.mjs +396 -0
  17. package/template/.claude/skills/sync-apple-calendar/SKILL.md +41 -0
  18. package/template/.claude/skills/sync-apple-calendar/scripts/query.mjs +301 -0
  19. package/template/.claude/skills/synthesize-deck/SKILL.md +296 -0
  20. package/template/.claude/skills/synthesize-deck/scripts/extract-pptx.mjs +210 -0
  21. package/template/.claude/skills/track-candidates/SKILL.md +45 -0
  22. package/template/.claude/skills/workday-requisition/SKILL.md +86 -53
  23. package/template/.claude/skills/workday-requisition/scripts/parse-workday.mjs +103 -37
  24. package/template/CLAUDE.md +13 -3
package/src/basecamp.js CHANGED
@@ -18,17 +18,23 @@ import {
18
18
  writeFileSync,
19
19
  existsSync,
20
20
  mkdirSync,
21
- unlinkSync,
22
- chmodSync,
23
21
  readdirSync,
24
- statSync,
25
22
  cpSync,
26
23
  copyFileSync,
24
+ appendFileSync,
27
25
  } from "node:fs";
28
26
  import { join, dirname, resolve } from "node:path";
29
27
  import { homedir } from "node:os";
30
28
  import { fileURLToPath } from "node:url";
31
- import { createServer } from "node:net";
29
+
30
+ import * as posixSpawn from "./posix-spawn.js";
31
+ import { StateManager } from "./state-manager.js";
32
+ import { AgentRunner } from "./agent-runner.js";
33
+ import { Scheduler } from "./scheduler.js";
34
+ import { KBManager } from "./kb-manager.js";
35
+ import { SocketServer, requestShutdown } from "./socket-server.js";
36
+
37
+ // --- Paths -------------------------------------------------------------------
32
38
 
33
39
  const HOME = homedir();
34
40
  const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
@@ -41,72 +47,62 @@ const __dirname =
41
47
  const SHARE_DIR = "/usr/local/share/fit-basecamp";
42
48
  const SOCKET_PATH = join(BASECAMP_HOME, "basecamp.sock");
43
49
 
44
- // --- posix_spawn (TCC-compliant process spawning) ---------------------------
45
-
46
- import * as posixSpawn from "./posix-spawn.js";
47
-
48
- let daemonStartedAt = null;
49
-
50
- // Maximum time an agent can be "active" before being considered stale (35 min).
51
- // Matches the 30-minute child_process timeout plus a buffer.
52
- const MAX_AGENT_RUNTIME_MS = 35 * 60_000;
53
-
54
- /** Active child PIDs spawned by posix_spawn (for graceful shutdown). */
55
- const activeChildren = new Set();
50
+ // --- Logging -----------------------------------------------------------------
51
+
52
+ function createLogger(logDir, fs) {
53
+ if (!logDir) throw new Error("logDir is required");
54
+ if (!fs) throw new Error("fs is required");
55
+ fs.mkdirSync(logDir, { recursive: true });
56
+ return function log(msg) {
57
+ const ts = new Date().toISOString();
58
+ const line = `[${ts}] ${msg}`;
59
+ console.log(line);
60
+ fs.appendFileSync(
61
+ join(logDir, `scheduler-${ts.slice(0, 10)}.log`),
62
+ line + "\n",
63
+ );
64
+ };
65
+ }
56
66
 
57
- // --- Helpers ----------------------------------------------------------------
67
+ const log = createLogger(LOG_DIR, { mkdirSync, appendFileSync });
58
68
 
59
- function ensureDir(dir) {
60
- mkdirSync(dir, { recursive: true });
61
- }
69
+ // --- Config ------------------------------------------------------------------
62
70
 
63
- function readJSON(path, fallback) {
71
+ function loadConfig() {
64
72
  try {
65
- return JSON.parse(readFileSync(path, "utf8"));
73
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
66
74
  } catch {
67
- return fallback;
75
+ return { agents: {} };
68
76
  }
69
77
  }
70
78
 
71
- function writeJSON(path, data) {
72
- ensureDir(dirname(path));
73
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
74
- }
75
-
76
79
  function expandPath(p) {
77
80
  return p.startsWith("~/") ? join(HOME, p.slice(2)) : resolve(p);
78
81
  }
79
82
 
80
- function log(msg) {
81
- const ts = new Date().toISOString();
82
- const line = `[${ts}] ${msg}`;
83
- console.log(line);
84
- try {
85
- ensureDir(LOG_DIR);
86
- writeFileSync(
87
- join(LOG_DIR, `scheduler-${ts.slice(0, 10)}.log`),
88
- line + "\n",
89
- { flag: "a" },
90
- );
91
- } catch {
92
- /* best effort */
93
- }
94
- }
83
+ // --- Wire dependencies -------------------------------------------------------
84
+
85
+ const fsOps = { readFileSync, writeFileSync, mkdirSync };
86
+ const stateManager = new StateManager(STATE_PATH, fsOps);
87
+ const agentRunner = new AgentRunner(posixSpawn, stateManager, log, CACHE_DIR);
88
+ const scheduler = new Scheduler(loadConfig, stateManager, agentRunner, log);
89
+ const kbManager = new KBManager(
90
+ {
91
+ existsSync,
92
+ mkdirSync,
93
+ cpSync,
94
+ copyFileSync,
95
+ readFileSync,
96
+ writeFileSync,
97
+ readdirSync,
98
+ },
99
+ log,
100
+ );
95
101
 
96
- function findClaude() {
97
- const paths = [
98
- "/usr/local/bin/claude",
99
- join(HOME, ".claude", "bin", "claude"),
100
- join(HOME, ".local", "bin", "claude"),
101
- "/opt/homebrew/bin/claude",
102
- ];
103
- for (const p of paths) if (existsSync(p)) return p;
104
- return "claude";
105
- }
102
+ // --- Template dir resolution -------------------------------------------------
106
103
 
107
104
  /**
108
105
  * Detect if running from inside a macOS .app bundle.
109
- * The binary is at Basecamp.app/Contents/MacOS/fit-basecamp.
110
106
  * @returns {{ bundle: string, resources: string } | null}
111
107
  */
112
108
  function getBundlePath() {
@@ -124,533 +120,6 @@ function getBundlePath() {
124
120
  return null;
125
121
  }
126
122
 
127
- function loadConfig() {
128
- return readJSON(CONFIG_PATH, { agents: {} });
129
- }
130
- function loadState() {
131
- const raw = readJSON(STATE_PATH, null);
132
- if (!raw || typeof raw !== "object" || !raw.agents) {
133
- const state = { agents: {} };
134
- saveState(state);
135
- return state;
136
- }
137
- return raw;
138
- }
139
- function saveState(state) {
140
- writeJSON(STATE_PATH, state);
141
- }
142
-
143
- // --- Cron matching ----------------------------------------------------------
144
-
145
- function matchField(field, value) {
146
- if (field === "*") return true;
147
- if (field.startsWith("*/")) return value % parseInt(field.slice(2)) === 0;
148
- return field.split(",").some((part) => {
149
- if (part.includes("-")) {
150
- const [lo, hi] = part.split("-").map(Number);
151
- return value >= lo && value <= hi;
152
- }
153
- return parseInt(part) === value;
154
- });
155
- }
156
-
157
- function cronMatches(expr, d) {
158
- const [min, hour, dom, month, dow] = expr.trim().split(/\s+/);
159
- return (
160
- matchField(min, d.getMinutes()) &&
161
- matchField(hour, d.getHours()) &&
162
- matchField(dom, d.getDate()) &&
163
- matchField(month, d.getMonth() + 1) &&
164
- matchField(dow, d.getDay())
165
- );
166
- }
167
-
168
- // --- Scheduling logic -------------------------------------------------------
169
-
170
- function floorToMinute(d) {
171
- const t = d.getTime();
172
- return t - (t % 60_000);
173
- }
174
-
175
- function shouldWake(agent, agentState, now) {
176
- if (agent.enabled === false) return false;
177
- if (agentState.status === "active") return false;
178
- const { schedule } = agent;
179
- if (!schedule) return false;
180
- const lastWoke = agentState.lastWokeAt
181
- ? new Date(agentState.lastWokeAt)
182
- : null;
183
-
184
- if (schedule.type === "cron") {
185
- if (lastWoke && floorToMinute(lastWoke) === floorToMinute(now))
186
- return false;
187
- return cronMatches(schedule.expression, now);
188
- }
189
- if (schedule.type === "interval") {
190
- const ms = (schedule.minutes || 5) * 60_000;
191
- return !lastWoke || now.getTime() - lastWoke.getTime() >= ms;
192
- }
193
- if (schedule.type === "once") {
194
- return !agentState.lastWokeAt && now >= new Date(schedule.runAt);
195
- }
196
- return false;
197
- }
198
-
199
- // --- Agent execution --------------------------------------------------------
200
-
201
- function failAgent(agentState, error) {
202
- Object.assign(agentState, {
203
- status: "failed",
204
- startedAt: null,
205
- lastWokeAt: new Date().toISOString(),
206
- lastError: String(error).slice(0, 500),
207
- });
208
- }
209
-
210
- async function wakeAgent(agentName, agent, state) {
211
- if (!agent.kb) {
212
- log(`Agent ${agentName}: no "kb" specified, skipping.`);
213
- return;
214
- }
215
- const kbPath = expandPath(agent.kb);
216
- if (!existsSync(kbPath)) {
217
- log(`Agent ${agentName}: path "${kbPath}" does not exist, skipping.`);
218
- return;
219
- }
220
-
221
- const claude = findClaude();
222
-
223
- log(`Waking agent: ${agentName} (kb: ${agent.kb})`);
224
-
225
- const as = (state.agents[agentName] ||= {});
226
- as.status = "active";
227
- as.startedAt = new Date().toISOString();
228
- saveState(state);
229
-
230
- const spawnArgs = ["--agent", agentName, "--print", "-p", "Observe and act."];
231
-
232
- try {
233
- const { pid, stdoutFd, stderrFd } = posixSpawn.spawn(
234
- claude,
235
- spawnArgs,
236
- undefined,
237
- kbPath,
238
- );
239
- activeChildren.add(pid);
240
-
241
- // Read stdout and stderr concurrently to avoid pipe deadlocks,
242
- // then wait for the child to exit.
243
- const [stdout, stderr] = await Promise.all([
244
- posixSpawn.readAll(stdoutFd),
245
- posixSpawn.readAll(stderrFd),
246
- ]);
247
- const exitCode = await posixSpawn.waitForExit(pid);
248
- activeChildren.delete(pid);
249
-
250
- if (exitCode === 0) {
251
- log(`Agent ${agentName} completed. Output: ${stdout.slice(0, 200)}...`);
252
- updateAgentState(as, stdout, agentName);
253
- } else {
254
- const errMsg = stderr || stdout || `Exit code ${exitCode}`;
255
- log(`Agent ${agentName} failed: ${errMsg.slice(0, 300)}`);
256
- failAgent(as, errMsg);
257
- }
258
- } catch (err) {
259
- log(`Agent ${agentName} failed: ${err.message}`);
260
- failAgent(as, err.message);
261
- }
262
- saveState(state);
263
- }
264
-
265
- /**
266
- * Parse Decision:/Action: lines from agent output and update state.
267
- * Also saves stdout to the state directory as a briefing fallback.
268
- * @param {object} agentState
269
- * @param {string} stdout
270
- * @param {string} agentName
271
- */
272
- function updateAgentState(agentState, stdout, agentName) {
273
- const lines = stdout.split("\n");
274
- const decisionLine = lines.find((l) => l.startsWith("Decision:"));
275
- const actionLine = lines.find((l) => l.startsWith("Action:"));
276
-
277
- Object.assign(agentState, {
278
- status: "idle",
279
- startedAt: null,
280
- lastWokeAt: new Date().toISOString(),
281
- lastDecision: decisionLine
282
- ? decisionLine.slice(10).trim()
283
- : stdout.slice(0, 200),
284
- lastAction: actionLine ? actionLine.slice(8).trim() : null,
285
- lastError: null,
286
- wakeCount: (agentState.wakeCount || 0) + 1,
287
- });
288
-
289
- // Save output as briefing fallback so View Briefing always has content
290
- const stateDir = join(CACHE_DIR, "state");
291
- ensureDir(stateDir);
292
- const prefix = agentName.replace(/-/g, "_");
293
- writeFileSync(join(stateDir, `${prefix}_last_output.md`), stdout);
294
- }
295
-
296
- /**
297
- * Reset agents stuck in "active" state. This happens when the daemon
298
- * restarts while agents were running, or when a child process exits
299
- * without triggering cleanup (e.g. pipe error, signal).
300
- *
301
- * @param {object} state
302
- * @param {{ reason: string, maxAge?: number }} opts
303
- * reason — logged and stored in lastError
304
- * maxAge — if set, only reset agents active longer than this (ms)
305
- */
306
- function resetStaleAgents(state, { reason, maxAge }) {
307
- let resetCount = 0;
308
- for (const [name, as] of Object.entries(state.agents)) {
309
- if (as.status !== "active") continue;
310
- if (maxAge && as.startedAt) {
311
- const elapsed = Date.now() - new Date(as.startedAt).getTime();
312
- if (elapsed < maxAge) continue;
313
- }
314
- log(`Resetting stale agent: ${name} (${reason})`);
315
- Object.assign(as, {
316
- status: "interrupted",
317
- startedAt: null,
318
- lastError: reason,
319
- });
320
- resetCount++;
321
- }
322
- if (resetCount > 0) saveState(state);
323
- return resetCount;
324
- }
325
-
326
- async function wakeDueAgents() {
327
- const config = loadConfig(),
328
- state = loadState(),
329
- now = new Date();
330
-
331
- // Reset agents that have been active longer than the maximum runtime.
332
- resetStaleAgents(state, {
333
- reason: "Exceeded maximum runtime",
334
- maxAge: MAX_AGENT_RUNTIME_MS,
335
- });
336
-
337
- let wokeAny = false;
338
- for (const [name, agent] of Object.entries(config.agents)) {
339
- if (shouldWake(agent, state.agents[name] || {}, now)) {
340
- await wakeAgent(name, agent, state);
341
- wokeAny = true;
342
- }
343
- }
344
- if (!wokeAny) log("No agents due.");
345
- }
346
-
347
- // --- Next-wake computation --------------------------------------------------
348
-
349
- /** @param {object} agent @param {object} agentState @param {Date} now */
350
- function computeNextWakeAt(agent, agentState, now) {
351
- if (agent.enabled === false) return null;
352
- const { schedule } = agent;
353
- if (!schedule) return null;
354
-
355
- if (schedule.type === "interval") {
356
- const ms = (schedule.minutes || 5) * 60_000;
357
- const lastWoke = agentState.lastWokeAt
358
- ? new Date(agentState.lastWokeAt)
359
- : null;
360
- if (!lastWoke) return now.toISOString();
361
- return new Date(lastWoke.getTime() + ms).toISOString();
362
- }
363
-
364
- if (schedule.type === "cron") {
365
- const limit = 24 * 60;
366
- const start = new Date(floorToMinute(now) + 60_000);
367
- for (let i = 0; i < limit; i++) {
368
- const candidate = new Date(start.getTime() + i * 60_000);
369
- if (cronMatches(schedule.expression, candidate)) {
370
- return candidate.toISOString();
371
- }
372
- }
373
- return null;
374
- }
375
-
376
- if (schedule.type === "once") {
377
- if (agentState.lastWokeAt) return null;
378
- return schedule.runAt;
379
- }
380
-
381
- return null;
382
- }
383
-
384
- // --- Briefing file resolution -----------------------------------------------
385
-
386
- /**
387
- * Resolve the briefing file for an agent by convention:
388
- * 1. Scan ~/.cache/fit/basecamp/state/ for files matching {agent_name}_*.md
389
- * 2. Fall back to the KB's knowledge/Briefings/ directory (latest .md file)
390
- *
391
- * @param {string} agentName
392
- * @param {object} agentConfig
393
- * @returns {string|null}
394
- */
395
- function resolveBriefingFile(agentName, agentConfig) {
396
- // 1. Scan state directory for agent-specific files (latest by mtime)
397
- const stateDir = join(CACHE_DIR, "state");
398
- if (existsSync(stateDir)) {
399
- const prefix = agentName.replace(/-/g, "_") + "_";
400
- const matches = readdirSync(stateDir).filter(
401
- (f) => f.startsWith(prefix) && f.endsWith(".md"),
402
- );
403
- if (matches.length > 0) {
404
- let latest = join(stateDir, matches[0]);
405
- let latestMtime = statSync(latest).mtimeMs;
406
- for (let i = 1; i < matches.length; i++) {
407
- const p = join(stateDir, matches[i]);
408
- const mt = statSync(p).mtimeMs;
409
- if (mt > latestMtime) {
410
- latest = p;
411
- latestMtime = mt;
412
- }
413
- }
414
- return latest;
415
- }
416
- }
417
-
418
- // 2. Fall back to KB briefings directory (latest by name)
419
- if (agentConfig.kb) {
420
- const dir = join(expandPath(agentConfig.kb), "knowledge", "Briefings");
421
- if (existsSync(dir)) {
422
- const files = readdirSync(dir)
423
- .filter((f) => f.endsWith(".md"))
424
- .sort()
425
- .reverse();
426
- if (files.length > 0) return join(dir, files[0]);
427
- }
428
- }
429
-
430
- return null;
431
- }
432
-
433
- // --- Socket server ----------------------------------------------------------
434
-
435
- /** @param {import('node:net').Socket} socket @param {object} data */
436
- function send(socket, data) {
437
- try {
438
- socket.write(JSON.stringify(data) + "\n");
439
- } catch {}
440
- }
441
-
442
- function handleStatusRequest(socket) {
443
- const config = loadConfig();
444
- const state = loadState();
445
- const now = new Date();
446
- const agents = {};
447
-
448
- for (const [name, agent] of Object.entries(config.agents)) {
449
- const as = state.agents[name] || {};
450
- agents[name] = {
451
- enabled: agent.enabled !== false,
452
- status: as.status || "never-woken",
453
- lastWokeAt: as.lastWokeAt || null,
454
- nextWakeAt: computeNextWakeAt(agent, as, now),
455
- lastAction: as.lastAction || null,
456
- lastDecision: as.lastDecision || null,
457
- wakeCount: as.wakeCount || 0,
458
- lastError: as.lastError || null,
459
- kbPath: agent.kb ? expandPath(agent.kb) : null,
460
- briefingFile: resolveBriefingFile(name, agent),
461
- };
462
- if (as.startedAt) agents[name].startedAt = as.startedAt;
463
- }
464
-
465
- send(socket, {
466
- type: "status",
467
- uptime: daemonStartedAt
468
- ? Math.floor((Date.now() - daemonStartedAt) / 1000)
469
- : 0,
470
- agents,
471
- });
472
- }
473
-
474
- function handleMessage(socket, line) {
475
- let request;
476
- try {
477
- request = JSON.parse(line);
478
- } catch {
479
- send(socket, { type: "error", message: "Invalid JSON" });
480
- return;
481
- }
482
-
483
- if (request.type === "status") return handleStatusRequest(socket);
484
-
485
- if (request.type === "shutdown") {
486
- log("Shutdown requested via socket.");
487
- send(socket, { type: "ack", command: "shutdown" });
488
- socket.end();
489
- killActiveChildren();
490
- process.exit(0);
491
- }
492
-
493
- if (request.type === "wake") {
494
- if (!request.agent) {
495
- send(socket, { type: "error", message: "Missing agent name" });
496
- return;
497
- }
498
- const config = loadConfig();
499
- const agent = config.agents[request.agent];
500
- if (!agent) {
501
- send(socket, {
502
- type: "error",
503
- message: `Agent not found: ${request.agent}`,
504
- });
505
- return;
506
- }
507
- send(socket, { type: "ack", command: "wake", agent: request.agent });
508
- const state = loadState();
509
- wakeAgent(request.agent, agent, state).catch(() => {});
510
- return;
511
- }
512
-
513
- send(socket, {
514
- type: "error",
515
- message: `Unknown request type: ${request.type}`,
516
- });
517
- }
518
-
519
- function startSocketServer() {
520
- try {
521
- unlinkSync(SOCKET_PATH);
522
- } catch {}
523
-
524
- const server = createServer((socket) => {
525
- let buffer = "";
526
- socket.on("data", (data) => {
527
- buffer += data.toString();
528
- let idx;
529
- while ((idx = buffer.indexOf("\n")) !== -1) {
530
- const line = buffer.slice(0, idx).trim();
531
- buffer = buffer.slice(idx + 1);
532
- if (line) handleMessage(socket, line);
533
- }
534
- });
535
- socket.on("error", () => {});
536
- });
537
-
538
- server.listen(SOCKET_PATH, () => {
539
- chmodSync(SOCKET_PATH, 0o600);
540
- log(`Socket server listening on ${SOCKET_PATH}`);
541
- });
542
-
543
- server.on("error", (err) => {
544
- log(`Socket server error: ${err.message}`);
545
- });
546
-
547
- const cleanup = () => {
548
- killActiveChildren();
549
- server.close();
550
- try {
551
- unlinkSync(SOCKET_PATH);
552
- } catch {}
553
- process.exit(0);
554
- };
555
- process.on("SIGTERM", cleanup);
556
- process.on("SIGINT", cleanup);
557
-
558
- return server;
559
- }
560
-
561
- // --- Graceful shutdown -------------------------------------------------------
562
-
563
- /**
564
- * Send SIGTERM to all tracked child processes (running claude sessions).
565
- * Called on daemon shutdown to prevent orphaned processes.
566
- */
567
- function killActiveChildren() {
568
- for (const pid of activeChildren) {
569
- try {
570
- process.kill(pid, "SIGTERM");
571
- log(`Sent SIGTERM to child PID ${pid}`);
572
- } catch {
573
- // Already exited
574
- }
575
- }
576
- activeChildren.clear();
577
- }
578
-
579
- /**
580
- * Connect to the daemon socket and request graceful shutdown.
581
- * Waits up to 5 seconds for the daemon to exit.
582
- * @returns {Promise<boolean>} true if shutdown succeeded
583
- */
584
- async function requestShutdown() {
585
- if (!existsSync(SOCKET_PATH)) {
586
- console.log("Daemon not running (no socket).");
587
- return false;
588
- }
589
-
590
- const { createConnection } = await import("node:net");
591
- return new Promise((resolve) => {
592
- const timeout = setTimeout(() => {
593
- console.log("Shutdown timed out.");
594
- socket.destroy();
595
- resolve(false);
596
- }, 5000);
597
-
598
- const socket = createConnection(SOCKET_PATH, () => {
599
- socket.write(JSON.stringify({ type: "shutdown" }) + "\n");
600
- });
601
-
602
- let buffer = "";
603
- socket.on("data", (data) => {
604
- buffer += data.toString();
605
- if (buffer.includes("\n")) {
606
- clearTimeout(timeout);
607
- console.log("Daemon stopped.");
608
- socket.destroy();
609
- resolve(true);
610
- }
611
- });
612
-
613
- socket.on("error", () => {
614
- clearTimeout(timeout);
615
- console.log("Daemon not running (connection refused).");
616
- resolve(false);
617
- });
618
-
619
- socket.on("close", () => {
620
- clearTimeout(timeout);
621
- resolve(true);
622
- });
623
- });
624
- }
625
-
626
- // --- Daemon -----------------------------------------------------------------
627
-
628
- function daemon() {
629
- daemonStartedAt = Date.now();
630
- log("Scheduler daemon started. Polling every 60 seconds.");
631
- log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
632
-
633
- // Reset any agents left "active" from a previous daemon session.
634
- const state = loadState();
635
- resetStaleAgents(state, { reason: "Daemon restarted" });
636
-
637
- startSocketServer();
638
- wakeDueAgents().catch((err) => log(`Error: ${err.message}`));
639
- setInterval(async () => {
640
- try {
641
- await wakeDueAgents();
642
- } catch (err) {
643
- log(`Error: ${err.message}`);
644
- }
645
- }, 60_000);
646
- }
647
-
648
- // --- Init knowledge base ----------------------------------------------------
649
-
650
- /**
651
- * Resolve the template directory or exit with an error.
652
- * @returns {string}
653
- */
654
123
  function requireTemplateDir() {
655
124
  const bundle = getBundlePath();
656
125
  if (bundle) {
@@ -666,148 +135,47 @@ function requireTemplateDir() {
666
135
  process.exit(1);
667
136
  }
668
137
 
669
- /**
670
- * Copy bundled files (CLAUDE.md, skills, agents) from template to a KB.
671
- * Shared by --init and --update.
672
- * @param {string} tpl Path to the template directory
673
- * @param {string} dest Path to the target knowledge base
674
- */
675
- function copyBundledFiles(tpl, dest) {
676
- // CLAUDE.md
677
- copyFileSync(join(tpl, "CLAUDE.md"), join(dest, "CLAUDE.md"));
678
- console.log(` Updated CLAUDE.md`);
679
-
680
- // Settings — merge template permissions into existing settings
681
- mergeSettings(tpl, dest);
682
-
683
- // Skills and agents
684
- for (const sub of ["skills", "agents"]) {
685
- const src = join(tpl, ".claude", sub);
686
- if (!existsSync(src)) continue;
687
- cpSync(src, join(dest, ".claude", sub), { recursive: true });
688
- const entries = readdirSync(src, { withFileTypes: true }).filter((d) =>
689
- sub === "skills" ? d.isDirectory() : d.name.endsWith(".md"),
690
- );
691
- const names = entries.map((d) =>
692
- sub === "agents" ? d.name.replace(".md", "") : d.name,
693
- );
694
- console.log(` Updated ${names.length} ${sub}: ${names.join(", ")}`);
695
- }
696
- }
697
-
698
- /**
699
- * Merge template settings.json into the destination's settings.json.
700
- * Adds any missing entries from allow, deny, and additionalDirectories
701
- * without removing user customizations.
702
- * @param {string} tpl Template directory
703
- * @param {string} dest Knowledge base directory
704
- */
705
- function mergeSettings(tpl, dest) {
706
- const src = join(tpl, ".claude", "settings.json");
707
- if (!existsSync(src)) return;
138
+ // --- Daemon ------------------------------------------------------------------
708
139
 
709
- const destPath = join(dest, ".claude", "settings.json");
140
+ function daemon() {
141
+ const daemonStartedAt = Date.now();
142
+ log("Scheduler daemon started. Polling every 60 seconds.");
143
+ log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
710
144
 
711
- // No existing settings copy template directly
712
- if (!existsSync(destPath)) {
713
- ensureDir(join(dest, ".claude"));
714
- copyFileSync(src, destPath);
715
- console.log(` Created settings.json`);
716
- return;
717
- }
145
+ // Reset any agents left "active" from a previous daemon session.
146
+ const state = stateManager.load();
147
+ stateManager.resetStaleAgents(state, { reason: "Daemon restarted" }, log);
148
+
149
+ const socketServer = new SocketServer(
150
+ SOCKET_PATH,
151
+ scheduler,
152
+ agentRunner,
153
+ stateManager,
154
+ loadConfig,
155
+ log,
156
+ CACHE_DIR,
157
+ daemonStartedAt,
158
+ );
159
+ socketServer.start();
718
160
 
719
- const template = readJSON(src, {});
720
- const existing = readJSON(destPath, {});
721
- const tp = template.permissions || {};
722
- const ep = (existing.permissions ||= {});
723
- let added = 0;
724
-
725
- // Merge array fields
726
- for (const key of ["allow", "deny", "additionalDirectories"]) {
727
- if (!tp[key]?.length) continue;
728
- const set = new Set((ep[key] ||= []));
729
- for (const entry of tp[key]) {
730
- if (!set.has(entry)) {
731
- ep[key].push(entry);
732
- set.add(entry);
733
- added++;
734
- }
161
+ scheduler.wakeDueAgents().catch((err) => log(`Error: ${err.message}`));
162
+ setInterval(async () => {
163
+ try {
164
+ await scheduler.wakeDueAgents();
165
+ } catch (err) {
166
+ log(`Error: ${err.message}`);
735
167
  }
736
- }
737
-
738
- // Merge scalar fields
739
- if (tp.defaultMode && !ep.defaultMode) {
740
- ep.defaultMode = tp.defaultMode;
741
- added++;
742
- }
743
-
744
- if (added > 0) {
745
- writeJSON(destPath, existing);
746
- console.log(` Updated settings.json (${added} new entries)`);
747
- } else {
748
- console.log(` Settings up to date`);
749
- }
750
- }
751
-
752
- function initKB(targetPath) {
753
- const dest = expandPath(targetPath);
754
- if (existsSync(join(dest, "CLAUDE.md"))) {
755
- console.error(`Knowledge base already exists at ${dest}`);
756
- process.exit(1);
757
- }
758
- const tpl = requireTemplateDir();
759
-
760
- ensureDir(dest);
761
- for (const d of [
762
- "knowledge/People",
763
- "knowledge/Organizations",
764
- "knowledge/Projects",
765
- "knowledge/Topics",
766
- "knowledge/Briefings",
767
- ])
768
- ensureDir(join(dest, d));
769
-
770
- // User-specific files (not overwritten by --update)
771
- copyFileSync(join(tpl, "USER.md"), join(dest, "USER.md"));
772
-
773
- // Bundled files (shared with --update)
774
- copyBundledFiles(tpl, dest);
775
-
776
- console.log(
777
- `Knowledge base initialized at ${dest}\n\nNext steps:\n 1. Edit ${dest}/USER.md with your name, email, and domain\n 2. cd ${dest} && claude`,
778
- );
168
+ }, 60_000);
779
169
  }
780
170
 
781
- // --- Update knowledge base --------------------------------------------------
171
+ // --- Update ------------------------------------------------------------------
782
172
 
783
- /**
784
- * Update an existing knowledge base with the latest bundled files.
785
- * User data (USER.md, knowledge/) is untouched.
786
- * Settings.json is merged — new template entries are added without
787
- * removing user customizations.
788
- * @param {string} targetPath
789
- */
790
- function updateKB(targetPath) {
791
- const dest = expandPath(targetPath);
792
- if (!existsSync(join(dest, "CLAUDE.md"))) {
793
- console.error(`No knowledge base found at ${dest}`);
794
- process.exit(1);
795
- }
796
- const tpl = requireTemplateDir();
797
- copyBundledFiles(tpl, dest);
798
- console.log(`\nKnowledge base updated: ${dest}`);
799
- }
800
-
801
- /**
802
- * Run --update for an explicit path or every unique KB in the scheduler config.
803
- */
804
- function runUpdate() {
805
- if (args[1]) {
806
- updateKB(args[1]);
173
+ function runUpdate(cliArgs) {
174
+ if (cliArgs[1]) {
175
+ kbManager.update(cliArgs[1], requireTemplateDir());
807
176
  return;
808
177
  }
809
178
 
810
- // Discover unique KB paths from config
811
179
  const config = loadConfig();
812
180
  const kbPaths = [
813
181
  ...new Set(
@@ -827,15 +195,15 @@ function runUpdate() {
827
195
 
828
196
  for (const kb of kbPaths) {
829
197
  console.log(`\nUpdating ${kb}...`);
830
- updateKB(kb);
198
+ kbManager.update(kb, requireTemplateDir());
831
199
  }
832
200
  }
833
201
 
834
- // --- Status -----------------------------------------------------------------
202
+ // --- Status ------------------------------------------------------------------
835
203
 
836
204
  function showStatus() {
837
- const config = loadConfig(),
838
- state = loadState();
205
+ const config = loadConfig();
206
+ const state = stateManager.load();
839
207
  console.log("\nBasecamp Scheduler\n==================\n");
840
208
 
841
209
  const agents = Object.entries(config.agents || {});
@@ -860,7 +228,7 @@ function showStatus() {
860
228
  }
861
229
  }
862
230
 
863
- // --- Validate ---------------------------------------------------------------
231
+ // --- Validate ----------------------------------------------------------------
864
232
 
865
233
  function findInLocalOrGlobal(kbPath, subPath) {
866
234
  const local = join(kbPath, ".claude", subPath);
@@ -906,7 +274,7 @@ function validate() {
906
274
  if (errors > 0) process.exit(1);
907
275
  }
908
276
 
909
- // --- Help -------------------------------------------------------------------
277
+ // --- Help --------------------------------------------------------------------
910
278
 
911
279
  function showHelp() {
912
280
  const bin = "fit-basecamp";
@@ -929,11 +297,11 @@ Logs: ~/.fit/basecamp/logs/
929
297
  `);
930
298
  }
931
299
 
932
- // --- CLI entry point --------------------------------------------------------
300
+ // --- CLI entry point ---------------------------------------------------------
933
301
 
934
302
  const args = process.argv.slice(2);
935
303
  const command = args[0];
936
- ensureDir(BASECAMP_HOME);
304
+ mkdirSync(BASECAMP_HOME, { recursive: true });
937
305
 
938
306
  function requireArg(usage) {
939
307
  if (!args[1]) {
@@ -949,25 +317,29 @@ const commands = {
949
317
  "--daemon": daemon,
950
318
  "--validate": validate,
951
319
  "--stop": async () => {
952
- const stopped = await requestShutdown();
320
+ const stopped = await requestShutdown(SOCKET_PATH);
953
321
  if (!stopped) process.exit(1);
954
322
  },
955
323
  "--status": showStatus,
956
- "--init": () => initKB(requireArg("Usage: fit-basecamp --init <path>")),
957
- "--update": runUpdate,
324
+ "--init": () =>
325
+ kbManager.init(
326
+ requireArg("Usage: fit-basecamp --init <path>"),
327
+ requireTemplateDir(),
328
+ ),
329
+ "--update": () => runUpdate(args),
958
330
  "--wake": async () => {
959
331
  const name = requireArg("Usage: fit-basecamp --wake <agent-name>");
960
- const config = loadConfig(),
961
- state = loadState(),
962
- agent = config.agents[name];
332
+ const config = loadConfig();
333
+ const state = stateManager.load();
334
+ const agent = config.agents[name];
963
335
  if (!agent) {
964
336
  console.error(
965
337
  `Agent "${name}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
966
338
  );
967
339
  process.exit(1);
968
340
  }
969
- await wakeAgent(name, agent, state);
341
+ await agentRunner.wake(name, agent, state);
970
342
  },
971
343
  };
972
344
 
973
- await (commands[command] || wakeDueAgents)();
345
+ await (commands[command] || (() => scheduler.wakeDueAgents()))();