references/roadmap-template.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Maintainer Roadmap</title>
<style>
/* ============================================================================
product-roadmap — self-contained roadmap template. No external dependencies.
Theming: warm-neutral paper (light) / deep editorial (dark), OKLCH tokens.
========================================================================== */
:root{
color-scheme: light dark;
--hue: 75; /* warm-neutral base */
--acc-h: 235; /* link / primary accent */
--bg: oklch(0.992 0.004 var(--hue));
--bg-sunk: oklch(0.967 0.006 var(--hue));
--panel: oklch(0.999 0.003 var(--hue));
--line: oklch(0.90 0.008 var(--hue));
--line-soft: oklch(0.94 0.006 var(--hue));
--ink: oklch(0.27 0.012 var(--hue));
--ink-2: oklch(0.47 0.012 var(--hue));
--ink-3: oklch(0.62 0.012 var(--hue));
--acc: oklch(0.52 0.13 var(--acc-h));
--acc-soft: oklch(0.52 0.13 var(--acc-h) / 0.10);
/* semantic status hues (badge tint + ink) */
--committed: 70; --ready: 150; --inflight: 245; --deferred: var(--hue);
--pos: 150; --warn: 75; --neg: 25; --info: 245; --muted: var(--hue);
--radius: 10px;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Roboto, sans-serif;
--shadow: 0 1px 2px oklch(0.27 0.02 var(--hue) / 0.05), 0 4px 16px oklch(0.27 0.02 var(--hue) / 0.04);
}
:root[data-theme="dark"], :root:not([data-theme]) { }
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
--bg: oklch(0.165 0.008 var(--hue));
--bg-sunk: oklch(0.135 0.008 var(--hue));
--panel: oklch(0.205 0.009 var(--hue));
--line: oklch(0.30 0.010 var(--hue));
--line-soft: oklch(0.255 0.009 var(--hue));
--ink: oklch(0.92 0.008 var(--hue));
--ink-2: oklch(0.72 0.010 var(--hue));
--ink-3: oklch(0.58 0.010 var(--hue));
--acc: oklch(0.74 0.12 var(--acc-h));
--acc-soft: oklch(0.74 0.12 var(--acc-h) / 0.14);
--shadow: 0 1px 2px oklch(0 0 0 / 0.3), 0 6px 22px oklch(0 0 0 / 0.28);
}}
:root[data-theme="dark"]{
--bg: oklch(0.165 0.008 var(--hue));
--bg-sunk: oklch(0.135 0.008 var(--hue));
--panel: oklch(0.205 0.009 var(--hue));
--line: oklch(0.30 0.010 var(--hue));
--line-soft: oklch(0.255 0.009 var(--hue));
--ink: oklch(0.92 0.008 var(--hue));
--ink-2: oklch(0.72 0.010 var(--hue));
--ink-3: oklch(0.58 0.010 var(--hue));
--acc: oklch(0.74 0.12 var(--acc-h));
--acc-soft: oklch(0.74 0.12 var(--acc-h) / 0.14);
--shadow: 0 1px 2px oklch(0 0 0 / 0.3), 0 6px 22px oklch(0 0 0 / 0.28);
}
*{ box-sizing:border-box; }
html{ scroll-behavior:smooth; scroll-padding-top:5rem; }
body{
margin:0; background:var(--bg); color:var(--ink);
font-family:var(--sans); font-size:15.5px; line-height:1.6;
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
}
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
a{ color:var(--acc); text-decoration:none; }
a:hover{ text-decoration:underline; text-underline-offset:2px; }
:focus-visible{ outline:2px solid var(--acc); outline-offset:2px; border-radius:4px; }
code, .mono{ font-family:var(--mono); font-size:0.85em; }
/* ---- top bar ---- */
header.bar{
position:sticky; top:0; z-index:20; background:color-mix(in oklab, var(--bg) 86%, transparent);
backdrop-filter:saturate(1.4) blur(8px); border-bottom:1px solid var(--line);
}
.bar-in{ max-width:62rem; margin:0 auto; padding:.7rem 1.4rem; display:flex; align-items:center; gap:.9rem; flex-wrap:wrap; }
.bar .repo{ font-weight:650; letter-spacing:-.01em; }
.bar .repo a{ color:inherit; }
.bar .ver{ font-family:var(--mono); font-size:.78rem; color:var(--ink-2); background:var(--bg-sunk); border:1px solid var(--line-soft); padding:.1rem .45rem; border-radius:6px; }
.bar nav{ display:flex; gap:.15rem; margin-left:auto; flex-wrap:wrap; }
.bar nav a{ color:var(--ink-2); font-size:.84rem; padding:.32rem .55rem; border-radius:7px; }
.bar nav a:hover{ color:var(--ink); background:var(--bg-sunk); text-decoration:none; }
.tgl{ border:1px solid var(--line); background:var(--panel); color:var(--ink-2); width:30px; height:30px; border-radius:8px; cursor:pointer; display:grid; place-items:center; font-size:.95rem; }
.tgl:hover{ color:var(--ink); border-color:var(--ink-3); }
/* ---- layout ---- */
main{ max-width:62rem; margin:0 auto; padding:1.2rem 1.4rem 5rem; }
section{ margin-top:2.9rem; }
.eyebrow{ font-size:.74rem; text-transform:uppercase; letter-spacing:.09em; color:var(--ink-3); font-weight:600; margin:0 0 .55rem; }
h1.title{ font-size:1.9rem; line-height:1.15; letter-spacing:-.02em; margin:.3rem 0 .35rem; font-weight:700; }
.tagline{ color:var(--ink-2); margin:.1rem 0 0; max-width:60ch; }
h2{ font-size:1.18rem; letter-spacing:-.015em; margin:0 0 .25rem; font-weight:660; }
.sec-sub{ color:var(--ink-3); font-size:.9rem; margin:.1rem 0 1.1rem; }
.prose{ max-width:70ch; color:var(--ink-2); }
/* ---- next moves ---- */
.moves{ list-style:none; margin:1rem 0 0; padding:0; counter-reset:m; display:grid; gap:.6rem; }
.moves li{ counter-increment:m; position:relative; padding:.85rem 1rem .85rem 3rem; background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); }
.moves li::before{ content:counter(m); position:absolute; left:.85rem; top:.8rem; width:1.5rem; height:1.5rem; border-radius:50%; background:var(--acc-soft); color:var(--acc); font-size:.82rem; font-weight:700; display:grid; place-items:center; }
/* ---- product ground ---- */
.subsys{ display:grid; gap:.2rem; margin-top:.4rem; }
.sub{ border-top:1px solid var(--line-soft); padding:.85rem 0; }
.sub:first-child{ border-top:0; }
.sub-h{ display:flex; align-items:baseline; gap:.6rem; flex-wrap:wrap; }
.sub-h b{ font-weight:640; font-size:1rem; }
.caps{ list-style:none; margin:.5rem 0 0; padding:0; display:grid; gap:.3rem; }
.caps li{ display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; color:var(--ink-2); font-size:.92rem; }
.caps li b{ color:var(--ink); font-weight:560; }
.prim{ font-family:var(--mono); font-size:.74rem; color:var(--ink-3); background:var(--bg-sunk); border:1px solid var(--line-soft); padding:.05rem .4rem; border-radius:5px; white-space:nowrap; }
.entities{ margin-top:1rem; display:flex; flex-wrap:wrap; gap:.4rem; }
.ent{ font-size:.82rem; background:var(--bg-sunk); border:1px solid var(--line-soft); padding:.2rem .55rem; border-radius:7px; color:var(--ink-2); }
.ent .mono{ color:var(--ink-3); }
/* ---- estafeta flow ---- */
.flow{ display:flex; align-items:stretch; gap:.4rem; margin-top:.6rem; overflow-x:auto; padding-bottom:.5rem; }
.flow .step{ flex:1 1 0; min-width:120px; background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:.7rem .75rem; }
.flow .step .k{ font-weight:600; font-size:.9rem; }
.flow .step .p{ display:block; margin-top:.35rem; font-family:var(--mono); font-size:.7rem; color:var(--ink-3); word-break:break-all; }
.flow .arr{ align-self:center; color:var(--ink-3); flex:0 0 auto; }
@media (max-width:680px){ .flow{ flex-direction:column; } .flow .arr{ transform:rotate(90deg); } }
/* ---- cross-repo graph (SVG) ---- */
#map .gv-wrap{ margin-top:.6rem; border:1px solid var(--line); border-radius:var(--radius); background:var(--panel); box-shadow:var(--shadow); padding:.8rem 1rem; overflow-x:auto; }
#map svg{ width:100%; height:auto; display:block; min-width:680px; }
.gv-band{ fill:var(--ink-3); font-family:var(--sans); font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; }
.gv-box-t{ fill:var(--ink); font-family:var(--mono); font-size:12px; font-weight:600; }
.gv-box-s{ fill:var(--ink-2); font-family:var(--sans); font-size:11px; }
.gv-edge{ stroke:var(--ink-3); stroke-width:1.4; }
.gv-hinge-lbl{ fill:var(--ink-3); font-family:var(--sans); font-size:9.5px; }
.gv-find-t{ fill:var(--ink); font-family:var(--sans); font-size:11px; font-weight:600; }
.gv-find-s{ fill:var(--ink-2); font-family:var(--sans); font-size:10px; }
.gv-dir-id{ fill:var(--ink-2); font-family:var(--mono); font-size:11px; font-weight:600; }
.gv-dir-n{ fill:var(--ink); font-family:var(--sans); font-size:11px; font-weight:560; }
.gv-dir-a{ fill:var(--ink-3); font-family:var(--mono); font-size:9.5px; }
.gv-leg{ fill:var(--ink-2); font-family:var(--sans); font-size:11px; }
.gv-clip{ cursor:help; }
.gv-tip{ position:fixed; z-index:60; max-width:340px; pointer-events:none; opacity:0; transform:translateY(2px); transition:opacity .07s ease; background:var(--panel); color:var(--ink); border:1px solid var(--line); border-radius:8px; box-shadow:var(--shadow); padding:.32rem .5rem; font:12px/1.45 var(--sans); }
.gv-tip.on{ opacity:1; transform:none; }
/* ---- direction filter ---- */
.filter{ display:inline-flex; gap:2px; background:var(--bg-sunk); border:1px solid var(--line); border-radius:9px; padding:2px; margin:.2rem 0 1rem; flex-wrap:wrap; }
.filter button{ border:0; background:transparent; color:var(--ink-2); font:inherit; font-size:.82rem; padding:.3rem .7rem; border-radius:7px; cursor:pointer; }
.filter button[aria-pressed="true"]{ background:var(--panel); color:var(--ink); box-shadow:var(--shadow); font-weight:560; }
/* ---- direction cards ---- */
.dir{ background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); margin-bottom:.7rem; box-shadow:var(--shadow); overflow:hidden; }
.dir > summary{ list-style:none; cursor:pointer; padding:.9rem 1.05rem; display:flex; align-items:center; gap:.7rem; flex-wrap:wrap; }
.dir > summary::-webkit-details-marker{ display:none; }
.dir > summary:hover{ background:var(--bg-sunk); }
.dir .chev{ color:var(--ink-3); transition:transform .18s cubic-bezier(.22,1,.36,1); margin-left:auto; }
.dir[open] .chev{ transform:rotate(90deg); }
.dir .did{ font-family:var(--mono); font-size:.74rem; color:var(--ink-3); }
.dir .dname{ font-weight:640; letter-spacing:-.01em; }
.dir .drepos{ display:inline-flex; gap:.25rem; flex-wrap:wrap; }
.dir .rchip{ font-family:var(--mono); font-size:.66rem; color:var(--ink-3); background:var(--surface-2,rgba(127,127,127,.12)); border:1px solid var(--line,rgba(127,127,127,.25)); border-radius:.45rem; padding:.02rem .3rem; white-space:nowrap; }
.dir .extends{ font-size:.78rem; color:var(--ink-3); }
.dir .extends b{ color:var(--ink-2); font-weight:560; }
.dir .body{ padding:.2rem 1.05rem 1.1rem; border-top:1px solid var(--line-soft); }
.telos{ color:var(--ink); margin:.7rem 0 .9rem; font-size:.97rem; max-width:68ch; }
.meta{ display:flex; flex-wrap:wrap; gap:.5rem 1.1rem; font-size:.85rem; color:var(--ink-2); margin-bottom:.6rem; }
.meta .lbl{ color:var(--ink-3); }
.anga{ font-style:italic; }
/* drivers table */
.drv{ width:100%; border-collapse:collapse; margin:.6rem 0 .2rem; font-size:.875rem; }
.drv th{ text-align:left; font-weight:560; color:var(--ink-3); font-size:.74rem; text-transform:uppercase; letter-spacing:.05em; padding:.3rem .5rem; border-bottom:1px solid var(--line); }
.drv td{ padding:.45rem .5rem; border-bottom:1px solid var(--line-soft); vertical-align:top; }
.drv tr:last-child td{ border-bottom:0; }
.drv .ttl{ color:var(--ink); }
.drv .ref{ font-family:var(--mono); font-size:.82rem; white-space:nowrap; }
.drv .act{ color:var(--ink-2); font-size:.82rem; }
.lst{ margin:.5rem 0 0; padding-left:1.1rem; color:var(--ink-2); font-size:.9rem; }
.lst li{ margin:.15rem 0; }
.note-h{ font-size:.74rem; text-transform:uppercase; letter-spacing:.06em; color:var(--ink-3); font-weight:600; margin:.9rem 0 .1rem; }
/* ---- badges & chips ---- */
.badge{ display:inline-flex; align-items:center; gap:.3rem; font-size:.72rem; font-weight:600; padding:.12rem .5rem; border-radius:999px; line-height:1.5; white-space:nowrap; }
.badge::before{ content:""; width:6px; height:6px; border-radius:50%; background:currentColor; opacity:.9; }
.st{ background:oklch(0.6 0.12 var(--h) / 0.13); color:oklch(0.48 0.14 var(--h)); }
:root[data-theme="dark"] .st{ color:oklch(0.78 0.13 var(--h)); }
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]) .st{ color:oklch(0.78 0.13 var(--h)); } }
.st-committed{ --h:var(--committed); }
.st-ready{ --h:var(--ready); }
.st-in-flight{ --h:var(--inflight); }
.st-deferred{ --h:var(--deferred); background:var(--bg-sunk); color:var(--ink-3); }
.st-deferred::before{ opacity:.5; }
.wt{ font-size:.7rem; font-weight:600; padding:.1rem .4rem; border-radius:5px; }
.wt-high{ color:oklch(0.5 0.14 var(--pos)); background:oklch(0.6 0.12 var(--pos) / 0.12); }
.wt-medium{ color:var(--ink-2); background:var(--bg-sunk); }
.wt-low{ color:var(--ink-3); background:var(--bg-sunk); }
.rd{ font-size:.7rem; font-weight:600; padding:.1rem .45rem; border-radius:5px; white-space:nowrap; }
.rd-ready{ color:oklch(0.5 0.14 var(--pos)); background:oklch(0.6 0.12 var(--pos) / 0.12); }
.rd-merged{ color:oklch(0.55 0.13 300); background:oklch(0.6 0.12 300 / 0.12); }
.rd-conflicting{ color:oklch(0.55 0.16 var(--neg)); background:oklch(0.6 0.14 var(--neg) / 0.12); }
.rd-ci, .rd-review{ color:oklch(0.55 0.13 var(--warn)); background:oklch(0.65 0.12 var(--warn) / 0.13); }
.rd-draft{ color:var(--ink-3); background:var(--bg-sunk); }
.rd-closed{ color:var(--ink-3); background:var(--bg-sunk); text-decoration:line-through; }
.who{ display:inline-flex; align-items:center; gap:.3rem; font-size:.8rem; }
.who .assoc{ font-size:.66rem; text-transform:uppercase; letter-spacing:.04em; color:var(--ink-3); border:1px solid var(--line); border-radius:4px; padding:0 .25rem; }
.who.maintainer .assoc{ color:var(--acc); border-color:color-mix(in oklab, var(--acc) 40%, var(--line)); }
.karta{ display:inline-flex; align-items:center; gap:.35rem; }
/* ---- signal audit table ---- */
.tbl-wrap{ overflow-x:auto; border:1px solid var(--line); border-radius:var(--radius); }
table.audit{ width:100%; border-collapse:collapse; font-size:.875rem; }
table.audit th{ position:sticky; top:0; background:var(--bg-sunk); text-align:left; font-weight:560; color:var(--ink-2); font-size:.76rem; text-transform:uppercase; letter-spacing:.04em; padding:.5rem .7rem; border-bottom:1px solid var(--line); }
table.audit td{ padding:.5rem .7rem; border-bottom:1px solid var(--line-soft); }
table.audit tr:last-child td{ border-bottom:0; }
table.audit .num{ text-align:right; font-variant-numeric:tabular-nums; color:var(--ink-2); white-space:nowrap; }
.disp{ font-size:.72rem; font-weight:600; padding:.1rem .45rem; border-radius:5px; }
.disp-in{ color:var(--acc); background:var(--acc-soft); }
.disp-deferred{ color:oklch(0.55 0.13 var(--warn)); background:oklch(0.65 0.12 var(--warn) / 0.13); }
.disp-out{ color:var(--ink-3); background:var(--bg-sunk); }
/* ---- reading of the field ---- */
.field{ display:grid; gap:1.2rem; }
@media (min-width:760px){ .field{ grid-template-columns:1fr 1fr; } .field .full{ grid-column:1 / -1; } }
.panel{ background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); padding:1rem 1.1rem; box-shadow:var(--shadow); }
.panel h3{ margin:0 0 .6rem; font-size:.95rem; }
.kartas{ list-style:none; margin:0; padding:0; display:grid; gap:.5rem; }
.kartas li{ display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; font-size:.9rem; color:var(--ink-2); }
.fog{ list-style:none; margin:0; padding:0; display:grid; gap:.4rem; font-size:.88rem; }
.fog li{ display:flex; align-items:baseline; gap:.5rem; }
.fog .to{ color:var(--ink-3); }
.srisk{ margin:0; padding:0; list-style:none; display:grid; gap:.55rem; }
.srisk li b{ display:block; color:var(--ink); font-weight:560; font-size:.9rem; }
.srisk li span{ color:var(--ink-2); font-size:.85rem; }
footer{ max-width:62rem; margin:0 auto; padding:1.5rem 1.4rem 3rem; color:var(--ink-3); font-size:.8rem; border-top:1px solid var(--line-soft); margin-top:3rem; }
footer .mono{ color:var(--ink-2); }
</style>
</head>
<body>
<h1 class="sr-only">Maintainer roadmap, rendered from an Iskron reasoning graph.</h1>
<header class="bar">
<div class="bar-in">
<span class="repo" id="bar-repo"></span>
<span class="ver" id="bar-ver"></span>
<nav aria-label="Sections">
<a href="#map">Map</a><a href="#next">Next</a><a href="#product">Product</a><a href="#flow">How it works</a>
<a href="#directions">Directions</a><a href="#signal">Signal</a><a href="#field">Field</a>
</nav>
<button class="tgl" id="tgl" title="Toggle theme" aria-label="Toggle light/dark">◐</button>
</div>
</header>
<main id="app"><!-- rendered from ROADMAP --></main>
<footer>
Generated <span id="ft-date" class="mono"></span> from an Iskron reasoning graph ·
product ground verified, backlog held as hypotheses. Live state re-verified at render time.
</footer>
<script>
/* ============================================================================
ROADMAP — the only thing the skill fills. Replace the object below with the
real data; the template renders it. Schema (all text is plain, auto-escaped):
repo, repoUrl, version, generated, tagline
repos: { '<key>':'https://github.com/<owner>/<repo>', ... } // MULTI-REPO only —
a product spanning several repos. Set repos to the per-repo URL map, put repo:'<key>'
on every driver/signal ref, and write free-text refs as '<key>#45' (e.g. 'backend#45').
repoUrl then = the org/product URL (the bar link). Single-repo runs omit repos and use repoUrl.
Internal graph refs must use a non-repo prefix (e.g. 'ISKRON#1120') so they render as
plain text, never as a GitHub link.
nextMoves: [string]
product: { summary, subsystems:[{name, repo, primitive, capabilities:[{name, primitive}]}],
entities:[{name, primitive}] } // multi-repo: subsystem.repo names the source repo
estafeta: { name, steps:[{label, repo, primitive, hinge:Boolean}] } // multi-repo: a cross-repo step names its repo.
hinge:true (or a label starting with '◆') marks a between-repo handoff phenomenon — the graph view draws it
as a small diamond BETWEEN the two repo boxes instead of a box (proof the repos are ONE product).
directions: [{ id, name, status:'committed|ready|in-flight|deferred',
repos:[string], // multi-repo: the repo keys this direction touches
after:[id]|string, // optional: which direction(s) this one follows — shown as an 'after Dx' sublabel in the graph view
extends, telos, karta, kartaKind:'maintainer|contributor|community',
anga, unblocks:[string], risks:[string],
drivers:[{ ref:Number, repo, kind:'issue|pr', title, author, assoc,
weight:'high|medium|low', state:'open|merged|closed|draft',
readiness:'ready|conflicting|ci|review|draft|merged|closed|na',
action }] }]
signalAudit: [{ ref, repo, kind:'issue|pr', title, reactions:Number, comments:Number,
disposition:'in:Dn|deferred|out-of-scope' }]
structuralRisks: [{ label, note, seam:String }] // seam (optional): the cross-repo seam this finding spans;
shown as the second line of the graph-view finding pill in place of the note.
field: { kartas:[{name, kind, drives}], figureOnGround:[{dir, capability}] }
========================================================================== */
const ROADMAP = {
repo: "acme/hoardly",
repoUrl: "https://github.com/acme/hoardly",
version: "v0.32.0",
generated: "2026-06-27",
tagline: "A self-hostable read-it-later app: hoard links, notes and files; it crawls, AI-tags, full-text indexes, and reads them back.",
nextMoves: [
"Ship the 0.33.0 slice — start with the maintainer-authored, high-priority items: #2669 background reaping, #2660 agentic extraction, #2667 rule preview.",
"Review + merge the clean community PRs: #2887 (Reddit OAuth), #2785 (configurable temperature), #2804 (precrawled screenshots).",
"Decide the two big substrate PRs before they rot: rebase #2825 (Postgres) and un-draft #2488 (platform renderers)."
],
product: {
summary: "Hoardly ingests a URL, note, or file, crawls and archives it, enriches it with AI tags and a summary, indexes it for full-text and semantic search, and serves it back through a web app, browser extension, mobile app, CLI, and an API.",
subsystems: [
{ name:"Crawler & archival", primitive:"apps/workers/crawlerWorker.ts · CRAWLER_*",
capabilities:[
{name:"Automatic link crawl & metadata fetch", primitive:"crawlerWorker.ts"},
{name:"Full-page archival (link-rot protection)", primitive:"CRAWLER_FULL_PAGE_ARCHIVE"},
{name:"SSRF-hardened crawling", primitive:"CRAWLER_IP_VALIDATION_*"} ] },
{ name:"AI inference", primitive:"packages/shared/inference.ts · INFERENCE_*",
capabilities:[
{name:"LLM auto-tagging & summarization", primitive:"inference/"},
{name:"Custom tagging prompts", primitive:"db customPrompts"} ] },
{ name:"Search & indexing", primitive:"searchWorker.ts · vectorStore.ts",
capabilities:[
{name:"Full-text search over the library", primitive:"shared/search.ts"},
{name:"Saved-search (smart) lists", primitive:"db bookmarkLists(smart)"} ] },
{ name:"Storage & backups", primitive:"packages/db/schema.ts · DATA_DIR",
capabilities:[
{name:"SQLite via Drizzle", primitive:"db/schema.ts"},
{name:"Scheduled & on-demand backups", primitive:"backupWorker.ts"} ] }
],
entities: [
{name:"Bookmark", primitive:"db bookmarks"}, {name:"Tag", primitive:"db bookmarkTags"},
{name:"List", primitive:"db bookmarkLists"}, {name:"Highlight", primitive:"db highlights"},
{name:"User", primitive:"db user"}, {name:"Embedding", primitive:"vectorStore.ts"}
]
},
estafeta: { name:"Bookmark lifecycle", steps:[
{label:"Capture", primitive:"trpc/bookmarks.ts"}, {label:"Crawl & archive", primitive:"crawlerWorker.ts"},
{label:"AI-enrich", primitive:"inference/"}, {label:"Index", primitive:"searchWorker.ts"},
{label:"Search & read", primitive:"apps/web reader"} ] },
directions: [
{ id:"D5", name:"The 0.33.0 release ships", status:"committed",
extends:"cross-subsystem — the committed next-release milestone",
telos:"All 15 open 0.33.0 milestone issues close. The immediate, committed slice — it draws gating work from across D1–D4.",
karta:"Maintainer (mbassem)", kartaKind:"maintainer",
anga:"What is the smallest committed slice that ships a coherent release?",
drivers:[
{ref:2669, kind:"issue", title:"bug: removing accounts with many bookmarks locks the database (background reaping)", author:"mbassem", assoc:"COLLABORATOR", weight:"high", state:"open", readiness:"na", action:"gate the release"},
{ref:2660, kind:"issue", title:"feat: Agentic interaction improvements for search and content extraction", author:"mbassem", assoc:"COLLABORATOR", weight:"high", state:"open", readiness:"na", action:"gate the release"},
{ref:2864, kind:"pr", title:"Fix: screenshot previewer does not show scroll bar", author:"vrtxomega", assoc:"CONTRIBUTOR", weight:"medium", state:"open", readiness:"ready", action:"review + merge → closes #2817"} ],
risks:[], unblocks:["the 0.33.0 release"] },
{ id:"D1", name:"Search becomes semantic and agent-shaped", status:"ready",
extends:"Search & indexing — today's full-text search plus the per-bookmark embedding vector",
telos:"Full-text search grows into embedding-based semantic similarity and an agent-shaped retrieval surface.",
karta:"Maintainer (mbassem)", kartaKind:"maintainer",
anga:"Can retrieval move from keyword to meaning on the embeddings already shipped?",
drivers:[
{ref:441, kind:"issue", title:"[Feature request] Self-hosted semantic search", author:"jojii", assoc:"NONE", weight:"high", state:"open", readiness:"na", action:"build on the merged embedding column"},
{ref:2857, kind:"pr", title:"Add bookmark embedding support", author:"mbassem", assoc:"COLLABORATOR", weight:"high", state:"merged", readiness:"merged", action:"shipped substrate"} ],
risks:["This direction is carried by committed work + merged substrate, not an open inquiry."], unblocks:["the chat assistant (D2)"] },
{ id:"D3", name:"Crawler becomes anti-bot resilient", status:"in-flight",
extends:"Crawler & archival — one generic extraction path repeatedly broken by anti-bot defenses",
telos:"The generic path that breaks on blocking platforms reliably fetches them via platform-specific renderers.",
karta:"Mixed (mbassem + community)", kartaKind:"contributor",
anga:"How does archival stay reliable as platforms harden against crawlers?",
drivers:[
{ref:2885, kind:"issue", title:"[Crawler] Reddit crawling is now getting blocked", author:"mbassem", assoc:"COLLABORATOR", weight:"high", state:"open", readiness:"na", action:"anchor"},
{ref:2887, kind:"pr", title:"Reddit OAuth client-credentials flow", author:"kunal-r", assoc:"NONE", weight:"high", state:"open", readiness:"ready", action:"review + merge → fixes #2885"},
{ref:2488, kind:"pr", title:"Content quality assessment + platform renderers", author:"mbassem", assoc:"COLLABORATOR", weight:"high", state:"draft", readiness:"conflicting", action:"un-draft + rebase (cannot merge yet)"} ],
risks:["Reddit blocked (#2885); chrome 500 on host-header (#2576)."], unblocks:["reliable archival for the whole pipeline"] },
{ id:"D4", name:"Storage becomes durable at scale", status:"ready",
extends:"Storage & backups — today SQLite-only, with an unbounded-memory worker",
telos:"SQLite-only + memory-leaking worker → an optional external Postgres backend and bounded memory under big imports.",
karta:"Community-requested, unowned", kartaKind:"community",
anga:"Can self-hosters run larger, multi-instance deployments?",
drivers:[
{ref:2344, kind:"issue", title:"[BUG] Memory leak (RAM usage continuously growing over time)", author:"nkkfs", assoc:"NONE", weight:"high", state:"open", readiness:"na", action:"resource-hardening, independent of Postgres"},
{ref:2825, kind:"pr", title:"Introduce Postgres support", author:"johnf", assoc:"NONE", weight:"high", state:"open", readiness:"conflicting", action:"rebase before merge (+7627/−1416)"} ],
risks:["The unbounded memory leak (#2344) bites SQLite too."], unblocks:["larger self-host deployments"] }
],
signalAudit: [
{ref:1077, title:"Offline cache on Mobile app", reactions:51, comments:8, disposition:"deferred"},
{ref:414, title:"Bypassing cookie and GDPR banner", reactions:40, comments:63, disposition:"in:D3"},
{ref:1782, title:"Support for external databases (Postgres)", reactions:32, comments:5, disposition:"in:D4"},
{ref:2189, title:"Enhanced Prompt Customization for Advanced Tagging", reactions:28, comments:0, disposition:"in:D1"},
{ref:2344, title:"[BUG] Memory leak (with Pangolin)", reactions:7, comments:54, disposition:"in:D4"}
],
structuralRisks: [
{label:"Dead-recipe capabilities", note:"OCR, RSS, webhooks, REST/SDK and MCP are shipped but have no producing flow in the graph — an under-anchored spine the AI/search directions lean on."},
{label:"Reader-view over-loaded", note:"The read-back step consumes more than any other; it is the single point most directions converge on."}
],
field: {
kartas:[
{name:"Maintainer (mbassem)", kind:"maintainer", drives:"D5, D1, D3"},
{name:"Contributor community", kind:"contributor", drives:"D3 carriers, review-ready PRs"},
{name:"Community-requested", kind:"community", drives:"D4 (unowned — needs a champion)"}
],
figureOnGround:[
{dir:"D1 Semantic search", capability:"Full-text search + embeddings"},
{dir:"D3 Anti-bot crawler", capability:"Automatic crawl & archival"},
{dir:"D4 Durable storage", capability:"SQLite via Drizzle"}
]
}
};
/* ---------------------------------------------------------------- rendering */
const esc = s => String(s==null?"":s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
const R = ROADMAP;
/* multi-repo aware: a driver/signal ref may carry repo:'<key>' resolved via R.repos[key];
single-repo runs omit repo and resolve via R.repoUrl (unchanged behaviour). */
const repoUrlOf = repo => (repo && R.repos && R.repos[repo]) ? R.repos[repo] : R.repoUrl;
const gh = (n, repo) => `${repoUrlOf(repo)}/issues/${n}`;
const ref = (n,k,repo) => `<a class="ref" href="${gh(n,repo)}" target="_blank" rel="noopener">${k==='pr'?'PR ':''}${repo?esc(repo)+'#':'#'}${n}</a>`;
const who = (login, assoc) => login
? `<span class="who ${/OWNER|MEMBER|COLLABORATOR/.test(assoc||'')?'maintainer':''}"><a href="https://github.com/${esc(login)}" target="_blank" rel="noopener">${esc(login)}</a>${assoc?`<span class="assoc">${esc(assoc)}</span>`:''}</span>`
: '<span class="who"></span>';
const statusLabel = {committed:"Committed", ready:"Ready", "in-flight":"In flight", deferred:"Deferred"};
const stBadge = s => `<span class="badge st st-${s}">${statusLabel[s]||esc(s)}</span>`;
const rdLabel = {ready:"ready",conflicting:"needs rebase",ci:"CI pending",review:"needs review",draft:"draft",merged:"merged",closed:"closed",na:""};
const kartaIcon = {maintainer:"●",contributor:"◐",community:"○"};
document.getElementById('bar-repo').innerHTML = `<a href="${R.repoUrl}" target="_blank" rel="noopener">${esc(R.repo)}</a>`;
document.getElementById('bar-ver').textContent = R.version || "";
document.getElementById('ft-date').textContent = R.generated || "";
document.title = `${R.repo} — roadmap`;
function rChips(items, cls, fn){ return (items||[]).map(fn).join(''); }
/* ---------------------------------------------- cross-repo graph (SVG) ----
Hand-rolled, zero-dep. Theme-safe: every fill is an oklch tint with an
oklch border and var(--ink) text, so it reads on both paper and near-black.
Renders from R.estafeta.steps / R.structuralRisks / R.directions / R.repos. */
const GRAPH_HUES = [150, 75, 245, 300, 25, 190];
function repoHueMap(R){
const m = {}; const keys = (R.repos && typeof R.repos==='object') ? Object.keys(R.repos) : [];
keys.forEach((k,i)=>{ m[k] = GRAPH_HUES[i % GRAPH_HUES.length]; });
return m;
}
function graphView(R){
const W = 1080, MX = 24;
const hues = repoHueMap(R);
const repoKeys = Object.keys(hues);
const isHinge = s => s && (s.hinge===true || (typeof s.label==='string' && s.label.trim().charCodeAt(0)===0x25C6 /* ◆ */));
const cleanLabel = l => String(l==null?'':l).replace(/^\s*◆\s*/, '');
const fillFor = h => `oklch(0.64 0.11 ${h} / 0.17)`;
const strokeFor = h => `oklch(0.58 0.13 ${h})`;
const clamp = (n,lo,hi) => Math.max(lo, Math.min(hi, n));
const cpw = (px, ppc) => Math.max(3, Math.floor(px / ppc)); // char budget for a pixel width
const txt = (x,y,cls,full,maxC,attrs='') => { // clip to width; clipped labels carry data-full for a custom instant tooltip
const s = String(full==null?'':full).trim();
const c = (s.length<=maxC) ? s : s.slice(0, Math.max(1,maxC-1)).trimEnd()+'…';
const df = (c!==s) ? ` data-full="${esc(s)}"` : '';
return `<text x="${x}" y="${y}" class="${cls}${df?' gv-clip':''}"${df}${attrs?' '+attrs:''}>${esc(c)}</text>`;
};
let svg = '', y = 18;
/* ---- band 1: cross-repo spine ---- */
const steps = (R.estafeta && Array.isArray(R.estafeta.steps)) ? R.estafeta.steps : [];
const stages = steps.filter(s => !isHinge(s));
const hinges = steps.filter(s => isHinge(s));
if (stages.length){
svg += `<text x="${MX}" y="${y}" class="gv-band">The product, assembled — one item end to end across the repos</text>`;
y += 18;
const n = stages.length;
const boxH = 52;
const bw = clamp((W - 2*MX) / n - 14, 150, 210);
const slot = (W - 2*MX) / n;
const rowY = y;
const cy = rowY + boxH/2;
const xOf = i => MX + slot*i + (slot - bw)/2;
// arrows + hinges between stages first (under the boxes)
for (let i=0;i<n-1;i++){
const x1 = xOf(i)+bw, x2 = xOf(i+1);
svg += `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" class="gv-edge" marker-end="url(#gv-arrow)"/>`;
const h = hinges[i];
if (h){
const hx = (x1+x2)/2, d=7;
svg += txt(hx, cy-16, 'gv-hinge-lbl', cleanLabel(h.label), cpw((x2-x1)+30, 5.2), 'text-anchor="middle"');
svg += `<path d="M ${hx} ${cy-d} L ${hx+d} ${cy} L ${hx} ${cy+d} L ${hx-d} ${cy} Z" fill="var(--bg-sunk)" stroke="var(--line)" stroke-width="1.2"/>`;
}
}
const single = repoKeys.length === 0; // single-repo: one neutral accent for all stages
const ACC_HUE = 235;
stages.forEach((s,i)=>{
const x = xOf(i), key = s.repo;
const h = (key!=null && hues[key]!=null) ? hues[key] : (single ? ACC_HUE : null);
const fill = h!=null ? fillFor(h) : 'var(--panel)';
const stroke = h!=null ? strokeFor(h) : 'var(--line)';
svg += `<rect x="${x}" y="${rowY}" width="${bw}" height="${boxH}" rx="9" fill="${fill}" stroke="${stroke}" stroke-width="1.3"/>`;
if (key!=null && hues[key]!=null){ // multi-repo box: repo key as title, step label as subtitle
svg += txt(x+11, rowY+20, 'gv-box-t', String(key), cpw(bw-22, 7.3));
svg += txt(x+11, rowY+38, 'gv-box-s', s.label, cpw(bw-22, 6.0));
} else { // single-repo / repo-less step: label is the title
svg += txt(x+11, rowY+31, 'gv-box-t', s.label, cpw(bw-22, 7.3));
}
});
y = rowY + boxH + 34;
}
/* ---- band 2: findings strip ---- */
const risks = (Array.isArray(R.structuralRisks) ? R.structuralRisks : []).slice(0, 6);
if (risks.length){
svg += `<text x="${MX}" y="${y}" class="gv-band">Findings — what no single repo's tracker can see</text>`;
y += 16;
const perRow = 4;
const rows = Math.ceil(risks.length / perRow);
const pillH = 50, gap = 12;
const fH = 25, fHue = fillFor(25), fStroke = strokeFor(25);
for (let r=0;r<rows;r++){
const rowItems = risks.slice(r*perRow, r*perRow+perRow);
const cols = rowItems.length;
const pw = (W - 2*MX - gap*(perRow-1)) / perRow;
const rowY = y + r*(pillH+10);
rowItems.forEach((risk,i)=>{
const x = MX + i*(pw+gap);
svg += `<rect x="${x}" y="${rowY}" width="${pw}" height="${pillH}" rx="8" fill="${fHue}" stroke="${fStroke}" stroke-width="1.2"/>`;
svg += txt(x+10, rowY+19, 'gv-find-t', risk.label, cpw(pw-20, 6.0));
const sub = risk.seam || risk.note;
svg += txt(x+10, rowY+37, 'gv-find-s', sub, cpw(pw-20, 6.0));
});
}
y += rows*(pillH+10) + 22;
}
/* ---- band 3: directions ---- */
const dirs = (Array.isArray(R.directions) ? R.directions : []);
if (dirs.length){
svg += `<text x="${MX}" y="${y}" class="gv-band">Directions — where the product goes next</text>`;
y += 16;
const perRow = 5;
const rows = Math.ceil(dirs.length / perRow);
const boxH = 62, gap = 12;
const greenHues = {ready:true, committed:true};
for (let r=0;r<rows;r++){
const rowItems = dirs.slice(r*perRow, r*perRow+perRow);
const bw = (W - 2*MX - gap*(perRow-1)) / perRow;
const rowY = y + r*(boxH+12);
rowItems.forEach((d,i)=>{
const x = MX + i*(bw+gap);
const green = greenHues[d.status];
const fill = green ? fillFor(150) : 'var(--bg-sunk)';
const stroke = green ? strokeFor(150) : 'var(--line)';
svg += `<rect x="${x}" y="${rowY}" width="${bw}" height="${boxH}" rx="8" fill="${fill}" stroke="${stroke}" stroke-width="1.2"/>`;
svg += `<text x="${x+10}" y="${rowY+18}" class="gv-dir-id">${esc(d.id)}</text>`;
svg += txt(x+10, rowY+34, 'gv-dir-n', d.name, cpw(bw-20, 6.0));
// repo dots
const dr = Array.isArray(d.repos) ? d.repos : [];
dr.forEach((k,j)=>{
const dh = hues[k];
const cx = x+12 + j*13, cyd = rowY+48;
svg += `<circle cx="${cx}" cy="${cyd}" r="4" fill="${dh!=null?strokeFor(dh):'var(--ink-3)'}"/>`;
});
let after = '';
if (Array.isArray(d.after)) after = d.after.join(', ');
else if (typeof d.after === 'string') after = d.after;
if (after) svg += `<text x="${x+bw-9}" y="${rowY+boxH-7}" class="gv-dir-a" text-anchor="end">after ${esc(after)}</text>`;
});
}
y += rows*(boxH+12) + 22;
}
/* ---- band 4: legend ---- */
svg += `<text x="${MX}" y="${y}" class="gv-band">Legend</text>`;
y += 16;
let lx = MX;
const legend = [];
repoKeys.forEach(k => legend.push({label:k, hue:hues[k]}));
legend.push({label:'graph finding — in no issue tracker', hue:25});
legend.push({label:'ready now', hue:150});
legend.forEach(item=>{
const tw = 8 + String(item.label).length*6.4 + 18;
if (lx + tw > W - MX){ lx = MX; y += 22; }
svg += `<rect x="${lx}" y="${y-9}" width="12" height="12" rx="3" fill="${fillFor(item.hue)}" stroke="${strokeFor(item.hue)}" stroke-width="1.2"/>`;
svg += `<text x="${lx+18}" y="${y+1}" class="gv-leg">${esc(item.label)}</text>`;
lx += tw + 8;
});
y += 18;
const H = Math.ceil(y);
const defs = `<defs><marker id="gv-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--ink-3)"/></marker></defs>`;
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Cross-repo product graph">${defs}${svg}</svg>`;
}
const app = document.getElementById('app');
const repoCount = (R.repos && typeof R.repos==='object') ? Object.keys(R.repos).length : 0;
const mapHeading = repoCount
? `The product, assembled across ${repoCount} repos`
: `The product, assembled across the codebase`;
app.innerHTML = `
<section id="map" style="margin-top:1.4rem">
<h2>${esc(mapHeading)}</h2>
<p class="sec-sub">Lead with the assembled picture and what no single repo's tracker can see.</p>
<div class="gv-wrap">${graphView(R)}</div>
</section>
<section id="next">
<p class="eyebrow">Maintainer roadmap</p>
<h1 class="title">${esc(R.repo)}</h1>
<p class="tagline">${esc(R.tagline||"")}</p>
<ol class="moves" aria-label="Next moves">${(R.nextMoves||[]).map(m=>`<li>${linkRefs(m)}</li>`).join('')}</ol>
</section>
<section id="product">
<h2>What this product is today</h2>
<p class="sec-sub">The verified ground every direction transforms — each line cited to a real primitive.</p>
<p class="prose">${esc(R.product.summary||"")}</p>
<div class="subsys">${(R.product.subsystems||[]).map(s=>`
<div class="sub">
<div class="sub-h"><b>${esc(s.name)}</b> ${s.primitive?`<span class="prim">${esc(s.primitive)}</span>`:''}</div>
<ul class="caps">${(s.capabilities||[]).map(c=>`<li><b>${esc(c.name)}</b> ${c.primitive?`<span class="prim">${esc(c.primitive)}</span>`:''}</li>`).join('')}</ul>
</div>`).join('')}</div>
${R.product.entities?.length?`<div class="entities">${R.product.entities.map(e=>`<span class="ent">${esc(e.name)}${e.primitive?` <span class="mono">${esc(e.primitive)}</span>`:''}</span>`).join('')}</div>`:''}
</section>
<section id="flow">
<h2>How it works today</h2>
<p class="sec-sub">${esc(R.estafeta?.name||"Core flow")} — the relay a single item travels, end to end.</p>
<div class="flow">${(R.estafeta?.steps||[]).map((s,i,a)=>`
<div class="step"><span class="k">${esc(s.label)}</span>${s.primitive?`<span class="p">${esc(s.primitive)}</span>`:''}</div>
${i<a.length-1?'<span class="arr">→</span>':''}`).join('')}</div>
</section>
<section id="directions">
<h2>Directions</h2>
<p class="sec-sub">In dependency order. Each transforms a capability the product already ships.</p>
<div class="filter" role="group" aria-label="Filter directions">
<button data-f="all" aria-pressed="true">All</button>
<button data-f="committed" aria-pressed="false">Committed</button>
<button data-f="ready" aria-pressed="false">Ready</button>
<button data-f="in-flight" aria-pressed="false">In flight</button>
<button data-f="deferred" aria-pressed="false">Deferred</button>
</div>
<div id="dirs">${(R.directions||[]).map(dirCard).join('')}</div>
</section>
<section id="signal">
<h2>Signal audit</h2>
<p class="sec-sub">The most-reacted and most-commented open issues, computed over the full set — each marked included, deferred, or out of scope.</p>
<div class="tbl-wrap"><table class="audit">
<thead><tr><th>Issue</th><th>Title</th><th class="num">👍</th><th class="num">💬</th><th>Disposition</th></tr></thead>
<tbody>${(R.signalAudit||[]).map(s=>`<tr>
<td>${ref(s.ref,s.kind||'issue',s.repo)}</td><td>${esc(s.title)}</td>
<td class="num">${s.reactions??''}</td><td class="num">${s.comments??''}</td>
<td>${dispBadge(s.disposition)}</td></tr>`).join('')}</tbody>
</table></div>
</section>
<section id="field">
<h2>Reading of the field</h2>
<p class="sec-sub">What the graph bought — the part a flat ticket list can't produce.</p>
<div class="field">
<div class="panel"><h3>Driving roles</h3>
<ul class="kartas">${(R.field?.kartas||[]).map(k=>`<li><span class="karta">${kartaIcon[k.kind]||'•'} <b>${esc(k.name)}</b></span> <span class="lbl" style="color:var(--ink-3)">drives ${esc(k.drives)}</span></li>`).join('')}</ul>
</div>
<div class="panel"><h3>Figure on ground</h3>
<ul class="fog">${(R.field?.figureOnGround||[]).map(f=>`<li><b>${esc(f.dir)}</b> <span class="to">extends</span> ${esc(f.capability)}</li>`).join('')}</ul>
</div>
<div class="panel full"><h3>Structural risks</h3>
<ul class="srisk">${(R.structuralRisks||[]).map(r=>`<li><b>${esc(r.label)}</b><span>${esc(r.note)}</span></li>`).join('')}</ul>
</div>
</div>
</section>`;
function dirCard(d){
return `<details class="dir" data-status="${esc(d.status)}"${d.status==='committed'?' open':''}>
<summary>
${stBadge(d.status)}
<span class="did">${esc(d.id)}</span>
<span class="dname">${esc(d.name)}</span>
${(d.repos&&d.repos.length)?`<span class="drepos">${d.repos.map(r=>`<span class="rchip">${esc(r)}</span>`).join('')}</span>`:''}
<span class="chev">›</span>
</summary>
<div class="body">
<p class="extends"><b>Extends:</b> ${esc(d.extends)}</p>
<p class="telos">${esc(d.telos)}</p>
<div class="meta">
<span><span class="lbl">Driven by</span> ${esc(d.karta||'—')}</span>
${d.anga?`<span class="anga"><span class="lbl">Question:</span> ${esc(d.anga)}</span>`:''}
</div>
${d.drivers?.length?`<table class="drv">
<thead><tr><th>Ref</th><th>Title</th><th>Author</th><th>Weight</th><th>State</th><th>Action</th></tr></thead>
<tbody>${d.drivers.map(p=>`<tr>
<td class="ref">${ref(p.ref,p.kind,p.repo)}</td>
<td class="ttl">${esc(p.title)}</td>
<td>${who(p.author,p.assoc)}</td>
<td><span class="wt wt-${esc(p.weight)}">${esc(p.weight)}</span></td>
<td>${p.readiness&&p.readiness!=='na'?`<span class="rd rd-${esc(p.readiness)}">${rdLabel[p.readiness]||esc(p.readiness)}</span>`:`<span class="rd rd-draft">${esc(p.state)}</span>`}</td>
<td class="act">${esc(p.action||'')}</td></tr>`).join('')}</tbody></table>`:''}
${d.risks?.length?`<p class="note-h">Open risks</p><ul class="lst">${d.risks.map(r=>`<li>${linkRefs(r)}</li>`).join('')}</ul>`:''}
${d.unblocks?.length?`<p class="note-h">Unblocks</p><ul class="lst">${d.unblocks.map(u=>`<li>${linkRefs(u)}</li>`).join('')}</ul>`:''}
</div>
</details>`;
}
function dispBadge(d){
if(!d) return '';
if(/^in:/.test(d)) return `<span class="disp disp-in">→ ${esc(d.slice(3))}</span>`;
if(d==='deferred') return '<span class="disp disp-deferred">deferred</span>';
return `<span class="disp disp-out">${esc(d)}</span>`;
}
/* turn "#123" inside free text into working links (after escaping) */
/* free-text refs: qualified 'backend#45' links if 'backend' is a known R.repos key;
an unknown prefix (e.g. internal 'ISKRON#1120') is left as plain text — it is NOT a GitHub ref;
a bare '#45' links via R.repoUrl in single-repo mode, but is left plain in multi-repo mode (ambiguous). */
function linkRefs(s){
return esc(s).replace(/([A-Za-z][\w.-]*)#(\d+)|#(\d+)/g, (m, repo, qn, bare) => {
if (repo){
return (R.repos && R.repos[repo])
? `<a class="ref" href="${repoUrlOf(repo)}/issues/${qn}" target="_blank" rel="noopener">${esc(repo)}#${qn}</a>`
: m;
}
return R.repos ? m : `<a class="ref" href="${gh(bare)}" target="_blank" rel="noopener">#${bare}</a>`;
});
}
/* ---- filter + theme ---- */
document.querySelector('.filter').addEventListener('click', e=>{
const b = e.target.closest('button'); if(!b) return;
document.querySelectorAll('.filter button').forEach(x=>x.setAttribute('aria-pressed', x===b));
const f = b.dataset.f;
document.querySelectorAll('#dirs > .dir').forEach(d=>{ d.style.display = (f==='all'||d.dataset.status===f)?'':'none'; });
});
const tgl = document.getElementById('tgl'), root = document.documentElement;
tgl.addEventListener('click', ()=>{
const cur = root.getAttribute('data-theme')
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
root.setAttribute('data-theme', cur==='dark' ? 'light' : 'dark');
});
/* ---- graph-view tooltip (instant, styled — replaces the slow native SVG <title>) ---- */
(function(){
const map = document.getElementById('map'); if(!map) return;
const tip = document.createElement('div'); tip.className = 'gv-tip'; tip.setAttribute('role','tooltip');
document.body.appendChild(tip);
let cur = null;
const place = (cx, cy) => {
let x = cx + 14, y = cy + 16;
const w = tip.offsetWidth, h = tip.offsetHeight;
if (x + w > innerWidth - 8) x = cx - w - 14;
if (y + h > innerHeight - 8) y = cy - h - 14;
tip.style.left = Math.max(8, x) + 'px';
tip.style.top = Math.max(8, y) + 'px';
};
map.addEventListener('mousemove', e => {
const t = e.target.closest && e.target.closest('[data-full]');
if (t){
if (t !== cur){ cur = t; tip.textContent = t.getAttribute('data-full'); tip.classList.add('on'); }
place(e.clientX, e.clientY);
} else if (cur){ cur = null; tip.classList.remove('on'); }
});
map.addEventListener('mouseleave', () => { cur = null; tip.classList.remove('on'); });
})();
</script>
</body>
</html>
SKILL.md
---
name: product-roadmap
slash: true
description: "Используй, чтобы собрать продуктовый роадмап GitHub-репозитория — или мультирепо-продукта (org или набор репо как один продукт) — выведенный из issues и PR через граф рассуждений. Триггеры: «собери роадмап из issues/PR», «роадмап по продукту», «что в этом репо делать дальше», roadmap from repo/github, roadmap for my org, multi-repo roadmap, quick roadmap, product-roadmap. Отыгрывает мейнтейнера: проверенная почва настоящего, взвешенный бэклог как шабда, направления (bianhua), рендер graph+markdown+HTML. Композирует iskronify, intake и assembly. Нужны gh CLI и тулы iskron_*."
---
# Продуктовый роадмап
Преврати GitHub-репо, **которое ты сопровождаешь**, — или мультирепо-продукт — в роадмап, из которого можно действовать. Ценность (обещание solo-разработчику): овладеть контекстом, рассыпанным по кодовой базе и бэклогу issues/PR, и дать графу поднять *направления, под которыми проект действительно находится*, — а не плоский список тикетов.
Ты отыгрываешь **мейнтейнера**. Конвейер:
```
рамка + бутстрап графа → моделирование текущего продукта (vartamana-почва) →
сбор milestones + issues + PR → взвешивание по автору → запись в граф (шабда-интейк) →
сборка (превращения: фигура-на-почве) → рендер (граф + markdown + HTML) → проверка
```
Скилл **композирует** три других: `iskronify` (понимание кодовой базы, граф/контур), `intake` (источник-независимая дисциплина шабда-интейка — хребет Шага 5) и `assembly` (различение превращений — сердце Шага 6). Сам он добавляет GitHub-**адаптер** (Шаги 3–4) и **рендер** роадмапа (Шаг 7). `references/self-checks.md` — полный проверочный гейт: Шаг 8 его прогоняет; без него не публикуй.
## Обязательства — держи их через все шаги
- **Фигура на почве.** Issues/PR — это *дельта*: что люди хотят изменить; сами по себе они читаются как триаж тикетов. Сначала смоделируй существующий продукт — проверенной `vartamana`-почвой, зримо отделённой от `kalpita`/`shabda`-бэклога. Тогда каждое направление преобразует нечто реальное.
- **Рычаг графа, не только его текст.** Акторы — роли с реальными рёбрами; фигура-на-почве — граф-легальная связь; ключевые потоки — эстафета `ahara`/`utpatti`; структурные риски — из собственных натяжений графа. Роадмап, который мог быть плоским markdown-списком, графом не воспользовался — а всякий рычаг, заявленный прозой, должны нести рёбра (проверка — режим claim-аудита скилла `integrity`).
- **Один продукт, даже через много репо.** ОДИН фокус-контур; каждый репо — top-level-подсистема; кросс-репо-поток — заголовок. Никогда N сшитых роадмапов. Подтверди с мейнтейнером, какие репо в продукте, до моделирования.
- **Читай-и-освежай, никогда не пересевай (контракт повторного прогона).** В существующий граф ориентируются и освежают его **на месте** — locate-before-write на каждой записи: обнови существующее, добавь только подлинно новое, закрой отгруженное. Отсутствующий граф *предлагается*, не создаётся молча. Выход — снимок настоящего, не межпрогонный журнал.
- **Честно прежде эффектно.** Пустой или тощий бэклог констатируется, не надувается; срезы объявляются; превосходные степени — только над множествами, которые ты реально вычислил; каждый процитированный примитив проверен. Полный список стражей — `references/self-checks.md`.
- **Дисциплина ссылок.** `#N` зарезервирован за реальными номерами GitHub issue/PR. Мультирепо: каждая ссылка репо-квалифицирована полным ключом (`backend#1`) — без самодельных сокращений, группировок и диапазонов, без голых `#N`. Внутренние seq графа — с не-репо-префиксом (`ISKRON#1120`). Направления — `D1`/`D2` или по имени.
## Предпосылки
- `gh` CLI аутентифицирован (`gh auth status`); целевые репо доступны.
- **Локальный чекаут каждого репо в объёме** (`git clone --depth 1`; приватные: `gh repo clone`). Шаг 2 читает реальную структуру кода — моделирование по одним `gh`-метаданным недослуживает продукту.
- Тулы iskron_*; граф — по контракту повторного прогона выше.
- Тяжёлые шаги (модель продукта, сбор, записи в граф, сборка, рендер) гоняй сабагентами; результаты между шагами передавай **файлами на диске**, возвращай короткие подтверждения + пути — большие возвраты роняют соединение. Проверяй шаг сабагента по произведённому артефакту (файлы, узлы графа), не по его отчёту; и никогда не пускай двух пишущих в граф сабагентов над одним графом параллельно — одноимённые создания сталкиваются или дедупятся поперёк дорожек.
## Шаг 1 — Рамка и бутстрап
- Определи цель — один `owner/repo` или набор (org или названный мейнтейнером список) — и границу продукта одной строкой. Несколько репо: подтверди, что это один продукт, и что в объёме.
- Сначала найди граф + фокус-контур (`iskron_realm` list; `iskron_orient` / `iskron_semantic_search`). Существует → ориентируйся и веди прогон как инкрементальное освежение. Отсутствует → предложи создание, создавай только по отмашке мейнтейнера.
- Ровно **один фокус-контур**, названный по **продукту**, `contains` от корня — один контур даже через много репо, никогда по контуру на репо.
- Чтение в духе `iskronify` настраивает граф/контур, но никогда не пиши `AGENTS.md` в репо, владение которым только отыгрываешь.
## Шаг 2 — Смоделируй текущий продукт (vartamana-почва)
Пропустишь — и весь граф будет `shabda`: роадмап прочтётся как триаж бэклога, висящего в вакууме.
**Читай то, что ЕСТЬ:** README + доки (собственная заявка продукта); CHANGELOG + релизы (`gh release list` — что реально отгружено, текущая версия); структура кодовой базы (top-level директории/пакеты — реальные границы подсистем, точки входа, таблицы роутов); поверхность конфигурации (флаги, env); поверхность API (endpoint'ы, CLI-команды).
**Засей почву в модусе наблюдения (Pt/Va/Up)** — засвидетельствовано, действует, принято — строго отдельно от бэклога:
| Что прочитал | Узел | given_as | модус |
|---|---|---|---|
| подсистема (crawler, indexer, auth, storage) | **holon** | — | (граница) |
| существующая способность («полнотекстовый поиск») | **phenomenon** | vollzug / sachverhalt | Pt/Va/Up |
| доменная сущность (Bookmark, Library, User) | **phenomenon** | bildung / sinn | Pt/Va/Up |
| ключевой поток, который продукт исполняет сегодня | **kriya** | — | Pt/Va/Up |
| отгруженный факт, подтверждённый релизом | phenomenon | sachverhalt | **Pm**/Va/Up |
- Вкладывай подсистемы под фокус-контур (`contains`); способности/сущности живут в своей подсистеме. **Мультирепо: репозитории И ЕСТЬ top-level-подсистемы** (`attrs.repo` на каждой); внутри репо может делиться дальше.
- **Правила заземления — каждый узел почвы:** цитируй реальный примитив в `attrs.source_ref` (путь модуля, конфиг-флаг, endpoint, релиз-тег; репо-квалифицированно для мультирепо). Цитируй **точный** примитив — названный символ существует по этому пути; цитируй, где поведение **реализовано**, не где читается; предпочитай **исполняемые примитивы прозе доков**; версии читай из **манифеста**, никогда из дока. `vartamana`-заявка без цитируемого примитива — догадка: понизь или выброси. Не выводи способность из feature-request'а (это бэклог, не почва).
- **Объём:** подсистемы, главные доменные сущности, горстка ключевых потоков — карта, которую мейнтейнер узнаёт как «да, это мой продукт», не полный реверс-инжиниринг.
- **Смоделируй акторов — сначала роль-тест (скилл writing): ролью становится только адресуемый делатель.** Два слоя, из двух разных источников, никогда не схлопнутых:
- **Операторы рантайма** — кто действует на *живую* систему / кого она обслуживает. Внешний потребитель → `agantuka` 客; внутренний оператор (админ, модератор, онбординг-стафф) → `adhikarin` 能 — *активный* делатель, который отвечает и действует, никогда не пассивно-«обслуживаемая» сторона. Парк воркеров / CI / cron — **не** роль: это ⚙️ феномен (`upadhi`); повысить его, чтобы заглушить no-actor-натяжение, — ровно тот антипаттерн, о котором натяжение предупреждает. Моделируется из **почвы** (роли, которым служит код): эти люди почти никогда не пишут issues.
- **Драйверы разработки** — кто авторствует *бэклог*. Мейнтейнер → `svatantra` 主 (делегированный dev-scope → `adhikarin`); залётный контрибьютор → `agantuka`. Только этот слой виден сбору.
Чем уже круг контрибьюторов (solo-проект = один 主), тем сильнее реальный акторный сигнал прячется в слое рантайма. Карта с нулём рёбер — театр: прошей её к деяниям, которые она двигает, или выброси; каждая роль несёт `manifested_as`.
- **Прошей ключевые потоки эстафетой:** каждая крия ключевого потока потребляет (`ahara`) феномены, которые читает-и-съедает, и производит (`utpatti`) те, что пишет, в последовательности `next` — хребет продукта как реальная эстафета (трассируется в Шаге 7) и причина, по которой структурные риски становятся вычислимыми. **Мультирепо: кросс-репо-хребет — заголовок**: крия в репо A производит *шарнирный феномен* (общая таблица БД, API-контракт, публикуемый артефакт), который потребляет крия в репо B; шарнирные феномены — граф-доказательство единого продукта.
## Шаг 3 — Собери milestones + issues + PR
`gh`, JSON. **Три источника** — обязательный план мейнтейнера живёт в milestones / Projects-доске и есть сильнейший одиночный сигнал.
- **Мультирепо:** собирай каждый репо в объёме, помечай элемент его репо, координированную работу поперёк репо веди одной кросс-репо-темой.
- **Сначала milestones:** `gh api repos/<owner>/<repo>/milestones?state=open`, затем issues каждого не-«Backlog»-майлстоуна. **Читай прозу описания самого майлстоуна** — мейнтейнеры называют там цели, у которых нет issue. Проверь и Projects-доски, и закреплённые «roadmap»-issues.
- **Issues:** `gh issue list --state open --limit <N> --json number,title,body,author,authorAssociation,labels,reactionGroups,comments,milestone,createdAt,updatedAt`
- **PR (открытые + недавно смерженные):** `gh pr list --state all --limit <N> --json number,title,body,author,authorAssociation,labels,state,milestone,additions,deletions,createdAt,mergedAt`
- **Отбор = ОБЪЕДИНЕНИЕ, срез объявлен, никогда молча:** (a) каждый issue с майлстоуном, (b) каждый с меткой accepted/approved/roadmap, (c) топ-N по реакциям / комментариям, (d) недавно активные N, (e) контрибьюторские PR. Одни реакции роняют уже решённую малореакционную работу — ровно то, что на роадмапе и должно быть. Не оконь PR до новейших N: долго открытый мейнтейнерский PR — высокий сигнал. Залогируй срез и что исключено.
- **Пустой/тощий бэклог честен, не провал:** элементы НЕ выдумываются. Почва плюс траектория смерженных PR (`gh pr list --state merged` — что недавно строилось, в каком направлении) несут роадмап; так и скажи в рендере.
- Фиксируй по элементу: номер, заголовок, обрезанное тело, **проверенный логин автора + authorAssociation (из API, никогда не выведенный)**, метки, майлстоун, вовлечённость, связанные issues, состояние.
## Шаг 4 — Взвесь по роли автора
| authorAssociation | вес | заметка |
|---|---|---|
| OWNER / MEMBER / COLLABORATOR | высокий | голос мейнтейнера |
| CONTRIBUTOR | высокий | есть смерженная работа — реален |
| FIRST_TIME_CONTRIBUTOR / FIRST_TIMER | средний | суди по содержанию + вовлечённости |
| NONE (issue) | средний | пользовательская нужда — держи, если содержательна |
| NONE (PR без связанного issue) | низкий | залётный; впускай только при реальной вовлечённости |
Повышай за вовлечённость и метки `accepted`/`roadmap`; понижай дубликаты, `wontfix`, ботов, чистые dependency-бампы.
- **Членство в майлстоуне перевешивает реакции** — issue следующего релиза есть обязательная работа независимо от счёта реакций.
- **Авторство проверяй из API, никогда не выводи** — приписать общинный вклад мейнтейнеру значит испортить само взвешивание, ради которого шаг существует.
## Шаг 5 — Запиши бэклог (шабда-интейк + роадмап-надстройки)
**Прогони скилл `intake` над собранным** — Шаги 3–4 суть его GitHub-адаптер (они дают каждому элементу содержание / форму / провенанс / авторитет). Хребет у intake — не пересказывай: форма → тип узла; эпистемика по виду (`kalpita` для непроверенной просьбы — собственная обязательная воля мейнтейнера *не* kalpita-на-проверку); `source_kind=shabda`; семантический дедуп, locate-before-write; якорь `arose_from` к источнику; сверка и градация модуса; срез селективности (из Шага 3).
**Одно сознательное расхождение с таблицей intake:** feature-request ложится здесь **феноменом** (желаемая способность, sinn/bildung) или семенной **крией** (anagata/chanda) — **не** intake-овским превращением. Направления различаются позже сборкой; Шаг 5 сеет желаемую способность, не превращение.
GitHub-PR-формы, которых таблица intake не называет:
| Элемент источника | Узел |
|---|---|
| смерженный контрибьюторский PR | **kriya** (деяние сделано) или произведённый им феномен |
| открытый контрибьюторский PR | **kriya** (anagata) — работа в полёте |
| маловесный залётный PR | пропустить — или низкоприоритетный феномен с пометкой |
**Роадмап-надстройки, которых intake не несёт:**
- **Якори каждый элемент к его подсистеме** (`context` / `vimarsha_of` к контуру подсистемы из Шага 2 или к способности, которую он расширяет) — бэклог кластеризуется по подсистемам. Элемент без дома — сигнал: недомоделированная почва (вернись в Шаг 2) или подлинно новая почва — скажи, что именно. Кросс-репо-тема якорится к фокус-контуру и линкует каждую задетую подсистему.
- **Дедуплицируй против ПОЧВЫ, не только бэклога:** просьба, совпадающая с отгруженной способностью, — *дельта*: формулируй «улучшить X», никогда «добавить X»; если базовая форма уже отгружена — переформулируй в реальный оставшийся зазор. Назвать уже отгруженное как to-build — подорвать мастерство продукта. Неси GitHub-реф (номер, автор, вес) в `attrs` для трассируемости рендера.
- **Ставь модус момента честно:** закоммичено майлстоуном → `adhimoksha`; спекулятивная просьба → `chanda`; проектируемая работа → `anagata`; почва остаётся `vartamana`/`pramanita`. Моделируй поле, как оно стоит сейчас; никогда не пере-переходи заранее.
- **Дай каждой бэклог-крие её ведущую роль как `actor`** — dev-драйверскую роль проверенного автора, никогда рантайм-актора. Именно это делает «кто владеет направлением» ребром графа, а не прозаическим ярлыком.
## Шаг 6 — Сборка (различи направления)
Прогони скилл `assembly` над засеянным графом: ориентация поля → триаж свободных вимарш → различение **превращений** → имя + телос → anga драйверов → порядок `anantara`. Играя владельца, принимай имена/телосы inline, но держи дисциплину: имя читается человеком-владельцем; телос — *качество назначения*; никогда превращение на одну вимаршу; риски остаются рисками.
- **Освежающие прогоны сверяют, не переразличают с нуля:** обнови телос / anga / порядок направлений, которые держатся; направление добавляй только для подлинно новой темы; закрой (visarjana) те, чья работа отгружена. Никогда — направление-близнец.
- **Фигура-на-почве как реальная стрелка:** ведущая крия направления достигает способности почвы через `upadhi`/`context` — никогда буквальным `ahara` от превращения (граф запрещает). Направление, чьи ведущие крии не касаются никакой способности почвы, плавает: либо подлинно новая почва (скажи явно), либо знак недомоделированного Шага 2 (иди досей способность).
- **Подними структурные риски из собственных натяжений графа** (`iskron_orient(lens="tensions")`): способность без производящего потока, relay-gap эстафеты, деяние без роли — самодиагноз графа, отличный от рисков из бэклога; плоский список тикетов структурно не может их произвести. **Мультирепо: кросс-репо dead-recipe или relay-gap — находка высшей ценности.** Чистые артефакты моделирования чини на месте, а не рапортуй.
- **Атрибутируй каждое направление по двум осям:** его **драйвер** (ведёт мейнтейнер и закоммичено / ведёт контрибьютор и ждёт ревью / просит сообщество и без владельца — из ролей сбора) и его **рантайм-цель** (кому служит изменённая система: внешний потребитель 客 или внутренний оператор 能 — из почвы). Не схлопывай вторую в первую — жди операторских направлений (админка, онбординг стаффа) рядом с потребительскими; на solo-продукте ось драйвера плоская, и направления реально различаются рантайм-целью. Владение — **факт графа**: anga-крии направления несут `actor` → роль-драйвера, иначе ярлык понижается до «выведено».
- **Когерентная высокосигнальная тема сообщества заслуживает направления** даже без закоммиченной работы — топ-реакционные issues есть роадмап-сигнал; сознательная отсрочка помечается «отложено, но реально», никогда не роняется молча.
- **Майлстоуны целиком:** закоммиченный ближний майлстоун — собственное направление, перечисляющее **каждый** открытый issue майлстоуна; топ-N ранжируй как гейтящие, но назови общее число и что остальное остаётся; никогда телос, заявляющий завершение по частичному списку. Перечисли каждый ОТКРЫТЫЙ майлстоун; catch-all «Backlog» — целиком или явно «сэмплировано». Закоммиченный мейнтейнерский issue из майлстоуна никогда не уступает место менее сигнальному по той же теме.
- **Каждому ребру `anantara` — свидетельство**: issue/PR или заявление мейнтейнера, его устанавливающее; собственный архитектурный вывод помечай эвристикой (`kalpita`) и хеджируй в рендере. **Не пере-называй «blocked»:** направление со своей работой в полёте — *sequenced-after*, не заблокировано.
- **Именуй направления на языке аудитории репо** — они станут заголовками роадмапа, которые читает владелец.
## Шаг 7 — Рендер (веди собранной картиной)
Роадмап ведёт собранной кросс-репо-картиной и тем, что нашёл граф, — дифференцирующая ценность на первом экране, не закопана. Три артефакта:
1. **Граф** — `iskron_orient(lens="bianhua")`; HTML рендерит его визуальным граф-видом (хребет + направления в порядке зависимостей + флаги структурных рисков).
2. **`roadmap.md`**, в этом порядке:
- **«Продукт, собранный»** — подсистемы (по одной на репо) + кросс-репо-хребет, одно-два предложения; реальная картина из ВСЕХ источников.
- **«Что нашёл граф»** — структурные риски из натяжений, каждый с помеченным швом. Веди ими.
- **«Следующие 3 хода»** — список действий на 3–5 строк.
- **«Что этот продукт сегодня»** — почва, каждая строка процитирована к примитиву; доказывает мастерство продукта.
- **«Как это работает сегодня»** — одна ключевая эстафета, протрассированная насквозь (`lens="trace"`): хребет одним проходом.
- **Направления** в порядке `anantara`; по направлению: расширяемая способность, телос, ведущие элементы (дословные заголовки + рефы + вес автора), роль-драйвер **и** рантайм-цель (客/能), anga (ведущий вопрос), открытые риски, что разблокирует.
- **«Чтение поля»** — роли-драйверы (+ рантайм-цели), структурные риски, карта фигура-на-почве. Написано для мейнтейнера, не методолога: глоссируй каждый термин графа по-простому при первом употреблении.
- **«Аудит сигнала»** — топ по реакциям и комментариям среди ОТКРЫТЫХ issues (числа + основание запроса), каждый помечен included / deferred / out-of-scope, с названным порогом вовлечённости. Тощий бэклог → таблица траектории (счётчики смерженных PR по репо + темы). Точные числа, никогда оценки.
3. **`roadmap.html`** — страницу НЕ пиши руками. Скопируй `references/roadmap-template.html` и замени ТОЛЬКО его data-объект `ROADMAP` (схема задокументирована в начале `<script>` шаблона); шаблон рендерит дизайн, реф-ссылки, чипы авторов, статус-бейджи, фильтры, темы. Весь текст авто-экранируется — вставляй дословные заголовки. **Мультирепо:** заполни URL-карту `repos`, ставь `repo:'<key>'` на каждый реф драйвера/сигнала и `directions[].repos`; свободные рефы пиши `<key>#N`; внутренние seq — `ISKRON#…`. Один репо: опусти `repos` (шаблон линкует через `repoUrl`).
Рендерь на языке аудитории репо. Цитируемые заголовки — дословно. **Точность состояний:** различай review / rebase / merge по draft-состоянию + mergeability + CI + ревью; докладывай разбор готовности по каждому PR и рекомендуй действие, снимающее *связывающее* ограничение. `mergeable=null` → пере-опроси один раз, затем «mergeability pending», никогда «clean»; `mergeable=true` ≠ готов; mergeable-PR — не «конфликтующий». Никогда не сваливай PR разных состояний в одну строку; ОТКРЫТЫЙ PR никогда не «отгружен». **Никаких невычисленных превосходных степеней** — скоуп-заявка или сырое число, никогда глобальное «#1» по срезанному подмножеству. **Покрытие — то, что делает путь данных/кода**, не то, что перечисляет конфиг или UI.
## Шаг 8 — Чекпойнт и проверка (падай громко)
Ненаписанный роадмап не стоит ничего; прогон, умерший молча и не отдавший ничего, — худший исход.
- **Чекпойнть по ходу:** пиши сырой выход каждой стадии (`milestones.json`, `issues.json`, `prs.json`, собранный скелет) в `--out`-директорию по мере сбора — поздний провал оставит восстановимое состояние.
- **Проверь запись:** перечитай артефакты; убедись — непустые, обязательные секции на месте; нет → скажи громко и почини; никогда не рапортуй успех без проверенного артефакта.
- **Прогони `references/self-checks.md` — каждую проверку.** Каждый PASS зарабатывается над полным названным множеством; расхождения исправь, перерендери, перепроверь.
- **Возвраты держи маленькими:** файлы на диске + короткое подтверждение с путями.
## Быстрый режим — тизер
Триггеры: `--quick`, «quick roadmap», «дай суть», первое знакомство. Не гони полный конвейер (десятки минут); выдай одноэкранный тизер за пару минут — «он действительно понимает мой продукт» — и предложи полный прогон. Та же механика, вширь и мелко:
1. **Рамка** (Шаг 1) — граница продукта; репо в объёме.
2. **Лёгкая почва** — подсистемы и кросс-репо-хребет (достаточно для граф-вида) + горстка ключевых способностей и сущностей; реальные примитивы процитированы, объём — хребет; засей графа ровно на хребет + одно заголовочное натяжение.
3. **Взгляд на траекторию** — темы смерженных PR / закоммиченный майлстоун; без полного сбора и глубины взвешивания.
4. **Топ-3 направления + один заголовочный структурный риск**; без полного леса.
5. **Рендер только лида** — граф-вид + резюме на 3–5 строк (*что это за продукт · что нашёл граф · следующий ход*) + явный указатель: «это тизер — прогони полный роадмап для полной картины».
Дисциплина держится: честное разделение shabda/vartamana, без фабрикаций, каждый процитированный примитив реален, рефы репо-квалифицированы, честность пустого бэклога. Мельче — да, небрежнее — нет.
## Контракт выхода
- Проверенный `vartamana`-подграф почвы (подсистемы + способности + ключевые потоки), процитированный к реальным примитивам, отдельный от `shabda`-бэклога.
- Граф засеян трассируемыми феноменами/рисками — шабда-честно, дедуплицировано, со срезом.
- Карта превращений = роадмап, секвенированная, каждое направление заякорено в почву.
- Рычаг графа видим: роли-акторы, связи фигура-на-почве, структурные риски из натяжений — собраны в «Чтении поля».
- `roadmap.md` + `roadmap.html` записаны локально; список «следующие 3 хода».
- Все self-checks пройдены.