okf 1.13.0 → 2.0.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +195 -0
  3. data/README.md +25 -6
  4. data/lib/okf/bundle/folder.rb +27 -1
  5. data/lib/okf/bundle/graph.rb +12 -3
  6. data/lib/okf/bundle/linter.rb +470 -47
  7. data/lib/okf/bundle/reader.rb +10 -4
  8. data/lib/okf/bundle/references.rb +111 -0
  9. data/lib/okf/bundle/row_filter.rb +53 -0
  10. data/lib/okf/bundle/search.rb +20 -2
  11. data/lib/okf/bundle/validator/result.rb +6 -3
  12. data/lib/okf/bundle/validator.rb +267 -26
  13. data/lib/okf/bundle/writer.rb +1 -1
  14. data/lib/okf/bundle.rb +105 -4
  15. data/lib/okf/cli/catalog.rb +1 -1
  16. data/lib/okf/cli/command.rb +27 -8
  17. data/lib/okf/cli/files.rb +1 -1
  18. data/lib/okf/cli/index.rb +1 -1
  19. data/lib/okf/cli/lint.rb +70 -12
  20. data/lib/okf/cli/references.rb +97 -0
  21. data/lib/okf/cli/search.rb +2 -1
  22. data/lib/okf/cli/stats.rb +3 -39
  23. data/lib/okf/cli/tags.rb +6 -43
  24. data/lib/okf/cli/types.rb +1 -1
  25. data/lib/okf/cli/validate.rb +3 -3
  26. data/lib/okf/cli.rb +4 -1
  27. data/lib/okf/concept.rb +362 -10
  28. data/lib/okf/markdown/citations.rb +41 -4
  29. data/lib/okf/markdown/frontmatter.rb +1 -1
  30. data/lib/okf/markdown/links.rb +67 -7
  31. data/lib/okf/render/graph/template.html.erb +173 -41
  32. data/lib/okf/render/graph.rb +11 -3
  33. data/lib/okf/server/app.rb +47 -15
  34. data/lib/okf/server/hub.rb +1 -1
  35. data/lib/okf/skill/SKILL.md +14 -12
  36. data/lib/okf/skill/playbooks/curate.md +8 -3
  37. data/lib/okf/skill/playbooks/doctor.md +3 -1
  38. data/lib/okf/skill/playbooks/maintain.md +7 -6
  39. data/lib/okf/skill/playbooks/menu.md +5 -4
  40. data/lib/okf/skill/playbooks/migrate.md +31 -8
  41. data/lib/okf/skill/playbooks/produce.md +16 -9
  42. data/lib/okf/skill/playbooks/search.md +2 -2
  43. data/lib/okf/skill/reference/SPEC.md +739 -187
  44. data/lib/okf/skill/reference/authoring.md +154 -35
  45. data/lib/okf/skill/reference/cli.md +155 -42
  46. data/lib/okf/skill/templates/attested-computation.md +41 -0
  47. data/lib/okf/skill/templates/concept.md +13 -6
  48. data/lib/okf/skill/templates/root-index.md +1 -1
  49. data/lib/okf/version.rb +1 -1
  50. data/lib/okf.rb +22 -2
  51. metadata +5 -1
@@ -8,7 +8,7 @@
8
8
  <meta property="og:site_name" content="OKF">
9
9
  <meta property="og:title" content="<%= og_title %>">
10
10
  <meta property="og:description" content="<%= og_desc %>">
11
- <meta property="og:image" content="https://okfgem.com/og-demo-v3.png">
11
+ <meta property="og:image" content="https://okfgem.com/og-demo-v5.png">
12
12
  <meta property="og:image:type" content="image/png">
13
13
  <meta property="og:image:width" content="1200">
14
14
  <meta property="og:image:height" content="630">
@@ -16,7 +16,7 @@
16
16
  <meta name="twitter:card" content="summary_large_image">
17
17
  <meta name="twitter:title" content="<%= og_title %>">
18
18
  <meta name="twitter:description" content="<%= og_desc %>">
19
- <meta name="twitter:image" content="https://okfgem.com/og-demo-v3.png">
19
+ <meta name="twitter:image" content="https://okfgem.com/og-demo-v5.png">
20
20
  <meta name="twitter:image:alt" content="An interactive Open Knowledge Format knowledge graph.">
21
21
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='24' fill='%231a1a1a'/><polygon points='38,44 62,44 50,82' fill='%237a0a1e'/><polygon points='18,44 38,44 50,82' fill='%23a8112c'/><polygon points='62,44 82,44 50,82' fill='%23a8112c'/><polygon points='35,28 18,44 38,44' fill='%23dc1e3c'/><polygon points='65,28 82,44 62,44' fill='%23dc1e3c'/><polygon points='35,28 65,28 62,44 38,44' fill='%23f43f5e'/><polygon points='36,29 49,29 42,42' fill='%23fff' opacity='.38'/><polygon points='35,28 65,28 82,44 50,82 18,44' fill='none' stroke='%23ff6b7f' stroke-width='2' stroke-linejoin='round'/></svg>">
22
22
  <script>/* Resolve theme before first paint so there is no flash. */
@@ -246,7 +246,31 @@
246
246
  background:var(--line-2);border:1px solid var(--line);border-radius:7px;padding:3px 9px}
247
247
  .badge .dot{width:8px;height:8px;border-radius:50%}
248
248
  .status{font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;padding:2px 7px;border-radius:5px;color:var(--muted);background:var(--line-2)}
249
+ /* §5.4's three, plus `shipped` — which this project used before the spec named
250
+ a vocabulary, and which stays styled because §4.1 lets a producer use any
251
+ value and requires a consumer to tolerate it. `stable` gets no rule because
252
+ it is never rendered: an absent status defaults to it, so badging it would
253
+ put a meaningless chip on every concept of every bundle. */
254
+ .status.draft{color:var(--warn);background:color-mix(in srgb,var(--warn) 14%,transparent)}
255
+ .status.deprecated{color:var(--accent-ink);background:color-mix(in srgb,var(--accent) 12%,transparent)}
249
256
  .status.shipped{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent)}
