@forwardimpact/outpost 3.1.2 → 3.1.4

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