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/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
@@ -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, Gmail, Notion, custom scrapers |
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
- ## Design reference
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, Gmail, Notion, etc.) are **separate** — you add those in your own `mcp.json`.
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
- Add to your MCP config (`~/.cursor/mcp.json`, Claude Desktop config, etc.):
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 (Content-Length framed JSON-RPC). No `@modelcontextprotocol/sdk` required at runtime.
94
+ Hand-rolled MCP over stdio (newline-delimited JSON-RPC). No `@modelcontextprotocol/sdk` required at runtime.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.9.20",
3
+ "version": "3.10.2",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
6
  "bin": {
@@ -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: Content-Length framed JSON-RPC 2.0.
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, gmail, manual). Default: manual.',
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: { type: 'object', properties: {} },
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: { type: 'object', properties: {} },
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
- const body = JSON.stringify(obj)
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 (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)
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 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)
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(body))
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
  }
@@ -1,15 +1,16 @@
1
1
  # Ingest source recipes
2
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**.
3
+ FDEOps does **not** bundle Granola / Slack / Notion OAuth and does **not** push to those tools.
4
4
 
5
- **Contract every source must satisfy:** fetch text `fde ingest stage` (or MCP `ingest_stage`) with `{ source, title, content }` → propose → FDE confirms → apply.
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) | 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).
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` agent follows `skills/fde/references/ingest-connect.md` and this recipe.
16
+ **Natural language:** `@fde I want to connect Granola` (or Slack / Notion) → `skills/fde/references/ingest-connect.md`.
@@ -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 beyond the FDEOps sink (`fde` CLI and optionally `fdeops-ingest` MCP).
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>` (or stdin).
21
- 3. `fde ingest propose <id>` → rewrite prefixes → show FDE → on confirm `fde ingest apply`.
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 for the source. Optional sink only — see [../fdeops-ingest/README.md](../fdeops-ingest/README.md).
25
+ Not required.