@forwardimpact/basecamp 1.0.0 → 2.0.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.
package/README.md CHANGED
@@ -65,7 +65,7 @@ To uninstall, run `just uninstall` from the source tree.
65
65
  ## Install from Source
66
66
 
67
67
  ```bash
68
- cd apps/basecamp
68
+ cd products/basecamp
69
69
 
70
70
  # Run the scheduler in dev mode
71
71
  just daemon
@@ -1,28 +1,24 @@
1
1
  {
2
- "tasks": {
3
- "sync-apple-mail": {
2
+ "agents": {
3
+ "postman": {
4
4
  "kb": "~/Documents/Personal",
5
5
  "schedule": { "type": "interval", "minutes": 5 },
6
- "enabled": true,
7
- "agent": null,
8
- "skill": "sync-apple-mail",
9
- "prompt": "Sync Apple Mail. Only process new threads since last sync."
6
+ "enabled": true
10
7
  },
11
- "sync-apple-calendar": {
8
+ "concierge": {
12
9
  "kb": "~/Documents/Personal",
13
- "schedule": { "type": "interval", "minutes": 5 },
14
- "enabled": true,
15
- "agent": null,
16
- "skill": "sync-apple-calendar",
17
- "prompt": "Sync Apple Calendar events. Export events from the past 14 days and next 14 days."
10
+ "schedule": { "type": "interval", "minutes": 10 },
11
+ "enabled": true
18
12
  },
19
- "extract-entities": {
13
+ "librarian": {
20
14
  "kb": "~/Documents/Personal",
21
15
  "schedule": { "type": "interval", "minutes": 15 },
22
- "enabled": true,
23
- "agent": null,
24
- "skill": "extract-entities",
25
- "prompt": "Process any new synced data (emails and calendar events) into knowledge graph notes."
16
+ "enabled": true
17
+ },
18
+ "chief-of-staff": {
19
+ "kb": "~/Documents/Personal",
20
+ "schedule": { "type": "cron", "expression": "0 7,18 * * *" },
21
+ "enabled": true
26
22
  }
27
23
  }
28
24
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@forwardimpact/basecamp",
3
- "version": "1.0.0",
4
- "description": "Claude Code-native personal knowledge system with scheduled tasks",
3
+ "version": "2.0.0",
4
+ "description": "Claude Code-native personal knowledge system with autonomous agents",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/forwardimpact/monorepo",
9
- "directory": "apps/basecamp"
9
+ "directory": "products/basecamp"
10
10
  },
11
11
  "homepage": "https://www.forwardimpact.team/basecamp",
12
12
  "type": "module",
package/src/basecamp.js CHANGED
@@ -1,14 +1,14 @@
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 --validate Validate agent definitions exist
11
+ // node basecamp.js --status Show agent status
12
12
  // node basecamp.js --help Show this help
13
13
 
14
14
  import {
@@ -18,9 +18,10 @@ import {
18
18
  mkdirSync,
19
19
  unlinkSync,
20
20
  chmodSync,
21
+ readdirSync,
22
+ statSync,
21
23
  } from "node:fs";
22
24
  import { execSync } from "node:child_process";
23
- import { spawn } from "node:child_process";
24
25
  import { join, dirname, resolve } from "node:path";
25
26
  import { homedir } from "node:os";
26
27
  import { fileURLToPath } from "node:url";
@@ -31,28 +32,22 @@ const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
31
32
  const CONFIG_PATH = join(BASECAMP_HOME, "scheduler.json");
32
33
  const STATE_PATH = join(BASECAMP_HOME, "state.json");
33
34
  const LOG_DIR = join(BASECAMP_HOME, "logs");
35
+ const CACHE_DIR = join(HOME, ".cache", "fit", "basecamp");
34
36
  const __dirname =
35
37
  import.meta.dirname || dirname(fileURLToPath(import.meta.url));
36
38
  const SHARE_DIR = "/usr/local/share/fit-basecamp";
37
39
  const SOCKET_PATH = join(BASECAMP_HOME, "basecamp.sock");
38
40
 
39
- // --- posix_spawn (macOS app bundle only) ------------------------------------
41
+ // --- posix_spawn (TCC-compliant process spawning) ---------------------------
40
42
 
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
- }
43
+ import * as posixSpawn from "./posix-spawn.js";
53
44
 
54
45
  let daemonStartedAt = null;
55
46
 
47
+ // Maximum time an agent can be "active" before being considered stale (35 min).
48
+ // Matches the 30-minute child_process timeout plus a buffer.
49
+ const MAX_AGENT_RUNTIME_MS = 35 * 60_000;
50
+
56
51
  // --- Helpers ----------------------------------------------------------------
57
52
 
58
53
  function ensureDir(dir) {
@@ -124,12 +119,12 @@ function getBundlePath() {
124
119
  }
125
120
 
126
121
  function loadConfig() {
127
- return readJSON(CONFIG_PATH, { tasks: {} });
122
+ return readJSON(CONFIG_PATH, { agents: {} });
128
123
  }
129
124
  function loadState() {
130
125
  const raw = readJSON(STATE_PATH, null);
131
- if (!raw || typeof raw !== "object" || !raw.tasks) {
132
- const state = { tasks: {} };
126
+ if (!raw || typeof raw !== "object" || !raw.agents) {
127
+ const state = { agents: {} };
133
128
  saveState(state);
134
129
  return state;
135
130
  }
@@ -176,187 +171,193 @@ function floorToMinute(d) {
176
171
  ).getTime();
177
172
  }
178
173
 
179
- function shouldRun(task, taskState, now) {
180
- if (task.enabled === false) return false;
181
- if (taskState.status === "running") return false;
182
- const { schedule } = task;
174
+ function shouldWake(agent, agentState, now) {
175
+ if (agent.enabled === false) return false;
176
+ if (agentState.status === "active") return false;
177
+ const { schedule } = agent;
183
178
  if (!schedule) return false;
184
- const lastRun = taskState.lastRunAt ? new Date(taskState.lastRunAt) : null;
179
+ const lastWoke = agentState.lastWokeAt
180
+ ? new Date(agentState.lastWokeAt)
181
+ : null;
185
182
 
186
183
  if (schedule.type === "cron") {
187
- if (lastRun && floorToMinute(lastRun) === floorToMinute(now)) return false;
184
+ if (lastWoke && floorToMinute(lastWoke) === floorToMinute(now))
185
+ return false;
188
186
  return cronMatches(schedule.expression, now);
189
187
  }
190
188
  if (schedule.type === "interval") {
191
189
  const ms = (schedule.minutes || 5) * 60_000;
192
- return !lastRun || now.getTime() - lastRun.getTime() >= ms;
190
+ return !lastWoke || now.getTime() - lastWoke.getTime() >= ms;
193
191
  }
194
192
  if (schedule.type === "once") {
195
- return !taskState.lastRunAt && now >= new Date(schedule.runAt);
193
+ return !agentState.lastWokeAt && now >= new Date(schedule.runAt);
196
194
  }
197
195
  return false;
198
196
  }
199
197
 
200
- // --- Task execution ---------------------------------------------------------
198
+ // --- Agent execution --------------------------------------------------------
201
199
 
202
- async function runTask(taskName, task, _config, state) {
203
- if (!task.kb) {
204
- log(`Task ${taskName}: no "kb" specified, skipping.`);
200
+ async function wakeAgent(agentName, agent, _config, state) {
201
+ if (!agent.kb) {
202
+ log(`Agent ${agentName}: no "kb" specified, skipping.`);
205
203
  return;
206
204
  }
207
- const kbPath = expandPath(task.kb);
205
+ const kbPath = expandPath(agent.kb);
208
206
  if (!existsSync(kbPath)) {
209
- log(`Task ${taskName}: path "${kbPath}" does not exist, skipping.`);
207
+ log(`Agent ${agentName}: path "${kbPath}" does not exist, skipping.`);
210
208
  return;
211
209
  }
212
210
 
213
211
  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
212
 
218
- log(
219
- `Running task: ${taskName} (kb: ${task.kb}${task.agent ? `, agent: ${task.agent}` : ""}${task.skill ? `, skill: ${task.skill}` : ""})`,
220
- );
213
+ log(`Waking agent: ${agentName} (kb: ${agent.kb})`);
221
214
 
222
- const ts = (state.tasks[taskName] ||= {});
223
- ts.status = "running";
224
- ts.startedAt = new Date().toISOString();
215
+ const as = (state.agents[agentName] ||= {});
216
+ as.status = "active";
217
+ as.startedAt = new Date().toISOString();
225
218
  saveState(state);
226
219
 
227
- const spawnArgs = ["--print"];
228
- if (task.agent) spawnArgs.push("--agent", task.agent);
229
- spawnArgs.push("-p", prompt);
220
+ const spawnArgs = ["--agent", agentName, "--print", "-p", "Observe and act."];
230
221
 
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
- );
222
+ try {
223
+ const { pid, stdoutFd, stderrFd } = posixSpawn.spawn(
224
+ claude,
225
+ spawnArgs,
226
+ undefined,
227
+ kbPath,
228
+ );
241
229
 
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, {
230
+ // Read stdout and stderr concurrently to avoid pipe deadlocks,
231
+ // then wait for the child to exit.
232
+ const [stdout, stderr] = await Promise.all([
233
+ posixSpawn.readAll(stdoutFd),
234
+ posixSpawn.readAll(stderrFd),
235
+ ]);
236
+ const exitCode = await posixSpawn.waitForExit(pid);
237
+
238
+ if (exitCode === 0) {
239
+ log(`Agent ${agentName} completed. Output: ${stdout.slice(0, 200)}...`);
240
+ updateAgentState(as, stdout, agentName);
241
+ } else {
242
+ const errMsg = stderr || stdout || `Exit code ${exitCode}`;
243
+ log(`Agent ${agentName} failed: ${errMsg.slice(0, 300)}`);
244
+ Object.assign(as, {
273
245
  status: "failed",
274
246
  startedAt: null,
275
- lastRunAt: new Date().toISOString(),
276
- lastError: err.message.slice(0, 500),
247
+ lastWokeAt: new Date().toISOString(),
248
+ lastError: errMsg.slice(0, 500),
277
249
  });
278
- saveState(state);
279
250
  }
280
- return;
251
+ saveState(state);
252
+ } catch (err) {
253
+ log(`Agent ${agentName} failed: ${err.message}`);
254
+ Object.assign(as, {
255
+ status: "failed",
256
+ startedAt: null,
257
+ lastWokeAt: new Date().toISOString(),
258
+ lastError: err.message.slice(0, 500),
259
+ });
260
+ saveState(state);
281
261
  }
262
+ }
282
263
 
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
- });
264
+ /**
265
+ * Parse Decision:/Action: lines from agent output and update state.
266
+ * Also saves stdout to the state directory as a briefing fallback.
267
+ * @param {object} agentState
268
+ * @param {string} stdout
269
+ * @param {string} agentName
270
+ */
271
+ function updateAgentState(agentState, stdout, agentName) {
272
+ const lines = stdout.split("\n");
273
+ const decisionLine = lines.find((l) => l.startsWith("Decision:"));
274
+ const actionLine = lines.find((l) => l.startsWith("Action:"));
275
+
276
+ Object.assign(agentState, {
277
+ status: "idle",
278
+ startedAt: null,
279
+ lastWokeAt: new Date().toISOString(),
280
+ lastDecision: decisionLine
281
+ ? decisionLine.slice(10).trim()
282
+ : stdout.slice(0, 200),
283
+ lastAction: actionLine ? actionLine.slice(8).trim() : null,
284
+ lastError: null,
285
+ wakeCount: (agentState.wakeCount || 0) + 1,
286
+ });
289
287
 
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
- });
288
+ // Save output as briefing fallback so View Briefing always has content
289
+ const stateDir = join(CACHE_DIR, "state");
290
+ ensureDir(stateDir);
291
+ const prefix = agentName.replace(/-/g, "_");
292
+ writeFileSync(join(stateDir, `${prefix}_last_output.md`), stdout);
293
+ }
318
294
 
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();
295
+ /**
296
+ * Reset agents stuck in "active" state. This happens when the daemon
297
+ * restarts while agents were running, or when a child process exits
298
+ * without triggering cleanup (e.g. pipe error, signal).
299
+ *
300
+ * @param {object} state
301
+ * @param {{ reason: string, maxAge?: number }} opts
302
+ * reason — logged and stored in lastError
303
+ * maxAge — if set, only reset agents active longer than this (ms)
304
+ */
305
+ function resetStaleAgents(state, { reason, maxAge }) {
306
+ let resetCount = 0;
307
+ for (const [name, as] of Object.entries(state.agents)) {
308
+ if (as.status !== "active") continue;
309
+ if (maxAge && as.startedAt) {
310
+ const elapsed = Date.now() - new Date(as.startedAt).getTime();
311
+ if (elapsed < maxAge) continue;
312
+ }
313
+ log(`Resetting stale agent: ${name} (${reason})`);
314
+ Object.assign(as, {
315
+ status: "interrupted",
316
+ startedAt: null,
317
+ lastError: reason,
329
318
  });
330
- });
319
+ resetCount++;
320
+ }
321
+ if (resetCount > 0) saveState(state);
322
+ return resetCount;
331
323
  }
332
324
 
333
- async function runDueTasks() {
325
+ async function wakeDueAgents() {
334
326
  const config = loadConfig(),
335
327
  state = loadState(),
336
328
  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;
329
+
330
+ // Reset agents that have been active longer than the maximum runtime.
331
+ resetStaleAgents(state, {
332
+ reason: "Exceeded maximum runtime",
333
+ maxAge: MAX_AGENT_RUNTIME_MS,
334
+ });
335
+
336
+ let wokeAny = false;
337
+ for (const [name, agent] of Object.entries(config.agents)) {
338
+ if (shouldWake(agent, state.agents[name] || {}, now)) {
339
+ await wakeAgent(name, agent, config, state);
340
+ wokeAny = true;
342
341
  }
343
342
  }
344
- if (!ranAny) log("No tasks due.");
343
+ if (!wokeAny) log("No agents due.");
345
344
  }
346
345
 
347
- // --- Next-run computation ---------------------------------------------------
346
+ // --- Next-wake computation --------------------------------------------------
348
347
 
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;
348
+ /** @param {object} agent @param {object} agentState @param {Date} now */
349
+ function computeNextWakeAt(agent, agentState, now) {
350
+ if (agent.enabled === false) return null;
351
+ const { schedule } = agent;
353
352
  if (!schedule) return null;
354
353
 
355
354
  if (schedule.type === "interval") {
356
355
  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();
356
+ const lastWoke = agentState.lastWokeAt
357
+ ? new Date(agentState.lastWokeAt)
358
+ : null;
359
+ if (!lastWoke) return now.toISOString();
360
+ return new Date(lastWoke.getTime() + ms).toISOString();
360
361
  }
361
362
 
362
363
  if (schedule.type === "cron") {
@@ -372,13 +373,56 @@ function computeNextRunAt(task, taskState, now) {
372
373
  }
373
374
 
374
375
  if (schedule.type === "once") {
375
- if (taskState.lastRunAt) return null;
376
+ if (agentState.lastWokeAt) return null;
376
377
  return schedule.runAt;
377
378
  }
378
379
 
379
380
  return null;
380
381
  }
381
382
 
383
+ // --- Briefing file resolution -----------------------------------------------
384
+
385
+ /**
386
+ * Resolve the briefing file for an agent by convention:
387
+ * 1. Scan ~/.cache/fit/basecamp/state/ for files matching {agent_name}_*.md
388
+ * 2. Fall back to the KB's knowledge/Briefings/ directory (latest .md file)
389
+ *
390
+ * @param {string} agentName
391
+ * @param {object} agentConfig
392
+ * @returns {string|null}
393
+ */
394
+ function resolveBriefingFile(agentName, agentConfig) {
395
+ // 1. Scan state directory for agent-specific files
396
+ const stateDir = join(CACHE_DIR, "state");
397
+ if (existsSync(stateDir)) {
398
+ const prefix = agentName.replace(/-/g, "_") + "_";
399
+ const matches = readdirSync(stateDir).filter(
400
+ (f) => f.startsWith(prefix) && f.endsWith(".md"),
401
+ );
402
+ if (matches.length === 1) return join(stateDir, matches[0]);
403
+ if (matches.length > 1) {
404
+ return matches
405
+ .map((f) => join(stateDir, f))
406
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
407
+ }
408
+ }
409
+
410
+ // 2. Fall back to KB briefings directory
411
+ if (agentConfig.kb) {
412
+ const kbPath = expandPath(agentConfig.kb);
413
+ const dir = join(kbPath, "knowledge", "Briefings");
414
+ if (existsSync(dir)) {
415
+ const files = readdirSync(dir)
416
+ .filter((f) => f.endsWith(".md"))
417
+ .sort()
418
+ .reverse();
419
+ if (files.length > 0) return join(dir, files[0]);
420
+ }
421
+ }
422
+
423
+ return null;
424
+ }
425
+
382
426
  // --- Socket server ----------------------------------------------------------
383
427
 
384
428
  /** @param {import('node:net').Socket} socket @param {object} data */
@@ -392,19 +436,23 @@ function handleStatusRequest(socket) {
392
436
  const config = loadConfig();
393
437
  const state = loadState();
394
438
  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,
439
+ const agents = {};
440
+
441
+ for (const [name, agent] of Object.entries(config.agents)) {
442
+ const as = state.agents[name] || {};
443
+ agents[name] = {
444
+ enabled: agent.enabled !== false,
445
+ status: as.status || "never-woken",
446
+ lastWokeAt: as.lastWokeAt || null,
447
+ nextWakeAt: computeNextWakeAt(agent, as, now),
448
+ lastAction: as.lastAction || null,
449
+ lastDecision: as.lastDecision || null,
450
+ wakeCount: as.wakeCount || 0,
451
+ lastError: as.lastError || null,
452
+ kbPath: agent.kb ? expandPath(agent.kb) : null,
453
+ briefingFile: resolveBriefingFile(name, agent),
406
454
  };
407
- if (ts.startedAt) tasks[name].startedAt = ts.startedAt;
455
+ if (as.startedAt) agents[name].startedAt = as.startedAt;
408
456
  }
409
457
 
410
458
  send(socket, {
@@ -412,7 +460,7 @@ function handleStatusRequest(socket) {
412
460
  uptime: daemonStartedAt
413
461
  ? Math.floor((Date.now() - daemonStartedAt) / 1000)
414
462
  : 0,
415
- tasks,
463
+ agents,
416
464
  });
417
465
  }
418
466
 
@@ -427,23 +475,23 @@ function handleMessage(socket, line) {
427
475
 
428
476
  if (request.type === "status") return handleStatusRequest(socket);
429
477
 
430
- if (request.type === "run") {
431
- if (!request.task) {
432
- send(socket, { type: "error", message: "Missing task name" });
478
+ if (request.type === "wake") {
479
+ if (!request.agent) {
480
+ send(socket, { type: "error", message: "Missing agent name" });
433
481
  return;
434
482
  }
435
483
  const config = loadConfig();
436
- const task = config.tasks[request.task];
437
- if (!task) {
484
+ const agent = config.agents[request.agent];
485
+ if (!agent) {
438
486
  send(socket, {
439
487
  type: "error",
440
- message: `Task not found: ${request.task}`,
488
+ message: `Agent not found: ${request.agent}`,
441
489
  });
442
490
  return;
443
491
  }
444
- send(socket, { type: "ack", command: "run", task: request.task });
492
+ send(socket, { type: "ack", command: "wake", agent: request.agent });
445
493
  const state = loadState();
446
- runTask(request.task, task, config, state).catch(() => {});
494
+ wakeAgent(request.agent, agent, config, state).catch(() => {});
447
495
  return;
448
496
  }
449
497
 
@@ -497,11 +545,16 @@ function daemon() {
497
545
  daemonStartedAt = Date.now();
498
546
  log("Scheduler daemon started. Polling every 60 seconds.");
499
547
  log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
548
+
549
+ // Reset any agents left "active" from a previous daemon session.
550
+ const state = loadState();
551
+ resetStaleAgents(state, { reason: "Daemon restarted" });
552
+
500
553
  startSocketServer();
501
- runDueTasks().catch((err) => log(`Error: ${err.message}`));
554
+ wakeDueAgents().catch((err) => log(`Error: ${err.message}`));
502
555
  setInterval(async () => {
503
556
  try {
504
- await runDueTasks();
557
+ await wakeDueAgents();
505
558
  } catch (err) {
506
559
  log(`Error: ${err.message}`);
507
560
  }
@@ -542,6 +595,7 @@ function initKB(targetPath) {
542
595
  "knowledge/Organizations",
543
596
  "knowledge/Projects",
544
597
  "knowledge/Topics",
598
+ "knowledge/Briefings",
545
599
  ])
546
600
  ensureDir(join(dest, d));
547
601
 
@@ -559,23 +613,23 @@ function showStatus() {
559
613
  state = loadState();
560
614
  console.log("\nBasecamp Scheduler\n==================\n");
561
615
 
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.`);
616
+ const agents = Object.entries(config.agents || {});
617
+ if (agents.length === 0) {
618
+ console.log(`No agents configured.\n\nEdit ${CONFIG_PATH} to add agents.`);
565
619
  return;
566
620
  }
