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.
- package/README.md +111 -125
- package/adapters/README.md +1 -1
- package/bin/check.js +10 -7
- package/bin/fde.js +341 -24
- package/bin/install.js +1 -1
- package/bin/lib/trust.js +11 -3
- package/bin/lib/vault.js +323 -0
- package/hooks/pre-compact +24 -1
- package/hooks/session-start +24 -1
- package/hooks/session-stop +24 -1
- package/mcp/README.md +1 -1
- package/mcp/fdeops-ingest/README.md +4 -2
- package/mcp/fdeops-ingest/package.json +1 -1
- package/mcp/fdeops-ingest/server.js +32 -11
- package/mcp/recipes/README.md +9 -8
- package/mcp/recipes/file.md +7 -7
- package/mcp/recipes/granola.md +16 -20
- package/mcp/recipes/notion.md +16 -18
- package/mcp/recipes/slack.md +61 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/fde/SKILL.md +5 -4
- package/skills/fde/references/ingest-connect.md +16 -18
- package/skills/fde/references/ingest.md +5 -4
- package/templates/.fde/assumptions.md +2 -2
- package/templates/.fde/decisions.md +2 -10
- package/templates/.fde/delivery.md +6 -2
- package/templates/.fde/evals.md +0 -4
- package/templates/.fde/risks.md +3 -1
- package/templates/.fde/stakeholders.md +2 -1
- package/templates/.fde/terrain.md +0 -2
package/bin/lib/vault.js
ADDED
|
@@ -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
|
-
|
|
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
|
package/hooks/session-start
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/hooks/session-stop
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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 (
|
|
94
|
+
Hand-rolled MCP over stdio (newline-delimited JSON-RPC). No `@modelcontextprotocol/sdk` required at runtime.
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* fdeops-ingest MCP — thin stdio sink for FDEOps ingest.
|
|
6
6
|
* Shells out to local `fde` CLI only. Never calls SaaS.
|
|
7
|
-
* MCP stdio transport:
|
|
7
|
+
* MCP stdio transport: newline-delimited JSON-RPC 2.0
|
|
8
|
+
* (Content-Length frames are accepted on input).
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
const fs = require('fs')
|
|
@@ -15,11 +16,17 @@ const PROTOCOL_VERSION = '2024-11-05'
|
|
|
15
16
|
const SERVER_NAME = 'fdeops-ingest'
|
|
16
17
|
const SERVER_VERSION = require('./package.json').version
|
|
17
18
|
|
|
19
|
+
const ENGAGEMENT_PROP = {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description:
|
|
22
|
+
'Path to this client\'s .fde/ folder (from `fde resume --bind`). Optional if FDEOPS_ENGAGEMENT is set or the process cwd is already bound.',
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
const TOOLS = [
|
|
19
26
|
{
|
|
20
27
|
name: 'ingest_stage',
|
|
21
28
|
description:
|
|
22
|
-
'Stage raw content into the engagement inbox (.inbox/). Does not write .fde/.',
|
|
29
|
+
'Stage raw content into the engagement inbox (.inbox/). Does not write .fde/. Prefer the fde ingest CLI when the workspace is already bound.',
|
|
23
30
|
inputSchema: {
|
|
24
31
|
type: 'object',
|
|
25
32
|
properties: {
|
|
@@ -29,12 +36,13 @@ const TOOLS = [
|
|
|
29
36
|
},
|
|
30
37
|
source: {
|
|
31
38
|
type: 'string',
|
|
32
|
-
description: 'Provenance label (e.g. granola,
|
|
39
|
+
description: 'Provenance label (e.g. granola, slack, notion, file, manual). Default: manual.',
|
|
33
40
|
},
|
|
34
41
|
title: {
|
|
35
42
|
type: 'string',
|
|
36
43
|
description: 'Optional human-readable title for the staged item.',
|
|
37
44
|
},
|
|
45
|
+
engagement: ENGAGEMENT_PROP,
|
|
38
46
|
},
|
|
39
47
|
required: ['content'],
|
|
40
48
|
},
|
|
@@ -42,7 +50,10 @@ const TOOLS = [
|
|
|
42
50
|
{
|
|
43
51
|
name: 'ingest_list',
|
|
44
52
|
description: 'List staged items in the current engagement inbox.',
|
|
45
|
-
inputSchema: {
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: { engagement: ENGAGEMENT_PROP },
|
|
56
|
+
},
|
|
46
57
|
},
|
|
47
58
|
{
|
|
48
59
|
name: 'ingest_propose',
|
|
@@ -55,6 +66,7 @@ const TOOLS = [
|
|
|
55
66
|
type: 'string',
|
|
56
67
|
description: 'Staged filename or id from ingest_list.',
|
|
57
68
|
},
|
|
69
|
+
engagement: ENGAGEMENT_PROP,
|
|
58
70
|
},
|
|
59
71
|
required: ['id'],
|
|
60
72
|
},
|
|
@@ -63,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: {
|
|
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
|
}
|
package/mcp/recipes/README.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# Ingest source recipes
|
|
2
2
|
|
|
3
|
-
FDEOps does **not** bundle Granola /
|
|
3
|
+
FDEOps does **not** bundle Granola / Slack / Notion OAuth and does **not** push to those tools.
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Daily (no MCP):** paste notes to `@fde debrief`, or drop a file ([file.md](./file.md)).
|
|
6
|
+
|
|
7
|
+
**Pull (optional):** you add a **source** MCP. The agent fetches text, then runs `fde ingest` in this bound workspace (stage → propose → you confirm → apply). The `fdeops-ingest` MCP is optional — only if you are not using the CLI from a bound workspace.
|
|
6
8
|
|
|
7
9
|
| Recipe | When |
|
|
8
10
|
|--------|------|
|
|
9
|
-
| [file.md](./file.md) |
|
|
10
|
-
| [granola.md](./granola.md) | Meeting transcripts via a
|
|
11
|
-
| [
|
|
12
|
-
|
|
13
|
-
Also wire the sink once: [fdeops-ingest/README.md](../fdeops-ingest/README.md).
|
|
11
|
+
| [file.md](./file.md) | Transcript / export already on disk, or paste |
|
|
12
|
+
| [granola.md](./granola.md) | Meeting transcripts via a notes MCP (or export) |
|
|
13
|
+
| [slack.md](./slack.md) | Pull a thread/channel as text — never post |
|
|
14
|
+
| [notion.md](./notion.md) | Read a Notion page (or export markdown) |
|
|
14
15
|
|
|
15
|
-
**Natural language:** `@fde I want to connect Granola`
|
|
16
|
+
**Natural language:** `@fde I want to connect Granola` (or Slack / Notion) → `skills/fde/references/ingest-connect.md`.
|