fdeops 3.9.18 → 3.9.20

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
@@ -31,6 +31,7 @@ Day to day you only need `@fde` and normal English. No command cheat sheet.
31
31
  |------|--------------|--------------|
32
32
  | **Start of week** | Open your AI coding agent (nothing to paste) | It already knows where you left off - trust, phase, what's next |
33
33
  | **After a meeting** | `@fde` debrief these notes *(paste or attach them)* | Proposed updates to the record - you review, then confirm |
34
+ | **Pull from tools** | `@fde` connect Granola *(once)* · then `@fde` pull today's Acme transcript | Wire any source MCP you choose; FDEOps stages → proposes → you confirm. Recipes: [mcp/recipes/](mcp/recipes/) |
34
35
  | **Before a stakeholder meeting** | `@fde` prep me for tomorrow's meeting with the sponsor | A short brief from what you already logged - not a blank chat |
35
36
  | **Someone disputes scope** | `@fde` when did we agree to drop that feature? | Dated answers from the record (or a clear gap if nothing was logged) |
36
37
  | **End of week** | `@fde` draft the sponsor update from the record | Status grounded in what actually happened |
@@ -91,6 +92,7 @@ npx fdeops resume # prints a short "where we are" for this clien
91
92
  - **You** describe the situation with `@fde` (or plain language once the skill is loaded)
92
93
  - **Session start / end** - small hooks load where you left off and capture what changed (no re-paste)
93
94
  - **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))
95
+ - **Pluggable pull (ingest)** - FDEOps is the **sink**, not a connector pack. You add any source MCP (Granola, Notion, Drive, …) in Cursor/Claude; say `@fde connect …` for a guided config + recipe, then pull in plain language. Raw text → `.inbox/` → propose → you confirm → `.fde/`. No ambient sync; nothing unreviewed enters the fieldbook. See [mcp/recipes/](mcp/recipes/).
94
96
 
95
97
  fdeops complements repo memory: CLAUDE.md holds how the *code* works; the fieldbook holds how the *client engagement* works.