257
+ /* §5.3's trust tiers — the third visual channel, after type colour and status.
258
+ Deliberately quieter than status: how much to believe a concept is a question
259
+ a reader asks *of* the card, where a deprecation is the card shouting. */
260
+ .tier{font-size:10.5px;font-weight:600;letter-spacing:.02em;padding:2px 7px;border-radius:5px;
261
+ color:var(--muted);background:var(--line-2);border:1px solid var(--line)}
262
+ .tier.machine-confirmed{color:var(--accent-ink);background:color-mix(in srgb,var(--accent) 12%,transparent);border-color:transparent}
263
+ .tier.human-reviewed{color:var(--ok);background:color-mix(in srgb,var(--ok) 14%,transparent);border-color:transparent}
264
+ /* Past the expiry its own author declared (§5.5), judged against the viewer's
265
+ clock. Warn-coloured wherever it appears — a card's meta row, the trust line. */
266
+ .stale{color:var(--warn)}
267
+ /* The inspector's trust line: the chips under a concept's description. */
268
+ .meta-trust{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:8px}
269
+ /* The two text members of the line, as opposed to the .tier/.status badges
270
+ that carry their own size. `.stale` has only a colour globally, so
271
+ without this it rendered at the panel's inherited size beside them. */
272
+ .meta-trust .gen,.meta-trust .stale{font-size:11.5px}
273
+ .meta-trust .gen{color:var(--faint)}
250
274
  .tag{background:var(--line-2);border:1px solid var(--line);border-radius:6px;padding:2px 8px;font-size:11px;color:var(--muted)}
251
275
 
252
276
  /* ── graph view ── */
@@ -1034,6 +1058,12 @@
1034
1058
  <div class="fgroup"><h4>Types</h4><div class="fchips" id="cat-ftypes"></div></div>
1035
1059
  <div class="fgroup"><h4>Dirs</h4><div class="fchips" id="cat-fdirs"></div></div>
1036
1060
  <div class="fgroup"><h4>Tags</h4><div class="fchips" id="cat-ftags"></div></div>
1061
+ <!-- §5.3/§5.4. Counted off the catalog rather than a fixed vocabulary,
1062
+ and hidden entirely when the bundle says nothing about either — a
1063
+ v0.1 bundle's whole trust posture is not worth a row of chips
1064
+ reading "unverified 9". -->
1065
+ <div class="fgroup" id="cat-fstatus-group" hidden><h4>Status</h4><div class="fchips" id="cat-fstatus"></div></div>
1066
+ <div class="fgroup" id="cat-ftrust-group" hidden><h4>Trust</h4><div class="fchips" id="cat-ftrust"></div></div>
1037
1067
  </div>
1038
1068
  </aside>
1039
1069
  </section>
@@ -1170,7 +1200,7 @@
1170
1200
  view switching full-text search
1171
1201
  catalog files
1172
1202
  tags stats
1173
- theme / fullscreen §6 map payload · §7 log payload
1203
+ theme / fullscreen §8 map payload · §9 log payload
1174
1204
  command palette the search bridge
1175
1205
  concept preview (touch) mobile drawer + sheet
1176
1206
  keyboard deep links · first visit
@@ -1224,18 +1254,22 @@ const SEARCH_ENDPOINT=<%= search_endpoint_json %>;
1224
1254
  cannot use — and the panel reads that null to explain itself. */
1225
1255
  const MANAGE_ROOT=<%= manage_root_json %>, MANAGE_TOKEN=<%= manage_token_json %>;
1226
1256
  /* null when served live (`okf server`) — the getters below fetch the endpoints
1227
- above. `okf render` injects a payload {catalog,index,logs,bodies}, and every
1228
- getter resolves from it instead (the /node/meta fragment derived from the
1229
- catalog), so one file needs no server and the endpoint consts above go inert. */
1257
+ above. `okf render` injects a payload {catalog,index,logs,bodies,sources}, and every
1258
+ getter resolves from it instead (the /node/meta trust line composed from the
1259
+ catalog row), so one file needs no server and the endpoint consts above go inert. */
1230
1260
  const EMBED=<%= embed_json %>;
1231
1261
  /* Full-text search state (MiniSearch, lazy — built on first search). One ranked
1232
1262
  index behind the search box for the graph, catalog and files views. It indexes
1233
- title/id/type/tags/description in every mode, plus each concept body wherever
1234
- the page already holds it: `okf render` bakes every body in, so a static file
1235
- searches bodies offline; the live server keeps bodies lazy, so its index stays
1236
- metadata-only until a backend body index arrives. Same 7.2.0 build and config
1237
- as the Ruby port, so a Ruby-built index and this one rank identically. */
1238
- const FT_FIELDS=['title','id','type','tags','description'].concat(EMBED?['body']:[]);
1263
+ title/id/type/tags/description in every mode, plus each concept body and its
1264
+ source text wherever the page already holds them: `okf render` bakes both in,
1265
+ so a static file searches them offline; the live server keeps bodies lazy and
1266
+ carries only a source *count* on a catalog row, so its index stays
1267
+ metadata-only until a backend body index arrives. The asymmetry is
1268
+ pre-existing and deliberate — spending body-sized bytes on every /catalog
1269
+ fetch to serve one view is the trade the payload already refuses for `body`
1270
+ (see .okf/capabilities/search.md). Same 7.2.0 build and config as the Ruby
1271
+ port, so a Ruby-built index and this one rank identically. */
1272
+ const FT_FIELDS=['title','id','type','tags','description'].concat(EMBED?['body','sources']:[]);
1239
1273
  let _MiniSearch=null,ftIndex=null,ftBuilding=null;
1240
1274
  const descOf={};
1241
1275
  const MIN=<%= OKF::Render::Graph::MIN_SIZE %>, MAX=<%= OKF::Render::Graph::MAX_SIZE %>;
@@ -1522,7 +1556,7 @@ interceptMdLinks(sideBody,()=>shownId,select,r=>r.kind==='index'?showDir(r.dir):
1522
1556
  interceptMdLinks(document.getElementById('fp-body'),()=>fileSel,id=>openFile(id),
1523
1557
  r=>openReserved(r.kind,r.kind==='index'?ixPathOf(r.dir):r.path));
1524
1558
  /* Clicking a folder node (tree mode) or an area box (cluster mode) opens that
1525
- directory's §6 index entry in the inspector: the authored map when one
1559
+ directory's §8 index entry in the inspector: the authored map when one
1526
1560
  exists, the synthesized listing when not. */
1527
1561
  function showDir(dir){getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dir);if(!d)return;
1528
1562
  const opened=openPanel();sideBody.scrollTop=0;
@@ -1541,19 +1575,86 @@ function showDir(dir){getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dir);i
1541
1575
  function showLog(path){LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);if(!l)return;
1542
1576
  const opened=openPanel();sideBody.scrollTop=0;
1543
1577
  shownId=path.replace(/\.md$/,'');
1544
- sideBody.innerHTML=`<span class="type">update log · §7</span><h2 class="title">${esc(path)}</h2><div class="body" id="dir-body"></div>`;
1578
+ sideBody.innerHTML=`<span class="type">update log · §9</span><h2 class="title">${esc(path)}</h2><div class="body" id="dir-body"></div>`;
1545
1579
  renderMarkdown(document.getElementById('dir-body'),l.content||'');
1546
1580
  if(opened)requestAnimationFrame(()=>cy.resize());});}
