fdeops 3.9.18 → 3.9.19

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/README.md CHANGED
@@ -91,6 +91,7 @@ npx fdeops resume # prints a short "where we are" for this clien
91
91
  - **You** describe the situation with `@fde` (or plain language once the skill is loaded)
92
92
  - **Session start / end** - small hooks load where you left off and capture what changed (no re-paste)
93
93
  - **Local CLI** - memory writes, search, and status with no model tokens; the agent runs it. You do not need to learn it for daily use ([docs/USAGE.md](docs/USAGE.md))
94
+ - **Pluggable pull (ingest)** - large transcripts and emails can land in `.inbox/` via `fde ingest stage` after **your** source MCPs fetch them (Granola, Gmail, Notion, custom — not bundled in fdeops). Same propose → confirm → apply loop as debrief; nothing unreviewed enters the fieldbook. No ambient sync.
94
95
 
95
96
  fdeops complements repo memory: CLAUDE.md holds how the *code* works; the fieldbook holds how the *client engagement* works.
96
97
 
package/bin/check.js CHANGED
@@ -316,7 +316,7 @@ if (!fs.existsSync(path.join(root, 'bin', 'fde.js'))) {
316
316
  .map(name => path.join('bin', 'lib', name)),
317
317
  ]
318
318
  const cliSource = cliFiles.map(read).join('\n')
