@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.
@@ -1,5 +1,17 @@
1
1
  #!/usr/bin/env node
2
- // Thin entry point — delegates to src/outpost.js.
2
+ // Thin entry point — the sole construction site for the runtime collaborator
3
+ // bag, threaded into src/outpost.js's dispatch via run(runtime, version).
3
4
  import "@forwardimpact/libpreflight/node22";
4
5
 
5
- import "../src/outpost.js";
6
+ import { resolveVersion } from "@forwardimpact/libcli";
7
+ import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
8
+
9
+ import { run } from "../src/outpost.js";
10
+
11
+ const runtime = createDefaultRuntime();
12
+ const version = resolveVersion({
13
+ packageJsonUrl: new URL("../package.json", import.meta.url),
14
+ runtime,
15
+ });
16
+ const code = await run(runtime, version);
17
+ if (code) runtime.proc.exit(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/outpost",
3
- "version": "3.1.3",
3
+ "version": "3.2.0",
4
4
  "description": "Personal operations center — context from email, calendar, and knowledge assembled so preparation is continuous, not a morning scramble.",
5
5
  "homepage": "https://www.forwardimpact.team",
6
6
  "repository": {
@@ -50,17 +50,23 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@forwardimpact/libcli": "^0.1.0",
53
- "@forwardimpact/libmacos": "^0.1.0",
54
53
  "@forwardimpact/libpreflight": "^0.1.0",
55
- "@forwardimpact/libtelemetry": "^0.1.33"
54
+ "@forwardimpact/libtelemetry": "^0.1.33",
55
+ "@forwardimpact/libutil": "^0.1.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@forwardimpact/libmock": "^0.1.0"
59
59
  },
60
+ "optionalDependencies": {
61
+ "@forwardimpact/libmacos": "^0.1.0"
62
+ },
60
63
  "engines": {
61
64
  "bun": ">=1.2.0",
62
65
  "node": ">=22.0.0"
63
66
  },
67
+ "os": [
68
+ "darwin"
69
+ ],
64
70
  "publishConfig": {
65
71
  "access": "public"
66
72
  }
@@ -2,9 +2,9 @@
2
2
  * AgentRunner — spawn agent process, capture output, update state.
3
3
  */
4
4
 
5
- import { existsSync } from "node:fs";
6
5
  import { resolve, join } from "node:path";
7
6
  import { homedir } from "node:os";
7
+ import { isoTimestamp } from "@forwardimpact/libutil";
8
8
 
9
9
  /** Spawn agent CLI processes, capture their output, and update agent state. */
10
10
  export class AgentRunner {
@@ -13,22 +13,38 @@ export class AgentRunner {
13
13
  #log;
14
14
  #activeChildren;
15
15
  #cacheDir;
16
+ #fs;
17
+ #proc;
18
+ #clock;
19
+ #runtime;
16
20
 
17
21
  /**
18
- * @param {Object} spawn - posix-spawn module
22
+ * @param {Object | (() => Object | Promise<{default?: Object}>)} spawn -
23
+ * The posix-spawn module, or a (possibly async) loader returning it. A
24
+ * loader lets the Bun-FFI module load lazily so plain `node` invocations
25
+ * that never wake an agent don't pull in `bun:ffi`.
19
26
  * @param {import('./state-manager.js').StateManager} stateManager
20
27
  * @param {Function} logFn - Logging function
21
28
  * @param {string} cacheDir - Cache directory for state files
29
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
30
+ * Injected runtime bag (uses `fs` (async), `proc`, `clock`).
22
31
  */
23
- constructor(spawn, stateManager, logFn, cacheDir) {
32
+ constructor(spawn, stateManager, logFn, cacheDir, runtime) {
24
33
  if (!spawn) throw new Error("spawn is required");
25
34
  if (!stateManager) throw new Error("stateManager is required");
26
35
  if (!logFn) throw new Error("logFn is required");
27
36
  if (!cacheDir) throw new Error("cacheDir is required");
37
+ if (!runtime?.fs) throw new Error("runtime.fs is required");
38
+ if (!runtime?.proc) throw new Error("runtime.proc is required");
39
+ if (!runtime?.clock) throw new Error("runtime.clock is required");
28
40
  this.#spawn = spawn;
29
41
  this.#stateManager = stateManager;
30
42
  this.#log = logFn;
31
43
  this.#cacheDir = cacheDir;
44
+ this.#fs = runtime.fs;
45
+ this.#proc = runtime.proc;
46
+ this.#clock = runtime.clock;
47
+ this.#runtime = runtime;
32
48
  this.#activeChildren = new Set();
33
49
  }
34
50
 
@@ -37,11 +53,36 @@ export class AgentRunner {
37
53
  return this.#activeChildren;
38
54
  }
39
55
 
56
+ /**
57
+ * Resolve the injected spawn collaborator to the posix-spawn module,
58
+ * invoking and unwrapping a loader thunk on first use.
59
+ * @returns {Promise<Object>}
60
+ */
61
+ async #resolveSpawn() {
62
+ if (typeof this.#spawn === "function" && !this.#spawn.spawn) {
63
+ const loaded = await this.#spawn();
64
+ this.#spawn = loaded?.spawn ? loaded : (loaded?.default ?? loaded);
65
+ }
66
+ return this.#spawn;
67
+ }
68
+
69
+ /**
70
+ * Test whether a path exists, via the async fs surface.
71
+ * @param {string} p
72
+ * @returns {Promise<boolean>}
73
+ */
74
+ async #exists(p) {
75
+ return this.#fs.access(p).then(
76
+ () => true,
77
+ () => false,
78
+ );
79
+ }
80
+
40
81
  /**
41
82
  * Find the claude CLI binary
42
- * @returns {string}
83
+ * @returns {Promise<string>}
43
84
  */
44
- #findClaude() {
85
+ async #findClaude() {
45
86
  const HOME = homedir();
46
87
  const paths = [
47
88
  "/usr/local/bin/claude",
@@ -49,7 +90,7 @@ export class AgentRunner {
49
90
  join(HOME, ".local", "bin", "claude"),
50
91
  "/opt/homebrew/bin/claude",
51
92
  ];
52
- for (const p of paths) if (existsSync(p)) return p;
93
+ for (const p of paths) if (await this.#exists(p)) return p;
53
94
  return "claude";
54
95
  }
55
96
 
@@ -71,7 +112,7 @@ export class AgentRunner {
71
112
  Object.assign(agentState, {
72
113
  status: "failed",
73
114
  startedAt: null,
74
- lastWokeAt: new Date().toISOString(),
115
+ lastWokeAt: isoTimestamp(this.#clock.now()),
75
116
  lastError: String(error).slice(0, 500),
76
117
  });
77
118
  }
@@ -84,7 +125,7 @@ export class AgentRunner {
84
125
  * @returns {Record<string, string>}
85
126
  */
86
127
  #buildSpawnEnv(configEnv) {
87
- const env = { ...process.env };
128
+ const env = { ...this.#proc.env };
88
129
  if (configEnv) {
89
130
  const home = homedir();
90
131
  for (const [key, value] of Object.entries(configEnv)) {
@@ -108,21 +149,21 @@ export class AgentRunner {
108
149
  return;
109
150
  }
110
151
  const kbPath = this.#expandPath(agent.kb);
111
- if (!existsSync(kbPath)) {
152
+ if (!(await this.#exists(kbPath))) {
112
153
  this.#log(
113
154
  `Agent ${agentName}: path "${kbPath}" does not exist, skipping.`,
114
155
  );
115
156
  return;
116
157
  }
117
158
 
118
- const claude = this.#findClaude();
159
+ const claude = await this.#findClaude();
119
160
 
120
161
  this.#log(`Waking agent: ${agentName} (kb: ${agent.kb})`);
121
162
 
122
163
  const as = (state.agents[agentName] ||= {});
123
164
  as.status = "active";
124
- as.startedAt = new Date().toISOString();
125
- this.#stateManager.save(state);
165
+ as.startedAt = isoTimestamp(this.#clock.now());
166
+ await this.#stateManager.save(state);
126
167
 
127
168
  const spawnArgs = [
128
169
  "--chrome",
@@ -134,27 +175,33 @@ export class AgentRunner {
134
175
  ];
135
176
 
136
177
  const env = this.#buildSpawnEnv(configEnv);
178
+ const spawnMod = await this.#resolveSpawn();
137
179
 
138
180
  try {
139
- const { pid, stdoutFile, stderrFile } = this.#spawn.spawn(
181
+ const { pid, stdoutFile, stderrFile } = spawnMod.spawn(
140
182
  claude,
141
183
  spawnArgs,
142
184
  env,
143
185
  kbPath,
186
+ this.#runtime,
144
187
  );
145
188
  this.#activeChildren.add(pid);
146
189
 
147
- const exitCode = await this.#spawn.waitForExit(pid);
190
+ const exitCode = await spawnMod.waitForExit(
191
+ pid,
192
+ undefined,
193
+ this.#runtime,
194
+ );
148
195
  this.#activeChildren.delete(pid);
149
196
 
150
- const stdout = this.#spawn.readOutput(stdoutFile);
151
- const stderr = this.#spawn.readOutput(stderrFile);
197
+ const stdout = spawnMod.readOutput(stdoutFile, this.#runtime);
198
+ const stderr = spawnMod.readOutput(stderrFile, this.#runtime);
152
199
 
153
200
  if (exitCode === 0) {
154
201
  this.#log(
155
202
  `Agent ${agentName} completed. Output: ${stdout.slice(0, 200)}...`,
156
203
  );
157
- this.#stateManager.updateAgentState(
204
+ await this.#stateManager.updateAgentState(
158
205
  as,
159
206
  stdout,
160
207
  agentName,
@@ -169,7 +216,7 @@ export class AgentRunner {
169
216
  this.#log(`Agent ${agentName} failed: ${err.message}`);
170
217
  this.#failAgent(as, err.message);
171
218
  }
172
- this.#stateManager.save(state);
219
+ await this.#stateManager.save(state);
173
220
  }
174
221
 
175
222
  /**
@@ -178,7 +225,7 @@ export class AgentRunner {
178
225
  killActiveChildren() {
179
226
  for (const pid of this.#activeChildren) {
180
227
  try {
181
- process.kill(pid, "SIGTERM");
228
+ this.#proc.kill(pid, "SIGTERM");
182
229
  this.#log(`Sent SIGTERM to child PID ${pid}`);
183
230
  } catch {
184
231
  // Already exited
package/src/kb-manager.js CHANGED
@@ -2,50 +2,55 @@
2
2
  * KBManager — knowledge base init/update operations.
3
3
  */
4
4
 
5
- import {
6
- existsSync,
7
- mkdirSync,
8
- copyFileSync,
9
- cpSync,
10
- readFileSync,
11
- writeFileSync,
12
- readdirSync,
13
- } from "node:fs";
14
5
  import { join, dirname, resolve } from "node:path";
15
6
  import { homedir } from "node:os";
16
7
  import { createLogger } from "@forwardimpact/libtelemetry";
17
8
 
18
- const logger = createLogger("outpost");
19
-
20
9
  /** Manage knowledge base lifecycle including initialization, updates, and settings merging. */
21
10
  export class KBManager {
22
11
  #fs;
12
+ #logger;
23
13
 
24
14
  /**
25
- * @param {{ existsSync: Function, mkdirSync: Function, copyFileSync: Function, cpSync: Function, readFileSync: Function, writeFileSync: Function, readdirSync: Function }} fs
15
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
16
+ * Injected runtime bag (uses `fs` (async)).
26
17
  * @param {Function} logFn
27
18
  */
28
- constructor(fs, logFn) {
29
- if (!fs) throw new Error("fs is required");
19
+ constructor(runtime, logFn) {
20
+ if (!runtime?.fs) throw new Error("runtime.fs is required");
30
21
  if (!logFn) throw new Error("logFn is required");
31
- this.#fs = fs;
22
+ this.#fs = runtime.fs;
23
+ this.#logger = createLogger("outpost", runtime);
24
+ }
25
+
26
+ /**
27
+ * Test whether a path exists, via the async fs surface.
28
+ * @param {string} p
29
+ * @returns {Promise<boolean>}
30
+ */
31
+ async #exists(p) {
32
+ return this.#fs.access(p).then(
33
+ () => true,
34
+ () => false,
35
+ );
32
36
  }
33
37
 
34
38
  /**
35
39
  * @param {string} dir
40
+ * @returns {Promise<void>}
36
41
  */
37
- #ensureDir(dir) {
38
- this.#fs.mkdirSync(dir, { recursive: true });
42
+ async #ensureDir(dir) {
43
+ await this.#fs.mkdir(dir, { recursive: true });
39
44
  }
40
45
 
41
46
  /**
42
47
  * @param {string} path
43
48
  * @param {*} fallback
44
- * @returns {*}
49
+ * @returns {Promise<*>}
45
50
  */
46
- #readJSON(path, fallback) {
51
+ async #readJSON(path, fallback) {
47
52
  try {
48
- return JSON.parse(this.#fs.readFileSync(path, "utf8"));
53
+ return JSON.parse(await this.#fs.readFile(path, "utf8"));
49
54
  } catch {
50
55
  return fallback;
51
56
  }
@@ -54,42 +59,46 @@ export class KBManager {
54
59
  /**
55
60
  * @param {string} path
56
61
  * @param {*} data
62
+ * @returns {Promise<void>}
57
63
  */
58
- #writeJSON(path, data) {
59
- this.#ensureDir(dirname(path));
60
- this.#fs.writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
64
+ async #writeJSON(path, data) {
65
+ await this.#ensureDir(dirname(path));
66
+ await this.#fs.writeFile(path, JSON.stringify(data, null, 2) + "\n");
61
67
  }
62
68
 
63
69
  /**
64
70
  * Copy bundled files (CLAUDE.md, skills, agents) from template to a KB.
65
71
  * @param {string} tpl - Path to the template directory
66
72
  * @param {string} dest - Path to the target knowledge base
73
+ * @returns {Promise<void>}
67
74
  */
68
- copyBundledFiles(tpl, dest) {
69
- this.#fs.copyFileSync(join(tpl, "CLAUDE.md"), join(dest, "CLAUDE.md"));
70
- logger.info(` Updated CLAUDE.md`);
75
+ async copyBundledFiles(tpl, dest) {
76
+ await this.#fs.copyFile(join(tpl, "CLAUDE.md"), join(dest, "CLAUDE.md"));
77
+ this.#logger.info(` Updated CLAUDE.md`);
71
78
 
72
79
  const apmSrc = join(tpl, "apm.yml");
73
- if (this.#fs.existsSync(apmSrc)) {
74
- this.#fs.copyFileSync(apmSrc, join(dest, "apm.yml"));
75
- logger.info(` Updated apm.yml`);
80
+ if (await this.#exists(apmSrc)) {
81
+ await this.#fs.copyFile(apmSrc, join(dest, "apm.yml"));
82
+ this.#logger.info(` Updated apm.yml`);
76
83
  }
77
84
 
78
- this.mergeSettings(tpl, dest);
85
+ await this.mergeSettings(tpl, dest);
79
86
 
80
87
  for (const sub of ["skills", "agents"]) {
81
88
  const src = join(tpl, ".claude", sub);
82
- if (!this.#fs.existsSync(src)) continue;
83
- this.#fs.cpSync(src, join(dest, ".claude", sub), { recursive: true });
84
- const entries = this.#fs
85
- .readdirSync(src, { withFileTypes: true })
86
- .filter((d) =>
87
- sub === "skills" ? d.isDirectory() : d.name.endsWith(".md"),
88
- );
89
+ if (!(await this.#exists(src))) continue;
90
+ await this.#fs.cp(src, join(dest, ".claude", sub), { recursive: true });
91
+ const entries = (
92
+ await this.#fs.readdir(src, { withFileTypes: true })
93
+ ).filter((d) =>
94
+ sub === "skills" ? d.isDirectory() : d.name.endsWith(".md"),
95
+ );
89
96
  const names = entries.map((d) =>
90
97
  sub === "agents" ? d.name.replace(".md", "") : d.name,
91
98
  );
92
- logger.info(` Updated ${names.length} ${sub}: ${names.join(", ")}`);
99
+ this.#logger.info(
100
+ ` Updated ${names.length} ${sub}: ${names.join(", ")}`,
101
+ );
93
102
  }
94
103
  }
95
104
 
@@ -123,48 +132,53 @@ export class KBManager {
123
132
  * Merge template settings.json into the destination's settings.json.
124
133
  * @param {string} tpl - Template directory
125
134
  * @param {string} dest - Knowledge base directory
135
+ * @returns {Promise<void>}
126
136
  */
127
- mergeSettings(tpl, dest) {
137
+ async mergeSettings(tpl, dest) {
128
138
  const src = join(tpl, ".claude", "settings.json");
129
- if (!this.#fs.existsSync(src)) return;
139
+ if (!(await this.#exists(src))) return;
130
140
 
131
141
  const destPath = join(dest, ".claude", "settings.json");
132
142
 
133
- if (!this.#fs.existsSync(destPath)) {
134
- this.#ensureDir(join(dest, ".claude"));
135
- this.#fs.copyFileSync(src, destPath);
136
- logger.info(` Created settings.json`);
143
+ if (!(await this.#exists(destPath))) {
144
+ await this.#ensureDir(join(dest, ".claude"));
145
+ await this.#fs.copyFile(src, destPath);
146
+ this.#logger.info(` Created settings.json`);
137
147
  return;
138
148
  }
139
149
 
140
- const template = this.#readJSON(src, {});
141
- const existing = this.#readJSON(destPath, {});
150
+ const template = await this.#readJSON(src, {});
151
+ const existing = await this.#readJSON(destPath, {});
142
152
  const added = this.#mergePermissionLists(
143
153
  template.permissions || {},
144
154
  (existing.permissions ||= {}),
145
155
  );
146
156
 
147
157
  if (added > 0) {
148
- this.#writeJSON(destPath, existing);
149
- logger.info(` Updated settings.json (${added} new entries)`);
158
+ await this.#writeJSON(destPath, existing);
159
+ this.#logger.info(` Updated settings.json (${added} new entries)`);
150
160
  } else {
151
- logger.info(` Settings up to date`);
161
+ this.#logger.info(` Settings up to date`);
152
162
  }
153
163
  }
154
164
 
155
165
  /**
156
- * Initialize a new knowledge base
166
+ * Initialize a new knowledge base.
157
167
  * @param {string} targetPath
158
168
  * @param {string} templateDir
169
+ * @returns {Promise<{ok: true, value: {dest: string}} | {ok: false, code: number, error: string}>}
159
170
  */
160
- init(targetPath, templateDir) {
171
+ async init(targetPath, templateDir) {
161
172
  const dest = this.#expandPath(targetPath);
162
- if (this.#fs.existsSync(join(dest, "CLAUDE.md"))) {
163
- console.error(`Knowledge base already exists at ${dest}`);
164
- process.exit(1);
173
+ if (await this.#exists(join(dest, "CLAUDE.md"))) {
174
+ return {
175
+ ok: false,
176
+ code: 1,
177
+ error: `Knowledge base already exists at ${dest}`,
178
+ };
165
179
  }
166
180
 
167
- this.#ensureDir(dest);
181
+ await this.#ensureDir(dest);
168
182
  for (const d of [
169
183
  "knowledge/People",
170
184
  "knowledge/Organizations",
@@ -172,30 +186,39 @@ export class KBManager {
172
186
  "knowledge/Topics",
173
187
  "knowledge/Briefings",
174
188
  ])
175
- this.#ensureDir(join(dest, d));
189
+ await this.#ensureDir(join(dest, d));
176
190
 
177
- this.#fs.copyFileSync(join(templateDir, "USER.md"), join(dest, "USER.md"));
191
+ await this.#fs.copyFile(
192
+ join(templateDir, "USER.md"),
193
+ join(dest, "USER.md"),
194
+ );
178
195
 
179
- this.copyBundledFiles(templateDir, dest);
196
+ await this.copyBundledFiles(templateDir, dest);
180
197
 
181
- logger.info(
198
+ this.#logger.info(
182
199
  `Knowledge base initialized at ${dest}\n\nNext steps:\n 1. Edit ${dest}/USER.md with your name, email, and domain\n 2. cd ${dest} && npx apm install\n 3. claude`,
183
200
  );
201
+ return { ok: true, value: { dest } };
184
202
  }
185
203
 
186
204
  /**
187
205
  * Update an existing knowledge base with the latest bundled files.
188
206
  * @param {string} targetPath
189
207
  * @param {string} templateDir
208
+ * @returns {Promise<{ok: true, value: {dest: string}} | {ok: false, code: number, error: string}>}
190
209
  */
191
- update(targetPath, templateDir) {
210
+ async update(targetPath, templateDir) {
192
211
  const dest = this.#expandPath(targetPath);
193
- if (!this.#fs.existsSync(join(dest, "CLAUDE.md"))) {
194
- console.error(`No knowledge base found at ${dest}`);
195
- process.exit(1);
212
+ if (!(await this.#exists(join(dest, "CLAUDE.md")))) {
213
+ return {
214
+ ok: false,
215
+ code: 1,
216
+ error: `No knowledge base found at ${dest}`,
217
+ };
196
218
  }
197
- this.copyBundledFiles(templateDir, dest);
198
- logger.info(`\nKnowledge base updated: ${dest}`);
219
+ await this.copyBundledFiles(templateDir, dest);
220
+ this.#logger.info(`\nKnowledge base updated: ${dest}`);
221
+ return { ok: true, value: { dest } };
199
222
  }
200
223
 
201
224
  /**
@@ -206,23 +229,3 @@ export class KBManager {
206
229
  return p.startsWith("~/") ? join(homedir(), p.slice(2)) : resolve(p);
207
230
  }
208
231
  }
209
-
210
- /**
211
- * Create a KBManager with real fs dependencies
212
- * @param {Function} logFn
213
- * @returns {KBManager}
214
- */
215
- export function createKBManager(logFn) {
216
- return new KBManager(
217
- {
218
- existsSync,
219
- mkdirSync,
220
- copyFileSync,
221
- cpSync,
222
- readFileSync,
223
- writeFileSync,
224
- readdirSync,
225
- },
226
- logFn,
227
- );
228
- }