@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.
- package/README.md +1 -1
- package/config/scheduler.json +18 -17
- package/package.json +3 -3
- package/src/basecamp.js +532 -259
- package/template/.claude/agents/chief-of-staff.md +103 -0
- package/template/.claude/agents/concierge.md +75 -0
- package/template/.claude/agents/librarian.md +59 -0
- package/template/.claude/agents/postman.md +73 -0
- package/template/.claude/agents/recruiter.md +222 -0
- package/template/.claude/settings.json +0 -4
- package/template/.claude/skills/analyze-cv/SKILL.md +267 -0
- package/template/.claude/skills/create-presentations/SKILL.md +2 -2
- package/template/.claude/skills/create-presentations/references/slide.css +1 -1
- package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.mjs +47 -0
- package/template/.claude/skills/draft-emails/SKILL.md +85 -123
- package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
- package/template/.claude/skills/draft-emails/scripts/send-email.mjs +118 -0
- package/template/.claude/skills/extract-entities/SKILL.md +2 -2
- package/template/.claude/skills/extract-entities/scripts/state.mjs +130 -0
- package/template/.claude/skills/manage-tasks/SKILL.md +242 -0
- package/template/.claude/skills/organize-files/SKILL.md +3 -3
- package/template/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
- package/template/.claude/skills/organize-files/scripts/summarize.mjs +84 -0
- package/template/.claude/skills/process-hyprnote/SKILL.md +2 -2
- package/template/.claude/skills/send-chat/SKILL.md +170 -0
- package/template/.claude/skills/sync-apple-calendar/SKILL.md +5 -5
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.mjs +325 -0
- package/template/.claude/skills/sync-apple-mail/SKILL.md +6 -6
- package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +374 -0
- package/template/.claude/skills/sync-apple-mail/scripts/sync.mjs +629 -0
- package/template/.claude/skills/track-candidates/SKILL.md +375 -0
- package/template/.claude/skills/weekly-update/SKILL.md +250 -0
- package/template/CLAUDE.md +73 -29
- package/template/knowledge/Briefings/.gitkeep +0 -0
- package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +0 -32
- package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +0 -34
- package/template/.claude/skills/extract-entities/scripts/state.py +0 -100
- package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +0 -42
- package/template/.claude/skills/organize-files/scripts/summarize.sh +0 -21
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +0 -242
- package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +0 -104
- 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
|
|
3
|
+
// Basecamp — CLI and scheduler for autonomous agent teams.
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
|
-
// node basecamp.js
|
|
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 --
|
|
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 --
|
|
11
|
-
// node basecamp.js --
|
|
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 (
|
|
44
|
+
// --- posix_spawn (TCC-compliant process spawning) ---------------------------
|
|
40
45
|
|
|
41
|
-
|
|
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
|
-
|
|
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, {
|
|
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.
|
|
132
|
-
const state = {
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
180
|
+
const lastWoke = agentState.lastWokeAt
|
|
181
|
+
? new Date(agentState.lastWokeAt)
|
|
182
|
+
: null;
|
|
185
183
|
|
|
186
184
|
if (schedule.type === "cron") {
|
|
187
|
-
if (
|
|
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 !
|
|
191
|
+
return !lastWoke || now.getTime() - lastWoke.getTime() >= ms;
|
|
193
192
|
}
|
|
194
193
|
if (schedule.type === "once") {
|
|
195
|
-
return !
|
|
194
|
+
return !agentState.lastWokeAt && now >= new Date(schedule.runAt);
|
|
196
195
|
}
|
|
197
196
|
return false;
|
|
198
197
|
}
|
|
199
198
|
|
|
200
|
-
// ---
|
|
199
|
+
// --- Agent execution --------------------------------------------------------
|
|
201
200
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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(
|
|
215
|
+
const kbPath = expandPath(agent.kb);
|
|
208
216
|
if (!existsSync(kbPath)) {
|
|
209
|
-
log(`
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
258
|
+
} catch (err) {
|
|
259
|
+
log(`Agent ${agentName} failed: ${err.message}`);
|
|
260
|
+
failAgent(as, err.message);
|
|
281
261
|
}
|
|
262
|
+
saveState(state);
|
|
263
|
+
}
|
|
282
264
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
326
|
+
async function wakeDueAgents() {
|
|
334
327
|
const config = loadConfig(),
|
|
335
328
|
state = loadState(),
|
|
336
329
|
now = new Date();
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 (!
|
|
344
|
+
if (!wokeAny) log("No agents due.");
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
// --- Next-
|
|
347
|
+
// --- Next-wake computation --------------------------------------------------
|
|
348
348
|
|
|
349
|
-
/** @param {object}
|
|
350
|
-
function
|
|
351
|
-
if (
|
|
352
|
-
const { schedule } =
|
|
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
|
|
358
|
-
|
|
359
|
-
|
|
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 (
|
|
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
|
|
396
|
-
|
|
397
|
-
for (const [name,
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
enabled:
|
|
401
|
-
status:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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 (
|
|
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
|
-
|
|
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 === "
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|
437
|
-
if (!
|
|
499
|
+
const agent = config.agents[request.agent];
|
|
500
|
+
if (!agent) {
|
|
438
501
|
send(socket, {
|
|
439
502
|
type: "error",
|
|
440
|
-
message: `
|
|
503
|
+
message: `Agent not found: ${request.agent}`,
|
|
441
504
|
});
|
|
442
505
|
return;
|
|
443
506
|
}
|
|
444
|
-
send(socket, { type: "ack", command: "
|
|
507
|
+
send(socket, { type: "ack", command: "wake", agent: request.agent });
|
|
445
508
|
const state = loadState();
|
|
446
|
-
|
|
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
|
-
|
|
638
|
+
wakeDueAgents().catch((err) => log(`Error: ${err.message}`));
|
|
502
639
|
setInterval(async () => {
|
|
503
640
|
try {
|
|
504
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
563
|
-
if (
|
|
564
|
-
console.log(`No
|
|
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("
|
|
569
|
-
for (const [name,
|
|
570
|
-
const s = state.
|
|
847
|
+
console.log("Agents:");
|
|
848
|
+
for (const [name, agent] of agents) {
|
|
849
|
+
const s = state.agents[name] || {};
|
|
571
850
|
const kbStatus =
|
|
572
|
-
|
|
851
|
+
agent.kb && !existsSync(expandPath(agent.kb)) ? " (not found)" : "";
|
|
573
852
|
console.log(
|
|
574
|
-
` ${
|
|
575
|
-
` KB: ${
|
|
576
|
-
` Status: ${s.status || "never-
|
|
577
|
-
(
|
|
578
|
-
(
|
|
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
|
|
597
|
-
if (
|
|
598
|
-
console.log("No
|
|
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
|
|
881
|
+
console.log("\nValidating agents...\n");
|
|
603
882
|
let errors = 0;
|
|
604
883
|
|
|
605
|
-
for (const [name,
|
|
606
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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 —
|
|
914
|
+
Basecamp — Schedule autonomous agents across knowledge bases.
|
|
648
915
|
|
|
649
916
|
Usage:
|
|
650
|
-
${bin}
|
|
917
|
+
${bin} Wake due agents once and exit
|
|
651
918
|
${bin} --daemon Run continuously (poll every 60s)
|
|
652
|
-
${bin} --
|
|
919
|
+
${bin} --wake <agent> Wake a specific agent immediately
|
|
653
920
|
${bin} --init <path> Initialize a new knowledge base
|
|
654
|
-
${bin} --
|
|
655
|
-
${bin} --
|
|
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
|
-
"--
|
|
675
|
-
|
|
676
|
-
if (!
|
|
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
|
-
"--
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
690
|
-
if (!
|
|
962
|
+
agent = config.agents[name];
|
|
963
|
+
if (!agent) {
|
|
691
964
|
console.error(
|
|
692
|
-
`
|
|
965
|
+
`Agent "${name}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
|
|
693
966
|
);
|
|
694
967
|
process.exit(1);
|
|
695
968
|
}
|
|
696
|
-
await
|
|
969
|
+
await wakeAgent(name, agent, state);
|
|
697
970
|
},
|
|
698
971
|
};
|
|
699
972
|
|
|
700
|
-
await (commands[command] ||
|
|
973
|
+
await (commands[command] || wakeDueAgents)();
|