fdeops 3.10.0 → 3.11.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.
@@ -0,0 +1,323 @@
1
+ 'use strict'
2
+
3
+ // Derived Obsidian view of the fieldbook. Pure builders: in comes already-redacted
4
+ // engagement data, out comes a list of { rel, content } files. No fs, no network.
5
+ //
6
+ // Two rules this file exists to keep:
7
+ // 1. `.fde/` stays the only source of truth. Nothing here is ever parsed back,
8
+ // so an FDE can edit the vault, delete it, or ignore it with no consequence.
9
+ // 2. The vault is an output of the CLI, so <private> blocks are already gone
10
+ // before anything reaches these builders (callers use readClean). `redacted`
11
+ // goes further and drops the political layer for a shared screen.
12
+
13
+ const FORMAT = 1
14
+ const STAMP = '.fdeops-vault'
15
+
16
+ // Obsidian resolves [[links]] by note name, and | # ^ [ ] break the link syntax.
17
+ // A client called "Acme | EU" must still get a reachable page.
18
+ function safeTitle(s) {
19
+ return String(s || '')
20
+ .replace(/[[\]|#^\\/:*?"<>]/g, ' ')
21
+ .replace(/\s+/g, ' ')
22
+ .trim() || 'untitled'
23
+ }
24
+
25
+ function cell(s) {
26
+ return String(s || '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim()
27
+ }
28
+
29
+ function yamlStr(s) {
30
+ return `"${String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
31
+ }
32
+
33
+ // Frontmatter is what makes the vault queryable (Obsidian properties, Dataview,
34
+ // Bases). It exists only in generated files - the authoritative .fde/ markdown
35
+ // stays plain, so a client can read it without a tool.
36
+ function frontmatter(fields) {
37
+ const lines = ['---']
38
+ for (const [k, v] of Object.entries(fields)) {
39
+ if (v == null || v === '') continue
40
+ if (Array.isArray(v)) {
41
+ if (!v.length) continue
42
+ lines.push(`${k}:`)
43
+ v.forEach(item => lines.push(` - ${yamlStr(item)}`))
44
+ } else if (typeof v === 'number' || typeof v === 'boolean') {
45
+ lines.push(`${k}: ${v}`)
46
+ } else {
47
+ lines.push(`${k}: ${yamlStr(v)}`)
48
+ }
49
+ }
50
+ lines.push('---', '')
51
+ return lines.join('\n')
52
+ }
53
+
54
+ function trustWord(trust) {
55
+ return String(trust || '').toLowerCase() === 'red' ? 'red' : String(trust || '')
56
+ }
57
+
58
+ // A signal token is internal shorthand; the sponsor view keeps the fact and
59
+ // drops the grading. Whole section bodies pass through here, so only the space
60
+ // the token itself occupied is closed up: a global whitespace collapse ate the
61
+ // newlines, glued headings to prose and broke every table on these pages.
62
+ function stripInternalTokens(text) {
63
+ return String(text || '')
64
+ .replace(/[^\S\n]*\[signal:(red|amber|green)\]/gi, '')
65
+ .replace(/[^\S\n]*\[@[^\]]+\]/g, '')
66
+ .trim()
67
+ }
68
+
69
+ function sectionPage({ eng, title, body, kind, redacted, today }) {
70
+ return frontmatter({
71
+ client: eng.name,
72
+ fde_page: kind,
73
+ phase: eng.signals.phase === '?' ? '' : eng.signals.phase,
74
+ generated: today,
75
+ tags: ['fdeops', `fdeops/${kind}`],
76
+ }) + `# ${safeTitle(eng.name)} - ${title}\n\n` +
77
+ `Engagement: [[${safeTitle(eng.name)}]]\n\n` +
78
+ (body.trim() ? body.trim() + '\n' : `*(nothing recorded yet)*\n`) +
79
+ `\n---\n*Generated from \`${eng.name}/.fde/${kind}.md\`${redacted ? ' - sponsor-safe copy' : ''}. Edit the fieldbook, not this page.*\n`
80
+ }
81
+
82
+ function personPage({ eng, person, today }) {
83
+ return frontmatter({
84
+ client: eng.name,
85
+ fde_page: 'person',
86
+ role: person.role,
87
+ signal: person.signal,
88
+ generated: today,
89
+ tags: ['fdeops', 'fdeops/person', `fdeops/signal/${person.signal || 'unknown'}`],
90
+ }) + `# ${safeTitle(person.name)}\n\n` +
91
+ `${person.role ? `**Role:** ${person.role} \n` : ''}` +
92
+ `**Latest signal:** ${person.signal || 'unrecorded'}\n\n` +
93
+ (person.note ? `${person.note}\n\n` : '') +
94
+ `Engagement: [[${safeTitle(eng.name)}]]\n`
95
+ }
96
+
97
+ function hubPage({ eng, today, redacted }) {
98
+ const s = eng.signals
99
+ // Next action / Brief / Reality are free text an FDE types, so they carry the
100
+ // same internal tokens the timeline does - strip them on the sponsor page too.
101
+ const plain = (text) => redacted ? stripInternalTokens(text) : String(text || '')
102
+ const links = [
103
+ ['Decisions', 'decisions'],
104
+ ['Risks', 'risks'],
105
+ ['Delivery', 'delivery'],
106
+ ...(redacted ? [] : [['Stakeholders', 'stakeholders']]),
107
+ ['Terrain', 'terrain'],
108
+ ['Success', 'success'],
109
+ ...(redacted ? [] : [['Trust profile', 'trust-profile']]),
110
+ ].filter(([, kind]) => eng.pages[kind] != null)
111
+
112
+ const timeline = (redacted ? eng.log.filter(e => e.kind !== 'note') : eng.log)
113
+ .map(e => `- **${e.date}** ${cell(redacted ? stripInternalTokens(e.text) : e.text)}${e.sig && !redacted ? ` \`[${e.sig}]\`` : ''}`)
114
+
115
+ const people = redacted ? [] : eng.stakeholders.map(p =>
116
+ `- [[${safeTitle(eng.name)}/People/${safeTitle(p.name)}|${safeTitle(p.name)}]]${p.role ? ` - ${cell(p.role)}` : ''} \`${p.signal || 'unrecorded'}\``)
117
+
118
+ return frontmatter({
119
+ client: eng.name,
120
+ fde_page: 'engagement',
121
+ phase: s.phase === '?' ? '' : s.phase,
122
+ ...(redacted ? {} : { trust: trustWord(s.trust), signal_age_days: s.signalAge == null ? '' : s.signalAge, signal_stale: !!s.stale }),
123
+ open_risks: s.openRisks,
124
+ days_elapsed: eng.days == null ? '' : eng.days,
125
+ last_updated: s.updated,
126
+ overlay: eng.overlay || '',
127
+ generated: today,
128
+ tags: ['fdeops', 'fdeops/engagement', ...(redacted ? [] : [`fdeops/trust/${trustWord(s.trust) || 'unknown'}`])],
129
+ }) + [
130
+ `# ${safeTitle(eng.name)}`,
131
+ '',
132
+ `**Phase:** ${s.phase === '?' ? 'unset' : s.phase}` +
133
+ (redacted ? '' : ` · **Trust:** ${trustWord(s.trust)}${s.stale ? ' (signal stale - reconfirm)' : ''}`) +
134
+ ` · **Open risks:** ${s.openRisks} · **Updated:** ${s.updated}`,
135
+ '',
136
+ '## Next action',
137
+ '',
138
+ eng.next ? plain(eng.next) : '*(none recorded - `fde log`/`@fde` writes one)*',
139
+ '',
140
+ ...(eng.brief ? ['## Brief', '', plain(eng.brief), ''] : []),
141
+ ...(eng.reality ? ['## Reality', '', plain(eng.reality), ''] : []),
142
+ '## The record',
143
+ '',
144
+ ...links.map(([label, kind]) => `- [[${safeTitle(eng.name)}/${label}|${label}]]`),
145
+ '',
146
+ ...(people.length ? ['## People', '', ...people, ''] : []),
147
+ ...(timeline.length ? ['## Timeline', '', ...timeline, ''] : []),
148
+ '---',
149
+ `*Derived from \`${eng.name}/.fde/\` on ${today}. Regenerate with \`fde vault${redacted ? ' --redacted' : ''}\`.*`,
150
+ '',
151
+ ].join('\n')
152
+ }
153
+
154
+ function portfolioPage({ engagements, today, redacted }) {
155
+ const order = { RED: 0, amber: 1, green: 2 }
156
+ const rows = [...engagements].sort((a, b) =>
157
+ (order[a.signals.trust] ?? 3) - (order[b.signals.trust] ?? 3) || a.name.localeCompare(b.name))
158
+
159
+ const head = redacted
160
+ ? ['| Engagement | Phase | Open risks | Next action |', '|---|---|---|---|']
161
+ : ['| Engagement | Phase | Trust | Open risks | Updated | Next action |', '|---|---|---|---|---|---|']
162
+
163
+ const body = rows.map(e => {
164
+ const link = `[[${safeTitle(e.name)}]]`
165
+ const phase = e.signals.phase === '?' ? 'unset' : e.signals.phase
166
+ const next = cell(redacted ? stripInternalTokens(e.next) : e.next) || '-'
167
+ return redacted
168
+ ? `| ${link} | ${phase} | ${e.signals.openRisks} | ${next} |`
169
+ : `| ${link} | ${phase} | ${trustWord(e.signals.trust)}${e.signals.stale ? '?' : ''} | ${e.signals.openRisks} | ${cell(e.signals.updated)} | ${next} |`
170
+ })
171
+
172
+ return frontmatter({
173
+ fde_page: 'portfolio',
174
+ engagements: rows.length,
175
+ generated: today,
176
+ tags: ['fdeops', 'fdeops/portfolio'],
177
+ }) + [
178
+ '# Portfolio',
179
+ '',
180
+ rows.length
181
+ ? `${rows.length} engagement${rows.length === 1 ? '' : 's'}${redacted ? '' : ', worst trust first'}.`
182
+ : 'No engagements yet - `fde resume --init <name>`.',
183
+ '',
184
+ ...(rows.length ? [...head, ...body, ''] : []),
185
+ ...(redacted ? [] : ['See [[Questions]] for what the record is missing.', '']),
186
+ ].join('\n')
187
+ }
188
+
189
+ // Deterministic answers, computed here rather than shipped as Dataview queries:
190
+ // the vault must work in a stock Obsidian with no plugins installed.
191
+ function questionsPage({ engagements, today }) {
192
+ const quiet = engagements.filter(e => e.signals.ageDays !== Infinity && e.signals.ageDays >= 14)
193
+ const staleSignal = engagements.filter(e => e.signals.stale)
194
+ const noNext = engagements.filter(e => !e.next)
195
+ const unaccepted = []
196
+ const noSignal = []
197
+ for (const e of engagements) {
198
+ for (const row of e.valueRows || []) {
199
+ if (!row.acceptedBy) unaccepted.push({ eng: e, row })
200
+ }
201
+ if (!e.stakeholders.length) noSignal.push(e)
202
+ }
203
+
204
+ const list = (items, empty) => items.length ? items : [`- ${empty}`]
205
+
206
+ return frontmatter({
207
+ fde_page: 'questions',
208
+ generated: today,
209
+ tags: ['fdeops', 'fdeops/questions'],
210
+ }) + [
211
+ '# Questions',
212
+ '',
213
+ 'What the record cannot answer is the part worth reading. Recomputed on every `fde vault`.',
214
+ '',
215
+ '## Gone quiet (14+ days since a memory write)',
216
+ '',
217
+ ...list(quiet.map(e => `- [[${safeTitle(e.name)}]] - ${e.signals.updated}`), 'none'),
218
+ '',
219
+ '## Value promised but nobody accepted it',
220
+ '',
221
+ ...list(unaccepted.map(({ eng, row }) => `- [[${safeTitle(eng.name)}]] - ${cell(row.slice || row.promised || 'unnamed slice')}`),
222
+ 'none - every delivered slice names a customer-side acceptor'),
223
+ '',
224
+ '## Trust signal older than 21 days',
225
+ '',
226
+ ...list(staleSignal.map(e => `- [[${safeTitle(e.name)}]] - signal ${e.signals.signalAge}d old`), 'none'),
227
+ '',
228
+ '## No stakeholder signal at all',
229
+ '',
230
+ ...list(noSignal.map(e => `- [[${safeTitle(e.name)}]]`), 'none'),
231
+ '',
232
+ '## No next action recorded',
233
+ '',
234
+ ...list(noNext.map(e => `- [[${safeTitle(e.name)}]]`), 'none'),
235
+ '',
236
+ `Portfolio: [[Portfolio]]`,
237
+ '',
238
+ ].join('\n')
239
+ }
240
+
241
+ function readmePage({ engagements, today, redacted, engagementsRoot }) {
242
+ return [
243
+ '# FDEOps vault (generated - do not keep anything here)',
244
+ '',
245
+ `Generated ${today} from \`${engagementsRoot}\`. ${engagements.length} engagement${engagements.length === 1 ? '' : 's'}.`,
246
+ '',
247
+ 'Open this folder as an Obsidian vault. Start at [[Portfolio]]' + (redacted ? '.' : ' and [[Questions]].'),
248
+ '',
249
+ '## What this is',
250
+ '',
251
+ '- A **derived** view. The fieldbook at `~/fde-engagements/<client>/.fde/` is the only source of truth.',
252
+ '- **Disposable.** `fde vault` deletes and rebuilds this folder, so anything you type here is lost. Log to the fieldbook instead (`@fde` or `fde log`).',
253
+ '- **Nothing is read back.** No plugin required, no sync, no network - plain markdown, wikilinks and frontmatter.',
254
+ '',
255
+ '## What is not here',
256
+ '',
257
+ '- `<private>` blocks. They never leave `.fde/`; every page here is built from redacted reads.',
258
+ ...(redacted
259
+ ? [
260
+ '- Stakeholders, people pages, trust signals and `trust-profile.md` - this is the `--redacted` build, meant for a shared screen.',
261
+ '- Internal `[signal:x]` and `[@owner]` tokens, and contact notes in the timeline.',
262
+ '',
263
+ '**Still check before you screen-share.** Redaction removes the political layer, not judgement: `decisions.md`, `risks.md` and `delivery.md` are shown as written.',
264
+ ]
265
+ : [
266
+ '- Nothing else. This is the full working view, for your machine only. For a sponsor meeting run `fde vault --redacted`.',
267
+ ]),
268
+ '',
269
+ ].join('\n')
270
+ }
271
+
272
+ function buildVaultFiles({ engagements, today, redacted = false, engagementsRoot = '~/fde-engagements', version = '' }) {
273
+ const files = []
274
+ const SECTIONS = [
275
+ ['Decisions', 'decisions'],
276
+ ['Risks', 'risks'],
277
+ ['Delivery', 'delivery'],
278
+ ['Stakeholders', 'stakeholders'],
279
+ ['Terrain', 'terrain'],
280
+ ['Success', 'success'],
281
+ ['Trust profile', 'trust-profile'],
282
+ ]
283
+ const REDACTED_OUT = new Set(['stakeholders', 'trust-profile'])
284
+
285
+ files.push({ rel: 'README.md', content: readmePage({ engagements, today, redacted, engagementsRoot }) })
286
+ files.push({ rel: 'Portfolio.md', content: portfolioPage({ engagements, today, redacted }) })
287
+ if (!redacted) files.push({ rel: 'Questions.md', content: questionsPage({ engagements, today }) })
288
+
289
+ for (const eng of engagements) {
290
+ const dir = safeTitle(eng.name)
291
+ files.push({ rel: `${dir}/${dir}.md`, content: hubPage({ eng, today, redacted }) })
292
+ for (const [title, kind] of SECTIONS) {
293
+ if (redacted && REDACTED_OUT.has(kind)) continue
294
+ const body = eng.pages[kind]
295
+ if (body == null) continue
296
+ files.push({
297
+ rel: `${dir}/${title}.md`,
298
+ content: sectionPage({ eng, title, body: redacted ? stripInternalTokens(body) : body, kind, redacted, today }),
299
+ })
300
+ }
301
+ if (!redacted) {
302
+ for (const person of eng.stakeholders) {
303
+ files.push({ rel: `${dir}/People/${safeTitle(person.name)}.md`, content: personPage({ eng, person, today }) })
304
+ }
305
+ }
306
+ }
307
+
308
+ // A generated vault must not become a commit. `*` covers the whole tree, so a
309
+ // vault written inside a repo checkout stays out of `git status` too.
310
+ files.push({ rel: '.gitignore', content: '# generated by fde vault - never commit a client record\n*\n' })
311
+ files.push({
312
+ rel: STAMP,
313
+ content: JSON.stringify({
314
+ tool: 'fdeops', format: FORMAT, version, generated: today,
315
+ mode: redacted ? 'redacted' : 'full', engagements: engagements.length,
316
+ source: engagementsRoot,
317
+ note: 'Written by `fde vault`. Deleted and rebuilt on every run - this file is how the CLI knows the folder is safe to replace.',
318
+ }, null, 2) + '\n',
319
+ })
320
+ return files
321
+ }
322
+
323
+ module.exports = { buildVaultFiles, safeTitle, stripInternalTokens, frontmatter, STAMP, FORMAT }
package/hooks/pre-compact CHANGED
@@ -7,7 +7,23 @@ resolve_engagement_dir() {
7
7
  [ -z "$raw" ] && return 1
8
8
  # strip surrounding whitespace/quotes only - paths may contain spaces
9
9
  raw=$(printf '%s' "$raw" | sed -e 's/^[[:space:]"'"'"']*//' -e 's/[[:space:]"'"'"']*$//' -e "s|^~|$HOME|")
10
- [ -d "$raw" ] && printf '%s\n' "$raw" && return 0
10
+ # Same forms bin/fde.js accepts: an absolute .fde, an absolute engagement
11
+ # folder, or a bare slug under the engagements root - keep the two in lockstep.
12
+ # Anything relative is refused: it would resolve against whatever directory
13
+ # the agent happened to start in.
14
+ case $raw in
15
+ /*)
16
+ [ -d "$raw/.fde" ] && printf '%s\n' "$raw/.fde" && return 0
17
+ [ -d "$raw" ] && printf '%s\n' "$raw" && return 0
18
+ return 1
19
+ ;;
20
+ */*|.*) return 1 ;;
21
+ esac
22
+ local root="${FDEOPS_ENGAGEMENTS_ROOT:-$HOME/fde-engagements}"
23
+ root="${root/#\~/$HOME}"
24
+ local slug
25
+ slug=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9]\+/-/g' -e 's/^-//' -e 's/-$//')
26
+ [ -n "$slug" ] && [ -d "$root/$slug/.fde" ] && printf '%s\n' "$root/$slug/.fde" && return 0
11
27
  return 1
12
28
  }
