@forwardimpact/basecamp 1.0.0 → 2.2.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 (42) hide show
  1. package/README.md +1 -1
  2. package/config/scheduler.json +18 -17
  3. package/package.json +3 -3
  4. package/src/basecamp.js +532 -259
  5. package/template/.claude/agents/chief-of-staff.md +103 -0
  6. package/template/.claude/agents/concierge.md +75 -0
  7. package/template/.claude/agents/librarian.md +59 -0
  8. package/template/.claude/agents/postman.md +73 -0
  9. package/template/.claude/agents/recruiter.md +222 -0
  10. package/template/.claude/settings.json +0 -4
  11. package/template/.claude/skills/analyze-cv/SKILL.md +267 -0
  12. package/template/.claude/skills/create-presentations/SKILL.md +2 -2
  13. package/template/.claude/skills/create-presentations/references/slide.css +1 -1
  14. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.mjs +47 -0
  15. package/template/.claude/skills/draft-emails/SKILL.md +85 -123
  16. package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
  17. package/template/.claude/skills/draft-emails/scripts/send-email.mjs +118 -0
  18. package/template/.claude/skills/extract-entities/SKILL.md +2 -2
  19. package/template/.claude/skills/extract-entities/scripts/state.mjs +130 -0
  20. package/template/.claude/skills/manage-tasks/SKILL.md +242 -0
  21. package/template/.claude/skills/organize-files/SKILL.md +3 -3
  22. package/template/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
  23. package/template/.claude/skills/organize-files/scripts/summarize.mjs +84 -0
  24. package/template/.claude/skills/process-hyprnote/SKILL.md +2 -2
  25. package/template/.claude/skills/send-chat/SKILL.md +170 -0
  26. package/template/.claude/skills/sync-apple-calendar/SKILL.md +5 -5
  27. package/template/.claude/skills/sync-apple-calendar/scripts/sync.mjs +325 -0
  28. package/template/.claude/skills/sync-apple-mail/SKILL.md +6 -6
  29. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +374 -0
  30. package/template/.claude/skills/sync-apple-mail/scripts/sync.mjs +629 -0
  31. package/template/.claude/skills/track-candidates/SKILL.md +375 -0
  32. package/template/.claude/skills/weekly-update/SKILL.md +250 -0
  33. package/template/CLAUDE.md +73 -29
  34. package/template/knowledge/Briefings/.gitkeep +0 -0
  35. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +0 -32
  36. package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +0 -34
  37. package/template/.claude/skills/extract-entities/scripts/state.py +0 -100
  38. package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +0 -42
  39. package/template/.claude/skills/organize-files/scripts/summarize.sh +0 -21
  40. package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +0 -242
  41. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +0 -104
  42. package/template/.claude/skills/sync-apple-mail/scripts/sync.py +0 -455
package/src/basecamp.js CHANGED
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Basecamp — CLI and scheduler for personal knowledge bases.
3
+ // Basecamp — CLI and scheduler for autonomous agent teams.
4
4
  //
5
5
  // Usage:
6
- // node basecamp.js Run due tasks once and exit
6
+ // node basecamp.js Wake due agents once and exit
7
7
  // node basecamp.js --daemon Run continuously (poll every 60s)
8
- // node basecamp.js --run <task> Run a specific task immediately
8
+ // node basecamp.js --wake <agent> Wake a specific agent immediately
9
9
  // node basecamp.js --init <path> Initialize a new knowledge base
10
- // node basecamp.js --validate Validate agents and skills exist
11
- // node basecamp.js --status Show task status
10
+ // node basecamp.js --update [path] Update KB with latest CLAUDE.md, agents and skills
11
+ // node basecamp.js --stop Gracefully stop daemon and children
12
+ // node basecamp.js --validate Validate agent definitions exist
13
+ // node basecamp.js --status Show agent status
12
14
  // node basecamp.js --help Show this help
13
15
 