1581
+ /* ── §5 trust: one composition, both modes ──
1582
+ The catalog row carries generated_at/generated_by, the raw `generated`
1583
+ boolean, the derived `trust` tier (hyphenated, the wire spelling), the
1584
+ declared `status` and `stale_after` — so every view can say how much to
1585
+ believe a concept. Three rules, shared by the cards and the trust line:
1586
+
1587
+ · status — only a NON-default one. §5.4 defaults an absent status to
1588
+ `stable`, so badging that would chip every concept everywhere.
1589
+ · trust — suppressed when unverified AND `generated` is undeclared: on a
1590
+ well-curated v0.1 bundle every concept has a lifted timestamp,
1591
+ so keying off generated_at would label every card "unverified".
1592
+ The raw boolean is the one predicate that gets this right.
1593
+ · expired — computed HERE, against the viewer's own today. A baked verdict
1594
+ is wrong from the next midnight and a static render lives for
1595
+ months; the page compares dates the way the CLI does. */
1596
+ const TODAY=()=>{const d=new Date();return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');};
1597
+ const isExpired=sa=>!!sa&&/^\d{4}-\d{2}-\d{2}$/.test(sa)&&TODAY()>=sa;
1598
+ /* One fold for every status keying — chips, facets, suppression — because the
1599
+ row carries the DECLARED value and the CLI folds case when it narrows: a
1600
+ `status: Stable` bundle otherwise grew duplicate facet chips filtering
1601
+ disjoint sets, and "Stable" cards wore a chip the default never earns. The
1602
+ raw spelling still renders as the chip's text; only keys fold. */
1603
+ const foldStatus=v=>{const s=String(v==null?'':v).trim().toLowerCase();return s||'stable';};
1604
+ /* The folded value is a filter key, not a class name: §4.1 lets a producer
1605
+ declare `In Review`, which folded straight into the attribute became
1606
+ `class="status in review"` — two classes a stylesheet may already own. One
1607
+ token for the class, the raw spelling still the chip's text. */
1608
+ const statusSlug=v=>foldStatus(v).replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'')||'stable';
1609
+ const statusChip=c=>foldStatus(c.status)!=='stable'?`<span class="status ${esc(statusSlug(c.status))}">${esc(c.status)}</span>`:'';
1610
+ /* An `unverified` tier is an answer, not an absence — but only once a concept
1611
+ declared `generated:`, since a v0.1 concept declaring neither would wear a
1612
+ chip about a family it never had. The catalog's trust facet gates on this
1613
+ same predicate: a tier shown on every card with no group to narrow on was
1614
+ the shape it took when the two were spelled separately.
1615
+
1616
+ The rule's home is Ruby — Concept.shows_trust?, which the server's
1617
+ /node/meta and okf-tui both ask. This is the client-side twin, unavoidable
1618
+ because it runs over baked rows with no Ruby to call; a change to one is a
1619
+ change to both, and render_test.rb pins them equal so the copy cannot drift
1620
+ silently. */
1621
+ const showsTrust=c=>!!(c.trust&&!(c.trust==='unverified'&&!c.generated));
1622
+ const trustChip=c=>showsTrust(c)?`<span class="tier ${esc(c.trust)}">${esc(c.trust)}</span>`:'';
1623
+ /* No `by` means v0.1 lifted this from a `timestamp`, which recorded no actor.
1624
+ Printing one would be the false provenance claim §5 exists to prevent. */
1625
+ const genChip=c=>c.generated_at?`<span class="mini">⌂ ${esc(c.generated_at)}${c.generated_by?' by '+esc(c.generated_by):''}</span>`:'';
1547
1626
  /* Two per-concept reads shared by the inspector and the files preview: the raw
1548
- markdown body (rendered client-side) and the description fragment. Live they
1549
- hit /node and /node/meta; under EMBED the body resolves from the baked payload
1550
- and the meta fragment is derived from the baked catalog escaped here the same
1551
- way /node/meta escapes it so the description lives in exactly one place.
1552
- Never memoized in server mode, so on-disk edits keep reflecting. */
1553
- const EMBED_DESC=EMBED?Object.fromEntries((EMBED.catalog||[]).map(c=>[c.id,c.description||''])):null;
1554
- const metaFragment=d=>(d==null||String(d).trim()==='')?'<span class="empty">no description</span>':esc(d);
1627
+ markdown body (rendered client-side) and the meta object {description,trust}.
1628
+ Live they hit /node and /node/meta (JSON); under EMBED both resolve from the
1629
+ baked payload — the trust fields read off the same catalog row the cards use,
1630
+ so the two modes compose one line from one ruleset. Never memoized in server
1631
+ mode, so on-disk edits keep reflecting. */
1632
+ const EMBED_ROW=EMBED?Object.fromEntries((EMBED.catalog||[]).map(c=>[c.id,c])):null;
1633
+ const rowTrust=c=>{if(!c)return null;const t={};
1634
+ if(showsTrust(c))t.tier=c.trust;
1635
+ if(c.generated_by)t.generated_by=c.generated_by;
1636
+ if(c.generated_at)t.generated_at=c.generated_at;
1637
+ if(c.status)t.status=c.status;
1638
+ if(c.stale_after)t.stale_after=c.stale_after;
1639
+ return Object.keys(t).length?t:null;};
1640
+ const metaFromRow=c=>{const m={description:(c&&c.description)||''};const t=rowTrust(c);if(t)m.trust=t;return m;};
1641
+ /* The one place meta reaches the DOM. Every producer-supplied string lands via
1642
+ textContent — never innerHTML — so no render path opens outside the
1643
+ sanitizer the bodies already pass through. */
1644
+ function renderMeta(el,meta){el.textContent='';
1645
+ const desc=String((meta&&meta.description)||'').trim();
1646
+ if(desc)el.appendChild(document.createTextNode(desc));
1647
+ else{const e=document.createElement('span');e.className='empty';e.textContent='no description';el.appendChild(e);}
1648
+ const t=meta&&meta.trust;if(!t)return;
1649
+ const line=document.createElement('span');line.className='meta-trust';
1650
+ const chip=(cls,text)=>{const s=document.createElement('span');s.className=cls;s.textContent=text;line.appendChild(s);};
1651
+ if(t.tier)chip('tier '+t.tier,t.tier);
1652
+ if(t.generated_at)chip('gen',t.generated_at+(t.generated_by?' by '+t.generated_by:''));
1653
+ if(foldStatus(t.status)!=='stable')chip('status '+statusSlug(t.status),t.status);
1654
+ if(isExpired(t.stale_after))chip('stale','expired '+t.stale_after);
1655
+ if(line.childNodes.length)el.appendChild(line);}
1555
1656
  function getNodeBody(id){return EMBED?Promise.resolve(EMBED.bodies[id]||''):fetch(NODE_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
1556
- function getNodeMeta(id){return EMBED?Promise.resolve(metaFragment(EMBED_DESC[id])):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.text():'');}
1657
+ function getNodeMeta(id){return EMBED?Promise.resolve(metaFromRow(EMBED_ROW[id])):fetch(META_ENDPOINT+'?id='+encodeURIComponent(id)).then(r=>r.ok?r.json():{});}
1557
1658
  function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
1558
1659
  shownId=id;const opened=openPanel();sideBody.scrollTop=0;
1559
1660
  sideBody.innerHTML=`<button type="button" class="type facet" data-focus-type="${esc(ty)}" title="Show only ${esc(ty)} in the graph"><span class="dot" style="background:${c}"></span>${esc(ty)}</button>
@@ -1571,7 +1672,7 @@ function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Unty
1571
1672
  sideBody.querySelectorAll('[data-focus-tag]').forEach(b=>b.onclick=()=>{
1572
1673
  const t=b.getAttribute('data-focus-tag');tagFocused(t)?clearGraphFilter():focusGraphTag(t);});
1573
1674
  syncFacets();
1574
- getNodeMeta(id).then(h=>{const d=document.getElementById('desc');if(d){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
1675
+ getNodeMeta(id).then(m=>{const d=document.getElementById('desc');if(d){renderMeta(d,m);d.classList.remove('loading');}}).catch(()=>{});
1575
1676
  const bodyEl=sideBody.querySelector('#body');
1576
1677
  getNodeBody(id).then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});
1577
1678
  return opened;}
@@ -1928,7 +2029,7 @@ function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false
1928
2029
  runLayout(layoutSel.value);}}
1929
2030
  btnTree.onclick=()=>setTree(!treeMode);
1930
2031
  /* ── the authored layer, drawn ──
1931
- The §6 map used to be visible only inside file-tree mode, where a folder node
2032
+ The §8 map used to be visible only inside file-tree mode, where a folder node
1932
2033
  stood in for a directory's index.md. It is a layer of its own now: switch it
1933
2034
  on under any layout and each `index.md` becomes a node, edged to the concepts
1934
2035
  it maps and to the child maps beneath it. Cluster and tree mode still work —
@@ -2209,8 +2310,8 @@ document.querySelectorAll('.rail-item').forEach(b=>b.onclick=()=>{
2209
2310
  function loadMiniSearch(){return _MiniSearch||(_MiniSearch=loadScript('https://cdn.jsdelivr.net/npm/minisearch@7.2.0/dist/umd/index.js').then(()=>window.MiniSearch));}
2210
2311
  function buildFtIndex(){return ftIndex?Promise.resolve(ftIndex):(ftBuilding||(ftBuilding=Promise.all([loadMiniSearch(),getCatalog()]).then(([MiniSearch,list])=>{
2211
2312
  const idx=new MiniSearch({idField:'id',fields:FT_FIELDS,storeFields:['id'],
2212
- extractField:(doc,f)=>f==='tags'?(doc.tags||[]).join(' '):f==='body'?((EMBED&&EMBED.bodies[doc.id])||''):(doc[f]==null?'':String(doc[f])),
2213
- searchOptions:{prefix:true,fuzzy:0.2,combineWith:'AND',boost:{title:5,id:4,tags:3,type:2,description:2,body:1}}});
2313
+ extractField:(doc,f)=>f==='tags'?(doc.tags||[]).join(' '):f==='body'?((EMBED&&EMBED.bodies[doc.id])||''):f==='sources'?((EMBED&&EMBED.sources[doc.id])||''):(doc[f]==null?'':String(doc[f])),
2314
+ searchOptions:{prefix:true,fuzzy:0.2,combineWith:'AND',boost:{title:5,id:4,tags:3,type:2,description:2,sources:1,body:1}}});
2214
2315
  idx.addAll(list);ftIndex=idx;
2215
2316
  if((view==='graph'||view==='catalog'||view==='files')&&(q[view]||'').trim())applySearch();
2216
2317
  return idx;}).catch(()=>{ftBuilding=null;return null;})));}
@@ -2242,11 +2343,30 @@ let CATALOG=null;
2242
2343
  otherwise) and fills descOf on the way, so even the pre-index substring
2243
2344
  fallback can match a graph node by its leaf description. */
2244
2345
  function getCatalog(){return CATALOG||(CATALOG=(EMBED?Promise.resolve(EMBED.catalog):fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts)).then(list=>{list.forEach(c=>{descOf[c.id]=c.description||'';});return list;}));}
2245
- const catActiveTypes=new Set(), catActiveDirs=new Set(), catActiveTags=new Set();
2346
+ const catActiveTypes=new Set(), catActiveDirs=new Set(), catActiveTags=new Set(),
2347
+ catActiveStatus=new Set(), catActiveTrust=new Set();
2348
+ /* The two §5 facets, counted off the catalog — it is the only payload that
2349
+ knows them, so they cannot be built at boot like types and tags. Status
2350
+ counts the EFFECTIVE value (absent reads stable, the same rule --status
2351
+ narrows by); trust counts the derived tier. A facet with nothing to say
2352
+ stays hidden: the status group needs at least one DECLARED status, and the
2353
+ trust group at least one concept whose tier the page is willing to claim —
2354
+ otherwise every v0.1 bundle would wear a row of chips saying only
2355
+ "unverified". `showsTrust` is that willingness, and all three of the gate,
2356
+ the counts and the narrowing read it, so the facet describes exactly the
2357
+ cards wearing a tier chip. Counting rows the page shows no chip for made it
2358
+ read "unverified 3" over two chipped cards, and narrow to three. */
2359
+ let catStatusCounts={}, catTrustCounts={}, catShowStatus=false, catShowTrust=false;
2360
+ function catFacets(list){catStatusCounts={};catTrustCounts={};catShowTrust=false;
2361
+ list.forEach(c=>{const s=foldStatus(c.status);catStatusCounts[s]=(catStatusCounts[s]||0)+1;
2362
+ if(showsTrust(c)){catTrustCounts[c.trust]=(catTrustCounts[c.trust]||0)+1;catShowTrust=true;}});
2363
+ catShowStatus=list.some(c=>c.status);}
2364
+ const facetByCount=counts=>Object.keys(counts).sort((a,b)=>counts[b]-counts[a]||a.localeCompare(b));
2365
+ const facetItems=counts=>facetByCount(counts).map(v=>({v:v,n:counts[v]}));
2246
2366
  const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
2247
2367
  const catChip=t=>`<span class="chip" data-t="${esc(t)}"><span class="dot" style="background:${color[t]}"></span>${esc(t)} <span class="c">${(TYPES[t]||[]).length}</span></span>`;
2248
2368
  function syncCatChips(){document.querySelectorAll('#cat-types .chip').forEach(c=>c.classList.toggle('on',catActiveTypes.has(c.getAttribute('data-t'))));
2249
- const n=catActiveTypes.size+catActiveDirs.size+catActiveTags.size;
2369
+ const n=catActiveTypes.size+catActiveDirs.size+catActiveTags.size+catActiveStatus.size+catActiveTrust.size;
2250
2370
  const b=document.getElementById('cat-filters-btn');b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
2251
2371
  function catToggle(t){toggleSet(catActiveTypes,t);renderCatFilters();renderCatalog();}
2252
2372
  function wireCatChips(el){el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>catToggle(ch.getAttribute('data-t')));}
@@ -2258,6 +2378,11 @@ function renderCatFilters(){const qq=(document.getElementById('cat-filter-search
2258
2378
  chipRow(document.getElementById('cat-ftypes'),typeItems(catByCount.filter(match)),'data-t',t=>catActiveTypes.has(t),t=>{toggleSet(catActiveTypes,t);after();});
2259
2379
  chipRow(document.getElementById('cat-fdirs'),dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>catActiveDirs.has(d),d=>{toggleSet(catActiveDirs,d);after();});
2260
2380
  chipRow(document.getElementById('cat-ftags'),tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>catActiveTags.has(t),t=>{toggleSet(catActiveTags,t);after();});
2381
+ const sItems=facetItems(catStatusCounts).filter(o=>match(o.v)), tItems=facetItems(catTrustCounts).filter(o=>match(o.v));
2382
+ document.getElementById('cat-fstatus-group').hidden=!(catShowStatus&&sItems.length);
2383
+ document.getElementById('cat-ftrust-group').hidden=!(catShowTrust&&tItems.length);
2384
+ chipRow(document.getElementById('cat-fstatus'),sItems,'data-status',s=>catActiveStatus.has(s),s=>{toggleSet(catActiveStatus,s);after();});
2385
+ chipRow(document.getElementById('cat-ftrust'),tItems,'data-trust',t=>catActiveTrust.has(t),t=>{toggleSet(catActiveTrust,t);after();});
2261
2386
  syncCatChips();}
2262
2387
  function initCatalog(){inited.catalog=true;
2263
2388
  // Only the five most common types get inline chips; everything else — types,
@@ -2268,13 +2393,18 @@ function initCatalog(){inited.catalog=true;
2268
2393
  const cf=document.getElementById('cat-filters');
2269
2394
  document.getElementById('cat-filters-btn').onclick=()=>cf.classList.toggle('open');
2270
2395
  document.getElementById('cat-filters-close').onclick=()=>cf.classList.remove('open');
2271
- document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveDirs.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
2396
+ document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveDirs.clear();catActiveTags.clear();
2397
+ catActiveStatus.clear();catActiveTrust.clear();renderCatFilters();renderCatalog();};
2272
2398
  document.getElementById('cat-filter-search').oninput=renderCatFilters;
2273
- getCatalog().then(renderCatalog);}
2399
+ // The §5 facets only exist once the catalog is in, so the panel renders
2400
+ // twice: the chips boot knows immediately, then the two that had to wait.
2401
+ getCatalog().then(list=>{catFacets(list);renderCatFilters();renderCatalog();});}
2274
2402
  function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();const ids=ftMatch(q.catalog);
2275
2403
  const rows=list.filter(c=>{if(catActiveTypes.size&&!catActiveTypes.has(c.type))return false;
2276
2404
  if(catActiveDirs.size&&![...catActiveDirs].some(d=>underDir(dirOf(c.id),d)))return false;
2277
2405
  if(catActiveTags.size&&!c.tags.some(t=>catActiveTags.has(t)))return false;
2406
+ if(catActiveStatus.size&&!catActiveStatus.has(foldStatus(c.status)))return false;
2407
+ if(catActiveTrust.size&&!(showsTrust(c)&&catActiveTrust.has(c.trust)))return false;
2278
2408
  if(ids)return ids.has(c.id);
2279
2409
  if(s){const hay=(c.title+' '+c.description+' '+c.type+' '+c.tags.join(' ')+' '+c.id).toLowerCase();if(!hay.includes(s))return false;}return true;});
2280
2410
  document.getElementById('cat-cnt').textContent=rows.length+' of '+list.length+' concepts';
@@ -2284,14 +2414,16 @@ function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.
2284
2414
  const g=document.getElementById('cat-grid');
2285
2415
  if(!rows.length){g.innerHTML='<div class="none">No concepts match — try clearing the filters.</div>';return;}
2286
2416
  g.innerHTML=rows.map(c=>{const cc=color[c.type]||'#64748b';
2287
- const st=c.status?`<span class="status ${c.status==='shipped'?'shipped':''}">${esc(c.status)}</span>`:'';
2417
+ const st=statusChip(c);
2418
+ const tr=trustChip(c);
2288
2419
  const tags=c.tags.slice(0,4).map(t=>`<span class="tag">${esc(t)}</span>`).join('');
2289
- const ts=c.timestamp?`<span class="mini">⌂ ${esc(c.timestamp)}</span>`:'';
2420
+ const ts=genChip(c);
2421
+ const sa=isExpired(c.stale_after)?`<span class="mini stale">⚠ expired ${esc(c.stale_after)}</span>`:'';
2290
2422
  const lk=(c.links_out+c.links_in)?`<span class="mini links">↳ ${c.links_out+c.links_in} links</span>`:'';
2291
2423
  return `<article class="card" tabindex="0" data-id="${esc(c.id)}">
2292
- <div class="r1"><span class="badge"><span class="dot" style="background:${cc}"></span>${esc(c.type)}</span>${st}<span class="area">${esc(c.dir)}/</span></div>
2424
+ <div class="r1"><span class="badge"><span class="dot" style="background:${cc}"></span>${esc(c.type)}</span>${st}${tr}<span class="area">${esc(c.dir)}/</span></div>
2293
2425
  <h3>${esc(c.title)}</h3><p class="cd">${esc(c.description)||'<span class="empty">No description.</span>'}</p>
2294
- <div class="cm">${tags}${ts}${lk}</div></article>`;}).join('');
2426
+ <div class="cm">${tags}${ts}${sa}${lk}</div></article>`;}).join('');
2295
2427
  g.querySelectorAll('.card').forEach(el=>{const id=el.dataset.id;el.onclick=()=>goToGraph(id);
2296
2428
  el.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();goToGraph(id);}};});});}