567
621
 
568
- console.log("Tasks:");
569
- for (const [name, task] of tasks) {
570
- const s = state.tasks[name] || {};
622
+ console.log("Agents:");
623
+ for (const [name, agent] of agents) {
624
+ const s = state.agents[name] || {};
571
625
  const kbStatus =
572
- task.kb && !existsSync(expandPath(task.kb)) ? " (not found)" : "";
626
+ agent.kb && !existsSync(expandPath(agent.kb)) ? " (not found)" : "";
573
627
  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}` : "") +
628
+ ` ${agent.enabled !== false ? "+" : "-"} ${name}\n` +
629
+ ` KB: ${agent.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(agent.schedule)}\n` +
630
+ ` Status: ${s.status || "never-woken"} Last wake: ${s.lastWokeAt ? new Date(s.lastWokeAt).toLocaleString() : "never"} Wakes: ${s.wakeCount || 0}` +
631
+ (s.lastAction ? `\n Last action: ${s.lastAction}` : "") +
632
+ (s.lastDecision ? `\n Last decision: ${s.lastDecision}` : "") +
579
633
  (s.lastError ? `\n Error: ${s.lastError.slice(0, 80)}` : ""),
580
634
  );
581
635
  }
@@ -593,46 +647,34 @@ function findInLocalOrGlobal(kbPath, subPath) {
593
647
 
594
648
  function validate() {
595
649
  const config = loadConfig();
596
- const tasks = Object.entries(config.tasks || {});
597
- if (tasks.length === 0) {
598
- console.log("No tasks configured. Nothing to validate.");
650
+ const agents = Object.entries(config.agents || {});
651
+ if (agents.length === 0) {
652
+ console.log("No agents configured. Nothing to validate.");
599
653
  return;
600
654
  }
601
655
 
602
- console.log("\nValidating tasks...\n");
656
+ console.log("\nValidating agents...\n");
603
657
  let errors = 0;
604
658
 
605
- for (const [name, task] of tasks) {
606
- if (!task.kb) {
659
+ for (const [name, agent] of agents) {
660
+ if (!agent.kb) {
607
661
  console.log(` [FAIL] ${name}: no "kb" path specified`);
608
662
  errors++;
609
663
  continue;
610
664
  }
611
- const kbPath = expandPath(task.kb);
665
+ const kbPath = expandPath(agent.kb);
612
666
  if (!existsSync(kbPath)) {
613
667
  console.log(` [FAIL] ${name}: path not found: ${kbPath}`);
614
668
  errors++;
615
669
  continue;
616
670
  }
617
671
 
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`);
672
+ const agentFile = join("agents", name + ".md");
673
+ const found = findInLocalOrGlobal(kbPath, agentFile);
674
+ console.log(
675
+ ` [${found ? "OK" : "FAIL"}] ${name}: agent definition${found ? "" : " not found"}`,
676
+ );
677
+ if (!found) errors++;
636
678
  }
