[object Object]

← back to Feature Harvest

feature-harvest: add skills + agents as review sources (244+74) + officer-recommendation badges from 7-VP cabinet review

921f77f8e7c0a37f14b30b01fa0bb155ce967d81 · 2026-06-26 10:11:47 -0700 · Steve

Files touched

Diff

commit 921f77f8e7c0a37f14b30b01fa0bb155ce967d81
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 26 10:11:47 2026 -0700

    feature-harvest: add skills + agents as review sources (244+74) + officer-recommendation badges from 7-VP cabinet review
---
 build-corpus.js        |  57 ++++++
 data/officer-recs.json | 532 +++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html      |  17 +-
 server.js              |   7 +
 4 files changed, 610 insertions(+), 3 deletions(-)

diff --git a/build-corpus.js b/build-corpus.js
index 0051802..fa27ec2 100644
--- a/build-corpus.js
+++ b/build-corpus.js
@@ -150,10 +150,65 @@ function addHtmlTools() {
   return n;
 }
 
+// ---- D) skills + E) agents (the "forgotten tool" review) ----------
+function frontmatter(file) {
+  try {
+    const t = fs.readFileSync(file, 'utf8');
+    const m = t.match(/^---\n([\s\S]*?)\n---/);
+    if (!m) return {};
+    const fm = {};
+    const nm = m[1].match(/^name:\s*(.+)$/m); if (nm) fm.name = nm[1].trim();
+    const ds = m[1].match(/^description:\s*([\s\S]+?)(?:\n[a-z_]+:|\n*$)/m);
+    if (ds) fm.description = ds[1].replace(/\s+/g, ' ').trim();
+    return fm;
+  } catch { return {}; }
+}
+function addSkills() {
+  const root = path.join(HOME, '.claude/skills');
+  if (!fs.existsSync(root)) return 0;
+  let n = 0;
+  for (const name of fs.readdirSync(root)) {
+    const sk = path.join(root, name, 'SKILL.md');
+    if (!fs.existsSync(sk)) continue;
+    let st; try { st = fs.statSync(sk); } catch { continue; }
+    const fm = frontmatter(sk);
+    items.push({
+      id: `skill:${name}`, source: 'skill', title: fm.name || name, project: 'skills',
+      date: st.mtime.toISOString().slice(0, 10), ts: st.mtime.getTime(),
+      summary: (fm.description || '').slice(0, 500), value: '', commit: '',
+      path: sk, port: '', hasUI: false,
+    });
+    n++;
+  }
+  return n;
+}
+function addAgents() {
+  const root = path.join(HOME, '.claude/agents');
+  if (!fs.existsSync(root)) return 0;
+  let n = 0;
+  for (const f of fs.readdirSync(root)) {
+    if (!f.endsWith('.md')) continue;
+    const fp = path.join(root, f);
+    let st; try { st = fs.statSync(fp); if (!st.isFile()) continue; } catch { continue; }
+    const fm = frontmatter(fp);
+    items.push({
+      id: `agent:${f.replace(/\.md$/, '')}`, source: 'agent',
+      title: fm.name || f.replace(/\.md$/, ''), project: 'agents',
+      date: st.mtime.toISOString().slice(0, 10), ts: st.mtime.getTime(),
+      summary: (fm.description || '').slice(0, 500), value: '', commit: '',
+      path: fp, port: '', hasUI: false,
+    });
+    n++;
+  }
+  return n;
+}
+
 // ---- run ----------------------------------------------------------
 const nWins = addWins();
 const nDW = addDirApps(path.join(HOME, 'Projects/Designer-Wallcoverings/DW-Programming'), 'dw-app', 'DW-Programming');
 const nTools = addHtmlTools();
+const nSkills = addSkills();
+const nAgents = addAgents();
 
 // stable sort: newest first
 items.sort((a, b) => (b.ts || 0) - (a.ts || 0));
