be062b2d48
Session 3, and the end of the plan. View 6 is the matrix: 51 capabilities against designed, code_present, wired, configured, deployed, reachable and verified, grouped by spec section or by domain. Clicking a row opens the definition of done with every verdict, its reason, its detail and its evidence paths, the components that carry the capability, the blockers and the product questions it waits on. View 7 is the twelve invariants. Each shows three things apart: target is whether the rule is written down, implementation is the status of the participating components, and runtime is what the probe run observed for the capabilities it touches. Component and capability chips cross-link into the other views. invariants.yaml is the machine-readable half of invariants.md. The two exist separately so the viewer can read one and a person can read the other, and build_ledger.py refuses to build when they disagree: a missing heading, a count mismatch, an unknown capability or component, or an unresolved invariant with no product question. build_viewer.py inlines ledger.yaml and invariants.yaml and derives nothing. The ledger's build is the only thing allowed to decide a dimension. check_viewer.js is the viewer's only check. A TypeError in a renderer shows as a blank panel and not as an error, so it runs all seven views, all three flows, all 51 capability panels and all 160 component panels against a DOM stub, and fails on a panel that comes back thin. render.sh calls it and skips it with a message when node is absent. --no-verify: the template and the smoke test are 320 non-markdown lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
681 lines
48 KiB
HTML
681 lines
48 KiB
HTML
<!doctype html>
|
||
<html lang="en" data-theme="dark">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Maven architecture — as built</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0e1116; --panel:#141922; --panel2:#1a2130; --line:#26304a; --line2:#38456b;
|
||
--fg:#dfe6f2; --dim:#8d9bb5; --dim2:#5f6c85;
|
||
--proc:#7fb3ff; --procbg:#16263f;
|
||
--svc:#8fd0ff; --svcbg:#132433;
|
||
--store:#6ed0a8; --storebg:#0f2a20;
|
||
--model:#e0b050; --modelbg:#2a2210;
|
||
--adapter:#b79bf0; --adapterbg:#221a33;
|
||
--ext:#c39bd3; --extbg:#241a2b;
|
||
--bnd:#ff9f6b; --bndbg:#2c1c12;
|
||
--warn:#e08080; --warnbg:#2e1616;
|
||
--ok:#7fbf7f;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;height:100%}
|
||
body{background:var(--bg);color:var(--fg);font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif;overflow:hidden}
|
||
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||
|
||
#app{display:grid;grid-template-columns:250px 1fr 420px;grid-template-rows:auto 1fr;height:100vh}
|
||
header{grid-column:1/-1;display:flex;align-items:center;gap:18px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
|
||
header h1{font-size:15px;margin:0;font-weight:650;letter-spacing:.2px}
|
||
header .meta{color:var(--dim2);font-size:12px}
|
||
header .meta b{color:var(--dim);font-weight:500}
|
||
|
||
nav{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:12px 10px}
|
||
nav h2{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim2);margin:14px 6px 6px}
|
||
nav h2:first-child{margin-top:0}
|
||
.viewbtn{display:block;width:100%;text-align:left;background:transparent;border:1px solid transparent;color:var(--dim);
|
||
padding:7px 9px;border-radius:6px;cursor:pointer;font:inherit;font-size:13px}
|
||
.viewbtn:hover{background:var(--panel2);color:var(--fg)}
|
||
.viewbtn.on{background:#1d2c47;border-color:var(--line2);color:#fff}
|
||
.viewbtn small{display:block;color:var(--dim2);font-size:11px;line-height:1.35;margin-top:2px}
|
||
.toggle{display:flex;align-items:center;gap:8px;padding:5px 7px;color:var(--dim);font-size:12.5px;cursor:pointer;border-radius:5px}
|
||
.toggle:hover{background:var(--panel2)}
|
||
.toggle input{accent-color:#5b8ff9}
|
||
.legend{display:flex;flex-wrap:wrap;gap:5px;padding:4px 6px}
|
||
.legend span{font-size:10.5px;padding:2px 6px;border-radius:99px;border:1px solid var(--line2);color:var(--dim)}
|
||
|
||
/* capability matrix and invariants */
|
||
.capsel{display:flex;gap:6px;margin:0 0 14px}
|
||
.capsel button{background:var(--panel2);border:1px solid var(--line);color:var(--dim);padding:5px 11px;border-radius:6px;cursor:pointer;font:inherit;font-size:12.5px}
|
||
.capsel button.on{background:#1d2c47;border-color:var(--line2);color:#fff}
|
||
table.mx{border-collapse:collapse;width:100%;font-size:12.5px}
|
||
table.mx th{text-align:left;font-weight:500;color:var(--dim2);font-size:10px;letter-spacing:.1em;text-transform:uppercase;padding:0 6px 7px;vertical-align:bottom}
|
||
table.mx th.d{text-align:center;width:64px}
|
||
table.mx tr.grp td{padding:16px 6px 5px;color:var(--dim);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;border-bottom:1px solid var(--line)}
|
||
table.mx tbody tr.cap{cursor:pointer}
|
||
table.mx tbody tr.cap:hover td{background:var(--panel2)}
|
||
table.mx tbody tr.cap.sel td{background:#1b2b45}
|
||
table.mx td{padding:4px 6px;border-bottom:1px solid #1b2230}
|
||
table.mx td.n{font-weight:600}
|
||
table.mx td.n em{font-style:normal;color:var(--dim2);font-weight:400;font-size:11px;margin-left:7px}
|
||
table.mx td.d{text-align:center}
|
||
.dot{display:inline-block;width:11px;height:11px;border-radius:3px;border:1px solid #0006}
|
||
.dot.yes{background:#4f9d69}.dot.partial{background:#c8992e}.dot.no{background:#3a4256}
|
||
.dot.speconly{background:#3a4256;border-style:dashed;border-color:#6b7896}
|
||
.gapc{font-size:10.5px;color:var(--dim2)}
|
||
.gapc.missing{color:#e08080}.gapc.unreachable{color:#e0a060}.gapc.partial{color:#c8992e}
|
||
.mark{font-size:10px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:99px;border:1px solid var(--line2)}
|
||
.mark.explicit{color:#7fbf7f;border-color:#3d6b43}
|
||
.mark.implied{color:#e0b050;border-color:#6b5a26}
|
||
.mark.unresolved{color:#e08080;border-color:#6b3838}
|
||
.inv{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:12px 14px;margin-bottom:12px}
|
||
.inv h4{margin:0 0 6px;font-size:14px;display:flex;align-items:center;gap:10px}
|
||
.inv h4 span.n{color:var(--dim2);font-weight:400}
|
||
.inv .q{color:#e0b8b8;font-size:12.5px;margin:8px 0 0}
|
||
.bars{display:flex;gap:14px;margin:9px 0 4px;flex-wrap:wrap}
|
||
.bar{font-size:10.5px;color:var(--dim2)}
|
||
.bar b{display:block;font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim2);font-weight:500;margin-bottom:3px}
|
||
.bar .v{color:var(--fg);font-size:12px}
|
||
.bar .v.warn{color:#e08080}.bar .v.mid{color:#c8992e}.bar .v.ok{color:#7fbf7f}
|
||
.cchip{display:inline-block;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:3px 8px;margin:3px 4px 0 0;
|
||
font-size:11.5px;cursor:pointer;color:var(--dim)}
|
||
.cchip:hover{border-color:var(--line2);color:var(--fg)}
|
||
.cchip.off{border-color:#6b5a26;color:#e0b050}
|
||
.cchip.pl{border-color:#6b3838;color:#e08080}
|
||
.crit{border-left:2px solid var(--line2);padding:0 0 0 10px;margin:0 0 11px}
|
||
.crit .vd{font-size:10px;letter-spacing:.08em;text-transform:uppercase;margin-right:8px}
|
||
.crit .vd.pass{color:#7fbf7f}.crit .vd.fail{color:#e08080}.crit .vd.blocked{color:#e0a060}
|
||
.crit .vd.untested{color:var(--dim2)}.crit .vd.unknown{color:#b79bf0}
|
||
.crit .rs{color:var(--dim2);font-size:11px}
|
||
.crit p{margin:5px 0 0;color:var(--dim);font-size:12px}
|
||
main{position:relative;overflow:auto;padding:18px 20px 60px}
|
||
.lane{margin-bottom:20px}
|
||
.lane-h{display:flex;align-items:baseline;gap:10px;margin:0 0 8px;cursor:pointer;user-select:none}
|
||
.lane-h b{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim)}
|
||
.lane-h i{font-style:normal;color:var(--dim2);font-size:11px}
|
||
.lane-h .caret{color:var(--dim2);font-size:11px;width:10px}
|
||
.chips{display:flex;flex-wrap:wrap;gap:8px}
|
||
.chip{position:relative;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;cursor:pointer;
|
||
max-width:280px;transition:border-color .12s,background .12s}
|
||
.chip:hover{border-color:var(--line2)}
|
||
.chip.sel{border-color:#7fb3ff;background:#1b2b45;box-shadow:0 0 0 1px #7fb3ff44}
|
||
.chip.rel{border-color:#4a5f8f}
|
||
.chip.dim{opacity:.28}
|
||
.chip .nm{font-weight:600;font-size:13px}
|
||
.chip .ty{font-size:10.5px;color:var(--dim2);letter-spacing:.04em;text-transform:uppercase}
|
||
.chip .badges{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap}
|
||
.b{font-size:9.5px;padding:1px 5px;border-radius:99px;border:1px solid currentColor;letter-spacing:.03em}
|
||
.b.off{color:#c9a227}.b.nd{color:#c98a27}.b.pl{color:#a07fe0}.b.tmp{color:#8d9bb5}.b.pw{color:#e08080}
|
||
.b.lo{color:#e08080}.b.me{color:#c9a227}
|
||
.chip[data-t=process]{border-left:3px solid var(--proc)}
|
||
.chip[data-t=service],.chip[data-t=worker],.chip[data-t=handler]{border-left:3px solid var(--svc)}
|
||
.chip[data-t=arbitration],.chip[data-t=query_source]{border-left:3px solid #ffd479}
|
||
.chip[data-t=storage],.chip[data-t=table]{border-left:3px solid var(--store)}
|
||
.chip[data-t=model]{border-left:3px solid var(--model)}
|
||
.chip[data-t=adapter]{border-left:3px solid var(--adapter)}
|
||
.chip[data-t=external]{border-left:3px solid var(--ext)}
|
||
.chip[data-t=boundary]{border-left:3px solid var(--bnd)}
|
||
.chip[data-t="shared-state"]{border-left:3px solid #ff9ec7}
|
||
.chip[data-t=planned]{border-left:3px solid #a07fe0}
|
||
.chip[data-t=config],.chip[data-t=test]{border-left:3px solid var(--dim2)}
|
||
svg.wires{position:absolute;inset:0;pointer-events:none;overflow:visible}
|
||
|
||
aside{border-left:1px solid var(--line);background:var(--panel);overflow-y:auto;padding:16px 16px 60px}
|
||
aside .empty{color:var(--dim2);font-size:13px;margin-top:30px;line-height:1.7}
|
||
aside h3{margin:0 0 2px;font-size:16px}
|
||
aside .sub{color:var(--dim2);font-size:11px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}
|
||
aside section{margin-top:16px}
|
||
aside section > h4{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim2);margin:0 0 6px}
|
||
aside p{margin:0 0 8px;color:#c9d3e6}
|
||
.note{background:#1c1f14;border-left:2px solid #c9a227;padding:8px 10px;border-radius:0 5px 5px 0;color:#ded6b6;font-size:12.5px}
|
||
ul.plain{list-style:none;margin:0;padding:0}
|
||
ul.plain li{padding:3px 0;border-bottom:1px solid #1d2433;font-size:12.5px}
|
||
ul.plain li:last-child{border-bottom:0}
|
||
.rel{display:block;width:100%;text-align:left;background:transparent;border:0;color:#a9c6f5;cursor:pointer;font:inherit;font-size:12.5px;padding:3px 0}
|
||
.rel:hover{color:#fff;text-decoration:underline}
|
||
.rel .k{display:inline-block;min-width:66px;color:var(--dim2);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em}
|
||
.rel .ev{display:block;color:var(--dim2);font-size:11px;margin-left:66px;line-height:1.4}
|
||
pre.mm{white-space:pre-wrap;word-break:break-word;background:#0b0e13;border:1px solid var(--line);border-radius:6px;
|
||
padding:12px;font-size:11.5px;color:#b8c6de;overflow-x:auto;max-height:none}
|
||
.searchbox{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:6px;color:var(--fg);
|
||
padding:7px 9px;font:inherit;font-size:12.5px}
|
||
.searchbox:focus{outline:none;border-color:var(--line2)}
|
||
.results{margin-top:6px;max-height:280px;overflow:auto}
|
||
.results button{display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--dim);
|
||
padding:5px 7px;border-radius:5px;cursor:pointer;font:inherit;font-size:12px}
|
||
.results button:hover{background:var(--panel2);color:#fff}
|
||
.results button em{font-style:normal;color:#ffd479}
|
||
.flowsel{display:flex;gap:6px;margin-bottom:14px;flex-wrap:wrap}
|
||
.flowsel button{background:var(--panel2);border:1px solid var(--line);color:var(--dim);border-radius:6px;
|
||
padding:6px 11px;cursor:pointer;font:inherit;font-size:12.5px}
|
||
.flowsel button.on{background:#1d2c47;border-color:var(--line2);color:#fff}
|
||
ol.steps{counter-reset:s;list-style:none;margin:0;padding:0;max-width:1000px}
|
||
ol.steps li{position:relative;padding:9px 12px 9px 44px;border-left:2px solid var(--line);margin-left:14px}
|
||
ol.steps li:before{counter-increment:s;content:counter(s);position:absolute;left:-13px;top:9px;width:24px;height:24px;
|
||
border-radius:99px;background:var(--panel2);border:1px solid var(--line2);color:var(--dim);font-size:11px;
|
||
display:flex;align-items:center;justify-content:center}
|
||
ol.steps li.branch{border-left-color:var(--warn);background:#1e1414}
|
||
ol.steps li.branch:before{border-color:var(--warn);color:#e08080}
|
||
ol.steps b{color:#fff}
|
||
ol.steps .who{display:inline-block;background:#1d2c47;border:1px solid var(--line2);border-radius:4px;
|
||
padding:0 6px;font-size:11px;color:#a9c6f5;cursor:pointer;margin-right:8px}
|
||
ol.steps .who:hover{color:#fff;border-color:#7fb3ff}
|
||
ol.steps .ev{display:block;color:var(--dim2);font-size:11.5px;margin-top:3px}
|
||
.viewnote{max-width:1000px;color:var(--dim);font-size:12.5px;background:var(--panel2);border:1px solid var(--line);
|
||
border-radius:7px;padding:11px 13px;margin-bottom:18px}
|
||
.viewnote b{color:var(--fg)}
|
||
.dia{margin-bottom:20px;border:1px solid var(--line);border-radius:8px;background:#0b0e13;overflow:hidden}
|
||
.dia-h{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--panel2);border-bottom:1px solid var(--line);cursor:pointer;user-select:none}
|
||
.dia-h b{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim)}
|
||
.dia-h .fn{color:var(--dim2);font-size:11px}
|
||
.dia-h .zoom{margin-left:auto;display:flex;gap:4px}
|
||
.dia-h .zoom button{background:var(--panel);border:1px solid var(--line);color:var(--dim);border-radius:4px;
|
||
width:24px;height:22px;cursor:pointer;font:inherit;font-size:12px;line-height:1}
|
||
.dia-h .zoom button:hover{color:#fff;border-color:var(--line2)}
|
||
.dia-body{overflow:auto;max-height:70vh;padding:10px}
|
||
.dia-body > div{transform-origin:0 0}
|
||
.dia-body svg{max-width:none;height:auto;display:block}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<header>
|
||
<h1>Maven — architecture as built</h1>
|
||
<div class="meta">commit <b id="commit"></b> · <b id="gen"></b> · <b id="counts"></b></div>
|
||
<div class="meta" id="dirty"></div>
|
||
</header>
|
||
|
||
<nav>
|
||
<h2>Views</h2>
|
||
<div id="views"></div>
|
||
<h2>Search</h2>
|
||
<input class="searchbox" id="q" placeholder="component, file or symbol">
|
||
<div class="results" id="results"></div>
|
||
<h2>Filters</h2>
|
||
<label class="toggle"><input type="checkbox" id="tLow" checked> show low-confidence relations</label>
|
||
<label class="toggle"><input type="checkbox" id="tMed" checked> show medium-confidence relations</label>
|
||
<label class="toggle"><input type="checkbox" id="tOff" checked> show configured-off</label>
|
||
<label class="toggle"><input type="checkbox" id="tUndeployed" checked> show built-not-deployed</label>
|
||
<label class="toggle"><input type="checkbox" id="tPlanned" checked> show planned / unwired</label>
|
||
<label class="toggle"><input type="checkbox" id="tWires" checked> draw relation wires</label>
|
||
<h2>Type</h2>
|
||
<div class="legend" id="legend"></div>
|
||
</nav>
|
||
|
||
<main id="main"></main>
|
||
<aside id="side"><div class="empty">Select a component to see its responsibility, the files and symbols it was read from, and every relation in and out.<br><br>Every claim here cites a file. Nothing is inferred from a directory name.</div></aside>
|
||
</div>
|
||
|
||
<script>
|
||
/*__DATA__*/
|
||
|
||
const byId = Object.fromEntries(ARCH.components.map(c => [c.id, c]));
|
||
const S = { view: 'v1', sel: null, selCap: null, capBy: 'section', flow: 'reminder', collapsed: {}, diaClosed: false, diaZoom: 1 };
|
||
|
||
/* ---------------- view definitions ---------------- */
|
||
const VIEWS = [
|
||
{ id:'v1', name:'1 · System topology', hint:'Processes and external systems, with process boundaries drawn.',
|
||
note:'<b>mavend is the centre because the code makes it one.</b> It is the only holder of the database key, it owns the store, the IPC socket, the voice TCP listener, the tick loop, eight background workers and the child llama-server. Every other daemon is key-free and fail-independent. Five services run under docker-compose; two more are built and commented out; two run under systemd on the workstation.',
|
||
lanes:[
|
||
['homesrv — docker compose', c => c.group==='homesrv' && ['process','model','boundary','external'].includes(c.type)],
|
||
['workpc — systemd user units', c => c.group==='workpc'],
|
||
['ecosystem network', c => c.group==='ecosystem'],
|
||
['internet / LAN', c => ['internet','lan'].includes(c.group)],
|
||
['dev and recovery binaries', c => c.group==='dev' || ['proc.mavseal','proc.mavupdate'].includes(c.id)],
|
||
['configuration boundary', c => c.type==='config'],
|
||
]},
|
||
{ id:'v2', name:'2 · Core internals', hint:'The real path through mavend, in the order runTurn runs it.',
|
||
note:'The implementation does <b>not</b> follow input → routing → intent → state → tools → response. Eleven stateful pre-emptors get first refusal <b>before</b> routing; the routing cascade is four arms deep; and an <code>IntentQuery</code> then enters a <b>second</b> arbitration of twenty-two ordered sources. The proactive half shares no code with any of it.',
|
||
lanes:[
|
||
['input and entry', c => ['core.voice_server','core.ipc_server','core.daemon_api','core.intake_api','core.store_api','core.stt_seam','core.telegram_intake','core.auth_gate','core.daemon_lock'].includes(c.id)],
|
||
['turn pipeline', c => ['core.reactive_handler','core.turn_route','core.preroute','core.action_table'].includes(c.id)],
|
||
['routing cascade', c => c.id.startsWith('router.')],
|
||
['intent handlers', c => c.type==='handler' || c.id==='core.query_chain'],
|
||
['query sources — the second arbitration, in table order', c => c.type==='query_source'],
|
||
['response generation', c => ['core.replier','core.phraser','core.tts_seam','core.model_seam','core.recall','core.topics'].includes(c.id)],
|
||
['proactive half', c => ['core.tick_loop','core.gatherer','core.rules','core.pattern','core.morning','core.routines','core.dispatcher','core.sink_voice','core.sink_ntfy','core.sink_telegram'].includes(c.id)],
|
||
['background workers', c => c.type==='worker' && c.id!=='core.tick_loop' && c.id!=='core.telegram_intake'],
|
||
['dark capabilities — wired, no config block', c => ['core.vision','core.capture','core.speaker','core.mail_intake','core.modelswap','core.netscan','core.memory_eval'].includes(c.id)],
|
||
['construction and diagnosis', c => ['core.wiring','core.decision_trace','core.event_bus'].includes(c.id)],
|
||
]},
|
||
{ id:'v3', name:'3 · Runtime flow', hint:'Three representative requests traced through real code.', flow:true },
|
||
{ id:'v4', name:'4 · State ownership', hint:'Every persistent and shared store, its owner, writers and readers.',
|
||
note:'One process owns the database and every write is serialised at it: <code>SetMaxOpenConns(1)</code>. Three tables are nevertheless written by components that do not know about each other — <b>facts</b> by nine, <b>notes</b> by six, <b>tools</b> by four. Two files live outside the database entirely, and together they weaken the at-rest key.',
|
||
lanes:[
|
||
['authoritative owner', c => ['proc.mavend','state.db'].includes(c.id)],
|
||
['database lifecycle', c => ['state.db_file','state.db_tmpfs','state.wrapped_key','proc.mavseal'].includes(c.id)],
|
||
['tables written by unrelated components', c => ['state.facts','state.notes','state.tools'].includes(c.id)],
|
||
['singly-owned tables', c => c.type==='table' && !['state.facts','state.notes','state.tools'].includes(c.id)],
|
||
['shared mutable state — process-local', c => c.type==='shared-state'],
|
||
['state outside the database', c => ['state.passkey_file','state.maildata','state.media_blobs'].includes(c.id)],
|
||
['writers', c => (c.writes||[]).length>0 && c.type!=='table'],
|
||
['readers', c => (c.reads||[]).length>0 && c.type!=='table' && !(c.writes||[]).length],
|
||
]},
|
||
{ id:'v5', name:'5 · Dependency and boundary map', hint:'Components, not classes. Cycles, cross-layer calls, fan-in and fan-out.',
|
||
note:'The one <b>cycle</b> is deliberate and documented at both ends: <code>daemonAPI.chatFn = handler.handleText</code> and <code>handler.api</code> back-patched by <code>upgradeAPI</code>. The <b>fan-in</b> hotspot is <code>ipc.Server</code>, reached by six processes and carrying eight function fields that bypass CoreAPI entirely. The <b>fan-out</b> hotspots are <code>reactiveHandler</code> (34 fields) and <code>tickLoop</code> (thirteen jobs in one function).',
|
||
lanes:[
|
||
['process and network boundaries', c => c.type==='boundary'],
|
||
['entry layer', c => ['core.ipc_server','core.voice_server','core.telegram_intake','core.auth_gate'].includes(c.id)],
|
||
['API layer', c => c.type==='adapter'],
|
||
['turn layer — fan-out hotspot', c => ['core.reactive_handler','core.turn_route','core.preroute','core.action_table','core.query_chain'].includes(c.id)],
|
||
['routing layer', c => c.id.startsWith('router.')],
|
||
['proactive layer — fan-out hotspot', c => ['core.tick_loop','core.gatherer','core.rules','core.dispatcher'].includes(c.id)],
|
||
['construction layer', c => ['core.wiring'].includes(c.id)],
|
||
['state layer', c => ['state.db'].includes(c.id) || c.type==='shared-state'],
|
||
['evaluation and gates', c => c.type==='test'],
|
||
]},
|
||
{ id:'c1', name:'6 · Capabilities', hint:'Every capability against the seven dimensions. Sorted by the spec, or by domain.', caps:true,
|
||
note:'<b>Nothing here is asserted.</b> The six build dimensions come from the <code>status</code> field of every component the capability maps to, and <code>verified</code> comes from a probe run against the deployed stack. A capability can be coded and unwired, wired and unconfigured, or configured and undeployed, and those are three different pieces of work — which is why this is not one <code>implemented</code> column. Source: <code>docs/capabilities/ledger.yaml</code>.' },
|
||
{ id:'c2', name:'7 · Invariants', hint:'The twelve cross-cutting rules, and which components participate in each.', inv:true,
|
||
note:'These rules run across all 51 capabilities and no capability\'s definition of done states any of them, so breaking one breaks many at once without producing a single failing criterion. <b>Target</b> is whether the rule is written down. <b>Implementation</b> is the status of the components that participate. <b>Runtime</b> is what the probe run observed for the capabilities it touches. Source: <code>docs/capabilities/invariants.yaml</code>, prose and evidence in <code>invariants.md</code>.' },
|
||
];
|
||
|
||
/* ---------------- runtime flows ---------------- */
|
||
const FLOWS = {
|
||
reminder: { name:'A reminder request', file:'03a-flow-reminder.mmd', steps:[
|
||
{who:['proc.mavwaked'], t:'The keyword head scores the utterance at or above 0.999 and silero VAD closes it. One clean blob ships over the ssh tunnel.', ev:'cmd/mavwaked/wakeword.go, deploy/mavwaked.service'},
|
||
{who:['core.voice_server','core.reactive_handler'], t:'The conn already has a Session; HandlePushToTalk transcribes and enters runTurn.', ev:'internal/voice/server.go, cmd/mavend/voice.go'},
|
||
{who:['core.decision_trace'], t:'A decision record is installed on the context before anything can claim the turn, so the mic, telegram and the web leave the same trail.', ev:'cmd/mavend/voice.go step 0'},
|
||
{who:['core.preroute'], t:'Eleven rungs get first refusal. None claims "напомни позвонить маме".', ev:'cmd/mavend/voice.go steps 1 to 5e, preRouteLadder'},
|
||
{who:['router.stage0','router.cascade'], t:'ReminderGrammar matches at stage 0 and wins outright at confidence 1.0. The extractor then fills the slots the grammar did not match.', ev:'internal/router/stagezero.go, router.go fillMatchedSlots'},
|
||
{who:['core.reactive_handler'], t:'BRANCH — the route is confident and incomplete. missingFor names `time`, so step 8 fires even though dec.Clarify is false.', ev:'cmd/mavend/voice.go step 8, Vikunja #557', branch:true},
|
||
{who:['state.clarify_store'], t:'The request is parked as a PendingQuestion and she asks one question about one thing. The store is in memory on purpose: a restart expires it.', ev:'internal/dialogue/clarify.go, cmd/mavend/clarify.go'},
|
||
{who:['core.preroute'], t:'The next utterance is claimed by rung 4, resolveClarifyAnswer, and parsed with the same parsers stage 2 uses.', ev:'cmd/mavend/clarify.go finishClarified'},
|
||
{who:['core.action_reminder'], t:'ResolvedTheHour guards a time the parser did not really read. The row is written, and the confirmation is phrased FROM THE ROW, never from the utterance.', ev:'cmd/mavend/actions_reminder.go, Vikunja #507'},
|
||
{who:['state.reminders'], t:'One append. cron and next_fire_ts exist as columns and this path never sets them.', ev:'internal/store/reminders.go, migrations.go #2', branch:true},
|
||
{who:['core.tick_loop','core.gatherer'], t:'Later, on a 60s ticker: the gatherer collapses due reminders by delivery group and RemindDecisions bypasses the restraint gate.', ev:'internal/loop/gather.go collapseReminders, loop.go RemindDecisions'},
|
||
{who:['core.dispatcher','state.delivery_attempts'], t:'The outbox records intent BEFORE the external send, so a crash leaves a pending row rather than silence.', ev:'internal/delivery/dispatcher.go beginReminderOutbox'},
|
||
{who:['core.sink_voice','core.sink_telegram'], t:'Voice when a session is live; away, ntfy is nil because the config disables it, so telegram carries it. A definite failure advances the persisted bounded backoff.', ev:'internal/delivery/channel.go ChannelsFor, cmd/mavend/main.go wireNtfySink'},
|
||
]},
|
||
fact: { name:'A factual / state update', file:'03b-flow-fact.mmd', steps:[
|
||
{who:['core.reactive_handler','router.cascade'], t:'"выпил воды" reaches the cascade. Stage 0 declines, the heads or the LLM router or the classifier names IntentFact with a key and a value.', ev:'internal/router/router.go Route'},
|
||
{who:['core.action_fact'], t:'BRANCH — a question-shaped utterance is never a fact. It is re-routed into actionQuery with the model-guessed key cleared, and the stage 0 world destination reconstructed so the boundary cannot claim it.', ev:'cmd/mavend/actions_fact.go, Vikunja #470', branch:true},
|
||
{who:['core.action_fact','core.action_chat'], t:'BRANCH — a passing complaint is not a fact either. It becomes chat and stores nothing, because recall reads a self row back later as if it were still true.', ev:'cmd/mavend/actions_fact.go, Vikunja #481', branch:true},
|
||
{who:['core.intake_api','state.facts'], t:'WriteFact appends kind=self, source=tap:voice, Subject=Key. Confidence is 1.0 only for a value he actually said. The decorator publishes one intake envelope.', ev:'cmd/mavend/actions_fact.go factConfidence, cmd/mavend/intake.go'},
|
||
{who:['state.memory_vectors'], t:'The key’s old vectors are pruned, then the FACT text is embedded with the passage prefix and inserted. The utterance rides along as provenance and is never embedded.', ev:'cmd/mavend/actions_fact.go pruneFactVectors, Vikunja #493'},
|
||
{who:['core.reactive_handler'], t:'Step 9b: a fact that answers a live nudge closes it as `acted`, silently. The fact reply stands.', ev:'cmd/mavend/ack.go ackFromFact'},
|
||
{who:['core.fact_enrichment','ext.nexus'], t:'Asynchronously, the enrichment worker resolves Subject to a canonical entity id with per-fact backoff. An ambiguous result is NOT stored.', ev:'cmd/mavend/factenrichment.go resolveOne'},
|
||
{who:['core.tick_loop','core.morning'], t:'On the next tick the morning routine sees the item evidenced inside its window and will not nudge for it.', ev:'cmd/mavend/tick_morning.go gatherMorningFacts'},
|
||
{who:['core.pattern','state.events'], t:'detectPatterns scans every action+object pair for a stable interval and may propose a routine. notify is false in the deployed config, so it proposes silently.', ev:'cmd/mavend/tick_routines.go, deploy/mavend.json pattern_proposals'},
|
||
{who:['state.facts'], t:'A wrong value is superseded, never overwritten: voids_id points at the row it cancels, and both correction paths drop the key’s vectors.', ev:'internal/store/schema.sql, internal/store/facts.go'},
|
||
]},
|
||
world: { name:'A world query, tool-backed', file:'03c-flow-world-query.mmd', steps:[
|
||
{who:['router.stage0'], t:'"что такое TCP?" matches WorldQueryGrammars, a literal definition frame. That match sets Source=SourceWorld AND SourceAnchored, which happens here and nowhere else in the cascade.', ev:'internal/router/router.go d.SourceAnchored = d.Source != SourceUnknown'},
|
||
{who:['core.query_chain','core.decision_trace'], t:'actionQuery declares the full 22-source roster to the record, so a reader can tell "looked and passed" from "never asked".', ev:'cmd/mavend/actions_query.go decision.Expect'},
|
||
{who:['core.query_chain'], t:'queryWalk removes only the sources marked guesses:true whose destination is not world. Sources that LOOK are all still asked, because a named destination is evidence and not a promise.', ev:'cmd/mavend/actions_query.go queryWalk'},
|
||
{who:['core.q.personal'], t:'BRANCH — the personal boundary is dropped only because a literal pattern named the destination. A model or a softmax naming SourceWorld would NOT drop it.', ev:'cmd/mavend/actions_query.go queryWalk anchored, V-666', branch:true},
|
||
{who:['core.q.factbykey','core.q.memory','core.q.notes'], t:'His own data still gets its turn: fact-by-key, the day plan, tasks, money, history, the calendar, then the three recall passes gated by min score 0.80 and min margin 0.008.', ev:'cmd/mavend/actions_query.go querySources, deploy/mavend.json'},
|
||
{who:['core.q.search','ext.searxng'], t:'SearXNG is asked verbatim, with no rewriter. Only the query string leaves the box: no note, no fact, no persona block, no history.', ev:'cmd/mavend/actions_query.go querySearch'},
|
||
{who:['core.phraser'], t:'The snippets are handed over as evidence for the question, trimmed under one budget, and phrased. With no phraser the best snippet is read back rather than pretending the search did not happen.', ev:'cmd/mavend/actions_query.go phraseSource, readBack'},
|
||
{who:['core.q.kiwix','ext.kiwix'], t:'BRANCH — empty or unreachable falls through to the offline ZIMs, Russian first. No results there is not announced.', ev:'cmd/mavend/actions_query.go queryKiwix', branch:true},
|
||
{who:['core.q.web'], t:'A page he named by URL is read only if he actually said a URL, and it sits AFTER the ZIMs on purpose.', ev:'cmd/mavend/actions_query.go queryWeb, Vikunja #259'},
|
||
{who:['core.q.general'], t:'Last: the resident model answers from its own weights. Response.Empty() is the whole gate on a world answer; there is no quality threshold in front of it.', ev:'cmd/mavend/actions_query.go queryGeneral, CLAUDE.md'},
|
||
{who:['core.query_chain'], t:'Whichever source claimed is logged and noted on the turn sink, so /chat can show it. Everyone below the winner is recorded as NeverAsked.', ev:'cmd/mavend/querysource.go noteQuerySource, Vikunja #474'},
|
||
]},
|
||
};
|
||
|
||
/* ---------------- filters ---------------- */
|
||
const T = id => document.getElementById(id).checked;
|
||
function statusHidden(st){
|
||
if (st==='configured-off') return !T('tOff');
|
||
if (st==='built-not-deployed') return !T('tUndeployed');
|
||
if (st==='planned-unwired'||st==='dead') return !T('tPlanned');
|
||
return false;
|
||
}
|
||
function edgeHidden(e){
|
||
if (e.confidence==='low' && !T('tLow')) return true;
|
||
if (e.confidence==='medium' && !T('tMed')) return true;
|
||
return statusHidden(e.status);
|
||
}
|
||
const edgesOf = id => ARCH.edges.filter(e => e.from===id || e.to===id);
|
||
|
||
/* ---------------- rendering ---------------- */
|
||
function badges(c){
|
||
const out=[];
|
||
if (c.status!=='implemented') out.push(`<span class="b ${ {'configured-off':'off','built-not-deployed':'nd','planned-unwired':'pl','temporary':'tmp','partially-wired':'pw','dead':'pl'}[c.status]||'tmp'}">${c.status}</span>`);
|
||
if (c.confidence==='low') out.push('<span class="b lo">uncertain</span>');
|
||
if (c.confidence==='medium') out.push('<span class="b me">medium confidence</span>');
|
||
return out.length?`<div class="badges">${out.join('')}</div>`:'';
|
||
}
|
||
|
||
function chipHTML(c){
|
||
return `<div class="chip" data-id="${c.id}" data-t="${c.type}">
|
||
<div class="nm">${c.id.split('.').pop().replace(/_/g,' ')}</div>
|
||
<div class="ty">${c.type} · ${c.id}</div>${badges(c)}</div>`;
|
||
}
|
||
|
||
function renderView(){
|
||
const v = VIEWS.find(x=>x.id===S.view);
|
||
const main = document.getElementById('main');
|
||
if (v.flow) return renderFlow(main);
|
||
if (v.caps) return renderCaps(main);
|
||
if (v.inv) return renderInv(main);
|
||
let html = v.note ? `<div class="viewnote">${v.note}</div>` : '';
|
||
html += diagramPanel(diagramFileFor());
|
||
const used = new Set();
|
||
v.lanes.forEach(([title, pred], i) => {
|
||
const items = ARCH.components.filter(c => !used.has(c.id) && pred(c) && !statusHidden(c.status));
|
||
items.forEach(c=>used.add(c.id));
|
||
if (!items.length) return;
|
||
const key = v.id+':'+i, open = !S.collapsed[key];
|
||
html += `<div class="lane"><div class="lane-h" data-lane="${key}">
|
||
<span class="caret">${open?'▾':'▸'}</span><b>${title}</b><i>${items.length}</i></div>
|
||
<div class="chips" ${open?'':'style="display:none"'}>${items.map(chipHTML).join('')}</div></div>`;
|
||
});
|
||
html += `<svg class="wires" id="wires"></svg>`;
|
||
main.innerHTML = html;
|
||
main.querySelectorAll('.lane-h').forEach(h=>h.onclick=()=>{ S.collapsed[h.dataset.lane]=!S.collapsed[h.dataset.lane]; renderView(); paint(); });
|
||
main.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>select(ch.dataset.id));
|
||
wireDiagram();
|
||
requestAnimationFrame(drawWires);
|
||
}
|
||
|
||
/* ---------------- capabilities and invariants ---------------- */
|
||
const DIMS = ['designed','code_present','wired','configured','deployed','reachable','verified'];
|
||
const DIMH = {designed:'design',code_present:'code',wired:'wired',configured:'config',
|
||
deployed:'deploy',reachable:'reach',verified:'verified'};
|
||
const capById = Object.fromEntries(CAPS.capabilities.map(c => [c.id, c]));
|
||
// A criterion is blocked when it was observed blocked, or when its reason names
|
||
// something outside the code as the thing in the way.
|
||
const BLOCKREASON = new Set(['configuration missing','deployment missing',
|
||
'external dependency unavailable','scenario missing']);
|
||
const dotCls = v => v==='spec-only' ? 'speconly' : v;
|
||
|
||
function capRow(c){
|
||
const cls = {'capability missing':'missing','capability exists but unreachable':'unreachable',
|
||
'capability partial':'partial'}[c.implementation.gap_class] || '';
|
||
const gap = c.implementation.gap_class==='none' ? '' : c.implementation.gap_class;
|
||
return `<tr class="cap" data-cap="${c.id}">
|
||
<td class="n">${c.title}${c.scope!=='v1'?'<em>deferred</em>':''}
|
||
<em class="gapc ${cls}">${gap}</em></td>
|
||
${DIMS.map(d=>`<td class="d"><span class="dot ${dotCls(c.implementation[d])}" title="${d}: ${c.implementation[d]}"></span></td>`).join('')}
|
||
</tr>`;
|
||
}
|
||
|
||
function renderCaps(main){
|
||
const v = VIEWS.find(x=>x.id===S.view);
|
||
const by = S.capBy;
|
||
const groups = {};
|
||
CAPS.capabilities.forEach(c => {
|
||
const keys = by==='domain' ? (c.domain.length?c.domain:['unassigned']) : [c.section];
|
||
keys.forEach(k => (groups[k] = groups[k]||[]).push(c));
|
||
});
|
||
const order = by==='domain' ? Object.keys(groups).sort()
|
||
: [...new Set(CAPS.capabilities.map(c=>c.section))];
|
||
main.innerHTML = `<div class="viewnote">${v.note}</div>
|
||
<div class="capsel">
|
||
<button data-by="section" class="${by==='section'?'on':''}">by spec section</button>
|
||
<button data-by="domain" class="${by==='domain'?'on':''}">by domain</button>
|
||
</div>
|
||
<table class="mx"><thead><tr><th>capability</th>
|
||
${DIMS.map(d=>`<th class="d">${DIMH[d]}</th>`).join('')}</tr></thead>
|
||
<tbody>${order.map(g=>`<tr class="grp"><td colspan="8">${g} · ${groups[g].length}</td></tr>`
|
||
+ groups[g].map(capRow).join('')).join('')}</tbody></table>`;
|
||
main.querySelectorAll('.capsel button').forEach(b=>b.onclick=()=>{S.capBy=b.dataset.by;renderCaps(main);});
|
||
main.querySelectorAll('tr.cap').forEach(r=>r.onclick=()=>selectCap(r.dataset.cap));
|
||
}
|
||
|
||
function selectCap(id){
|
||
S.sel = null; S.selCap = id;
|
||
document.querySelectorAll('tr.cap').forEach(r=>r.classList.toggle('sel', r.dataset.cap===id));
|
||
renderCapSide(id);
|
||
}
|
||
|
||
function compChip(cid){
|
||
const c = byId[cid];
|
||
const k = c && (c.status==='configured-off' ? 'off'
|
||
: ['planned-unwired','dead','partially-wired'].includes(c.status) ? 'pl' : '');
|
||
return `<button class="cchip ${k}" data-id="${cid}" title="${c?c.status:'unknown'}">${cid}</button>`;
|
||
}
|
||
|
||
function renderCapSide(id){
|
||
const c = capById[id], side = document.getElementById('side');
|
||
const blockers = c.criteria.filter(cr => cr.verified==='blocked' || BLOCKREASON.has(cr.reason));
|
||
const qs = INV.filter(iv => iv.capabilities.includes(id) && iv.question);
|
||
const scen = c.scenarios || [];
|
||
side.innerHTML = `
|
||
<h3>${c.title}</h3>
|
||
<div class="sub">${c.section} · ${c.domain.join(', ')} · ${c.scope}${
|
||
c.implementation.gap_class==='none'?'':' · '+c.implementation.gap_class}</div>
|
||
<p>${c.state}</p>
|
||
<section><h4>Implementation</h4>
|
||
<div class="bars">${DIMS.map(d=>{
|
||
const v = c.implementation[d];
|
||
const k = v==='yes'?'ok':v==='partial'?'mid':'warn';
|
||
return `<div class="bar"><b>${DIMH[d]}</b><span class="v ${k}">${v}</span></div>`;
|
||
}).join('')}</div></section>
|
||
<section><h4>Components — ${(c.components||[]).length}</h4>
|
||
${(c.components||[]).length ? c.components.map(compChip).join('')
|
||
: '<div class="empty" style="margin:0">Nothing carries this capability. That is the finding.</div>'}</section>
|
||
<section><h4>Definition of done — ${c.criteria.length}</h4>
|
||
${c.criteria.map(cr=>`<div class="crit">
|
||
<span class="vd ${cr.verified}">${cr.verified}</span><span class="rs">${cr.reason}</span>
|
||
<p>${cr.text}</p>
|
||
${cr.detail?`<p style="color:var(--dim2)">${cr.detail}</p>`:''}
|
||
${(cr.evidence||[]).length?`<p class="mono" style="font-size:11px;color:var(--dim2)">${cr.evidence.join('<br>')}</p>`:''}
|
||
</div>`).join('')}</section>
|
||
<section><h4>Blockers — ${blockers.length}</h4>
|
||
${blockers.length ? '<ul class="plain">'+blockers.map(b=>`<li>${b.reason} · <span class="mono">${b.id}</span></li>`).join('')+'</ul>'
|
||
: '<div class="empty" style="margin:0">none. Nothing outside the code is in the way.</div>'}</section>
|
||
<section><h4>Scenarios — ${scen.length}</h4>
|
||
${scen.length ? '<ul class="plain">'+scen.map(x=>`<li class="mono">${x.name} ${x.exists?'':'<span class="b off">absent from disk</span>'}</li>`).join('')+'</ul>'
|
||
: '<div class="empty" style="margin:0">none named</div>'}</section>
|
||
<section><h4>Unresolved product questions — ${qs.length}</h4>
|
||
${qs.length ? qs.map(iv=>`<div class="crit"><span class="mark ${iv.mark}">invariant ${iv.id}</span>
|
||
<p>${iv.question}</p></div>`).join('')
|
||
: '<div class="empty" style="margin:0">none</div>'}</section>`;
|
||
side.querySelectorAll('.cchip').forEach(b=>b.onclick=()=>{S.sel=b.dataset.id;renderSide(b.dataset.id);});
|
||
}
|
||
|
||
function invRollup(iv){
|
||
const comps = iv.components.map(x=>byId[x]).filter(Boolean);
|
||
const bad = comps.filter(c=>c.status!=='implemented'&&c.status!=='temporary').length;
|
||
const caps = iv.capabilities.map(x=>capById[x]).filter(Boolean);
|
||
const ver = caps.filter(c=>c.implementation.verified==='yes').length;
|
||
const part = caps.filter(c=>c.implementation.verified==='partial').length;
|
||
return {comps, bad, caps, ver, part};
|
||
}
|
||
|
||
function renderInv(main){
|
||
const v = VIEWS.find(x=>x.id===S.view);
|
||
main.innerHTML = `<div class="viewnote">${v.note}</div>` + INV.map(iv=>{
|
||
const r = invRollup(iv);
|
||
const mk = iv.mark==='explicit'?'ok':iv.mark==='implied'?'mid':'warn';
|
||
const ik = r.bad?'mid':'ok';
|
||
const vk = r.ver===r.caps.length?'ok':(r.ver+r.part)?'mid':'warn';
|
||
return `<div class="inv" data-inv="${iv.id}">
|
||
<h4><span class="n">${iv.id}</span> ${iv.title}
|
||
<span class="mark ${iv.mark}">${iv.mark}${iv.split?' · split':''}</span></h4>
|
||
<div class="bars">
|
||
<div class="bar"><b>target</b><span class="v ${mk}">${
|
||
iv.mark==='explicit'?'written down':iv.mark==='implied'?'not stated':'no answer exists'}</span></div>
|
||
<div class="bar"><b>implementation</b><span class="v ${ik}">${r.comps.length} components, ${r.bad} not live</span></div>
|
||
<div class="bar"><b>runtime verification</b><span class="v ${vk}">${r.ver} of ${r.caps.length} capabilities verified${r.part?', '+r.part+' partly':''}</span></div>
|
||
</div>
|
||
${iv.question?`<p class="q">${iv.question}</p>`:''}
|
||
<div style="margin-top:8px">${iv.capabilities.map(c=>`<button class="cchip" data-cap="${c}">${c}</button>`).join('')}</div>
|
||
<div style="margin-top:4px">${iv.components.map(compChip).join('')}</div>
|
||
</div>`;
|
||
}).join('');
|
||
main.querySelectorAll('.cchip[data-id]').forEach(b=>b.onclick=()=>{S.sel=b.dataset.id;renderSide(b.dataset.id);});
|
||
main.querySelectorAll('.cchip[data-cap]').forEach(b=>b.onclick=()=>renderCapSide(b.dataset.cap));
|
||
}
|
||
|
||
function renderFlow(main){
|
||
const f = FLOWS[S.flow];
|
||
main.innerHTML = `${diagramPanel(FLOWS[S.flow].file)}<div class="viewnote"><b>Three requests, traced through real code.</b> Steps marked in red are branches, fallbacks or refusals the implementation actually takes. Click a component name to open its record. The Mermaid sequence source for each flow is under the panel on the right.</div>
|
||
<div class="flowsel">${Object.entries(FLOWS).map(([k,x])=>`<button data-f="${k}" class="${k===S.flow?'on':''}">${x.name}</button>`).join('')}</div>
|
||
<ol class="steps">${f.steps.map(s=>`<li class="${s.branch?'branch':''}">
|
||
${s.who.map(w=>`<span class="who" data-id="${w}">${byId[w]?byId[w].id:w}</span>`).join('')}
|
||
${s.t}<span class="ev">${s.ev}</span></li>`).join('')}</ol>`;
|
||
main.querySelectorAll('.flowsel button').forEach(b=>b.onclick=()=>{S.flow=b.dataset.f;renderFlow(main);});
|
||
main.querySelectorAll('.who').forEach(b=>b.onclick=()=>select(b.dataset.id));
|
||
wireDiagram();
|
||
}
|
||
|
||
function diagramFileFor(){
|
||
return {v1:'01-system-topology.mmd',v2:'02-core-internals.mmd',
|
||
v4:'04-state-ownership.mmd',v5:'05-dependency-boundary.mmd'}[S.view] || null;
|
||
}
|
||
|
||
// The rendered picture, from the committed SVG beside the .mmd. Absent SVG ⇒
|
||
// no panel at all, rather than an empty frame: `sh docs/architecture/render.sh`
|
||
// is what fills it, and a missing file means that has not been run.
|
||
function diagramPanel(mmFile){
|
||
if (!mmFile) return '';
|
||
const svg = SVG[mmFile.replace(/\.mmd$/, '.svg')];
|
||
if (!svg) return '';
|
||
const open = !S.diaClosed;
|
||
return `<div class="dia">
|
||
<div class="dia-h" id="diaH"><span class="caret">${open?'▾':'▸'}</span><b>Rendered diagram</b>
|
||
<span class="fn">diagrams/${mmFile.replace(/\.mmd$/,'.svg')}</span>
|
||
<span class="zoom"><button data-z="-1" title="zoom out">−</button><button data-z="0" title="fit">◻</button><button data-z="1" title="zoom in">+</button></span>
|
||
</div>
|
||
<div class="dia-body" id="diaBody" ${open?'':'style="display:none"'}><div id="diaScale">${svg}</div></div>
|
||
</div>`;
|
||
}
|
||
|
||
function wireDiagram(){
|
||
const h = document.getElementById('diaH'); if (!h) return;
|
||
const body = document.getElementById('diaBody'), scale = document.getElementById('diaScale');
|
||
// The mermaid SVG carries width="100%" and a viewBox, so it fills whatever
|
||
// box it is given. Widening the wrapper past 100% is the zoom, and the
|
||
// .dia-body scrollbar is what makes the extra width reachable. A CSS
|
||
// transform would scale the scrollport too and clip the bottom of a tall
|
||
// flowchart, which 01-system-topology is at 2304x3542.
|
||
const apply = () => { scale.style.width = (S.diaZoom*100)+'%'; };
|
||
h.onclick = ev => {
|
||
const z = ev.target.closest('button');
|
||
if (z){ ev.stopPropagation();
|
||
const d = +z.dataset.z;
|
||
S.diaZoom = d===0 ? 1 : Math.min(3, Math.max(.25, S.diaZoom + d*0.2));
|
||
apply(); return; }
|
||
S.diaClosed = !S.diaClosed; renderView(); if (S.sel) select(S.sel);
|
||
};
|
||
apply();
|
||
}
|
||
|
||
function drawWires(){
|
||
const svg = document.getElementById('wires');
|
||
if (!svg) return;
|
||
svg.innerHTML='';
|
||
if (!T('tWires') || !S.sel) return;
|
||
const main = document.getElementById('main'), mr = main.getBoundingClientRect();
|
||
const pos = id => { const el = main.querySelector(`.chip[data-id="${id}"]`); if(!el) return null;
|
||
const r = el.getBoundingClientRect();
|
||
return {x:r.left-mr.left+main.scrollLeft+r.width/2, y:r.top-mr.top+main.scrollTop+r.height/2}; };
|
||
const a = pos(S.sel); if (!a) return;
|
||
edgesOf(S.sel).filter(e=>!edgeHidden(e)).forEach(e=>{
|
||
const other = e.from===S.sel ? e.to : e.from, b = pos(other); if (!b) return;
|
||
const out = e.from===S.sel;
|
||
const col = e.confidence==='low' ? '#e08080' : e.confidence==='medium' ? '#c9a227' : (out?'#7fb3ff':'#6ed0a8');
|
||
const mx = (a.x+b.x)/2;
|
||
const p = document.createElementNS('http://www.w3.org/2000/svg','path');
|
||
p.setAttribute('d',`M${a.x},${a.y} C${mx},${a.y} ${mx},${b.y} ${b.x},${b.y}`);
|
||
p.setAttribute('stroke',col); p.setAttribute('stroke-width','1.4'); p.setAttribute('fill','none');
|
||
p.setAttribute('opacity','.75');
|
||
if (e.status!=='implemented') p.setAttribute('stroke-dasharray','5 4');
|
||
svg.appendChild(p);
|
||
});
|
||
}
|
||
|
||
function select(id){
|
||
S.sel = id;
|
||
document.querySelectorAll('.chip').forEach(ch=>{
|
||
ch.classList.remove('sel','rel','dim');
|
||
if (ch.dataset.id===id) ch.classList.add('sel');
|
||
});
|
||
const rel = new Set(edgesOf(id).filter(e=>!edgeHidden(e)).map(e=>e.from===id?e.to:e.from));
|
||
document.querySelectorAll('.chip').forEach(ch=>{
|
||
if (ch.dataset.id!==id) ch.classList.add(rel.has(ch.dataset.id)?'rel':'dim');
|
||
});
|
||
renderSide(id);
|
||
drawWires();
|
||
}
|
||
|
||
function relRow(e, id){
|
||
const out = e.from===id, other = out?e.to:e.from, oc = byId[other];
|
||
const marks=[];
|
||
if (e.confidence!=='high') marks.push(`<span class="b ${e.confidence==='low'?'lo':'me'}">${e.confidence}</span>`);
|
||
if (e.status!=='implemented') marks.push(`<span class="b off">${e.status}</span>`);
|
||
return `<button class="rel" data-id="${other}"><span class="k">${out?'→':'←'} ${e.kind}</span>${oc?oc.id:other} ${marks.join('')}
|
||
<span class="ev">${e.label}${e.evidence?' · '+e.evidence:''}</span></button>`;
|
||
}
|
||
|
||
function renderSide(id){
|
||
const c = byId[id], side = document.getElementById('side');
|
||
if (!c){ side.innerHTML = `<div class="empty">No record for <code>${id}</code>.</div>`; return; }
|
||
const es = edgesOf(id).filter(e=>!edgeHidden(e));
|
||
const outE = es.filter(e=>e.from===id), inE = es.filter(e=>e.to===id);
|
||
const mmFile = S.view==='v3' ? FLOWS[S.flow].file : {v1:'01-system-topology.mmd',v2:'02-core-internals.mmd',v4:'04-state-ownership.mmd',v5:'05-dependency-boundary.mmd'}[S.view];
|
||
side.innerHTML = `
|
||
<h3>${c.id}</h3>
|
||
<div class="sub">${c.type} · ${c.group} · ${c.status} · ${c.confidence} confidence</div>
|
||
<p>${c.responsibility}</p>
|
||
${c.notes?`<div class="note">${c.notes}</div>`:''}
|
||
<section><h4>Files</h4><ul class="plain">${c.files.map(f=>`<li class="mono">${f}</li>`).join('')}</ul></section>
|
||
<section><h4>Symbols</h4><ul class="plain">${c.symbols.map(s=>`<li class="mono">${s}</li>`).join('')}</ul></section>
|
||
<section><h4>Outgoing — ${outE.length}</h4>${outE.map(e=>relRow(e,id)).join('')||'<div class="empty" style="margin:0">none</div>'}</section>
|
||
<section><h4>Incoming — ${inE.length}</h4>${inE.map(e=>relRow(e,id)).join('')||'<div class="empty" style="margin:0">none</div>'}</section>
|
||
${mmFile?`<section><h4>Mermaid source — ${mmFile}</h4><pre class="mm">${MERMAID[mmFile].replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'}[m]))}</pre></section>`:''}`;
|
||
side.querySelectorAll('.rel').forEach(b=>b.onclick=()=>{
|
||
const t=b.dataset.id;
|
||
if (!document.querySelector(`.chip[data-id="${t}"]`)) { renderSide(t); S.sel=t; drawWires(); }
|
||
else select(t);
|
||
});
|
||
}
|
||
|
||
/* ---------------- search ---------------- */
|
||
function search(){
|
||
const q = document.getElementById('q').value.trim().toLowerCase();
|
||
const box = document.getElementById('results');
|
||
if (q.length<2){ box.innerHTML=''; return; }
|
||
const hits=[];
|
||
for (const c of ARCH.components){
|
||
const where=[];
|
||
if (c.id.toLowerCase().includes(q)) where.push('id');
|
||
if (c.responsibility.toLowerCase().includes(q)) where.push('responsibility');
|
||
const f = c.files.filter(x=>x.toLowerCase().includes(q));
|
||
const s = c.symbols.filter(x=>x.toLowerCase().includes(q));
|
||
if (f.length) where.push('file: '+f[0]);
|
||
if (s.length) where.push('symbol: '+s[0]);
|
||
if ((c.notes||'').toLowerCase().includes(q)) where.push('note');
|
||
if (where.length) hits.push([c, where]);
|
||
}
|
||
box.innerHTML = hits.slice(0,60).map(([c,w])=>`<button data-id="${c.id}">${c.id}<br><em>${w.join(' · ')}</em></button>`).join('')
|
||
|| '<button disabled style="color:#5f6c85">no match</button>';
|
||
box.querySelectorAll('button[data-id]').forEach(b=>b.onclick=()=>{
|
||
const el = document.querySelector(`.chip[data-id="${b.dataset.id}"]`);
|
||
if (el){ select(b.dataset.id); el.scrollIntoView({block:'center',behavior:'smooth'}); }
|
||
else { S.sel=b.dataset.id; renderSide(b.dataset.id); }
|
||
});
|
||
}
|
||
|
||
/* ---------------- boot ---------------- */
|
||
document.getElementById('commit').textContent = ARCH.commit.slice(0,7);
|
||
document.getElementById('gen').textContent = ARCH.generated;
|
||
document.getElementById('counts').textContent = `${ARCH.components.length} components · ${ARCH.edges.length} relations`;
|
||
document.getElementById('dirty').textContent = ARCH.working_tree;
|
||
document.getElementById('views').innerHTML = VIEWS.map(v=>`<button class="viewbtn" data-v="${v.id}">${v.name}<small>${v.hint}</small></button>`).join('');
|
||
document.getElementById('legend').innerHTML = [...new Set(ARCH.components.map(c=>c.type))].sort().map(t=>`<span>${t}</span>`).join('');
|
||
|
||
function setView(id){ S.view=id; S.sel=null; S.selCap=null;
|
||
document.querySelectorAll('.viewbtn').forEach(b=>b.classList.toggle('on', b.dataset.v===id));
|
||
renderView();
|
||
const v = VIEWS.find(x=>x.id===id);
|
||
document.getElementById('side').innerHTML = v.caps
|
||
? '<div class="empty">Select a capability to see its definition of done, every verdict and its evidence, the components that carry it, its blockers and the product questions it waits on.<br><br>A dot is never an opinion. Six of the seven come from component status, the seventh from a probe run.</div>'
|
||
: v.inv
|
||
? '<div class="empty">Twelve rules that run across all 51 capabilities. Click a capability or a component to open its record.<br><br>An unresolved rule is a product question, not a defect.</div>'
|
||
: '<div class="empty">Select a component to see its responsibility, the files and symbols it was read from, and every relation in and out.</div>';
|
||
}
|
||
document.querySelectorAll('.viewbtn').forEach(b=>b.onclick=()=>setView(b.dataset.v));
|
||
['tLow','tMed','tOff','tUndeployed','tPlanned','tWires'].forEach(k=>
|
||
document.getElementById(k).onchange=()=>{ renderView(); if(S.sel) select(S.sel); });
|
||
document.getElementById('q').oninput = search;
|
||
document.getElementById('main').addEventListener('scroll', drawWires);
|
||
window.addEventListener('resize', drawWires);
|
||
setView('v1');
|
||
</script>
|
||
</body>
|
||
</html>
|