319
- for (const sub of ['cmdScan', 'cmdResume', 'cmdLog', 'cmdDebrief', 'cmdReceipts', 'cmdCapture', 'cmdStatus', 'cmdDashboard']) {
319
+ for (const sub of ['cmdScan', 'cmdResume', 'cmdLog', 'cmdDebrief', 'cmdIngest', 'cmdReceipts', 'cmdCapture', 'cmdStatus', 'cmdDashboard']) {
320
320
  if (!cliSource.includes(sub)) fail(`CLI sources missing ${sub}`)
321
321
  }
322
322
  if (!JSON.parse(read('package.json')).bin.fde) fail('package.json must expose the fde bin')
@@ -345,6 +345,14 @@ if (pkg.version !== plugin.version) {
345
345
  fail(`version mismatch package.json ${pkg.version} vs plugin ${plugin.version}`)
346
346
  } else ok('plugin version aligned')
347
347
 
348
+ if (!fs.existsSync(path.join(root, 'mcp', 'fdeops-ingest', 'server.js'))) {
349
+ fail('mcp/fdeops-ingest/server.js missing (ingest MCP sink)')
350
+ } else if (!read('mcp/fdeops-ingest/server.js').includes('ingest_stage')) {
351
+ fail('ingest MCP must expose ingest_stage')
352
+ } else if (!read('skills/fde/references/ingest.md').includes('stage')) {
353
+ fail('skills/fde/references/ingest.md missing stage contract')
354
+ } else ok('ingest MCP + skill reference')
355
+
348
356
  if (!fs.existsSync(path.join(root, '.github', 'ISSUE_TEMPLATE', 'bug_report.yml'))) {
349
357
  fail('GitHub issue template missing')
350
358
  } else ok('issue templates')
package/bin/fde.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * fde prep [label] grounded walk-in brief from existing .fde/ only
20
20
  * fde doctor deterministic memory lint (stale signals, gaps)
21
21
  * fde garden [--apply] propose safe consolidations; apply only with --apply
22
+ * fde ingest … stage → propose → apply pull sink (.inbox/; never auto-writes .fde/)
22
23
  * fde owner [set …] who keeps this engagement record
23
24
  * fde receipts <term> "what did we agree?" - search memory with dates
24
25
  * fde capture session-end snapshot → context.md (hooks use this)
@@ -1399,6 +1400,188 @@ function cmdDebrief(args) {
1399
1400
  console.log(parts.length ? `${verb} → ${parts.join(', ')}` : 'debrief empty - nothing routed')
1400
1401
  }
1401
1402
 
1403
+ // Pull sink: stage raw artifacts outside the memory ledger, then reuse debrief
1404
+ // propose/apply. Never writes .fde/ until the FDE confirms apply. Source SaaS
1405
+ // (Granola/Gmail/…) is not here — only staging + the existing confirm gate.
1406
+ function inboxDir(eng) {
1407
+ return path.join(path.dirname(eng), '.inbox')
1408
+ }
1409
+
1410
+ function sanitizeIngestToken(s, fallback) {
1411
+ const t = String(s || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48)
1412
+ return t || fallback
1413
+ }
1414
+
1415
+ function parseIngestFrontMatter(raw) {
1416
+ const text = String(raw || '')
1417
+ if (!text.startsWith('---\n')) return { meta: {}, body: text }
1418
+ const end = text.indexOf('\n---\n', 4)
1419
+ if (end === -1) return { meta: {}, body: text }
1420
+ const head = text.slice(4, end)
1421
+ const body = text.slice(end + 5)
1422
+ const meta = {}
1423
+ for (const line of head.split('\n')) {
1424
+ const m = /^([a-z_]+):\s*(.*)$/i.exec(line.trim())
1425
+ if (m) meta[m[1].toLowerCase()] = m[2].trim()
1426
+ }
1427
+ return { meta, body }
1428
+ }
1429
+
1430
+ function resolveInboxItem(eng, id) {
1431
+ const box = inboxDir(eng)
1432
+ const want = String(id || '').trim()
1433
+ if (!want) return null
1434
+ const direct = path.join(box, want)
1435
+ if (fs.existsSync(direct) && fs.statSync(direct).isFile()) return direct
1436
+ const withMd = want.endsWith('.md') ? want : `${want}.md`
1437
+ const alt = path.join(box, withMd)
1438
+ if (fs.existsSync(alt) && fs.statSync(alt).isFile()) return alt
1439
+ try {
1440
+ const hits = fs.readdirSync(box).filter(f => f === want || f.startsWith(want) || f.includes(want))
1441
+ if (hits.length === 1) return path.join(box, hits[0])
1442
+ } catch (_) {}
1443
+ return null
1444
+ }
1445
+
1446
+ function cmdIngest(args) {
1447
+ args = args.slice()
1448
+ const sub = (args.shift() || '').toLowerCase()
1449
+ if (!['stage', 'list', 'propose', 'apply'].includes(sub)) {
1450
+ console.error('usage: fde ingest stage [--source NAME] [--title TEXT] [--force] [file|-]\n' +
1451
+ ' fde ingest list\n' +
1452
+ ' fde ingest propose <id>\n' +
1453
+ ' fde ingest apply')
1454
+ process.exit(1)
1455
+ }
1456
+
1457
+ if (sub === 'apply') {
1458
+ cmdDebrief(['--apply', ...args])
1459
+ return
1460
+ }
1461
+
1462
+ const eng = resolveEngagement({ forWrite: true })
1463
+ if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1464
+
1465
+ if (sub === 'list') {
1466
+ const box = inboxDir(eng)
1467
+ if (!fs.existsSync(box)) {
1468
+ console.log(`inbox empty → ${box}`)
1469
+ console.log('(stage with: fde ingest stage --source granola notes.md)')
1470
+ return
1471
+ }
1472
+ const files = fs.readdirSync(box).filter(f => f.endsWith('.md')).sort().reverse()
1473
+ if (!files.length) {
1474
+ console.log(`inbox empty → ${box}`)
1475
+ return
1476
+ }
1477
+ console.log(`INBOX → ${box}\n`)
1478
+ for (const f of files) {
1479
+ const raw = fs.readFileSync(path.join(box, f), 'utf8')
1480
+ const { meta } = parseIngestFrontMatter(raw)
1481
+ const src = meta.source || '?'
1482
+ const title = meta.title || ''
1483
+ const when = meta.staged || ''
1484
+ console.log(` ${f}${title ? ` ${title}` : ''} via:${src}${when ? ` ${when}` : ''}`)
1485
+ }
1486
+ console.log(`\npropose: fde ingest propose <id>`)
1487
+ return
1488
+ }
1489
+
1490
+ if (sub === 'propose') {
1491
+ const id = args[0]
1492
+ if (!id) { console.error('usage: fde ingest propose <id>'); process.exit(1) }
1493
+ const item = resolveInboxItem(eng, id)
1494
+ if (!item) {
1495
+ console.error(`ingest propose: no staged item matching "${id}" - run: fde ingest list`)
1496
+ process.exit(1)
1497
+ }
1498
+ const raw = stripControlChars(fs.readFileSync(item, 'utf8'))
1499
+ const { meta, body } = parseIngestFrontMatter(raw)
1500
+ const source = meta.source || 'manual'
1501
+ const title = meta.title || path.basename(item, '.md')
1502
+ const stamped = meta.staged || 'unknown'
1503
+ const viaLine = `via:${source} ${title} (staged ${stamped}; file ${path.basename(item)})`
1504
+ const input = `${viaLine}\n\n${body.trim()}\n`
1505
+ if (Buffer.byteLength(input) > DEBRIEF_MAX_BYTES) {
1506
+ console.error(`ingest propose refused: staged item is over ${DEBRIEF_MAX_BYTES} bytes after provenance. Split it.`)
1507
+ process.exit(1)
1508
+ }
1509
+ const proposed = smartProposeText(input)
1510
+ const proposePath = path.join(eng, DEBRIEF_PROPOSE)
1511
+ withFileLock(proposePath, () => { atomicWriteFile(proposePath, proposed) })
1512
+ console.log(`INGEST PROPOSE from ${path.basename(item)} (via:${source})\n`)
1513
+ routeDebriefInput(eng, proposed, { dry: true, force: false })
1514
+ console.log(`\nproposal saved → ${proposePath}`)
1515
+ console.log('confirm: fde ingest apply')
1516
+ console.log('(agent: rewrite lines with decision:/risk:/contact:/next: prefixes before apply)')
1517
+ return
1518
+ }
1519
+
1520
+ // stage
1521
+ let source = 'manual'
1522
+ let title = ''
1523
+ let force = false
1524
+ const rest = []
1525
+ for (let i = 0; i < args.length; i++) {
1526
+ if (args[i] === '--source' && args[i + 1]) { source = args[++i]; continue }
1527
+ if (args[i] === '--title' && args[i + 1]) { title = args[++i]; continue }
1528
+ if (args[i] === '--force') { force = true; continue }
1529
+ rest.push(args[i])
1530
+ }
1531
+ source = sanitizeIngestToken(source, 'manual')
1532
+ const titleSlug = sanitizeIngestToken(title || 'notes', 'notes')
1533
+ if (!title) title = titleSlug
1534
+
1535
+ let input = ''
1536
+ if (rest[0] && rest[0] !== '-') {
1537
+ const p = path.resolve(rest[0])
1538
+ try {
1539
+ const st = fs.statSync(p)
1540
+ if (st.size > DEBRIEF_MAX_BYTES) {
1541
+ console.error(`ingest stage refused: ${rest[0]} is ${st.size} bytes (max ${DEBRIEF_MAX_BYTES}). Split or stage a relevant section.`)
1542
+ process.exit(1)
1543
+ }
1544
+ input = stripControlChars(fs.readFileSync(p, 'utf8'))
1545
+ } catch (e) {
1546
+ failFs(e, 'read', p)
1547
+ }
1548
+ } else {
1549
+ input = stripControlChars(fs.readFileSync(0, 'utf8'))
1550
+ if (Buffer.byteLength(input) > DEBRIEF_MAX_BYTES) {
1551
+ console.error(`ingest stage refused: stdin is over ${DEBRIEF_MAX_BYTES} bytes. Split the notes.`)
1552
+ process.exit(1)
1553
+ }
1554
+ }
1555
+ if (!input.trim()) {
1556
+ console.error('ingest stage refused: empty input')
1557
+ process.exit(1)
1558
+ }
1559
+ const hit = findSecretHit(input)
1560
+ if (hit && !force) { refuseSecret('ingest stage', hit); process.exit(1) }
1561
+ if (hit && force) console.error(`warning: staging possible ${hit} (--force)`)
1562
+
1563
+ const box = inboxDir(eng)
1564
+ try { fs.mkdirSync(box, { recursive: true }) } catch (e) { failFs(e, 'create inbox', box) }
1565
+ const compact = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
1566
+ const id = `${compact}-${source}-${titleSlug}.md`
1567
+ const dest = path.join(box, id)
1568
+ const body = [
1569
+ '---',
1570
+ `source: ${source}`,
1571
+ `title: ${title.replace(/\n/g, ' ').slice(0, 120)}`,
1572
+ `staged: ${new Date().toISOString()}`,
1573
+ `id: ${id}`,
1574
+ '---',
1575
+ '',
1576
+ input.replace(/\s+$/, '') + '\n',
1577
+ ].join('\n')
1578
+ withFileLock(dest, () => { atomicWriteFile(dest, body) })
1579
+ console.log(`staged → ${dest}`)
1580
+ console.log(`id: ${id}`)
1581
+ console.log('next: fde ingest propose ' + id)
1582
+ console.log('(does not write .fde/ — confirm via propose → apply)')
1583
+ }
1584
+
1402
1585
  function cmdReceipts(args) {
1403
1586
  const term = args.join(' ')
1404
1587
  if (!term) { console.error('usage: fde receipts <search term>'); process.exit(1) }
@@ -2293,6 +2476,10 @@ function printUsage() {
2293
2476
  fde log --undo remove the last CLI log/debrief entry from memory
2294
2477
  fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
2295
2478
  fde debrief --smart heuristic propose (prefix + light keywords); agent routes, CLI gates → --apply
2479
+ fde ingest stage … stage raw pull into <engagement>/.inbox/ (not .fde/)
2480
+ fde ingest list list staged inbox items
2481
+ fde ingest propose <id> smart-propose a staged item → .debrief-propose (confirm before apply)
2482
+ fde ingest apply same as: fde debrief --apply
2296
2483
  fde prep [label] grounded walk-in brief from existing .fde/ only
2297
2484
  fde doctor lint engagement memory (stale signals, gaps)
2298
2485
  fde redact <term> preview/remove lines containing a buried term (pass --apply to commit)
@@ -2305,7 +2492,8 @@ function printUsage() {
2305
2492
  fde dashboard [--all] current engagement fieldbook (pass --all for every client)
2306
2493
  env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
2307
2494
  writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only
2308
- .fde/ is git-versioned locally for tamper-evident receipts (no remote, no telemetry)`)
2495
+ .fde/ is git-versioned locally for tamper-evident receipts (no remote, no telemetry)
2496
+ ingest is a sink only - source MCPs (Granola/Gmail/…) are user-configured; never ambient sync`)
2309
2497
  }
2310
2498
 
2311
2499
  const [cmd, ...args] = process.argv.slice(2)
@@ -2315,6 +2503,7 @@ switch (cmd) {
2315
2503
  case 'triage': cmdTriage(); break
2316
2504
  case 'log': cmdLog(args); break
2317
2505
  case 'debrief': cmdDebrief(args); break
2506
+ case 'ingest': cmdIngest(args); break
2318
2507
  case 'prep': cmdPrep(args); break
2319
2508
  case 'doctor': cmdDoctor(); break
2320
2509
  case 'redact': cmdRedact(args); break
package/bin/install.js CHANGED
@@ -225,7 +225,7 @@ function cmdInstall() {
225
225
  // `npx fdeops scan` must recon, not install - any fde subcommand passes straight
226
226
  // through to the CLI (fde.js reads process.argv itself, so require() is enough).
227
227
  const FDE_SUBCOMMANDS = [
228
- 'scan', 'resume', 'triage', 'log', 'debrief', 'prep', 'doctor', 'redact',
228
+ 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
229
229
  'garden', 'owner', 'receipts', 'capture', 'status', 'dashboard', 'help',
230
230
  ]
231
231
 
package/mcp/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # FDEOps MCP packages
2
+
3
+ FDEOps MCP servers follow a **pluggable source model**: core owns the **sink**, sources are **user-added**.
4
+
5
+ ## Sink vs sources
6
+
7
+ | Role | Owner | Examples |
8
+ |------|-------|----------|
9
+ | **Source** | FDE configures separately | Granola, Gmail, Notion, custom scrapers |
10
+ | **Sink** | FDEOps (`fdeops-ingest`) | stage → propose → apply into engagement memory |
11
+
12
+ Source MCPs fetch raw text from SaaS APIs using credentials the FDE manages. The ingest MCP never stores OAuth tokens or calls external services — it only shells out to the local `fde` CLI.
13
+
14
+ ## Ground loop
15
+
16
+ ```
17
+ Source MCP(s) fdeops-ingest MCP fde CLI
18
+ │ │ │
19
+ │ raw transcript/email │ │
20
+ └───────────────────────►│ ingest_stage │
21
+ ├─────────────────────►│ .inbox/
22
+ │ ingest_list │
23
+ │ ingest_propose ├─► .debrief-propose
24
+ │ (FDE confirms) │
25
+ │ ingest_apply ├─► .fde/
26
+ ```
27
+
28
+ 1. Agent pulls from whichever source MCPs are configured.
29
+ 2. Agent stages raw content via `ingest_stage` (with `source` provenance).
30
+ 3. Agent proposes routing via `ingest_propose`; FDE confirms.
31
+ 4. Agent applies via `ingest_apply` — nothing writes `.fde/` unreviewed.
32
+
33
+ ## Packages
34
+
35
+ | Package | Path | Purpose |
36
+ |---------|------|---------|
37
+ | `fdeops-ingest-mcp` | [`fdeops-ingest/`](./fdeops-ingest/) | Ingest sink (stage, list, propose, apply) |
38
+
39
+ ## Adding a source MCP
40
+
41
+ Source MCPs are **not** bundled in fdeops. To add Granola, Gmail, or another provider:
42
+
43
+ 1. Install or configure that provider's MCP in your Cursor/Claude `mcp.json`.
44
+ 2. Configure `fdeops-ingest` separately (see [`fdeops-ingest/README.md`](./fdeops-ingest/README.md)).
45
+ 3. In your daily workflow, the agent uses source tools to fetch, then ingest tools to stage and commit.
46
+
47
+ FDEOps credentials stay local to the CLI; source MCP credentials stay with that MCP.
48
+
49
+ ## Design reference
50
+
51
+ See [`docs/plans/2026-07-29-ingest-mcp-design.md`](../docs/plans/2026-07-29-ingest-mcp-design.md) for the approved ingest MCP design.
@@ -0,0 +1,90 @@
1
+ # fdeops-ingest MCP
2
+
3
+ Thin stdio MCP server for the FDEOps **ingest sink** only: **stage → propose → apply**.
4
+
5
+ This package shells out to the local `fde` CLI. It never calls SaaS APIs. Source MCPs (Granola, Gmail, Notion, etc.) are **separate** — you add those in your own `mcp.json`.
6
+
7
+ ## Tools
8
+
9
+ | Tool | CLI equivalent |
10
+ |------|----------------|
11
+ | `ingest_stage` | `fde ingest stage [--source NAME] [--title TEXT]` (content on stdin) |
12
+ | `ingest_list` | `fde ingest list` |
13
+ | `ingest_propose` | `fde ingest propose <id>` |
14
+ | `ingest_apply` | `fde ingest apply` |
15
+
16
+ Each tool returns `{ stdout, stderr, status }` from the CLI.
17
+
18
+ ## Requirements
19
+
20
+ - Node.js ≥ 18
21
+ - `fde` CLI on PATH, or run from a checkout (auto-resolves `../../bin/fde.js`)
22
+
23
+ ## Configure in Cursor / Claude
24
+
25
+ Add to your MCP config (`~/.cursor/mcp.json`, Claude Desktop config, etc.):
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "fdeops-ingest": {
31
+ "command": "node",
32
+ "args": [
33
+ "/absolute/path/to/fdeops/mcp/fdeops-ingest/server.js"
34
+ ],
35
+ "env": {
36
+ "FDEOPS_ENGAGEMENT": "/Users/you/fde-engagements/acme/.fde"
37
+ }
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ Or after `npm link` in this directory:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "fdeops-ingest": {
49
+ "command": "fdeops-ingest-mcp",
50
+ "env": {
51
+ "FDEOPS_ENGAGEMENT": "/Users/you/fde-engagements/acme/.fde"
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ ### Environment
59
+
60
+ | Variable | Purpose |
61
+ |----------|---------|
62
+ | `FDEOPS_ENGAGEMENT` | Bind to a specific engagement (path to `.fde/`) |
63
+ | `FDEOPS_ENGAGEMENTS_ROOT` | Override engagements root (default `~/fde-engagements`) |
64
+ | `FDEOPS_FDE` | Override `fde` binary (default: repo `bin/fde.js`, else `fde` on PATH) |
65
+ | `HOME` | Passed through for engagement resolution |
66
+
67
+ ## Daily loop (with separate source MCPs)
68
+
69
+ 1. **Fetch** — Use your source MCP (e.g. Granola, Gmail) to pull raw text. FDEOps does not bundle these.
70
+ 2. **Stage** — `ingest_stage` with `content`, `source` (e.g. `"granola"`), optional `title`.
71
+ 3. **List** — `ingest_list` to see staged items in `.inbox/`.
72
+ 4. **Propose** — `ingest_propose` with the staged `id`; agent reviews `.debrief-propose`.
73
+ 5. **Confirm** — FDE approves the proposal in chat.
74
+ 6. **Apply** — `ingest_apply` writes dated facts into `.fde/` with provenance.
75
+
76
+ Raw artifacts stay in `.inbox/`; the system of record (`.fde/`) stays thin and reviewed.
77
+
78
+ ## Architecture
79
+
80
+ ```
81
+ [Granola MCP] ──┐
82
+ [Gmail MCP] ──┼──► agent ──► fdeops-ingest MCP ──► fde CLI ──► .inbox/ → .fde/
83
+ [manual paste]──┘ (this package)
84
+ ```
85
+
86
+ Sources are pluggable and user-configured. This MCP owns the sink only.
87
+
88
+ ## Zero dependencies
89
+
90
+ Hand-rolled MCP over stdio (Content-Length framed JSON-RPC). No `@modelcontextprotocol/sdk` required at runtime.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "fdeops-ingest-mcp",
3
+ "version": "3.9.19",
4
+ "private": true,
5
+ "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
+ "bin": {
7
+ "fdeops-ingest-mcp": "./server.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ }
12
+ }
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ /**
5
+ * fdeops-ingest MCP — thin stdio sink for FDEOps ingest.
6
+ * Shells out to local `fde` CLI only. Never calls SaaS.
7
+ * MCP stdio transport: Content-Length framed JSON-RPC 2.0.
8
+ */
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+ const { spawnSync } = require('child_process')
13
+
14
+ const PROTOCOL_VERSION = '2024-11-05'
15
+ const SERVER_NAME = 'fdeops-ingest'
16
+ const SERVER_VERSION = require('./package.json').version
17
+
18
+ const TOOLS = [
19
+ {
20
+ name: 'ingest_stage',
21
+ description:
22
+ 'Stage raw content into the engagement inbox (.inbox/). Does not write .fde/.',
23
+ inputSchema: {
24
+ type: 'object',
25
+ properties: {
26
+ content: {
27
+ type: 'string',
28
+ description: 'Raw text to stage (transcript, email body, notes).',
29
+ },
30
+ source: {
31
+ type: 'string',
32
+ description: 'Provenance label (e.g. granola, gmail, manual). Default: manual.',
33
+ },
34
+ title: {
35
+ type: 'string',
36
+ description: 'Optional human-readable title for the staged item.',
37
+ },
38
+ },
39
+ required: ['content'],
40
+ },
41
+ },
42
+ {
43
+ name: 'ingest_list',
44
+ description: 'List staged items in the current engagement inbox.',
45
+ inputSchema: { type: 'object', properties: {} },
46
+ },
47
+ {
48
+ name: 'ingest_propose',
49
+ description:
50
+ 'Propose debrief routing for a staged item (writes .fde/.debrief-propose; does not apply).',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ id: {
55
+ type: 'string',
56
+ description: 'Staged filename or id from ingest_list.',
57
+ },
58
+ },
59
+ required: ['id'],
60
+ },
61
+ },
62
+ {
63
+ name: 'ingest_apply',
64
+ description:
65
+ 'Apply the current debrief proposal into .fde/ memory (requires prior FDE confirm).',
66
+ inputSchema: { type: 'object', properties: {} },
67
+ },
68
+ ]
69
+
70
+ let readBuffer = Buffer.alloc(0)
71
+
72
+ function writeMessage(obj) {
73
+ const body = JSON.stringify(obj)
74
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`)
75
+ }
76
+
77
+ function parseMessages() {
78
+ const messages = []
79
+ while (true) {
80
+ const headerEnd = readBuffer.indexOf('\r\n\r\n')
81
+ if (headerEnd === -1) break
82
+
83
+ const header = readBuffer.slice(0, headerEnd).toString('utf8')
84
+ const match = header.match(/Content-Length:\s*(\d+)/i)
85
+ if (!match) {
86
+ readBuffer = readBuffer.slice(headerEnd + 4)
87
+ continue
88
+ }
89
+
90
+ const length = parseInt(match[1], 10)
91
+ const bodyStart = headerEnd + 4
92
+ if (readBuffer.length < bodyStart + length) break
93
+
94
+ const body = readBuffer.slice(bodyStart, bodyStart + length).toString('utf8')
95
+ readBuffer = readBuffer.slice(bodyStart + length)
96
+ try {
97
+ messages.push(JSON.parse(body))
98
+ } catch (_) {}
99
+ }
100
+ return messages
101
+ }
102
+
103
+ function resolveFde() {
104
+ const override = (process.env.FDEOPS_FDE || '').trim()
105
+ if (override) return { cmd: override, prefix: [] }
106
+
107
+ const local = path.join(__dirname, '..', '..', 'bin', 'fde.js')
108
+ if (fs.existsSync(local)) return { cmd: process.execPath, prefix: [local] }
109
+
110
+ return { cmd: 'fde', prefix: [] }
111
+ }
112
+
113
+ function fdeEnv() {
114
+ const env = { ...process.env }
115
+ for (const key of ['HOME', 'FDEOPS_ENGAGEMENT', 'FDEOPS_ENGAGEMENTS_ROOT']) {
116
+ if (process.env[key] !== undefined) env[key] = process.env[key]
117
+ }
118
+ return env
119
+ }
120
+
121
+ function runFde(args, stdin) {
122
+ const { cmd, prefix } = resolveFde()
123
+ const result = spawnSync(cmd, [...prefix, ...args], {
124
+ env: fdeEnv(),
125
+ input: stdin ?? undefined,
126
+ encoding: 'utf8',
127
+ maxBuffer: 16 * 1024 * 1024,
128
+ })
129
+ return {
130
+ stdout: result.stdout ?? '',
131
+ stderr: result.stderr ?? '',
132
+ status: result.status ?? (result.error ? 1 : 0),
133
+ error: result.error ? String(result.error.message || result.error) : null,
134
+ }
135
+ }
136
+
137
+ function cliPayload(out) {
138
+ const payload = { stdout: out.stdout, stderr: out.stderr, status: out.status }
139
+ if (out.error) payload.spawnError = out.error
140
+ return payload
141
+ }
142
+
143
+ function toolResult(payload) {
144
+ const text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)
145
+ return { content: [{ type: 'text', text }] }
146
+ }
147
+
148
+ function toolError(payload) {
149
+ const result = toolResult(payload)
150
+ result.isError = true
151
+ return result
152
+ }
153
+
154
+ function handleToolCall(name, args) {
155
+ args = args || {}
156
+
157
+ switch (name) {
158
+ case 'ingest_stage': {
159
+ if (!args.content || typeof args.content !== 'string') {
160
+ return toolError('Missing required argument: content')
161
+ }
162
+ const source = args.source || 'manual'
163
+ const cliArgs = ['ingest', 'stage', '--source', source]
164
+ if (args.title) cliArgs.push('--title', args.title)
165
+ const out = runFde(cliArgs, args.content)
166
+ const payload = cliPayload(out)
167
+ return out.status === 0 ? toolResult(payload) : toolError(payload)
168
+ }
169
+ case 'ingest_list': {
170
+ const out = runFde(['ingest', 'list'])
171
+ const payload = cliPayload(out)
172
+ return out.status === 0 ? toolResult(payload) : toolError(payload)
173
+ }
174
+ case 'ingest_propose': {
175
+ if (!args.id) return toolError('Missing required argument: id')
176
+ const out = runFde(['ingest', 'propose', String(args.id)])
177
+ const payload = cliPayload(out)
178
+ return out.status === 0 ? toolResult(payload) : toolError(payload)
179
+ }
180
+ case 'ingest_apply': {
181
+ const out = runFde(['ingest', 'apply'])
182
+ const payload = cliPayload(out)
183
+ return out.status === 0 ? toolResult(payload) : toolError(payload)
184
+ }
185
+ default:
186
+ return toolError(`Unknown tool: ${name}`)
187
+ }
188
+ }
189
+
190
+ function handleMessage(msg) {
191
+ if (!msg || typeof msg !== 'object') return
192
+
193
+ const { id, method, params } = msg
194
+
195
+ if (id === undefined && method) return
196
+
197
+ if (method === 'initialize') {
198
+ writeMessage({
199
+ jsonrpc: '2.0',
200
+ id,
201
+ result: {
202
+ protocolVersion: PROTOCOL_VERSION,
203
+ capabilities: { tools: {} },
204
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
205
+ },
206
+ })
207
+ return
208
+ }
209
+
210
+ if (method === 'tools/list') {
211
+ writeMessage({ jsonrpc: '2.0', id, result: { tools: TOOLS } })
212
+ return
213
+ }
214
+
215
+ if (method === 'tools/call') {
216
+ try {
217
+ const result = handleToolCall(params?.name, params?.arguments)
218
+ writeMessage({ jsonrpc: '2.0', id, result })
219
+ } catch (err) {
220
+ writeMessage({
221
+ jsonrpc: '2.0',
222
+ id,
223
+ result: toolError(String(err.message || err)),
224
+ })
225
+ }
226
+ return
227
+ }
228
+
229
+ if (method === 'ping') {
230
+ writeMessage({ jsonrpc: '2.0', id, result: {} })
231
+ return
232
+ }
233
+
234
+ if (id !== undefined) {
235
+ writeMessage({
236
+ jsonrpc: '2.0',
237
+ id,
238
+ error: { code: -32601, message: `Method not found: ${method}` },
239
+ })
240
+ }
241
+ }
242
+
243
+ process.stdin.on('data', (chunk) => {
244
+ readBuffer = Buffer.concat([readBuffer, chunk])
245
+ for (const msg of parseMessages()) handleMessage(msg)
246
+ })
247
+
248
+ process.stdin.resume()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.9.18",
3
+ "version": "3.9.19",
4
4
  "description": "Field kit for engineers embedded in client work - a real CLI (recon, memory, portfolio), one @fde skill with field judgment on top, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",
@@ -18,6 +18,7 @@
18
18
  "hooks/",
19
19
  "templates/",
20
20
  "adapters/",
21
+ "mcp/",
21
22
  "CLAUDE.md.template",
22
23
  "AGENTS.md"
23
24
  ],
@@ -83,6 +83,7 @@ When NOT to interrogate or challenge: unambiguous one-liners, mechanical ops, FD
83
83
  | (session entry / where are we) | `fde resume` or use injected TRIAGE; `fde resume --init <name>` only if unbound |
84
84
  | Day-1 look at the repo | `fde scan` - then you interpret against the brief |
85
85
  | "Debrief these notes" / pastes meeting notes | Prefer `fde debrief --smart <notes>` → **you** (the agent) rewrite `.debrief-propose` with `decision:`/`risk:`/`delivery:`/`contact:`/`next:` prefixes where needed → show FDE → on confirm `fde debrief --apply`. `--smart` is a prefix/keyword gate, not a brain. Fallback: structure prefixed lines yourself, show FDE, then `fde debrief` |
86
+ | "Make sure we're up to date" / "pull relevant info" / "pull from Granola/email/transcript" | Bind engagement; if ambiguous ask which meeting/thread. Use whatever source MCPs the FDE has configured (Granola, Gmail, Notion, custom — **not bundled in fdeops**) to fetch raw text → `fde ingest stage [--source NAME] [--title TEXT]` → `fde ingest propose <id>` → **you** rewrite `.debrief-propose` with type prefixes → show FDE → on confirm `fde ingest apply`. **Never auto-apply. Never ambient sync.** Detail: `references/ingest.md` |
86
87
  | "Prep me for the meeting with …" / walk-in brief | `fde prep "<short label>"` - present the brief in plain language; do not invent facts missing from `.fde/` |
87
88
  | "When did we agree…?" / scope dispute | `fde receipts <term>` - answer with dates; no hit = gap, not proof |
88
89
  | "Draft the sponsor update" / how are we doing | `fde status` then follow `references/status.md` for the narrative |
@@ -269,6 +270,7 @@ Running the engagement and ending it well.
269
270
  | Weekly update due, "need to send the sponsor something" | status | `references/status.md` |
270
271
  | Demo coming up, show-and-tell, exec walkthrough | demo-prep | `references/demo-prep.md` |
271
272
  | Just out of a meeting, raw notes, "they said…", "debrief" | debrief | the debrief verb (above) + `references/debrief.md` |
273
+ | Make sure we're up to date, pull what's relevant, fetch from Granola/Gmail/transcript | ingest | `references/ingest.md` (stage → propose → confirm → apply; source MCPs are user-configured) |
272
274
  | Prep me for a meeting / walk-in brief / "what should I know before I talk to…" | - | run `fde prep "<label>"`, present in plain language |
273
275
  | Sponsor's boss needs a summary, board update, justify continued investment | exec-narrative | `references/exec-narrative.md` |
274
276
  | Status across all my customers | dashboard | `references/dashboard.md` |
@@ -2,6 +2,8 @@
2
2
 
3
3
  **Enter when:** the FDE just left a meeting/call and dumps raw notes, a transcript, or "they said…". Highest-frequency moment in FDE life. Capture within the hour.
4
4
 
5
+ **Large transcripts or emails** sitting in Granola/Gmail/Notion → prefer **`fde ingest stage`** first (via source MCPs the FDE configured), then the same propose → confirm → **`fde ingest apply`** path. See `references/ingest.md`. Pasted short notes stay on this debrief verb.
6
+
5
7
  **Read first:** `context.md`, `stakeholders.md` (signals against what's known).
6
8
 
7
9
  **Who runs the CLI:** you (the agent). Never tell the FDE to type `fde debrief …`.
@@ -0,0 +1,62 @@
1
+ # ingest - pull large artifacts into the fieldbook loop
2
+
3
+ **Enter when:** the FDE wants to catch the engagement up from external sources — "make sure Acme is up to date," "pull what's relevant," "grab today's Granola and Denise's last email." Raw transcripts and long emails that are too big to paste usefully.
4
+
5
+ **Read first:** `context.md` (what's already logged, what's stale). Bind the engagement before staging anything.
6
+
7
+ **Who runs the CLI:** you (the agent). Never tell the FDE to type `fde ingest …`. Never auto-apply. Never background-sync or poll sources on your own.
8
+
9
+ ## Honest contract (read once)
10
+
11
+ - FDEOps owns the **sink only**: stage raw pulls → propose → confirm → apply. Nothing writes `.fde/` unreviewed.
12
+ - **Source MCPs are the FDE's.** Granola, Gmail, Notion, custom — whatever they configured in Cursor/Claude. fdeops does not bundle OAuth, connectors, or ambient sync.
13
+ - The core `fde` CLI stays local (git + file reads). Source credentials live with that MCP; fdeops never stores them.
14
+ - After apply, raw stays in `.inbox/`; the system of record (`.fde/`) stays thin dated facts.
15
+
16
+ ## Ground loop (you do this work)
17
+
18
+ 1. **Bind** the engagement (`fde resume` / registry). If multiple meetings or threads could apply, ask **one** clarifying question — which meeting, which thread, which date range.
19
+ 2. **Fetch** via the FDE's available source MCP(s). You pull; the CLI does not reach the network.
20
+ 3. **Stage** — `fde ingest stage [--source NAME] [--title TEXT] [file|-]` writes raw text into `<engagement>/.inbox/` (outside the memory git ledger).
21
+ 4. **List** (optional) — `fde ingest list` shows staged items when you need an id or filename.
22
+ 5. **Propose** — `fde ingest propose <id-or-filename>` runs the debrief `--smart` path on the staged body (+ provenance line). Opens `.debrief-propose`.
23
+ 6. **Rewrite prefixes** — same as debrief: lines without `decision:` / `risk:` / `delivery:` / `contact:` / `next:` need **you** to rewrite before showing the FDE. `--smart` is a gate, not a brain.
24
+ 7. **Show** the proposed routing in plain language. Wait for confirm.
25
+ 8. **Apply** — on FDE confirm only → `fde ingest apply` (= `fde debrief --apply`). On reject → stop; ask what to change.
26
+
27
+ No invented names, meetings, or quotes. If the propose looks wrong, fix prefixes with judgment, then re-show before apply.
28
+
29
+ ## Paths
30
+
31
+ | Path | Role |
32
+ |------|------|
33
+ | `~/fde-engagements/<slug>/.inbox/` | Staging for raw pulls. Not the memory ledger. NDA surface — same home tree as `.fde/`. |
34
+ | `~/fde-engagements/<slug>/.fde/` | System of record (unchanged contract). |
35
+ | `.fde/.debrief-propose` | Propose file (shared with debrief). |
36
+
37
+ ## CLI verbs
38
+
39
+ ```bash
40
+ fde ingest stage [--source NAME] [--title TEXT] [file|-]
41
+ fde ingest list
42
+ fde ingest propose <id-or-filename>
43
+ fde ingest apply
44
+ ```
45
+
46
+ ## Provenance
47
+
48
+ When a staged fact came from a named source, carry `via:<source>` on the applied line where useful (e.g. `via:granola`, `via:gmail`). Helps receipts and sponsor disputes later — not mandatory on every context line.
49
+
50
+ ## MCP sink
51
+
52
+ Optional `mcp/fdeops-ingest` wraps the same verbs over stdio. Source MCPs remain separate — the FDE adds whichever fetch tools they trust.
53
+
54
+ ## Checkpoint
55
+
56
+ Before apply, read back the 2–3 most consequential captures in one breath — same as debrief. Confirm which sources you staged and what would land in the record. Then stop.
57
+
58
+ ## Principles
59
+
60
+ - Pull on request, not on a schedule. No auto-poll, no vacuum of inbox or Slack.
61
+ - Staging is not memory. Only `--apply` after confirm writes `.fde/`.
62
+ - Large artifact → ingest stage first; pasted short notes → debrief verb directly (`references/debrief.md`).