14
16
  import {
@@ -18,9 +20,11 @@ import {
18
20
  mkdirSync,
19
21
  unlinkSync,
20
22
  chmodSync,
23
+ readdirSync,
24
+ statSync,
25
+ cpSync,
26
+ copyFileSync,
21
27
  } from "node:fs";
22
- import { execSync } from "node:child_process";
23
- import { spawn } from "node:child_process";
24
28
  import { join, dirname, resolve } from "node:path";
25
29
  import { homedir } from "node:os";
26
30
  import { fileURLToPath } from "node:url";
@@ -31,32 +35,29 @@ const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
31
35
  const CONFIG_PATH = join(BASECAMP_HOME, "scheduler.json");
32
36
  const STATE_PATH = join(BASECAMP_HOME, "state.json");
33
37
  const LOG_DIR = join(BASECAMP_HOME, "logs");
38
+ const CACHE_DIR = join(HOME, ".cache", "fit", "basecamp");
34
39
  const __dirname =
35
40
  import.meta.dirname || dirname(fileURLToPath(import.meta.url));
36
41
  const SHARE_DIR = "/usr/local/share/fit-basecamp";
37
42
  const SOCKET_PATH = join(BASECAMP_HOME, "basecamp.sock");
38
43
 
39
- // --- posix_spawn (macOS app bundle only) ------------------------------------
44
+ // --- posix_spawn (TCC-compliant process spawning) ---------------------------
40
45
 
41
- const USE_POSIX_SPAWN = !!process.env.BASECAMP_BUNDLE;
42
- let posixSpawn;
43
- if (USE_POSIX_SPAWN) {
44
- try {
45
- posixSpawn = await import("./posix-spawn.js");
46
- } catch (err) {
47
- console.error(
48
- "Failed to load posix-spawn, falling back to child_process:",
49
- err.message,
50
- );
51
- }
52
- }
46
+ import * as posixSpawn from "./posix-spawn.js";
53
47
 
54
48
  let daemonStartedAt = null;
55
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();
56
+
56
57
  // --- Helpers ----------------------------------------------------------------
57
58
 
58
59
  function ensureDir(dir) {
59
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
60
+ mkdirSync(dir, { recursive: true });
60
61
  }
61
62
 
62
63
  function readJSON(path, fallback) {
@@ -124,12 +125,12 @@ function getBundlePath() {
124
125
  }
125
126
 
126
127
  function loadConfig() {
127
- return readJSON(CONFIG_PATH, { tasks: {} });
128
+ return readJSON(CONFIG_PATH, { agents: {} });
128
129
  }
129
130
  function loadState() {
130
131
  const raw = readJSON(STATE_PATH, null);
131
- if (!raw || typeof raw !== "object" || !raw.tasks) {
132
- const state = { tasks: {} };
132
+ if (!raw || typeof raw !== "object" || !raw.agents) {
133
+ const state = { agents: {} };
133
134
  saveState(state);
134
135
  return state;
135
136
  }
@@ -167,196 +168,197 @@ function cronMatches(expr, d) {
167
168
  // --- Scheduling logic -------------------------------------------------------
168
169
 
169
170
  function floorToMinute(d) {
170
- return new Date(
171
- d.getFullYear(),
172
- d.getMonth(),
173
- d.getDate(),
174
- d.getHours(),
175
- d.getMinutes(),
176
- ).getTime();
177
- }
178
-
179
- function shouldRun(task, taskState, now) {
180
- if (task.enabled === false) return false;
181
- if (taskState.status === "running") return false;
182
- const { schedule } = task;
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;
183
179
  if (!schedule) return false;
184
- const lastRun = taskState.lastRunAt ? new Date(taskState.lastRunAt) : null;
180
+ const lastWoke = agentState.lastWokeAt
181
+ ? new Date(agentState.lastWokeAt)
182
+ : null;
185
183
 
186
184
  if (schedule.type === "cron") {
187
- if (lastRun && floorToMinute(lastRun) === floorToMinute(now)) return false;
185
+ if (lastWoke && floorToMinute(lastWoke) === floorToMinute(now))
186
+ return false;
188
187
  return cronMatches(schedule.expression, now);
189
188
  }
190
189
  if (schedule.type === "interval") {
191
190
  const ms = (schedule.minutes || 5) * 60_000;
192
- return !lastRun || now.getTime() - lastRun.getTime() >= ms;
191
+ return !lastWoke || now.getTime() - lastWoke.getTime() >= ms;
193
192
  }
194
193
  if (schedule.type === "once") {
195
- return !taskState.lastRunAt && now >= new Date(schedule.runAt);
194
+ return !agentState.lastWokeAt && now >= new Date(schedule.runAt);
196
195
  }
197
196
  return false;
198
197
  }
199
198
 
200
- // --- Task execution ---------------------------------------------------------
199
+ // --- Agent execution --------------------------------------------------------
201
200
 
202
- async function runTask(taskName, task, _config, state) {
203
- if (!task.kb) {
204
- log(`Task ${taskName}: no "kb" specified, skipping.`);
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.`);
205
213
  return;
206
214
  }
207
- const kbPath = expandPath(task.kb);
215
+ const kbPath = expandPath(agent.kb);
208
216
  if (!existsSync(kbPath)) {
209
- log(`Task ${taskName}: path "${kbPath}" does not exist, skipping.`);
217
+ log(`Agent ${agentName}: path "${kbPath}" does not exist, skipping.`);
210
218
  return;
211
219
  }
212
220
 
213
221
  const claude = findClaude();
214
- const prompt = task.skill
215
- ? `Use the skill "${task.skill}" — ${task.prompt || `Run the ${taskName} task.`}`
216
- : task.prompt || `Run the ${taskName} task.`;
217
222
 
218
- log(
219
- `Running task: ${taskName} (kb: ${task.kb}${task.agent ? `, agent: ${task.agent}` : ""}${task.skill ? `, skill: ${task.skill}` : ""})`,
220
- );
223
+ log(`Waking agent: ${agentName} (kb: ${agent.kb})`);
221
224
 
222
- const ts = (state.tasks[taskName] ||= {});
223
- ts.status = "running";
224
- ts.startedAt = new Date().toISOString();
225
+ const as = (state.agents[agentName] ||= {});
226
+ as.status = "active";
227
+ as.startedAt = new Date().toISOString();
225
228
  saveState(state);
226
229
 
227
- const spawnArgs = ["--print"];
228
- if (task.agent) spawnArgs.push("--agent", task.agent);
229
- spawnArgs.push("-p", prompt);
230
-
231
- // Use posix_spawn when running inside the app bundle for TCC inheritance.
232
- // Fall back to child_process.spawn for dev mode and other platforms.
233
- if (posixSpawn) {
234
- try {
235
- const { pid, stdoutFd, stderrFd } = posixSpawn.spawn(
236
- claude,
237
- spawnArgs,
238
- undefined,
239
- kbPath,
240
- );
230
+ const spawnArgs = ["--agent", agentName, "--print", "-p", "Observe and act."];
241
231
 
242
- // Read stdout and stderr concurrently to avoid pipe deadlocks,
243
- // then wait for the child to exit.
244
- const [stdout, stderr] = await Promise.all([
245
- posixSpawn.readAll(stdoutFd),
246
- posixSpawn.readAll(stderrFd),
247
- ]);
248
- const exitCode = await posixSpawn.waitForExit(pid);
249
-
250
- if (exitCode === 0) {
251
- log(`Task ${taskName} completed. Output: ${stdout.slice(0, 200)}...`);
252
- Object.assign(ts, {
253
- status: "finished",
254
- startedAt: null,
255
- lastRunAt: new Date().toISOString(),
256
- lastError: null,
257
- runCount: (ts.runCount || 0) + 1,
258
- });
259
- } else {
260
- const errMsg = stderr || stdout || `Exit code ${exitCode}`;
261
- log(`Task ${taskName} failed: ${errMsg.slice(0, 300)}`);
262
- Object.assign(ts, {
263
- status: "failed",
264
- startedAt: null,
265
- lastRunAt: new Date().toISOString(),
266
- lastError: errMsg.slice(0, 500),
267
- });
268
- }
269
- saveState(state);
270
- } catch (err) {
271
- log(`Task ${taskName} failed: ${err.message}`);
272
- Object.assign(ts, {
273
- status: "failed",
274
- startedAt: null,
275
- lastRunAt: new Date().toISOString(),
276
- lastError: err.message.slice(0, 500),
277
- });
278
- saveState(state);
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);
279
257
  }
280
- return;
258
+ } catch (err) {
259
+ log(`Agent ${agentName} failed: ${err.message}`);
260
+ failAgent(as, err.message);
281
261
  }
262
+ saveState(state);
263
+ }
282
264
 
283
- return new Promise((resolve) => {
284
- const child = spawn(claude, spawnArgs, {
285
- cwd: kbPath,
286
- stdio: ["pipe", "pipe", "pipe"],
287
- timeout: 30 * 60_000,
288
- });
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
+ });
289
288
 
290
- let stdout = "";
291
- let stderr = "";
292
- child.stdout.on("data", (d) => (stdout += d));
293
- child.stderr.on("data", (d) => (stderr += d));
294
-
295
- child.on("close", (code) => {
296
- if (code === 0) {
297
- log(`Task ${taskName} completed. Output: ${stdout.slice(0, 200)}...`);
298
- Object.assign(ts, {
299
- status: "finished",
300
- startedAt: null,
301
- lastRunAt: new Date().toISOString(),
302
- lastError: null,
303
- runCount: (ts.runCount || 0) + 1,
304
- });
305
- } else {
306
- const errMsg = stderr || stdout || `Exit code ${code}`;
307
- log(`Task ${taskName} failed: ${errMsg.slice(0, 300)}`);
308
- Object.assign(ts, {
309
- status: "failed",
310
- startedAt: null,
311
- lastRunAt: new Date().toISOString(),
312
- lastError: errMsg.slice(0, 500),
313
- });
314
- }
315
- saveState(state);
316
- resolve();
317
- });
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
+ }
318
295
 
319
- child.on("error", (err) => {
320
- log(`Task ${taskName} failed: ${err.message}`);
321
- Object.assign(ts, {
322
- status: "failed",
323
- startedAt: null,
324
- lastRunAt: new Date().toISOString(),
325
- lastError: err.message.slice(0, 500),
326
- });
327
- saveState(state);
328
- resolve();
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,
329
319
  });
330
- });
320
+ resetCount++;
321
+ }
322
+ if (resetCount > 0) saveState(state);
323
+ return resetCount;
331
324
  }
332
325
 
333
- async function runDueTasks() {
326
+ async function wakeDueAgents() {
334
327
  const config = loadConfig(),
335
328
  state = loadState(),
336
329
  now = new Date();
337
- let ranAny = false;
338
- for (const [name, task] of Object.entries(config.tasks)) {
339
- if (shouldRun(task, state.tasks[name] || {}, now)) {
340
- await runTask(name, task, config, state);
341
- ranAny = true;
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
342
  }
343
343
  }
344
- if (!ranAny) log("No tasks due.");
344
+ if (!wokeAny) log("No agents due.");
345
345
  }
346
346
 
347
- // --- Next-run computation ---------------------------------------------------
347
+ // --- Next-wake computation --------------------------------------------------
348
348
 
349
- /** @param {object} task @param {object} taskState @param {Date} now */
350
- function computeNextRunAt(task, taskState, now) {
351
- if (task.enabled === false) return null;
352
- const { schedule } = task;
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
353
  if (!schedule) return null;
354
354
 
355
355
  if (schedule.type === "interval") {
356
356
  const ms = (schedule.minutes || 5) * 60_000;
357
- const lastRun = taskState.lastRunAt ? new Date(taskState.lastRunAt) : null;
358
- if (!lastRun) return now.toISOString();
359
- return new Date(lastRun.getTime() + ms).toISOString();
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();
360
362
  }
361
363
 
362
364
  if (schedule.type === "cron") {
@@ -372,13 +374,62 @@ function computeNextRunAt(task, taskState, now) {
372
374
  }
373
375
 
374
376
  if (schedule.type === "once") {
375
- if (taskState.lastRunAt) return null;
377
+ if (agentState.lastWokeAt) return null;
376
378
  return schedule.runAt;
377
379
  }
378
380
 
379
381
  return null;
380
382
  }
381
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
+
382
433
  // --- Socket server ----------------------------------------------------------
383
434
 
384
435
  /** @param {import('node:net').Socket} socket @param {object} data */
@@ -392,19 +443,23 @@ function handleStatusRequest(socket) {
392
443
  const config = loadConfig();
393
444
  const state = loadState();
394
445
  const now = new Date();
395
- const tasks = {};
396
-
397
- for (const [name, task] of Object.entries(config.tasks)) {
398
- const ts = state.tasks[name] || {};
399
- tasks[name] = {
400
- enabled: task.enabled !== false,
401
- status: ts.status || "never-run",
402
- lastRunAt: ts.lastRunAt || null,
403
- nextRunAt: computeNextRunAt(task, ts, now),
404
- runCount: ts.runCount || 0,
405
- lastError: ts.lastError || null,
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),
406
461
  };
407
- if (ts.startedAt) tasks[name].startedAt = ts.startedAt;
462
+ if (as.startedAt) agents[name].startedAt = as.startedAt;
408
463
  }
409
464
 
410
465
  send(socket, {
@@ -412,7 +467,7 @@ function handleStatusRequest(socket) {
412
467
  uptime: daemonStartedAt
413
468
  ? Math.floor((Date.now() - daemonStartedAt) / 1000)
414
469
  : 0,
415
- tasks,
470
+ agents,
416
471
  });
417
472
  }
418
473
 
@@ -427,23 +482,31 @@ function handleMessage(socket, line) {
427
482
 
428
483
  if (request.type === "status") return handleStatusRequest(socket);
429
484
 
430
- if (request.type === "run") {
431
- if (!request.task) {
432
- send(socket, { type: "error", message: "Missing task name" });
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" });
433
496
  return;
434
497
  }
435
498
  const config = loadConfig();
436
- const task = config.tasks[request.task];
437
- if (!task) {
499
+ const agent = config.agents[request.agent];
500
+ if (!agent) {
438
501
  send(socket, {
439
502
  type: "error",
440
- message: `Task not found: ${request.task}`,
503
+ message: `Agent not found: ${request.agent}`,
441
504
  });
442
505
  return;
443
506
  }
444
- send(socket, { type: "ack", command: "run", task: request.task });
507
+ send(socket, { type: "ack", command: "wake", agent: request.agent });
445
508
  const state = loadState();
446
- runTask(request.task, task, config, state).catch(() => {});
509
+ wakeAgent(request.agent, agent, state).catch(() => {});
447
510
  return;
448
511
  }
449
512
 
@@ -482,7 +545,11 @@ function startSocketServer() {
482
545
  });
483
546
 
484
547
  const cleanup = () => {
548
+ killActiveChildren();
485
549
  server.close();
550
+ try {
551
+ unlinkSync(SOCKET_PATH);
552
+ } catch {}
486
553
  process.exit(0);
487
554
  };
488
555
  process.on("SIGTERM", cleanup);
@@ -491,17 +558,87 @@ function startSocketServer() {
491
558
  return server;
492
559
  }
493
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
+
494
626
  // --- Daemon -----------------------------------------------------------------
495
627
 
496
628
  function daemon() {
497
629
  daemonStartedAt = Date.now();
498
630
  log("Scheduler daemon started. Polling every 60 seconds.");
499
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
+
500
637
  startSocketServer();
501
- runDueTasks().catch((err) => log(`Error: ${err.message}`));
638
+ wakeDueAgents().catch((err) => log(`Error: ${err.message}`));
502
639
  setInterval(async () => {
503
640
  try {
504
- await runDueTasks();
641
+ await wakeDueAgents();
505
642
  } catch (err) {
506
643
  log(`Error: ${err.message}`);
507
644
  }
@@ -510,7 +647,11 @@ function daemon() {
510
647
 
511
648
  // --- Init knowledge base ----------------------------------------------------
512
649
 
513
- function findTemplateDir() {
650
+ /**
651
+ * Resolve the template directory or exit with an error.
652
+ * @returns {string}
653
+ */
654
+ function requireTemplateDir() {
514
655
  const bundle = getBundlePath();
515
656
  if (bundle) {
516
657
  const tpl = join(bundle.resources, "template");
@@ -521,7 +662,91 @@ function findTemplateDir() {
521
662
  join(__dirname, "..", "template"),
522
663
  ])
523
664
  if (existsSync(d)) return d;
524
- return null;
665
+ console.error("Template not found. Reinstall fit-basecamp.");
666
+ process.exit(1);
667
+ }
668
+
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;
708
+
709
+ const destPath = join(dest, ".claude", "settings.json");
710
+
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
+ }
718
+
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
+ }
735
+ }
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
+ }
525
750
  }
526
751
 
527
752
  function initKB(targetPath) {
@@ -530,11 +755,7 @@ function initKB(targetPath) {
530
755
  console.error(`Knowledge base already exists at ${dest}`);
531
756
  process.exit(1);
532
757
  }
533
- const tpl = findTemplateDir();
534
- if (!tpl) {
535
- console.error("Template not found. Reinstall fit-basecamp.");
536
- process.exit(1);
537
- }
758
+ const tpl = requireTemplateDir();
538
759
 
539
760
  ensureDir(dest);
540
761
  for (const d of [
@@ -542,16 +763,74 @@ function initKB(targetPath) {
542
763
  "knowledge/Organizations",
543
764
  "knowledge/Projects",
544
765
  "knowledge/Topics",
766
+ "knowledge/Briefings",
545
767
  ])
546
768
  ensureDir(join(dest, d));
547
769
 
548
- execSync(`cp -R "${tpl}/." "${dest}/"`);
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);
549
775
 
550
776
  console.log(
551
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`,
552
778
  );
553
779
  }
554
780
 
781
+ // --- Update knowledge base --------------------------------------------------
782
+
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]);
807
+ return;
808
+ }
809
+
810
+ // Discover unique KB paths from config
811
+ const config = loadConfig();
812
+ const kbPaths = [
813
+ ...new Set(
814
+ Object.values(config.agents)
815
+ .filter((a) => a.kb)
816
+ .map((a) => expandPath(a.kb)),
817
+ ),
818
+ ];
819
+
820
+ if (kbPaths.length === 0) {
821
+ console.error(
822
+ "No knowledge bases configured and no path given.\n" +
823
+ "Usage: fit-basecamp --update [path]",
824
+ );
825
+ process.exit(1);
826
+ }
827
+
828
+ for (const kb of kbPaths) {
829
+ console.log(`\nUpdating ${kb}...`);
830
+ updateKB(kb);
831
+ }
832
+ }
833
+
555
834
  // --- Status -----------------------------------------------------------------
556
835
 
557
836
  function showStatus() {
@@ -559,23 +838,23 @@ function showStatus() {
559
838
  state = loadState();
560
839
  console.log("\nBasecamp Scheduler\n==================\n");
561
840
 
562
- const tasks = Object.entries(config.tasks || {});
563
- if (tasks.length === 0) {
564
- console.log(`No tasks configured.\n\nEdit ${CONFIG_PATH} to add tasks.`);
841
+ const agents = Object.entries(config.agents || {});
842
+ if (agents.length === 0) {
843
+ console.log(`No agents configured.\n\nEdit ${CONFIG_PATH} to add agents.`);
565
844
  return;
566
845
  }
567
846
 
568
- console.log("Tasks:");
569
- for (const [name, task] of tasks) {
570
- const s = state.tasks[name] || {};
847
+ console.log("Agents:");
848
+ for (const [name, agent] of agents) {
849
+ const s = state.agents[name] || {};
571
850
  const kbStatus =
572
- task.kb && !existsSync(expandPath(task.kb)) ? " (not found)" : "";
851
+ agent.kb && !existsSync(expandPath(agent.kb)) ? " (not found)" : "";
573
852
  console.log(
574
- ` ${task.enabled !== false ? "+" : "-"} ${name}\n` +
575
- ` KB: ${task.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(task.schedule)}\n` +
576
- ` Status: ${s.status || "never-run"} Last: ${s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : "never"} Runs: ${s.runCount || 0}` +
577
- (task.agent ? `\n Agent: ${task.agent}` : "") +
578
- (task.skill ? `\n Skill: ${task.skill}` : "") +
853
+ ` ${agent.enabled !== false ? "+" : "-"} ${name}\n` +
854
+ ` KB: ${agent.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(agent.schedule)}\n` +
855
+ ` Status: ${s.status || "never-woken"} Last wake: ${s.lastWokeAt ? new Date(s.lastWokeAt).toLocaleString() : "never"} Wakes: ${s.wakeCount || 0}` +
856
+ (s.lastAction ? `\n Last action: ${s.lastAction}` : "") +
857
+ (s.lastDecision ? `\n Last decision: ${s.lastDecision}` : "") +
579
858
  (s.lastError ? `\n Error: ${s.lastError.slice(0, 80)}` : ""),
580
859
  );
581
860
  }
@@ -593,46 +872,34 @@ function findInLocalOrGlobal(kbPath, subPath) {
593
872
 
594
873
  function validate() {
595
874
  const config = loadConfig();
596
- const tasks = Object.entries(config.tasks || {});
597
- if (tasks.length === 0) {
598
- console.log("No tasks configured. Nothing to validate.");
875
+ const agents = Object.entries(config.agents || {});
876
+ if (agents.length === 0) {
877
+ console.log("No agents configured. Nothing to validate.");
599
878
  return;
600
879
  }
601
880
 
602
- console.log("\nValidating tasks...\n");
881
+ console.log("\nValidating agents...\n");
603
882
  let errors = 0;
604
883
 
605
- for (const [name, task] of tasks) {
606
- if (!task.kb) {
884
+ for (const [name, agent] of agents) {
885
+ if (!agent.kb) {
607
886
  console.log(` [FAIL] ${name}: no "kb" path specified`);
608
887
  errors++;
609
888
  continue;
610
889
  }
611
- const kbPath = expandPath(task.kb);
890
+ const kbPath = expandPath(agent.kb);
612
891
  if (!existsSync(kbPath)) {
613
892
  console.log(` [FAIL] ${name}: path not found: ${kbPath}`);
614
893
  errors++;
615
894
  continue;
616
895
  }
617
896
 
618
- for (const [kind, sub] of [
619
- ["agent", task.agent],
620
- ["skill", task.skill],
621
- ]) {
622
- if (!sub) continue;
623
- const relPath =
624
- kind === "agent"
625
- ? join("agents", sub.endsWith(".md") ? sub : sub + ".md")
626
- : join("skills", sub, "SKILL.md");
627
- const found = findInLocalOrGlobal(kbPath, relPath);
628
- console.log(
629
- ` [${found ? "OK" : "FAIL"}] ${name}: ${kind} "${sub}"${found ? "" : " not found"}`,
630
- );
631
- if (!found) errors++;
632
- }
633
-
634
- if (!task.agent && !task.skill)
635
- console.log(` [OK] ${name}: no agent or skill to validate`);
897
+ const agentFile = join("agents", name + ".md");
898
+ const found = findInLocalOrGlobal(kbPath, agentFile);
899
+ console.log(
900
+ ` [${found ? "OK" : "FAIL"}] ${name}: agent definition${found ? "" : " not found"}`,
901
+ );
902
+ if (!found) errors++;
636
903
  }
637
904
 
638
905
  console.log(errors > 0 ? `\n${errors} error(s).` : "\nAll OK.");
@@ -644,15 +911,17 @@ function validate() {
644
911
  function showHelp() {
645
912
  const bin = "fit-basecamp";
646
913
  console.log(`
647
- Basecamp — Run scheduled Claude tasks across knowledge bases.
914
+ Basecamp — Schedule autonomous agents across knowledge bases.
648
915
 
649
916
  Usage:
650
- ${bin} Run due tasks once and exit
917
+ ${bin} Wake due agents once and exit
651
918
  ${bin} --daemon Run continuously (poll every 60s)
652
- ${bin} --run <task> Run a specific task immediately
919
+ ${bin} --wake <agent> Wake a specific agent immediately
653
920
  ${bin} --init <path> Initialize a new knowledge base
654
- ${bin} --validate Validate agents and skills exist
655
- ${bin} --status Show task status
921
+ ${bin} --update [path] Update KB with latest CLAUDE.md, agents and skills
922
+ ${bin} --stop Gracefully stop daemon and all running agents
923
+ ${bin} --validate Validate agent definitions exist
924
+ ${bin} --status Show agent status
656
925
 
657
926
  Config: ~/.fit/basecamp/scheduler.json
658
927
  State: ~/.fit/basecamp/state.json
@@ -666,35 +935,39 @@ const args = process.argv.slice(2);
666
935
  const command = args[0];
667
936
  ensureDir(BASECAMP_HOME);
668
937
 
938
+ function requireArg(usage) {
939
+ if (!args[1]) {
940
+ console.error(usage);
941
+ process.exit(1);
942
+ }
943
+ return args[1];
944
+ }
945
+
669
946
  const commands = {
670
947
  "--help": showHelp,
671
948
  "-h": showHelp,
672
949
  "--daemon": daemon,
673
950
  "--validate": validate,
674
- "--status": showStatus,
675
- "--init": () => {
676
- if (!args[1]) {
677
- console.error("Usage: node basecamp.js --init <path>");
678
- process.exit(1);
679
- }
680
- initKB(args[1]);
951
+ "--stop": async () => {
952
+ const stopped = await requestShutdown();
953
+ if (!stopped) process.exit(1);
681
954
  },
682
- "--run": async () => {
683
- if (!args[1]) {
684
- console.error("Usage: node basecamp.js --run <task-name>");
685
- process.exit(1);
686
- }
955
+ "--status": showStatus,
956
+ "--init": () => initKB(requireArg("Usage: fit-basecamp --init <path>")),
957
+ "--update": runUpdate,
958
+ "--wake": async () => {
959
+ const name = requireArg("Usage: fit-basecamp --wake <agent-name>");
687
960
  const config = loadConfig(),
688
961
  state = loadState(),
689
- task = config.tasks[args[1]];
690
- if (!task) {
962
+ agent = config.agents[name];
963
+ if (!agent) {
691
964
  console.error(
692
- `Task "${args[1]}" not found. Available: ${Object.keys(config.tasks).join(", ") || "(none)"}`,
965
+ `Agent "${name}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
693
966
  );
694
967
  process.exit(1);
695
968
  }
696
- await runTask(args[1], task, config, state);
969
+ await wakeAgent(name, agent, state);
697
970
  },
698
971
  };
699
972
 
700
- await (commands[command] || runDueTasks)();
973
+ await (commands[command] || wakeDueAgents)();