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/bin/install.js CHANGED
@@ -53,6 +53,49 @@ function slugify(name) {
53
53
  || 'engagement'
54
54
  }
55
55
 
56
+ // Written into every skill directory this installer creates. Nothing is ever
57
+ // removed or overwritten without it: `healthcare-fde` and `fintech-fde` are
58
+ // plausible names for a skill the user wrote themselves, and a name collision
59
+ // must be reported, not silently resolved by deleting their work.
60
+ const MANAGED_MARKER = '.fdeops-managed'
61
+
62
+ function markManaged(dir) {
63
+ let version = 'unknown'
64
+ try { version = require(path.join(__dirname, '..', 'package.json')).version } catch (_) {}
65
+ try {
66
+ fs.writeFileSync(
67
+ path.join(dir, MANAGED_MARKER),
68
+ `managed-by: fdeops\nversion: ${version}\ninstalled: ${new Date().toISOString()}\n` +
69
+ 'Delete this file to make fdeops treat the directory as yours and leave it alone.\n',
70
+ )
71
+ } catch (_) {}
72
+ }
73
+
74
+ function isManaged(dir) {
75
+ return fs.existsSync(path.join(dir, MANAGED_MARKER))
76
+ }
77
+
78
+ // A skill "directory" that is really a symlink points somewhere outside
79
+ // ~/.claude/skills that fdeops has no claim on. Writing through it would edit
80
+ // files in the user's own tree - refuse even under --force, which is permission
81
+ // to take over this location, not to follow it elsewhere.
82
+ function isLink(p) {
83
+ try { return fs.lstatSync(p).isSymbolicLink() } catch (_) { return false }
84
+ }
85
+
86
+ // Fingerprint of a skill fdeops itself wrote before markers existed. Anchored on
87
+ // the shipped frontmatter, not a bare "fdeops" substring: a skill of the user's
88
+ // that merely mentions fdeops in prose is theirs, not ours. Only consulted for a
89
+ // directory whose name matches one we ship, and only to overwrite - never to delete.
90
+ function wasInstalledByUs(dir) {
91
+ try {
92
+ const md = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8')
93
+ const fm = (md.match(/^---\n([\s\S]*?)\n---/) || [])[1]
94
+ if (!fm) return false
95
+ return /^description:\s*Engagement fieldbook for Forward Deployed Engineers\b/im.test(fm)
96
+ } catch (_) { return false }
97
+ }
98
+
56
99
  // v2 shipped 16 standalone skills; v3 is one `fde` skill + references.
57
100
  // Leaving the old ones in place would route users to stale content.