96
98
 
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,22 @@ 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 if (!fs.existsSync(path.join(root, 'skills', 'fde', 'references', 'ingest-connect.md'))) {
355
+ fail('skills/fde/references/ingest-connect.md missing')
356
+ } else {
357
+ for (const recipe of ['file.md', 'granola.md', 'notion.md']) {
358
+ if (!fs.existsSync(path.join(root, 'mcp', 'recipes', recipe))) fail(`mcp/recipes/${recipe} missing`)
359
+ }
360
+ if (!read('README.md').includes('mcp/recipes')) fail('README must point at mcp/recipes for connect clarity')
361
+ ok('ingest MCP + connect recipes + skill reference')
362
+ }
363
+
348
364
  if (!fs.existsSync(path.join(root, '.github', 'ISSUE_TEMPLATE', 'bug_report.yml'))) {
349
365
  fail('GitHub issue template missing')
350
366
  } 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,55 @@
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
+ ## Recipes (copy-paste connect)
40
+
41
+ See [`recipes/`](./recipes/) for file, Granola-shaped, and Notion-shaped setup. In chat: `@fde I want to connect Granola` → skill `ingest-connect` walks the FDE through config + reload + verify.
42
+
43
+ ## Adding a source MCP
44
+
45
+ Source MCPs are **not** bundled in fdeops. To add Granola, Gmail, or another provider:
46
+
47
+ 1. Install or configure that provider's MCP in your Cursor/Claude `mcp.json`.
48
+ 2. Configure `fdeops-ingest` separately (see [`fdeops-ingest/README.md`](./fdeops-ingest/README.md)).
49
+ 3. In your daily workflow, the agent uses source tools to fetch, then ingest tools to stage and commit.
50
+
51
+ FDEOps credentials stay local to the CLI; source MCP credentials stay with that MCP.
52
+
53
+ ## Design reference
54
+
55
+ 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.20",
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()
@@ -0,0 +1,15 @@
1
+ # Ingest source recipes
2
+
3
+ FDEOps does **not** bundle Granola / Notion / Drive OAuth. These recipes show how an FDE wires a **source MCP** (or file drop) into the FDEOps **sink**.
4
+
5
+ **Contract every source must satisfy:** fetch text → `fde ingest stage` (or MCP `ingest_stage`) with `{ source, title, content }` → propose → FDE confirms → apply.
6
+
7
+ | Recipe | When |
8
+ |--------|------|
9
+ | [file.md](./file.md) | Local transcript / export already on disk (no source MCP) |
10
+ | [granola.md](./granola.md) | Meeting transcripts via a Granola-shaped MCP (or export) |
11
+ | [notion.md](./notion.md) | Notion pages / meeting notes via a Notion MCP |
12
+
13
+ Also wire the sink once: [fdeops-ingest/README.md](../fdeops-ingest/README.md).
14
+
15
+ **Natural language:** `@fde I want to connect Granola` → agent follows `skills/fde/references/ingest-connect.md` and this recipe.
@@ -0,0 +1,25 @@
1
+ # Recipe: local file / paste (no source MCP)
2
+
3
+ **Use when:** you already have a transcript, `.eml`, or export on disk — or you paste into chat.
4
+
5
+ ## Setup
6
+
7
+ None beyond the FDEOps sink (`fde` CLI and optionally `fdeops-ingest` MCP).
8
+
9
+ ## Pull phrase
10
+
11
+ ```text
12
+ @fde stage this transcript into the fieldbook and propose updates
13
+ ```
14
+
15
+ (or attach / point at a path)
16
+
17
+ ## Agent steps
18
+
19
+ 1. Bind engagement.
20
+ 2. `fde ingest stage --source file --title "<short>" <path>` (or stdin).
21
+ 3. `fde ingest propose <id>` → rewrite prefixes → show FDE → on confirm `fde ingest apply`.
22
+
23
+ ## mcp.json
24
+
25
+ Not required for the source. Optional sink only — see [../fdeops-ingest/README.md](../fdeops-ingest/README.md).
@@ -0,0 +1,58 @@
1
+ # Recipe: Granola-shaped meeting transcripts
2
+
3
+ **Use when:** meeting notes live in Granola (or a similar notes MCP). FDEOps does not ship a Granola server — you add whichever MCP/export path you trust.
4
+
5
+ ## Setup (once)
6
+
7
+ 1. Install / enable a **Granola (or notes) MCP** in Cursor/Claude per that product’s docs.
8
+ 2. Add the FDEOps **sink** MCP (`fdeops-ingest`) — [../fdeops-ingest/README.md](../fdeops-ingest/README.md).
9
+ 3. Reload MCP / restart the agent host.
10
+ 4. Test: `@fde what can you pull?` — agent should see both sink tools and the notes source tools.
11
+
12
+ ### Example mcp.json shape (illustrative)
13
+
14
+ Replace `granola-mcp` command/args with whatever the real server documents. FDEOps only needs *some* tool that returns transcript text.
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "granola": {
20
+ "command": "npx",
21
+ "args": ["-y", "YOUR-GRANOLA-MCP-PACKAGE"],
22
+ "env": {
23
+ "GRANOLA_API_KEY": "from-your-secrets"
24
+ }
25
+ },
26
+ "fdeops-ingest": {
27
+ "command": "node",
28
+ "args": ["/absolute/path/to/fdeops/mcp/fdeops-ingest/server.js"],
29
+ "env": {
30
+ "FDEOPS_ENGAGEMENT": "/Users/you/fde-engagements/acme/.fde"
31
+ }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ **No Granola MCP available?** Export transcript to a file → follow [file.md](./file.md).
38
+
39
+ ## Pull phrase
40
+
41
+ ```text
42
+ @fde pull today's Acme Granola into the fieldbook
43
+ ```
44
+
45
+ ## Agent steps
46
+
47
+ 1. Capability check — if no notes/Granola tools, run connect flow (`ingest-connect.md`).
48
+ 2. Fetch transcript via source MCP (or ask which meeting).
49
+ 3. `ingest_stage` / `fde ingest stage --source granola --title "…"`.
50
+ 4. Propose → confirm → apply. Never auto-apply.
51
+
52
+ ## Common fails
53
+
54
+ | Symptom | Fix |
55
+ |---------|-----|
56
+ | Agent says it can’t reach Granola | MCP not saved / host not reloaded / wrong env key |
57
+ | Wrong client inbox | Set `FDEOPS_ENGAGEMENT` or bind workspace (`fde resume --init`) |
58
+ | Empty propose | Agent must rewrite `.debrief-propose` with type prefixes |
@@ -0,0 +1,56 @@
1
+ # Recipe: Notion docs / meeting notes
2
+
3
+ **Use when:** useful engagement notes live in Notion. FDEOps does not ship a Notion server — use a Notion MCP (or export markdown).
4
+
5
+ ## Setup (once)
6
+
7
+ 1. Enable a **Notion MCP** (official or community) with a token that can read the pages you need.
8
+ 2. Add **fdeops-ingest** sink — [../fdeops-ingest/README.md](../fdeops-ingest/README.md).
9
+ 3. Reload MCP / restart host.
10
+ 4. Test: `@fde what can you pull?`
11
+
12
+ ### Example mcp.json shape (illustrative)
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "notion": {
18
+ "command": "npx",
19
+ "args": ["-y", "YOUR-NOTION-MCP-PACKAGE"],
20
+ "env": {
21
+ "NOTION_TOKEN": "from-your-secrets"
22
+ }
23
+ },
24
+ "fdeops-ingest": {
25
+ "command": "node",
26
+ "args": ["/absolute/path/to/fdeops/mcp/fdeops-ingest/server.js"],
27
+ "env": {
28
+ "FDEOPS_ENGAGEMENT": "/Users/you/fde-engagements/acme/.fde"
29
+ }
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ **No Notion MCP?** Export page to markdown → [file.md](./file.md).
36
+
37
+ ## Pull phrase
38
+
39
+ ```text
40
+ @fde pull the Acme discovery notes Notion page into the fieldbook
41
+ ```
42
+
43
+ ## Agent steps
44
+
45
+ 1. Capability check — Notion tools present?
46
+ 2. Fetch page/block text via Notion MCP (ask which page if ambiguous).
47
+ 3. Stage with `--source notion`.
48
+ 4. Propose → confirm → apply.
49
+
50
+ ## Common fails
51
+
52
+ | Symptom | Fix |
53
+ |---------|-----|
54
+ | 401 / forbidden | Token lacks access to that workspace/page |
55
+ | Huge page dump | Stage full text in `.inbox/`; propose only short dated facts |
56
+ | Wrong engagement | Bind / `FDEOPS_ENGAGEMENT` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.9.18",
3
+ "version": "3.9.20",
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,8 @@ 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; **capability check** (which source MCPs exist this session — never pretend). If missing → connect flow. Else fetch → `fde ingest stage` → `fde ingest propose` → rewrite prefixes → show FDE → on confirm `fde ingest apply`. **Never auto-apply. Never ambient sync.** Detail: `references/ingest.md` |
87
+ | "Connect a new MCP" / "connect Granola/Notion" / "what can you pull?" | Follow `references/ingest-connect.md`: ask which source → emit `mcp.json` from `mcp/recipes/` + sink block → they save/reload in Cursor/Claude → verify tools → optional test stage to `.inbox/` only. You cannot silently install host MCPs. |
86
88
  | "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
89
  | "When did we agree…?" / scope dispute | `fde receipts <term>` - answer with dates; no hit = gap, not proof |
88
90
  | "Draft the sponsor update" / how are we doing | `fde status` then follow `references/status.md` for the narrative |
@@ -269,6 +271,8 @@ Running the engagement and ending it well.
269
271
  | Weekly update due, "need to send the sponsor something" | status | `references/status.md` |
270
272
  | Demo coming up, show-and-tell, exec walkthrough | demo-prep | `references/demo-prep.md` |
271
273
  | Just out of a meeting, raw notes, "they said…", "debrief" | debrief | the debrief verb (above) + `references/debrief.md` |
274
+ | Make sure we're up to date, pull what's relevant, fetch from Granola/Gmail/transcript | ingest | `references/ingest.md` (capability check → stage → propose → confirm → apply) |
275
+ | Connect a new MCP / connect Granola or Notion / what can you pull | ingest-connect | `references/ingest-connect.md` (+ `mcp/recipes/`) |
272
276
  | 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
277
  | Sponsor's boss needs a summary, board update, justify continued investment | exec-narrative | `references/exec-narrative.md` |
274
278
  | 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,36 @@
1
+ # ingest-connect - wire a source MCP in plain language
2
+
3
+ **Enter when:** the FDE says "I want to connect a new MCP", "connect Granola / Notion / Drive", "how do I pull from …", or a pull request fails because no source tools exist.
4
+
5
+ **Read first:** `references/ingest.md` (sink contract). Recipe pack: `mcp/recipes/` in the fdeops install (file, granola, notion).
6
+
7
+ **Who runs setup:** you guide; the **host** (Cursor/Claude) must save MCP config. You cannot silently install servers into the host.
8
+
9
+ ## Honest contract
10
+
11
+ - FDEOps = **sink** (`fdeops-ingest` / `fde ingest`). Sources = **whatever MCP the FDE adds**.
12
+ - You produce a ready config snippet + steps. They save + reload. Then you verify with a capability check + optional test stage.
13
+ - Never invent that Granola/Notion is available if tools are missing. Never ambient sync. Never auto-apply to `.fde/`.
14
+
15
+ ## Method
16
+
17
+ 1. **Ask one question** — which source? (`file` / `granola` / `notion` / other name). If "other", ask for the MCP package or docs URL they intend to use.
18
+ 2. **Capability check (current session)** — list MCP tools you can actually call:
19
+ - Sink present? (`ingest_stage` / `ingest_list` / or `fde ingest` CLI)
20
+ - Source present? (anything that can fetch that system's content)
21
+ - Say clearly: *available now* vs *needs config*.
22
+ 3. **Emit config** — open the matching recipe under `mcp/recipes/<source>.md`. Fill absolute paths:
23
+ - path to `mcp/fdeops-ingest/server.js` (from this fdeops install)
24
+ - `FDEOPS_ENGAGEMENT` → this client's `…/<slug>/.fde`
25
+ - placeholders for source API keys (tell them to paste secrets into host env — do not commit keys into the fieldbook)
26
+ 4. **Tell them where to paste** — Cursor: MCP settings / `~/.cursor/mcp.json` (or project MCP). Claude Code: MCP config per their docs. One sentence: save → reload MCP / restart session.
27
+ 5. **Verify** — after they confirm reload: re-run capability check. If source tools appear, offer a **test pull** into `.inbox/` only (stage + show list). Stop before apply unless they ask to propose.
28
+ 6. **Handoff phrase** — give them the daily line, e.g. `@fde pull today's Acme Granola into the fieldbook`.
29
+
30
+ ## If they only want the sink
31
+
32
+ Still wire `fdeops-ingest` (or rely on CLI). File drops work with [mcp/recipes/file.md](../../../mcp/recipes/file.md) without any source MCP.
33
+
34
+ ## Checkpoint
35
+
36
+ Before ending connect: (1) sink reachable, (2) source reachable or honest gap, (3) they know the pull phrase. Do not write `.fde/` during connect.
@@ -0,0 +1,72 @@
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
+ **Connect / capability (different entry):** "connect a new MCP", "connect Granola/Notion", "what can you pull?" → `references/ingest-connect.md` first. Recipes: `mcp/recipes/` (file, granola, notion).
6
+
7
+ **Read first:** `context.md` (what's already logged, what's stale). Bind the engagement before staging anything.
8
+
9
+ **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.
10
+
11
+ ## Honest contract (read once)
12
+
13
+ - FDEOps owns the **sink only**: stage raw pulls → propose → confirm → apply. Nothing writes `.fde/` unreviewed.
14
+ - **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.
15
+ - The core `fde` CLI stays local (git + file reads). Source credentials live with that MCP; fdeops never stores them.
16
+ - After apply, raw stays in `.inbox/`; the system of record (`.fde/`) stays thin dated facts.
17
+
18
+ ## Capability check (before every pull)
19
+
20
+ List what you can actually call **this session**:
21
+
22
+ 1. **Sink** — `ingest_stage` / `fde ingest` available?
23
+ 2. **Sources** — which fetch tools exist (Granola-shaped, Notion, Drive, file-only)?
24
+ 3. Tell the FDE in one line: *I can pull from X; Y is not connected.* If they asked to pull Y and it is missing → switch to `ingest-connect.md`. Never pretend a source exists.
25
+
26
+ ## Ground loop (you do this work)
27
+
28
+ 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.
29
+ 2. **Capability check** (above). Then **fetch** via available source MCP(s). You pull; the CLI does not reach the network.
30
+ 3. **Stage** — `fde ingest stage [--source NAME] [--title TEXT] [file|-]` writes raw text into `<engagement>/.inbox/` (outside the memory git ledger).
31
+ 4. **List** (optional) — `fde ingest list` shows staged items when you need an id or filename.
32
+ 5. **Propose** — `fde ingest propose <id-or-filename>` runs the debrief `--smart` path on the staged body (+ provenance line). Opens `.debrief-propose`.
33
+ 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.
34
+ 7. **Show** the proposed routing in plain language. Wait for confirm.
35
+ 8. **Apply** — on FDE confirm only → `fde ingest apply` (= `fde debrief --apply`). On reject → stop; ask what to change.
36
+
37
+ No invented names, meetings, or quotes. If the propose looks wrong, fix prefixes with judgment, then re-show before apply.
38
+
39
+ ## Paths
40
+
41
+ | Path | Role |
42
+ |------|------|
43
+ | `~/fde-engagements/<slug>/.inbox/` | Staging for raw pulls. Not the memory ledger. NDA surface — same home tree as `.fde/`. |
44
+ | `~/fde-engagements/<slug>/.fde/` | System of record (unchanged contract). |
45
+ | `.fde/.debrief-propose` | Propose file (shared with debrief). |
46
+
47
+ ## CLI verbs
48
+
49
+ ```bash
50
+ fde ingest stage [--source NAME] [--title TEXT] [file|-]
51
+ fde ingest list
52
+ fde ingest propose <id-or-filename>
53
+ fde ingest apply
54
+ ```
55
+
56
+ ## Provenance
57
+
58
+ 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.
59
+
60
+ ## MCP sink + recipes
61
+
62
+ Optional `mcp/fdeops-ingest` wraps the same verbs over stdio. Source MCPs remain separate — the FDE adds whichever fetch tools they trust. Setup coach: `ingest-connect.md`. Copy-paste recipes: `mcp/recipes/`.
63
+
64
+ ## Checkpoint
65
+
66
+ 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.
67
+
68
+ ## Principles
69
+
70
+ - Pull on request, not on a schedule. No auto-poll, no vacuum of inbox or Slack.
71
+ - Staging is not memory. Only `--apply` after confirm writes `.fde/`.
72
+ - Large artifact → ingest stage first; pasted short notes → debrief verb directly (`references/debrief.md`).