@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.
Files changed (120) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +15 -0
  3. package/bin/fit-outpost.js +3 -0
  4. package/config/scheduler.json +34 -0
  5. package/package.json +63 -0
  6. package/src/agent-runner.js +189 -0
  7. package/src/index.js +5 -0
  8. package/src/kb-manager.js +228 -0
  9. package/src/outpost.js +417 -0
  10. package/src/scheduler.js +195 -0
  11. package/src/socket-server.js +336 -0
  12. package/src/state-manager.js +126 -0
  13. package/templates/.claude/agents/chief-of-staff.md +58 -0
  14. package/templates/.claude/agents/concierge.md +46 -0
  15. package/templates/.claude/agents/head-hunter.md +57 -0
  16. package/templates/.claude/agents/librarian.md +60 -0
  17. package/templates/.claude/agents/postman.md +47 -0
  18. package/templates/.claude/agents/recruiter.md +63 -0
  19. package/templates/.claude/settings.json +88 -0
  20. package/templates/.claude/skills/candidate-report/SKILL.md +139 -0
  21. package/templates/.claude/skills/candidate-report/references/report-template.html +203 -0
  22. package/templates/.claude/skills/candidate-report/references/report.css +438 -0
  23. package/templates/.claude/skills/candidate-report/references/rubric.md +76 -0
  24. package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +48 -0
  25. package/templates/.claude/skills/deck-create/SKILL.md +75 -0
  26. package/templates/.claude/skills/deck-create/references/slide.css +35 -0
  27. package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +47 -0
  28. package/templates/.claude/skills/deck-summarize/SKILL.md +139 -0
  29. package/templates/.claude/skills/deck-summarize/references/brief-template.md +85 -0
  30. package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +210 -0
  31. package/templates/.claude/skills/doc-collab/SKILL.md +112 -0
  32. package/templates/.claude/skills/doc-create/SKILL.md +105 -0
  33. package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +50 -0
  34. package/templates/.claude/skills/draft-emails/SKILL.md +141 -0
  35. package/templates/.claude/skills/draft-emails/references/template.md +26 -0
  36. package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
  37. package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +139 -0
  38. package/templates/.claude/skills/extract-entities/SKILL.md +187 -0
  39. package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +14 -0
  40. package/templates/.claude/skills/extract-entities/references/conditions.md +55 -0
  41. package/templates/.claude/skills/extract-entities/references/content.md +104 -0
  42. package/templates/.claude/skills/extract-entities/references/links.md +48 -0
  43. package/templates/.claude/skills/extract-entities/references/recruitment.md +57 -0
  44. package/templates/.claude/skills/extract-entities/references/resolution.md +71 -0
  45. package/templates/.claude/skills/extract-entities/references/sources.md +70 -0
  46. package/templates/.claude/skills/extract-entities/references/templates-conditions.md +36 -0
  47. package/templates/.claude/skills/extract-entities/references/templates-goals-priorities.md +63 -0
  48. package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +67 -0
  49. package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +68 -0
  50. package/templates/.claude/skills/extract-entities/scripts/state.mjs +131 -0
  51. package/templates/.claude/skills/hyprnote-follow/SKILL.md +174 -0
  52. package/templates/.claude/skills/hyprnote-follow/references/coaching.md +118 -0
  53. package/templates/.claude/skills/hyprnote-follow/references/debrief.md +59 -0
  54. package/templates/.claude/skills/hyprnote-follow/references/meeting-types.md +14 -0
  55. package/templates/.claude/skills/hyprnote-follow/scripts/follow.mjs +243 -0
  56. package/templates/.claude/skills/hyprnote-process/SKILL.md +147 -0
  57. package/templates/.claude/skills/hyprnote-process/references/extraction.md +84 -0
  58. package/templates/.claude/skills/hyprnote-process/references/sessions.md +81 -0
  59. package/templates/.claude/skills/hyprnote-process/scripts/scan.mjs +246 -0
  60. package/templates/.claude/skills/hyprnote-trim/SKILL.md +177 -0
  61. package/templates/.claude/skills/manage-tasks/SKILL.md +152 -0
  62. package/templates/.claude/skills/manage-tasks/references/format.md +73 -0
  63. package/templates/.claude/skills/meeting-prep/SKILL.md +180 -0
  64. package/templates/.claude/skills/organize-files/SKILL.md +146 -0
  65. package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
  66. package/templates/.claude/skills/organize-files/scripts/summarize.mjs +91 -0
  67. package/templates/.claude/skills/req-assess/SKILL.md +137 -0
  68. package/templates/.claude/skills/req-assess/references/interview-template.md +62 -0
  69. package/templates/.claude/skills/req-assess/references/panel-template.md +117 -0
  70. package/templates/.claude/skills/req-assess/references/rubric.md +65 -0
  71. package/templates/.claude/skills/req-decide/SKILL.md +163 -0
  72. package/templates/.claude/skills/req-decide/references/rubric.md +63 -0
  73. package/templates/.claude/skills/req-decide/references/template.md +118 -0
  74. package/templates/.claude/skills/req-forget/SKILL.md +154 -0
  75. package/templates/.claude/skills/req-forget/references/classify.md +23 -0
  76. package/templates/.claude/skills/req-forget/references/locations.md +59 -0
  77. package/templates/.claude/skills/req-forget/references/report-template.md +67 -0
  78. package/templates/.claude/skills/req-scan/SKILL.md +145 -0
  79. package/templates/.claude/skills/req-scan/references/fallbacks.md +76 -0
  80. package/templates/.claude/skills/req-scan/references/filters.md +83 -0
  81. package/templates/.claude/skills/req-scan/references/sources.md +76 -0
  82. package/templates/.claude/skills/req-scan/references/state.md +54 -0
  83. package/templates/.claude/skills/req-scan/references/template.md +36 -0
  84. package/templates/.claude/skills/req-scan/scripts/state.mjs +396 -0
  85. package/templates/.claude/skills/req-screen/SKILL.md +151 -0
  86. package/templates/.claude/skills/req-screen/references/rubric.md +94 -0
  87. package/templates/.claude/skills/req-screen/references/template.md +91 -0
  88. package/templates/.claude/skills/req-track/SKILL.md +189 -0
  89. package/templates/.claude/skills/req-track/references/fields.md +114 -0
  90. package/templates/.claude/skills/req-track/references/signals.md +42 -0
  91. package/templates/.claude/skills/req-track/references/statuses.md +32 -0
  92. package/templates/.claude/skills/req-track/references/templates.md +127 -0
  93. package/templates/.claude/skills/req-workday/SKILL.md +162 -0
  94. package/templates/.claude/skills/req-workday/references/field-mapping.md +40 -0
  95. package/templates/.claude/skills/req-workday/references/status-mapping.md +38 -0
  96. package/templates/.claude/skills/req-workday/references/templates.md +126 -0
  97. package/templates/.claude/skills/req-workday/references/xlsx-format.md +56 -0
  98. package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +313 -0
  99. package/templates/.claude/skills/send-chat/SKILL.md +170 -0
  100. package/templates/.claude/skills/sync-apple-calendar/SKILL.md +145 -0
  101. package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
  102. package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +307 -0
  103. package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +332 -0
  104. package/templates/.claude/skills/sync-apple-mail/SKILL.md +151 -0
  105. package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +115 -0
  106. package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +389 -0
  107. package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +372 -0
  108. package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +239 -0
  109. package/templates/.claude/skills/sync-teams/SKILL.md +177 -0
  110. package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +147 -0
  111. package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +288 -0
  112. package/templates/.claude/skills/sync-teams/scripts/sync.mjs +381 -0
  113. package/templates/.claude/skills/upstream-skill/SKILL.md +130 -0
  114. package/templates/.claude/skills/upstream-skill/references/examples.md +80 -0
  115. package/templates/.claude/skills/weekly-update/SKILL.md +141 -0
  116. package/templates/.claude/skills/weekly-update/references/template.md +55 -0
  117. package/templates/CLAUDE.md +134 -0
  118. package/templates/USER.md +3 -0
  119. package/templates/apm.yml +5 -0
  120. package/templates/knowledge/Briefings/.gitkeep +0 -0
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Scheduler — cron matching, shouldWake logic, wake orchestration.
3
+ */
4
+
5
+ /** Maximum time an agent can be "active" before being considered stale (35 min). */
6
+ const MAX_AGENT_RUNTIME_MS = 35 * 60_000;
7
+
8
+ // --- Cron matching (pure functions) ------------------------------------------
9
+
10
+ /**
11
+ * @param {string} field
12
+ * @param {number} value
13
+ * @returns {boolean}
14
+ */
15
+ export function matchField(field, value) {
16
+ if (field === "*") return true;
17
+ if (field.startsWith("*/")) return value % parseInt(field.slice(2)) === 0;
18
+ return field.split(",").some((part) => {
19
+ if (part.includes("-")) {
20
+ const [lo, hi] = part.split("-").map(Number);
21
+ return value >= lo && value <= hi;
22
+ }
23
+ return parseInt(part) === value;
24
+ });
25
+ }
26
+
27
+ /**
28
+ * @param {string} expr
29
+ * @param {Date} d
30
+ * @returns {boolean}
31
+ */
32
+ export function cronMatches(expr, d) {
33
+ const [min, hour, dom, month, dow] = expr.trim().split(/\s+/);
34
+ return (
35
+ matchField(min, d.getMinutes()) &&
36
+ matchField(hour, d.getHours()) &&
37
+ matchField(dom, d.getDate()) &&
38
+ matchField(month, d.getMonth() + 1) &&
39
+ matchField(dow, d.getDay())
40
+ );
41
+ }
42
+
43
+ /**
44
+ * @param {Date} d
45
+ * @returns {number}
46
+ */
47
+ export function floorToMinute(d) {
48
+ const t = d.getTime();
49
+ return t - (t % 60_000);
50
+ }
51
+
52
+ /**
53
+ * @param {Object} agent
54
+ * @param {Object} agentState
55
+ * @param {Date} now
56
+ * @returns {boolean}
57
+ */
58
+ export function shouldWake(agent, agentState, now) {
59
+ if (agent.enabled === false) return false;
60
+ if (agentState.status === "active") return false;
61
+ const { schedule } = agent;
62
+ if (!schedule) return false;
63
+ const lastWoke = agentState.lastWokeAt
64
+ ? new Date(agentState.lastWokeAt)
65
+ : null;
66
+
67
+ if (schedule.type === "cron") {
68
+ if (lastWoke && floorToMinute(lastWoke) === floorToMinute(now))
69
+ return false;
70
+ return cronMatches(schedule.expression, now);
71
+ }
72
+ if (schedule.type === "interval") {
73
+ const ms = (schedule.minutes || 5) * 60_000;
74
+ return !lastWoke || now.getTime() - lastWoke.getTime() >= ms;
75
+ }
76
+ if (schedule.type === "once") {
77
+ return !agentState.lastWokeAt && now >= new Date(schedule.runAt);
78
+ }
79
+ return false;
80
+ }
81
+
82
+ /**
83
+ * @param {Object} schedule
84
+ * @param {Object} agentState
85
+ * @param {Date} now
86
+ * @returns {string|null}
87
+ */
88
+ function nextWakeInterval(schedule, agentState, now) {
89
+ const ms = (schedule.minutes || 5) * 60_000;
90
+ const lastWoke = agentState.lastWokeAt
91
+ ? new Date(agentState.lastWokeAt)
92
+ : null;
93
+ if (!lastWoke) return now.toISOString();
94
+ return new Date(lastWoke.getTime() + ms).toISOString();
95
+ }
96
+
97
+ /**
98
+ * @param {Object} schedule
99
+ * @param {Date} now
100
+ * @returns {string|null}
101
+ */
102
+ function nextWakeCron(schedule, now) {
103
+ const limit = 24 * 60;
104
+ const start = new Date(floorToMinute(now) + 60_000);
105
+ for (let i = 0; i < limit; i++) {
106
+ const candidate = new Date(start.getTime() + i * 60_000);
107
+ if (cronMatches(schedule.expression, candidate)) {
108
+ return candidate.toISOString();
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * @param {Object} agent
116
+ * @param {Object} agentState
117
+ * @param {Date} now
118
+ * @returns {string|null}
119
+ */
120
+ export function computeNextWakeAt(agent, agentState, now) {
121
+ if (agent.enabled === false) return null;
122
+ const { schedule } = agent;
123
+ if (!schedule) return null;
124
+
125
+ if (schedule.type === "interval")
126
+ return nextWakeInterval(schedule, agentState, now);
127
+ if (schedule.type === "cron") return nextWakeCron(schedule, now);
128
+ if (schedule.type === "once")
129
+ return agentState.lastWokeAt ? null : schedule.runAt;
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * @param {Object} agentState
135
+ * @param {string} error
136
+ */
137
+ export function failAgent(agentState, error) {
138
+ Object.assign(agentState, {
139
+ status: "failed",
140
+ startedAt: null,
141
+ lastWokeAt: new Date().toISOString(),
142
+ lastError: String(error).slice(0, 500),
143
+ });
144
+ }
145
+
146
+ // --- Scheduler class ---------------------------------------------------------
147
+
148
+ /** Orchestrate periodic agent wakes by evaluating schedules and delegating to AgentRunner. */
149
+ export class Scheduler {
150
+ #loadConfig;
151
+ #stateManager;
152
+ #agentRunner;
153
+ #log;
154
+
155
+ /**
156
+ * @param {Function} loadConfig - Returns scheduler config
157
+ * @param {import('./state-manager.js').StateManager} stateManager
158
+ * @param {import('./agent-runner.js').AgentRunner} agentRunner
159
+ * @param {Function} logFn
160
+ */
161
+ constructor(loadConfig, stateManager, agentRunner, logFn) {
162
+ if (!loadConfig) throw new Error("loadConfig is required");
163
+ if (!stateManager) throw new Error("stateManager is required");
164
+ if (!agentRunner) throw new Error("agentRunner is required");
165
+ if (!logFn) throw new Error("logFn is required");
166
+ this.#loadConfig = loadConfig;
167
+ this.#stateManager = stateManager;
168
+ this.#agentRunner = agentRunner;
169
+ this.#log = logFn;
170
+ }
171
+
172
+ /**
173
+ * Reset any agents exceeding max runtime, reload config, then wake each agent whose schedule is due.
174
+ */
175
+ async wakeDueAgents() {
176
+ const config = this.#loadConfig();
177
+ const state = this.#stateManager.load();
178
+ const now = new Date();
179
+
180
+ this.#stateManager.resetStaleAgents(
181
+ state,
182
+ { reason: "Exceeded maximum runtime", maxAge: MAX_AGENT_RUNTIME_MS },
183
+ this.#log,
184
+ );
185
+
186
+ let wokeAny = false;
187
+ for (const [name, agent] of Object.entries(config.agents)) {
188
+ if (shouldWake(agent, state.agents[name] || {}, now)) {
189
+ await this.#agentRunner.wake(name, agent, state, config.env);
190
+ wokeAny = true;
191
+ }
192
+ }
193
+ if (!wokeAny) this.#log("No agents due.");
194
+ }
195
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * SocketServer — Unix socket IPC for daemon communication.
3
+ */
4
+
5
+ import { createServer } from "node:net";
6
+ import { createConnection } from "node:net";
7
+ import { createLogger } from "@forwardimpact/libtelemetry";
8
+ import {
9
+ existsSync,
10
+ unlinkSync,
11
+ chmodSync,
12
+ readdirSync,
13
+ statSync,
14
+ } from "node:fs";
15
+
16
+ const logger = createLogger("outpost");
17
+ import { join, resolve } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { computeNextWakeAt } from "./scheduler.js";
20
+
21
+ /** Unix-socket IPC server that handles status queries, wake requests, and shutdown commands. */
22
+ export class SocketServer {
23
+ #socketPath;
24
+ #agentRunner;
25
+ #stateManager;
26
+ #log;
27
+ #loadConfig;
28
+ #cacheDir;
29
+ #daemonStartedAt;
30
+ #server;
31
+
32
+ /**
33
+ * @param {string} socketPath
34
+ * @param {import('./scheduler.js').Scheduler} scheduler
35
+ * @param {import('./agent-runner.js').AgentRunner} agentRunner
36
+ * @param {import('./state-manager.js').StateManager} stateManager
37
+ * @param {Function} loadConfig
38
+ * @param {Function} logFn
39
+ * @param {string} cacheDir
40
+ * @param {number} daemonStartedAt
41
+ */
42
+ constructor(
43
+ socketPath,
44
+ scheduler,
45
+ agentRunner,
46
+ stateManager,
47
+ loadConfig,
48
+ logFn,
49
+ cacheDir,
50
+ daemonStartedAt,
51
+ ) {
52
+ if (!socketPath) throw new Error("socketPath is required");
53
+ if (!scheduler) throw new Error("scheduler is required");
54
+ if (!agentRunner) throw new Error("agentRunner is required");
55
+ if (!stateManager) throw new Error("stateManager is required");
56
+ if (!loadConfig) throw new Error("loadConfig is required");
57
+ if (!logFn) throw new Error("logFn is required");
58
+ if (!cacheDir) throw new Error("cacheDir is required");
59
+ this.#socketPath = socketPath;
60
+ this.#agentRunner = agentRunner;
61
+ this.#stateManager = stateManager;
62
+ this.#loadConfig = loadConfig;
63
+ this.#log = logFn;
64
+ this.#cacheDir = cacheDir;
65
+ this.#daemonStartedAt = daemonStartedAt;
66
+ }
67
+
68
+ /**
69
+ * @param {import('node:net').Socket} socket
70
+ * @param {Object} data
71
+ */
72
+ #send(socket, data) {
73
+ try {
74
+ socket.write(JSON.stringify(data) + "\n");
75
+ } catch {}
76
+ }
77
+
78
+ /**
79
+ * @param {string} p
80
+ * @returns {string}
81
+ */
82
+ #expandPath(p) {
83
+ return p.startsWith("~/") ? join(homedir(), p.slice(2)) : resolve(p);
84
+ }
85
+
86
+ /**
87
+ * Find the most recently modified file in a directory matching a filter.
88
+ * @param {string} dir
89
+ * @param {(name: string) => boolean} filter
90
+ * @returns {string|null}
91
+ */
92
+ #latestFileByMtime(dir, filter) {
93
+ const matches = readdirSync(dir).filter(filter);
94
+ if (matches.length === 0) return null;
95
+ let latest = join(dir, matches[0]);
96
+ let latestMtime = statSync(latest).mtimeMs;
97
+ for (let i = 1; i < matches.length; i++) {
98
+ const p = join(dir, matches[i]);
99
+ const mt = statSync(p).mtimeMs;
100
+ if (mt > latestMtime) {
101
+ latest = p;
102
+ latestMtime = mt;
103
+ }
104
+ }
105
+ return latest;
106
+ }
107
+
108
+ /**
109
+ * Resolve briefing file for an agent
110
+ * @param {string} agentName
111
+ * @param {Object} agentConfig
112
+ * @returns {string|null}
113
+ */
114
+ #resolveBriefingFile(agentName, agentConfig) {
115
+ const stateDir = join(this.#cacheDir, "state");
116
+ if (existsSync(stateDir)) {
117
+ const prefix = agentName.replace(/-/g, "_") + "_";
118
+ const found = this.#latestFileByMtime(
119
+ stateDir,
120
+ (f) => f.startsWith(prefix) && f.endsWith(".md"),
121
+ );
122
+ if (found) return found;
123
+ }
124
+
125
+ if (agentConfig.kb) {
126
+ const dir = join(
127
+ this.#expandPath(agentConfig.kb),
128
+ "knowledge",
129
+ "Briefings",
130
+ );
131
+ if (existsSync(dir)) {
132
+ const files = readdirSync(dir)
133
+ .filter((f) => f.endsWith(".md"))
134
+ .sort()
135
+ .reverse();
136
+ if (files.length > 0) return join(dir, files[0]);
137
+ }
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * @param {import('node:net').Socket} socket
145
+ */
146
+ #handleStatusRequest(socket) {
147
+ const config = this.#loadConfig();
148
+ const state = this.#stateManager.load();
149
+ const now = new Date();
150
+ const agents = {};
151
+
152
+ for (const [name, agent] of Object.entries(config.agents)) {
153
+ const as = state.agents[name] || {};
154
+ agents[name] = {
155
+ enabled: agent.enabled !== false,
156
+ status: as.status || "never-woken",
157
+ lastWokeAt: as.lastWokeAt || null,
158
+ nextWakeAt: computeNextWakeAt(agent, as, now),
159
+ lastAction: as.lastAction || null,
160
+ lastDecision: as.lastDecision || null,
161
+ wakeCount: as.wakeCount || 0,
162
+ lastError: as.lastError || null,
163
+ kbPath: agent.kb ? this.#expandPath(agent.kb) : null,
164
+ briefingFile: this.#resolveBriefingFile(name, agent),
165
+ };
166
+ if (as.startedAt) agents[name].startedAt = as.startedAt;
167
+ }
168
+
169
+ this.#send(socket, {
170
+ type: "status",
171
+ uptime: this.#daemonStartedAt
172
+ ? Math.floor((Date.now() - this.#daemonStartedAt) / 1000)
173
+ : 0,
174
+ agents,
175
+ });
176
+ }
177
+
178
+ /**
179
+ * @param {import('node:net').Socket} socket
180
+ * @param {string} line
181
+ */
182
+ #handleMessage(socket, line) {
183
+ let request;
184
+ try {
185
+ request = JSON.parse(line);
186
+ } catch {
187
+ this.#send(socket, { type: "error", message: "Invalid JSON" });
188
+ return;
189
+ }
190
+
191
+ if (request.type === "status") return this.#handleStatusRequest(socket);
192
+
193
+ if (request.type === "shutdown") {
194
+ this.#log("Shutdown requested via socket.");
195
+ this.#send(socket, { type: "ack", command: "shutdown" });
196
+ socket.end();
197
+ this.#agentRunner.killActiveChildren();
198
+ process.exit(0);
199
+ }
200
+
201
+ if (request.type === "wake") {
202
+ if (!request.agent) {
203
+ this.#send(socket, { type: "error", message: "Missing agent name" });
204
+ return;
205
+ }
206
+ const config = this.#loadConfig();
207
+ const agent = config.agents[request.agent];
208
+ if (!agent) {
209
+ this.#send(socket, {
210
+ type: "error",
211
+ message: `Agent not found: ${request.agent}`,
212
+ });
213
+ return;
214
+ }
215
+ this.#send(socket, {
216
+ type: "ack",
217
+ command: "wake",
218
+ agent: request.agent,
219
+ });
220
+ const state = this.#stateManager.load();
221
+ this.#agentRunner
222
+ .wake(request.agent, agent, state, config.env)
223
+ .catch(() => {});
224
+ return;
225
+ }
226
+
227
+ this.#send(socket, {
228
+ type: "error",
229
+ message: `Unknown request type: ${request.type}`,
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Remove any existing socket file, bind the server, and register SIGTERM/SIGINT handlers to kill active children before exit.
235
+ * @returns {import('node:net').Server}
236
+ */
237
+ start() {
238
+ try {
239
+ unlinkSync(this.#socketPath);
240
+ } catch {}
241
+
242
+ this.#server = createServer((socket) => {
243
+ let buffer = "";
244
+ socket.on("data", (data) => {
245
+ buffer += data.toString();
246
+ let idx;
247
+ while ((idx = buffer.indexOf("\n")) !== -1) {
248
+ const line = buffer.slice(0, idx).trim();
249
+ buffer = buffer.slice(idx + 1);
250
+ if (line) this.#handleMessage(socket, line);
251
+ }
252
+ });
253
+ socket.on("error", () => {});
254
+ });
255
+
256
+ this.#server.listen(this.#socketPath, () => {
257
+ chmodSync(this.#socketPath, 0o600);
258
+ this.#log(`Socket server listening on ${this.#socketPath}`);
259
+ });
260
+
261
+ this.#server.on("error", (err) => {
262
+ this.#log(`Socket server error: ${err.message}`);
263
+ });
264
+
265
+ const cleanup = () => {
266
+ this.#agentRunner.killActiveChildren();
267
+ this.#server.close();
268
+ try {
269
+ unlinkSync(this.#socketPath);
270
+ } catch {}
271
+ process.exit(0);
272
+ };
273
+ process.on("SIGTERM", cleanup);
274
+ process.on("SIGINT", cleanup);
275
+
276
+ return this.#server;
277
+ }
278
+
279
+ /**
280
+ * Stop the socket server
281
+ */
282
+ stop() {
283
+ if (this.#server) {
284
+ this.#server.close();
285
+ }
286
+ try {
287
+ unlinkSync(this.#socketPath);
288
+ } catch {}
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Connect to the daemon socket and request graceful shutdown.
294
+ * @param {string} socketPath
295
+ * @returns {Promise<boolean>}
296
+ */
297
+ export async function requestShutdown(socketPath) {
298
+ if (!existsSync(socketPath)) {
299
+ logger.info("Daemon not running (no socket).");
300
+ return false;
301
+ }
302
+
303
+ return new Promise((resolve) => {
304
+ const timeout = setTimeout(() => {
305
+ logger.info("Shutdown timed out.");
306
+ socket.destroy();
307
+ resolve(false);
308
+ }, 5000);
309
+
310
+ const socket = createConnection(socketPath, () => {
311
+ socket.write(JSON.stringify({ type: "shutdown" }) + "\n");
312
+ });
313
+
314
+ let buffer = "";
315
+ socket.on("data", (data) => {
316
+ buffer += data.toString();
317
+ if (buffer.includes("\n")) {
318
+ clearTimeout(timeout);
319
+ logger.info("Daemon stopped.");
320
+ socket.destroy();
321
+ resolve(true);
322
+ }
323
+ });
324
+
325
+ socket.on("error", () => {
326
+ clearTimeout(timeout);
327
+ logger.info("Daemon not running (connection refused).");
328
+ resolve(false);
329
+ });
330
+
331
+ socket.on("close", () => {
332
+ clearTimeout(timeout);
333
+ resolve(true);
334
+ });
335
+ });
336
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * StateManager — load/save state.json, reset stale agents.
3
+ */
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+
8
+ /** Persist and query agent scheduler state from a JSON file on disk. */
9
+ export class StateManager {
10
+ #statePath;
11
+ #fs;
12
+
13
+ /**
14
+ * @param {string} statePath - Path to state.json
15
+ * @param {{ readFileSync: Function, writeFileSync: Function, mkdirSync: Function }} fs
16
+ */
17
+ constructor(statePath, fs) {
18
+ if (!statePath) throw new Error("statePath is required");
19
+ if (!fs) throw new Error("fs is required");
20
+ this.#statePath = statePath;
21
+ this.#fs = fs;
22
+ }
23
+
24
+ /**
25
+ * Read and parse state from disk; on any read or parse error, write a fresh empty state and return it.
26
+ * @returns {Object}
27
+ */
28
+ load() {
29
+ try {
30
+ const raw = JSON.parse(this.#fs.readFileSync(this.#statePath, "utf8"));
31
+ if (!raw || typeof raw !== "object" || !raw.agents) {
32
+ const state = { agents: {} };
33
+ this.save(state);
34
+ return state;
35
+ }
36
+ return raw;
37
+ } catch {
38
+ const state = { agents: {} };
39
+ this.save(state);
40
+ return state;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Save state to disk
46
+ * @param {Object} state
47
+ */
48
+ save(state) {
49
+ this.#fs.mkdirSync(dirname(this.#statePath), { recursive: true });
50
+ this.#fs.writeFileSync(
51
+ this.#statePath,
52
+ JSON.stringify(state, null, 2) + "\n",
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Reset agents stuck in "active" state.
58
+ * @param {Object} state
59
+ * @param {{ reason: string, maxAge?: number }} opts
60
+ * @param {Function} logFn
61
+ * @returns {number} Number of agents reset
62
+ */
63
+ resetStaleAgents(state, { reason, maxAge }, logFn) {
64
+ let resetCount = 0;
65
+ for (const [name, as] of Object.entries(state.agents)) {
66
+ if (as.status !== "active") continue;
67
+ if (maxAge && as.startedAt) {
68
+ const elapsed = Date.now() - new Date(as.startedAt).getTime();
69
+ if (elapsed < maxAge) continue;
70
+ }
71
+ logFn(`Resetting stale agent: ${name} (${reason})`);
72
+ Object.assign(as, {
73
+ status: "interrupted",
74
+ startedAt: null,
75
+ lastError: reason,
76
+ });
77
+ resetCount++;
78
+ }
79
+ if (resetCount > 0) this.save(state);
80
+ return resetCount;
81
+ }
82
+
83
+ /**
84
+ * Parse Decision:/Action: lines from agent output and update state.
85
+ * @param {Object} agentState
86
+ * @param {string} stdout
87
+ * @param {string} agentName
88
+ * @param {string} cacheDir - Cache directory for state files
89
+ */
90
+ updateAgentState(agentState, stdout, agentName, cacheDir) {
91
+ const lines = stdout.split("\n");
92
+ const decisionLine = lines.find((l) => l.startsWith("Decision:"));
93
+ const actionLine = lines.find((l) => l.startsWith("Action:"));
94
+
95
+ Object.assign(agentState, {
96
+ status: "idle",
97
+ startedAt: null,
98
+ lastWokeAt: new Date().toISOString(),
99
+ lastDecision: decisionLine
100
+ ? decisionLine.slice(10).trim()
101
+ : stdout.slice(0, 200),
102
+ lastAction: actionLine ? actionLine.slice(8).trim() : null,
103
+ lastError: null,
104
+ wakeCount: (agentState.wakeCount || 0) + 1,
105
+ });
106
+
107
+ // Save output as briefing fallback
108
+ const stateDir = join(cacheDir, "state");
109
+ this.#fs.mkdirSync(stateDir, { recursive: true });
110
+ const prefix = agentName.replace(/-/g, "_");
111
+ this.#fs.writeFileSync(join(stateDir, `${prefix}_last_output.md`), stdout);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Create a StateManager with real fs dependencies
117
+ * @param {string} statePath
118
+ * @returns {StateManager}
119
+ */
120
+ export function createStateManager(statePath) {
121
+ return new StateManager(statePath, {
122
+ readFileSync,
123
+ writeFileSync,
124
+ mkdirSync,
125
+ });
126
+ }
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: chief-of-staff
3
+ description: >
4
+ The user's executive assistant. Creates daily briefings that synthesize
5
+ email, calendar, and knowledge graph state into actionable priorities.
6
+ Woken at key moments (morning, evening) by the Outpost scheduler.
7
+ model: sonnet
8
+ permissionMode: bypassPermissions
9
+ skills:
10
+ - weekly-update
11
+ ---
12
+
13
+ You are the chief of staff — the user's executive assistant. Each wake:
14
+ synthesize what matters across email, calendar, and the knowledge graph into a
15
+ single briefing.
16
+
17
+ ## Inputs
18
+
19
+ Read all five sibling agents' triage files before writing — these are the
20
+ authoritative current-state summaries:
21
+
22
+ - `~/.cache/fit/outpost/state/postman_triage.md`
23
+ - `~/.cache/fit/outpost/state/concierge_triage.md`
24
+ - `~/.cache/fit/outpost/state/librarian_triage.md`
25
+ - `~/.cache/fit/outpost/state/recruiter_triage.md`
26
+ - `~/.cache/fit/outpost/state/head_hunter_triage.md`
27
+
28
+ Plus directly: `knowledge/Goals/`, `knowledge/Priorities/`, `drafts/`,
29
+ `~/.cache/fit/outpost/apple_calendar/`, and unchecked `- [ ]` items in
30
+ `knowledge/`.
31
+
32
+ ## Routing
33
+
34
+ | Trigger | Output |
35
+ | -------------- | -------------------------------------------------- |
36
+ | Before noon | `knowledge/Briefings/{YYYY-MM-DD}-morning.md` |
37
+ | Noon or later | `knowledge/Briefings/{YYYY-MM-DD}-evening.md` |
38
+ | Monday morning | also run `weekly-update` for the week's priorities |
39
+
40
+ A briefing covers: today's schedule with prep status, top three priority actions
41
+ linked to `[[Priorities/...]]`, goal progress, inbox snapshot (urgent / awaiting
42
+ reply), open commitments, recruitment pipeline summary, and a heads-up section.
43
+ Evening briefings replace "Priority Actions" with "What Happened Today" and
44
+ "Still Outstanding".
45
+
46
+ ## Scope
47
+
48
+ - This agent **synthesizes** — never duplicate work the other agents have
49
+ already triaged. Cite their findings, don't re-derive them.
50
+ - Do not act on email, candidates, or transcripts directly — those belong to the
51
+ postman, recruiter, and concierge.
52
+
53
+ ## Output
54
+
55
+ ```
56
+ Decision: {morning/evening} briefing — {key insight about today}
57
+ Action: Created knowledge/Briefings/{YYYY-MM-DD}-{morning|evening}.md
58
+ ```