@forwardimpact/outpost 3.1.3 → 3.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/src/outpost.js CHANGED
@@ -1,5 +1,3 @@
1
- #!/usr/bin/env bun
2
-
3
1
  // Outpost — CLI and scheduler for autonomous agent teams.
4
2
  //
5
3
  // Usage:
@@ -12,406 +10,452 @@
12
10
  // fit-outpost validate Validate agent definitions exist
13
11
  // fit-outpost status Show agent status
14
12
  // fit-outpost --help Show this help
13
+ //
14
+ // This module owns the CLI definition and dispatch table. The runtime
15
+ // collaborator bag is constructed once in bin/fit-outpost.js (the sole
16
+ // construction site) and threaded into `run(runtime, version)`; `run` returns
17
+ // the process exit code and the bin translates it to `runtime.proc.exit`.
15
18
 
16
- import {
17
- readFileSync,
18
- writeFileSync,
19
- existsSync,
20
- mkdirSync,
21
- readdirSync,
22
- copyFileSync,
23
- cpSync,
24
- appendFileSync,
25
- } from "node:fs";
26
19
  import { join, dirname, resolve } from "node:path";
27
20
  import { homedir } from "node:os";
28
- import { fileURLToPath } from "node:url";
29
21
  import { createCli } from "@forwardimpact/libcli";
30
22
  import { createLogger } from "@forwardimpact/libtelemetry";
23
+ import { isoTimestamp } from "@forwardimpact/libutil";
31
24
 
32
- const logger = createLogger("outpost");
33
-
34
- import * as posixSpawn from "@forwardimpact/libmacos/posix-spawn";
35
25
  import { StateManager } from "./state-manager.js";
36
26
  import { AgentRunner } from "./agent-runner.js";
37
- import { Scheduler } from "./scheduler.js";
27
+ import { Scheduler, formatLocalTime } from "./scheduler.js";
38
28
  import { KBManager } from "./kb-manager.js";
39
29
  import { SocketServer, requestShutdown } from "./socket-server.js";
40
30
 
41
- // --- Paths -------------------------------------------------------------------
42
-
43
- const HOME = homedir();
44
- const OUTPOST_HOME = join(HOME, ".fit", "outpost");
45
- const CONFIG_PATH = join(OUTPOST_HOME, "scheduler.json");
46
- const STATE_PATH = join(OUTPOST_HOME, "state.json");
47
- const LOG_DIR = join(OUTPOST_HOME, "logs");
48
- const CACHE_DIR = join(HOME, ".cache", "fit", "outpost");
49
- const __dirname =
50
- import.meta.dirname || dirname(fileURLToPath(import.meta.url));
51
31
  const SHARE_DIR = "/usr/local/share/fit-outpost";
52
- const SOCKET_PATH = join(OUTPOST_HOME, "outpost.sock");
53
-
54
- // In compiled binaries (bun build --compile), `bun build --define` injects the
55
- // version string here so the readFileSync branch is eliminated as dead code.
56
- // Source execution (bun src/outpost.js) falls through to package.json.
57
- const VERSION =
58
- process.env.OUTPOST_VERSION ||
59
- JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"))
60
- .version;
61
-
62
- // --- Logging -----------------------------------------------------------------
63
-
64
- function createFileLogger(logDir, fs) {
65
- if (!logDir) throw new Error("logDir is required");
66
- if (!fs) throw new Error("fs is required");
67
- fs.mkdirSync(logDir, { recursive: true });
68
- return function log(msg) {
69
- const ts = new Date().toISOString();
70
- const line = `[${ts}] ${msg}`;
71
- logger.info(line);
72
- fs.appendFileSync(
73
- join(logDir, `scheduler-${ts.slice(0, 10)}.log`),
74
- line + "\n",
75
- );
76
- };
77
- }
78
-
79
- const log = createFileLogger(LOG_DIR, { mkdirSync, appendFileSync });
80
-
81
- // --- Config ------------------------------------------------------------------
82
32
 
