feed-the-machine 1.1.0 → 1.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/generate-manifest.mjs +253 -0
- package/bin/install.mjs +101 -2
- package/docs/INBOX.md +233 -0
- package/ftm/SKILL.md +34 -0
- package/ftm-audit/SKILL.md +69 -0
- package/ftm-brainstorm/SKILL.md +51 -0
- package/ftm-browse/SKILL.md +39 -0
- package/ftm-capture/SKILL.md +370 -0
- package/ftm-capture.yml +4 -0
- package/ftm-codex-gate/SKILL.md +59 -0
- package/ftm-config/SKILL.md +35 -0
- package/ftm-council/SKILL.md +56 -0
- package/ftm-dashboard/SKILL.md +34 -0
- package/ftm-debug/SKILL.md +84 -0
- package/ftm-diagram/SKILL.md +44 -0
- package/ftm-executor/SKILL.md +97 -0
- package/ftm-git/SKILL.md +60 -0
- package/ftm-inbox/backend/__init__.py +0 -0
- package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/adapters/__init__.py +0 -0
- package/ftm-inbox/backend/adapters/_retry.py +64 -0
- package/ftm-inbox/backend/adapters/base.py +230 -0
- package/ftm-inbox/backend/adapters/freshservice.py +104 -0
- package/ftm-inbox/backend/adapters/gmail.py +125 -0
- package/ftm-inbox/backend/adapters/jira.py +136 -0
- package/ftm-inbox/backend/adapters/registry.py +192 -0
- package/ftm-inbox/backend/adapters/slack.py +110 -0
- package/ftm-inbox/backend/db/__init__.py +0 -0
- package/ftm-inbox/backend/db/connection.py +54 -0
- package/ftm-inbox/backend/db/schema.py +78 -0
- package/ftm-inbox/backend/executor/__init__.py +7 -0
- package/ftm-inbox/backend/executor/engine.py +149 -0
- package/ftm-inbox/backend/executor/step_runner.py +98 -0
- package/ftm-inbox/backend/main.py +103 -0
- package/ftm-inbox/backend/models/__init__.py +1 -0
- package/ftm-inbox/backend/models/unified_task.py +36 -0
- package/ftm-inbox/backend/planner/__init__.py +6 -0
- package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/planner/generator.py +127 -0
- package/ftm-inbox/backend/planner/schema.py +34 -0
- package/ftm-inbox/backend/requirements.txt +5 -0
- package/ftm-inbox/backend/routes/__init__.py +0 -0
- package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
- package/ftm-inbox/backend/routes/execute.py +186 -0
- package/ftm-inbox/backend/routes/health.py +52 -0
- package/ftm-inbox/backend/routes/inbox.py +68 -0
- package/ftm-inbox/backend/routes/plan.py +271 -0
- package/ftm-inbox/bin/launchagent.mjs +91 -0
- package/ftm-inbox/bin/setup.mjs +188 -0
- package/ftm-inbox/bin/start.sh +10 -0
- package/ftm-inbox/bin/status.sh +17 -0
- package/ftm-inbox/bin/stop.sh +8 -0
- package/ftm-inbox/config.example.yml +55 -0
- package/ftm-inbox/package-lock.json +2898 -0
- package/ftm-inbox/package.json +26 -0
- package/ftm-inbox/postcss.config.js +6 -0
- package/ftm-inbox/src/app.css +199 -0
- package/ftm-inbox/src/app.html +18 -0
- package/ftm-inbox/src/lib/api.ts +166 -0
- package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -0
- package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -0
- package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -0
- package/ftm-inbox/src/lib/components/PlanView.svelte +206 -0
- package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -0
- package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -0
- package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -0
- package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -0
- package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -0
- package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -0
- package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -0
- package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -0
- package/ftm-inbox/src/lib/theme.ts +47 -0
- package/ftm-inbox/src/routes/+layout.svelte +76 -0
- package/ftm-inbox/src/routes/+page.svelte +401 -0
- package/ftm-inbox/static/favicon.png +0 -0
- package/ftm-inbox/svelte.config.js +12 -0
- package/ftm-inbox/tailwind.config.ts +63 -0
- package/ftm-inbox/tsconfig.json +13 -0
- package/ftm-inbox/vite.config.ts +6 -0
- package/ftm-intent/SKILL.md +44 -0
- package/ftm-manifest.json +3794 -0
- package/ftm-map/SKILL.md +50 -0
- package/ftm-mind/SKILL.md +173 -66
- package/ftm-pause/SKILL.md +43 -0
- package/ftm-researcher/SKILL.md +55 -0
- package/ftm-resume/SKILL.md +47 -0
- package/ftm-retro/SKILL.md +54 -0
- package/ftm-routine/SKILL.md +36 -0
- package/ftm-state/blackboard/capabilities.json +5 -0
- package/ftm-state/blackboard/capabilities.schema.json +27 -0
- package/ftm-upgrade/SKILL.md +41 -0
- package/package.json +6 -2
|
@@ -131,10 +131,214 @@ function extractBlackboardPaths(lines) {
|
|
|
131
131
|
return paths;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// New section parsers for the 6 structured YAML contracts
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Parses the ## Requirements section.
|
|
140
|
+
* Format: - type: `name` | required|optional | description
|
|
141
|
+
* Returns: Array of { type, name, required, description }
|
|
142
|
+
*/
|
|
143
|
+
function parseRequirements(lines) {
|
|
144
|
+
const requirements = [];
|
|
145
|
+
// Match lines like: - tool: `knip` | required | static analysis engine
|
|
146
|
+
// or: - config: `knip.config.ts` | optional | custom knip config
|
|
147
|
+
const reqRegex = /^-\s+(tool|config|reference|env):\s+`([^`]+)`\s*\|\s*(required|optional)\s*\|\s*(.+)/;
|
|
148
|
+
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
const match = line.match(reqRegex);
|
|
151
|
+
if (match) {
|
|
152
|
+
requirements.push({
|
|
153
|
+
type: match[1],
|
|
154
|
+
name: match[2],
|
|
155
|
+
required: match[3] === 'required',
|
|
156
|
+
description: match[4].trim(),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return requirements;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parses the ## Risk section.
|
|
166
|
+
* Format:
|
|
167
|
+
* - level: read_only | low_write | medium_write | high_write | destructive
|
|
168
|
+
* - scope: description
|
|
169
|
+
* - rollback: description
|
|
170
|
+
* Returns: { level, scope, rollback }
|
|
171
|
+
*/
|
|
172
|
+
function parseRisk(lines) {
|
|
173
|
+
let level = null;
|
|
174
|
+
let scope = null;
|
|
175
|
+
let rollback = null;
|
|
176
|
+
|
|
177
|
+
for (const line of lines) {
|
|
178
|
+
const levelMatch = line.match(/^-\s+level:\s+(.+)/);
|
|
179
|
+
const scopeMatch = line.match(/^-\s+scope:\s+(.+)/);
|
|
180
|
+
const rollbackMatch = line.match(/^-\s+rollback:\s+(.+)/);
|
|
181
|
+
|
|
182
|
+
if (levelMatch) level = levelMatch[1].trim();
|
|
183
|
+
if (scopeMatch) scope = scopeMatch[1].trim();
|
|
184
|
+
if (rollbackMatch) rollback = rollbackMatch[1].trim();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { level, scope, rollback };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parses the ## Approval Gates section.
|
|
192
|
+
* Format:
|
|
193
|
+
* - trigger: condition | action: what happens
|
|
194
|
+
* - complexity_routing: micro → auto | small → auto | ...
|
|
195
|
+
* Returns: { gates: Array<{ trigger, action }>, complexity_routing: object }
|
|
196
|
+
*/
|
|
197
|
+
function parseApprovalGates(lines) {
|
|
198
|
+
const gates = [];
|
|
199
|
+
let complexity_routing = null;
|
|
200
|
+
|
|
201
|
+
for (const line of lines) {
|
|
202
|
+
// complexity_routing line
|
|
203
|
+
const crMatch = line.match(/^-\s+complexity_routing:\s+(.+)/);
|
|
204
|
+
if (crMatch) {
|
|
205
|
+
// Parse: micro → auto | small → auto | medium → plan_first | ...
|
|
206
|
+
const routing = {};
|
|
207
|
+
const parts = crMatch[1].split('|').map(s => s.trim());
|
|
208
|
+
for (const part of parts) {
|
|
209
|
+
const arrowMatch = part.match(/^(\w+)\s+[→>-]+\s+(.+)/);
|
|
210
|
+
if (arrowMatch) {
|
|
211
|
+
routing[arrowMatch[1].trim()] = arrowMatch[2].trim();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
complexity_routing = routing;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// trigger/action line
|
|
219
|
+
const triggerMatch = line.match(/^-\s+trigger:\s+(.+?)\s*\|\s*action:\s+(.+)/);
|
|
220
|
+
if (triggerMatch) {
|
|
221
|
+
gates.push({
|
|
222
|
+
trigger: triggerMatch[1].trim(),
|
|
223
|
+
action: triggerMatch[2].trim(),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { gates, complexity_routing };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Parses the ## Fallbacks section.
|
|
233
|
+
* Format: - condition: description | action: what happens
|
|
234
|
+
* Returns: Array of { condition, action }
|
|
235
|
+
*/
|
|
236
|
+
function parseFallbacks(lines) {
|
|
237
|
+
const fallbacks = [];
|
|
238
|
+
const fallbackRegex = /^-\s+condition:\s+(.+?)\s*\|\s*action:\s+(.+)/;
|
|
239
|
+
|
|
240
|
+
for (const line of lines) {
|
|
241
|
+
const match = line.match(fallbackRegex);
|
|
242
|
+
if (match) {
|
|
243
|
+
fallbacks.push({
|
|
244
|
+
condition: match[1].trim(),
|
|
245
|
+
action: match[2].trim(),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return fallbacks;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Parses the ## Capabilities section.
|
|
255
|
+
* Format: - mcp|cli|env: `name` | required|optional | description
|
|
256
|
+
* Returns: Array of { type, name, required, description }
|
|
257
|
+
*/
|
|
258
|
+
function parseCapabilities(lines) {
|
|
259
|
+
const capabilities = [];
|
|
260
|
+
const capRegex = /^-\s+(mcp|cli|env):\s+`([^`]+)`\s*\|\s*(required|optional)\s*\|\s*(.+)/;
|
|
261
|
+
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
const match = line.match(capRegex);
|
|
264
|
+
if (match) {
|
|
265
|
+
capabilities.push({
|
|
266
|
+
type: match[1],
|
|
267
|
+
name: match[2],
|
|
268
|
+
required: match[3] === 'required',
|
|
269
|
+
description: match[4].trim(),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return capabilities;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Parses the ## Event Payloads section.
|
|
279
|
+
* Format:
|
|
280
|
+
* ### event_name
|
|
281
|
+
* - field: type — description
|
|
282
|
+
* Returns: object mapping event_name -> Array<{ field, type, description }>
|
|
283
|
+
*/
|
|
284
|
+
function parseEventPayloads(content) {
|
|
285
|
+
const payloads = {};
|
|
286
|
+
|
|
287
|
+
// We need to parse ### sub-sections within ## Event Payloads.
|
|
288
|
+
// Find the section start, then extract up to the next ## heading or end of string.
|
|
289
|
+
// Using manual indexing is more reliable than regex for the "last section" case.
|
|
290
|
+
const sectionStart = content.indexOf('\n## Event Payloads\n');
|
|
291
|
+
if (sectionStart < 0) return payloads;
|
|
292
|
+
|
|
293
|
+
const afterHeading = content.substring(sectionStart + '\n## Event Payloads\n'.length);
|
|
294
|
+
const nextH2 = afterHeading.indexOf('\n## ');
|
|
295
|
+
const sectionContent = nextH2 >= 0 ? afterHeading.substring(0, nextH2) : afterHeading;
|
|
296
|
+
const lines = sectionContent.split('\n');
|
|
297
|
+
|
|
298
|
+
let currentEvent = null;
|
|
299
|
+
|
|
300
|
+
for (const line of lines) {
|
|
301
|
+
// ### event_name header
|
|
302
|
+
const h3Match = line.match(/^###\s+(.+)/);
|
|
303
|
+
if (h3Match) {
|
|
304
|
+
currentEvent = h3Match[1].trim();
|
|
305
|
+
payloads[currentEvent] = [];
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!currentEvent) continue;
|
|
310
|
+
|
|
311
|
+
// - field: type — description
|
|
312
|
+
const fieldMatch = line.match(/^-\s+(\w+):\s+([\w\[\]|]+)\s+[—-]+\s+(.+)/);
|
|
313
|
+
if (fieldMatch) {
|
|
314
|
+
payloads[currentEvent].push({
|
|
315
|
+
field: fieldMatch[1],
|
|
316
|
+
type: fieldMatch[2],
|
|
317
|
+
description: fieldMatch[3].trim(),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return payloads;
|
|
323
|
+
}
|
|
324
|
+
|
|
134
325
|
// ---------------------------------------------------------------------------
|
|
135
326
|
// Per-skill metadata extraction
|
|
136
327
|
// ---------------------------------------------------------------------------
|
|
137
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Required sections for the structured YAML contract.
|
|
331
|
+
* Used to generate warnings when sections are missing.
|
|
332
|
+
*/
|
|
333
|
+
const REQUIRED_CONTRACT_SECTIONS = [
|
|
334
|
+
'Requirements',
|
|
335
|
+
'Risk',
|
|
336
|
+
'Approval Gates',
|
|
337
|
+
'Fallbacks',
|
|
338
|
+
'Capabilities',
|
|
339
|
+
'Event Payloads',
|
|
340
|
+
];
|
|
341
|
+
|
|
138
342
|
function processSkill({ skillFile, skillDir, triggerFile }) {
|
|
139
343
|
const raw = fs.readFileSync(skillFile, 'utf8');
|
|
140
344
|
const stat = fs.statSync(skillFile);
|
|
@@ -151,6 +355,24 @@ function processSkill({ skillFile, skillDir, triggerFile }) {
|
|
|
151
355
|
const blackboardReads = extractBlackboardPaths(sections['Blackboard Read'] || []);
|
|
152
356
|
const blackboardWrites = extractBlackboardPaths(sections['Blackboard Write'] || []);
|
|
153
357
|
|
|
358
|
+
// New structured contract sections
|
|
359
|
+
const requirements = parseRequirements(sections['Requirements'] || []);
|
|
360
|
+
const risk = parseRisk(sections['Risk'] || []);
|
|
361
|
+
const { gates: approval_gates, complexity_routing } = parseApprovalGates(
|
|
362
|
+
sections['Approval Gates'] || []
|
|
363
|
+
);
|
|
364
|
+
const fallbacks = parseFallbacks(sections['Fallbacks'] || []);
|
|
365
|
+
const capabilities = parseCapabilities(sections['Capabilities'] || []);
|
|
366
|
+
const event_payloads = parseEventPayloads(parsed.content);
|
|
367
|
+
|
|
368
|
+
// Warnings — flag any missing required contract sections
|
|
369
|
+
const warnings = [];
|
|
370
|
+
for (const section of REQUIRED_CONTRACT_SECTIONS) {
|
|
371
|
+
if (!sections[section]) {
|
|
372
|
+
warnings.push(`Missing required section: ## ${section}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
154
376
|
// References directory
|
|
155
377
|
const referencesDir = path.join(ROOT, skillDir, 'references');
|
|
156
378
|
let references = [];
|
|
@@ -178,8 +400,16 @@ function processSkill({ skillFile, skillDir, triggerFile }) {
|
|
|
178
400
|
events_listens: eventsListens,
|
|
179
401
|
blackboard_reads: blackboardReads,
|
|
180
402
|
blackboard_writes: blackboardWrites,
|
|
403
|
+
requirements,
|
|
404
|
+
risk,
|
|
405
|
+
approval_gates,
|
|
406
|
+
complexity_routing,
|
|
407
|
+
fallbacks,
|
|
408
|
+
capabilities,
|
|
409
|
+
event_payloads,
|
|
181
410
|
references,
|
|
182
411
|
has_evals: hasEvals,
|
|
412
|
+
warnings,
|
|
183
413
|
size_bytes: stat.size,
|
|
184
414
|
enabled: true,
|
|
185
415
|
};
|
|
@@ -196,15 +426,38 @@ function main() {
|
|
|
196
426
|
// Sort alphabetically by name
|
|
197
427
|
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
198
428
|
|
|
429
|
+
// Collect manifest-level warnings for skills with missing sections
|
|
430
|
+
const manifestWarnings = [];
|
|
431
|
+
for (const skill of skills) {
|
|
432
|
+
if (skill.warnings && skill.warnings.length > 0) {
|
|
433
|
+
manifestWarnings.push({
|
|
434
|
+
skill: skill.name,
|
|
435
|
+
warnings: skill.warnings,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
199
440
|
const manifest = {
|
|
200
441
|
generated_at: new Date().toISOString(),
|
|
201
442
|
skills,
|
|
443
|
+
warnings: manifestWarnings,
|
|
202
444
|
};
|
|
203
445
|
|
|
204
446
|
const outputPath = path.join(ROOT, 'ftm-manifest.json');
|
|
205
447
|
fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
206
448
|
|
|
207
449
|
process.stderr.write(`Generated manifest for ${skills.length} skills\n`);
|
|
450
|
+
|
|
451
|
+
if (manifestWarnings.length > 0) {
|
|
452
|
+
process.stderr.write(
|
|
453
|
+
`Warnings: ${manifestWarnings.length} skill(s) missing required contract sections\n`
|
|
454
|
+
);
|
|
455
|
+
for (const w of manifestWarnings) {
|
|
456
|
+
process.stderr.write(` ${w.skill}: ${w.warnings.join(', ')}\n`);
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
process.stderr.write(`All ${skills.length} skills have complete contract sections\n`);
|
|
460
|
+
}
|
|
208
461
|
}
|
|
209
462
|
|
|
210
463
|
main();
|
package/bin/install.mjs
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* and symlinking them into the Claude Code skills directory.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { existsSync, mkdirSync, readdirSync, lstatSync, readFileSync, writeFileSync, copyFileSync, symlinkSync, unlinkSync, chmodSync } from "fs";
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, lstatSync, readFileSync, writeFileSync, copyFileSync, symlinkSync, unlinkSync, chmodSync, cpSync } from "fs";
|
|
11
11
|
import { join, basename, dirname } from "path";
|
|
12
|
-
import { homedir } from "os";
|
|
12
|
+
import { homedir, platform } from "os";
|
|
13
13
|
import { fileURLToPath } from "url";
|
|
14
|
+
import { execSync, spawnSync } from "child_process";
|
|
14
15
|
|
|
15
16
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
17
|
const __dirname = dirname(__filename);
|
|
@@ -20,6 +21,10 @@ const SKILLS_DIR = join(HOME, ".claude", "skills");
|
|
|
20
21
|
const STATE_DIR = join(HOME, ".claude", "ftm-state");
|
|
21
22
|
const CONFIG_DIR = join(HOME, ".claude");
|
|
22
23
|
const HOOKS_DIR = join(HOME, ".claude", "hooks");
|
|
24
|
+
const INBOX_INSTALL_DIR = join(HOME, ".claude", "ftm-inbox");
|
|
25
|
+
|
|
26
|
+
const ARGS = process.argv.slice(2);
|
|
27
|
+
const WITH_INBOX = ARGS.includes("--with-inbox");
|
|
23
28
|
|
|
24
29
|
function log(msg) {
|
|
25
30
|
console.log(` ${msg}`);
|
|
@@ -139,6 +144,100 @@ function main() {
|
|
|
139
144
|
console.log(" Option B: Copy entries from hooks/settings-template.json manually");
|
|
140
145
|
console.log(" See docs/HOOKS.md for details.");
|
|
141
146
|
console.log("");
|
|
147
|
+
|
|
148
|
+
if (WITH_INBOX) {
|
|
149
|
+
installInbox();
|
|
150
|
+
} else {
|
|
151
|
+
console.log("Try: /ftm help");
|
|
152
|
+
console.log(" To also install the inbox service: npx feed-the-machine --with-inbox");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function installInbox() {
|
|
157
|
+
const inboxSrc = join(REPO_DIR, "ftm-inbox");
|
|
158
|
+
if (!existsSync(inboxSrc)) {
|
|
159
|
+
console.error("ERROR: ftm-inbox/ not found in package. Cannot install inbox service.");
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log("Installing ftm-inbox service...");
|
|
164
|
+
console.log(` Source: ${inboxSrc}`);
|
|
165
|
+
console.log(` Destination: ${INBOX_INSTALL_DIR}`);
|
|
166
|
+
console.log("");
|
|
167
|
+
|
|
168
|
+
// Copy ftm-inbox/ to ~/.claude/ftm-inbox/
|
|
169
|
+
ensureDir(INBOX_INSTALL_DIR);
|
|
170
|
+
cpSync(inboxSrc, INBOX_INSTALL_DIR, { recursive: true });
|
|
171
|
+
log("COPY ftm-inbox → ~/.claude/ftm-inbox/");
|
|
172
|
+
|
|
173
|
+
// Make shell scripts executable
|
|
174
|
+
const binDir = join(INBOX_INSTALL_DIR, "bin");
|
|
175
|
+
const scripts = ["start.sh", "stop.sh", "status.sh"];
|
|
176
|
+
for (const script of scripts) {
|
|
177
|
+
const scriptPath = join(binDir, script);
|
|
178
|
+
if (existsSync(scriptPath)) {
|
|
179
|
+
chmodSync(scriptPath, 0o755);
|
|
180
|
+
log(`CHMOD +x bin/${script}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Install Node deps if package.json exists
|
|
185
|
+
const pkgJson = join(INBOX_INSTALL_DIR, "package.json");
|
|
186
|
+
if (existsSync(pkgJson)) {
|
|
187
|
+
console.log("");
|
|
188
|
+
console.log("Installing Node.js dependencies...");
|
|
189
|
+
const npmResult = spawnSync("npm", ["install", "--prefix", INBOX_INSTALL_DIR], {
|
|
190
|
+
stdio: "inherit",
|
|
191
|
+
cwd: INBOX_INSTALL_DIR,
|
|
192
|
+
});
|
|
193
|
+
if (npmResult.status !== 0) {
|
|
194
|
+
console.warn("WARNING: npm install failed. Check Node.js version and try manually.");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Install Python deps if requirements.txt exists
|
|
199
|
+
const reqTxt = join(INBOX_INSTALL_DIR, "requirements.txt");
|
|
200
|
+
if (existsSync(reqTxt)) {
|
|
201
|
+
console.log("");
|
|
202
|
+
console.log("Installing Python dependencies...");
|
|
203
|
+
const pipResult = spawnSync("pip3", ["install", "-r", reqTxt], {
|
|
204
|
+
stdio: "inherit",
|
|
205
|
+
cwd: INBOX_INSTALL_DIR,
|
|
206
|
+
});
|
|
207
|
+
if (pipResult.status !== 0) {
|
|
208
|
+
console.warn("WARNING: pip3 install failed. Check Python 3 and try manually:");
|
|
209
|
+
console.warn(` pip3 install -r ${reqTxt}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Run setup wizard
|
|
214
|
+
console.log("");
|
|
215
|
+
console.log("Running setup wizard...");
|
|
216
|
+
const setupScript = join(binDir, "setup.mjs");
|
|
217
|
+
if (existsSync(setupScript)) {
|
|
218
|
+
const setupResult = spawnSync("node", [setupScript], { stdio: "inherit" });
|
|
219
|
+
if (setupResult.status !== 0) {
|
|
220
|
+
console.warn("WARNING: Setup wizard exited with errors.");
|
|
221
|
+
console.warn(`Re-run manually: node ${setupScript}`);
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
console.warn("WARNING: setup.mjs not found. Run setup manually.");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Offer LaunchAgent (macOS only)
|
|
228
|
+
if (platform() === "darwin") {
|
|
229
|
+
console.log("");
|
|
230
|
+
console.log("macOS detected. To auto-start ftm-inbox on login, run:");
|
|
231
|
+
console.log(` node ${join(binDir, "launchagent.mjs")}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
console.log("");
|
|
235
|
+
console.log("ftm-inbox installed.");
|
|
236
|
+
console.log(` Start: ${join(binDir, "start.sh")}`);
|
|
237
|
+
console.log(` Stop: ${join(binDir, "stop.sh")}`);
|
|
238
|
+
console.log(` Status: ${join(binDir, "status.sh")}`);
|
|
239
|
+
console.log("");
|
|
240
|
+
console.log("See docs/INBOX.md for full documentation.");
|
|
142
241
|
console.log("Try: /ftm help");
|
|
143
242
|
}
|
|
144
243
|
|
package/docs/INBOX.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# ftm-inbox
|
|
2
|
+
|
|
3
|
+
ftm-inbox is an optional background service that polls your work tools (Jira, Freshservice, Slack, Gmail) and surfaces actionable items directly inside the FTM Operator Cockpit dashboard. It runs locally on your machine and never sends data to external services.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
Without ftm-inbox, the Operator Cockpit is a static interface. With it:
|
|
8
|
+
|
|
9
|
+
- Jira issues assigned to you appear as inbox items
|
|
10
|
+
- Freshservice tickets awaiting your response are surfaced
|
|
11
|
+
- Slack DMs and mentions are queued for triage
|
|
12
|
+
- Gmail threads that match configurable filters are included
|
|
13
|
+
|
|
14
|
+
Each item is stored in a local SQLite database. The FTM skills (`/ftm-mind`, `/ftm-executor`) can read from this inbox to generate plans and take action on your behalf.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx feed-the-machine --with-inbox
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This will:
|
|
23
|
+
|
|
24
|
+
1. Install core FTM skills (same as `npx feed-the-machine`)
|
|
25
|
+
2. Copy `ftm-inbox/` to `~/.claude/ftm-inbox/`
|
|
26
|
+
3. Run `npm install` for Node dependencies
|
|
27
|
+
4. Run `pip3 install -r requirements.txt` for Python dependencies
|
|
28
|
+
5. Launch the interactive setup wizard
|
|
29
|
+
6. Optionally install a macOS LaunchAgent for auto-start on login
|
|
30
|
+
|
|
31
|
+
The core `npx feed-the-machine` install (without `--with-inbox`) is completely unchanged.
|
|
32
|
+
|
|
33
|
+
### Requirements
|
|
34
|
+
|
|
35
|
+
- Node.js 18+
|
|
36
|
+
- Python 3.9+
|
|
37
|
+
- `pip3` in PATH
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
The setup wizard writes credentials to `~/.claude/ftm-inbox/config.yml`. This directory is outside any git repository and should never be committed.
|
|
42
|
+
|
|
43
|
+
### config.yml reference
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
server:
|
|
47
|
+
port: 8042 # Port for the local API (default: 8042)
|
|
48
|
+
|
|
49
|
+
adapters:
|
|
50
|
+
jira:
|
|
51
|
+
enabled: true
|
|
52
|
+
base_url: "https://yourorg.atlassian.net"
|
|
53
|
+
email: "you@example.com"
|
|
54
|
+
api_token: "your-jira-api-token"
|
|
55
|
+
poll_interval_seconds: 60
|
|
56
|
+
|
|
57
|
+
freshservice:
|
|
58
|
+
enabled: true
|
|
59
|
+
domain: "yourorg.freshservice.com"
|
|
60
|
+
api_key: "your-freshservice-api-key"
|
|
61
|
+
poll_interval_seconds: 120
|
|
62
|
+
|
|
63
|
+
slack:
|
|
64
|
+
enabled: true
|
|
65
|
+
bot_token: "xoxb-your-slack-bot-token"
|
|
66
|
+
poll_interval_seconds: 30
|
|
67
|
+
|
|
68
|
+
gmail:
|
|
69
|
+
enabled: false
|
|
70
|
+
credentials_path: "~/credentials.json"
|
|
71
|
+
poll_interval_seconds: 120
|
|
72
|
+
|
|
73
|
+
database:
|
|
74
|
+
path: "~/.claude/ftm-inbox/inbox.db"
|
|
75
|
+
|
|
76
|
+
logging:
|
|
77
|
+
level: "INFO"
|
|
78
|
+
path: "~/.claude/ftm-inbox/logs"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
To re-run the wizard after initial setup:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
node ~/.claude/ftm-inbox/bin/setup.mjs
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
To edit manually:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
$EDITOR ~/.claude/ftm-inbox/config.yml
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Starting and Stopping
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Start the service
|
|
97
|
+
~/.claude/ftm-inbox/bin/start.sh
|
|
98
|
+
|
|
99
|
+
# Stop the service
|
|
100
|
+
~/.claude/ftm-inbox/bin/stop.sh
|
|
101
|
+
|
|
102
|
+
# Check status and last poll times
|
|
103
|
+
~/.claude/ftm-inbox/bin/status.sh
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The port can be overridden at runtime:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
FTM_INBOX_PORT=9000 ~/.claude/ftm-inbox/bin/start.sh
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Auto-start on Login (macOS)
|
|
113
|
+
|
|
114
|
+
To generate and load a LaunchAgent that starts ftm-inbox on login:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
node ~/.claude/ftm-inbox/bin/launchagent.mjs
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
This creates `~/Library/LaunchAgents/com.ftm.inbox.plist` and loads it immediately. Logs are written to `~/.claude/ftm-inbox/logs/`.
|
|
121
|
+
|
|
122
|
+
To remove the LaunchAgent:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
launchctl unload ~/Library/LaunchAgents/com.ftm.inbox.plist
|
|
126
|
+
rm ~/Library/LaunchAgents/com.ftm.inbox.plist
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Architecture
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
External Services ftm-inbox FTM Skills
|
|
133
|
+
────────────────── ───────────────────────── ──────────────────
|
|
134
|
+
Jira REST API ──────► Jira Adapter (poller) ─┐
|
|
135
|
+
Freshservice API ──────► Freshservice Adapter ─┤► SQLite DB ──► FastAPI ──► /ftm-mind
|
|
136
|
+
Slack API ──────► Slack Adapter ─┤ inbox.db 8042 /ftm-executor
|
|
137
|
+
Gmail API ──────► Gmail Adapter ─┘
|
|
138
|
+
▲
|
|
139
|
+
│
|
|
140
|
+
Svelte Dashboard
|
|
141
|
+
(Operator Cockpit)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- **Adapters** poll their respective APIs on configurable intervals and write normalized `InboxItem` records to SQLite
|
|
145
|
+
- **FastAPI backend** (`backend/main.py`) exposes a REST API at `http://localhost:8042`
|
|
146
|
+
- **Svelte dashboard** reads from the API and renders the Operator Cockpit UI
|
|
147
|
+
- **FTM skills** use the API to read inbox items and generate or execute plans
|
|
148
|
+
|
|
149
|
+
## Adding a Custom Poller
|
|
150
|
+
|
|
151
|
+
1. Create a new file in `ftm-inbox/backend/adapters/`:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
# ftm-inbox/backend/adapters/my_service.py
|
|
155
|
+
from .base import BaseAdapter, InboxItem
|
|
156
|
+
from typing import List
|
|
157
|
+
|
|
158
|
+
class MyServiceAdapter(BaseAdapter):
|
|
159
|
+
name = "my_service"
|
|
160
|
+
|
|
161
|
+
async def fetch(self) -> List[InboxItem]:
|
|
162
|
+
# Hit your API, return a list of InboxItem objects
|
|
163
|
+
items = []
|
|
164
|
+
# ... your logic here ...
|
|
165
|
+
return items
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
2. Add credentials to `~/.claude/ftm-inbox/config.yml`:
|
|
169
|
+
|
|
170
|
+
```yaml
|
|
171
|
+
adapters:
|
|
172
|
+
my_service:
|
|
173
|
+
enabled: true
|
|
174
|
+
api_key: "your-key"
|
|
175
|
+
poll_interval_seconds: 60
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
3. Register it in `ftm-inbox/backend/adapters/__init__.py`:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from .my_service import MyServiceAdapter
|
|
182
|
+
ADAPTERS = [..., MyServiceAdapter]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
4. Restart the service: `~/.claude/ftm-inbox/bin/stop.sh && ~/.claude/ftm-inbox/bin/start.sh`
|
|
186
|
+
|
|
187
|
+
## Troubleshooting
|
|
188
|
+
|
|
189
|
+
### Service won't start
|
|
190
|
+
|
|
191
|
+
Check that Python 3 and uvicorn are installed:
|
|
192
|
+
```bash
|
|
193
|
+
python3 --version
|
|
194
|
+
python3 -c "import uvicorn; print(uvicorn.__version__)"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
If uvicorn is missing:
|
|
198
|
+
```bash
|
|
199
|
+
pip3 install -r ~/.claude/ftm-inbox/requirements.txt
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### No items appearing in the dashboard
|
|
203
|
+
|
|
204
|
+
1. Check the service is running: `~/.claude/ftm-inbox/bin/status.sh`
|
|
205
|
+
2. Check logs: `tail -f ~/.claude/ftm-inbox/logs/*.log`
|
|
206
|
+
3. Verify credentials in `~/.claude/ftm-inbox/config.yml`
|
|
207
|
+
4. Confirm the adapter is set to `enabled: true`
|
|
208
|
+
|
|
209
|
+
### Port conflict
|
|
210
|
+
|
|
211
|
+
If port 8042 is already in use:
|
|
212
|
+
```bash
|
|
213
|
+
FTM_INBOX_PORT=9042 ~/.claude/ftm-inbox/bin/start.sh
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Update `config.yml` to match so the dashboard connects to the right port.
|
|
217
|
+
|
|
218
|
+
### Jira authentication errors
|
|
219
|
+
|
|
220
|
+
Jira Cloud requires an API token, not your password. Generate one at:
|
|
221
|
+
`https://id.atlassian.com/manage-profile/security/api-tokens`
|
|
222
|
+
|
|
223
|
+
### Freshservice 403 errors
|
|
224
|
+
|
|
225
|
+
Ensure the API key belongs to an agent with at least Viewer permissions on the relevant groups.
|
|
226
|
+
|
|
227
|
+
### Re-running setup
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
node ~/.claude/ftm-inbox/bin/setup.mjs
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
This overwrites `~/.claude/ftm-inbox/config.yml` but does not touch the database.
|
package/ftm/SKILL.md
CHANGED
|
@@ -33,6 +33,7 @@ If input starts with a recognized skill name, route directly to that skill:
|
|
|
33
33
|
| `upgrade` | ftm-upgrade |
|
|
34
34
|
| `retro` | ftm-retro |
|
|
35
35
|
| `config` | ftm-config |
|
|
36
|
+
| `capture`, `codify`, `save as routine` | ftm-capture |
|
|
36
37
|
| `mind` | ftm-mind |
|
|
37
38
|
|
|
38
39
|
When routing to a specific skill:
|
|
@@ -77,6 +78,7 @@ FTM Skills:
|
|
|
77
78
|
/ftm upgrade — Check for and install skill updates
|
|
78
79
|
/ftm retro — Post-execution retrospective
|
|
79
80
|
/ftm config — View and edit ftm configuration
|
|
81
|
+
/ftm capture [name] — Extract routine + playbook from current session
|
|
80
82
|
|
|
81
83
|
Or just describe what you need and ftm-mind will handle it.
|
|
82
84
|
```
|
|
@@ -86,3 +88,35 @@ Or just describe what you need and ftm-mind will handle it.
|
|
|
86
88
|
- Do not attempt to do the work yourself — route only.
|
|
87
89
|
- Be fast — decisive routing, not conversation.
|
|
88
90
|
- Case insensitive matching for all prefix detection.
|
|
91
|
+
|
|
92
|
+
## Requirements
|
|
93
|
+
|
|
94
|
+
- config: `~/.claude/ftm-config.yml` | optional | legacy_router_fallback setting
|
|
95
|
+
- reference: `~/.claude/ftm-state/blackboard/context.json` | optional | session state for blackboard update on routing
|
|
96
|
+
- tool: none beyond skill invocation mechanism
|
|
97
|
+
|
|
98
|
+
## Risk
|
|
99
|
+
|
|
100
|
+
- level: read_only
|
|
101
|
+
- scope: reads blackboard context.json and updates session_metadata.skills_invoked before routing; does not modify any project files
|
|
102
|
+
- rollback: no mutations to reverse; blackboard update is a metadata append
|
|
103
|
+
|
|
104
|
+
## Approval Gates
|
|
105
|
+
|
|
106
|
+
- trigger: ftm-mind failure AND legacy_router_fallback enabled | action: fall back to keyword routing automatically (no user gate needed)
|
|
107
|
+
- complexity_routing: micro → auto | small → auto | medium → auto | large → auto | xl → auto
|
|
108
|
+
|
|
109
|
+
## Fallbacks
|
|
110
|
+
|
|
111
|
+
- condition: ftm-mind fails or times out | action: check legacy_router_fallback in ftm-config.yml; if true, use keyword matching; if false, report failure
|
|
112
|
+
- condition: blackboard context.json missing | action: skip blackboard update, proceed with routing
|
|
113
|
+
- condition: skill tool unavailable for target skill | action: report routing failure to user with the target skill name
|
|
114
|
+
|
|
115
|
+
## Capabilities
|
|
116
|
+
|
|
117
|
+
- env: none required
|
|
118
|
+
|
|
119
|
+
## Event Payloads
|
|
120
|
+
|
|
121
|
+
### (none)
|
|
122
|
+
ftm is a pure router and does not emit events directly. Events are emitted by the target skill after routing.
|