fdeops 3.9.19 → 3.10.0
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 +45 -7
- package/adapters/LOCAL-LLM.md +1 -1
- package/adapters/README.md +1 -1
- package/bin/check.js +219 -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 +5 -3
- package/mcp/fdeops-ingest/README.md +3 -1
- package/mcp/fdeops-ingest/package.json +1 -1
- package/mcp/fdeops-ingest/server.js +27 -17
- package/mcp/recipes/README.md +15 -0
- package/mcp/recipes/file.md +25 -0
- package/mcp/recipes/granola.md +58 -0
- package/mcp/recipes/notion.md +56 -0
- package/mcp.json +10 -0
- package/package.json +4 -2
- package/plugin.json +21 -0
- package/skills/fde/SKILL.md +4 -2
- 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 +36 -0
- package/skills/fde/references/ingest.md +13 -3
- 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
|
@@ -36,6 +36,10 @@ Source MCP(s) fdeops-ingest MCP fde CLI
|
|
|
36
36
|
|---------|------|---------|
|
|
37
37
|
| `fdeops-ingest-mcp` | [`fdeops-ingest/`](./fdeops-ingest/) | Ingest sink (stage, list, propose, apply) |
|
|
38
38
|
|
|
39
|
+
## Recipes (copy-paste connect)
|
|
40
|
+
|
|
41
|
+
See [`recipes/`](./recipes/) for file, Granola-shaped, and Notion-shaped setup. In chat: `@fde I want to connect Granola` → skill `ingest-connect` walks the FDE through config + reload + verify.
|
|
42
|
+
|
|
39
43
|
## Adding a source MCP
|
|
40
44
|
|
|
41
45
|
Source MCPs are **not** bundled in fdeops. To add Granola, Gmail, or another provider:
|
|
@@ -46,6 +50,4 @@ Source MCPs are **not** bundled in fdeops. To add Granola, Gmail, or another pro
|
|
|
46
50
|
|
|
47
51
|
FDEOps credentials stay local to the CLI; source MCP credentials stay with that MCP.
|
|
48
52
|
|
|
49
|
-
|
|
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.
|
|
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).
|
|
@@ -22,7 +22,9 @@ Each tool returns `{ stdout, stderr, status }` from the CLI.
|
|
|
22
22
|
|
|
23
23
|
## Configure in Cursor / Claude
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
**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.
|
|
26
|
+
|
|
27
|
+
Everywhere else, add to your MCP config (`~/.cursor/mcp.json`, Claude Desktop config, etc.):
|
|
26
28
|
|
|
27
29
|
```json
|
|
28
30
|
{
|
|
@@ -70,31 +70,41 @@ const TOOLS = [
|
|
|
70
70
|
let readBuffer = Buffer.alloc(0)
|
|
71
71
|
|
|
72
72
|
function writeMessage(obj) {
|
|
73
|
-
|
|
74
|
-
process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`)
|
|
73
|
+
process.stdout.write(`${JSON.stringify(obj)}\n`)
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
// MCP stdio frames messages by newline. Content-Length headers are tolerated on
|
|
77
|
+
// input only, so an LSP-style client still gets through.
|
|
77
78
|
function parseMessages() {
|
|
78
79
|
const messages = []
|
|
79
|
-
while (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
80
|
+
while (readBuffer.length) {
|
|
81
|
+
if (/^Content-Length:/i.test(readBuffer.slice(0, 15).toString('utf8'))) {
|
|
82
|
+
const headerEnd = readBuffer.indexOf('\r\n\r\n')
|
|
83
|
+
if (headerEnd === -1) break
|
|
84
|
+
const header = readBuffer.slice(0, headerEnd).toString('utf8')
|
|
85
|
+
const match = header.match(/Content-Length:\s*(\d+)/i)
|
|
86
|
+
if (!match) {
|
|
87
|
+
readBuffer = readBuffer.slice(headerEnd + 4)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const length = parseInt(match[1], 10)
|
|
91
|
+
const bodyStart = headerEnd + 4
|
|
92
|
+
if (readBuffer.length < bodyStart + length) break
|
|
93
|
+
const body = readBuffer.slice(bodyStart, bodyStart + length).toString('utf8')
|
|
94
|
+
readBuffer = readBuffer.slice(bodyStart + length)
|
|
95
|
+
try {
|
|
96
|
+
messages.push(JSON.parse(body))
|
|
97
|
+
} catch (_) {}
|
|
87
98
|
continue
|
|
88
99
|
}
|
|
89
100
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
readBuffer = readBuffer.slice(bodyStart + length)
|
|
101
|
+
const newline = readBuffer.indexOf('\n')
|
|
102
|
+
if (newline === -1) break
|
|
103
|
+
const line = readBuffer.slice(0, newline).toString('utf8').trim()
|
|
104
|
+
readBuffer = readBuffer.slice(newline + 1)
|
|
105
|
+
if (!line) continue
|
|
96
106
|
try {
|
|
97
|
-
messages.push(JSON.parse(
|
|
107
|
+
messages.push(JSON.parse(line))
|
|
98
108
|
} catch (_) {}
|
|
99
109
|
}
|
|
100
110
|
return messages
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Ingest source recipes
|
|
2
|
+
|
|
3
|
+
FDEOps does **not** bundle Granola / Notion / Drive OAuth. These recipes show how an FDE wires a **source MCP** (or file drop) into the FDEOps **sink**.
|
|
4
|
+
|
|
5
|
+
**Contract every source must satisfy:** fetch text → `fde ingest stage` (or MCP `ingest_stage`) with `{ source, title, content }` → propose → FDE confirms → apply.
|
|
6
|
+
|
|
7
|
+
| Recipe | When |
|
|
8
|
+
|--------|------|
|
|
9
|
+
| [file.md](./file.md) | Local transcript / export already on disk (no source MCP) |
|
|
10
|
+
| [granola.md](./granola.md) | Meeting transcripts via a Granola-shaped MCP (or export) |
|
|
11
|
+
| [notion.md](./notion.md) | Notion pages / meeting notes via a Notion MCP |
|
|
12
|
+
|
|
13
|
+
Also wire the sink once: [fdeops-ingest/README.md](../fdeops-ingest/README.md).
|
|
14
|
+
|
|
15
|
+
**Natural language:** `@fde I want to connect Granola` → agent follows `skills/fde/references/ingest-connect.md` and this recipe.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Recipe: local file / paste (no source MCP)
|
|
2
|
+
|
|
3
|
+
**Use when:** you already have a transcript, `.eml`, or export on disk — or you paste into chat.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
None beyond the FDEOps sink (`fde` CLI and optionally `fdeops-ingest` MCP).
|
|
8
|
+
|
|
9
|
+
## Pull phrase
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
@fde stage this transcript into the fieldbook and propose updates
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
(or attach / point at a path)
|
|
16
|
+
|
|
17
|
+
## Agent steps
|
|
18
|
+
|
|
19
|
+
1. Bind engagement.
|
|
20
|
+
2. `fde ingest stage --source file --title "<short>" <path>` (or stdin).
|
|
21
|
+
3. `fde ingest propose <id>` → rewrite prefixes → show FDE → on confirm `fde ingest apply`.
|
|
22
|
+
|
|
23
|
+
## mcp.json
|
|
24
|
+
|
|
25
|
+
Not required for the source. Optional sink only — see [../fdeops-ingest/README.md](../fdeops-ingest/README.md).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Recipe: Granola-shaped meeting transcripts
|
|
2
|
+
|
|
3
|
+
**Use when:** meeting notes live in Granola (or a similar notes MCP). FDEOps does not ship a Granola server — you add whichever MCP/export path you trust.
|
|
4
|
+
|
|
5
|
+
## Setup (once)
|
|
6
|
+
|
|
7
|
+
1. Install / enable a **Granola (or notes) MCP** in Cursor/Claude per that product’s docs.
|
|
8
|
+
2. Add the FDEOps **sink** MCP (`fdeops-ingest`) — [../fdeops-ingest/README.md](../fdeops-ingest/README.md).
|
|
9
|
+
3. Reload MCP / restart the agent host.
|
|
10
|
+
4. Test: `@fde what can you pull?` — agent should see both sink tools and the notes source tools.
|
|
11
|
+
|
|
12
|
+
### Example mcp.json shape (illustrative)
|
|
13
|
+
|
|
14
|
+
Replace `granola-mcp` command/args with whatever the real server documents. FDEOps only needs *some* tool that returns transcript text.
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"mcpServers": {
|
|
19
|
+
"granola": {
|
|
20
|
+
"command": "npx",
|
|
21
|
+
"args": ["-y", "YOUR-GRANOLA-MCP-PACKAGE"],
|
|
22
|
+
"env": {
|
|
23
|
+
"GRANOLA_API_KEY": "from-your-secrets"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"fdeops-ingest": {
|
|
27
|
+
"command": "node",
|
|
28
|
+
"args": ["/absolute/path/to/fdeops/mcp/fdeops-ingest/server.js"],
|
|
29
|
+
"env": {
|
|
30
|
+
"FDEOPS_ENGAGEMENT": "/Users/you/fde-engagements/acme/.fde"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**No Granola MCP available?** Export transcript to a file → follow [file.md](./file.md).
|
|
38
|
+
|
|
39
|
+
## Pull phrase
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
@fde pull today's Acme Granola into the fieldbook
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Agent steps
|
|
46
|
+
|
|
47
|
+
1. Capability check — if no notes/Granola tools, run connect flow (`ingest-connect.md`).
|
|
48
|
+
2. Fetch transcript via source MCP (or ask which meeting).
|
|
49
|
+
3. `ingest_stage` / `fde ingest stage --source granola --title "…"`.
|
|
50
|
+
4. Propose → confirm → apply. Never auto-apply.
|
|
51
|
+
|
|
52
|
+
## Common fails
|
|
53
|
+
|
|
54
|
+
| Symptom | Fix |
|
|
55
|
+
|---------|-----|
|
|
56
|
+
| Agent says it can’t reach Granola | MCP not saved / host not reloaded / wrong env key |
|
|
57
|
+
| Wrong client inbox | Set `FDEOPS_ENGAGEMENT` or bind workspace (`fde resume --init`) |
|
|
58
|
+
| Empty propose | Agent must rewrite `.debrief-propose` with type prefixes |
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Recipe: Notion docs / meeting notes
|
|
2
|
+
|
|
3
|
+
**Use when:** useful engagement notes live in Notion. FDEOps does not ship a Notion server — use a Notion MCP (or export markdown).
|
|
4
|
+
|
|
5
|
+
## Setup (once)
|
|
6
|
+
|
|
7
|
+
1. Enable a **Notion MCP** (official or community) with a token that can read the pages you need.
|
|
8
|
+
2. Add **fdeops-ingest** sink — [../fdeops-ingest/README.md](../fdeops-ingest/README.md).
|
|
9
|
+
3. Reload MCP / restart host.
|
|
10
|
+
4. Test: `@fde what can you pull?`
|
|
11
|
+
|
|
12
|
+
### Example mcp.json shape (illustrative)
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"notion": {
|
|
18
|
+
"command": "npx",
|
|
19
|
+
"args": ["-y", "YOUR-NOTION-MCP-PACKAGE"],
|
|
20
|
+
"env": {
|
|
21
|
+
"NOTION_TOKEN": "from-your-secrets"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"fdeops-ingest": {
|
|
25
|
+
"command": "node",
|
|
26
|
+
"args": ["/absolute/path/to/fdeops/mcp/fdeops-ingest/server.js"],
|
|
27
|
+
"env": {
|
|
28
|
+
"FDEOPS_ENGAGEMENT": "/Users/you/fde-engagements/acme/.fde"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**No Notion MCP?** Export page to markdown → [file.md](./file.md).
|
|
36
|
+
|
|
37
|
+
## Pull phrase
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
@fde pull the Acme discovery notes Notion page into the fieldbook
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Agent steps
|
|
44
|
+
|
|
45
|
+
1. Capability check — Notion tools present?
|
|
46
|
+
2. Fetch page/block text via Notion MCP (ask which page if ambiguous).
|
|
47
|
+
3. Stage with `--source notion`.
|
|
48
|
+
4. Propose → confirm → apply.
|
|
49
|
+
|
|
50
|
+
## Common fails
|
|
51
|
+
|
|
52
|
+
| Symptom | Fix |
|
|
53
|
+
|---------|-----|
|
|
54
|
+
| 401 / forbidden | Token lacks access to that workspace/page |
|
|
55
|
+
| Huge page dump | Stage full text in `.inbox/`; propose only short dated facts |
|
|
56
|
+
| Wrong engagement | Bind / `FDEOPS_ENGAGEMENT` |
|
package/mcp.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
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",
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
"adapters/",
|
|
21
21
|
"mcp/",
|
|
22
22
|
"CLAUDE.md.template",
|
|
23
|
-
"AGENTS.md"
|
|
23
|
+
"AGENTS.md",
|
|
24
|
+
"plugin.json",
|
|
25
|
+
"mcp.json"
|
|
24
26
|
],
|
|
25
27
|
"keywords": [
|
|
26
28
|
"claude-code",
|
package/plugin.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
|
+
"name": "fdeops",
|
|
4
|
+
"version": "3.10.0",
|
|
5
|
+
"description": "Engagement fieldbook for Forward Deployed Engineers: per-client memory in local .fde/ files, one @fde skill, land to close methodology. Local-only, no network.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Subash Natarajan",
|
|
8
|
+
"url": "https://github.com/suboss87"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://fdeops.io/",
|
|
11
|
+
"repository": "https://github.com/suboss87/FDEOps",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"fde",
|
|
15
|
+
"forward-deployed",
|
|
16
|
+
"engagement-memory",
|
|
17
|
+
"client-work",
|
|
18
|
+
"consulting",
|
|
19
|
+
"brownfield"
|
|
20
|
+
]
|
|
21
|
+
}
|