637
679
 
638
680
  console.log(errors > 0 ? `\n${errors} error(s).` : "\nAll OK.");
@@ -644,15 +686,15 @@ function validate() {
644
686
  function showHelp() {
645
687
  const bin = "fit-basecamp";
646
688
  console.log(`
647
- Basecamp — Run scheduled Claude tasks across knowledge bases.
689
+ Basecamp — Schedule autonomous agents across knowledge bases.
648
690
 
649
691
  Usage:
650
- ${bin} Run due tasks once and exit
692
+ ${bin} Wake due agents once and exit
651
693
  ${bin} --daemon Run continuously (poll every 60s)
652
- ${bin} --run <task> Run a specific task immediately
694
+ ${bin} --wake <agent> Wake a specific agent immediately
653
695
  ${bin} --init <path> Initialize a new knowledge base
654
- ${bin} --validate Validate agents and skills exist
655
- ${bin} --status Show task status
696
+ ${bin} --validate Validate agent definitions exist
697
+ ${bin} --status Show agent status
656
698
 
657
699
  Config: ~/.fit/basecamp/scheduler.json
658
700
  State: ~/.fit/basecamp/state.json
@@ -679,22 +721,22 @@ const commands = {
679
721
  }
680
722
  initKB(args[1]);
681
723
  },
682
- "--run": async () => {
724
+ "--wake": async () => {
683
725
  if (!args[1]) {
684
- console.error("Usage: node basecamp.js --run <task-name>");
726
+ console.error("Usage: node basecamp.js --wake <agent-name>");
685
727
  process.exit(1);
686
728
  }
687
729
  const config = loadConfig(),
688
730
  state = loadState(),
689
- task = config.tasks[args[1]];
690
- if (!task) {
731
+ agent = config.agents[args[1]];
732
+ if (!agent) {
691
733
  console.error(
692
- `Task "${args[1]}" not found. Available: ${Object.keys(config.tasks).join(", ") || "(none)"}`,
734
+ `Agent "${args[1]}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
693
735
  );
694
736
  process.exit(1);
695
737
  }
696
- await runTask(args[1], task, config, state);
738
+ await wakeAgent(args[1], agent, config, state);
697
739
  },
