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/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 (fs.existsSync(path.join(p, 'SKILL.md'))) {
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
- function installSkills() {
78
- const removed = removeLegacySkills()
79
- if (removed > 0) console.log(` Removed ${removed} v2 skill dir(s) (now covered by @fde)`)
80
- copyDir(SKILLS_SRC, GLOBAL_SKILLS_DIR)
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 arg = process.argv[2]
233
- if (arg === 'init') {
234
- cmdInit(process.argv[3])
235
- } else if (arg === 'adapters') {
236
- cmdAdapters(process.argv[3])
237
- } else if (FDE_SUBCOMMANDS.includes(arg)) {
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)}">●&nbsp;${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
- ## 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.
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
- Add to your MCP config (`~/.cursor/mcp.json`, Claude Desktop config, etc.):
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
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.9.19",
3
+ "version": "3.10.0",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
6
  "bin": {
@@ -70,31 +70,41 @@ const TOOLS = [
70
70
  let readBuffer = Buffer.alloc(0)
71
71
 
72
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}`)
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 (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)
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 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)
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(body))
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
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
3
+ "mcpServers": {
4
+ "fdeops-ingest": {
5
+ "type": "stdio",
6
+ "command": "node",
7
+ "args": ["${PLUGIN_ROOT}/mcp/fdeops-ingest/server.js"]
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.9.19",
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
+ }