fdeops 3.9.20 → 3.10.2
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 +106 -87
- package/adapters/LOCAL-LLM.md +1 -1
- package/adapters/README.md +1 -1
- package/bin/check.js +211 -4
- package/bin/fde.js +379 -26
- package/bin/install.js +223 -20
- package/bin/lib/memory.js +2 -2
- package/bin/lib/render.js +2 -0
- package/mcp/README.md +2 -4
- package/mcp/fdeops-ingest/README.md +7 -3
- package/mcp/fdeops-ingest/package.json +1 -1
- package/mcp/fdeops-ingest/server.js +59 -28
- package/mcp/recipes/README.md +9 -8
- package/mcp/recipes/file.md +7 -7
- package/mcp/recipes/granola.md +16 -20
- package/mcp/recipes/notion.md +16 -18
- package/mcp/recipes/slack.md +61 -0
- package/mcp.json +10 -0
- package/package.json +4 -2
- package/plugin.json +21 -0
- package/skills/fde/SKILL.md +4 -4
- package/skills/fde/references/assumption-audit.md +10 -0
- package/skills/fde/references/build.md +13 -1
- package/skills/fde/references/business-case.md +10 -0
- package/skills/fde/references/close.md +11 -1
- package/skills/fde/references/discover.md +10 -0
- package/skills/fde/references/ingest-connect.md +16 -18
- package/skills/fde/references/ingest.md +5 -4
- package/skills/fde/references/land.md +20 -0
- package/skills/fde/references/options-analysis.md +10 -0
- package/skills/fde/references/plan.md +10 -0
- package/skills/fde/references/scope-defense.md +10 -0
- package/skills/fde/references/ship.md +10 -0
- package/skills/fde/references/stakeholder-radar.md +21 -0
- package/skills/fde/references/status.md +12 -2
- package/templates/.fde/delivery.md +3 -3
package/bin/install.js
CHANGED
|
@@ -53,6 +53,49 @@ function slugify(name) {
|
|
|
53
53
|
|| 'engagement'
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Written into every skill directory this installer creates. Nothing is ever
|
|
57
|
+
// removed or overwritten without it: `healthcare-fde` and `fintech-fde` are
|
|
58
|
+
// plausible names for a skill the user wrote themselves, and a name collision
|
|
59
|
+
// must be reported, not silently resolved by deleting their work.
|
|
60
|
+
const MANAGED_MARKER = '.fdeops-managed'
|
|
61
|
+
|
|
62
|
+
function markManaged(dir) {
|
|
63
|
+
let version = 'unknown'
|
|
64
|
+
try { version = require(path.join(__dirname, '..', 'package.json')).version } catch (_) {}
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
path.join(dir, MANAGED_MARKER),
|
|
68
|
+
`managed-by: fdeops\nversion: ${version}\ninstalled: ${new Date().toISOString()}\n` +
|
|
69
|
+
'Delete this file to make fdeops treat the directory as yours and leave it alone.\n',
|
|
70
|
+
)
|
|
71
|
+
} catch (_) {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isManaged(dir) {
|
|
75
|
+
return fs.existsSync(path.join(dir, MANAGED_MARKER))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A skill "directory" that is really a symlink points somewhere outside
|
|
79
|
+
// ~/.claude/skills that fdeops has no claim on. Writing through it would edit
|
|
80
|
+
// files in the user's own tree - refuse even under --force, which is permission
|
|
81
|
+
// to take over this location, not to follow it elsewhere.
|
|
82
|
+
function isLink(p) {
|
|
83
|
+
try { return fs.lstatSync(p).isSymbolicLink() } catch (_) { return false }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Fingerprint of a skill fdeops itself wrote before markers existed. Anchored on
|
|
87
|
+
// the shipped frontmatter, not a bare "fdeops" substring: a skill of the user's
|
|
88
|
+
// that merely mentions fdeops in prose is theirs, not ours. Only consulted for a
|
|
89
|
+
// directory whose name matches one we ship, and only to overwrite - never to delete.
|
|
90
|
+
function wasInstalledByUs(dir) {
|
|
91
|
+
try {
|
|
92
|
+
const md = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8')
|
|
93
|
+
const fm = (md.match(/^---\n([\s\S]*?)\n---/) || [])[1]
|
|
94
|
+
if (!fm) return false
|
|
95
|
+
return /^description:\s*Engagement fieldbook for Forward Deployed Engineers\b/im.test(fm)
|
|
96
|
+
} catch (_) { return false }
|
|
97
|
+
}
|
|
98
|
+
|
|
56
99
|
// v2 shipped 16 standalone skills; v3 is one `fde` skill + references.
|
|
57
100
|
// Leaving the old ones in place would route users to stale content.
|
|
58
101
|
const LEGACY_SKILL_DIRS = [
|
|
@@ -62,22 +105,105 @@ const LEGACY_SKILL_DIRS = [
|
|
|
62
105
|
'gov-fde',
|
|
63
106
|
]
|
|
64
107
|
|
|
65
|
-
function removeLegacySkills() {
|
|
108
|
+
function removeLegacySkills(opts = {}) {
|
|
66
109
|
let removed = 0
|
|
110
|
+
const skipped = []
|
|
111
|
+
const links = []
|
|
67
112
|
for (const dir of LEGACY_SKILL_DIRS) {
|
|
68
113
|
const p = path.join(GLOBAL_SKILLS_DIR, dir)
|
|
69
|
-
if (
|
|
114
|
+
if (isLink(p)) { links.push(dir); continue }
|
|
115
|
+
if (!fs.existsSync(path.join(p, 'SKILL.md'))) continue
|
|
116
|
+
if (isManaged(p) || opts.force) {
|
|
70
117
|
fs.rmSync(p, { recursive: true, force: true })
|
|
71
118
|
removed++
|
|
119
|
+
} else {
|
|
120
|
+
skipped.push(dir)
|
|
72
121
|
}
|
|
73
122
|
}
|
|
74
|
-
return removed
|
|
123
|
+
return { removed, skipped, links }
|
|
75
124
|
}
|
|
76
125
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
126
|
+
// Copy each skill in, but never over a directory fdeops did not create.
|
|
127
|
+
function installSkillDirs(opts = {}) {
|
|
128
|
+
const skipped = []
|
|
129
|
+
const links = []
|
|
130
|
+
const failed = []
|
|
131
|
+
fs.mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true })
|
|
132
|
+
for (const entry of fs.readdirSync(SKILLS_SRC, { withFileTypes: true })) {
|
|
133
|
+
const src = path.join(SKILLS_SRC, entry.name)
|
|
134
|
+
const dest = path.join(GLOBAL_SKILLS_DIR, entry.name)
|
|
135
|
+
if (!entry.isDirectory()) { fs.copyFileSync(src, dest); continue }
|
|
136
|
+
if (isLink(dest)) { links.push(entry.name); continue }
|
|
137
|
+
if (fs.existsSync(dest) && !isManaged(dest) && !opts.force) {
|
|
138
|
+
// Installs predating the marker are still ours: adopt a same-named dir
|
|
139
|
+
// whose SKILL.md is recognizably fdeops', so upgrades keep working.
|
|
140
|
+
if (!wasInstalledByUs(dest)) {
|
|
141
|
+
skipped.push(entry.name)
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
console.log(` adopt ~/.claude/skills/${entry.name} (earlier fdeops install)`)
|
|
145
|
+
}
|
|
146
|
+
// One unwritable skill dir must not abort the install with a stack trace:
|
|
147
|
+
// say it in human terms, place the rest, and exit non-zero at the end.
|
|
148
|
+
try {
|
|
149
|
+
copyDir(src, dest)
|
|
150
|
+
markManaged(dest)
|
|
151
|
+
} catch (e) {
|
|
152
|
+
failed.push({ name: entry.name, code: e.code || 'error', path: destPathFor(e.path, src, dest) })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { skipped, links, failed }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A failed copy can surface either side of the operation; the user can only fix
|
|
159
|
+
// the destination, so never point them at a path inside the package.
|
|
160
|
+
function destPathFor(failedPath, src, dest) {
|
|
161
|
+
if (!failedPath) return dest
|
|
162
|
+
if (failedPath === src || failedPath.startsWith(src + path.sep)) {
|
|
163
|
+
return path.join(dest, path.relative(src, failedPath))
|
|
164
|
+
}
|
|
165
|
+
return failedPath
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function reportCollisions(paths, verb) {
|
|
169
|
+
if (!paths.length) return
|
|
170
|
+
console.log(` skip ${paths.length} skill dir(s) fdeops did not create - ${verb} would destroy your own work:`)
|
|
171
|
+
for (const name of paths) console.log(` ~/.claude/skills/${name}`)
|
|
172
|
+
console.log(' move or delete them yourself, or re-run with --force to let fdeops take them over')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function reportLinks(names) {
|
|
176
|
+
if (!names.length) return
|
|
177
|
+
console.log(` skip ${names.length} skill path(s) that are symlinks - fdeops will not write through them:`)
|
|
178
|
+
for (const name of names) console.log(` ~/.claude/skills/${name} -> ${readLinkQuiet(path.join(GLOBAL_SKILLS_DIR, name))}`)
|
|
179
|
+
console.log(' remove the link if you want fdeops to install at that path itself')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readLinkQuiet(p) {
|
|
183
|
+
try { return fs.readlinkSync(p) } catch (_) { return '(unreadable)' }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Anything here means part of the install did not land; cmdInstall exits non-zero.
|
|
187
|
+
let installIncomplete = false
|
|
188
|
+
function reportFailures(failures) {
|
|
189
|
+
if (!failures.length) return
|
|
190
|
+
installIncomplete = true
|
|
191
|
+
console.log(` error ${failures.length} skill dir(s) could not be written:`)
|
|
192
|
+
for (const f of failures) {
|
|
193
|
+
const why = f.code === 'EACCES' || f.code === 'EPERM' ? 'permission denied' : f.code
|
|
194
|
+
console.log(` ~/.claude/skills/${f.name} - ${why} at ${f.path}`)
|
|
195
|
+
}
|
|
196
|
+
console.log(' fix the permissions (or remove the directory) and re-run - the rest of the install continued')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function installSkills(opts = {}) {
|
|
200
|
+
const legacy = removeLegacySkills(opts)
|
|
201
|
+
if (legacy.removed > 0) console.log(` Removed ${legacy.removed} v2 skill dir(s) (now covered by @fde)`)
|
|
202
|
+
const placed = installSkillDirs(opts)
|
|
203
|
+
reportCollisions(legacy.skipped, 'removing them')
|
|
204
|
+
reportCollisions(placed.skipped, 'overwriting them')
|
|
205
|
+
reportLinks([...new Set([...legacy.links, ...placed.links])])
|
|
206
|
+
reportFailures(placed.failed)
|
|
81
207
|
fs.mkdirSync(GLOBAL_HOOKS_DIR, { recursive: true })
|
|
82
208
|
for (const name of HOOK_SCRIPTS) {
|
|
83
209
|
const src = path.join(HOOKS_SRC, name)
|
|
@@ -138,7 +264,7 @@ function placePointer(destPath, content, label, appendable) {
|
|
|
138
264
|
console.log(` write ${label}`)
|
|
139
265
|
}
|
|
140
266
|
|
|
141
|
-
function cmdAdapters(targetDir) {
|
|
267
|
+
function cmdAdapters(targetDir, opts = {}) {
|
|
142
268
|
const dest = path.resolve(targetDir || process.cwd())
|
|
143
269
|
console.log('')
|
|
144
270
|
console.log(` fdeops cross-platform adapters → ${dest}`)
|
|
@@ -150,7 +276,7 @@ function cmdAdapters(targetDir) {
|
|
|
150
276
|
// yet, a dangling reference for anyone following the documented Cursor/Codex
|
|
151
277
|
// path. installSkills() is idempotent (safe to call every run).
|
|
152
278
|
if (!fs.existsSync(path.join(GLOBAL_SKILLS_DIR, 'fde', 'SKILL.md'))) {
|
|
153
|
-
installSkills()
|
|
279
|
+
installSkills(opts)
|
|
154
280
|
console.log(' Skills → ~/.claude/skills/ (installed - the pointers below need this)')
|
|
155
281
|
console.log('')
|
|
156
282
|
}
|
|
@@ -198,11 +324,11 @@ function cmdInit(engagementName) {
|
|
|
198
324
|
console.log('')
|
|
199
325
|
}
|
|
200
326
|
|
|
201
|
-
function cmdInstall() {
|
|
327
|
+
function cmdInstall(opts = {}) {
|
|
202
328
|
console.log('')
|
|
203
329
|
console.log(' fdeops - installs on YOUR machine only')
|
|
204
330
|
console.log('')
|
|
205
|
-
installSkills()
|
|
331
|
+
installSkills(opts)
|
|
206
332
|
console.log(' Skills → ~/.claude/skills/')
|
|
207
333
|
console.log(' Hooks → ~/.claude/hooks/fdeops-*')
|
|
208
334
|
console.log(' CLI → ~/.claude/fdeops/fde.js (try: node ~/.claude/fdeops/fde.js scan)')
|
|
@@ -220,22 +346,99 @@ function cmdInstall() {
|
|
|
220
346
|
console.log(' Then open your workspace and use @fde')
|
|
221
347
|
console.log(' Docs: docs/install.md')
|
|
222
348
|
console.log('')
|
|
349
|
+
// A partly-installed skill set is not success - a script that ran this must be
|
|
350
|
+
// able to tell, and the reason is already printed above.
|
|
351
|
+
if (installIncomplete) process.exit(1)
|
|
223
352
|
}
|
|
224
353
|
|
|
225
354
|
// `npx fdeops scan` must recon, not install - any fde subcommand passes straight
|
|
226
355
|
// through to the CLI (fde.js reads process.argv itself, so require() is enough).
|
|
227
356
|
const FDE_SUBCOMMANDS = [
|
|
228
|
-
'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
|
|
229
|
-
'garden', 'owner', 'receipts', 'capture', 'status', 'dashboard', 'help',
|
|
357
|
+
'demo', 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
|
|
358
|
+
'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'help',
|
|
230
359
|
]
|
|
231
360
|
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
361
|
+
const INSTALL_SUBCOMMANDS = ['init', 'adapters', 'install']
|
|
362
|
+
|
|
363
|
+
function editDistance(a, b) {
|
|
364
|
+
let prev = [...Array(b.length + 1).keys()]
|
|
365
|
+
for (let i = 1; i <= a.length; i++) {
|
|
366
|
+
const row = [i]
|
|
367
|
+
for (let j = 1; j <= b.length; j++) {
|
|
368
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
369
|
+
}
|
|
370
|
+
prev = row
|
|
371
|
+
}
|
|
372
|
+
return prev[b.length]
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function nearest(word, known) {
|
|
376
|
+
const w = word.toLowerCase()
|
|
377
|
+
let best = null
|
|
378
|
+
let bestScore = 3
|
|
379
|
+
for (const k of known) {
|
|
380
|
+
const d = editDistance(w, k)
|
|
381
|
+
if (d < bestScore) { best = k; bestScore = d }
|
|
382
|
+
}
|
|
383
|
+
return best
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const argv = process.argv.slice(2)
|
|
387
|
+
const force = argv.includes('--force')
|
|
388
|
+
const positional = argv.filter(a => !a.startsWith('-'))
|
|
389
|
+
const raw = positional[0]
|
|
390
|
+
// `fdeops Demo` is a typo, not a request to rewrite ~/.claude: verbs match
|
|
391
|
+
// case-insensitively, and anything unrecognized fails loudly instead of
|
|
392
|
+
// falling through to a full install. Asking a question (`--help`, `--version`)
|
|
393
|
+
// is not consent to write to the home directory either.
|
|
394
|
+
const known = INSTALL_SUBCOMMANDS.concat(FDE_SUBCOMMANDS)
|
|
395
|
+
const arg = raw ? known.find(k => k === raw.toLowerCase()) : undefined
|
|
396
|
+
const askedHelp = argv.some(a => /^--?(h|help)$/i.test(a))
|
|
397
|
+
const askedVersion = argv.some(a => /^--?(v|version)$/i.test(a))
|
|
398
|
+
|
|
399
|
+
// Flags before the verb belong to fdeops, so an unknown one is a mistake worth
|
|
400
|
+
// saying out loud - the alternative is honoring `fdeops --all status` by
|
|
401
|
+
// quietly dropping --all.
|
|
402
|
+
const FDEOPS_FLAGS = /^--?(force|h|help|v|version)$/i
|
|
403
|
+
const leading = (raw === undefined ? argv : argv.slice(0, argv.indexOf(raw))).filter(a => !FDEOPS_FLAGS.test(a))
|
|
404
|
+
if (leading.length) {
|
|
405
|
+
console.error(` fdeops: unknown option '${leading[0]}'${raw ? ` before '${raw}' - put command options after the command: fdeops ${raw} ${leading[0]}` : ''}`)
|
|
406
|
+
console.error(' fdeops itself takes only --force, --help and --version.')
|
|
407
|
+
process.exit(1)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (raw && !arg) {
|
|
411
|
+
const guess = nearest(raw, known)
|
|
412
|
+
console.error(` fdeops: unknown command '${raw}'${guess ? ` - did you mean '${guess}'?` : ''}`)
|
|
413
|
+
console.error(' Run `npx fdeops help` for the command list, or `npx fdeops` with no arguments to install.')
|
|
414
|
+
process.exit(1)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// The verb the user typed may not be argv[0] (`fdeops --force redact ledger`),
|
|
418
|
+
// and fde.js reads process.argv itself - hand it back the positional order it
|
|
419
|
+
// expects with only the verb's case normalized.
|
|
420
|
+
function handOffToCli(verb) {
|
|
421
|
+
// fde.js reads process.argv itself and takes the verb first, so hand it the
|
|
422
|
+
// verb (case-normalized) plus everything the user typed after it. A flag
|
|
423
|
+
// typed BEFORE the verb (`fdeops --force redact ledger`) belongs to fdeops,
|
|
424
|
+
// not to the command - passing it on would make it part of the command's own
|
|
425
|
+
// arguments (here: a search term of "--force ledger").
|
|
426
|
+
const at = raw === undefined ? -1 : argv.indexOf(raw)
|
|
427
|
+
const rest = at === -1 ? [] : argv.slice(at + 1)
|
|
428
|
+
process.argv = [process.argv[0], process.argv[1], verb, ...rest]
|
|
238
429
|
require(path.join(__dirname, 'fde.js'))
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!raw && askedVersion) {
|
|
433
|
+
console.log(require(path.join(__dirname, '..', 'package.json')).version)
|
|
434
|
+
} else if (!raw && askedHelp) {
|
|
435
|
+
handOffToCli('help')
|
|
436
|
+
} else if (arg === 'init') {
|
|
437
|
+
cmdInit(positional[1])
|
|
438
|
+
} else if (arg === 'adapters') {
|
|
439
|
+
cmdAdapters(positional[1], { force })
|
|
440
|
+
} else if (arg && arg !== 'install') {
|
|
441
|
+
handOffToCli(arg)
|
|
239
442
|
} else {
|
|
240
|
-
cmdInstall()
|
|
443
|
+
cmdInstall({ force })
|
|
241
444
|
}
|
package/bin/lib/memory.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { execFileSync } = require('child_process')
|
|
4
4
|
|
|
5
5
|
// Ephemeral sidecar files - never treated as "manual tamper" dirt.
|
|
6
|
-
const MEMORY_EPHEMERAL = new Set(['.last-write', '.debrief-propose'])
|
|
6
|
+
const MEMORY_EPHEMERAL = new Set(['.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal'])
|
|
7
7
|
|
|
8
8
|
function createMemoryApi(deps) {
|
|
9
9
|
const { fs, path, gitBinOk, writeOwnerIfMissing, atomicWriteFile } = deps
|
|
@@ -131,7 +131,7 @@ function createMemoryApi(deps) {
|
|
|
131
131
|
execFileSync('git', ['init'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
132
132
|
atomicWriteFile(
|
|
133
133
|
path.join(eng, '.gitignore'),
|
|
134
|
-
['*.lock', '*.tmp', '.last-write', '.debrief-propose', ''].join('\n')
|
|
134
|
+
['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', ''].join('\n')
|
|
135
135
|
)
|
|
136
136
|
const owner = writeOwnerIfMissing(eng)
|
|
137
137
|
configureMemoryGitIdentity(eng, owner)
|
package/bin/lib/render.js
CHANGED
|
@@ -196,6 +196,7 @@ strong{font-weight:600}
|
|
|
196
196
|
.fb-log-date{font-family:'Geist Mono',monospace;font-size:11.5px;color:var(--ink-faint);flex:0 0 48px}
|
|
197
197
|
.fb-log-kind{font-family:'Geist Mono',monospace;font-size:10px;letter-spacing:.03em;text-transform:uppercase;flex:0 0 62px}
|
|
198
198
|
.fb-log-text{flex:1 1 auto;font-size:14px;line-height:1.5;color:var(--ink)}
|
|
199
|
+
.fb-log-sig{font-family:'Geist Mono',monospace;font-size:10px;letter-spacing:.03em;text-transform:uppercase;white-space:nowrap}
|
|
199
200
|
.fb-more{border-top:1px solid var(--line);padding:12px 0}
|
|
200
201
|
.fb-more:first-child{border-top:none;padding-top:0}
|
|
201
202
|
.fb-more-sum{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px}
|
|
@@ -537,6 +538,7 @@ ${e.log.map(g => `<div class="fb-row fb-log">
|
|
|
537
538
|
<span class="fb-log-date">${escapeHtml(formatLogDate(g.date))}</span>
|
|
538
539
|
<span class="fb-log-kind t-${g.kind === 'receipt' ? 'green' : g.kind === 'decision' ? 'accent' : 'faint'}">${escapeHtml(g.kind)}</span>
|
|
539
540
|
<span class="fb-log-text">${inlineMd(g.text)}</span>
|
|
541
|
+
${g.sig ? `<span class="fb-log-sig t-${g.sig}" title="trust signal ${escapeHtml(g.sig)}">● ${escapeHtml(g.sig)}</span>` : ''}
|
|
540
542
|
</div>`).join('\n')}
|
|
541
543
|
</div>
|
|
542
544
|
</div>` : ''
|
package/mcp/README.md
CHANGED
|
@@ -6,7 +6,7 @@ FDEOps MCP servers follow a **pluggable source model**: core owns the **sink**,
|
|
|
6
6
|
|
|
7
7
|
| Role | Owner | Examples |
|
|
8
8
|
|------|-------|----------|
|
|
9
|
-
| **Source** | FDE configures separately | Granola,
|
|
9
|
+
| **Source** | FDE configures separately | Granola, Slack, Notion, Gmail, file |
|
|
10
10
|
| **Sink** | FDEOps (`fdeops-ingest`) | stage → propose → apply into engagement memory |
|
|
11
11
|
|
|
12
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.
|
|
@@ -50,6 +50,4 @@ Source MCPs are **not** bundled in fdeops. To add Granola, Gmail, or another pro
|
|
|
50
50
|
|
|
51
51
|
FDEOps credentials stay local to the CLI; source MCP credentials stay with that MCP.
|
|
52
52
|
|
|
53
|
-
|
|
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.
|
|
53
|
+
**Non-goals:** no bundled OAuth/connectors, no ambient sync, no unreviewed writes to `.fde/`. Method: [`skills/fde/references/ingest.md`](../skills/fde/references/ingest.md).
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Thin stdio MCP server for the FDEOps **ingest sink** only: **stage → propose → apply**.
|
|
4
4
|
|
|
5
|
-
This package shells out to the local `fde` CLI. It never calls SaaS APIs. Source MCPs (Granola,
|
|
5
|
+
This package shells out to the local `fde` CLI. It never calls SaaS APIs. Source MCPs (Granola, Slack, Notion, etc.) are **separate** — you add those in your own `mcp.json`.
|
|
6
|
+
|
|
7
|
+
**Prefer the CLI when this workspace is bound:** `fde ingest stage|list|propose|apply`. Use this MCP when the host did not start in a bound workspace — then pass `engagement` (path to `.fde/` from `fde resume --bind`) on every tool call.
|
|
6
8
|
|
|
7
9
|
## Tools
|
|
8
10
|
|
|
@@ -22,7 +24,9 @@ Each tool returns `{ stdout, stderr, status }` from the CLI.
|
|
|
22
24
|
|
|
23
25
|
## Configure in Cursor / Claude
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
**Agent Plugins clients: nothing to configure.** fdeops ships a root [`mcp.json`](../../mcp.json) declaring this server as `stdio` with a `${PLUGIN_ROOT}`-relative path, so a client that supports [Agent Plugins 1.0.0](https://agent-plugins.org/specification) wires it on install - no absolute paths to edit.
|
|
28
|
+
|
|
29
|
+
Everywhere else, add to your MCP config (`~/.cursor/mcp.json`, Claude Desktop config, etc.):
|
|
26
30
|
|
|
27
31
|
```json
|
|
28
32
|
{
|
|
@@ -87,4 +91,4 @@ Sources are pluggable and user-configured. This MCP owns the sink only.
|
|
|
87
91
|
|
|
88
92
|
## Zero dependencies
|
|
89
93
|
|
|
90
|
-
Hand-rolled MCP over stdio (
|
|
94
|
+
Hand-rolled MCP over stdio (newline-delimited JSON-RPC). No `@modelcontextprotocol/sdk` required at runtime.
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* fdeops-ingest MCP — thin stdio sink for FDEOps ingest.
|
|
6
6
|
* Shells out to local `fde` CLI only. Never calls SaaS.
|
|
7
|
-
* MCP stdio transport:
|
|
7
|
+
* MCP stdio transport: newline-delimited JSON-RPC 2.0
|
|
8
|
+
* (Content-Length frames are accepted on input).
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
const fs = require('fs')
|
|
@@ -15,11 +16,17 @@ const PROTOCOL_VERSION = '2024-11-05'
|
|
|
15
16
|
const SERVER_NAME = 'fdeops-ingest'
|
|
16
17
|
const SERVER_VERSION = require('./package.json').version
|
|
17
18
|
|
|
19
|
+
const ENGAGEMENT_PROP = {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description:
|
|
22
|
+
'Path to this client\'s .fde/ folder (from `fde resume --bind`). Optional if FDEOPS_ENGAGEMENT is set or the process cwd is already bound.',
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
const TOOLS = [
|
|
19
26
|
{
|
|
20
27
|
name: 'ingest_stage',
|
|
21
28
|
description:
|
|
22
|
-
'Stage raw content into the engagement inbox (.inbox/). Does not write .fde/.',
|
|
29
|
+
'Stage raw content into the engagement inbox (.inbox/). Does not write .fde/. Prefer the fde ingest CLI when the workspace is already bound.',
|
|
23
30
|
inputSchema: {
|
|
24
31
|
type: 'object',
|
|
25
32
|
properties: {
|
|
@@ -29,12 +36,13 @@ const TOOLS = [
|
|
|
29
36
|
},
|
|
30
37
|
source: {
|
|
31
38
|
type: 'string',
|
|
32
|
-
description: 'Provenance label (e.g. granola,
|
|
39
|
+
description: 'Provenance label (e.g. granola, slack, notion, file, manual). Default: manual.',
|
|
33
40
|
},
|
|
34
41
|
title: {
|
|
35
42
|
type: 'string',
|
|
36
43
|
description: 'Optional human-readable title for the staged item.',
|
|
37
44
|
},
|
|
45
|
+
engagement: ENGAGEMENT_PROP,
|
|
38
46
|
},
|
|
39
47
|
required: ['content'],
|
|
40
48
|
},
|
|
@@ -42,7 +50,10 @@ const TOOLS = [
|
|
|
42
50
|
{
|
|
43
51
|
name: 'ingest_list',
|
|
44
52
|
description: 'List staged items in the current engagement inbox.',
|
|
45
|
-
inputSchema: {
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: { engagement: ENGAGEMENT_PROP },
|
|
56
|
+
},
|
|
46
57
|
},
|
|
47
58
|
{
|
|
48
59
|
name: 'ingest_propose',
|
|
@@ -55,6 +66,7 @@ const TOOLS = [
|
|
|
55
66
|
type: 'string',
|
|
56
67
|
description: 'Staged filename or id from ingest_list.',
|
|
57
68
|
},
|
|
69
|
+
engagement: ENGAGEMENT_PROP,
|
|
58
70
|
},
|
|
59
71
|
required: ['id'],
|
|
60
72
|
},
|
|
@@ -63,38 +75,51 @@ const TOOLS = [
|
|
|
63
75
|
name: 'ingest_apply',
|
|
64
76
|
description:
|
|
65
77
|
'Apply the current debrief proposal into .fde/ memory (requires prior FDE confirm).',
|
|
66
|
-
inputSchema: {
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: { engagement: ENGAGEMENT_PROP },
|
|
81
|
+
},
|
|
67
82
|
},
|
|
68
83
|
]
|
|
69
84
|
|
|
70
85
|
let readBuffer = Buffer.alloc(0)
|
|
71
86
|
|
|
72
87
|
function writeMessage(obj) {
|
|
73
|
-
|
|
74
|
-
process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`)
|
|
88
|
+
process.stdout.write(`${JSON.stringify(obj)}\n`)
|
|
75
89
|
}
|
|
76
90
|
|
|
91
|
+
// MCP stdio frames messages by newline. Content-Length headers are tolerated on
|
|
92
|
+
// input only, so an LSP-style client still gets through.
|
|
77
93
|
function parseMessages() {
|
|
78
94
|
const messages = []
|
|
79
|
-
while (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
95
|
+
while (readBuffer.length) {
|
|
96
|
+
if (/^Content-Length:/i.test(readBuffer.slice(0, 15).toString('utf8'))) {
|
|
97
|
+
const headerEnd = readBuffer.indexOf('\r\n\r\n')
|
|
98
|
+
if (headerEnd === -1) break
|
|
99
|
+
const header = readBuffer.slice(0, headerEnd).toString('utf8')
|
|
100
|
+
const match = header.match(/Content-Length:\s*(\d+)/i)
|
|
101
|
+
if (!match) {
|
|
102
|
+
readBuffer = readBuffer.slice(headerEnd + 4)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
const length = parseInt(match[1], 10)
|
|
106
|
+
const bodyStart = headerEnd + 4
|
|
107
|
+
if (readBuffer.length < bodyStart + length) break
|
|
108
|
+
const body = readBuffer.slice(bodyStart, bodyStart + length).toString('utf8')
|
|
109
|
+
readBuffer = readBuffer.slice(bodyStart + length)
|
|
110
|
+
try {
|
|
111
|
+
messages.push(JSON.parse(body))
|
|
112
|
+
} catch (_) {}
|
|
87
113
|
continue
|
|
88
114
|
}
|
|
89
115
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
readBuffer = readBuffer.slice(bodyStart + length)
|
|
116
|
+
const newline = readBuffer.indexOf('\n')
|
|
117
|
+
if (newline === -1) break
|
|
118
|
+
const line = readBuffer.slice(0, newline).toString('utf8').trim()
|
|
119
|
+
readBuffer = readBuffer.slice(newline + 1)
|
|
120
|
+
if (!line) continue
|
|
96
121
|
try {
|
|
97
|
-
messages.push(JSON.parse(
|
|
122
|
+
messages.push(JSON.parse(line))
|
|
98
123
|
} catch (_) {}
|
|
99
124
|
}
|
|
100
125
|
return messages
|
|
@@ -118,10 +143,10 @@ function fdeEnv() {
|
|
|
118
143
|
return env
|
|
119
144
|
}
|
|
120
145
|
|
|
121
|
-
function runFde(args, stdin) {
|
|
146
|
+
function runFde(args, stdin, extraEnv) {
|
|
122
147
|
const { cmd, prefix } = resolveFde()
|
|
123
148
|
const result = spawnSync(cmd, [...prefix, ...args], {
|
|
124
|
-
env: fdeEnv(),
|
|
149
|
+
env: { ...fdeEnv(), ...(extraEnv || {}) },
|
|
125
150
|
input: stdin ?? undefined,
|
|
126
151
|
encoding: 'utf8',
|
|
127
152
|
maxBuffer: 16 * 1024 * 1024,
|
|
@@ -134,6 +159,11 @@ function runFde(args, stdin) {
|
|
|
134
159
|
}
|
|
135
160
|
}
|
|
136
161
|
|
|
162
|
+
function engagementEnv(args) {
|
|
163
|
+
const p = args && typeof args.engagement === 'string' ? args.engagement.trim() : ''
|
|
164
|
+
return p ? { FDEOPS_ENGAGEMENT: p } : {}
|
|
165
|
+
}
|
|
166
|
+
|
|
137
167
|
function cliPayload(out) {
|
|
138
168
|
const payload = { stdout: out.stdout, stderr: out.stderr, status: out.status }
|
|
139
169
|
if (out.error) payload.spawnError = out.error
|
|
@@ -153,6 +183,7 @@ function toolError(payload) {
|
|
|
153
183
|
|
|
154
184
|
function handleToolCall(name, args) {
|
|
155
185
|
args = args || {}
|
|
186
|
+
const extraEnv = engagementEnv(args)
|
|
156
187
|
|
|
157
188
|
switch (name) {
|
|
158
189
|
case 'ingest_stage': {
|
|
@@ -162,23 +193,23 @@ function handleToolCall(name, args) {
|
|
|
162
193
|
const source = args.source || 'manual'
|
|
163
194
|
const cliArgs = ['ingest', 'stage', '--source', source]
|
|
164
195
|
if (args.title) cliArgs.push('--title', args.title)
|
|
165
|
-
const out = runFde(cliArgs, args.content)
|
|
196
|
+
const out = runFde(cliArgs, args.content, extraEnv)
|
|
166
197
|
const payload = cliPayload(out)
|
|
167
198
|
return out.status === 0 ? toolResult(payload) : toolError(payload)
|
|
168
199
|
}
|
|
169
200
|
case 'ingest_list': {
|
|
170
|
-
const out = runFde(['ingest', 'list'])
|
|
201
|
+
const out = runFde(['ingest', 'list'], undefined, extraEnv)
|
|
171
202
|
const payload = cliPayload(out)
|
|
172
203
|
return out.status === 0 ? toolResult(payload) : toolError(payload)
|
|
173
204
|
}
|
|
174
205
|
case 'ingest_propose': {
|
|
175
206
|
if (!args.id) return toolError('Missing required argument: id')
|
|
176
|
-
const out = runFde(['ingest', 'propose', String(args.id)])
|
|
207
|
+
const out = runFde(['ingest', 'propose', String(args.id)], undefined, extraEnv)
|
|
177
208
|
const payload = cliPayload(out)
|
|
178
209
|
return out.status === 0 ? toolResult(payload) : toolError(payload)
|
|
179
210
|
}
|
|
180
211
|
case 'ingest_apply': {
|
|
181
|
-
const out = runFde(['ingest', 'apply'])
|
|
212
|
+
const out = runFde(['ingest', 'apply'], undefined, extraEnv)
|
|
182
213
|
const payload = cliPayload(out)
|
|
183
214
|
return out.status === 0 ? toolResult(payload) : toolError(payload)
|
|
184
215
|
}
|
package/mcp/recipes/README.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# Ingest source recipes
|
|
2
2
|
|
|
3
|
-
FDEOps does **not** bundle Granola /
|
|
3
|
+
FDEOps does **not** bundle Granola / Slack / Notion OAuth and does **not** push to those tools.
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Daily (no MCP):** paste notes to `@fde debrief`, or drop a file ([file.md](./file.md)).
|
|
6
|
+
|
|
7
|
+
**Pull (optional):** you add a **source** MCP. The agent fetches text, then runs `fde ingest` in this bound workspace (stage → propose → you confirm → apply). The `fdeops-ingest` MCP is optional — only if you are not using the CLI from a bound workspace.
|
|
6
8
|
|
|
7
9
|
| Recipe | When |
|
|
8
10
|
|--------|------|
|
|
9
|
-
| [file.md](./file.md) |
|
|
10
|
-
| [granola.md](./granola.md) | Meeting transcripts via a
|
|
11
|
-
| [
|
|
12
|
-
|
|
13
|
-
Also wire the sink once: [fdeops-ingest/README.md](../fdeops-ingest/README.md).
|
|
11
|
+
| [file.md](./file.md) | Transcript / export already on disk, or paste |
|
|
12
|
+
| [granola.md](./granola.md) | Meeting transcripts via a notes MCP (or export) |
|
|
13
|
+
| [slack.md](./slack.md) | Pull a thread/channel as text — never post |
|
|
14
|
+
| [notion.md](./notion.md) | Read a Notion page (or export markdown) |
|
|
14
15
|
|
|
15
|
-
**Natural language:** `@fde I want to connect Granola`
|
|
16
|
+
**Natural language:** `@fde I want to connect Granola` (or Slack / Notion) → `skills/fde/references/ingest-connect.md`.
|
package/mcp/recipes/file.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Recipe: local file / paste (no source MCP)
|
|
2
2
|
|
|
3
|
-
**Use when:** you already have a transcript, `.eml`, or export on disk — or you paste into chat.
|
|
3
|
+
**Use when:** you already have a transcript, `.eml`, or export on disk — or you paste into chat. This is the default FDE path.
|
|
4
4
|
|
|
5
5
|
## Setup
|
|
6
6
|
|
|
7
|
-
None
|
|
7
|
+
None. Bound workspace + `fde ingest` (or `@fde debrief` for short notes).
|
|
8
8
|
|
|
9
9
|
## Pull phrase
|
|
10
10
|
|
|
@@ -12,14 +12,14 @@ None beyond the FDEOps sink (`fde` CLI and optionally `fdeops-ingest` MCP).
|
|
|
12
12
|
@fde stage this transcript into the fieldbook and propose updates
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
(or attach / point at a path)
|
|
15
|
+
(or attach / point at a path, or paste and say debrief)
|
|
16
16
|
|
|
17
17
|
## Agent steps
|
|
18
18
|
|
|
19
|
-
1. Bind engagement.
|
|
20
|
-
2. `fde ingest stage --source file --title "<short>" <path
|
|
21
|
-
3.
|
|
19
|
+
1. Bind engagement (`fde resume`).
|
|
20
|
+
2. Short paste → debrief verb. Long file → `fde ingest stage --source file --title "<short>" <path>`.
|
|
21
|
+
3. Propose → rewrite prefixes → show FDE → on confirm apply.
|
|
22
22
|
|
|
23
23
|
## mcp.json
|
|
24
24
|
|
|
25
|
-
Not required
|
|
25
|
+
Not required.
|