13
29
 
@@ -57,6 +73,13 @@ resolve_fde() {
57
73
 
58
74
  ENG_DIR=$(resolve_engagement_dir "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}")
59
75
 
76
+ # An override that cannot be honored is never ignored: falling through to the
77
+ # registry would write this session into whichever engagement the workspace is
78
+ # bound to, and the operator named a different one. bin/fde.js refuses; so do we.
79
+ if [ -z "$ENG_DIR" ] && [ -n "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}" ]; then
80
+ exit 0
81
+ fi
82
+
60
83
  if [ -z "$ENG_DIR" ]; then
61
84
  ENG_DIR=$(registry_engagement_dir)
62
85
  fi
@@ -11,7 +11,23 @@ resolve_engagement_dir() {
11
11
  [ -z "$raw" ] && return 1
12
12
  # strip surrounding whitespace/quotes only - paths may contain spaces
13
13
  raw=$(printf '%s' "$raw" | sed -e 's/^[[:space:]"'"'"']*//' -e 's/[[:space:]"'"'"']*$//' -e "s|^~|$HOME|")
14
- [ -d "$raw" ] && printf '%s\n' "$raw" && return 0
14
+ # Same forms bin/fde.js accepts: an absolute .fde, an absolute engagement
15
+ # folder, or a bare slug under the engagements root - keep the two in lockstep.
16
+ # Anything relative is refused: it would resolve against whatever directory
17
+ # the agent happened to start in.
18
+ case $raw in
19
+ /*)
20
+ [ -d "$raw/.fde" ] && printf '%s\n' "$raw/.fde" && return 0
21
+ [ -d "$raw" ] && printf '%s\n' "$raw" && return 0
22
+ return 1
23
+ ;;
24
+ */*|.*) return 1 ;;
25
+ esac
26
+ local root="${FDEOPS_ENGAGEMENTS_ROOT:-$HOME/fde-engagements}"
27
+ root="${root/#\~/$HOME}"
28
+ local slug
29
+ slug=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9]\+/-/g' -e 's/^-//' -e 's/-$//')
30
+ [ -n "$slug" ] && [ -d "$root/$slug/.fde" ] && printf '%s\n' "$root/$slug/.fde" && return 0
15
31
  return 1