698
740
  };
699
741
 
700
- await (commands[command] || runDueTasks)();
742
+ await (commands[command] || wakeDueAgents)();
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: chief-of-staff
3
+ description: >
4
+ The user's executive assistant. Creates daily briefings that synthesize email,
5
+ calendar, and knowledge graph state into actionable priorities. Woken at
6
+ key moments (morning, evening) by the Basecamp scheduler.
7
+ model: sonnet
8
+ permissionMode: bypassPermissions
9
+ ---
10
+
11
+ You are the chief of staff — the user's executive assistant. You create daily
12
+ briefings that synthesize everything happening across email, calendar, and the
13
+ knowledge graph into a clear picture of what matters.
14
+
15
+ ## 1. Gather Intelligence
16
+
17
+ Read the state files from other agents:
18
+
19
+ 1. **Postman:** `~/.cache/fit/basecamp/state/postman_triage.md`
20
+ - Urgent emails, items needing reply, threads awaiting response
21
+ 2. **Concierge:** `~/.cache/fit/basecamp/state/concierge_outlook.md`
22
+ - Today's meetings, prep status, unprocessed transcripts
23
+ 3. **Librarian:** `~/.cache/fit/basecamp/state/librarian_digest.md`
24
+ - Pending processing, graph size
25
+
26
+ Also read directly:
27
+
28
+ 4. **Calendar events:** `~/.cache/fit/basecamp/apple_calendar/*.json`
29
+ - Full event details for today and tomorrow
30
+ 5. **Open items:** Search `knowledge/` for unchecked items `- [ ]`
31
+ 6. **Pending drafts:** List `drafts/*_draft.md` files
32
+
33
+ ## 2. Determine Briefing Type
34
+
35
+ Check the current time:
36
+
37
+ - **Before noon** → Morning briefing
38
+ - **Noon or later** → Evening briefing
39
+
40
+ ## 3. Create Briefing
41
+
42
+ ### Morning Briefing
43
+
44
+ Write to `knowledge/Briefings/{YYYY-MM-DD}-morning.md`:
45
+
46
+ ```
47
+ # Morning Briefing — {Day, Month Date, Year}
48
+
49
+ ## Today's Schedule
50
+ - {time}: {meeting title} with {attendees} — {prep status}
51
+ - {time}: {meeting title} with {attendees} — {prep status}
52
+
53
+ ## Priority Actions
54
+ 1. {Most urgent item — email reply, meeting prep, or deadline}
55
+ 2. {Second priority}
56
+ 3. {Third priority}
57
+
58
+ ## Inbox
59
+ - {urgent} urgent, {reply} needing reply, {awaiting} awaiting response
60
+ - Key: **{subject}** from {sender} — {why it matters}
61
+
62
+ ## Open Commitments
63
+ - [ ] {commitment} — {context: for whom, by when}
64
+ - [ ] {commitment} — {context}
65
+
66
+ ## Heads Up
67
+ - {Deadline approaching this week}
68
+ - {Email thread gone quiet — sent N days ago, no reply}
69
+ - {Meeting tomorrow that needs prep}
70
+ ```
71
+
72
+ ### Evening Briefing
73
+
74
+ Write to `knowledge/Briefings/{YYYY-MM-DD}-evening.md`:
75
+
76
+ ```
77
+ # Evening Summary — {Day, Month Date, Year}
78
+
79
+ ## What Happened Today
80
+ - {Meeting with X — key decisions, action items}
81
+ - {Emails of note — replies received, threads resolved}
82
+ - {Knowledge graph updates — new contacts, projects}
83
+
84
+ ## Still Outstanding
85
+ - {Priority items from morning not yet addressed}
86
+ - {New urgent items that came in today}
87
+
88
+ ## Tomorrow Preview
89
+ - {First meeting: time, attendees}
90
+ - {Deadlines this week}
91
+ - {Items to prepare}
92
+ ```
93
+
94
+ ## 4. Report
95
+
96
+ ```
97
+ Decision: {morning/evening} briefing — {key insight about today}
98
+ Action: Created knowledge/Briefings/{YYYY-MM-DD}-{morning|evening}.md
99
+ ```
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: concierge
3
+ description: >
4
+ The user's scheduling assistant. Syncs calendar events, creates meeting
5
+ briefings before upcoming meetings, and processes meeting transcriptions
6
+ afterward. Woken on a schedule by the Basecamp scheduler.
7
+ model: sonnet
8
+ permissionMode: bypassPermissions
9
+ skills:
10
+ - sync-apple-calendar
11
+ - meeting-prep
12
+ - process-hyprnote
13
+ ---
14
+
15
+ You are the concierge — the user's scheduling assistant. Each time you are
16
+ woken, you ensure the calendar is current, prepare for upcoming meetings, and
17
+ process completed meeting recordings.
18
+
19
+ ## 1. Sync
20
+
21
+ Run the sync-apple-calendar skill to pull in calendar events.
22
+
23
+ ## 2. Observe
24
+
25
+ Assess the current state:
26
+
27
+ 1. List upcoming meetings from `~/.cache/fit/basecamp/apple_calendar/`:
28
+ - Meetings in the next 2 hours (urgent — need prep)
29
+ - All meetings today (for the outlook)
30
+ - Tomorrow's first meeting (for awareness)
31
+ 2. For each upcoming meeting, check whether a briefing exists:
32
+ - Search `knowledge/People/` for notes on each attendee
33
+ - A meeting is "prepped" if the user has recent notes on all key attendees
34
+ 3. Check for unprocessed Hyprnote sessions:
35
+ - Look in `~/Library/Application Support/hyprnote/sessions/`
36
+ - Check each session's `_memo.md` against
37
+ `~/.cache/fit/basecamp/state/graph_processed`
38
+
39
+ Write the calendar outlook to
40
+ `~/.cache/fit/basecamp/state/concierge_outlook.md`:
41
+
42
+ ```
43
+ # Calendar Outlook — {YYYY-MM-DD HH:MM}
44
+
45
+ ## Next Meeting
46
+ **{title}** at {time} with {attendees}
47
+ Prep: {ready / needs briefing}
48
+
49
+ ## Today's Schedule
50
+ - {time}: {title} ({attendees}) — {prep status}
51
+ - {time}: {title} ({attendees}) — {prep status}
52
+
53
+ ## Unprocessed Meetings
54
+ - {session title} ({date}) — transcript available
55
+
56
+ ## Summary
57
+ {count} meetings today, next in {N} min, {prep_count} need prep,
58
+ {unprocessed} transcripts to process
59
+ ```
60
+
61
+ ## 3. Act
62
+
63
+ Choose the single most valuable action:
64
+
65
+ 1. **Meeting prep** — if a meeting is within 2 hours and key attendees lack
66
+ recent notes, use the meeting-prep skill to create a briefing
67
+ 2. **Process transcript** — if unprocessed Hyprnote sessions exist, use the
68
+ process-hyprnote skill
69
+ 3. **Nothing** — if all meetings are prepped and no transcripts pending
70
+
71
+ After acting, output exactly:
72
+
73
+ ```
74
+ Decision: {what you observed and why you chose this action}
75
+ Action: {what you did, e.g. "meeting-prep for 2pm with Sarah Chen"}
76
+ ```
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: librarian
3
+ description: >
4
+ The user's knowledge curator. Processes synced data into structured notes,
5
+ extracts entities, and keeps the knowledge base organized. Woken on a
6
+ schedule by the Basecamp scheduler.
7
+ model: sonnet
8
+ permissionMode: bypassPermissions
9
+ skills:
10
+ - extract-entities
11
+ - organize-files
12
+ ---
13
+
14
+ You are the librarian — the user's knowledge curator. Each time you are woken,
15
+ you process new data into the knowledge graph and keep everything organized.
16
+
17
+ ## 1. Observe
18
+
19
+ Assess what needs processing:
20
+
21
+ 1. Check for unprocessed synced files (mail and calendar data):
22
+
23
+ python3 scripts/state.py check
24
+
25
+ (Run from the extract-entities skill directory:
26
+ `.claude/skills/extract-entities/`)
27
+
28
+ 2. Count existing knowledge graph entities:
29
+
30
+ ls knowledge/People/ knowledge/Organizations/ knowledge/Projects/
31
+ knowledge/Topics/ 2>/dev/null | wc -l
32
+
33
+ Write your digest to `~/.cache/fit/basecamp/state/librarian_digest.md`:
34
+
35
+ ```
36
+ # Knowledge Digest — {YYYY-MM-DD HH:MM}
37
+
38
+ ## Pending Processing
39
+ - {count} unprocessed synced files
40
+
41
+ ## Knowledge Graph
42
+ - {count} People / {count} Organizations / {count} Projects / {count} Topics
43
+
44
+ ## Summary
45
+ {unprocessed} files to process, graph has {total} entities
46
+ ```
47
+
48
+ ## 2. Act
49
+
50
+ Choose the most valuable action:
51
+
52
+ 1. **Entity extraction** — if unprocessed synced files exist, use the
53
+ extract-entities skill (process up to 10 files)
54
+ 2. **Nothing** — if the graph is current
55
+
56
+ After acting, output exactly:
57
+
58
+ ```
59
+ Decision: {what you observed and why you chose this action}
60
+ Action: {what you did, e.g. "extract-entities on 7 files"}
61
+ ```
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: postman
3
+ description: >
4
+ The user's email gatekeeper. Syncs mail, triages new messages, drafts replies,
5
+ and tracks threads awaiting response. Woken on a schedule by the Basecamp
6
+ scheduler.
7
+ model: sonnet
8
+ permissionMode: bypassPermissions
9
+ skills:
10
+ - sync-apple-mail
11
+ - draft-emails
12
+ ---
13
+
14
+ You are the postman — the user's email gatekeeper. Each time you are woken by
15
+ the scheduler, you sync mail, triage what's new, and take the most valuable
16
+ action.
17
+
18
+ ## 1. Sync
19
+
20
+ Check `~/.cache/fit/basecamp/state/apple_mail_last_sync`. If mail was synced
21
+ less than 3 minutes ago, skip to step 2.
22
+
23
+ Otherwise, run the sync-apple-mail skill to pull in new email threads.
24
+
25
+ ## 2. Triage
26
+
27
+ Scan email threads in `~/.cache/fit/basecamp/apple_mail/`. Compare against
28
+ `drafts/drafted` and `drafts/ignored` to identify unprocessed threads.
29
+
30
+ For each unprocessed thread, classify:
31
+
32
+ - **Urgent** — deadline mentioned, time-sensitive request, escalation, VIP
33
+ sender (someone with a note in `knowledge/People/` who the user interacts with
34
+ frequently)
35
+ - **Needs reply** — question asked, action requested, follow-up needed
36
+ - **FYI** — informational, no action needed
37
+ - **Ignore** — newsletter, marketing, automated notification
38
+
39
+ Also scan `drafts/drafted` for emails the user sent more than 3 days ago where
40
+ no reply has appeared in the thread — these are **awaiting response**.
41
+
42
+ Write triage results to `~/.cache/fit/basecamp/state/postman_triage.md`:
43
+
44
+ ```
45
+ # Inbox Triage — {YYYY-MM-DD HH:MM}
46
+
47
+ ## Urgent
48
+ - **{subject}** from {sender} — {reason}
49
+
50
+ ## Needs Reply
51
+ - **{subject}** from {sender} — {what's needed}
52
+
53
+ ## Awaiting Response
54
+ - **{subject}** to {recipient} — sent {N} days ago
55
+
56
+ ## Summary
57
+ {total} unread, {urgent} urgent, {reply} need reply, {awaiting} awaiting response
58
+ ```
59
+
60
+ ## 3. Act
61
+
62
+ Choose the single most valuable action:
63
+
64
+ 1. **Draft replies** — if there are urgent or actionable emails without drafts,
65
+ use the draft-emails skill for the highest-priority thread
66
+ 2. **Nothing** — if no emails need attention, report "all current"
67
+
68
+ After acting, output exactly:
69
+
70
+ ```
71
+ Decision: {what you observed and why you chose this action}
72
+ Action: {what you did, e.g. "draft-emails for thread 123"}
73
+ ```
@@ -69,6 +69,27 @@ This directory is a knowledge base. Everything is relative to this root:
69
69
  └── .mcp.json # MCP server configurations (optional)