@@ -162,4 +217,6 @@ console.log(`corpus built → ${OUT}`);
 console.log(`  wins:     ${nWins}`);
 console.log(`  dw-apps:  ${nDW}`);
 console.log(`  wpb-tools:${nTools}`);
+console.log(`  skills:   ${nSkills}`);
+console.log(`  agents:   ${nAgents}`);
 console.log(`  TOTAL:    ${items.length}`);
diff --git a/data/officer-recs.json b/data/officer-recs.json
new file mode 100644
index 0000000..5c12e35
--- /dev/null
+++ b/data/officer-recs.json
@@ -0,0 +1,532 @@
+{
+  "price-finder": {
+    "name": "price-finder",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "dw-commerce"
+    ],
+    "count": 1,
+    "practice": "Confirm net cost + trade discount before any import",
+    "why": "Closes the ~30k no-cost-held SKU gap; gates publish on real cost not a guess",
+    "conflict": false
+  },
+  "pairs-well-with": {
+    "name": "pairs-well-with",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "dw-commerce"
+    ],
+    "count": 1,
+    "practice": "Every PDP gets 6 cross-sell tiles (60-30-10)",
+    "why": "Pure AOV lift on 64k active PDPs — built and unused",
+    "conflict": false
+  },
+  "competitor-of-product": {
+    "name": "competitor-of-product",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "dw-commerce"
+    ],
+    "count": 1,
+    "practice": "Per-SKU \"who else sells this MFR SKU\" web check",
+    "why": "Pricing intel: surfaces where we are undercut vs exclusive",
+    "conflict": false
+  },
+  "kravet-price-watch": {
+    "name": "kravet-price-watch",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "dw-commerce"
+    ],
+    "count": 1,
+    "practice": "Auto-ingest Kravet price-list emails into authoritative MAP",
+    "why": "Stale cost = MAP breach across ~4,500 ROLL SKUs; self-healing",
+    "conflict": false
+  },
+  "collection-creator": {
+    "name": "collection-creator",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT",
+      "ADOPT"
+    ],
+    "officers": [
+      "dw-commerce",
+      "dw-marketing"
+    ],
+    "count": 2,
+    "practice": "Catalog-driven curated collections w/ rationale",
+    "why": "SEO landing pages + campaign hooks across 160k products, dormant",
+    "conflict": false
+  },
+  "weekend-csv-products": {
+    "name": "weekend-csv-products",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "dw-commerce"
+    ],
+    "count": 1,
+    "practice": "Scheduled weekend-ordered SKU export to purchasing",
+    "why": "Faster vendor POs, fewer fulfillment-delay refunds",
+    "conflict": false
+  },
+  "architectural-update-skill": {
+    "name": "architectural-update-skill",
+    "verdict": "RETIRE",
+    "verdicts": [
+      "RETIRE"
+    ],
+    "officers": [
+      "dw-commerce"
+    ],
+    "count": 1,
+    "practice": "(de-dup paid-rebrand churn)",
+    "why": "Overlaps enrich-ai-tags + conflicts with the SEO vendor-name carve-out",
+    "conflict": false
+  },
+  "advertising-signals": {
+    "name": "advertising-signals",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE",
+      "AUTOMATE",
+      "AUTOMATE"
+    ],
+    "officers": [
+      "dw-marketing",
+      "research-content",
+      "directories"
+    ],
+    "count": 3,
+    "practice": "Weekly competitor ad-platform reconnaissance",
+    "why": "Tells DW where rivals buy ads so it stops guessing spend; zero API cost",
+    "conflict": false
+  },
+  "dw-pinterest": {
+    "name": "dw-pinterest",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "dw-marketing"
+    ],
+    "count": 1,
+    "practice": "Pinterest as primary visual-discovery funnel",
+    "why": "Highest-intent interior-design search funnel; DW has zero presence",
+    "conflict": false
+  },
+  "analytics": {
+    "name": "analytics",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE",
+      "AUTOMATE"
+    ],
+    "officers": [
+      "dw-marketing",
+      "directories"
+    ],
+    "count": 2,
+    "practice": "GA4 coverage + weekly traffic reporting across the fleet",
+    "why": "Spend/posts ship blind today — the conversion-measurement foundation",
+    "conflict": false
+  },
+  "dw-youtube": {
+    "name": "dw-youtube",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "dw-marketing"
+    ],
+    "count": 1,
+    "practice": "YouTube Shorts + room-walkthrough channel",
+    "why": "Evergreen how-to/inspiration search capture; near-zero output cost",
+    "conflict": false
+  },
+  "stampede": {
+    "name": "stampede",
+    "verdict": "RETIRE",
+    "verdicts": [
+      "RETIRE"
+    ],
+    "officers": [
+      "dw-marketing"
+    ],
+    "count": 1,
+    "practice": "(de-dup creative tooling)",
+    "why": "Overlaps four-horsemen + canvas-design; produced nothing for DW",
+    "conflict": false
+  },
+  "claude-codex-kimi": {
+    "name": "claude-codex-kimi",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "engineering"
+    ],
+    "count": 1,
+    "practice": "Multi-LLM adversarial review before merge",
+    "why": "Catches the silent-bug class single-pass review misses, ~5c/run",
+    "conflict": false
+  },
+  "secret-strip": {
+    "name": "secret-strip",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "engineering"
+    ],
+    "count": 1,
+    "practice": "Fleet-wide hardcoded-credential scrub, weekly",
+    "why": "Closes the recurring 478-file leak class (May-30 compromise)",
+    "conflict": false
+  },
+  "validation-loop": {
+    "name": "validation-loop",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "engineering"
+    ],
+    "count": 1,
+    "practice": "Re-run the review panel on POST-edit code",
+    "why": "Stops \"shipped without re-testing\" false-confidence regressions",
+    "conflict": false
+  },
+  "test-engineer": {
+    "name": "test-engineer",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "engineering"
+    ],
+    "count": 1,
+    "practice": "Tests + CI on the cadence importer + budget/refund paths",
+    "why": "The live revenue path has had repeated silent-drain/publish bugs",
+    "conflict": false
+  },
+  "compound-engineering": {
+    "name": "compound-engineering",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "engineering"
+    ],
+    "count": 1,
+    "practice": "Plan-Work-Review-Compound: codify each fix as a guardrail",
+    "why": "Turns one-off debugging into permanent velocity leverage",
+    "conflict": false
+  },
+  "debugger": {
+    "name": "debugger",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "engineering"
+    ],
+    "count": 1,
+    "practice": "Structured root-cause vs ad-hoc poking",
+    "why": "Recurring \"cost hours\" incidents are classic misdiagnoses",
+    "conflict": false
+  },
+  "codex-debate": {
+    "name": "codex-debate",
+    "verdict": "RETIRE",
+    "verdicts": [
+      "RETIRE"
+    ],
+    "officers": [
+      "engineering"
+    ],
+    "count": 1,
+    "practice": "(consolidate debate skills)",
+    "why": "Redundant with cck + dtd; four overlapping debate skills = clutter",
+    "conflict": false
+  },
+  "competitors": {
+    "name": "competitors",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "research-content"
+    ],
+    "count": 1,
+    "practice": "Name the structural differentiation vectors rivals cannot copy",
+    "why": "The artifact that makes a pitch land; unused outside SDCC",
+    "conflict": false
+  },
+  "session-debrief": {
+    "name": "session-debrief",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "research-content"
+    ],
+    "count": 1,
+    "practice": "End every session with a VC-lens recap MP4",
+    "why": "Storytelling as byproduct — highest ROI/min content skill, idle",
+    "conflict": false
+  },
+  "peer-survey": {
+    "name": "peer-survey",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "research-content"
+    ],
+    "count": 1,
+    "practice": "Screenshot N peers, steal one UX move each, before redesign",
+    "why": "Grounds design in market reality not taste",
+    "conflict": false
+  },
+  "info-hub": {
+    "name": "info-hub",
+    "verdict": "CONFLICT",
+    "verdicts": [
+      "ADOPT",
+      "RETIRE"
+    ],
+    "officers": [
+      "research-content",
+      "directories"
+    ],
+    "count": 2,
+    "practice": "Curated authoritative-source hub per vertical = trust/SEO moat",
+    "why": "Hard-to-copy content asset; built once for SDCC, never reused",
+    "conflict": true
+  },
+  "ad-social-tracker": {
+    "name": "ad-social-tracker",
+    "verdict": "CONFLICT",
+    "verdicts": [
+      "RETIRE",
+      "ADOPT"
+    ],
+    "officers": [
+      "research-content",
+      "directories"
+    ],
+    "count": 2,
+    "practice": "(consolidate to advertising-signals)",
+    "why": "Always-on directory batch version is redundant + unused",
+    "conflict": true
+  },
+  "appointment-agent": {
+    "name": "appointment-agent",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "directories"
+    ],
+    "count": 1,
+    "practice": "venturacorridor booking + calendar-connect health check",
+    "why": "Live revenue surface untouched since May; weekly liveness probe",
+    "conflict": false
+  },
+  "site-audit": {
+    "name": "site-audit",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "directories"
+    ],
+    "count": 1,
+    "practice": "SEO/a11y/dead-link + 90-day roadmap per directory front door",
+    "why": "Surfaces SEO gaps blocking inbound (only allowed growth channel)",
+    "conflict": false
+  },
+  "dw-restore-rehearsal": {
+    "name": "dw-restore-rehearsal",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "operations"
+    ],
+    "count": 1,
+    "practice": "Backups must be proven restorable, not just present",
+    "why": "A fresh dump that wont restore is worse than none; monthly drill",
+    "conflict": false
+  },
+  "secrets-env-backup": {
+    "name": "secrets-env-backup",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "operations"
+    ],
+    "count": 1,
+    "practice": "Secrets registry must have a recoverable off-box backup",
+    "why": "launchd job is exit-1 FAILING NOW — invisible until a disk loss",
+    "conflict": false
+  },
+  "machine-sync": {
+    "name": "machine-sync",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "operations"
+    ],
+    "count": 1,
+    "practice": "Mac2/Mac1/Kamatera must not silently drift apart",
+    "why": "NEVER produced a run — antidote to repeated fleet-collapse recoveries",
+    "conflict": false
+  },
+  "dw-canary-meta-watchdog": {
+    "name": "dw-canary-meta-watchdog",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "operations"
+    ],
+    "count": 1,
+    "practice": "Every canary must prove it is itself still firing",
+    "why": "The 12-day silent pg_dump death is the canonical failure",
+    "conflict": false
+  },
+  "disk-space-canary": {
+    "name": "disk-space-canary",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "operations"
+    ],
+    "count": 1,
+    "practice": "Verify-then-delete + monitor, never autonomous deletion",
+    "why": "Full-disk/forced-sleep are recurring continuity killers",
+    "conflict": false
+  },
+  "keep-alive": {
+    "name": "keep-alive",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "operations"
+    ],
+    "count": 1,
+    "practice": "pm2 online lies — restart on HTTP-dead not just process-exit",
+    "why": "Only thing that auto-revives a hung site serving 500s while pm2=green",
+    "conflict": false
+  },
+  "comms-compliance": {
+    "name": "comms-compliance",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "compliance"
+    ],
+    "count": 1,
+    "practice": "CAN-SPAM/TCPA/CCPA pre-send audit of every mailer/SMS/voice sender",
+    "why": "Only thing between an info@ mailer and a per-email fine; zero scheduled run while mailers ship live",
+    "conflict": false
+  },
+  "dw-leak-scanner": {
+    "name": "dw-leak-scanner",
+    "verdict": "AUTOMATE",
+    "verdicts": [
+      "AUTOMATE"
+    ],
+    "officers": [
+      "compliance"
+    ],
+    "count": 1,
+    "practice": "Private-label leak scan (Brewster/York/WallQuest/Command54 never front-facing)",
+    "why": "Hard-banned names + ~430 SKUs/day ship rate; tighten weekly→daily",
+    "conflict": false
+  },
+  "best-practices": {
+    "name": "best-practices",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "compliance"
+    ],
+    "count": 1,
+    "practice": "Standing-rules pre-flight before any non-trivial action",
+    "why": "Codifies every MEMORY.md rule whose skip caused real incidents (285 spec-less publishes)",
+    "conflict": false
+  },
+  "dw-legal-compliance": {
+    "name": "dw-legal-compliance",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "compliance"
+    ],
+    "count": 1,
+    "practice": "Trademark/contract/description legal review for vendor copy",
+    "why": "Should gate every vendor-landing + microsite description build; on-demand only today",
+    "conflict": false
+  },
+  "settlement": {
+    "name": "settlement",
+    "verdict": "ADOPT",
+    "verdicts": [
+      "ADOPT"
+    ],
+    "officers": [
+      "compliance"
+    ],
+    "count": 1,
+    "practice": "Pre-generation legal gate on ALL pattern creation",
+    "why": "Court-binding; only wired into 3 pipelines — any path skipping it is a breach vector",
+    "conflict": false
+  }
+}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 3b11962..4263b34 100644
--- a/public/index.html
+++ b/public/index.html
@@ -59,6 +59,14 @@
   .src.win{background:rgba(52,165,244,.16);color:var(--win)}
   .src.dw-app{background:rgba(224,169,59,.16);color:var(--app)}
   .src.wpb-tool{background:rgba(176,124,240,.16);color:var(--tool)}