83
- function loadConfig() {
84
- try {
85
- return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
86
- } catch {
87
- return { agents: {} };
88
- }
33
+ /**
34
+ * Build the CLI definition. The object is byte-identical to the libcli
35
+ * definition the goldens were captured against, so `--help` / `--version`
36
+ * output stays stable.
37
+ * @param {string} version
38
+ * @returns {object}
39
+ */
40
+ function buildDefinition(version) {
41
+ return {
42
+ name: "fit-outpost",
43
+ version,
44
+ description: "Schedule autonomous agents across knowledge bases",
45
+ commands: [
46
+ { name: "daemon", description: "Run continuously (poll every 60s)" },
47
+ {
48
+ name: "wake",
49
+ args: "<agent>",
50
+ description: "Wake a specific agent immediately",
51
+ },
52
+ {
53
+ name: "init",
54
+ args: "<path>",
55
+ description: "Initialize a new knowledge base",
56
+ },
57
+ {
58
+ name: "update",
59
+ args: "[path]",
60
+ description: "Update KB with latest CLAUDE.md, agents and skills",
61
+ },
62
+ {
63
+ name: "stop",
64
+ description: "Gracefully stop daemon and all running agents",
65
+ },
66
+ { name: "validate", description: "Validate agent definitions exist" },
67
+ { name: "status", description: "Show agent status" },
68
+ ],
69
+ globalOptions: {
70
+ help: { type: "boolean", short: "h", description: "Show this help" },
71
+ version: { type: "boolean", description: "Show version" },
72
+ json: { type: "boolean", description: "JSON output (with --help)" },
73
+ },
74
+ documentation: [
75
+ {
76
+ title: "Outpost Overview",
77
+ url: "https://www.forwardimpact.team/outpost/index.md",
78
+ description: "Product overview, audience model, and key concepts.",
79
+ },
80
+ {
81
+ title: "Getting Started: Outpost for Engineers",
82
+ url: "https://www.forwardimpact.team/docs/getting-started/engineers/outpost/index.md",
83
+ description: "From zero to your first daily briefing.",
84
+ },
85
+ {
86
+ title: "Keep Track of Context Without Effort",
87
+ url: "https://www.forwardimpact.team/docs/products/knowledge-systems/index.md",
88
+ description:
89
+ "Maintain continuous awareness of people, projects, and threads.",
90
+ },
91
+ {
92
+ title: "Walk Into Every Meeting Already Oriented",
93
+ url: "https://www.forwardimpact.team/docs/products/knowledge-systems/meeting-prep/index.md",
94
+ description: "Assemble context so you arrive prepared.",
95
+ },
96
+ ],
97
+ };
89
98
  }
90
99
 
91
- function expandPath(p) {
92
- return p.startsWith("~/") ? join(HOME, p.slice(2)) : resolve(p);
100
+ /**
101
+ * Render an agent's multi-line status block (pure formatting).
102
+ * @param {string} name
103
+ * @param {Object} agent
104
+ * @param {Object} s - The agent's persisted state.
105
+ * @param {boolean} kbMissing - Whether the agent's kb path is absent.
106
+ * @returns {string}
107
+ */
108
+ function renderAgentStatus(name, agent, s, kbMissing) {
109
+ const enabledMark = agent.enabled !== false ? "+" : "-";
110
+ const kbStatus = kbMissing ? " (not found)" : "";
111
+ const lastWake = s.lastWokeAt ? formatLocalTime(s.lastWokeAt) : "never";
112
+ const lines = [
113
+ ` ${enabledMark} ${name}`,
114
+ ` KB: ${agent.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(agent.schedule)}`,
115
+ ` Status: ${s.status || "never-woken"} Last wake: ${lastWake} Wakes: ${s.wakeCount || 0}`,
116
+ ];
117
+ if (s.lastAction) lines.push(` Last action: ${s.lastAction}`);
118
+ if (s.lastDecision) lines.push(` Last decision: ${s.lastDecision}`);
119
+ if (s.lastError) lines.push(` Error: ${s.lastError.slice(0, 80)}`);
120
+ return lines.join("\n");
93
121
  }
94
122
 
95
- // --- Wire dependencies -------------------------------------------------------
96
-
97
- const fsOps = { readFileSync, writeFileSync, mkdirSync };
98
- const stateManager = new StateManager(STATE_PATH, fsOps);
99
- const agentRunner = new AgentRunner(posixSpawn, stateManager, log, CACHE_DIR);
100
- const scheduler = new Scheduler(loadConfig, stateManager, agentRunner, log);
101
- const kbManager = new KBManager(
102
- {
103
- existsSync,
104
- mkdirSync,
105
- copyFileSync,
106
- cpSync,
107
- readFileSync,
108
- writeFileSync,
109
- readdirSync,
110
- },
111
- log,
112
- );
113
-
114
- // --- Template dir resolution -------------------------------------------------
115
-
116
123
  /**
117
- * Detect if running from inside a macOS .app bundle.
118
- * @returns {{ bundle: string, resources: string } | null}
124
+ * Run the Outpost CLI.
125
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
126
+ * Injected collaborator bag (constructed in the bin).
127
+ * @param {string} version - Resolved CLI version string.
128
+ * @returns {Promise<number>} Process exit code.
119
129
  */
120
- function getBundlePath() {
121
- try {
122
- const exe = process.execPath || "";
123
- const macosDir = dirname(exe);
124
- const contentsDir = dirname(macosDir);
125
- const resourcesDir = join(contentsDir, "Resources");
126
- if (existsSync(join(resourcesDir, "config"))) {
127
- return { bundle: dirname(contentsDir), resources: resourcesDir };
128
- }
129
- } catch {
130
- /* not in bundle */
131
- }
132
- return null;
133
- }
130
+ export async function run(runtime, version) {
131
+ const { fs, proc, clock } = runtime;
132
+ const logger = createLogger("outpost", runtime);
133
+
134
+ /**
135
+ * Async existence check via the one fs surface this module uses.
136
+ * @param {string} p
137
+ * @returns {Promise<boolean>}
138
+ */
139
+ const exists = (p) =>
140
+ fs.access(p).then(
141
+ () => true,
142
+ () => false,
143
+ );
134
144
 
135
- function requireTemplateDir() {
136
- const bundle = getBundlePath();
137
- if (bundle) {
138
- const tpl = join(bundle.resources, "templates");
139
- if (existsSync(tpl)) return tpl;
145
+ // --- Paths -----------------------------------------------------------------
146
+ const HOME = homedir();
147
+ const OUTPOST_HOME = join(HOME, ".fit", "outpost");
148
+ const CONFIG_PATH = join(OUTPOST_HOME, "scheduler.json");
149
+ const STATE_PATH = join(OUTPOST_HOME, "state.json");
150
+ const LOG_DIR = join(OUTPOST_HOME, "logs");
151
+ const CACHE_DIR = join(HOME, ".cache", "fit", "outpost");
152
+ const SOCKET_PATH = join(OUTPOST_HOME, "outpost.sock");
153
+ const PKG_DIR = dirname(import.meta.dirname);
154
+
155
+ // --- Logging ---------------------------------------------------------------
156
+ await fs.mkdir(LOG_DIR, { recursive: true });
157
+ function log(msg) {
158
+ const ts = isoTimestamp(clock.now());
159
+ const line = `[${ts}] ${msg}`;
160
+ logger.info(line);
161
+ void fs.appendFile(
162
+ join(LOG_DIR, `scheduler-${ts.slice(0, 10)}.log`),
163
+ line + "\n",
164
+ );
140
165
  }
141
- for (const d of [
142
- join(SHARE_DIR, "templates"),
143
- join(__dirname, "..", "templates"),
144
- ])
145
- if (existsSync(d)) return d;
146
- console.error("Template not found. Reinstall fit-outpost.");
147
- process.exit(1);
148
- }
149
-
150
- // --- Daemon ------------------------------------------------------------------
151
166
 
152
- function daemon() {
153
- const daemonStartedAt = Date.now();
154
- log("Scheduler daemon started. Polling every 60 seconds.");
155
- log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
167
+ // --- Config ----------------------------------------------------------------
168
+ async function loadConfig() {
169
+ try {
170
+ return JSON.parse(await fs.readFile(CONFIG_PATH, "utf8"));
171
+ } catch {
172
+ return { agents: {} };
173
+ }
174
+ }
156
175
 
157
- // Reset any agents left "active" from a previous daemon session.
158
- const state = stateManager.load();
159
- stateManager.resetStaleAgents(state, { reason: "Daemon restarted" }, log);
176
+ function expandPath(p) {
177
+ return p.startsWith("~/") ? join(HOME, p.slice(2)) : resolve(p);
178
+ }
160
179
 
161
- const socketServer = new SocketServer(
162
- SOCKET_PATH,
163
- scheduler,
164
- agentRunner,
180
+ // --- Wire dependencies -----------------------------------------------------
181
+ // posix-spawn is a Bun-FFI module (`bun:ffi`); importing it eagerly would
182
+ // crash plain `node` (e.g. `--help`/`--version`/golden capture). Load it
183
+ // lazily so only an actual agent wake — which only runs under Bun on macOS —
184
+ // pulls it in.
185
+ const loadSpawn = () => import("@forwardimpact/libmacos/posix-spawn");
186
+ const stateManager = new StateManager(STATE_PATH, runtime);
187
+ const agentRunner = new AgentRunner(
188
+ loadSpawn,
165
189
  stateManager,
166
- loadConfig,
167
190
  log,
168
191
  CACHE_DIR,
169
- daemonStartedAt,
192
+ runtime,
193
+ );
194
+ const scheduler = new Scheduler(
195
+ loadConfig,
196
+ stateManager,
197
+ agentRunner,
198
+ log,
199
+ runtime,
170
200
  );
171
- socketServer.start();
201
+ const kbManager = new KBManager(runtime, log);
172
202
 
173
- async function tick() {
203
+ // --- Template dir resolution -----------------------------------------------
204
+ async function getBundlePath() {
174
205
  try {
175
- await scheduler.wakeDueAgents();
176
- } catch (err) {
177
- log(`Error: ${err.message}`);
206
+ const exe = process.execPath || "";
207
+ const macosDir = dirname(exe);
208
+ const contentsDir = dirname(macosDir);
209
+ const resourcesDir = join(contentsDir, "Resources");
210
+ if (await exists(join(resourcesDir, "config"))) {
211
+ return { bundle: dirname(contentsDir), resources: resourcesDir };
212
+ }
213
+ } catch {
214
+ /* not in bundle */
178
215
  }
179
- setTimeout(tick, 60_000);
216
+ return null;
180
217
  }
181
- tick();
182
- }
183
218
 
184
- // --- Update ------------------------------------------------------------------
185
-
186
- function runUpdate(args) {
187
- if (args[0]) {
188
- kbManager.update(args[0], requireTemplateDir());
189
- return;
219
+ async function requireTemplateDir() {
220
+ const bundle = await getBundlePath();
221
+ if (bundle) {
222
+ const tpl = join(bundle.resources, "templates");
223
+ if (await exists(tpl)) return tpl;
224
+ }
225
+ for (const d of [join(SHARE_DIR, "templates"), join(PKG_DIR, "templates")])
226
+ if (await exists(d)) return d;
227
+ proc.stderr.write("Template not found. Reinstall fit-outpost.\n");
228
+ return null;
190
229
  }
191
230
 
192
- const config = loadConfig();
193
- const kbPaths = [
194
- ...new Set(
195
- Object.values(config.agents)
196
- .filter((a) => a.kb)
197
- .map((a) => expandPath(a.kb)),
198
- ),
199
- ];
231
+ // --- Daemon ----------------------------------------------------------------
232
+ async function daemon() {
233
+ const daemonStartedAt = clock.now();
234
+ log("Scheduler daemon started. Polling every 60 seconds.");
235
+ log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
236
+
237
+ // Reset any agents left "active" from a previous daemon session.
238
+ const state = await stateManager.load();
239
+ await stateManager.resetStaleAgents(
240
+ state,
241
+ { reason: "Daemon restarted" },
242
+ log,
243
+ );
200
244
 
201
- if (kbPaths.length === 0) {
202
- console.error(
203
- "No knowledge bases configured and no path given.\n" +
204
- "Usage: fit-outpost update [path]",
245
+ const socketServer = new SocketServer(
246
+ SOCKET_PATH,
247
+ scheduler,
248
+ agentRunner,
249
+ stateManager,
250
+ loadConfig,
251
+ log,
252
+ CACHE_DIR,
253
+ daemonStartedAt,
254
+ runtime,
205
255
  );
206
- process.exit(1);
207
- }
256
+ socketServer.start();
257
+
258
+ let stopped = false;
259
+ let tickHandle;
260
+ void socketServer.whenStopped().then(() => {
261
+ stopped = true;
262
+ // Cancel any pending poll so the armed timer does not keep the event
263
+ // loop alive after shutdown — `run()` returns 0 and the bin exits only
264
+ // on a nonzero code, so a lingering 60s timer would delay exit.
265
+ if (tickHandle !== undefined) clock.clearTimeout(tickHandle);
266
+ });
267
+
268
+ async function tick() {
269
+ if (stopped) return;
270
+ try {
271
+ await scheduler.wakeDueAgents();
272
+ } catch (err) {
273
+ log(`Error: ${err.message}`);
274
+ }
275
+ if (!stopped) tickHandle = clock.setTimeout(tick, 60_000);
276
+ }
277
+ void tick();
208
278
 
209
- for (const kb of kbPaths) {
210
- logger.info(`\nUpdating ${kb}...`);
211
- kbManager.update(kb, requireTemplateDir());
279
+ // Block until a shutdown is requested via socket or signal; the bin then
280
+ // owns the process-exit call.
281
+ await socketServer.whenStopped();
282
+ return 0;
212
283
  }
213
- }
214
284
 
215
- // --- Status ------------------------------------------------------------------
216
-
217
- function formatAgentStatus(name, agent, s) {
218
- const enabledMark = agent.enabled !== false ? "+" : "-";
219
- const kbStatus =
220
- agent.kb && !existsSync(expandPath(agent.kb)) ? " (not found)" : "";
221
- const lastWake = s.lastWokeAt
222
- ? new Date(s.lastWokeAt).toLocaleString()
223
- : "never";
224
- const lines = [
225
- ` ${enabledMark} ${name}`,
226
- ` KB: ${agent.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(agent.schedule)}`,
227
- ` Status: ${s.status || "never-woken"} Last wake: ${lastWake} Wakes: ${s.wakeCount || 0}`,
228
- ];
229
- if (s.lastAction) lines.push(` Last action: ${s.lastAction}`);
230
- if (s.lastDecision) lines.push(` Last decision: ${s.lastDecision}`);
231
- if (s.lastError) lines.push(` Error: ${s.lastError.slice(0, 80)}`);
232
- return lines.join("\n");
233
- }
285
+ // --- Update ----------------------------------------------------------------
286
+ async function runUpdate(args) {
287
+ const tpl = await requireTemplateDir();
288
+ if (tpl === null) return 1;
289
+
290
+ if (args[0]) {
291
+ const result = await kbManager.update(args[0], tpl);
292
+ if (!result.ok) {
293
+ proc.stderr.write(result.error + "\n");
294
+ return result.code;
295
+ }
296
+ return 0;
297
+ }
234
298
 
235
- function showStatus() {
236
- const config = loadConfig();
237
- const state = stateManager.load();
238
- logger.info("\nOutpost Scheduler\n==================\n");
299
+ const config = await loadConfig();
300
+ const kbPaths = [
301
+ ...new Set(
302
+ Object.values(config.agents)
303
+ .filter((a) => a.kb)
304
+ .map((a) => expandPath(a.kb)),
305
+ ),
306
+ ];
307
+
308
+ if (kbPaths.length === 0) {
309
+ proc.stderr.write(
310
+ "No knowledge bases configured and no path given.\n" +
311
+ "Usage: fit-outpost update [path]\n",
312
+ );
313
+ return 1;
314
+ }
239
315
 
240
- const agents = Object.entries(config.agents || {});
241
- if (agents.length === 0) {
242
- logger.info(`No agents configured.\n\nEdit ${CONFIG_PATH} to add agents.`);
243
- return;
316
+ for (const kb of kbPaths) {
317
+ logger.info(`\nUpdating ${kb}...`);
318
+ const result = await kbManager.update(kb, tpl);
319
+ if (!result.ok) {
320
+ proc.stderr.write(result.error + "\n");
321
+ return result.code;
322
+ }
323
+ }
324
+ return 0;
244
325
  }
245
326
 
246
- logger.info("Agents:");
247
- for (const [name, agent] of agents) {
248
- logger.info(formatAgentStatus(name, agent, state.agents[name] || {}));
327
+ // --- Status ----------------------------------------------------------------
328
+ async function formatAgentStatus(name, agent, s) {
329
+ const kbMissing = agent.kb ? !(await exists(expandPath(agent.kb))) : false;
330
+ return renderAgentStatus(name, agent, s, kbMissing);
249
331
  }
250
- }
251
332
 
252
- // --- Validate ----------------------------------------------------------------
333
+ async function showStatus() {
334
+ const config = await loadConfig();
335
+ const state = await stateManager.load();
336
+ logger.info("\nOutpost Scheduler\n==================\n");
253
337
 
254
- function findInLocalOrGlobal(kbPath, subPath) {
255
- const local = join(kbPath, ".claude", subPath);
256
- const global = join(HOME, ".claude", subPath);
257
- if (existsSync(local)) return local;
258
- if (existsSync(global)) return global;
259
- return null;
260
- }
338
+ const agents = Object.entries(config.agents || {});
339
+ if (agents.length === 0) {
340
+ logger.info(
341
+ `No agents configured.\n\nEdit ${CONFIG_PATH} to add agents.`,
342
+ );
343
+ return 0;
344
+ }
261
345
 
262
- /**
263
- * Validate a single agent's configuration.
264
- * @param {string} name
265
- * @param {Object} agent
266
- * @returns {boolean} true if valid
267
- */
268
- function validateAgent(name, agent) {
269
- if (!agent.kb) {
270
- logger.info(` [FAIL] ${name}: no "kb" path specified`);
271
- return false;
346
+ logger.info("Agents:");
347
+ for (const [name, agent] of agents) {
348
+ logger.info(
349
+ await formatAgentStatus(name, agent, state.agents[name] || {}),
350
+ );
351
+ }
352
+ return 0;
272
353
  }
273
- const kbPath = expandPath(agent.kb);
274
- if (!existsSync(kbPath)) {
275
- logger.info(` [FAIL] ${name}: path not found: ${kbPath}`);
276
- return false;
354
+
355
+ // --- Validate --------------------------------------------------------------
356
+ async function findInLocalOrGlobal(kbPath, subPath) {
357
+ const local = join(kbPath, ".claude", subPath);
358
+ const global = join(HOME, ".claude", subPath);
359
+ if (await exists(local)) return local;
360
+ if (await exists(global)) return global;
361
+ return null;
277
362
  }
278
363
 
279
- const agentFile = join("agents", name + ".md");
280
- const found = findInLocalOrGlobal(kbPath, agentFile);
281
- logger.info(
282
- ` [${found ? "OK" : "FAIL"}] ${name}: agent definition${found ? "" : " not found"}`,
283
- );
284
- return !!found;
285
- }
364
+ async function validateAgent(name, agent) {
365
+ if (!agent.kb) {
366
+ logger.info(` [FAIL] ${name}: no "kb" path specified`);
367
+ return false;
368
+ }
369
+ const kbPath = expandPath(agent.kb);
370
+ if (!(await exists(kbPath))) {
371
+ logger.info(` [FAIL] ${name}: path not found: ${kbPath}`);
372
+ return false;
373
+ }
286
374
 
287
- function validate() {
288
- const config = loadConfig();
289
- const agents = Object.entries(config.agents || {});
290
- if (agents.length === 0) {
291
- logger.info("No agents configured. Nothing to validate.");
292
- return;
375
+ const agentFile = join("agents", name + ".md");
376
+ const found = await findInLocalOrGlobal(kbPath, agentFile);
377
+ logger.info(
378
+ ` [${found ? "OK" : "FAIL"}] ${name}: agent definition${found ? "" : " not found"}`,
379
+ );
380
+ return !!found;
293
381
  }
294
382
 
295
- logger.info("\nValidating agents...\n");
296
- let errors = 0;
383
+ async function validate() {
384
+ const config = await loadConfig();
385
+ const agents = Object.entries(config.agents || {});
386
+ if (agents.length === 0) {
387
+ logger.info("No agents configured. Nothing to validate.");
388
+ return 0;
389
+ }
297
390
 
298
- for (const [name, agent] of agents) {
299
- if (!validateAgent(name, agent)) errors++;
300
- }
391
+ logger.info("\nValidating agents...\n");
392
+ let errors = 0;
301
393
 
302
- logger.info(errors > 0 ? `\n${errors} error(s).` : "\nAll OK.");
303
- if (errors > 0) process.exit(1);
304
- }
394
+ for (const [name, agent] of agents) {
395
+ if (!(await validateAgent(name, agent))) errors++;
396
+ }
305
397
 
306
- // --- CLI definition ----------------------------------------------------------
307
-
308
- const definition = {
309
- name: "fit-outpost",
310
- version: VERSION,
311
- description: "Schedule autonomous agents across knowledge bases",
312
- commands: [
313
- { name: "daemon", description: "Run continuously (poll every 60s)" },
314
- {
315
- name: "wake",
316
- args: "<agent>",
317
- description: "Wake a specific agent immediately",
318
- },
319
- {
320
- name: "init",
321
- args: "<path>",
322
- description: "Initialize a new knowledge base",
323
- },
324
- {
325
- name: "update",
326
- args: "[path]",
327
- description: "Update KB with latest CLAUDE.md, agents and skills",
328
- },
329
- {
330
- name: "stop",
331
- description: "Gracefully stop daemon and all running agents",
332
- },
333
- { name: "validate", description: "Validate agent definitions exist" },
334
- { name: "status", description: "Show agent status" },
335
- ],
336
- globalOptions: {
337
- help: { type: "boolean", short: "h", description: "Show this help" },
338
- version: { type: "boolean", description: "Show version" },
339
- json: { type: "boolean", description: "JSON output (with --help)" },
340
- },
341
- documentation: [
342
- {
343
- title: "Outpost Overview",
344
- url: "https://www.forwardimpact.team/outpost/index.md",
345
- description: "Product overview, audience model, and key concepts.",
346
- },
347
- {
348
- title: "Getting Started: Outpost for Engineers",
349
- url: "https://www.forwardimpact.team/docs/getting-started/engineers/outpost/index.md",
350
- description: "From zero to your first daily briefing.",
398
+ logger.info(errors > 0 ? `\n${errors} error(s).` : "\nAll OK.");
399
+ return errors > 0 ? 1 : 0;
400
+ }
401
+
402
+ // --- CLI entry point -------------------------------------------------------
403
+ const cli = createCli(buildDefinition(version), { runtime });
404
+ const parsed = cli.parse(proc.argv.slice(2));
405
+ if (!parsed) return 0;
406
+
407
+ const { positionals } = parsed;
408
+ const [command, ...args] = positionals;
409
+
410
+ await fs.mkdir(OUTPOST_HOME, { recursive: true });
411
+
412
+ const COMMANDS = {
413
+ daemon,
414
+ wake: async () => {
415
+ if (!args[0]) {
416
+ cli.usageError("missing required argument <agent>");
417
+ return 2;
418
+ }
419
+ const config = await loadConfig();
420
+ const state = await stateManager.load();
421
+ const agent = config.agents[args[0]];
422
+ if (!agent) {
423
+ cli.error(
424
+ `agent "${args[0]}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
425
+ );
426
+ return 1;
427
+ }
428
+ await agentRunner.wake(args[0], agent, state);
429
+ return 0;
351
430
  },
352
- {
353
- title: "Keep Track of Context Without Effort",
354
- url: "https://www.forwardimpact.team/docs/products/knowledge-systems/index.md",
355
- description:
356
- "Maintain continuous awareness of people, projects, and threads.",
431
+ init: async () => {
432
+ if (!args[0]) {
433
+ cli.usageError("missing required argument <path>");
434
+ return 2;
435
+ }
436
+ const tpl = await requireTemplateDir();
437
+ if (tpl === null) return 1;
438
+ const result = await kbManager.init(args[0], tpl);
439
+ if (!result.ok) {
440
+ proc.stderr.write(result.error + "\n");
441
+ return result.code;
442
+ }
443
+ return 0;
357
444
  },
358
- {
359
- title: "Walk Into Every Meeting Already Oriented",
360
- url: "https://www.forwardimpact.team/docs/products/knowledge-systems/meeting-prep/index.md",
361
- description: "Assemble context so you arrive prepared.",
445
+ update: () => runUpdate(args),
446
+ stop: async () => {
447
+ const stopped = await requestShutdown(SOCKET_PATH, runtime);
448
+ return stopped ? 0 : 1;
362
449
  },
363
- ],
364
- };
365
-
366
- // --- CLI entry point ---------------------------------------------------------
367
-
368
- const cli = createCli(definition);
369
- const parsed = cli.parse(process.argv.slice(2));
370
- if (!parsed) process.exit(0);
371
-
372
- const { positionals } = parsed;
373
- const [command, ...args] = positionals;
450
+ validate,
451
+ status: showStatus,
452
+ };
374
453
 
375
- mkdirSync(OUTPOST_HOME, { recursive: true });
454
+ const handler = COMMANDS[command];
455
+ if (command && !handler) {
456
+ cli.usageError(`unknown command "${command}"`);
457
+ return 2;
458
+ }
376
459
 
377
- const COMMANDS = {
378
- daemon,
379
- wake: async () => {
380
- if (!args[0]) {
381
- cli.usageError("missing required argument <agent>");
382
- process.exit(2);
383
- }
384
- const config = loadConfig();
385
- const state = stateManager.load();
386
- const agent = config.agents[args[0]];
387
- if (!agent) {
388
- cli.error(
389
- `agent "${args[0]}" not found. Available: ${Object.keys(config.agents).join(", ") || "(none)"}`,
390
- );
391
- process.exit(1);
392
- }
393
- await agentRunner.wake(args[0], agent, state);
394
- },
395
- init: () => {
396
- if (!args[0]) {
397
- cli.usageError("missing required argument <path>");
398
- process.exit(2);
399
- }
400
- kbManager.init(args[0], requireTemplateDir());
401
- },
402
- update: () => runUpdate(args),
403
- stop: async () => {
404
- const stopped = await requestShutdown(SOCKET_PATH);
405
- if (!stopped) process.exit(1);
406
- },
407
- validate,
408
- status: showStatus,
409
- };
410
-
411
- const handler = COMMANDS[command];
412
- if (command && !handler) {
413
- cli.usageError(`unknown command "${command}"`);
414
- process.exit(2);
460
+ return (await (handler || (() => scheduler.wakeDueAgents()))()) ?? 0;
415
461
  }
416
-
417
- await (handler || (() => scheduler.wakeDueAgents()))();