2297
2429
 
@@ -2502,11 +2634,11 @@ function openReserved(kind,path){fileSel=path;
2502
2634
  const body=document.getElementById('fp-body');body.innerHTML='<p class="loading">loading…</p>';
2503
2635
  const done=txt=>{const wrap=document.createElement('div');wrap.className='body';renderMarkdown(wrap,txt);body.innerHTML='';body.appendChild(wrap);};
2504
2636
  if(kind==='index')getIndex().then(dirs=>{const d=dirs.find(x=>x.dir===dirOfPath(path));
2505
- type.textContent=d&&d.synthesized?'directory index · synthesized':'directory index · §6';
2637
+ type.textContent=d&&d.synthesized?'directory index · synthesized':'directory index · §8';
2506
2638
  if(d&&d.synthesized){body.innerHTML='<div class="body">'+listingHtml(d.listing)+'</div>';
2507
2639
  body.querySelectorAll('[data-go]').forEach(x=>x.onclick=()=>openFile(x.getAttribute('data-go')));}
2508
2640
  else done((d&&d.body)||'');});
2509
- else{type.textContent='update log · §7';
2641
+ else{type.textContent='update log · §9';
2510
2642
  LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);done((l&&l.content)||'');});}}
2511
2643
 
2512
2644
  /* ── tags ── */
