fdeops 3.9.17 → 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 +4 -1
- package/bin/check.js +9 -1
- package/bin/fde.js +273 -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 +3 -1
- package/skills/fde/references/debrief.md +2 -0
- package/skills/fde/references/discover.md +1 -1
- package/skills/fde/references/ingest.md +62 -0
- package/skills/fde/references/stakeholder-radar.md +2 -0
- package/templates/.fde/stakeholders.md +4 -2
- package/templates/.fde/terrain.md +2 -1
package/README.md
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://nodejs.org)
|
|
9
9
|
|
|
10
|
-
**Memory + methodology + skills, in one kit.** Skill packs teach your AI agent how to build. None of them remember who the client is, what was decided, or what's safe to ship. FDEOps adds the missing layer: a private fieldbook per engagement (`.fde/`), a field methodology (land → close), and one `@fde` skill that routes it all.
|
|
10
|
+
**Memory + methodology + skills, in one kit.** Skill packs teach your AI agent how to build. None of them remember who the client is, what was decided, or what's safe to ship. FDEOps adds the missing layer: a private fieldbook per engagement (`.fde/`), a field methodology (land → close), and one `@fde` skill that routes it all.
|
|
11
|
+
|
|
12
|
+
Built for Forward Deployed Engineers, and anyone embedded in client work: consultants, agency developers, solutions architects, fractional CTOs. Feels like a second brain; behaves like a defensible record (dated, sourced, yours).
|
|
11
13
|
|
|
12
14
|
```
|
|
13
15
|
land discover plan build ship close
|
|
@@ -89,6 +91,7 @@ npx fdeops resume # prints a short "where we are" for this clien
|
|
|
89
91
|
- **You** describe the situation with `@fde` (or plain language once the skill is loaded)
|
|
90
92
|
- **Session start / end** - small hooks load where you left off and capture what changed (no re-paste)
|
|
91
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.
|
|
92
95
|
|
|
93
96
|
fdeops complements repo memory: CLAUDE.md holds how the *code* works; the fieldbook holds how the *client engagement* works.
|
|
94
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) }
|
|
@@ -1671,9 +1854,92 @@ function collectDoctorIssues(eng) {
|
|
|
1671
1854
|
`${dupes.length} duplicate open-risk cluster(s) (e.g. "${sample}${sample.length >= 60 ? '…' : ''}") - consolidate or retire echoes in risks.md`
|
|
1672
1855
|
)
|
|
1673
1856
|
}
|
|
1857
|
+
// Failure-path (exception-led operating map): required once past discover.
|
|
1858
|
+
// Land seeds; discover fills; plan+ without a real break→owner row is wallpaper.
|
|
1859
|
+
if (/^(plan|build|ship|close)$/.test(s.phase) && !hasOperatingMapContent(eng)) {
|
|
1860
|
+
issues.push(
|
|
1861
|
+
`phase is ${s.phase} with empty operating map - fill terrain.md ## Operating map (exception-led): break → who notices → workaround → evidence`
|
|
1862
|
+
)
|
|
1863
|
+
}
|
|
1864
|
+
const aliases = findAmbiguousStakeholders(eng)
|
|
1865
|
+
if (aliases.length) {
|
|
1866
|
+
const sample = aliases[0].forms.slice(0, 3).join(' / ')
|
|
1867
|
+
issues.push(
|
|
1868
|
+
`${aliases.length} stakeholder identity cluster(s) (e.g. "${sample}") - same person under different names? consolidate in stakeholders.md`
|
|
1869
|
+
)
|
|
1870
|
+
}
|
|
1674
1871
|
return issues
|
|
1675
1872
|
}
|
|
1676
1873
|
|
|
1874
|
+
// True when ## Operating map has at least one real exception row (not the empty template).
|
|
1875
|
+
function hasOperatingMapContent(eng) {
|
|
1876
|
+
const terrain = stripTemplateNoise(readClean(eng, 'terrain.md'))
|
|
1877
|
+
const body = sectionBody(terrain, 'Operating map')
|
|
1878
|
+
if (!body.trim()) return false
|
|
1879
|
+
const table = parseMdTable(body)
|
|
1880
|
+
if (table) {
|
|
1881
|
+
const exIdx = colIndex(table.headers, /exception|break/i)
|
|
1882
|
+
const idx = exIdx !== -1 ? exIdx : 0
|
|
1883
|
+
for (const row of table.rows) {
|
|
1884
|
+
const cell = (row[idx] || '').trim()
|
|
1885
|
+
if (cell && !/^unknown/i.test(cell)) return true
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
for (const raw of body.split('\n')) {
|
|
1889
|
+
const t = raw.trim().replace(/^[-*]\s+/, '')
|
|
1890
|
+
if (!t || t.startsWith('#') || t.startsWith('|') || /^\*\*/.test(t)) continue
|
|
1891
|
+
if (t.length >= 8 && !/^unknown/i.test(t)) return true
|
|
1892
|
+
}
|
|
1893
|
+
return false
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// Near-duplicate stakeholder forms sharing a first-name key (Denise vs Denise Chen).
|
|
1897
|
+
function findAmbiguousStakeholders(eng) {
|
|
1898
|
+
const forms = []
|
|
1899
|
+
const md = readClean(eng, 'stakeholders.md')
|
|
1900
|
+
const table = parseMdTable(md)
|
|
1901
|
+
if (table) {
|
|
1902
|
+
const nameIdx = colIndex(table.headers, /name|who/i)
|
|
1903
|
+
if (nameIdx !== -1) {
|
|
1904
|
+
for (const row of table.rows) {
|
|
1905
|
+
const name = (row[nameIdx] || '').trim()
|
|
1906
|
+
if (!name || name.length < 2) continue
|
|
1907
|
+
forms.push(name)
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
for (const h of parseSignalHistoryEntries(eng)) {
|
|
1912
|
+
const name = displayNameFromSignalText(h.text)
|
|
1913
|
+
if (name && name.length >= 2 && !/^anon:/i.test(name)) forms.push(name)
|
|
1914
|
+
}
|
|
1915
|
+
const byKey = new Map()
|
|
1916
|
+
for (const name of forms) {
|
|
1917
|
+
const key = signalSubjectKey(name)
|
|
1918
|
+
if (!key || key.startsWith('anon:')) continue
|
|
1919
|
+
const norm = name.replace(/\s+/g, ' ').trim().toLowerCase()
|
|
1920
|
+
if (!byKey.has(key)) byKey.set(key, new Set())
|
|
1921
|
+
byKey.get(key).add(norm)
|
|
1922
|
+
}
|
|
1923
|
+
const clusters = []
|
|
1924
|
+
for (const [key, set] of byKey) {
|
|
1925
|
+
if (set.size < 2) continue
|
|
1926
|
+
// Prefer clusters where forms aren't just identical casing - already lowercased.
|
|
1927
|
+
// Require at least one multi-token form vs a shorter form (Denise / Denise Chen).
|
|
1928
|
+
const list = [...set]
|
|
1929
|
+
const hasLong = list.some(f => f.split(/\s+/).length >= 2)
|
|
1930
|
+
const hasShort = list.some(f => f.split(/\s+/).length === 1)
|
|
1931
|
+
if (hasLong && hasShort) {
|
|
1932
|
+
clusters.push({ key, forms: list })
|
|
1933
|
+
continue
|
|
1934
|
+
}
|
|
1935
|
+
// Or two multi-token forms that share first token but differ later (Denise Chen / Denise C.)
|
|
1936
|
+
if (list.length >= 2 && list.every(f => f.split(/\s+/).length >= 2)) {
|
|
1937
|
+
clusters.push({ key, forms: list })
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
return clusters
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1677
1943
|
// Strip template comments / italic *(hints)* so doctor does not treat stubs as filled.
|
|
1678
1944
|
function stripTemplateNoise(md) {
|
|
1679
1945
|
return String(md || '')
|
|
@@ -2210,6 +2476,10 @@ function printUsage() {
|
|
|
2210
2476
|
fde log --undo remove the last CLI log/debrief entry from memory
|
|
2211
2477
|
fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
|
|
2212
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
|
|
2213
2483
|
fde prep [label] grounded walk-in brief from existing .fde/ only
|
|
2214
2484
|
fde doctor lint engagement memory (stale signals, gaps)
|
|
2215
2485
|
fde redact <term> preview/remove lines containing a buried term (pass --apply to commit)
|
|
@@ -2222,7 +2492,8 @@ function printUsage() {
|
|
|
2222
2492
|
fde dashboard [--all] current engagement fieldbook (pass --all for every client)
|
|
2223
2493
|
env FDEOPS_ENGAGEMENTS_ROOT override ~/fde-engagements (init/status/dashboard/registry)
|
|
2224
2494
|
writes require a workspace bind (or FDEOPS_ENGAGEMENT) - folder-name match is read-only
|
|
2225
|
-
.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`)
|
|
2226
2497
|
}
|
|
2227
2498
|
|
|
2228
2499
|
const [cmd, ...args] = process.argv.slice(2)
|
|
@@ -2232,6 +2503,7 @@ switch (cmd) {
|
|
|
2232
2503
|
case 'triage': cmdTriage(); break
|
|
2233
2504
|
case 'log': cmdLog(args); break
|
|
2234
2505
|
case 'debrief': cmdDebrief(args); break
|
|
2506
|
+
case 'ingest': cmdIngest(args); break
|
|
2235
2507
|
case 'prep': cmdPrep(args); break
|
|
2236
2508
|
case 'doctor': cmdDoctor(); break
|
|
2237
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,12 +83,13 @@ 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 |
|
|
89
90
|
| "Log that they went quiet" / trust signal | `fde log contact "…" --signal amber\|green\|red`. If they already named the color ("log that as amber"), that is the confirm — write it. If they only described the situation, playback the color once, then write. |
|
|
90
91
|
| Want the HTML fieldbook | `fde dashboard` |
|
|
91
|
-
| "Clean up the fieldbook" / hygiene / memory feels messy | `fde doctor` - walk issues in plain language; propose fixes; never auto-rewrite without confirm. Contradictions need judgment (brief vs reality) - doctor is structural; you handle meaning. |
|
|
92
|
+
| "Clean up the fieldbook" / hygiene / memory feels messy | `fde doctor` - walk issues in plain language; propose fixes; never auto-rewrite without confirm. Includes structural gaps: empty operating map (plan+), stakeholder name forks (Denise vs Denise Chen), duplicates, ship/close risks. Contradictions need judgment (brief vs reality) - doctor is structural; you handle meaning. |
|
|
92
93
|
| "Scrub this secret / redact that token" (buried line, not just last write) | `fde redact <term>` preview, then `fde redact <term> --apply` after confirm. Undo is last-write only; redact is for buried lines. Remind them to rotate the real credential. |
|
|
93
94
|
|
|
94
95
|
**The debrief verb.** Highest-frequency loop. When the FDE shares notes or says "debrief": **you** run the smart path (write notes to a temp file if needed). `--smart` writes a propose file via deterministic heuristics (existing prefixes + light keywords); authentic rambling notes often land mostly in context until **you** rewrite lines with type prefixes. Show the proposed routing in plain language. Only `--apply` (or pipe prefixed lines) after they confirm. Never ask them to run the CLI. Detail: `references/debrief.md`.
|
|
@@ -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 …`.
|
|
@@ -92,7 +92,7 @@ The real spec is what people **do** when the system fails - not what the slide d
|
|
|
92
92
|
- **The hesitation.** When someone says "well, there's also this other thing we do…" - stop them, ask them to finish. The main story is what they're comfortable explaining; the hesitation is the real problem.
|
|
93
93
|
- **"Which part of the codebase do you least want to touch?"** The answer is unanimous and it's the load-bearing wall. Check it against your churn scan - when the human answer and the churn data agree, that's your first map landmark.
|
|
94
94
|
- **Shadow AI.** Someone pasting data into ChatGPT to cope = a real unmet need + an uncontrolled data risk. Note both.
|
|
95
|
-
- **Exception-led operating map.** For each real break (not the slide-deck process): what fails, who notices first, what they do today, and which artifact is trusted in that moment. Prefer exceptions over happy-path swimlanes — the workaround is the operating system. Write rows under `terrain.md` → `## Operating map (exception-led)`. If the section is missing on an older engagement, add it; never regenerate the rest of terrain. When AI is in play, also fill `## Intelligence placement` (deterministic vs LLM judgement vs human approve).
|
|
95
|
+
- **Exception-led operating map.** For each real break (not the slide-deck process): what fails, who notices first, what they do today, and which artifact is trusted in that moment. Prefer exceptions over happy-path swimlanes — the workaround is the operating system. Write rows under `terrain.md` → `## Operating map (exception-led)`. If the section is missing on an older engagement, add it; never regenerate the rest of terrain. When AI is in play, also fill `## Intelligence placement` (deterministic vs LLM judgement vs human approve). **`fde doctor` requires at least one filled exception row before plan/build/ship/close** — empty map after discover is a hygiene fail, not optional polish.
|
|
96
96
|
|
|
97
97
|
## Method - part 3: workshop facilitation
|
|
98
98
|
|
|
@@ -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`).
|
|
@@ -28,6 +28,8 @@ The org chart tells you who reports to whom. The stakeholder radar tells you who
|
|
|
28
28
|
|
|
29
29
|
**3. The 48-hour rule.** A stakeholder who goes amber has roughly 48 hours before they go red. A stakeholder who goes red is already escalating above you. Respond same-day to amber signals - not with more delivery, with a conversation.
|
|
30
30
|
|
|
31
|
+
**3b. One name per person.** If the table says "Denise Chen" and Signal history says "Denise" or "D. Chen", trust keys fork and prep lies. Consolidate to one spelling. `fde doctor` flags these identity clusters — treat that as a fix, not a nit.
|
|
32
|
+
|
|
31
33
|
**4. Detect the invisible escalation.** Three markers:
|
|
32
34
|
- Questions shift from "what are you building" to "when will it be done" - someone above is asking.
|
|
33
35
|
- A meeting gets shortened or cancelled - they're meeting without you.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Stakeholders
|
|
2
2
|
|
|
3
|
-
<!-- Champions, blockers, veto power. Trust signals: quiet = escalate.
|
|
3
|
+
<!-- Champions, blockers, veto power. Trust signals: quiet = escalate.
|
|
4
|
+
One row per real person - if Denise / Denise Chen / D. Chen appear, consolidate. -->
|
|
4
5
|
|
|
5
6
|
| Name | Role | Stance | Notes |
|
|
6
7
|
|------|------|--------|-------|
|
|
@@ -14,5 +15,6 @@
|
|
|
14
15
|
<!-- `fde log contact --signal <color>` and `fde debrief` write dated tokens here.
|
|
15
16
|
fde status/receipts read the LATEST dated [signal:...] token in this section
|
|
16
17
|
to drive the trust column - do not delete this heading, and if you rewrite
|
|
17
|
-
this file as an artifact, keep this section's entries intact.
|
|
18
|
+
this file as an artifact, keep this section's entries intact.
|
|
19
|
+
Prefer the same Name spelling as the table so trust keys don't fork. -->
|
|
18
20
|
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
## Operating map (exception-led)
|
|
10
10
|
|
|
11
|
-
<!-- How work actually runs when the happy path fails. Fill in discover; leave blank until heard/seen.
|
|
11
|
+
<!-- How work actually runs when the happy path fails. Fill in discover; leave blank until heard/seen.
|
|
12
|
+
fde doctor requires ≥1 real exception row before plan/build/ship/close. -->
|
|
12
13
|
|
|
13
14
|
| Exception / break | Who notices first | What they do today (workaround) | System of record then | Blast if wrong | Evidence |
|
|
14
15
|
|-------------------|-------------------|---------------------------------|-----------------------|----------------|----------|
|