fdeops 3.13.0 → 3.15.1

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/check.js CHANGED
@@ -41,12 +41,11 @@ for (const f of requiredTemplates) {
41
41
  else ok(`template ${f}`)
42
42
  }
43
43
 
44
- const gif = path.join(root, 'media', 'demo.gif')
45
- if (!fs.existsSync(gif) || fs.statSync(gif).size < 1000) {
46
- fail('media/demo.gif missing or too small')
47
- } else {
48
- ok('media/demo.gif')
44
+ const deadMedia = ['demo.gif', 'demo.sh', 'demo.tape', 'terminal-demo.svg', 'fieldbook-dashboard.png', 'fieldbook-detail.png', 'fieldbook-walkthrough.gif']
45
+ for (const name of deadMedia) {
46
+ if (fs.existsSync(path.join(root, 'media', name))) fail(`dead media/${name} must not ship — the recorded session is session.gif`)
49
47
  }
48
+ ok('no staged mock media')
50
49
 
51
50
  for (const dir of fs.readdirSync(path.join(root, 'skills'))) {
52
51
  const skill = path.join(root, 'skills', dir, 'SKILL.md')
@@ -57,6 +56,10 @@ for (const dir of fs.readdirSync(path.join(root, 'skills'))) {
57
56
  }
58
57
  ok('skills structure')
59
58
 
59
+ if (fs.existsSync(path.join(root, 'skills', 'fde', 'archive'))) {
60
+ fail('skills/fde/archive must not exist — unrouted methods are dead code')
61
+ } else ok('no archived skill dump')
62
+
60
63
  // v3: one skill + phase references (progressive disclosure)
61
64
  const requiredReferences = [
62
65
  'land.md', 'discover.md', 'audit.md', 'plan.md', 'review.md',
@@ -118,7 +121,7 @@ ok(`router dispatch (${mentioned.length} reference targets verified) + memory co
118
121
  // Every routing row must parse. A row this misses is a method that could go
119
122
  // undocumented for free, so an unparsed row is a hard failure, not a silent skip.
120
123
  const routed = new Set()
121
- const routing = (router.split('## Routing - 6 domains')[1] || '').split('**Overlays')[0]
124
+ const routing = (router.split(/^## Routing[^\n]*$/m)[1] || '').split('**Overlays')[0]
122
125
  const methodCell = line => (line.split('|')[2] || '').trim().replace(/\s*\([^)]*\)\s*$/, '')
123
126
  for (const line of routing.split('\n')) {
124
127
  if (!/^\|/.test(line)) continue
@@ -179,24 +182,27 @@ ok(`router dispatch (${mentioned.length} reference targets verified) + memory co
179
182
  // the agent can never reach, advertised anyway.
180
183
  const unrouted = [...documented].filter(name => !routed.has(name))
181
184
  if (unrouted.length) {
182
- fail(`docs/skills-reference.md documents method(s) SKILL.md never routes to: ${unrouted.join(', ')}`)
185
+ fail(`docs/skills-reference.md documents skill(s) SKILL.md never routes to: ${unrouted.join(', ')}`)
183
186
  }
184
187
  for (const rel of ['docs/skills.md', 'docs/skills-reference.md']) {
185
188
  const body = read(rel)
186
189
  // `-` is a word boundary, so \bingest\b matches inside `ingest-connect`:
187
190
  // a method could disappear from the docs behind a hyphenated sibling.
188
191
  const absent = [...documented].filter(name => !new RegExp(`(?<![\\w-])${name}(?![\\w-])`).test(body))
189
- if (absent.length) fail(`${rel} does not list method(s): ${absent.join(', ')}`)
190
- // every count claim, not just the first: an earlier sentence must not shadow
191
- // a stale headline (or the reverse).
192
- const claims = [...body.matchAll(/(\d+)\s+methods/g)].map(m => Number(m[1]))
193
- if (!claims.length) fail(`${rel} must state how many methods it documents`)
192
+ if (absent.length) fail(`${rel} does not list skill(s): ${absent.join(', ')}`)
193
+ const claims = [...body.matchAll(/(\d+)\s+skills/g)].map(m => Number(m[1]))
194
+ if (!claims.length) fail(`${rel} must state how many skills it documents`)
194
195
  const wrong = [...new Set(claims.filter(n => n !== documented.size))]
195
196
  if (wrong.length) {
196
- fail(`${rel} claims ${wrong.join('/')} methods; ${documented.size} are documented`)
197
+ fail(`${rel} claims ${wrong.join('/')} skills; ${documented.size} are documented`)
197
198
  }
198
199
  }
199
- ok(`public method count is verifiable (${documented.size} documented, ${routed.size} routed)`)
200
+ ok(`public skill count is verifiable (${documented.size} documented, ${routed.size} routed)`)
201
+
202
+ const refDir = path.join(root, 'skills', 'fde', 'references')
203
+ const extra = fs.readdirSync(refDir).filter(f => f.endsWith('.md') && !mentioned.includes(f))
204
+ if (extra.length) fail(`unrouted reference file(s) — dead method: ${extra.join(', ')}`)
205
+ else ok('no unrouted reference files')
200
206
  }
201
207
 
202
208
  const install = read('bin/install.js')
@@ -215,26 +221,20 @@ if (read('package.json').includes('postinstall')) {
215
221
  }
216
222
 
217
223
  const readme = read('README.md')
218
- // The README shows one recording: media/session.gif, a real CLI session.
219
- // media/demo.gif is a hand-typed mock kept for history - it must never be embedded,
220
- // or the front door shows output no command actually produced.
221
- if (readme.includes('demo.gif')) fail('README must not embed media/demo.gif (staged mock, not real CLI output)')
222
- else ok('README no staged demo gif')
223
-
224
- // The recording may live on the front door or one click in (docs/USAGE.md), but
225
- // it must stay reachable and reproducible - an orphaned gif rots silently.
226
- const recordingHosts = ['README.md', 'docs/USAGE.md'].filter(f => read(f).includes('media/session.gif'))
227
- if (!recordingHosts.length) {
228
- fail('media/session.gif must be embedded in README.md or docs/USAGE.md (the recorded session is the proof)')
229
- } else if (!recordingHosts.some(f => read(f).includes('media/record-session.sh'))) {
230
- fail('link media/record-session.sh next to the recording, so it can be re-recorded')
224
+ if (/session\.gif|demo\.gif|<img /i.test(readme)) {
225
+ fail('README must not embed images front door is text; the recording lives in docs/USAGE.md')
226
+ } else ok('README is text (no gif)')
227
+
228
+ const usage = read('docs/USAGE.md')
229
+ if (!usage.includes('media/session.gif') || !usage.includes('media/record-session.sh')) {
230
+ fail('docs/USAGE.md must embed media/session.gif and link media/record-session.sh')
231
231
  } else {
232
232
  const gifPath = path.join(root, 'media', 'session.gif')
233
233
  const rec = path.join(root, 'media', 'record-session.sh')
234
234
  if (!fs.existsSync(gifPath) || fs.statSync(gifPath).size < 50000) fail('media/session.gif missing or too small')
235
235
  else if (!fs.existsSync(rec)) fail('media/record-session.sh missing - the recording must be reproducible')
236
236
  else if (!fs.existsSync(path.join(root, 'media', 'session.cast'))) fail('media/session.cast missing - keep the source recording next to the gif')
237
- else ok(`recorded session in ${recordingHosts.join(' + ')} (gif + reproducible recorder + cast)`)
237
+ else ok('recorded session in docs/USAGE.md (gif + reproducible recorder + cast)')
238
238
  }
239
239
 
240
240
  // Every repo-relative README link and image must resolve, or the front door 404s.
@@ -248,11 +248,11 @@ if (brokenLinks.length) fail(`README links to missing paths: ${brokenLinks.join(
248
248
  else ok('README links all resolve')
249
249
 
250
250
  for (const section of [
251
- 'How it works',
252
- 'Quickstart',
251
+ 'How Skills Work',
252
+ 'Quick Start',
253
253
  'Engagement memory',
254
254
  'Who this is for',
255
- 'The week',
255
+ 'Commands',
256
256
  'Principles',
257
257
  ]) {
258
258
  if (!readme.includes(section)) fail(`README missing section: ${section}`)
@@ -262,6 +262,16 @@ if (!readme.includes('AI coding agent')) {
262
262
  }
263
263
  ok('README clarity sections')
264
264
 
265
+ for (const cmd of ['/brief', '/discover', '/plan', '/ship', '/got', '/close', '/debrief', '/prep', '/quiet', '/agreed', '/status']) {
266
+ if (!readme.includes(cmd)) fail(`README must document slash command ${cmd}`)
267
+ }
268
+ ok('README slash commands documented')
269
+
270
+ // Front-door map is the embed left-to-right (LAND → CLOSE), not a pile of situations.
271
+ if (!readme.slice(0, 4000).includes('LAND') || !readme.slice(0, 4000).includes('/discover')) {
272
+ fail('README must include the LAND→CLOSE command-map diagram near the top')
273
+ } else ok('README command-map diagram')
274
+
265
275
  if (readme.includes('your-client-repo')) {
266
276
  fail('README must not instruct install in customer repo (your-client-repo)')
267
277
  } else ok('README no customer-repo install')
@@ -270,9 +280,8 @@ if (!readme.includes('fde-engagements') || !/fdeops.*init.*engagement/i.test(rea
270
280
  fail('README must document fde-engagements + init flow')
271
281
  } else ok('README engagement path')
272
282
 
273
- // The contributor-only testing skill lives in .agents/skills/, so a bare
274
- // `skills add` installs it too. The advertised command must name the one skill
275
- // a field user wants.
283
+ // The advertised command must name the one skill a field user wants.
284
+ // Contributor CLI attack notes live in evals/testing-fieldbook.md, not as a skill.
276
285
  for (const m of readme.match(/^.*npx skills add .*$/gm) || []) {
277
286
  if (!m.includes('--skill fde')) {
278
287
  fail(`README skills-add command must pin --skill fde: ${m.trim()}`)
@@ -298,19 +307,7 @@ if (!read('skills/fde/SKILL.md').includes('npx --yes fdeops')) {
298
307
  fail(`SKILL.md description needs several "Use when …" triggers so it fires on intent (found ${triggers.length})`)
299
308
  } else if (/Use when the human says @fde or/.test(desc)) {
300
309
  fail('SKILL.md description must not gate on @fde - name the client-work intents first')
301
- } else ok('SKILL.md description triggers on intent')
302
- }
303
-
304
- // Six invented words (fieldbook, terrain, reality, trust signal, receipts,
305
- // vault) carry the method. They were never defined in one place, so a stranger
306
- // met them scattered through the docs and guessed.
307
- {
308
- const gloss = /Words used here[\s\S]{0,1200}/.exec(readme)
309
- const missing = ['fieldbook', 'reality', 'terrain', 'trust signal', 'receipts', 'vault']
310
- .filter(w => !gloss || !new RegExp(w, 'i').test(gloss[0]))
311
- if (!gloss) fail('README must define its invented words once (a "Words used here" line)')
312
- else if (missing.length) fail(`README glossary is missing: ${missing.join(', ')}`)
313
- else ok('README defines its own vocabulary')
310
+ } else ok('SKILL.md description triggers on intent')
314
311
  }
315
312
 
316
313
  if (!fs.existsSync(path.join(root, 'docs', 'USAGE.md'))) {
@@ -535,6 +532,16 @@ const plugin = JSON.parse(read('.claude-plugin/plugin.json'))
535
532
  if (pkg.version !== plugin.version) {
536
533
  fail(`version mismatch package.json ${pkg.version} vs plugin ${plugin.version}`)
537
534
  } else ok('plugin version aligned')
535
+ if (plugin.commands !== './.claude/commands' || plugin.skills !== './skills') {
536
+ fail('.claude-plugin/plugin.json must declare skills and commands')
537
+ } else {
538
+ for (const cmd of ['brief', 'discover', 'plan', 'ship', 'got', 'close', 'debrief', 'prep', 'quiet', 'agreed', 'status']) {
539
+ const rel = `.claude/commands/${cmd}.md`
540
+ if (!fs.existsSync(path.join(root, rel))) fail(`${rel} missing`)
541
+ else if (!read(rel).includes('@fde')) fail(`${rel} must load @fde`)
542
+ }
543
+ ok('slash commands load @fde')
544
+ }
538
545
 
539
546
  if (!fs.existsSync(path.join(root, 'mcp', 'fdeops-ingest', 'server.js'))) {
540
547
  fail('mcp/fdeops-ingest/server.js missing (ingest MCP sink)')
@@ -607,12 +614,34 @@ for (const manifest of ['plugin.json', 'mcp.json']) {
607
614
  else ok(`${manifest} published to npm`)
608
615
  }
609
616
 
617
+ const skillDirs = []
610
618
  for (const entry of fs.readdirSync(path.join(root, 'skills'))) {
611
619
  const dir = path.join(root, 'skills', entry)
612
620
  if (!fs.statSync(dir).isDirectory()) continue
613
621
  if (!fs.existsSync(path.join(dir, 'SKILL.md'))) fail(`skills/${entry}/ has no SKILL.md - clients skip it`)
614
- else ok(`skill ${entry} discoverable`)
622
+ else {
623
+ skillDirs.push(entry)
624
+ ok(`skill ${entry} discoverable`)
625
+ }
626
+ }
627
+ if (skillDirs.length !== 1 || skillDirs[0] !== 'fde') {
628
+ fail(`public tree ships one skill (skills/fde); found: ${skillDirs.join(', ') || '(none)'}`)
629
+ } else ok('one public skill')
630
+
631
+ function findSkillMd(dir, acc = []) {
632
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
633
+ if (e.name === '.git' || e.name === 'node_modules' || e.name === '.agents') continue
634
+ const p = path.join(dir, e.name)
635
+ if (e.isDirectory()) findSkillMd(p, acc)
636
+ else if (e.name === 'SKILL.md') acc.push(path.relative(root, p))
637
+ }
638
+ return acc
615
639
  }
640
+ const skillFiles = findSkillMd(root)
641
+ const allowedSkill = path.join('skills', 'fde', 'SKILL.md')
642
+ if (skillFiles.length !== 1 || skillFiles[0] !== allowedSkill) {
643
+ fail(`only ${allowedSkill} may exist; found: ${skillFiles.join(', ') || '(none)'}`)
644
+ } else ok('one SKILL.md')
616
645
 
617
646
  if (!fs.existsSync(path.join(root, '.github', 'ISSUE_TEMPLATE', 'bug_report.yml'))) {
618
647
  fail('GitHub issue template missing')
package/bin/fde.js CHANGED
@@ -738,11 +738,20 @@ function appendLogEntry(eng, type, entry, opts = {}) {
738
738
  // degrading to "nothing found" rather than guessing when the shape does not
739
739
  // match. Never fabricate a number, a name, or a signal that is not in the text.
740
740
 
741
+ const PHASES = ['land', 'discover', 'plan', 'ship', 'prove', 'close']
742
+ const PHASE_ALIASES = { build: 'ship' } // legacy SDLC name; public map is ship
741
743
  const PHASE_LABELS = {
742
- land: 'Embed & Trust', discover: 'Discover & Diagnose', plan: 'Plan & Align',
743
- build: 'Build & Guard', ship: 'Ship & Verify', close: 'Operate & Close',
744
+ land: 'Land', discover: 'Discover', plan: 'Plan',
745
+ ship: 'Ship', prove: 'Prove', close: 'Close',
746
+ }
747
+ function canonicalPhase(phase) {
748
+ const p = String(phase).toLowerCase()
749
+ return PHASE_ALIASES[p] || p
750
+ }
751
+ function phaseLabel(phase) {
752
+ const p = canonicalPhase(phase)
753
+ return PHASE_LABELS[p] || phase
744
754
  }
745
- function phaseLabel(phase) { return PHASE_LABELS[String(phase).toLowerCase()] || phase }
746
755
 
747
756
  // First real (non-blank, non-heading) line of a prose file, bold-label prefix
748
757
  // stripped ("**Confirmed:** text..." -> "text...") - same spirit as the
@@ -1312,11 +1321,11 @@ function cmdLog(args) {
1312
1321
  const eng = resolveEngagement({ forWrite: true })
1313
1322
  if (!eng) { console.error('no engagement - run: fde resume --init <name>'); process.exit(2) }
1314
1323
 
1315
- // fde log phase <land|discover|plan|build|ship|close> - advances portfolio phase
1324
+ // fde log phase <land|discover|plan|ship|prove|close> - advances portfolio phase
1316
1325
  if (type === 'phase') {
1317
- const phase = (text || '').toLowerCase().trim()
1318
- if (!['land', 'discover', 'plan', 'build', 'ship', 'close'].includes(phase)) {
1319
- console.error('usage: fde log phase <land|discover|plan|build|ship|close>')
1326
+ const phase = canonicalPhase((text || '').toLowerCase().trim())
1327
+ if (!PHASES.includes(phase)) {
1328
+ console.error(`usage: fde log phase <${PHASES.join('|')}>`)
1320
1329
  process.exit(1)
1321
1330
  }
1322
1331
  const hash = setContextPhase(eng, phase)
@@ -1324,7 +1333,7 @@ function cmdLog(args) {
1324
1333
  return
1325
1334
  }
1326
1335
 
1327
- if (!LOG_FILES[type] || !text) { console.error('usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log phase <land|discover|plan|build|ship|close>\n fde log --undo'); process.exit(1) }
1336
+ if (!LOG_FILES[type] || !text) { console.error(`usage: fde log <decision|risk|delivery|contact> <text> [--signal red|amber|green] [--force]\n fde log phase <${PHASES.join('|')}>\n fde log --undo`); process.exit(1) }
1328
1337
  if (signal && type !== 'contact') { console.error('--signal only applies to: fde log contact'); process.exit(1) }
1329
1338
  const hit = findSecretHit(text)
1330
1339
  if (hit && !force) { refuseSecret('log text', hit); process.exit(1) }
@@ -2091,7 +2100,7 @@ function collectDoctorIssues(eng) {
2091
2100
 
2092
2101
  if (s.phase === '?' || s.phase === 'unset') {
2093
2102
  if (hasDatedWork) {
2094
- issues.push('phase is unset but dated work exists - run: fde log phase <land|discover|plan|build|ship|close>')
2103
+ issues.push(`phase is unset but dated work exists - run: fde log phase <${PHASES.join('|')}>`)
2095
2104
  }
2096
2105
  }
2097
2106
  if (s.stale) issues.push(`trust signal is STALE (${s.signalAge}d) - reconfirm with fde log contact ... --signal`)
@@ -2161,7 +2170,7 @@ function collectDoctorIssues(eng) {
2161
2170
  }
2162
2171
  // Failure-path (exception-led operating map): required once past discover.
2163
2172
  // Land seeds; discover fills; plan+ without a real break→owner row is wallpaper.
2164
- if (/^(plan|build|ship|close)$/.test(s.phase) && !hasOperatingMapContent(eng)) {
2173
+ if (/^(plan|ship|prove|close)$/.test(s.phase) && !hasOperatingMapContent(eng)) {
2165
2174
  issues.push(
2166
2175
  `phase is ${s.phase} with empty operating map - fill terrain.md ## Operating map (exception-led): break → who notices → workaround → evidence`
2167
2176
  )
@@ -3178,7 +3187,7 @@ function printUsage() {
3178
3187
  fde resume --bind show what this workspace is bound to, and what resolves
3179
3188
  fde triage TRIAGE block only (hooks / Cursor session entry)
3180
3189
  fde log <type> <text> append decision|risk|delivery|contact (contact takes --signal red|amber|green; --force to allow secret-like text)
3181
- fde log phase <phase> set engagement phase (land|discover|plan|build|ship|close)
3190
+ fde log phase <phase> set engagement phase (land|discover|plan|ship|prove|close)
3182
3191
  fde log --undo remove the last CLI log/debrief entry from memory
3183
3192
  fde debrief [file] meeting notes → memory (prefixed lines; --dry-run; --force)
3184
3193
  fde debrief --smart heuristic propose (prefix + light keywords); agent routes, CLI gates → --apply
package/bin/lib/trust.js CHANGED
@@ -64,8 +64,9 @@ function createTrustApi(deps) {
64
64
  if (!m) return '?'
65
65
  const raw = m[1].replace(/\*/g, '').trim()
66
66
  if (!raw || /\|/.test(raw) || /^unset$/i.test(raw) || /^[\[(]/.test(raw)) return '?'
67
- const one = raw.toLowerCase().match(/^(land|discover|plan|build|ship|close)\b/)
68
- return one ? one[1] : '?'
67
+ const one = raw.toLowerCase().match(/^(land|discover|plan|build|ship|prove|close)\b/)
68
+ if (!one) return '?'
69
+ return one[1] === 'build' ? 'ship' : one[1]
69
70
  }
70
71
 
71
72
  function countOpenRisks(eng) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdeops-ingest-mcp",
3
- "version": "3.13.0",
3
+ "version": "3.15.1",
4
4
  "private": true,
5
5
  "description": "Thin stdio MCP sink for FDEOps ingest (stage → propose → apply). Zero runtime dependencies.",
6
6
  "bin": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fdeops",
3
- "version": "3.13.0",
4
- "description": "Engagement memory for AI coding agents. Your agent forgets the client every morning - the sponsor, the promise, who signed off. FDEOps keeps that as dated markdown on your laptop: one @fde skill for the four days of an embed, a deterministic local CLI, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
3
+ "version": "3.15.1",
4
+ "description": "Forward deployed engineering skills for AI coding agents. Your agent forgets the client every morning - the sponsor, the promise, who signed off. FDEOps keeps that as dated markdown on your laptop: one @fde skill, a deterministic local CLI, and hooks that make it automatic. Claude Code plugin and any agent that loads skills.",
5
5
  "bin": {
6
6
  "fdeops": "bin/install.js",
7
7
  "fde": "bin/fde.js"
package/plugin.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "fdeops",
4
- "version": "3.13.0",
5
- "description": "Engagement memory for AI coding agents: per-client memory in local .fde/ files, one @fde skill, the four days of an embed. Local-only, no network.",
4
+ "version": "3.15.1",
5
+ "description": "Forward deployed engineering skills for AI coding agents: per-client memory in local .fde/ files, one @fde skill. Local-only, no network.",
6
6
  "author": {
7
7
  "name": "Subash Natarajan",
8
8
  "url": "https://github.com/suboss87"
9
9
  },
10
- "homepage": "https://fdeops.io/",
10
+ "homepage": "https://github.com/suboss87/fdeops",
11
11
  "repository": "https://github.com/suboss87/FDEOps",
12
12
  "license": "MIT",
13
13
  "keywords": [