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 CHANGED
@@ -7,7 +7,7 @@
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
8
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
9
9
 
10
- **Memory + methodology + skills, in one kit.** Skill packs teach your AI agent how to build. None of them remember who the client is, what was decided, or what's safe to ship. FDEOps adds the missing layer: a private fieldbook per engagement (`.fde/`), a field methodology (land → close), and one `@fde` skill that routes it all.
10
+ **Memory + methodology + skills, in one kit.** Skill packs - BMAD, Spec-Kit, [mattpocock/skills](https://github.com/mattpocock/skills) - teach your AI agent how to *build*. None of them remember who the client is, what you promised them, or who agreed it was delivered. FDEOps adds the missing layer: a private fieldbook per engagement (`.fde/`), a field methodology (land → close), and one `@fde` skill that routes it all.
11
11
 
12
12
  Built for Forward Deployed Engineers, and anyone embedded in client work: consultants, agency developers, solutions architects, fractional CTOs. Feels like a second brain; behaves like a defensible record (dated, sourced, yours).
13
13
 
@@ -40,19 +40,35 @@ Same client folder every time (`~/fde-engagements/<client>/.fde/`). Your AI codi
40
40
 
41
41
  ---
42
42
 
43
- ## Quickstart
44
-
45
- **1. Install** (pick one)
43
+ ## See it in 60 seconds (no client, no install)
46
44
 
47
45
  ```bash
48
- npx skills add suboss87/fdeops # Cursor, Codex, and skills-compatible agents
46
+ npx fdeops demo
49
47
  ```
50
48
 
49
+ Walks one fake engagement end to end with the **real** commands: messy kickoff notes → proposed updates → you confirm → cold session reload → meeting prep → dated receipts → the fieldbook page. Nothing of yours is read, nothing leaves the machine, and it lives in a throwaway `~/fde-engagements/.demo/` that never appears in your portfolio. Remove it with `npx fdeops demo --clean`.
50
+
51
+ Or watch it first. One real session below - day 1 kickoff notes, the next morning's cold start, and "when did we agree to that?" six weeks later. Every line is the CLI's own output; only the typing pace is staged, and you can re-record it yourself with [`media/record-session.sh`](media/record-session.sh):
52
+
53
+ <p align="center"><img alt="A real fdeops session: messy kickoff notes routed into dated memory after you confirm, then a cold session that already knows the client, a grounded sponsor-meeting brief, and dated receipts" src="media/session.gif" width="900" /></p>
54
+
55
+ Note the two things a chat window cannot do: **nothing is written until you confirm the routing**, and the `<private>` block in those notes lands sealed on disk as `(private - redacted)` - it never appears in `resume`, `prep`, `receipts`, or the dashboard.
56
+
57
+ ---
58
+
59
+ ## Quickstart
60
+
61
+ **1. Install.** Claude Code gets session hooks (context loads before you type); everywhere else the same fieldbook loads when you ask.
62
+
51
63
  ```text
52
64
  /plugin marketplace add suboss87/fdeops # Claude Code
53
65
  /plugin install fdeops@fdeops
54
66
  ```
55
67
 
68
+ ```bash
69
+ npx skills add suboss87/fdeops # Cursor, Codex, and skills-compatible agents
70
+ ```
71
+
56
72
  **2. Bind once** - inside the client workspace (setup only; not a daily habit):
57
73
 
58
74
  ```bash
@@ -73,13 +89,15 @@ npx fdeops resume # prints a short "where we are" for this clien
73
89
 
74
90
  `@fde` routes and updates the fieldbook - you confirm judgment. Full workflow: [docs/USAGE.md](docs/USAGE.md).
75
91
 
92
+ **It's working if** `npx fdeops resume` prints this client's phase, trust signal, and next action - and tomorrow's session starts from that instead of a blank chat.
93
+
76
94
  <details>
77
95
  <summary><strong>Other install paths</strong> · scan · env</summary>
78
96
 
79
97
  - **Cursor / Codex / Copilot / Gemini CLI:** `npx fdeops adapters .` - [adapters/](adapters/README.md)
80
98
  - **Local LLMs (Ollama, LM Studio, llama.cpp):** load `skills/fde/SKILL.md` as the system prompt - [guide](adapters/LOCAL-LLM.md)
81
99
  - **Manual / air-gapped:** `git clone https://github.com/suboss87/fdeops.git && cd fdeops && node bin/install.js`
82
- - **Try without install:** `npx fdeops scan` - day-1 recon (heuristic leads, not findings)
100
+ - **Try without install:** `npx fdeops demo` - the whole loop on a fake client · `npx fdeops scan` - day-1 recon of this repo (heuristic leads, not findings)
83
101
  - **Requires:** [Node.js](https://nodejs.org) >= 18 for the CLI and adapters
84
102
  - **Advanced:** `FDEOPS_ENGAGEMENT` overrides the workspace registry. Full matrix: [docs/install.md](docs/install.md)
85
103
 
@@ -126,6 +144,25 @@ Overlays for regulated domains (AI, fintech, healthcare, government) activate on
126
144
 
127
145
  ---
128
146
 
147
+ ## The field methods
148
+
149
+ You never pick one - you describe the situation and `@fde` routes. They are listed here because the methodology is the product, and it should be readable before you install anything. **37 methods across 6 domains**, each one a method (the thinking, the artifact it drafts, the checkpoint with you), not advice:
150
+
151
+ | Domain | Methods |
152
+ |--------|---------|
153
+ | **1. Embed & Trust** - first days, access, credibility | [land](skills/fde/references/land.md) · [audit](skills/fde/references/audit.md) · [stakeholder-radar](skills/fde/references/stakeholder-radar.md) · [trust-engineering](skills/fde/references/trust-engineering.md) · [scope-defense](skills/fde/references/scope-defense.md) |
154
+ | **2. Discover & Diagnose** - find the real problem | [discover](skills/fde/references/discover.md) · [assumption-audit](skills/fde/references/assumption-audit.md) · [use-case-scoring](skills/fde/references/use-case-scoring.md) · [sketch](skills/fde/references/sketch.md) |
155
+ | **3. Plan & Align** - sequence it, sell it | [plan](skills/fde/references/plan.md) · [business-case](skills/fde/references/business-case.md) · [options-analysis](skills/fde/references/options-analysis.md) · [initiative-triage](skills/fde/references/initiative-triage.md) |
156
+ | **4. Build & Guard** - the work, and not breaking their business | [build](skills/fde/references/build.md) · [incremental-build](skills/fde/references/incremental-build.md) · [test-on-legacy](skills/fde/references/test-on-legacy.md) · [blast-radius](skills/fde/references/blast-radius.md) · [debug](skills/fde/references/debug.md) · [rescue](skills/fde/references/rescue.md) · [security-audit](skills/fde/references/security-audit.md) · [observability](skills/fde/references/observability.md) |
157
+ | **5. Ship & Verify** - production, with a way back | [ship](skills/fde/references/ship.md) · [review](skills/fde/references/review.md) · [rollback-drill](skills/fde/references/rollback-drill.md) · [qa-live](skills/fde/references/qa-live.md) |
158
+ | **6. Operate & Close** - the part that decides renewals | [status](skills/fde/references/status.md) · [demo-prep](skills/fde/references/demo-prep.md) · [debrief](skills/fde/references/debrief.md) · [exec-narrative](skills/fde/references/exec-narrative.md) · [dashboard](skills/fde/references/dashboard.md) · [multi-customer-ops](skills/fde/references/multi-customer-ops.md) · [close](skills/fde/references/close.md) · [handoff-engineering](skills/fde/references/handoff-engineering.md) · [pattern-extract](skills/fde/references/pattern-extract.md) · [red-team](skills/fde/references/red-team.md) · [ingest](skills/fde/references/ingest.md) · [ingest-connect](skills/fde/references/ingest-connect.md) |
159
+
160
+ Plus five overlays that activate on signal rather than being chosen - [ai](skills/fde/references/ai.md) · [artifacts](skills/fde/references/artifacts.md) · [fintech](skills/fde/references/fintech.md) · [healthcare](skills/fde/references/healthcare.md) · [gov](skills/fde/references/gov.md) - which add the checks that domain demands to whatever method is already running.
161
+
162
+ Every method, with the exact phrases that route to it: **[docs/skills-reference.md](docs/skills-reference.md)**.
163
+
164
+ ---
165
+
129
166
  ## Engagement memory (`.fde/`)
130
167
 
131
168
  The **fieldbook** is the system of record for the embed - one folder per client, plain markdown you can read, grep, and take with you:
@@ -62,7 +62,7 @@ The model reads SKILL.md, routes to the right skill, and produces artifacts in y
62
62
 
63
63
  ## Model size recommendations
64
64
 
65
- The methodology is detailed (35 skills, routing logic, evidence format, memory contract). Larger models handle it better:
65
+ The methodology is detailed (37 methods, routing logic, evidence format, memory contract). Larger models handle it better:
66
66
 
67
67
  | Model class | Experience |
68
68
  |-------------|-----------|
@@ -31,4 +31,4 @@ Defaults to the current directory if no path is given. Existing files are never
31
31
 
32
32
  ## The principle
33
33
 
34
- The adapter only tells the tool **where the brain is and how to behave**. All the method - the 35 skills, the overlays, the memory contract - lives once in `skills/fde/SKILL.md`. Update the brain, every platform gets it. That's why fdeops feels native in whatever the FDE already uses, without five things to keep in sync.
34
+ The adapter only tells the tool **where the brain is and how to behave**. All the method - the 37 methods, the overlays, the memory contract - lives once in `skills/fde/SKILL.md`. Update the brain, every platform gets it. That's why fdeops feels native in whatever the FDE already uses, without five things to keep in sync.
package/bin/check.js CHANGED
@@ -76,6 +76,29 @@ for (const f of requiredReferences) {
76
76
  }
77
77
  ok('phase references')
78
78
 
79
+ // A method reference teaches by showing one engagement, not by describing a shape:
80
+ // every judgment-heavy reference carries a worked example that names the memory
81
+ // file the work lands in. Prose-only guidance drifts into advice nobody can apply.
82
+ const exampleReferences = [
83
+ 'land.md', 'discover.md', 'plan.md', 'build.md', 'ship.md', 'close.md',
84
+ 'status.md', 'stakeholder-radar.md', 'options-analysis.md', 'business-case.md',
85
+ 'assumption-audit.md', 'scope-defense.md',
86
+ ]
87
+ for (const f of exampleReferences) {
88
+ const p = path.join(root, 'skills', 'fde', 'references', f)
89
+ if (!fs.existsSync(p)) { fail(`missing method reference skills/fde/references/${f}`); continue }
90
+ const body = fs.readFileSync(p, 'utf8')
91
+ const section = (body.match(/\n## Worked example\n([\s\S]*?)(?=\n## |$)/) || [])[1]
92
+ if (!section) {
93
+ fail(`references/${f} missing ## Worked example`)
94
+ } else if (section.trim().length < 400) {
95
+ fail(`references/${f} worked example is too thin to teach anything`)
96
+ } else if (!/`[a-z-]+\.md`/.test(section)) {
97
+ fail(`references/${f} worked example never names the memory file the work lands in`)
98
+ }
99
+ }
100
+ ok(`worked examples (${exampleReferences.length} references)`)
101
+
79
102
  const router = read('skills/fde/SKILL.md')
80
103
  if (!router.includes('memory contract')) fail('SKILL.md must define the memory contract')
81
104
  // every references/<name>.md the router mentions must exist
@@ -88,6 +111,94 @@ for (const refFile of mentioned) {
88
111
  }
89
112
  ok(`router dispatch (${mentioned.length} reference targets verified) + memory contract`)
90
113
 
114
+ // Public claims must match the router. The docs advertise a method count and a
115
+ // per-domain list; both drifted from SKILL.md once (ingest / ingest-connect
116
+ // routed but undocumented), and a number nobody can verify is worse than none.
117
+ {
118
+ // Every routing row must parse. A row this misses is a method that could go
119
+ // undocumented for free, so an unparsed row is a hard failure, not a silent skip.
120
+ const routed = new Set()
121
+ const routing = (router.split('## Routing - 6 domains')[1] || '').split('**Overlays')[0]
122
+ const methodCell = line => (line.split('|')[2] || '').trim().replace(/\s*\([^)]*\)\s*$/, '')
123
+ for (const line of routing.split('\n')) {
124
+ if (!/^\|/.test(line)) continue
125
+ // A row that names a method but no reference would route that method while
126
+ // nothing requires anyone to document it - shape checks can only police rows
127
+ // they recognise, so name a method here and you must name its reference.
128
+ if (!/references\/[a-z0-9-]+\.md/.test(line)) {
129
+ const orphan = methodCell(line)
130
+ // a method name, not a `-` placeholder (CLI-only rows) or a `---` separator
131
+ if (/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(orphan)) {
132
+ fail(`SKILL.md routes '${orphan}' without naming a reference: ${line.trim().slice(0, 80)}`)
133
+ }
134
+ continue
135
+ }
136
+ // Candidate rows are selected on the reference name in ANY form, then the
137
+ // shape is enforced - a row this cannot read must fail, never be skipped,
138
+ // or a method could go undocumented by being written unusually.
139
+ if (!/`references\/[a-z0-9-]+\.md`/.test(line)) {
140
+ fail(`SKILL.md routing row must name its reference as \`references/<name>.md\`: ${line.trim().slice(0, 80)}`)
141
+ continue
142
+ }
143
+ // | You hear | <method> | <cell mentioning references/*.md> |
144
+ const method = methodCell(line)
145
+ if (!/^[a-z0-9-]+$/.test(method)) {
146
+ fail(`check.js cannot read the method name in a SKILL.md routing row: ${line.trim().slice(0, 80)}`)
147
+ continue
148
+ }
149
+ routed.add(method)
150
+ }
151
+ if (!routed.size) fail('check.js could not parse the SKILL.md routing table')
152
+
153
+ // docs/skills-reference.md is the canonical per-method list: one row per
154
+ // method inside the six domain tables, ending at the Overlays section.
155
+ const reference = read('docs/skills-reference.md')
156
+ const documented = new Set()
157
+ let documentedRows = 0
158
+ for (const line of reference.split('### Overlays')[0].split('\n')) {
159
+ const m = line.match(/^\|\s*\[([a-z0-9-]+)\]\(\.\.\/skills\/fde\/references\/([a-z0-9-]+\.md)\)/)
160
+ if (!m) continue
161
+ documentedRows++
162
+ documented.add(m[1])
163
+ // A link nobody followed is the same unverifiable claim this gate exists for:
164
+ // the target must exist, and it must be the method the text names.
165
+ if (m[2] !== `${m[1]}.md`) {
166
+ fail(`docs/skills-reference.md links [${m[1]}] at references/${m[2]}`)
167
+ } else if (!fs.existsSync(path.join(root, 'skills', 'fde', 'references', m[2]))) {
168
+ fail(`docs/skills-reference.md links references/${m[2]}, which does not exist`)
169
+ }
170
+ }
171
+ if (documented.size !== documentedRows) {
172
+ fail(`docs/skills-reference.md lists ${documentedRows} method rows for ${documented.size} methods - a duplicate row inflates the count`)
173
+ }
174
+ const undocumented = [...routed].filter(name => !documented.has(name))
175
+ if (undocumented.length) {
176
+ fail(`SKILL.md routes skill(s) missing from docs/skills-reference.md: ${undocumented.join(', ')}`)
177
+ }
178
+ // and the other direction: a documented method nothing routes to is a method
179
+ // the agent can never reach, advertised anyway.
180
+ const unrouted = [...documented].filter(name => !routed.has(name))
181
+ if (unrouted.length) {
182
+ fail(`docs/skills-reference.md documents method(s) SKILL.md never routes to: ${unrouted.join(', ')}`)
183
+ }
184
+ for (const rel of ['docs/skills.md', 'docs/skills-reference.md']) {
185
+ const body = read(rel)
186
+ // `-` is a word boundary, so \bingest\b matches inside `ingest-connect`:
187
+ // a method could disappear from the docs behind a hyphenated sibling.
188
+ 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`)
194
+ const wrong = [...new Set(claims.filter(n => n !== documented.size))]
195
+ if (wrong.length) {
196
+ fail(`${rel} claims ${wrong.join('/')} methods; ${documented.size} are documented`)
197
+ }
198
+ }
199
+ ok(`public method count is verifiable (${documented.size} documented, ${routed.size} routed)`)
200
+ }
201
+
91
202
  const install = read('bin/install.js')
92
203
  if (install.includes('scaffoldFdeInProject(process.cwd())')) {
93
204
  fail('install.js must not auto-scaffold .fde in customer cwd')
@@ -104,8 +215,34 @@ if (read('package.json').includes('postinstall')) {
104
215
  }
105
216
 
106
217
  const readme = read('README.md')
107
- if (readme.includes('demo.gif')) fail('README must not embed demo.gif (removed for clarity)')
108
- else ok('README no demo gif')
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
+ if (!readme.includes('media/session.gif')) {
225
+ fail('README must embed media/session.gif (the recorded session is the front door)')
226
+ } else if (!readme.includes('media/record-session.sh')) {
227
+ fail('README must link media/record-session.sh next to the recording, so it can be re-recorded')
228
+ } else {
229
+ const gifPath = path.join(root, 'media', 'session.gif')
230
+ const rec = path.join(root, 'media', 'record-session.sh')
231
+ if (!fs.existsSync(gifPath) || fs.statSync(gifPath).size < 50000) fail('media/session.gif missing or too small')
232
+ else if (!fs.existsSync(rec)) fail('media/record-session.sh missing - the recording must be reproducible')
233
+ else if (!fs.existsSync(path.join(root, 'media', 'session.cast'))) fail('media/session.cast missing - keep the source recording next to the gif')
234
+ else ok('README recorded session (gif + reproducible recorder + cast)')
235
+ }
236
+
237
+ // Every repo-relative README link and image must resolve, or the front door 404s.
238
+ const brokenLinks = []
239
+ for (const m of readme.matchAll(/(?:\]\(|src=")([^)"#\s]+)(?:\)|")/g)) {
240
+ const target = m[1]
241
+ if (/^(https?:|mailto:|#|\/)/.test(target)) continue
242
+ if (!fs.existsSync(path.join(root, target))) brokenLinks.push(target)
243
+ }
244
+ if (brokenLinks.length) fail(`README links to missing paths: ${brokenLinks.join(', ')}`)
245
+ else ok('README links all resolve')
109
246
 
110
247
  for (const section of [
111
248
  'How it works',
@@ -180,7 +317,15 @@ if (!fs.existsSync(path.join(root, 'docs', 'schema.md'))) fail('docs/schema.md m
180
317
  else ok('docs/schema.md')
181
318
 
182
319
  if (!fs.existsSync(path.join(root, 'SECURITY.md'))) fail('SECURITY.md missing')
183
- else ok('SECURITY.md')
320
+ else {
321
+ // A reporting section that only offers GitHub Security Advisories is a dead end
322
+ // whenever private reporting is off for the repo (that page 403s), which is how
323
+ // issue #9 sat unreported. Require a channel that does not depend on a repo setting.
324
+ const sec = read('SECURITY.md')
325
+ if (!/[\w.+-]+@[\w-]+\.[\w.]+/.test(sec)) {
326
+ fail('SECURITY.md must give a reporting channel that works when GitHub private reporting is off (an email address)')
327
+ } else ok('SECURITY.md (reachable reporting channel)')
328
+ }
184
329
 
185
330
  const exampleFiles = ['reality.md', 'decisions.md', 'delivery.md', 'stakeholders.md', 'assumptions.md']
186
331
  for (const f of exampleFiles) {
@@ -361,6 +506,68 @@ if (!fs.existsSync(path.join(root, 'mcp', 'fdeops-ingest', 'server.js'))) {
361
506
  ok('ingest MCP + connect recipes + skill reference')
362
507
  }
363
508
 
509
+ // Agent Plugins 1.0.0 conformance (agent-plugins.org/specification).
510
+ // plugin.json has a closed schema: an illegal field is fatal to the whole plugin,
511
+ // and an mcp.json whose $schema version differs from plugin.json silently disables MCP.
512
+ const AP_VERSION = '1.0.0'
513
+ const AP_PLUGIN_SCHEMA = `https://agent-plugins.org/schemas/${AP_VERSION}/plugin.schema.json`
514
+ const AP_MCP_SCHEMA = `https://agent-plugins.org/schemas/${AP_VERSION}/mcp.schema.json`
515
+ const AP_MANIFEST_FIELDS = [
516
+ '$schema', 'name', 'version', 'description', 'author',
517
+ 'homepage', 'repository', 'license', 'keywords', 'extensions',
518
+ ]
519
+
520
+ const apManifest = JSON.parse(read('plugin.json'))
521
+ const apUnknown = Object.keys(apManifest).filter(k => !AP_MANIFEST_FIELDS.includes(k))
522
+ if (apManifest.$schema !== AP_PLUGIN_SCHEMA) {
523
+ fail(`plugin.json $schema must be ${AP_PLUGIN_SCHEMA}`)
524
+ } else if (apUnknown.length) {
525
+ fail(`plugin.json has non-portable field(s) ${apUnknown.join(', ')} - client-specific data belongs under extensions`)
526
+ } else if (!/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(apManifest.name)) {
527
+ fail(`plugin.json name "${apManifest.name}" violates Agent Plugins name constraints`)
528
+ } else if (apManifest.version !== pkg.version) {
529
+ fail(`version mismatch package.json ${pkg.version} vs plugin.json ${apManifest.version}`)
530
+ } else ok('agent plugins manifest')
531
+
532
+ const apMcp = JSON.parse(read('mcp.json'))
533
+ const apServers = apMcp.mcpServers || {}
534
+ if (apMcp.$schema !== AP_MCP_SCHEMA) {
535
+ fail(`mcp.json $schema must be ${AP_MCP_SCHEMA} (a version mismatch with plugin.json disables MCP)`)
536
+ } else if (Object.keys(apMcp).some(k => k !== '$schema' && k !== 'mcpServers')) {
537
+ fail('mcp.json permits only $schema and mcpServers')
538
+ } else {
539
+ for (const [name, srv] of Object.entries(apServers)) {
540
+ if (srv.type !== 'stdio') fail(`mcp.json ${name}: fdeops ships stdio servers only (local-only core)`)
541
+ else if (!srv.command || (/[\s/]/.test(srv.command) && !srv.command.startsWith('./'))) {
542
+ fail(`mcp.json ${name}: command must be one executable token or a ./-relative path`)
543
+ } else if ((srv.args || []).some(a => path.isAbsolute(a))) {
544
+ fail(`mcp.json ${name}: args must use \${PLUGIN_ROOT}, never an absolute path`)
545
+ } else if (Object.keys(srv.env || {}).some(k => k === 'PLUGIN_ROOT' || k === 'PLUGIN_DATA')) {
546
+ fail(`mcp.json ${name}: env must not set PLUGIN_ROOT/PLUGIN_DATA (client-supplied)`)
547
+ }
548
+ }
549
+ const ingest = apServers['fdeops-ingest']
550
+ const rootArg = ingest && (ingest.args || []).find(a => String(a).startsWith('${PLUGIN_ROOT}/'))
551
+ const target = rootArg ? String(rootArg).replace('${PLUGIN_ROOT}/', '') : ''
552
+ if (!ingest) fail('mcp.json must declare the fdeops-ingest stdio server')
553
+ else if (!rootArg) fail('mcp.json fdeops-ingest: args must include a ${PLUGIN_ROOT}-relative server path')
554
+ else if (!fs.existsSync(path.join(root, target))) fail(`mcp.json fdeops-ingest points at missing ${target}`)
555
+ else ok('agent plugins mcp config')
556
+ }
557
+
558
+ // A manifest that ships only in git and not in the npm tarball is the worst kind of drift.
559
+ for (const manifest of ['plugin.json', 'mcp.json']) {
560
+ if (!(pkg.files || []).includes(manifest)) fail(`package.json files must include ${manifest}`)
561
+ else ok(`${manifest} published to npm`)
562
+ }
563
+
564
+ for (const entry of fs.readdirSync(path.join(root, 'skills'))) {
565
+ const dir = path.join(root, 'skills', entry)
566
+ if (!fs.statSync(dir).isDirectory()) continue
567
+ if (!fs.existsSync(path.join(dir, 'SKILL.md'))) fail(`skills/${entry}/ has no SKILL.md - clients skip it`)
568
+ else ok(`skill ${entry} discoverable`)
569
+ }
570
+
364
571
  if (!fs.existsSync(path.join(root, '.github', 'ISSUE_TEMPLATE', 'bug_report.yml'))) {
365
572
  fail('GitHub issue template missing')
366
573
  } else ok('issue templates')