16
32
  }
17
33
 
@@ -46,6 +62,13 @@ if [ -z "$CONTEXT_FILE" ]; then
46
62
  [ -n "$ENG_DIR" ] && CONTEXT_FILE="$ENG_DIR/context.md"
47
63
  fi
48
64
 
65
+ # An override that cannot be honored is never ignored: falling through to the
66
+ # registry would load whichever engagement the workspace is bound to, and the
67
+ # operator named a different one. bin/fde.js refuses; so do we.
68
+ if [ -z "$CONTEXT_FILE" ] && [ -n "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}" ]; then
69
+ exit 0
70
+ fi
71
+
49
72
  # 2) Workspace registry binding (written by fde resume --init)
50
73
  if [ -z "$CONTEXT_FILE" ]; then
51
74
  ENG_DIR=$(registry_engagement_dir)
@@ -14,7 +14,23 @@ resolve_engagement_dir() {
14
14
  [ -z "$raw" ] && return 1
15
15
  # strip surrounding whitespace/quotes only - paths may contain spaces
16
16
  raw=$(printf '%s' "$raw" | sed -e 's/^[[:space:]"'"'"']*//' -e 's/[[:space:]"'"'"']*$//' -e "s|^~|$HOME|")
17
- [ -d "$raw" ] && printf '%s\n' "$raw" && return 0
17
+ # Same forms bin/fde.js accepts: an absolute .fde, an absolute engagement
18
+ # folder, or a bare slug under the engagements root - keep the two in lockstep.
19
+ # Anything relative is refused: it would resolve against whatever directory
20
+ # the agent happened to start in.
21
+ case $raw in
22
+ /*)
23
+ [ -d "$raw/.fde" ] && printf '%s\n' "$raw/.fde" && return 0
24
+ [ -d "$raw" ] && printf '%s\n' "$raw" && return 0
25
+ return 1
26
+ ;;
27
+ */*|.*) return 1 ;;
28
+ esac
29
+ local root="${FDEOPS_ENGAGEMENTS_ROOT:-$HOME/fde-engagements}"
30
+ root="${root/#\~/$HOME}"
31
+ local slug
32
+ slug=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9]\+/-/g' -e 's/^-//' -e 's/-$//')
33
+ [ -n "$slug" ] && [ -d "$root/$slug/.fde" ] && printf '%s\n' "$root/$slug/.fde" && return 0
18
34
  return 1