58
101
  const LEGACY_SKILL_DIRS = [
@@ -62,22 +105,105 @@ const LEGACY_SKILL_DIRS = [
62
105
  'gov-fde',
63
106
  ]
64
107
 
65
- function removeLegacySkills() {
108
+ function removeLegacySkills(opts = {}) {
66
109
  let removed = 0
110
+ const skipped = []
111
+ const links = []
67
112
  for (const dir of LEGACY_SKILL_DIRS) {
68
113
  const p = path.join(GLOBAL_SKILLS_DIR, dir)
69
- if (fs.existsSync(path.join(p, 'SKILL.md'))) {
114
+ if (isLink(p)) { links.push(dir); continue }
115
+ if (!fs.existsSync(path.join(p, 'SKILL.md'))) continue
116
+ if (isManaged(p) || opts.force) {
70
117
  fs.rmSync(p, { recursive: true, force: true })
71
118
  removed++
119
+ } else {
120
+ skipped.push(dir)
72
121
  }
73
122
  }
74
- return removed
123
+ return { removed, skipped, links }
75
124
  }
76
125
 
77
- function installSkills() {
78
- const removed = removeLegacySkills()
79
- if (removed > 0) console.log(` Removed ${removed} v2 skill dir(s) (now covered by @fde)`)
80
- copyDir(SKILLS_SRC, GLOBAL_SKILLS_DIR)
126
+ // Copy each skill in, but never over a directory fdeops did not create.
127
+ function installSkillDirs(opts = {}) {
128
+ const skipped = []
129
+ const links = []
130
+ const failed = []
131
+ fs.mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true })
132
+ for (const entry of fs.readdirSync(SKILLS_SRC, { withFileTypes: true })) {
133
+ const src = path.join(SKILLS_SRC, entry.name)
134
+ const dest = path.join(GLOBAL_SKILLS_DIR, entry.name)
135
+ if (!entry.isDirectory()) { fs.copyFileSync(src, dest); continue }
136
+ if (isLink(dest)) { links.push(entry.name); continue }
137
+ if (fs.existsSync(dest) && !isManaged(dest) && !opts.force) {
138
+ // Installs predating the marker are still ours: adopt a same-named dir
139
+ // whose SKILL.md is recognizably fdeops', so upgrades keep working.
140
+ if (!wasInstalledByUs(dest)) {
141
+ skipped.push(entry.name)
142
+ continue
143
+ }
144
+ console.log(` adopt ~/.claude/skills/${entry.name} (earlier fdeops install)`)
145
+ }
146
+ // One unwritable skill dir must not abort the install with a stack trace:
147
+ // say it in human terms, place the rest, and exit non-zero at the end.
148
+ try {
149
+ copyDir(src, dest)
150
+ markManaged(dest)
151
+ } catch (e) {
152
+ failed.push({ name: entry.name, code: e.code || 'error', path: destPathFor(e.path, src, dest) })
153
+ }
154
+ }
155
+ return { skipped, links, failed }
156
+ }
157
+
158
+ // A failed copy can surface either side of the operation; the user can only fix
159
+ // the destination, so never point them at a path inside the package.
160
+ function destPathFor(failedPath, src, dest) {
161
+ if (!failedPath) return dest
162
+ if (failedPath === src || failedPath.startsWith(src + path.sep)) {
163
+ return path.join(dest, path.relative(src, failedPath))
164
+ }
165
+ return failedPath
166
+ }
167
+
168
+ function reportCollisions(paths, verb) {
169
+ if (!paths.length) return
170
+ console.log(` skip ${paths.length} skill dir(s) fdeops did not create - ${verb} would destroy your own work:`)
171
+ for (const name of paths) console.log(` ~/.claude/skills/${name}`)
172
+ console.log(' move or delete them yourself, or re-run with --force to let fdeops take them over')
173
+ }
174
+
175
+ function reportLinks(names) {
176
+ if (!names.length) return
177
+ console.log(` skip ${names.length} skill path(s) that are symlinks - fdeops will not write through them:`)
178
+ for (const name of names) console.log(` ~/.claude/skills/${name} -> ${readLinkQuiet(path.join(GLOBAL_SKILLS_DIR, name))}`)
179
+ console.log(' remove the link if you want fdeops to install at that path itself')
180
+ }
181
+
182
+ function readLinkQuiet(p) {
183
+ try { return fs.readlinkSync(p) } catch (_) { return '(unreadable)' }
184
+ }
185
+
186
+ // Anything here means part of the install did not land; cmdInstall exits non-zero.
187
+ let installIncomplete = false
188
+ function reportFailures(failures) {
189
+ if (!failures.length) return
190
+ installIncomplete = true
191
+ console.log(` error ${failures.length} skill dir(s) could not be written:`)
192
+ for (const f of failures) {
193
+ const why = f.code === 'EACCES' || f.code === 'EPERM' ? 'permission denied' : f.code
194
+ console.log(` ~/.claude/skills/${f.name} - ${why} at ${f.path}`)
195
+ }
196
+ console.log(' fix the permissions (or remove the directory) and re-run - the rest of the install continued')
197
+ }
198
+
199
+ function installSkills(opts = {}) {
200
+ const legacy = removeLegacySkills(opts)
201
+ if (legacy.removed > 0) console.log(` Removed ${legacy.removed} v2 skill dir(s) (now covered by @fde)`)
202
+ const placed = installSkillDirs(opts)
203
+ reportCollisions(legacy.skipped, 'removing them')
204
+ reportCollisions(placed.skipped, 'overwriting them')
205
+ reportLinks([...new Set([...legacy.links, ...placed.links])])
206
+ reportFailures(placed.failed)
81
207
  fs.mkdirSync(GLOBAL_HOOKS_DIR, { recursive: true })
82
208
  for (const name of HOOK_SCRIPTS) {
83
209
  const src = path.join(HOOKS_SRC, name)
@@ -138,7 +264,7 @@ function placePointer(destPath, content, label, appendable) {
138
264
  console.log(` write ${label}`)
139
265
  }
140
266
 
141
- function cmdAdapters(targetDir) {
267
+ function cmdAdapters(targetDir, opts = {}) {
142
268
  const dest = path.resolve(targetDir || process.cwd())
143
269
  console.log('')
144
270
  console.log(` fdeops cross-platform adapters → ${dest}`)
@@ -150,7 +276,7 @@ function cmdAdapters(targetDir) {
150
276
  // yet, a dangling reference for anyone following the documented Cursor/Codex
151
277
  // path. installSkills() is idempotent (safe to call every run).
152
278
  if (!fs.existsSync(path.join(GLOBAL_SKILLS_DIR, 'fde', 'SKILL.md'))) {
153
- installSkills()
279
+ installSkills(opts)
154
280
  console.log(' Skills → ~/.claude/skills/ (installed - the pointers below need this)')
155
281
  console.log('')
156
282
  }
@@ -198,11 +324,11 @@ function cmdInit(engagementName) {
198
324
  console.log('')
199
325
  }
200
326
 
201
- function cmdInstall() {
327
+ function cmdInstall(opts = {}) {
202
328
  console.log('')
203
329
  console.log(' fdeops - installs on YOUR machine only')
204
330
  console.log('')
205
- installSkills()
331
+ installSkills(opts)
206
332
  console.log(' Skills → ~/.claude/skills/')
207
333
  console.log(' Hooks → ~/.claude/hooks/fdeops-*')
208
334
  console.log(' CLI → ~/.claude/fdeops/fde.js (try: node ~/.claude/fdeops/fde.js scan)')
@@ -220,22 +346,99 @@ function cmdInstall() {
220
346
  console.log(' Then open your workspace and use @fde')
221
347
  console.log(' Docs: docs/install.md')
222
348
  console.log('')
349
+ // A partly-installed skill set is not success - a script that ran this must be
350
+ // able to tell, and the reason is already printed above.
351
+ if (installIncomplete) process.exit(1)
223
352
  }
224
353
 
225
354
  // `npx fdeops scan` must recon, not install - any fde subcommand passes straight
226
355
  // through to the CLI (fde.js reads process.argv itself, so require() is enough).
227
356
  const FDE_SUBCOMMANDS = [
228
- 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
229
- 'garden', 'owner', 'receipts', 'capture', 'status', 'dashboard', 'help',
357
+ 'demo', 'scan', 'resume', 'triage', 'log', 'debrief', 'ingest', 'prep', 'doctor', 'redact',
358
+ 'garden', 'owner', 'receipts', 'capture', 'preserve', 'status', 'dashboard', 'help',
230
359
  ]
231
360
 
232
- const arg = process.argv[2]
233
- if (arg === 'init') {
234
- cmdInit(process.argv[3])
235
- } else if (arg === 'adapters') {
236
- cmdAdapters(process.argv[3])
237
- } else if (FDE_SUBCOMMANDS.includes(arg)) {
361
+ const INSTALL_SUBCOMMANDS = ['init', 'adapters', 'install']
362
+
363
+ function editDistance(a, b) {
364
+ let prev = [...Array(b.length + 1).keys()]
365
+ for (let i = 1; i <= a.length; i++) {
366
+ const row = [i]
367
+ for (let j = 1; j <= b.length; j++) {
368
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
369
+ }
370
+ prev = row
371
+ }
372
+ return prev[b.length]
373
+ }
374
+
375
+ function nearest(word, known) {
376
+ const w = word.toLowerCase()
377
+ let best = null
378
+ let bestScore = 3
379
+ for (const k of known) {
380
+ const d = editDistance(w, k)
381
+ if (d < bestScore) { best = k; bestScore = d }
382
+ }
383
+ return best
384
+ }
385
+
386
+ const argv = process.argv.slice(2)
387
+ const force = argv.includes('--force')
388
+ const positional = argv.filter(a => !a.startsWith('-'))
389
+ const raw = positional[0]
390
+ // `fdeops Demo` is a typo, not a request to rewrite ~/.claude: verbs match
391
+ // case-insensitively, and anything unrecognized fails loudly instead of
392
+ // falling through to a full install. Asking a question (`--help`, `--version`)
393
+ // is not consent to write to the home directory either.
394
+ const known = INSTALL_SUBCOMMANDS.concat(FDE_SUBCOMMANDS)
395
+ const arg = raw ? known.find(k => k === raw.toLowerCase()) : undefined
396
+ const askedHelp = argv.some(a => /^--?(h|help)$/i.test(a))
397
+ const askedVersion = argv.some(a => /^--?(v|version)$/i.test(a))
398
+
399
+ // Flags before the verb belong to fdeops, so an unknown one is a mistake worth
400
+ // saying out loud - the alternative is honoring `fdeops --all status` by
401
+ // quietly dropping --all.
402
+ const FDEOPS_FLAGS = /^--?(force|h|help|v|version)$/i
403
+ const leading = (raw === undefined ? argv : argv.slice(0, argv.indexOf(raw))).filter(a => !FDEOPS_FLAGS.test(a))
404
+ if (leading.length) {
405
+ console.error(` fdeops: unknown option '${leading[0]}'${raw ? ` before '${raw}' - put command options after the command: fdeops ${raw} ${leading[0]}` : ''}`)
406
+ console.error(' fdeops itself takes only --force, --help and --version.')
407
+ process.exit(1)
408
+ }
409
+
410
+ if (raw && !arg) {
411
+ const guess = nearest(raw, known)
412
+ console.error(` fdeops: unknown command '${raw}'${guess ? ` - did you mean '${guess}'?` : ''}`)
413
+ console.error(' Run `npx fdeops help` for the command list, or `npx fdeops` with no arguments to install.')
414
+ process.exit(1)
415
+ }
416
+
417
+ // The verb the user typed may not be argv[0] (`fdeops --force redact ledger`),
418
+ // and fde.js reads process.argv itself - hand it back the positional order it
419
+ // expects with only the verb's case normalized.
420
+ function handOffToCli(verb) {
421
+ // fde.js reads process.argv itself and takes the verb first, so hand it the
422
+ // verb (case-normalized) plus everything the user typed after it. A flag
423
+ // typed BEFORE the verb (`fdeops --force redact ledger`) belongs to fdeops,
424
+ // not to the command - passing it on would make it part of the command's own
425
+ // arguments (here: a search term of "--force ledger").
426
+ const at = raw === undefined ? -1 : argv.indexOf(raw)
427
+ const rest = at === -1 ? [] : argv.slice(at + 1)
428
+ process.argv = [process.argv[0], process.argv[1], verb, ...rest]
238
429
  require(path.join(__dirname, 'fde.js'))
430
+ }
431
+
432
+ if (!raw && askedVersion) {
433
+ console.log(require(path.join(__dirname, '..', 'package.json')).version)
434
+ } else if (!raw && askedHelp) {
435
+ handOffToCli('help')
436
+ } else if (arg === 'init') {
437
+ cmdInit(positional[1])
438
+ } else if (arg === 'adapters') {
439
+ cmdAdapters(positional[1], { force })
440
+ } else if (arg && arg !== 'install') {
441
+ handOffToCli(arg)
239
442
  } else {
240
- cmdInstall()
443
+ cmdInstall({ force })
241
444
  }
package/bin/lib/memory.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const { execFileSync } = require('child_process')
4
4
 
5
5
  // Ephemeral sidecar files - never treated as "manual tamper" dirt.
6
- const MEMORY_EPHEMERAL = new Set(['.last-write', '.debrief-propose'])
6
+ const MEMORY_EPHEMERAL = new Set(['.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal'])
7
7
 
8
8
  function createMemoryApi(deps) {
9
9
  const { fs, path, gitBinOk, writeOwnerIfMissing, atomicWriteFile } = deps
@@ -131,7 +131,7 @@ function createMemoryApi(deps) {
131
131
  execFileSync('git', ['init'], { cwd: eng, stdio: 'ignore', timeout: 10000 })
132
132
  atomicWriteFile(
133
133
  path.join(eng, '.gitignore'),
134
- ['*.lock', '*.tmp', '.last-write', '.debrief-propose', ''].join('\n')
134
+ ['*.lock', '*.tmp', '.last-write', '.debrief-propose', '.debrief-private', '.debrief-seal', ''].join('\n')
135
135
  )
136
136
  const owner = writeOwnerIfMissing(eng)
137
137
  configureMemoryGitIdentity(eng, owner)
package/bin/lib/render.js CHANGED
@@ -196,6 +196,7 @@ strong{font-weight:600}
196
196
  .fb-log-date{font-family:'Geist Mono',monospace;font-size:11.5px;color:var(--ink-faint);flex:0 0 48px}
197
197
  .fb-log-kind{font-family:'Geist Mono',monospace;font-size:10px;letter-spacing:.03em;text-transform:uppercase;flex:0 0 62px}
198
198
  .fb-log-text{flex:1 1 auto;font-size:14px;line-height:1.5;color:var(--ink)}
199
+ .fb-log-sig{font-family:'Geist Mono',monospace;font-size:10px;letter-spacing:.03em;text-transform:uppercase;white-space:nowrap}
199
200
  .fb-more{border-top:1px solid var(--line);padding:12px 0}
200
201
  .fb-more:first-child{border-top:none;padding-top:0}
201
202
  .fb-more-sum{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px}
@@ -537,6 +538,7 @@ ${e.log.map(g => `<div class="fb-row fb-log">
537
538
  <span class="fb-log-date">${escapeHtml(formatLogDate(g.date))}</span>
538
539
  <span class="fb-log-kind t-${g.kind === 'receipt' ? 'green' : g.kind === 'decision' ? 'accent' : 'faint'}">${escapeHtml(g.kind)}</span>
539
540
  <span class="fb-log-text">${inlineMd(g.text)}</span>
541
+ ${g.sig ? `<span class="fb-log-sig t-${g.sig}" title="trust signal ${escapeHtml(g.sig)}">●&nbsp;${escapeHtml(g.sig)}</span>` : ''}
540
542
  </div>`).join('\n')}
541
543
  </div>
542
544
  </div>` : ''
package/mcp/README.md CHANGED
@@ -50,6 +50,4 @@ Source MCPs are **not** bundled in fdeops. To add Granola, Gmail, or another pro
50
50
 
51
51
  FDEOps credentials stay local to the CLI; source MCP credentials stay with that MCP.
52
52
 
53
- ## Design reference
54
-
55
- See [`docs/plans/2026-07-29-ingest-mcp-design.md`](../docs/plans/2026-07-29-ingest-mcp-design.md) for the approved ingest MCP design.
53
+ **Non-goals:** no bundled OAuth/connectors, no ambient sync, no unreviewed writes to `.fde/`. Method: [`skills/fde/references/ingest.md`](../skills/fde/references/ingest.md).
@@ -22,7 +22,9 @@ Each tool returns `{ stdout, stderr, status }` from the CLI.
22
22
 
23
23
  ## Configure in Cursor / Claude
24
24
 
25
- Add to your MCP config (`~/.cursor/mcp.json`, Claude Desktop config, etc.):
25
+ **Agent Plugins clients: nothing to configure.** fdeops ships a root [`mcp.json`](../../mcp.json) declaring this server as `stdio` with a `${PLUGIN_ROOT}`-relative path, so a client that supports [Agent Plugins 1.0.0](https://agent-plugins.org/specification) wires it on install - no absolute paths to edit.
26
+
27
+ Everywhere else, add to your MCP config (`~/.cursor/mcp.json`, Claude Desktop config, etc.):
26
28
 
27
29
  ```json
28
30
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.9.20",
3
+ "version": "3.10.0",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
6
  "bin": {
@@ -70,31 +70,41 @@ const TOOLS = [
70
70
  let readBuffer = Buffer.alloc(0)
71
71
 
72
72
  function writeMessage(obj) {
73
- const body = JSON.stringify(obj)
74
- process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`)
73
+ process.stdout.write(`${JSON.stringify(obj)}\n`)
75
74
  }
76
75
 
76
+ // MCP stdio frames messages by newline. Content-Length headers are tolerated on
77
+ // input only, so an LSP-style client still gets through.
77
78
  function parseMessages() {
78
79
  const messages = []
79
- while (true) {
80
- const headerEnd = readBuffer.indexOf('\r\n\r\n')
81
- if (headerEnd === -1) break
82
-
83
- const header = readBuffer.slice(0, headerEnd).toString('utf8')
84
- const match = header.match(/Content-Length:\s*(\d+)/i)
85
- if (!match) {
86
- readBuffer = readBuffer.slice(headerEnd + 4)
80
+ while (readBuffer.length) {
81
+ if (/^Content-Length:/i.test(readBuffer.slice(0, 15).toString('utf8'))) {
82
+ const headerEnd = readBuffer.indexOf('\r\n\r\n')
83
+ if (headerEnd === -1) break
84
+ const header = readBuffer.slice(0, headerEnd).toString('utf8')
85
+ const match = header.match(/Content-Length:\s*(\d+)/i)
86
+ if (!match) {
87
+ readBuffer = readBuffer.slice(headerEnd + 4)
88
+ continue
89
+ }
90
+ const length = parseInt(match[1], 10)
91
+ const bodyStart = headerEnd + 4
92
+ if (readBuffer.length < bodyStart + length) break
93
+ const body = readBuffer.slice(bodyStart, bodyStart + length).toString('utf8')
94
+ readBuffer = readBuffer.slice(bodyStart + length)
95
+ try {
96
+ messages.push(JSON.parse(body))
97
+ } catch (_) {}
87
98
  continue
88
99
  }
89
100
 
90
- const length = parseInt(match[1], 10)
91
- const bodyStart = headerEnd + 4
92
- if (readBuffer.length < bodyStart + length) break
93
-
94
- const body = readBuffer.slice(bodyStart, bodyStart + length).toString('utf8')
95
- readBuffer = readBuffer.slice(bodyStart + length)
101
+ const newline = readBuffer.indexOf('\n')
102
+ if (newline === -1) break
103
+ const line = readBuffer.slice(0, newline).toString('utf8').trim()
104
+ readBuffer = readBuffer.slice(newline + 1)
105
+ if (!line) continue
96
106
  try {
97
- messages.push(JSON.parse(body))
107
+ messages.push(JSON.parse(line))
98
108
  } catch (_) {}
99
109
  }
100
110
  return messages
package/mcp.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
3
+ "mcpServers": {
4
+ "fdeops-ingest": {
5
+ "type": "stdio",
6
+ "command": "node",
7
+ "args": ["${PLUGIN_ROOT}/mcp/fdeops-ingest/server.js"]
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.9.20",
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.