@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/bin/fit-outpost.js +14 -2
- package/package.json +9 -3
- package/src/agent-runner.js +66 -19
- package/src/kb-manager.js +88 -85
- package/src/outpost.js +386 -342
- package/src/scheduler.js +47 -8
- package/src/socket-server.js +77 -47
- package/src/state-manager.js +32 -35
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
118
|
-
* @
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
176
|
+
function expandPath(p) {
|
|
177
|
+
return p.startsWith("~/") ? join(HOME, p.slice(2)) : resolve(p);
|
|
178
|
+
}
|
|
160
179
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
192
|
+
runtime,
|
|
193
|
+
);
|
|
194
|
+
const scheduler = new Scheduler(
|
|
195
|
+
loadConfig,
|
|
196
|
+
stateManager,
|
|
197
|
+
agentRunner,
|
|
198
|
+
log,
|
|
199
|
+
runtime,
|
|
170
200
|
);
|
|
171
|
-
|
|
201
|
+
const kbManager = new KBManager(runtime, log);
|
|
172
202
|
|
|
173
|
-
|
|
203
|
+
// --- Template dir resolution -----------------------------------------------
|
|
204
|
+
async function getBundlePath() {
|
|
174
205
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
216
|
+
return null;
|
|
180
217
|
}
|
|
181
|
-
tick();
|
|
182
|
-
}
|
|
183
218
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
// ---
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
391
|
+
logger.info("\nValidating agents...\n");
|
|
392
|
+
let errors = 0;
|
|
301
393
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
394
|
+
for (const [name, agent] of agents) {
|
|
395
|
+
if (!(await validateAgent(name, agent))) errors++;
|
|
396
|
+
}
|
|
305
397
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
454
|
+
const handler = COMMANDS[command];
|
|
455
|
+
if (command && !handler) {
|
|
456
|
+
cli.usageError(`unknown command "${command}"`);
|
|
457
|
+
return 2;
|
|
458
|
+
}
|
|
376
459
|
|
|
377
|
-
|
|
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()))();
|