19
35
  }
20
36
 
@@ -77,6 +93,13 @@ ENG_DIR=""
77
93
  # 1) Environment variable (any agent)
78
94
  ENG_DIR=$(resolve_engagement_dir "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}")
79
95
 
96
+ # An override that cannot be honored is never ignored: falling through to the
97
+ # registry would write this session into whichever engagement the workspace is
98
+ # bound to, and the operator named a different one. bin/fde.js refuses; so do we.
99
+ if [ -z "$ENG_DIR" ] && [ -n "${FDEOPS_ENGAGEMENT:-${FDEOS_ENGAGEMENT:-}}" ]; then
100
+ exit 0
101
+ fi
102
+
80
103
  # 2) Workspace registry binding (written by fde resume --init)
81
104
  if [ -z "$ENG_DIR" ]; then
82
105
  ENG_DIR=$(registry_engagement_dir)
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.
@@ -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
 
@@ -89,4 +91,4 @@ Sources are pluggable and user-configured. This MCP owns the sink only.
89
91
 
90
92
  ## Zero dependencies
91
93
 
92
- 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.10.0",
3
+ "version": "3.11.0",
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,7 +75,10 @@ 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
 
@@ -128,10 +143,10 @@ function fdeEnv() {
128
143
  return env
129
144
  }
