fdeops 3.9.20 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -6
- package/adapters/LOCAL-LLM.md +1 -1
- package/adapters/README.md +1 -1
- package/bin/check.js +210 -3
- package/bin/fde.js +379 -26
- package/bin/install.js +223 -20
- package/bin/lib/memory.js +2 -2
- package/bin/lib/render.js +2 -0
- package/mcp/README.md +1 -3
- package/mcp/fdeops-ingest/README.md +3 -1
- package/mcp/fdeops-ingest/package.json +1 -1
- package/mcp/fdeops-ingest/server.js +27 -17
- package/mcp.json +10 -0
- package/package.json +4 -2
- package/plugin.json +21 -0
- package/skills/fde/references/assumption-audit.md +10 -0
- package/skills/fde/references/build.md +13 -1
- package/skills/fde/references/business-case.md +10 -0
- package/skills/fde/references/close.md +11 -1
- package/skills/fde/references/discover.md +10 -0
- package/skills/fde/references/land.md +20 -0
- package/skills/fde/references/options-analysis.md +10 -0
- package/skills/fde/references/plan.md +10 -0
- package/skills/fde/references/scope-defense.md +10 -0
- package/skills/fde/references/ship.md +10 -0
- package/skills/fde/references/stakeholder-radar.md +21 -0
- package/skills/fde/references/status.md +12 -2
- package/templates/.fde/delivery.md +3 -3
package/bin/install.js
CHANGED
|
@@ -53,6 +53,49 @@ function slugify(name) {
|
|
|
53
53
|
|| 'engagement'
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Written into every skill directory this installer creates. Nothing is ever
|
|
57
|
+
// removed or overwritten without it: `healthcare-fde` and `fintech-fde` are
|
|
58
|
+
// plausible names for a skill the user wrote themselves, and a name collision
|
|
59
|
+
// must be reported, not silently resolved by deleting their work.
|
|
60
|
+
const MANAGED_MARKER = '.fdeops-managed'
|
|
61
|
+
|
|
62
|
+
function markManaged(dir) {
|
|
63
|
+
let version = 'unknown'
|
|
64
|
+
try { version = require(path.join(__dirname, '..', 'package.json')).version } catch (_) {}
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
path.join(dir, MANAGED_MARKER),
|
|
68
|
+
`managed-by: fdeops\nversion: ${version}\ninstalled: ${new Date().toISOString()}\n` +
|
|
69
|
+
'Delete this file to make fdeops treat the directory as yours and leave it alone.\n',
|
|
70
|
+
)
|
|
71
|
+
} catch (_) {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isManaged(dir) {
|
|
75
|
+
return fs.existsSync(path.join(dir, MANAGED_MARKER))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A skill "directory" that is really a symlink points somewhere outside
|
|
79
|
+
// ~/.claude/skills that fdeops has no claim on. Writing through it would edit
|
|
80
|
+
// files in the user's own tree - refuse even under --force, which is permission
|
|
81
|
+
// to take over this location, not to follow it elsewhere.
|
|
82
|
+
function isLink(p) {
|
|
83
|
+
try { return fs.lstatSync(p).isSymbolicLink() } catch (_) { return false }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Fingerprint of a skill fdeops itself wrote before markers existed. Anchored on
|
|
87
|
+
// the shipped frontmatter, not a bare "fdeops" substring: a skill of the user's
|
|
88
|
+
// that merely mentions fdeops in prose is theirs, not ours. Only consulted for a
|
|
89
|
+
// directory whose name matches one we ship, and only to overwrite - never to delete.
|
|
90
|
+
function wasInstalledByUs(dir) {
|
|
91
|
+
try {
|
|
92
|
+
const md = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8')
|
|
93
|
+
const fm = (md.match(/^---\n([\s\S]*?)\n---/) || [])[1]
|
|
94
|
+
if (!fm) return false
|
|
95
|
+
return /^description:\s*Engagement fieldbook for Forward Deployed Engineers\b/im.test(fm)
|
|
96
|
+
} catch (_) { return false }
|
|
97
|
+
}
|
|
98
|
+
|
|
56
99
|
// v2 shipped 16 standalone skills; v3 is one `fde` skill + references.
|
|
57
100
|
// Leaving the old ones in place would route users to stale content.
|
|
58
101
|
const LEGACY_SKILL_DIRS = [
|
|
@@ -62,22 +105,105 @@ const LEGACY_SKILL_DIRS = [
|
|
|
62
105
|
'gov-fde',
|
|
63
106
|
]
|
|
64
107
|
|
|
65
|
-
function removeLegacySkills() {
|
|
108
|
+
function removeLegacySkills(opts = {}) {
|
|
66
109
|
let removed = 0
|
|
110
|
+
const skipped = []
|
|
111
|
+
const links = []
|
|
67
112
|
for (const dir of LEGACY_SKILL_DIRS) {
|
|
68
113
|
const p = path.join(GLOBAL_SKILLS_DIR, dir)
|
|
69
|
-
if (
|
|
114
|
+
if (isLink(p)) { links.push(dir); continue }
|
|
115
|
+
if (!fs.existsSync(path.join(p, 'SKILL.md'))) continue
|
|
116
|
+
if (isManaged(p) || opts.force) {
|
|
70
117
|
fs.rmSync(p, { recursive: true, force: true })
|
|
71
118
|
removed++
|
|
119
|
+
} else {
|
|
120
|
+
skipped.push(dir)
|
|
72
121
|
}
|
|
73
122
|
}
|
|
74
|
-
return removed
|
|
123
|
+
return { removed, skipped, links }
|
|
75
124
|
}
|
|
76
125
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
126
|
+
// Copy each skill in, but never over a directory fdeops did not create.
|
|
127
|
+
function installSkillDirs(opts = {}) {
|
|
128
|
+
const skipped = []
|
|
129
|
+
const links = []
|
|
130
|
+
const failed = []
|
|
131
|
+
fs.mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true })
|
|
132
|
+
for (const entry of fs.readdirSync(SKILLS_SRC, { withFileTypes: true })) {
|
|
133
|
+
const src = path.join(SKILLS_SRC, entry.name)
|
|
134
|
+
const dest = path.join(GLOBAL_SKILLS_DIR, entry.name)
|
|
135
|
+
if (!entry.isDirectory()) { fs.copyFileSync(src, dest); continue }
|
|
136
|
+
if (isLink(dest)) { links.push(entry.name); continue }
|
|
137
|
+
if (fs.existsSync(dest) && !isManaged(dest) && !opts.force) {
|
|
138
|
+
// Installs predating the marker are still ours: adopt a same-named dir
|
|
139
|
+
// whose SKILL.md is recognizably fdeops', so upgrades keep working.
|
|
140
|
+
if (!wasInstalledByUs(dest)) {
|
|
141
|
+
skipped.push(entry.name)
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
console.log(` adopt ~/.claude/skills/${entry.name} (earlier fdeops install)`)
|
|
145
|
+
}
|
|
146
|
+
// One unwritable skill dir must not abort the install with a stack trace:
|
|
147
|
+
// say it in human terms, place the rest, and exit non-zero at the end.
|
|
148
|
+
try {
|
|
149
|
+
copyDir(src, dest)
|
|
150
|
+
markManaged(dest)
|
|
151
|
+
} catch (e) {
|
|
152
|
+
failed.push({ name: entry.name, code: e.code || 'error', path: destPathFor(e.path, src, dest) })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { skipped, links, failed }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A failed copy can surface either side of the operation; the user can only fix
|
|
159
|
+
// the destination, so never point them at a path inside the package.
|
|
160
|
+
function destPathFor(failedPath, src, dest) {
|
|
161
|
+
if (!failedPath) return dest
|
|
162
|
+
if (failedPath === src || failedPath.startsWith(src + path.sep)) {
|
|
163
|
+
return path.join(dest, path.relative(src, failedPath))
|
|
164
|
+
}
|
|
165
|
+
return failedPath
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function reportCollisions(paths, verb) {
|
|
169
|
+
if (!paths.length) return
|
|
170
|
+
console.log(` skip ${paths.length} skill dir(s) fdeops did not create - ${verb} would destroy your own work:`)
|
|
171
|
+
for (const name of paths) console.log(` ~/.claude/skills/${name}`)
|
|
172
|
+
console.log(' move or delete them yourself, or re-run with --force to let fdeops take them over')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function reportLinks(names) {
|
|
176
|
+
if (!names.length) return
|
|
177
|
+
console.log(` skip ${names.length} skill path(s) that are symlinks - fdeops will not write through them:`)
|
|
178
|
+
for (const name of names) console.log(` ~/.claude/skills/${name} -> ${readLinkQuiet(path.join(GLOBAL_SKILLS_DIR, name))}`)
|
|
179
|
+
console.log(' remove the link if you want fdeops to install at that path itself')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readLinkQuiet(p) {
|
|
183
|
+
try { return fs.readlinkSync(p) } catch (_) { return '(unreadable)' }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Anything here means part of the install did not land; cmdInstall exits non-zero.
|
|
187
|
+
let installIncomplete = false
|
|
188
|
+
function reportFailures(failures) {
|
|
189
|
+
if (!failures.length) return
|
|
190
|
+
installIncomplete = true
|
|
191
|
+
console.log(` error ${failures.length} skill dir(s) could not be written:`)
|
|
192
|
+
for (const f of failures) {
|
|
193
|
+
const why = f.code === 'EACCES' || f.code === 'EPERM' ? 'permission denied' : f.code
|
|
194
|
+
console.log(` ~/.claude/skills/${f.name} - ${why} at ${f.path}`)
|
|
195
|
+
}
|
|
196
|
+
console.log(' fix the permissions (or remove the directory) and re-run - the rest of the install continued')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function installSkills(opts = {}) {
|
|
200
|
+
const legacy = removeLegacySkills(opts)
|
|
201
|
+
if (legacy.removed > 0) console.log(` Removed ${legacy.removed} v2 skill dir(s) (now covered by @fde)`)
|
|
202
|
+
const placed = installSkillDirs(opts)
|
|
203
|
+
reportCollisions(legacy.skipped, 'removing them')
|
|
204
|
+
reportCollisions(placed.skipped, 'overwriting them')
|
|
205
|
+
reportLinks([...new Set([...legacy.links, ...placed.links])])
|
|
206
|
+
reportFailures(placed.failed)
|
|
81
207
|
fs.mkdirSync(GLOBAL_HOOKS_DIR, { recursive: true })
|
|
82
208
|
for (const name of HOOK_SCRIPTS) {
|
|
83
209
|
const src = path.join(HOOKS_SRC, name)
|
|
@@ -138,7 +264,7 @@ function placePointer(destPath, content, label, appendable) {
|
|
|
138
264
|
console.log(` write ${label}`)
|
|
139
265
|
}
|
|
140
266
|
|
|
141
|
-
function cmdAdapters(targetDir) {
|
|
267
|
+
function cmdAdapters(targetDir, opts = {}) {
|
|
142
268
|
const dest = path.resolve(targetDir || process.cwd())
|
|
143
269
|
console.log('')
|
|
144
270
|
console.log(` fdeops cross-platform adapters → ${dest}`)
|
|
@@ -150,7 +276,7 @@ function cmdAdapters(targetDir) {
|
|
|
150
276
|
// yet, a dangling reference for anyone following the documented Cursor/Codex
|
|
151
277
|
// path. installSkills() is idempotent (safe to call every run).
|
|
152
278
|
if (!fs.existsSync(path.join(GLOBAL_SKILLS_DIR, 'fde', 'SKILL.md'))) {
|
|
153
|
-
installSkills()
|
|
279
|
+
installSkills(opts)
|
|
154
280
|
console.log(' Skills → ~/.claude/skills/ (installed - the pointers below need this)')
|
|
155
281
|
console.log('')
|
|
156
282
|
}
|
|
@@ -198,11 +324,11 @@ function cmdInit(engagementName) {
|
|
|
198
324
|
console.log('')
|
|
199
325
|
}
|
|
200
326
|
|
|
201
|
-
function cmdInstall() {
|
|
327
|
+
function cmdInstall(opts = {}) {
|
|
202
328
|
console.log('')
|
|
203
329
|
console.log(' fdeops - installs on YOUR machine only')
|
|
204
330
|
console.log('')
|
|
205
|
-
installSkills()
|
|
331
|
+
installSkills(opts)
|
|
206
332
|
console.log(' Skills → ~/.claude/skills/')
|
|
207
333
|
console.log(' Hooks → ~/.claude/hooks/fdeops-*')
|
|
208
334
|
console.log(' CLI → ~/.claude/fdeops/fde.js (try: node ~/.claude/fdeops/fde.js scan)')
|
|
@@ -220,22 +346,99 @@ function cmdInstall() {
|
|
|
220
346
|
console.log(' Then open your workspace and use @fde')
|
|
221
347
|
console.log(' Docs: docs/install.md')
|
|
222
348
|
console.log('')
|
|
349
|
+
// A partly-installed skill set is not success - a script that ran this must be
|
|
350
|
+
// able to tell, and the reason is already printed above.
|
|
351
|
+
if (installIncomplete) process.exit(1)
|
|
223
352
|
}
|
|
224
353
|
|
|
225
354
|
// `npx fdeops scan` must recon, not install - any fde subcommand passes straight
|
|
226
355
|
// through to the CLI (fde.js reads process.argv itself, so require() is enough).
|
|
227
356
|
const FDE_SUBCOMMANDS = [
|
|
228
|
-
'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
|
|
229
|
-
'garden', 'owner', 'receipts', 'capture', 'status', 'dashboard', 'help',
|
|
357
|
+
'demo', 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
|
|
358
|
+
'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'help',
|
|
230
359
|
]
|
|
231
360
|
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
361
|
+
const INSTALL_SUBCOMMANDS = ['init', 'adapters', 'install']
|
|
362
|
+
|
|
363
|
+
function editDistance(a, b) {
|
|
364
|
+
let prev = [...Array(b.length + 1).keys()]
|
|
365
|
+
for (let i = 1; i <= a.length; i++) {
|
|
366
|
+
const row = [i]
|
|
367
|
+
for (let j = 1; j <= b.length; j++) {
|
|
368
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
369
|
+
}
|
|
370
|
+
prev = row
|
|
371
|
+
}
|
|
372
|
+
return prev[b.length]
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function nearest(word, known) {
|
|
376
|
+
const w = word.toLowerCase()
|
|
377
|
+
let best = null
|
|
378
|
+
let bestScore = 3
|
|
379
|
+
for (const k of known) {
|
|
380
|
+
const d = editDistance(w, k)
|
|
381
|
+
if (d < bestScore) { best = k; bestScore = d }
|
|
382
|
+
}
|
|
383
|
+
return best
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const argv = process.argv.slice(2)
|
|
387
|
+
const force = argv.includes('--force')
|
|
388
|
+
const positional = argv.filter(a => !a.startsWith('-'))
|
|
389
|
+
const raw = positional[0]
|
|
390
|
+
// `fdeops Demo` is a typo, not a request to rewrite ~/.claude: verbs match
|
|
391
|
+
// case-insensitively, and anything unrecognized fails loudly instead of
|
|
392
|
+
// falling through to a full install. Asking a question (`--help`, `--version`)
|
|
393
|
+
// is not consent to write to the home directory either.
|
|
394
|
+
const known = INSTALL_SUBCOMMANDS.concat(FDE_SUBCOMMANDS)
|
|
395
|
+
const arg = raw ? known.find(k => k === raw.toLowerCase()) : undefined
|
|
396
|
+
const askedHelp = argv.some(a => /^--?(h|help)$/i.test(a))
|
|
397
|
+
const askedVersion = argv.some(a => /^--?(v|version)$/i.test(a))
|
|
398
|
+
|
|
399
|
+
// Flags before the verb belong to fdeops, so an unknown one is a mistake worth
|
|
400
|
+
// saying out loud - the alternative is honoring `fdeops --all status` by
|
|
401
|
+
// quietly dropping --all.
|
|
402
|
+
const FDEOPS_FLAGS = /^--?(force|h|help|v|version)$/i
|
|
403
|
+
const leading = (raw === undefined ? argv : argv.slice(0, argv.indexOf(raw))).filter(a => !FDEOPS_FLAGS.test(a))
|
|
404
|
+
if (leading.length) {
|
|
405
|
+
console.error(` fdeops: unknown option '${leading[0]}'${raw ? ` before '${raw}' - put command options after the command: fdeops ${raw} ${leading[0]}` : ''}`)
|
|
406
|
+
console.error(' fdeops itself takes only --force, --help and --version.')
|
|
407
|
+
process.exit(1)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (raw && !arg) {
|
|
411
|
+
const guess = nearest(raw, known)
|
|
412
|
+
console.error(` fdeops: unknown command '${raw}'${guess ? ` - did you mean '${guess}'?` : ''}`)
|
|
413
|
+
console.error(' Run `npx fdeops help` for the command list, or `npx fdeops` with no arguments to install.')
|
|
414
|
+
process.exit(1)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// The verb the user typed may not be argv[0] (`fdeops --force redact ledger`),
|
|
418
|
+
// and fde.js reads process.argv itself - hand it back the positional order it
|
|
419
|
+
// expects with only the verb's case normalized.
|
|
420
|
+
function handOffToCli(verb) {
|
|
421
|
+
// fde.js reads process.argv itself and takes the verb first, so hand it the
|
|
422
|
+
// verb (case-normalized) plus everything the user typed after it. A flag
|
|
423
|
+
// typed BEFORE the verb (`fdeops --force redact ledger`) belongs to fdeops,
|
|
424
|
+
// not to the command - passing it on would make it part of the command's own
|
|
425
|
+
// arguments (here: a search term of "--force ledger").
|
|
426
|
+
const at = raw === undefined ? -1 : argv.indexOf(raw)
|
|
427
|
+
const rest = at === -1 ? [] : argv.slice(at + 1)
|
|
428
|
+
process.argv = [process.argv[0], process.argv[1], verb, ...rest]
|
|
238
429
|
require(path.join(__dirname, 'fde.js'))
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!raw && askedVersion) {
|
|
433
|
+
console.log(require(path.join(__dirname, '..', 'package.json')).version)
|
|
434
|
+
} else if (!raw && askedHelp) {
|
|
435
|
+
handOffToCli('help')
|
|
436
|
+
} else if (arg === 'init') {
|
|
437
|
+
cmdInit(positional[1])
|
|
438
|
+
} else if (arg === 'adapters') {
|
|
439
|
+
cmdAdapters(positional[1], { force })
|
|
440
|
+
} else if (arg && arg !== 'install') {
|
|
441
|
+
handOffToCli(arg)
|
|
239
442
|
} else {
|
|
240
|
-
cmdInstall()
|
|
443
|
+
cmdInstall({ force })
|
|
241
444
|
}
|
package/bin/lib/memory.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { execFileSync } = require('child_process')
|
|
4
4
|
|
|
5
5
|
// Ephemeral sidecar files - never treated as "manual tamper" dirt.
|
|
6
|
-
const MEMORY_EPHEMERAL = new Set(['.last-write', '.debrief-propose'])
|
|
6
|
+
const MEMORY_EPHEMERAL = new Set(['.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal'])
|
|
7
7
|
|
|
8
8
|
function createMemoryApi(deps) {
|
|
9
9
|
const { fs, path, gitBinOk, writeOwnerIfMissing, atomicWriteFile } = deps
|
|
@@ -131,7 +131,7 @@ function createMemoryApi(deps) {
|
|
|
131
131
|
execFileSync('git', ['init'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
|
|
132
132
|
atomicWriteFile(
|
|
133
133
|
path.join(eng, '.gitignore'),
|
|
134
|
-
['*.lock', '*.tmp', '.last-write', '.debrief-propose', ''].join('\n')
|
|
134
|
+
['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', ''].join('\n')
|
|
135
135
|
)
|
|
136
136
|
const owner = writeOwnerIfMissing(eng)
|
|
137
137
|
configureMemoryGitIdentity(eng, owner)
|
package/bin/lib/render.js
CHANGED
|
@@ -196,6 +196,7 @@ strong{font-weight:600}
|
|
|
196
196
|
.fb-log-date{font-family:'Geist Mono',monospace;font-size:11.5px;color:var(--ink-faint);flex:0 0 48px}
|
|
197
197
|
.fb-log-kind{font-family:'Geist Mono',monospace;font-size:10px;letter-spacing:.03em;text-transform:uppercase;flex:0 0 62px}
|
|
198
198
|
.fb-log-text{flex:1 1 auto;font-size:14px;line-height:1.5;color:var(--ink)}
|
|
199
|
+
.fb-log-sig{font-family:'Geist Mono',monospace;font-size:10px;letter-spacing:.03em;text-transform:uppercase;white-space:nowrap}
|
|
199
200
|
.fb-more{border-top:1px solid var(--line);padding:12px 0}
|
|
200
201
|
.fb-more:first-child{border-top:none;padding-top:0}
|
|
201
202
|
.fb-more-sum{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px}
|
|
@@ -537,6 +538,7 @@ ${e.log.map(g => `<div class="fb-row fb-log">
|
|
|
537
538
|
<span class="fb-log-date">${escapeHtml(formatLogDate(g.date))}</span>
|
|
538
539
|
<span class="fb-log-kind t-${g.kind === 'receipt' ? 'green' : g.kind === 'decision' ? 'accent' : 'faint'}">${escapeHtml(g.kind)}</span>
|
|
539
540
|
<span class="fb-log-text">${inlineMd(g.text)}</span>
|
|
541
|
+
${g.sig ? `<span class="fb-log-sig t-${g.sig}" title="trust signal ${escapeHtml(g.sig)}">● ${escapeHtml(g.sig)}</span>` : ''}
|
|
540
542
|
</div>`).join('\n')}
|
|
541
543
|
</div>
|
|
542
544
|
</div>` : ''
|
package/mcp/README.md
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
@@ -22,7 +22,9 @@ Each tool returns `{ stdout, stderr, status }` from the CLI.
|
|
|
22
22
|
|
|
23
23
|
## Configure in Cursor / Claude
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
**Agent Plugins clients: nothing to configure.** fdeops ships a root [`mcp.json`](../../mcp.json) declaring this server as `stdio` with a `${PLUGIN_ROOT}`-relative path, so a client that supports [Agent Plugins 1.0.0](https://agent-plugins.org/specification) wires it on install - no absolute paths to edit.
|
|
26
|
+
|
|
27
|
+
Everywhere else, add to your MCP config (`~/.cursor/mcp.json`, Claude Desktop config, etc.):
|
|
26
28
|
|
|
27
29
|
```json
|
|
28
30
|
{
|
|
@@ -70,31 +70,41 @@ const TOOLS = [
|
|
|
70
70
|
let readBuffer = Buffer.alloc(0)
|
|
71
71
|
|
|
72
72
|
function writeMessage(obj) {
|
|
73
|
-
|
|
74
|
-
process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`)
|
|
73
|
+
process.stdout.write(`${JSON.stringify(obj)}\n`)
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
// MCP stdio frames messages by newline. Content-Length headers are tolerated on
|
|
77
|
+
// input only, so an LSP-style client still gets through.
|
|
77
78
|
function parseMessages() {
|
|
78
79
|
const messages = []
|
|
79
|
-
while (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
80
|
+
while (readBuffer.length) {
|
|
81
|
+
if (/^Content-Length:/i.test(readBuffer.slice(0, 15).toString('utf8'))) {
|
|
82
|
+
const headerEnd = readBuffer.indexOf('\r\n\r\n')
|
|
83
|
+
if (headerEnd === -1) break
|
|
84
|
+
const header = readBuffer.slice(0, headerEnd).toString('utf8')
|
|
85
|
+
const match = header.match(/Content-Length:\s*(\d+)/i)
|
|
86
|
+
if (!match) {
|
|
87
|
+
readBuffer = readBuffer.slice(headerEnd + 4)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const length = parseInt(match[1], 10)
|
|
91
|
+
const bodyStart = headerEnd + 4
|
|
92
|
+
if (readBuffer.length < bodyStart + length) break
|
|
93
|
+
const body = readBuffer.slice(bodyStart, bodyStart + length).toString('utf8')
|
|
94
|
+
readBuffer = readBuffer.slice(bodyStart + length)
|
|
95
|
+
try {
|
|
96
|
+
messages.push(JSON.parse(body))
|
|
97
|
+
} catch (_) {}
|
|
87
98
|
continue
|
|
88
99
|
}
|
|
89
100
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
readBuffer = readBuffer.slice(bodyStart + length)
|
|
101
|
+
const newline = readBuffer.indexOf('\n')
|
|
102
|
+
if (newline === -1) break
|
|
103
|
+
const line = readBuffer.slice(0, newline).toString('utf8').trim()
|
|
104
|
+
readBuffer = readBuffer.slice(newline + 1)
|
|
105
|
+
if (!line) continue
|
|
96
106
|
try {
|
|
97
|
-
messages.push(JSON.parse(
|
|
107
|
+
messages.push(JSON.parse(line))
|
|
98
108
|
} catch (_) {}
|
|
99
109
|
}
|
|
100
110
|
return messages
|
package/mcp.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fdeops",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.0",
|
|
4
4
|
"description": "Field kit for engineers embedded in client work - a real CLI (recon, memory, portfolio), one @fde skill with field judgment on top, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fdeops": "bin/install.js",
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
"adapters/",
|
|
21
21
|
"mcp/",
|
|
22
22
|
"CLAUDE.md.template",
|
|
23
|
-
"AGENTS.md"
|
|
23
|
+
"AGENTS.md",
|
|
24
|
+
"plugin.json",
|
|
25
|
+
"mcp.json"
|
|
24
26
|
],
|
|
25
27
|
"keywords": [
|
|
26
28
|
"claude-code",
|
package/plugin.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
|
+
"name": "fdeops",
|
|
4
|
+
"version": "3.10.0",
|
|
5
|
+
"description": "Engagement fieldbook for Forward Deployed Engineers: per-client memory in local .fde/ files, one @fde skill, land to close methodology. Local-only, no network.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Subash Natarajan",
|
|
8
|
+
"url": "https://github.com/suboss87"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://fdeops.io/",
|
|
11
|
+
"repository": "https://github.com/suboss87/FDEOps",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"fde",
|
|
15
|
+
"forward-deployed",
|
|
16
|
+
"engagement-memory",
|
|
17
|
+
"client-work",
|
|
18
|
+
"consulting",
|
|
19
|
+
"brownfield"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -72,6 +72,16 @@ Status values: `OPEN` · `TESTING` · `CONFIRMED` · `DISPROVED` · `PARKED`. A
|
|
|
72
72
|
|
|
73
73
|
Tell the FDE: how many assumptions extracted, how many critical, which ones were tested, which changed the direction. If a critical assumption is disproved: recommend the next move (rescope, pivot, or the conversation with the sponsor) before the FDE asks. If any CRITICAL remains OPEN: do not route to plan.
|
|
74
74
|
|
|
75
|
+
## Worked example
|
|
76
|
+
|
|
77
|
+
Acme's brief reads cleanly, which is the signal.
|
|
78
|
+
|
|
79
|
+
Extracted assumptions include one nobody said aloud: *finance would act on an alert*. The whole plan rests on it, and the evidence behind it is a sentence in a kickoff. Blast radius CRITICAL — if false, alerting changes nothing and the engagement delivers a page nobody answers.
|
|
80
|
+
|
|
81
|
+
Validation is a test, not a discussion, and it is cheap: send one real failure notification to the finance channel and watch what happens. It goes first because highest blast radius × cheapest test is the killer test.
|
|
82
|
+
|
|
83
|
+
Result: acked in 40 minutes, by Marco, not finance. Assumption DISPROVED, and the plan changes before six weeks are spent on it — the alert needs a rota with an owner, which is a different piece of work than the one that was funded. `assumptions.md` records the status, the evidence, and the date; the finding is presented to the FDE as a fact base, not as "the brief was wrong".
|
|
84
|
+
|
|
75
85
|
## Principles
|
|
76
86
|
|
|
77
87
|
- Every "just" is an assumption. Every "should" is an assumption.
|
|
@@ -127,12 +127,24 @@ The FDE's job is to make themselves replaceable. Not at handoff - every day. A c
|
|
|
127
127
|
|
|
128
128
|
- **`decisions.md`** - each significant choice: what, alternatives considered, why this one. For non-trivial architecture decisions, present three options to the FDE (safe / pragmatic / aggressive) with costs and a recommendation - three options is a real decision; one option is a request for trust. Integration contracts go here too.
|
|
129
129
|
- **`risks.md`** - new risks discovered while building.
|
|
130
|
-
- **`delivery.md`** - append a **value ledger** row for every ship: Date | Slice | Bucket | Promised | Measured | Evidence | Rollback. Bucket is `cost-save` / `risk-mitigation` / `revenue-uplift`. "Measured" may be `pending` until the pulse exists - never skip the promised column. Narrative under Shipped is optional color; the ledger is the record status and close read.
|
|
130
|
+
- **`delivery.md`** - append a **value ledger** row for every ship: Date | Slice | Bucket | Promised | Measured | Accepted by | Evidence | Rollback. Bucket is `cost-save` / `risk-mitigation` / `revenue-uplift`. "Measured" may be `pending` until the pulse exists - never skip the promised column. Narrative under Shipped is optional color; the ledger is the record status and close read.
|
|
131
|
+
|
|
132
|
+
**Measured is not the same as accepted.** A number the FDE calculated is `claimed` until a named customer-side owner agrees it is real - their finance lead, their ops manager, the person whose budget it moves. Write the name and the date in **Accepted by**; leave it empty and the row reads `claimed`, never "delivered". This is the difference between a benefit that survives the renewal conversation and one the sponsor's CFO deletes in the review.
|
|
131
133
|
|
|
132
134
|
## Checkpoint
|
|
133
135
|
|
|
134
136
|
Before merge: the two-stage review (scope, then safety) has run clean, verification evidence is stated, and the slice is demonstrable. If `trust-profile.md` requires human sign-off on AI-generated code, that sign-off exists.
|
|
135
137
|
|
|
138
|
+
## Worked example
|
|
139
|
+
|
|
140
|
+
Acme task 1: route reconciliation failures to a named on-call.
|
|
141
|
+
|
|
142
|
+
Characterisation first — a test that captures what the job does *today* when it exits non-zero (silently succeeds from the caller's perspective). That test fails after the fix, which is the point: it documents the behaviour being changed rather than trusting the diff.
|
|
143
|
+
|
|
144
|
+
Mid-build, Tom asks to "just also fix the retry logic while you're in there." That is a scope decision, not a request: logged in `decisions.md` with the blast radius (retry touches the settlement path — not in this slice's declared radius) and routed to the plan's Next lane.
|
|
145
|
+
|
|
146
|
+
Ledger row on ship: `Jun 18 | failure routing | risk-mitigation | 4h → 15min detection | pending | — | staging kill test, PR #212 | disable alert route`. **Accepted by** stays empty until Marco confirms from a real incident — until then the number is claimed, and the status update says so.
|
|
147
|
+
|
|
136
148
|
## Principles
|
|
137
149
|
|
|
138
150
|
- Characterisation tests before modification. Every time.
|
|
@@ -69,6 +69,16 @@ The sponsor who sees you've identified where the case could break trusts the cas
|
|
|
69
69
|
|
|
70
70
|
Walk the FDE through: the cost of doing nothing (anchor), the investment, the return, and the one sensitivity that matters most. If the FDE says "the sponsor won't buy the ROI number" - ask what number they would believe and work backwards from there.
|
|
71
71
|
|
|
72
|
+
## Worked example
|
|
73
|
+
|
|
74
|
+
Acme phase 2 needs funding. The case starts with the cost of doing nothing, not the cost of building.
|
|
75
|
+
|
|
76
|
+
Anchor: two silent failures since March, each one day of finance reconciliation by hand plus a late close (`reality.md`, Marco's sheet). That is the number the sponsor already believes because her own team reported it.
|
|
77
|
+
|
|
78
|
+
Driver model the sponsor can trace: incidents/quarter × hours of manual reconciliation × loaded cost, plus the tail risk of a late regulatory close — stated separately, because mixing a certain small number with an uncertain large one is how a case loses credibility.
|
|
79
|
+
|
|
80
|
+
Sensitivity names the two drivers that swing it: incident frequency (2/quarter → 1/quarter and the case halves) and whether the manual re-run continues in parallel (if Marco keeps re-running every morning, the saving is theoretical). The second one is the honest weakness, so it is in the case rather than waiting to be found in the room — with the condition that makes it hold: the morning re-run stops after two clean cycles, agreed with Marco.
|
|
81
|
+
|
|
72
82
|
## Principles
|
|
73
83
|
|
|
74
84
|
- The cost of doing nothing is always the opening move. Anchor before proposing.
|
|
@@ -18,7 +18,7 @@ The engagement doesn't end at ship. It ends when the customer can maintain what
|
|
|
18
18
|
- AI components: did they behave in production? What failure modes did the prototype hide? Is the team equipped to maintain them?
|
|
19
19
|
|
|
20
20
|
**1b. Value + receipts close gate (refuse green close if any fail):**
|
|
21
|
-
- Primary value bucket in `success.md` matches what the sponsor funded; at least one ledger row has **Measured** (not forever-`pending`) with evidence for that bucket — or the retrospective explicitly records “not measured; sponsor accepted pending.”
|
|
21
|
+
- Primary value bucket in `success.md` matches what the sponsor funded; at least one ledger row has **Measured** (not forever-`pending`) with evidence **and a named customer-side owner in Accepted by** for that bucket — or the retrospective explicitly records “not measured; sponsor accepted pending.” A measured-but-unaccepted number closes as `claimed`; say so in the retrospective rather than closing green on arithmetic nobody signed.
|
|
22
22
|
- Audit receipt exists for the final shipped path (exceptions/operating map walked; cite file).
|
|
23
23
|
- Eval receipt: **n/a if no AI**, else final golden/eval result + HITL owner recorded; kill switch / fallback named in `handoff.md`.
|
|
24
24
|
- One line in the retrospective: which bucket moved, by how much, vs baseline.
|
|
@@ -41,6 +41,16 @@ The engagement doesn't end at ship. It ends when the customer can maintain what
|
|
|
41
41
|
|
|
42
42
|
Direct assessment to the FDE: did the engagement achieve `success.md` · 2–3 lessons that matter · is the pattern worth encoding · is the handoff complete or where are the gaps. Also: value bucket + audit receipt green; eval **n/a or green**. Pending Measured without sponsor acceptance = gap, not green close. Honest - a gap named now is cheaper than a callback in six weeks.
|
|
43
43
|
|
|
44
|
+
## Worked example
|
|
45
|
+
|
|
46
|
+
Acme, twelve weeks in, the FDE is rolling off.
|
|
47
|
+
|
|
48
|
+
Retrospective against the receipts: `brief.md` asked for monitoring, `reality.md` proved it was ownership — and the delta is the most useful paragraph in the file, because it is exactly the argument the next engagement will need.
|
|
49
|
+
|
|
50
|
+
The close gate bites in a useful way. The ledger shows detection at 12 minutes measured across two real incidents, but **Accepted by** is empty — Marco confirmed it in Slack, Denise (finance) never did, and Denise is whose escalation started the engagement. So it closes as `claimed` with a one-line retrospective note and a named next step, rather than a green close on a number nobody with budget agreed to.
|
|
51
|
+
|
|
52
|
+
`handoff.md` is written for the person woken at 2am: the three things that break, what the page means, how to re-run manually the way Marco does, and who holds the tribal knowledge (Raj, who built the original job — credited, because he protects it now). `patterns.md` gets *"unowned job" presents as "unmonitored job"* — it has now happened twice.
|
|
53
|
+
|
|
44
54
|
## Principles
|
|
45
55
|
|
|
46
56
|
- Done = the customer operates without you.
|
|
@@ -192,6 +192,16 @@ If discovery revealed the problem is 3× the brief: the FDE tells the customer *
|
|
|
192
192
|
|
|
193
193
|
Stop. Don't form a fourth hypothesis. Three disproven reads means the brief is actively misleading - usually the person who briefed doesn't know, or knows and can't say. Change method: stop analysing the system, ask three people separately "if you had to bet on what's actually wrong here, what would you say?" The thing they all hesitate before saying is the real problem.
|
|
194
194
|
|
|
195
|
+
## Worked example
|
|
196
|
+
|
|
197
|
+
Acme's brief blamed missing monitoring. Discovery goes to the workaround first.
|
|
198
|
+
|
|
199
|
+
`git log` shows the reconciliation module at 47 commits/90d with no tests, all from one author who left in February. Marco (ops lead) turns out to keep a spreadsheet: every morning he re-runs the job manually and eyeballs the totals — a habit nobody mentioned because to him it is just the job. That spreadsheet is the system of record when the job fails, which is the actual finding.
|
|
200
|
+
|
|
201
|
+
`reality.md`: **Confirmed:** the job has no owner, and the manual re-run masks failures for a day (evidence: Marco's sheet, Day 5; two silent failures since March, finance escalation Mar 14). **Stated brief was wrong because:** alerting existed last year and was disabled — adding it again without an owner reproduces the same outcome. `terrain.md` gets the hotspot row and an operating-map row: `job fails silently → Marco notices next morning → re-runs by hand → spreadsheet is truth → LOAD-BEARING (Marco, Day 5)`.
|
|
202
|
+
|
|
203
|
+
Checkpoint to the FDE names the sponsor decision this creates: fund ownership, or fund alerting and accept the same failure in six months.
|
|
204
|
+
|
|
195
205
|
## Principles
|
|
196
206
|
|
|
197
207
|
- The brief is a hypothesis until evidence confirms it.
|
|
@@ -79,6 +79,16 @@ Before the end of day 1, ship one visible thing: a small bug fix, a cleanup the
|
|
|
79
79
|
|
|
80
80
|
**`success.md`** - what done looks like, **primary value bucket** (`cost-save` | `risk-mitigation` | `revenue-uplift`), baseline → target, who actually signs off, what is explicitly out of scope. Agreed with the customer, not assumed.
|
|
81
81
|
|
|
82
|
+
For every target number, run the **gaming check** before it is written down: *how could this metric hit its target without the customer being any better off?* There is always an answer, and the answer is what the org will drift toward under pressure. Write the guard next to the metric:
|
|
83
|
+
|
|
84
|
+
```markdown
|
|
85
|
+
| Metric | Baseline → target | Gamed by | Guard |
|
|
86
|
+
|--------|-------------------|----------|-------|
|
|
87
|
+
| reconciliation alert latency | 4h → 15min | alerting on everything, so nobody reads them | alerts acked by a named owner, ≤2/week |
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A metric with no gaming check is a metric the FDE will be held to and cannot defend. If the customer resists the guard, that is the real conversation - they are attached to the number, not the outcome.
|
|
91
|
+
|
|
82
92
|
**`stakeholders.md`**:
|
|
83
93
|
```markdown
|
|
84
94
|
| Who | Role | Signal | Notes |
|
|
@@ -105,6 +115,16 @@ One page back to the FDE: success + value bucket + sign-off owner, out-of-scope
|
|
|
105
115
|
|
|
106
116
|
If remote: trust-building takes ~40% longer - push for a short video call before anything asynchronous.
|
|
107
117
|
|
|
118
|
+
## Worked example
|
|
119
|
+
|
|
120
|
+
Kickoff at Acme payments. Priya (VP Eng) sponsors; the brief says "add monitoring to the reconciliation service."
|
|
121
|
+
|
|
122
|
+
Asking what happens the week after a perfect delivery gets: "I stop hearing about it from finance." That is the real success statement — not monitoring. The previous attempt surfaces too: the platform team built alerting last year, it was turned off. Raj, who built it, is still there and was not in the kickoff — the passed-over team, found on day 1 rather than at the first standup.
|
|
123
|
+
|
|
124
|
+
What gets written: `success.md` with bucket `risk-mitigation`, `reconciliation failures reach a named owner within 15 min (baseline: 4h, found by finance)`, gaming check `alerting on everything so nobody reads them` → guard `≤2 alerts/week, acked by name`, sign-off Priya. `brief.md` carries the gap list and the hypothesis: *the job is not unmonitored, it is unowned*. `assumptions.md` seeds `"finance would act on an alert" — CRITICAL — OPEN — (stated, unverified)`. `trust-profile.md` records the sacred thing Priya hesitated before naming.
|
|
125
|
+
|
|
126
|
+
Day-1 deliverable: fix the log line that swallows the job's exit code. Small, visible, in their environment.
|
|
127
|
+
|
|
108
128
|
## Principles
|
|
109
129
|
|
|
110
130
|
- Never start technical work before `success.md` exists.
|
|
@@ -72,6 +72,16 @@ The full option details in the same entry or linked to a section in `reality.md`
|
|
|
72
72
|
|
|
73
73
|
Present the three options and the recommendation. One question to the FDE: "Which option matches what the sponsor can hear right now?" (A risk-averse sponsor after an incident → conservative. A founder pre-fundraise → ambitious.) If unsure: present all three and let the sponsor decide.
|
|
74
74
|
|
|
75
|
+
## Worked example
|
|
76
|
+
|
|
77
|
+
Acme: the reconciliation job needs to survive the FDE leaving. Priya asks "so what should we do?"
|
|
78
|
+
|
|
79
|
+
Three real paths, not a strawman set. **Safe:** keep the job, add the rota and runbook — two weeks, no new failure modes, does nothing about the 47-commits/90d hotspot. **Pragmatic:** extract the settlement-matching step behind a tested interface — six weeks, retires the untested hotspot, needs Raj's time and he currently opposes it. **Aggressive:** rewrite the service — a quarter, fixes everything, and the same team already abandoned this once.
|
|
80
|
+
|
|
81
|
+
Same dimensions on each, so comparison is instant, and every cost carries a source: the six-week figure is churn-based, not felt.
|
|
82
|
+
|
|
83
|
+
Recommendation: pragmatic, conditional — *if* Raj is on the design, otherwise safe, because the aggressive path failed here before for exactly the reason it would fail again. `decisions.md` records the decision, who chose it, and the condition, so week 10's "why aren't we rewriting it" has an answer with a date on it.
|
|
84
|
+
|
|
75
85
|
## Principles
|
|
76
86
|
|
|
77
87
|
- Three options, never one. One option is a request for trust; three is a real decision.
|