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 +1 -0
- package/bin/check.js +9 -1
- package/bin/fde.js +190 -1
- package/bin/install.js +1 -1
- package/mcp/README.md +51 -0
- package/mcp/fdeops-ingest/README.md +90 -0
- package/mcp/fdeops-ingest/package.json +12 -0
- package/mcp/fdeops-ingest/server.js +248 -0
- package/package.json +2 -1
- package/skills/fde/SKILL.md +2 -0
- package/skills/fde/references/debrief.md +2 -0
- package/skills/fde/references/ingest.md +62 -0
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.
|
|
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
|
],
|
package/skills/fde/SKILL.md
CHANGED
|
@@ -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`).
|