130
145
 
131
- function runFde(args, stdin) {
146
+ function runFde(args, stdin, extraEnv) {
132
147
  const { cmd, prefix } = resolveFde()
133
148
  const result = spawnSync(cmd, [...prefix, ...args], {
134
- env: fdeEnv(),
149
+ env: { ...fdeEnv(), ...(extraEnv || {}) },
135
150
  input: stdin ?? undefined,
136
151
  encoding: 'utf8',
137
152
  maxBuffer: 16 * 1024 * 1024,
@@ -144,6 +159,11 @@ function runFde(args, stdin) {
144
159
  }
145
160
  }
146
161
 
162
+ function engagementEnv(args) {
163
+ const p = args && typeof args.engagement === 'string' ? args.engagement.trim() : ''
164
+ return p ? { FDEOPS_ENGAGEMENT: p } : {}
165
+ }
166
+
147
167
  function cliPayload(out) {
148
168
  const payload = { stdout: out.stdout, stderr: out.stderr, status: out.status }
149
169
  if (out.error) payload.spawnError = out.error
@@ -163,6 +183,7 @@ function toolError(payload) {
163
183
 
164
184
  function handleToolCall(name, args) {
165
185
  args = args || {}
186
+ const extraEnv = engagementEnv(args)
166
187
 
167
188
  switch (name) {
168
189
  case 'ingest_stage': {
@@ -172,23 +193,23 @@ function handleToolCall(name, args) {
172
193
  const source = args.source || 'manual'
173
194
  const cliArgs = ['ingest', 'stage', '--source', source]
174
195
  if (args.title) cliArgs.push('--title', args.title)
175
- const out = runFde(cliArgs, args.content)
196
+ const out = runFde(cliArgs, args.content, extraEnv)
176
197
  const payload = cliPayload(out)
177
198
  return out.status === 0 ? toolResult(payload) : toolError(payload)
178
199
  }
179
200
  case 'ingest_list': {
180
- const out = runFde(['ingest', 'list'])
201
+ const out = runFde(['ingest', 'list'], undefined, extraEnv)
181
202
  const payload = cliPayload(out)
182
203
  return out.status === 0 ? toolResult(payload) : toolError(payload)
183
204
  }
184
205
  case 'ingest_propose': {
185
206
  if (!args.id) return toolError('Missing required argument: id')
186
- const out = runFde(['ingest', 'propose', String(args.id)])
207
+ const out = runFde(['ingest', 'propose', String(args.id)], undefined, extraEnv)
187
208
  const payload = cliPayload(out)
188
209
  return out.status === 0 ? toolResult(payload) : toolError(payload)
189
210
  }
190
211
  case 'ingest_apply': {
191
- const out = runFde(['ingest', 'apply'])
212
+ const out = runFde(['ingest', 'apply'], undefined, extraEnv)
192
213
  const payload = cliPayload(out)
193
214
  return out.status === 0 ? toolResult(payload) : toolError(payload)
194
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`.