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.
Files changed (94) hide show
  1. package/bin/generate-manifest.mjs +253 -0
  2. package/bin/install.mjs +101 -2
  3. package/docs/INBOX.md +233 -0
  4. package/ftm/SKILL.md +34 -0
  5. package/ftm-audit/SKILL.md +69 -0
  6. package/ftm-brainstorm/SKILL.md +51 -0
  7. package/ftm-browse/SKILL.md +39 -0
  8. package/ftm-capture/SKILL.md +370 -0
  9. package/ftm-capture.yml +4 -0
  10. package/ftm-codex-gate/SKILL.md +59 -0
  11. package/ftm-config/SKILL.md +35 -0
  12. package/ftm-council/SKILL.md +56 -0
  13. package/ftm-dashboard/SKILL.md +34 -0
  14. package/ftm-debug/SKILL.md +84 -0
  15. package/ftm-diagram/SKILL.md +44 -0
  16. package/ftm-executor/SKILL.md +97 -0
  17. package/ftm-git/SKILL.md +60 -0
  18. package/ftm-inbox/backend/__init__.py +0 -0
  19. package/ftm-inbox/backend/__pycache__/main.cpython-314.pyc +0 -0
  20. package/ftm-inbox/backend/adapters/__init__.py +0 -0
  21. package/ftm-inbox/backend/adapters/_retry.py +64 -0
  22. package/ftm-inbox/backend/adapters/base.py +230 -0
  23. package/ftm-inbox/backend/adapters/freshservice.py +104 -0
  24. package/ftm-inbox/backend/adapters/gmail.py +125 -0
  25. package/ftm-inbox/backend/adapters/jira.py +136 -0
  26. package/ftm-inbox/backend/adapters/registry.py +192 -0
  27. package/ftm-inbox/backend/adapters/slack.py +110 -0
  28. package/ftm-inbox/backend/db/__init__.py +0 -0
  29. package/ftm-inbox/backend/db/connection.py +54 -0
  30. package/ftm-inbox/backend/db/schema.py +78 -0
  31. package/ftm-inbox/backend/executor/__init__.py +7 -0
  32. package/ftm-inbox/backend/executor/engine.py +149 -0
  33. package/ftm-inbox/backend/executor/step_runner.py +98 -0
  34. package/ftm-inbox/backend/main.py +103 -0
  35. package/ftm-inbox/backend/models/__init__.py +1 -0
  36. package/ftm-inbox/backend/models/unified_task.py +36 -0
  37. package/ftm-inbox/backend/planner/__init__.py +6 -0
  38. package/ftm-inbox/backend/planner/__pycache__/__init__.cpython-314.pyc +0 -0
  39. package/ftm-inbox/backend/planner/__pycache__/generator.cpython-314.pyc +0 -0
  40. package/ftm-inbox/backend/planner/__pycache__/schema.cpython-314.pyc +0 -0
  41. package/ftm-inbox/backend/planner/generator.py +127 -0
  42. package/ftm-inbox/backend/planner/schema.py +34 -0
  43. package/ftm-inbox/backend/requirements.txt +5 -0
  44. package/ftm-inbox/backend/routes/__init__.py +0 -0
  45. package/ftm-inbox/backend/routes/__pycache__/plan.cpython-314.pyc +0 -0
  46. package/ftm-inbox/backend/routes/execute.py +186 -0
  47. package/ftm-inbox/backend/routes/health.py +52 -0
  48. package/ftm-inbox/backend/routes/inbox.py +68 -0
  49. package/ftm-inbox/backend/routes/plan.py +271 -0
  50. package/ftm-inbox/bin/launchagent.mjs +91 -0
  51. package/ftm-inbox/bin/setup.mjs +188 -0
  52. package/ftm-inbox/bin/start.sh +10 -0
  53. package/ftm-inbox/bin/status.sh +17 -0
  54. package/ftm-inbox/bin/stop.sh +8 -0
  55. package/ftm-inbox/config.example.yml +55 -0
  56. package/ftm-inbox/package-lock.json +2898 -0
  57. package/ftm-inbox/package.json +26 -0
  58. package/ftm-inbox/postcss.config.js +6 -0
  59. package/ftm-inbox/src/app.css +199 -0
  60. package/ftm-inbox/src/app.html +18 -0
  61. package/ftm-inbox/src/lib/api.ts +166 -0
  62. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -0
  63. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -0
  64. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -0
  65. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -0
  66. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -0
  67. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -0
  68. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -0
  69. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -0
  70. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -0
  71. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -0
  72. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -0
  73. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -0
  74. package/ftm-inbox/src/lib/theme.ts +47 -0
  75. package/ftm-inbox/src/routes/+layout.svelte +76 -0
  76. package/ftm-inbox/src/routes/+page.svelte +401 -0
  77. package/ftm-inbox/static/favicon.png +0 -0
  78. package/ftm-inbox/svelte.config.js +12 -0
  79. package/ftm-inbox/tailwind.config.ts +63 -0
  80. package/ftm-inbox/tsconfig.json +13 -0
  81. package/ftm-inbox/vite.config.ts +6 -0
  82. package/ftm-intent/SKILL.md +44 -0
  83. package/ftm-manifest.json +3794 -0
  84. package/ftm-map/SKILL.md +50 -0
  85. package/ftm-mind/SKILL.md +173 -66
  86. package/ftm-pause/SKILL.md +43 -0
  87. package/ftm-researcher/SKILL.md +55 -0
  88. package/ftm-resume/SKILL.md +47 -0
  89. package/ftm-retro/SKILL.md +54 -0
  90. package/ftm-routine/SKILL.md +36 -0
  91. package/ftm-state/blackboard/capabilities.json +5 -0
  92. package/ftm-state/blackboard/capabilities.schema.json +27 -0
  93. package/ftm-upgrade/SKILL.md +41 -0
  94. 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.