+  .src.skill{background:rgba(61,220,151,.16);color:#3ddc97}
+  .src.agent{background:rgba(240,140,90,.16);color:#f08c5a}
+  /* officer recommendation badge */
+  .rec{display:inline-flex;gap:6px;align-items:center;margin:10px 0 0;padding:8px 11px;border-radius:9px;font-size:13px;
+    border-left:3px solid var(--gold);background:rgba(202,164,90,.08)}
+  .rec b{color:var(--gold)} .rec .v{font-weight:800;font-size:11px;padding:1px 7px;border-radius:5px;background:var(--gold);color:#1a1407}
+  .rec .v.RETIRE{background:var(--skip);color:#fff}
+  .mini .recdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--gold);margin-left:5px}
   .when{color:var(--mut);font-size:12px;margin-left:auto;white-space:nowrap}
   .verdict-tag{font-size:11px;font-weight:800;padding:2px 8px;border-radius:6px}
   .vt-dw{background:rgba(202,164,90,.18);color:var(--dw)}
@@ -131,6 +139,8 @@
       <option value="win">Shipped wins</option>
       <option value="dw-app">DW apps</option>
       <option value="wpb-tool">WPB tools</option>
+      <option value="skill">Skills</option>
+      <option value="agent">Agents</option>
     </select>
     <select id="fStatus">
       <option value="unreviewed">Unreviewed</option>
@@ -164,7 +174,7 @@
 <script>
 const $ = (s,r=document)=>r.querySelector(s);
 const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
-const SRC_LABEL = {win:'Shipped win', 'dw-app':'DW app', 'wpb-tool':'WPB tool'};
+const SRC_LABEL = {win:'Shipped win', 'dw-app':'DW app', 'wpb-tool':'WPB tool', skill:'Skill', agent:'Agent'};
 const DEC = {dw:'→ DW', wpb:'→ WPB', both:'Both', skip:'Skip'};
 let ITEMS=[], V={}, qi=0;
 const state = {
@@ -290,6 +300,7 @@ function cardHTML(it, big){
   const path = it.path ? `<div class="pathline">📁 <code>${esc(it.path)}</code>${it.port?` · port ${esc(it.port)}`:''}${it.hasUI?' · has UI':''}</div>` : '';
   const commit = it.commit ? ` · <code>${esc(it.commit)}</code>` : '';
   const val = it.value ? `<div class="value"><b>Value:</b> ${esc(it.value)}</div>` : '';
+  const rec = it.rec ? `<div class="rec"><span class="v ${it.rec.conflict?'RETIRE':''}">${it.rec.conflict?('CONFLICT: '+it.rec.verdicts.join(' / ')):it.rec.verdict}</span> <b>${it.rec.count} officer${it.rec.count>1?'s':''}</b> (${esc(it.rec.officers.join(', '))}) · ${esc(it.rec.practice)} — ${esc(it.rec.why)}</div>` : '';
   return `
   <div class="card" data-id="${esc(it.id)}">
     ${previewHTML(it)}
@@ -302,7 +313,7 @@ function cardHTML(it, big){
       <h2>${esc(it.title)}</h2>
       <div class="meta">${esc(it.project||'')}${commit}</div>
       <div class="summary">${esc(it.summary||'')||'<span class="hint">no description captured</span>'}</div>
-      ${val}${path}
+      ${val}${rec}${path}
     </div>
     <div class="verdict">
       <div class="vbtn dw ${v&&v.decision==='dw'?'on':''}" data-d="dw">→ DW <span class="k">D</span></div>
@@ -366,7 +377,7 @@ function renderBoard(){
 function miniHTML(it){
   return `<div class="mini" data-id="${esc(it.id)}" title="${esc(it.title)}">
     ${previewHTML(it)}
-    <div class="mt">${esc(it.title)}</div>
+    <div class="mt">${esc(it.title)}${it.rec?'<span class="recdot" title="officer pick"></span>':''}</div>
     <div class="ms"><span class="src ${it.source}" style="font-size:9px;padding:1px 5px">${(SRC_LABEL[it.source]||it.source)}</span> ${esc(it.project||'')}</div>
   </div>`;
 }
diff --git a/server.js b/server.js
index d7fb748..954e9ea 100644
--- a/server.js
+++ b/server.js
@@ -17,6 +17,7 @@ const DATA = path.join(ROOT, 'data');
 const SNAP = path.join(DATA, 'wins-snapshot.json');
 const ITEMS = path.join(DATA, 'items.json');
 const VERDICTS = path.join(DATA, 'verdicts.json');
+const RECS = path.join(DATA, 'officer-recs.json');
 const THUMBS = path.join(DATA, 'thumbs');
 const THUMB_MANIFEST = path.join(THUMBS, 'manifest.json');
 const CNCP_WINS = 'http://127.0.0.1:3333/api/wins';
@@ -59,9 +60,15 @@ function loadWins() { return readJSON(SNAP, []); }
 function loadItems() {
   const items = readJSON(ITEMS, loadWins());
   const man = readJSON(THUMB_MANIFEST, {});
+  const recs = readJSON(RECS, {});
   for (const it of items) {
     const m = man[it.id];
     it.thumb = m && m.ok ? m.file : null;   // null → UI shows gradient poster
+    if (it.source === 'skill' || it.source === 'agent') {
+      const key = (it.title || '').toLowerCase();
+      const idkey = (it.id.split(':')[1] || '').toLowerCase();
+      it.rec = recs[key] || recs[idkey] || null;   // officer recommendation, if any
+    }
   }
   return items;
 }

← bd3f8ae feature-harvest: handle /favicon.ico (204) — silences consol  ·  back to Feature Harvest  ·  (newest)