@forwardimpact/outpost 3.0.3
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/LICENSE +201 -0
- package/README.md +15 -0
- package/bin/fit-outpost.js +3 -0
- package/config/scheduler.json +34 -0
- package/package.json +63 -0
- package/src/agent-runner.js +189 -0
- package/src/index.js +5 -0
- package/src/kb-manager.js +228 -0
- package/src/outpost.js +417 -0
- package/src/scheduler.js +195 -0
- package/src/socket-server.js +336 -0
- package/src/state-manager.js +126 -0
- package/templates/.claude/agents/chief-of-staff.md +58 -0
- package/templates/.claude/agents/concierge.md +46 -0
- package/templates/.claude/agents/head-hunter.md +57 -0
- package/templates/.claude/agents/librarian.md +60 -0
- package/templates/.claude/agents/postman.md +47 -0
- package/templates/.claude/agents/recruiter.md +63 -0
- package/templates/.claude/settings.json +88 -0
- package/templates/.claude/skills/candidate-report/SKILL.md +139 -0
- package/templates/.claude/skills/candidate-report/references/report-template.html +203 -0
- package/templates/.claude/skills/candidate-report/references/report.css +438 -0
- package/templates/.claude/skills/candidate-report/references/rubric.md +76 -0
- package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +48 -0
- package/templates/.claude/skills/deck-create/SKILL.md +75 -0
- package/templates/.claude/skills/deck-create/references/slide.css +35 -0
- package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +47 -0
- package/templates/.claude/skills/deck-summarize/SKILL.md +139 -0
- package/templates/.claude/skills/deck-summarize/references/brief-template.md +85 -0
- package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +210 -0
- package/templates/.claude/skills/doc-collab/SKILL.md +112 -0
- package/templates/.claude/skills/doc-create/SKILL.md +105 -0
- package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +50 -0
- package/templates/.claude/skills/draft-emails/SKILL.md +141 -0
- package/templates/.claude/skills/draft-emails/references/template.md +26 -0
- package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
- package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +139 -0
- package/templates/.claude/skills/extract-entities/SKILL.md +187 -0
- package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +14 -0
- package/templates/.claude/skills/extract-entities/references/conditions.md +55 -0
- package/templates/.claude/skills/extract-entities/references/content.md +104 -0
- package/templates/.claude/skills/extract-entities/references/links.md +48 -0
- package/templates/.claude/skills/extract-entities/references/recruitment.md +57 -0
- package/templates/.claude/skills/extract-entities/references/resolution.md +71 -0
- package/templates/.claude/skills/extract-entities/references/sources.md +70 -0
- package/templates/.claude/skills/extract-entities/references/templates-conditions.md +36 -0
- package/templates/.claude/skills/extract-entities/references/templates-goals-priorities.md +63 -0
- package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +67 -0
- package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +68 -0
- package/templates/.claude/skills/extract-entities/scripts/state.mjs +131 -0
- package/templates/.claude/skills/hyprnote-follow/SKILL.md +174 -0
- package/templates/.claude/skills/hyprnote-follow/references/coaching.md +118 -0
- package/templates/.claude/skills/hyprnote-follow/references/debrief.md +59 -0
- package/templates/.claude/skills/hyprnote-follow/references/meeting-types.md +14 -0
- package/templates/.claude/skills/hyprnote-follow/scripts/follow.mjs +243 -0
- package/templates/.claude/skills/hyprnote-process/SKILL.md +147 -0
- package/templates/.claude/skills/hyprnote-process/references/extraction.md +84 -0
- package/templates/.claude/skills/hyprnote-process/references/sessions.md +81 -0
- package/templates/.claude/skills/hyprnote-process/scripts/scan.mjs +246 -0
- package/templates/.claude/skills/hyprnote-trim/SKILL.md +177 -0
- package/templates/.claude/skills/manage-tasks/SKILL.md +152 -0
- package/templates/.claude/skills/manage-tasks/references/format.md +73 -0
- package/templates/.claude/skills/meeting-prep/SKILL.md +180 -0
- package/templates/.claude/skills/organize-files/SKILL.md +146 -0
- package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
- package/templates/.claude/skills/organize-files/scripts/summarize.mjs +91 -0
- package/templates/.claude/skills/req-assess/SKILL.md +137 -0
- package/templates/.claude/skills/req-assess/references/interview-template.md +62 -0
- package/templates/.claude/skills/req-assess/references/panel-template.md +117 -0
- package/templates/.claude/skills/req-assess/references/rubric.md +65 -0
- package/templates/.claude/skills/req-decide/SKILL.md +163 -0
- package/templates/.claude/skills/req-decide/references/rubric.md +63 -0
- package/templates/.claude/skills/req-decide/references/template.md +118 -0
- package/templates/.claude/skills/req-forget/SKILL.md +154 -0
- package/templates/.claude/skills/req-forget/references/classify.md +23 -0
- package/templates/.claude/skills/req-forget/references/locations.md +59 -0
- package/templates/.claude/skills/req-forget/references/report-template.md +67 -0
- package/templates/.claude/skills/req-scan/SKILL.md +145 -0
- package/templates/.claude/skills/req-scan/references/fallbacks.md +76 -0
- package/templates/.claude/skills/req-scan/references/filters.md +83 -0
- package/templates/.claude/skills/req-scan/references/sources.md +76 -0
- package/templates/.claude/skills/req-scan/references/state.md +54 -0
- package/templates/.claude/skills/req-scan/references/template.md +36 -0
- package/templates/.claude/skills/req-scan/scripts/state.mjs +396 -0
- package/templates/.claude/skills/req-screen/SKILL.md +151 -0
- package/templates/.claude/skills/req-screen/references/rubric.md +94 -0
- package/templates/.claude/skills/req-screen/references/template.md +91 -0
- package/templates/.claude/skills/req-track/SKILL.md +189 -0
- package/templates/.claude/skills/req-track/references/fields.md +114 -0
- package/templates/.claude/skills/req-track/references/signals.md +42 -0
- package/templates/.claude/skills/req-track/references/statuses.md +32 -0
- package/templates/.claude/skills/req-track/references/templates.md +127 -0
- package/templates/.claude/skills/req-workday/SKILL.md +162 -0
- package/templates/.claude/skills/req-workday/references/field-mapping.md +40 -0
- package/templates/.claude/skills/req-workday/references/status-mapping.md +38 -0
- package/templates/.claude/skills/req-workday/references/templates.md +126 -0
- package/templates/.claude/skills/req-workday/references/xlsx-format.md +56 -0
- package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +313 -0
- package/templates/.claude/skills/send-chat/SKILL.md +170 -0
- package/templates/.claude/skills/sync-apple-calendar/SKILL.md +145 -0
- package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
- package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +307 -0
- package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +332 -0
- package/templates/.claude/skills/sync-apple-mail/SKILL.md +151 -0
- package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +115 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +389 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +372 -0
- package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +239 -0
- package/templates/.claude/skills/sync-teams/SKILL.md +177 -0
- package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +147 -0
- package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +288 -0
- package/templates/.claude/skills/sync-teams/scripts/sync.mjs +381 -0
- package/templates/.claude/skills/upstream-skill/SKILL.md +130 -0
- package/templates/.claude/skills/upstream-skill/references/examples.md +80 -0
- package/templates/.claude/skills/weekly-update/SKILL.md +141 -0
- package/templates/.claude/skills/weekly-update/references/template.md +55 -0
- package/templates/CLAUDE.md +134 -0
- package/templates/USER.md +3 -0
- package/templates/apm.yml +5 -0
- package/templates/knowledge/Briefings/.gitkeep +0 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KBManager — knowledge base init/update operations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
copyFileSync,
|
|
9
|
+
cpSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { join, dirname, resolve } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
17
|
+
|
|
18
|
+
const logger = createLogger("outpost");
|
|
19
|
+
|
|
20
|
+
/** Manage knowledge base lifecycle including initialization, updates, and settings merging. */
|
|
21
|
+
export class KBManager {
|
|
22
|
+
#fs;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ existsSync: Function, mkdirSync: Function, copyFileSync: Function, cpSync: Function, readFileSync: Function, writeFileSync: Function, readdirSync: Function }} fs
|
|
26
|
+
* @param {Function} logFn
|
|
27
|
+
*/
|
|
28
|
+
constructor(fs, logFn) {
|
|
29
|
+
if (!fs) throw new Error("fs is required");
|
|
30
|
+
if (!logFn) throw new Error("logFn is required");
|
|
31
|
+
this.#fs = fs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} dir
|
|
36
|
+
*/
|
|
37
|
+
#ensureDir(dir) {
|
|
38
|
+
this.#fs.mkdirSync(dir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} path
|
|
43
|
+
* @param {*} fallback
|
|
44
|
+
* @returns {*}
|
|
45
|
+
*/
|
|
46
|
+
#readJSON(path, fallback) {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(this.#fs.readFileSync(path, "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} path
|
|
56
|
+
* @param {*} data
|
|
57
|
+
*/
|
|
58
|
+
#writeJSON(path, data) {
|
|
59
|
+
this.#ensureDir(dirname(path));
|
|
60
|
+
this.#fs.writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Copy bundled files (CLAUDE.md, skills, agents) from template to a KB.
|
|
65
|
+
* @param {string} tpl - Path to the template directory
|
|
66
|
+
* @param {string} dest - Path to the target knowledge base
|
|
67
|
+
*/
|
|
68
|
+
copyBundledFiles(tpl, dest) {
|
|
69
|
+
this.#fs.copyFileSync(join(tpl, "CLAUDE.md"), join(dest, "CLAUDE.md"));
|
|
70
|
+
logger.info(` Updated CLAUDE.md`);
|
|
71
|
+
|
|
72
|
+
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`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.mergeSettings(tpl, dest);
|
|
79
|
+
|
|
80
|
+
for (const sub of ["skills", "agents"]) {
|
|
81
|
+
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
|
+
const names = entries.map((d) =>
|
|
90
|
+
sub === "agents" ? d.name.replace(".md", "") : d.name,
|
|
91
|
+
);
|
|
92
|
+
logger.info(` Updated ${names.length} ${sub}: ${names.join(", ")}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Merge new entries from a template permission list into an existing one.
|
|
98
|
+
* @param {Object} templatePerms - Template permissions object
|
|
99
|
+
* @param {Object} existingPerms - Existing permissions object (mutated)
|
|
100
|
+
* @returns {number} Number of entries added
|
|
101
|
+
*/
|
|
102
|
+
#mergePermissionLists(templatePerms, existingPerms) {
|
|
103
|
+
let added = 0;
|
|
104
|
+
for (const key of ["allow", "deny", "additionalDirectories"]) {
|
|
105
|
+
if (!templatePerms[key]?.length) continue;
|
|
106
|
+
const set = new Set((existingPerms[key] ||= []));
|
|
107
|
+
for (const entry of templatePerms[key]) {
|
|
108
|
+
if (!set.has(entry)) {
|
|
109
|
+
existingPerms[key].push(entry);
|
|
110
|
+
set.add(entry);
|
|
111
|
+
added++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (templatePerms.defaultMode && !existingPerms.defaultMode) {
|
|
116
|
+
existingPerms.defaultMode = templatePerms.defaultMode;
|
|
117
|
+
added++;
|
|
118
|
+
}
|
|
119
|
+
return added;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Merge template settings.json into the destination's settings.json.
|
|
124
|
+
* @param {string} tpl - Template directory
|
|
125
|
+
* @param {string} dest - Knowledge base directory
|
|
126
|
+
*/
|
|
127
|
+
mergeSettings(tpl, dest) {
|
|
128
|
+
const src = join(tpl, ".claude", "settings.json");
|
|
129
|
+
if (!this.#fs.existsSync(src)) return;
|
|
130
|
+
|
|
131
|
+
const destPath = join(dest, ".claude", "settings.json");
|
|
132
|
+
|
|
133
|
+
if (!this.#fs.existsSync(destPath)) {
|
|
134
|
+
this.#ensureDir(join(dest, ".claude"));
|
|
135
|
+
this.#fs.copyFileSync(src, destPath);
|
|
136
|
+
logger.info(` Created settings.json`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const template = this.#readJSON(src, {});
|
|
141
|
+
const existing = this.#readJSON(destPath, {});
|
|
142
|
+
const added = this.#mergePermissionLists(
|
|
143
|
+
template.permissions || {},
|
|
144
|
+
(existing.permissions ||= {}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (added > 0) {
|
|
148
|
+
this.#writeJSON(destPath, existing);
|
|
149
|
+
logger.info(` Updated settings.json (${added} new entries)`);
|
|
150
|
+
} else {
|
|
151
|
+
logger.info(` Settings up to date`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Initialize a new knowledge base
|
|
157
|
+
* @param {string} targetPath
|
|
158
|
+
* @param {string} templateDir
|
|
159
|
+
*/
|
|
160
|
+
init(targetPath, templateDir) {
|
|
161
|
+
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);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.#ensureDir(dest);
|
|
168
|
+
for (const d of [
|
|
169
|
+
"knowledge/People",
|
|
170
|
+
"knowledge/Organizations",
|
|
171
|
+
"knowledge/Projects",
|
|
172
|
+
"knowledge/Topics",
|
|
173
|
+
"knowledge/Briefings",
|
|
174
|
+
])
|
|
175
|
+
this.#ensureDir(join(dest, d));
|
|
176
|
+
|
|
177
|
+
this.#fs.copyFileSync(join(templateDir, "USER.md"), join(dest, "USER.md"));
|
|
178
|
+
|
|
179
|
+
this.copyBundledFiles(templateDir, dest);
|
|
180
|
+
|
|
181
|
+
logger.info(
|
|
182
|
+
`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
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Update an existing knowledge base with the latest bundled files.
|
|
188
|
+
* @param {string} targetPath
|
|
189
|
+
* @param {string} templateDir
|
|
190
|
+
*/
|
|
191
|
+
update(targetPath, templateDir) {
|
|
192
|
+
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);
|
|
196
|
+
}
|
|
197
|
+
this.copyBundledFiles(templateDir, dest);
|
|
198
|
+
logger.info(`\nKnowledge base updated: ${dest}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} p
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
#expandPath(p) {
|
|
206
|
+
return p.startsWith("~/") ? join(homedir(), p.slice(2)) : resolve(p);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
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
|
+
}
|
package/src/outpost.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// Outpost — CLI and scheduler for autonomous agent teams.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// fit-outpost Wake due agents once and exit
|
|
7
|
+
// fit-outpost daemon Run continuously (poll every 60s)
|
|
8
|
+
// fit-outpost wake <agent> Wake a specific agent immediately
|
|
9
|
+
// fit-outpost init <path> Initialize a new knowledge base
|
|
10
|
+
// fit-outpost update [path] Update KB with latest CLAUDE.md, agents and skills
|
|
11
|
+
// fit-outpost stop Gracefully stop daemon and all running agents
|
|
12
|
+
// fit-outpost validate Validate agent definitions exist
|
|
13
|
+
// fit-outpost status Show agent status
|
|
14
|
+
// fit-outpost --help Show this help
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
readFileSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
copyFileSync,
|
|
23
|
+
cpSync,
|
|
24
|
+
appendFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { join, dirname, resolve } from "node:path";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { createCli } from "@forwardimpact/libcli";
|
|
30
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
31
|
+
|
|
32
|
+
const logger = createLogger("outpost");
|
|
33
|
+
|
|
34
|
+
import * as posixSpawn from "@forwardimpact/libmacos/posix-spawn";
|
|
35
|
+
import { StateManager } from "./state-manager.js";
|
|
36
|
+
import { AgentRunner } from "./agent-runner.js";
|
|
37
|
+
import { Scheduler } from "./scheduler.js";
|
|
38
|
+
import { KBManager } from "./kb-manager.js";
|
|
39
|
+
import { SocketServer, requestShutdown } from "./socket-server.js";
|
|
40
|
+
|
|
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
|
+
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
|
+
|
|
83
|
+
function loadConfig() {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
86
|
+
} catch {
|
|
87
|
+
return { agents: {} };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function expandPath(p) {
|
|
92
|
+
return p.startsWith("~/") ? join(HOME, p.slice(2)) : resolve(p);
|
|
93
|
+
}
|
|
94
|
+
|
|
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
|
+
/**
|
|
117
|
+
* Detect if running from inside a macOS .app bundle.
|
|
118
|
+
* @returns {{ bundle: string, resources: string } | null}
|
|
119
|
+
*/
|
|
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
|
+
}
|
|
134
|
+
|
|
135
|
+
function requireTemplateDir() {
|
|
136
|
+
const bundle = getBundlePath();
|
|
137
|
+
if (bundle) {
|
|
138
|
+
const tpl = join(bundle.resources, "templates");
|
|
139
|
+
if (existsSync(tpl)) return tpl;
|
|
140
|
+
}
|
|
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
|
+
|
|
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}`);
|
|
156
|
+
|
|
157
|
+
// Reset any agents left "active" from a previous daemon session.
|
|
158
|
+
const state = stateManager.load();
|
|
159
|
+
stateManager.resetStaleAgents(state, { reason: "Daemon restarted" }, log);
|
|
160
|
+
|
|
161
|
+
const socketServer = new SocketServer(
|
|
162
|
+
SOCKET_PATH,
|
|
163
|
+
scheduler,
|
|
164
|
+
agentRunner,
|
|
165
|
+
stateManager,
|
|
166
|
+
loadConfig,
|
|
167
|
+
log,
|
|
168
|
+
CACHE_DIR,
|
|
169
|
+
daemonStartedAt,
|
|
170
|
+
);
|
|
171
|
+
socketServer.start();
|
|
172
|
+
|
|
173
|
+
async function tick() {
|
|
174
|
+
try {
|
|
175
|
+
await scheduler.wakeDueAgents();
|
|
176
|
+
} catch (err) {
|
|
177
|
+
log(`Error: ${err.message}`);
|
|
178
|
+
}
|
|
179
|
+
setTimeout(tick, 60_000);
|
|
180
|
+
}
|
|
181
|
+
tick();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Update ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
function runUpdate(args) {
|
|
187
|
+
if (args[0]) {
|
|
188
|
+
kbManager.update(args[0], requireTemplateDir());
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
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
|
+
];
|
|
200
|
+
|
|
201
|
+
if (kbPaths.length === 0) {
|
|
202
|
+
console.error(
|
|
203
|
+
"No knowledge bases configured and no path given.\n" +
|
|
204
|
+
"Usage: fit-outpost update [path]",
|
|
205
|
+
);
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const kb of kbPaths) {
|
|
210
|
+
logger.info(`\nUpdating ${kb}...`);
|
|
211
|
+
kbManager.update(kb, requireTemplateDir());
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
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
|
+
}
|
|
234
|
+
|
|
235
|
+
function showStatus() {
|
|
236
|
+
const config = loadConfig();
|
|
237
|
+
const state = stateManager.load();
|
|
238
|
+
logger.info("\nOutpost Scheduler\n==================\n");
|
|
239
|
+
|
|
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;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
logger.info("Agents:");
|
|
247
|
+
for (const [name, agent] of agents) {
|
|
248
|
+
logger.info(formatAgentStatus(name, agent, state.agents[name] || {}));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --- Validate ----------------------------------------------------------------
|
|
253
|
+
|
|
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
|
+
}
|
|
261
|
+
|
|
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;
|
|
272
|
+
}
|
|
273
|
+
const kbPath = expandPath(agent.kb);
|
|
274
|
+
if (!existsSync(kbPath)) {
|
|
275
|
+
logger.info(` [FAIL] ${name}: path not found: ${kbPath}`);
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
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
|
+
}
|
|
286
|
+
|
|
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;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
logger.info("\nValidating agents...\n");
|
|
296
|
+
let errors = 0;
|
|
297
|
+
|
|
298
|
+
for (const [name, agent] of agents) {
|
|
299
|
+
if (!validateAgent(name, agent)) errors++;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
logger.info(errors > 0 ? `\n${errors} error(s).` : "\nAll OK.");
|
|
303
|
+
if (errors > 0) process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
|
|
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.",
|
|
351
|
+
},
|
|
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.",
|
|
357
|
+
},
|
|
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.",
|
|
362
|
+
},
|
|
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;
|
|
374
|
+
|
|
375
|
+
mkdirSync(OUTPOST_HOME, { recursive: true });
|
|
376
|
+
|
|
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);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
await (handler || (() => scheduler.wakeDueAgents()))();
|