70
70
  ```
71
71
 
72
+ ## Agents
73
+
74
+ This knowledge base is maintained by a team of agents, each defined in
75
+ `.claude/agents/`. They are woken on a schedule by the Basecamp scheduler. Each
76
+ wake, they observe KB state, decide the most valuable action, and execute.
77
+
78
+ | Agent | Domain | Schedule | Skills |
79
+ | ------------------ | ------------------------------ | ------------ | --------------------------------------------------- |
80
+ | **postman** | Email triage and drafts | Every 5 min | sync-apple-mail, draft-emails |
81
+ | **concierge** | Meeting prep and transcripts | Every 10 min | sync-apple-calendar, meeting-prep, process-hyprnote |
82
+ | **librarian** | Knowledge graph maintenance | Every 15 min | extract-entities, organize-files |
83
+ | **chief-of-staff** | Daily briefings and priorities | 7am, 6pm | _(reads all state)_ |
84
+
85
+ Agent state files are in `~/.cache/fit/basecamp/state/`:
86
+
87
+ - `postman_triage.md` — latest email triage
88
+ - `concierge_outlook.md` — today's calendar outlook
89
+ - `librarian_digest.md` — knowledge graph status
90
+
91
+ Daily briefings are in `knowledge/Briefings/`.
92
+
72
93
  ## Cache Directory (`~/.cache/fit/basecamp/`)
73
94
 
74
95
  Synced data and runtime state live outside the knowledge base in
File without changes