@@ -2584,13 +2716,13 @@ const fsOn=()=>document.fullscreenElement===appEl;
2584
2716
  btnFull.onclick=()=>{if(fsOn())document.exitFullscreen();else if(appEl.requestFullscreen)appEl.requestFullscreen();};
2585
2717
  document.addEventListener('fullscreenchange',()=>btnFull.setAttribute('aria-pressed',String(fsOn())));
2586
2718
 
2587
- /* ── the §6 map payload — feeds folder clicks, the Indexes tab, and previews ── */
2719
+ /* ── the §8 map payload — feeds folder clicks, the Indexes tab, and previews ── */
2588
2720
  let INDEX=null;
2589
2721
  function getIndex(){return INDEX||(INDEX=EMBED?Promise.resolve(EMBED.index):fetch(INDEX_ENDPOINT).then(r=>r.json()).then(d=>d.directories));}
2590
2722
  function listingHtml(items){if(!items||!items.length)return '<p class="empty" style="margin:10px 0 0">No concepts directly here.</p>';
2591
2723
  return '<ul class="ix-listing">'+items.map(it=>`<li><a data-go="${esc(it.id)}">${esc(it.title||it.id)}</a>${it.description?` <span class="d">— ${esc(it.description)}</span>`:''}</li>`).join('')+'</ul>';}
2592
2724
 
2593
- /* ── log payload (the §7 history) — read by the Files view's log entries ── */
2725
+ /* ── log payload (the §9 history) — read by the Files view's log entries ── */
2594
2726
  let LOGS=null;
2595
2727
  function getLogs(){return LOGS||(LOGS=EMBED?Promise.resolve(EMBED.logs):fetch(LOG_ENDPOINT).then(r=>r.json()).then(d=>d.logs));}
2596
2728
 
@@ -3215,8 +3347,8 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
3215
3347
  '<p class="pv-meta">'+out+' link'+(out===1?'':'s')+' out · '+inn+' in</p>';
3216
3348
  bodyIn.innerHTML=relList('Links to',outL[id]||[])+relList('Linked from',inL[id]||[])+'<div class="body" id="pv-md"></div>';
3217
3349
  bodyIn.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
3218
- getNodeMeta(id).then(h=>{const d=document.getElementById('pv-desc');
3219
- if(d&&shown===id){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
3350
+ getNodeMeta(id).then(m=>{const d=document.getElementById('pv-desc');
3351
+ if(d&&shown===id){renderMeta(d,m);d.classList.remove('loading');}}).catch(()=>{});
3220
3352
  if(snap!=='peek')loadBody();
3221
3353
  return true;}
3222
3354
  /* lazy: the body is markdown nobody can see at peek, and on a phone peek is the
@@ -3245,7 +3377,7 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
3245
3377
  bodyIn.querySelectorAll('[data-go]').forEach(a=>a.onclick=()=>select(a.getAttribute('data-go')));
3246
3378
  raise();}).catch(function(){});}
3247
3379
  function fillLog(path){LOGS=null;getLogs().then(function(logs){const l=logs.find(x=>x.path===path);if(!l)return;
3248
- renderMarkdown(fillHead('update log · §7',path,'the §7 history'),l.content||'');
3380
+ renderMarkdown(fillHead('update log · §9',path,'the §9 history'),l.content||'');
3249
3381
  raise();}).catch(function(){});}
3250
3382
 
3251
3383
  /* ---- the camera -------------------------------------------------------- */
@@ -55,14 +55,22 @@ module OKF
55
55
  # answer. Every key here is data a client getter reads from EMBED instead of
56
56
  # fetching — and each derives from the *same* folder method the matching
57
57
  # OKF::Server::App endpoint uses, so the bake and the live server cannot
58
- # drift (/node/meta is the exception: the fragment is derived on the client
59
- # from the catalog's raw description, so no map is baked for it).
58
+ # drift (/node/meta is the exception: the trust line is composed on the
59
+ # client from the catalog row, so no map is baked for it). `bodies` and
60
+ # `sources` have no bare endpoint at all — they exist so a static file
61
+ # searches offline what the server-mode index deliberately leaves out; the
62
+ # sources text is the same join the Ruby engines index, so `--engine index`
63
+ # and the baked page rank identically. Empty strings are baked too: an
64
+ # undefined getter throws client-side.
60
65
  def self.payload(folder)
61
66
  {
62
67
  catalog: folder.catalog,
63
68
  index: folder.directory_index,
64
69
  logs: folder.log_entries,
65
- bodies: folder.concepts.each_with_object({}) { |concept, map| map[concept.id] = concept.body.to_s }
70
+ bodies: folder.concepts.each_with_object({}) { |concept, map| map[concept.id] = concept.body.to_s },
71
+ sources: folder.concepts.each_with_object({}) do |concept, map|
72
+ map[concept.id] = OKF::Bundle::Search.field_texts(concept)["sources"]
73
+ end
66
74
  }
67
75
  end
68
76
 
@@ -18,16 +18,23 @@ module OKF
18
18
  #
19
19
  # GET / the interactive graph page (text/html)
20
20
  # GET /node?id=… the concept's raw markdown body (text/markdown)
21
- # GET /node/meta?id=… its description, as an escaped HTML fragment
21
+ # GET /node/meta?id=… its description and null-stripped §5 trust fields —
22
+ # { description, trust: { tier, generated_by,
23
+ # generated_at, status, stale_after } } (JSON). The
24
+ # page composes the trust line client-side, once, for
25
+ # served and baked pages alike; expiry is the client's
26
+ # to compute, against the viewer's own today.
22
27
  # GET /catalog rich per-concept metadata for the catalog/files/stats
23
28
  # views: { concepts: [ {id, title, type, description,
24
- # tags, timestamp, status, top_dir, dir, links_*} ] } (JSON)
29
+ # tags, generated_at, generated_by, generated, trust,
30
+ # status, stale_after, sources, top_dir, dir,
31
+ # links_*} ] } (JSON)
25
32
  # GET /tags the tag index { tag => [id, …] } (JSON)
26
33
  # GET /types the type index { type => [id, …] } (JSON)
27
- # GET /index the §6 progressive-disclosure map for the Index panel:
34
+ # GET /index the §8 progressive-disclosure map for the Index panel:
28
35
  # { directories: [ …okf-index rows… ] } (JSON, from the
29
36
  # boot snapshot — authored maps are structure)
30
- # GET /log the §7 history for the Log panel: { logs: [ {path,
37
+ # GET /log the §9 history for the Log panel: { logs: [ {path,
31
38
  # dir, content} ] } (JSON; content read live from disk,
32
39
  # like a body — the log is the file that changes most)
33
40
  # GET /search?q=… ranked concepts in this bundle, for the ⌘K palette:
@@ -143,14 +150,14 @@ module OKF
143
150
  { concepts: @folder.catalog }
144
151
  end
145
152
 
146
- # The §6 map the Index panel renders — the same rows `okf index` prints,
153
+ # The §8 map the Index panel renders — the same rows `okf index` prints,
147
154
  # built by the pure OKF::Bundle#directory_index over the boot snapshot
148
155
  # (authored index bodies are structure, read at load like the graph).
149
156
  def directory_index
150
157
  { directories: @folder.directory_index }
151
158
  end
152
159
 
153
- # The §7 history the Log panel renders: every log.md with its content, root
160
+ # The §9 history the Log panel renders: every log.md with its content, root
154
161
  # scope first, read live from disk. Built by OKF::Bundle::Folder#log_entries,
155
162
  # shared with `okf render`'s bake so the served and baked logs cannot drift.
156
163
  def logs
@@ -181,11 +188,19 @@ module OKF
181
188
  respond("text/markdown; charset=utf-8", concept.body)
182
189
  end
183
190
 
191
+ # JSON, not a fragment: a server-composed HTML line would be a second
192
+ # render path reaching innerHTML outside DOMPurify — the exact hole the
193
+ # self-contained-page rule names — and would need a byte-identical JS twin
194
+ # for the baked page anyway. The client owns the one composition, and
195
+ # every string here lands via textContent there.
184
196
  def node_meta(id)
185
197
  concept = concept_for(id)
186
198
  return not_found if concept.nil?
187
199
 
188
- respond("text/html; charset=utf-8", description_fragment(concept))
200
+ payload = { "description" => concept.description.to_s }
201
+ trust = trust_fields(concept)
202
+ payload["trust"] = trust unless trust.empty?
203
+ respond_json(payload)
189
204
  end
190
205
 
191
206
  # Resolve an id to its concept, read live from disk. The id is only ever a key
@@ -200,11 +215,32 @@ module OKF
200
215
  nil
201
216
  end
202
217
 
203
- def description_fragment(concept)
204
- description = concept.description.to_s
205
- return %(<span class="empty">no description</span>) if description.strip.empty?
218
+ # Null-stripped, and absent as a whole when a concept says nothing about
219
+ # its own provenance — a v0.1 concept that adopted no §5 family gets the
220
+ # panel it always had. The tier is skipped for unverified-and-undeclared
221
+ # through Concept#shows_trust?, the one predicate the card chip and the
222
+ # page's facets read too: an untouched v0.1 bundle must not read
223
+ # "unverified" on every panel. No expiry verdict is baked (the client
224
+ # compares dates with the viewer's today), and no actor is invented for a
225
+ # lifted timestamp.
226
+ def trust_fields(concept)
227
+ fields = {}
228
+ fields["tier"] = concept.trust if concept.shows_trust?
229
+ fields["generated_by"] = concept.generated_by
230
+ fields["generated_at"] = iso(concept.generated_at)
231
+ # The row's serialization, not the raw Psych value: `status: no` reads
232
+ # as false, blank?(false) is true, and stripping it here while the
233
+ # baked page reads the row's "false" split the served and baked
234
+ # inspectors over one concept.
235
+ fields["status"] = concept.declared_status&.to_s
236
+ fields["stale_after"] = iso(concept.stale_after)
237
+ fields.reject { |_, value| OKF.blank?(value) }
238
+ end
206
239
 
207
- html_escape(description)
240
+ # The one temporal-serialization rule the catalog row keeps — shared, not
241
+ # mirrored, so /node/meta and the row cannot drift (see OKF.iso8601).
242
+ def iso(value)
243
+ OKF.iso8601(value)
208
244
  end
209
245
 
210
246
  def respond(content_type, body)
@@ -225,10 +261,6 @@ module OKF
225
261
  def not_found
226
262
  self.class.not_found
227
263
  end
228
-
229
- def html_escape(str)
230
- Rack::Utils.escape_html(str.to_s)
231
- end
232
264
  end
233
265
  end
234
266
  end
@@ -599,7 +599,7 @@ module OKF
599
599
  end
600
600
 
601
601
  # ok / warn / error, with the word that carries the same message for a
602
- # reader who cannot see the colour. validate and lint stay separate (§9):
602
+ # reader who cannot see the colour. validate and lint stay separate (§11):
603
603
  # a curation finding is a warning and never a conformance error, so a thin
604
604
  # bundle keeps its link and only a non-conformant one reads as broken.
605
605
  # Memoised like #counts — every stray 404 renders a bundle list too, and
@@ -35,13 +35,13 @@ Two ideas govern everything:
35
35
  declare a graph; it arises from how you link concepts. Good linking *is* good
36
36
  knowledge modelling.
37
37
 
38
- ## The hard rules (§9 conformance)
38
+ ## The hard rules (§11 conformance)
39
39
 
40
40
  Three conditions, all hard — `validate` fails a bundle on any of them:
41
41
 
42
- 1. **§9.1** every non-reserved `.md` file has a parseable YAML frontmatter block;
43
- 2. **§9.2** every such block has a **non-empty `type`**;
44
- 3. **§9.3** every reserved file present is well-formed — a nested `index.md` has
42
+ 1. **§11 cond. 1** every non-reserved `.md` file has a parseable YAML frontmatter block;
43
+ 2. **§11 cond. 2** every such block has a **non-empty `type`**;
44
+ 3. **§11 cond. 3** every reserved file present is well-formed — a nested `index.md` has
45
45
  no frontmatter, the bundle-root `index.md` carries *only* `okf_version`, and
46
46
  `log.md` date headings are ISO `YYYY-MM-DD`.
47
47
 
@@ -55,11 +55,11 @@ most common mistake:
55
55
 
56
56
  | Lens | Question | Tool | Nature |
57
57
  |-----------|-----------------------------------|-------------------------|---------------------------|
58
- | **Legal** | Is it conformant OKF? (§9) | `validate` | Binary, tolerant |
58
+ | **Legal** | Is it conformant OKF? (§11) | `validate` | Binary, tolerant |
59
59
  | **Good** | Is it navigable, complete, fresh? | `lint` | Advisory, structural |
60
60
  | **True** | Is it consistent and *current*? | *you*, over `lint --json` | Semantic — needs meaning |
61
61
 
62
- `validate` is *forbidden* by §9 from failing a bundle for broken links or missing
62
+ `validate` is *forbidden* by §11 from failing a bundle for broken links or missing
63
63
  optional fields — that is `lint`'s job. And neither tool can judge contradictions
64
64
  or *semantic* staleness (a concept that parses fine but no longer matches
65
65
  reality); only an agent reasoning over meaning can. That last lens is where you
@@ -81,7 +81,7 @@ read and act on, never a missing toolchain to send to doctor.
81
81
  Don't memorize the surface — `okf --help` maps every verb, `okf <verb> --help` its
82
82
  flags. The division of labour is the whole game:
83
83
 
84
- - **Shell out — never eyeball —** anything a verb computes: conformance (§9), what
84
+ - **Shell out — never eyeball —** anything a verb computes: conformance (§11), what
85
85
  exists, what links where, where a term lives, what's stale, the map. Every read
86
86
  verb takes `--json` and the list views filter by type/dir/tag, so ask the narrow
87
87
  question instead of paging the bundle.
@@ -92,9 +92,11 @@ flags. The division of labour is the whole game:
92
92
  (parses fine, no longer true), whether a loose file is terminal-by-design, whether
93
93
  a singleton tag is a deliberate marker. Tool output is evidence, never a verdict.
94
94
 
95
- The one trap worth carrying in your head: **freshness is off by default** — a plain
96
- `okf lint` never reports stale concepts; pass `--stale-after <90d|12w|ISO-date>`
97
- when the bundle carries timestamps. <!-- check:stale -->
95
+ The one trap worth carrying in your head: **the age cutoff is off by default** —
96
+ a plain `okf lint` reports concepts past their own declared `stale_after` (the
97
+ `expired` check reads the clock the CLI supplies), but never judges *age*; pass
98
+ `--stale-after <90d|12w|ISO-date>` when you want anything not touched since then
99
+ flagged too. <!-- check:stale -->
98
100
 
99
101
  Read [cli.md](reference/cli.md) before *interpreting* a verb's output in depth:
100
102
  what `validate` may and may not reject, lint's categories and check ids, the JSON
@@ -105,8 +107,8 @@ shapes, the tag-curation views, the server's trust boundary.
105
107
  Picking up a bundle you don't already know — to consume or maintain — start with
106
108
  `okf dirs <dir|@slug>`: one row per *directory*, so it stays small on a bundle of
107
109
  any size and it names the branches every other view narrows to. Then open the one
108
- you want with `okf index <dir|@slug> --dir <branch>` (the §6 map: that directory's
109
- index body, rollups, and listing), and read `log.md` (the §7 baseline of what
110
+ you want with `okf index <dir|@slug> --dir <branch>` (the §8 map: that directory's
111
+ index body, rollups, and listing), and read `log.md` (the §9 baseline of what
110
112
  changed last) — all of it **before** greping or opening leaves. Reach for `index`
111
113
  rather than grep for the one reason that outranks convenience: **grep cannot find
112
114
  an index entry that is missing**, so enumeration drift is invisible to it — you
@@ -20,11 +20,16 @@ mis-homed hubs, an uncurated tag layer — that is [refine](refine.md).
20
20
  `okf loose <root> --json`.
21
21
  3. Interpret through the three lenses the okf skill teaches, and keep them
22
22
  separate:
23
- - conformance errors (§9) are the only hard failures; fix them first,
23
+ - conformance errors (§11) are the only hard failures; fix them first,
24
24
  always;
25
25
  - lint findings are curation debt across reachability, backlog,
26
- completeness, freshness, provenance, and hygiene. They are advisory;
27
- rank them by how much each hurts a reader navigating the graph;
26
+ completeness, freshness, provenance, attestation, migration, and hygiene.
27
+ They are advisory; rank them by how much each hurts a reader navigating
28
+ the graph. Read the stats' `trust` and `status` distributions for the
29
+ bundle's posture (how much is unverified, what is draft or deprecated),
30
+ and treat the Migration findings as an offer, not debt — §13 keeps a v0.1
31
+ bundle consumable forever, and the [migrate playbook](migrate.md) carries
32
+ the rewrite when the user wants it;
28
33
  - loose files can be legitimate terminal leaves, so judge each one before
29
34
  linking it anywhere.
30
35
  4. Propose, then apply: list the fixes worth making (must-fix errors first,