seisin 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/index.html CHANGED
@@ -86,7 +86,7 @@ button.btn:hover{background:var(--hover)}
86
86
  button.btn.warn{border-color:var(--amber);color:var(--amber)}
87
87
  button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
88
88
 
89
- .cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(168px,1fr))}
89
+ .cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
90
90
  .card{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--line2);
91
91
  border-radius:8px;padding:14px 16px;min-height:96px;display:flex;flex-direction:column}
92
92
  .card.g{border-left-color:var(--green)} .card.a{border-left-color:var(--amber)} .card.b{border-left-color:var(--blue)}
@@ -94,6 +94,51 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
94
94
  .card .n{font-size:28px;font-weight:600;line-height:1;margin-top:12px;font-variant-numeric:tabular-nums;min-width:2.2ch}
95
95
  .card .s{font-size:11px;color:var(--mid);margin-top:5px;min-height:16px}
96
96
  .card.g .n{color:var(--green)} .card.a .n{color:var(--amber)} .card.b .n{color:var(--blue)}
97
+ .card.kpi{text-decoration:none;color:inherit;transition:border-color .12s,transform .12s}
98
+ .card.kpi:hover{border-color:var(--line2)}
99
+ .card.kpi:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
100
+ /* Only when it is not zero. An indicator that shouts at rest is one people
101
+ learn to ignore, and these two are the ones that mean "do something". */
102
+ .card.kpi.live{border-left-color:var(--blue)} .card.kpi.live .n{color:var(--blue)}
103
+ .card.kpi.alarm{border-left-color:var(--amber)} .card.kpi.alarm .n{color:var(--amber)}
104
+ .card.kpi.calm .n{color:var(--dim)}
105
+ /* Left border follows the state too. A card that is amber at zero has cried
106
+ wolf before you read the number. */
107
+ .card.kpi{border-left-color:var(--line2)}
108
+
109
+ /* The shape of the day. A hundred identical rows is not a reading, so each cause gets a
110
+ bar proportional to the others and the eye does the arithmetic. */
111
+ .lede{font-size:14px;line-height:1.55;margin:0 0 16px;color:var(--fg)}
112
+ .lede b{font-weight:600}
113
+ .lede .q{color:var(--mid)}
114
+ .lede code{font-family:var(--mono);color:var(--fg);font-size:13px}
115
+ .pad{padding:16px}
116
+ .bars{display:flex;flex-direction:column;gap:10px}
117
+ .bar{display:grid;grid-template-columns:1fr 132px;gap:14px;align-items:baseline}
118
+ .bar .lbl{min-width:0;font-size:12px}
119
+ .bar .p{font-family:var(--mono);color:var(--fg);overflow-wrap:anywhere}
120
+ .bar .who{color:var(--mid);font-size:11px;margin-top:2px}
121
+ .bar .t{height:6px;border-radius:3px;background:var(--line);overflow:hidden;margin-top:6px}
122
+ .bar .f{height:100%;border-radius:3px;background:var(--blue);display:block}
123
+ .bar.cold .f{background:var(--line2)}
124
+ .bar .num{text-align:right;font-variant-numeric:tabular-nums;font-size:12px;color:var(--fg)}
125
+ .bar .num s{display:block;color:var(--mid);font-size:11px;text-decoration:none;margin-top:2px}
126
+ .bar.cold .p,.bar.cold .num{color:var(--mid)}
127
+ .fam{padding:14px 0;border-top:1px solid var(--line)}
128
+ .fam:first-child{border-top:0;padding-top:0}
129
+ .fam-h{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
130
+ .fam-h code{font-family:var(--mono);font-size:13px;color:var(--fg);font-weight:600}
131
+ .fam-h .num{font-variant-numeric:tabular-nums;font-size:13px;color:var(--fg);white-space:nowrap}
132
+ .fam-h .num s{color:var(--mid);text-decoration:none;font-size:11px;margin-left:4px}
133
+ .fam .t{height:5px;border-radius:3px;background:var(--line);overflow:hidden;margin:8px 0 6px}
134
+ .fam .f{display:block;height:100%;border-radius:3px;background:var(--blue)}
135
+ .fam .who{font-size:11px;color:var(--mid)}
136
+ .places{margin:8px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}
137
+ .places li{display:flex;gap:12px;justify-content:space-between;align-items:baseline;
138
+ font-family:var(--mono);font-size:11px;color:var(--mid)}
139
+ .places li span{overflow-wrap:anywhere}
140
+ .places li em{font-family:inherit;font-style:normal;color:var(--dim)}
141
+ .places li b{color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap}
97
142
 
98
143
  .box{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}
99
144
  .box>header{padding:10px 14px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;
@@ -154,7 +199,7 @@ pre .c{color:var(--dim)} pre .k{color:var(--blue)} pre .d{color:var(--amber)} pr
154
199
  .ev .who em{font-style:normal;color:var(--blue);font-weight:600}
155
200
  .ev .when{font-size:11px;color:var(--dim);flex:0 0 auto;font-variant-numeric:tabular-nums}
156
201
  /* Which writer saw it. The hook reports an attempt and can be talked around; the kernel
157
- reports a refusal and cannot. When the two disagree about a run the reader needs to know
202
+ reports a denial and cannot. When the two disagree about a run the reader needs to know
158
203
  which line carries more weight, so the one that does is the one that is marked — a small
159
204
  word in the margin, not a column. Lines without the field render exactly as before. */
160
205
  .ev .src{font:10px/1.6 var(--mono);color:var(--dim);flex:0 0 auto;padding:0 5px;
@@ -201,6 +246,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
201
246
  /* What a view says when it has nothing to show. Not a shrug: the two reasons a log is empty
202
247
  need different answers, so it names them. */
203
248
  .blank{margin:0;padding:34px 16px;color:var(--fg);font-size:13px;line-height:1.7;text-align:center}
249
+ .blank.quiet{padding:16px;font-size:12px;color:var(--mid)}
250
+ .blank.quiet span{display:inline}
204
251
  .blank span{color:var(--mid);display:block;margin-top:6px;font-size:12px;
205
252
  max-width:58ch;margin-inline:auto;text-wrap:pretty}
206
253
  .blank code{font-family:var(--mono);font-size:11.5px;background:var(--hover);
@@ -248,8 +295,10 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
248
295
  </div>
249
296
  <div class="list">
250
297
  <div class="cap">Policy</div>
251
- <a class="on" href="#policy" aria-current="page"><i>⚷</i>Roles<span class="pin" id="pin"></span></a>
252
- <a href="#requests" id="nav-req"><i>⚖</i>Requests<span class="pin" id="req-pin"></span></a>
298
+ <a class="on" href="#requests" id="nav-req" aria-current="page"><i>⚖</i>Waiting on you<span class="pin" id="req-pin"></span></a>
299
+ <a href="#denied"><i>⊘</i>Denied<span class="pin" id="dn-pin"></span></a>
300
+ <a href="#walls"><i>▦</i>Walls<span class="pin" id="wl-pin"></span></a>
301
+ <a href="#policy"><i>⚷</i>Roles<span class="pin" id="pin"></span></a>
253
302
  <a href="#activity"><i>◷</i>Activity</a>
254
303
  <a href="#configfile"><i>⚙</i>Config file</a>
255
304
  <div class="cap">Docs</div>
@@ -270,15 +319,32 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
270
319
  <button class="btn warn" id="kill" type="button">Unsandbox everything</button>
271
320
  </div>
272
321
 
273
- <section data-view="roles">
274
- <div class="cards">
275
- <div class="card"><h2>roles</h2><div class="n" id="c-roles">0</div><div class="s">declared in seisin.toml</div></div>
276
- <div class="card g"><h2>sandboxed</h2><div class="n" id="c-on">0</div><div class="s">enforced by the OS</div></div>
277
- <div class="card a"><h2>unsandboxed</h2><div class="n" id="c-off">0</div><div class="s" id="c-off-s">nothing enforces these</div></div>
278
- <div class="card"><h2>no keys</h2><div class="n" id="c-nokeys">0</div><div class="s">declare none</div></div>
279
- <div class="card b"><h2>blocks today</h2><div class="n" id="c-blocks">0</div><div class="s">asked outside territory</div></div>
322
+ <!-- OUTSIDE the views, so the same five numbers are on every screen.
323
+ They used to live inside Roles, which meant the only live one —
324
+ denials — was invisible from the three other pages, and four of the
325
+ five were constants: 31 roles, 31 sandboxed, 0 unsandboxed, 31 with no
326
+ keys. A number that reads the same every day is not an indicator, it
327
+ is a caption, and putting it beside a live one teaches people to stop
328
+ reading the row.
329
+
330
+ Each one is a link to the screen that explains it. A number you cannot
331
+ act on is decoration; a number that takes you to the thing it counts
332
+ is the top of a path. -->
333
+ <div class="cards" id="kpis">
334
+ <a class="card kpi" href="#requests" id="k-wait">
335
+ <h2>waiting on you</h2><div class="n" id="c-wait">0</div><div class="s" id="c-wait-s">nothing to decide</div></a>
336
+ <a class="card kpi" href="#policy" id="k-open">
337
+ <h2>unsandboxed</h2><div class="n" id="c-off">0</div><div class="s" id="c-off-s">none are open</div></a>
338
+ <a class="card kpi" href="#denied" id="k-ref">
339
+ <h2>denied</h2><div class="n" id="c-blocks">0</div><div class="s" id="c-blocks-s">in this window</div></a>
340
+ <a class="card kpi" href="#causes" id="k-conc">
341
+ <h2>one cause</h2><div class="n" id="c-conc">—</div><div class="s" id="c-conc-s">of all denials</div></a>
342
+ <a class="card kpi" href="#walls" id="k-retry">
343
+ <h2>spent retrying</h2><div class="n" id="c-retry">0</div><div class="s" id="c-retry-s">calls on known walls</div></a>
280
344
  </div>
281
345
 
346
+ <section data-view="roles">
347
+
282
348
  <div class="box">
283
349
  <header id="policy">
284
350
  <h2>Policy</h2>
@@ -312,16 +378,65 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
312
378
  </div>
313
379
  </section>
314
380
 
381
+ <!-- One view per KPI. A number you can click is the top of a path; a number
382
+ you cannot is decoration, and the five up there all pointed at two
383
+ pages. Each of these is the detail of exactly one of them. -->
384
+
315
385
  <section data-view="requests" hidden>
316
386
  <div class="box" id="requests">
317
387
  <header>
318
- <h2>Waiting on you</h2>
319
- <span class="note">what an agent asked for and cannot have yet — approving happens here or in the terminal, never through MCP</span>
388
+ <h2>Pending decisions</h2>
389
+ <span class="note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
320
390
  </header>
321
391
  <div id="reqs"></div>
322
392
  </div>
323
393
  </section>
324
394
 
395
+ <!-- KPI: denied. Grouped by WHAT was denied, never by who asked — per role
396
+ it is the same list a hundred times over. -->
397
+ <section data-view="denied" hidden>
398
+ <div class="box">
399
+ <header>
400
+ <h2>Denied, by path</h2>
401
+ <span class="note" id="dn-window"></span>
402
+ </header>
403
+ <div class="pad">
404
+ <p class="lede" id="ca-lede"></p>
405
+ <div class="bars" id="ca-bars"></div>
406
+ </div>
407
+ </div>
408
+ </section>
409
+
410
+ <!-- KPI: one cause. The same denials one level coarser — by the name at the
411
+ end of the path. This is the cut that says whether a day was a tooling
412
+ problem or a territory one, and by path alone it reads backwards. -->
413
+ <section data-view="causes" hidden>
414
+ <div class="box">
415
+ <header>
416
+ <h2>Denied, by name</h2>
417
+ <span class="note">the same denials, grouped one level coarser</span>
418
+ </header>
419
+ <div class="pad">
420
+ <p class="lede" id="fa-lede"></p>
421
+ <div id="fa-list"></div>
422
+ </div>
423
+ </div>
424
+ </section>
425
+
426
+ <!-- KPI: spent retrying. -->
427
+ <section data-view="walls" hidden>
428
+ <div class="box">
429
+ <header>
430
+ <h2>Walls</h2>
431
+ <span class="note">denied twice or more, and the policy still denies it</span>
432
+ </header>
433
+ <div class="pad">
434
+ <p class="lede" id="wl-lede"></p>
435
+ <div id="wl-list"></div>
436
+ </div>
437
+ </div>
438
+ </section>
439
+
325
440
  <section data-view="activity" hidden>
326
441
  <div class="box">
327
442
  <header><h2>Activity</h2><span class="note">every decision, with its owner</span></header>
@@ -526,12 +641,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
526
641
 
527
642
  var on=0,off=0,nok=0,bl=0;
528
643
  ROLES.forEach(function(r){r.sandbox?on++:off++; if(!r.keys.length)nok++; bl+=r.blocks;});
529
- document.getElementById("c-roles").textContent=ROLES.length;
530
- document.getElementById("c-on").textContent=on;
531
- document.getElementById("c-off").textContent=off;
532
- document.getElementById("c-off-s").textContent=off?"nothing enforces these":"none are open";
533
- document.getElementById("c-nokeys").textContent=nok;
534
- document.getElementById("c-blocks").textContent=bl;
644
+ drawKpis();
535
645
  var pin=document.getElementById("pin");
536
646
  if(off){pin.textContent=off;pin.hidden=false;}else{pin.hidden=true;}
537
647
  var k=document.getElementById("kill");
@@ -650,7 +760,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
650
760
  // Only the kernel's own lines get the mark. A hook line and an older line look the
651
761
  // same as they always did — the absence is the default, not a third state.
652
762
  var src=e.source==="kernel"
653
- ? '<span class="src" title="Recorded from the kernel: what the OS actually refused, '+
763
+ ? '<span class="src" title="Recorded from the kernel: what the OS actually denied, '+
654
764
  'whether or not the hook saw the attempt. This line cannot be talked around.">kernel</span>'
655
765
  : "";
656
766
  d.innerHTML="<b>"+label+'</b><span class="body"><span class="what">'+line+"</span>"+who+
@@ -679,6 +789,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
679
789
 
680
790
  var REQS = [];
681
791
  var ONDISK = null; // the file on disk, when there is one
792
+ var CAUSES = null; // this window's denials, grouped by what was denied
793
+ var WALLS = {}; // per role, what it keeps being denied and still would be
682
794
 
683
795
  /**
684
796
  * The queue, as something a person acts on.
@@ -686,6 +798,200 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
686
798
  * Hidden entirely when empty rather than shown as an empty table: the point
687
799
  * of this panel is to be noticed on the days it has something in it.
688
800
  */
801
+ /**
802
+ * The shape of the window, grouped by cause.
803
+ *
804
+ * The reading comes first and the bars second, because the number that
805
+ * matters is almost never "how many" — it is "how much of it is one thing".
806
+ * A day where three quarters of the denials are a single lock file is a
807
+ * day where nobody disputed any territory, and that sentence is the whole
808
+ * value of grouping. It is written out rather than left for the reader to
809
+ * infer from twelve bars.
810
+ */
811
+ function drawCauses(){
812
+ var f = CAUSES;
813
+ var lede = document.getElementById("ca-lede");
814
+ if (!f || !f.total) {
815
+ if (lede) lede.textContent = "Nothing was denied in this window.";
816
+ var bars = document.getElementById("ca-bars"); if (bars) bars.innerHTML = "";
817
+ return;
818
+ }
819
+ var top = f.causes[0];
820
+ var fam = (f.families || [])[0];
821
+ var famShare = fam ? Math.round(fam.share * 100) : 0;
822
+ var lede = "<b>" + f.total.toLocaleString() + "</b> denial" + (f.total === 1 ? "" : "s") +
823
+ " in this window, over <b>" + (f.distinct || f.causes.length).toLocaleString() +
824
+ "</b> distinct path" + ((f.distinct || f.causes.length) === 1 ? "" : "s") + ". ";
825
+ // The reading has to be taken at the level of the NAME, not the path, or it comes out
826
+ // backwards. Measured here: six of the top seven causes were the same lock file in six
827
+ // different repositories and no single path passed 17% \u2014 so "no cause dominates" was
828
+ // what the arithmetic said and the opposite of the truth. One KIND of thing at that scale
829
+ // is a tooling problem with a mechanical fix; the same volume spread across unrelated
830
+ // paths is a territory question and needs a person. They take opposite responses, which
831
+ // is why this sentence is written out instead of left to be inferred from twelve bars.
832
+ if (fam && famShare >= 40 && fam.paths > 1)
833
+ lede += "<span class=\"q\"><b>" + fam.times.toLocaleString() + "</b> of them \u2014 <b>" +
834
+ famShare + "%</b> \u2014 are the same name in " + fam.paths + " places: " +
835
+ "<code>" + esc(fam.name) + "</code>. One kind of thing at that scale is a " +
836
+ "tooling problem with a mechanical fix, not something to grant.</span>";
837
+ else if (fam && famShare >= 40)
838
+ lede += "<span class=\"q\"><b>" + famShare + "%</b> of it is a single path. That is " +
839
+ "usually mechanical rather than something to grant.</span>";
840
+ else
841
+ lede += "<span class=\"q\">No one kind of thing dominates, which is what a real boundary " +
842
+ "question looks like rather than a tooling one.</span>";
843
+ /* The half a grep cannot do, because it needs the policy: how many of these
844
+ are on paths nobody claims. Those are the ones no grant resolves until a
845
+ person decides who owns them — a decision, not a count. Added after a
846
+ field review pointed out that the percentage above was the console
847
+ reading the log back to somebody who had already read it. */
848
+ if (f.unowned)
849
+ lede += " <b>" + f.unowned + "</b> of the " + (f.distinct || f.causes.length).toLocaleString() +
850
+ " are on paths <b>no role owns</b> \u2014 a grant cannot settle those until " +
851
+ "somebody claims them.";
852
+ document.getElementById("ca-lede").innerHTML = lede;
853
+ var win = document.getElementById("dn-window");
854
+ if (win) win.textContent = f.total.toLocaleString() + " denials \u00b7 " +
855
+ (f.distinct || f.causes.length).toLocaleString() + " distinct paths";
856
+
857
+ var max = top.times || 1;
858
+ document.getElementById("ca-bars").innerHTML = f.causes.map(function(c){
859
+ var w = Math.max(2, Math.round((c.times / max) * 100));
860
+ // A cause the policy no longer denies is history, not live. It is
861
+ // shown, greyed, rather than dropped: it explains a number somebody may
862
+ // remember from yesterday instead of silently changing it.
863
+ var cold = c.stillDeclined === 0;
864
+ var who = c.roles.length === 1 ? c.roles[0] : c.roles.length + " roles";
865
+ return '<div class="bar' + (cold ? ' cold' : '') + '">' +
866
+ '<div class="lbl"><div class="p">' + esc(c.action) + ' ' + esc(c.target) + '</div>' +
867
+ '<div class="who">' + esc(who) +
868
+ (c.owners && c.owners.length ? ' · owned by ' + esc(c.owners.join(", ")) : ' · owned by nobody') +
869
+ (cold ? ' · <b>granted since</b>' : '') + '</div>' +
870
+ '<div class="t"><span class="f" style="width:' + w + '%"></span></div></div>' +
871
+ '<div class="num">' + c.times.toLocaleString() +
872
+ '<s>' + (c.share * 100).toFixed(1) + '%</s></div>' +
873
+ '</div>';
874
+ }).join("");
875
+
876
+ }
877
+
878
+ /** Denied, by name — the cut that says what kind of day it was. */
879
+ function drawFamilies(){
880
+ var f = CAUSES; if (!f) return;
881
+ var fams = f.families || [];
882
+ var top = fams[0];
883
+ document.getElementById("fa-lede").innerHTML = !top ? "Nothing was denied in this window."
884
+ : "<b>" + Math.round(top.share * 100) + "%</b> of " + f.total.toLocaleString() +
885
+ " denials is <code>" + esc(top.name) + "</code>, across <b>" + top.paths + "</b> path" +
886
+ (top.paths === 1 ? "" : "s") + "." +
887
+ (top.share >= 0.4 && top.paths > 1
888
+ ? ' <span class="q">One name at that scale is usually mechanical. Fixing the tool ' +
889
+ 'removes the denials; granting the paths only hides them.</span>'
890
+ : ' <span class="q">No single name dominates \u2014 these are separate questions, ' +
891
+ 'not one problem.</span>');
892
+
893
+ document.getElementById("fa-list").innerHTML = fams.map(function(x){
894
+ var w = Math.max(2, Math.round((x.times / (top.times || 1)) * 100));
895
+ return '<div class="fam">' +
896
+ '<div class="fam-h"><code>' + esc(x.name) + '</code>' +
897
+ '<span class="num">' + x.times.toLocaleString() + ' <s>' + (x.share*100).toFixed(1) + '%</s></span></div>' +
898
+ '<div class="t"><span class="f" style="width:' + w + '%"></span></div>' +
899
+ '<div class="who">' + x.paths + ' path' + (x.paths===1?'':'s') + ' \u00b7 ' +
900
+ x.roles.length + ' role' + (x.roles.length===1?'':'s') + ': ' + esc(x.roles.join(", ")) + '</div>' +
901
+ '<ul class="places">' + x.where.map(function(p){
902
+ return '<li><span>' + esc(p.target) + '</span><b>' + p.times.toLocaleString() + '</b></li>';
903
+ }).join("") + '</ul></div>';
904
+ }).join("");
905
+ }
906
+
907
+ /** Walls — per role, what it keeps hitting and what that cost. */
908
+ function drawWalls(){
909
+ var rows = Object.keys(WALLS || {}).map(function(r){
910
+ var w = WALLS[r];
911
+ return { role: r, walls: w, spent: w.reduce(function(n,x){ return n + x.times - 1; }, 0) };
912
+ }).sort(function(a,b){ return b.spent - a.spent; });
913
+ var total = rows.reduce(function(n,x){ return n + x.spent; }, 0);
914
+
915
+ document.getElementById("wl-lede").innerHTML = !rows.length
916
+ ? "No role is repeating a denial it has already been given."
917
+ : "<b>" + total.toLocaleString() + "</b> call" + (total===1?"":"s") + " across <b>" +
918
+ rows.length + "</b> role" + (rows.length===1?"":"s") +
919
+ ' went into retrying something already denied. <span class="q">A denial is said once, ' +
920
+ 'mid-turn. Nothing carries it forward, so it gets tried again.</span>';
921
+
922
+ document.getElementById("wl-list").innerHTML = rows.map(function(x){
923
+ return '<div class="fam">' +
924
+ '<div class="fam-h"><code>' + esc(x.role) + '</code>' +
925
+ '<span class="num">' + x.spent.toLocaleString() + ' <s>calls retried</s></span></div>' +
926
+ '<ul class="places">' + x.walls.map(function(w){
927
+ return '<li><span>' + esc(w.action) + ' ' + esc(w.target) + '<br>' +
928
+ '<em>' + esc(w.reason) + '</em></span><b>' + w.times + '\u00d7</b></li>';
929
+ }).join("") + '</ul></div>';
930
+ }).join("");
931
+ }
932
+
933
+ /** A count beside a nav item, or nothing. Zero is not news. */
934
+ function pill(id, n){
935
+ var el = document.getElementById(id);
936
+ if (!el) return;
937
+ el.textContent = n ? (n > 999 ? Math.round(n/1000) + "k" : String(n)) : "";
938
+ el.hidden = !n;
939
+ }
940
+
941
+ /**
942
+ * The five numbers, on every screen.
943
+ *
944
+ * Chosen by one rule: a KPI has to be something that can change and that you
945
+ * would do something about. The row this replaces failed both — four of its
946
+ * five read 31, 31, 0 and 31 every single day — and the one that moved was
947
+ * last and the same size as the rest.
948
+ *
949
+ * Emphasis is conditional, never constant. At zero they go quiet and say so
950
+ * in words, because "nothing to decide" is a state and an indicator that
951
+ * shouts at rest is one people stop seeing.
952
+ *
953
+ * Every value is read from the globals rather than passed in, so any refresh
954
+ * can call it — the queue refreshes on its own, and passing the role counts
955
+ * in once left the row stale on the number that moves most.
956
+ */
957
+ function drawKpis(){
958
+ var f = CAUSES || { total: 0, families: [], distinct: 0, causes: [] };
959
+ var waiting = REQS.length;
960
+ var retry = Object.keys(WALLS || {}).reduce(function(n, r){
961
+ return n + WALLS[r].reduce(function(m, w){ return m + w.times - 1; }, 0);
962
+ }, 0);
963
+ var fam = f.families && f.families[0];
964
+ var off = (ROLES || []).filter(function(r){ return !r.sandbox; }).length;
965
+
966
+ function set(id, cls, value, sub){
967
+ var el = document.getElementById(id);
968
+ if (!el) return;
969
+ el.classList.remove("live","alarm","calm");
970
+ if (cls) el.classList.add(cls);
971
+ el.querySelector(".n").textContent = value;
972
+ el.querySelector(".s").textContent = sub;
973
+ }
974
+
975
+ set("k-wait", waiting ? "live" : "calm", waiting,
976
+ waiting ? (waiting === 1 ? "decision" : "decisions") : "nothing to decide");
977
+ // The only number here that is an emergency, and it is almost always zero.
978
+ set("k-open", off ? "alarm" : "calm", off,
979
+ off ? (off === 1 ? "role nothing is enforcing" : "roles nothing is enforcing") : "every role confined");
980
+ set("k-ref", f.total ? "live" : "calm", f.total.toLocaleString(),
981
+ f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct paths" : "nothing was denied");
982
+ // The shape, not the size: one name at 74% is a tooling problem, the same
983
+ // volume spread thin is a territory question, so the caption carries the name.
984
+ set("k-conc", fam && fam.share >= 0.4 ? "live" : "calm",
985
+ fam ? Math.round(fam.share * 100) + "%" : "\u2014",
986
+ fam ? "is " + fam.name : "of all denials");
987
+ // The only one that is money rather than state.
988
+ set("k-retry", retry ? "live" : "calm", retry.toLocaleString(),
989
+ retry ? "calls on walls already denied" : "nothing retried");
990
+
991
+ pill("dn-pin", f.total);
992
+ pill("wl-pin", Object.keys(WALLS || {}).length);
993
+ }
994
+
689
995
  function drawReqs(){
690
996
  var box = document.getElementById("requests");
691
997
  var pin = document.getElementById("req-pin");
@@ -696,10 +1002,12 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
696
1002
  // a page with a heading and nothing under it. It stays, and says what would fill it.
697
1003
  box.hidden = false;
698
1004
  if (!REQS.length) {
699
- host.innerHTML = '<p class="blank">Nothing waiting on you.' +
700
- '<span>An agent lands here when the policy denies it a path and it asks for the path ' +
701
- 'rather than working around it. Approving is the one action in this console that ' +
702
- 'writes to your policy \u2014 it edits <code>seisin.toml</code> in place, with your reason.</span></p>';
1005
+ // Compact when this is the landing view. The tall empty state was right
1006
+ // when Requests was a page of its own — a heading with nothing under it
1007
+ // reads as broken. At the top of the front page it is 180px of nothing
1008
+ // standing between you and the only content there is.
1009
+ host.innerHTML = '<p class="blank quiet">No pending decisions. ' +
1010
+ '<span>Agents land here when they ask for a path the policy denies them.</span></p>';
703
1011
  return;
704
1012
  }
705
1013
 
@@ -715,7 +1023,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
715
1023
  '<div class="act">' +
716
1024
  '<input id="why-' + i + '" placeholder="why — this outlives you" aria-label="reason">' +
717
1025
  '<button class="btn" data-n="' + (i+1) + '" data-d="granted" type="button">Approve</button>' +
718
- '<button class="btn warn" data-n="' + (i+1) + '" data-d="denied" type="button">Refuse</button>' +
1026
+ '<button class="btn warn" data-n="' + (i+1) + '" data-d="denied" type="button">Decline</button>' +
719
1027
  "</div>" +
720
1028
  "</div>";
721
1029
  }).join("");
@@ -743,7 +1051,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
743
1051
  body: JSON.stringify({ number: n, decision: btn.dataset.d, reason: why })
744
1052
  });
745
1053
  var out = await r.json();
746
- if (!r.ok) throw new Error(out.error || "refused");
1054
+ if (!r.ok) throw new Error(out.error || "denied");
747
1055
  // Re-read rather than patch: the grant rewrote seisin.toml, so the
748
1056
  // territory on screen is stale too, not just the queue.
749
1057
  await reload();
@@ -754,7 +1062,19 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
754
1062
  }
755
1063
  }
756
1064
 
757
- function draw(){drawRows();drawCfg();drawToml();drawLog();drawReqs();}
1065
+ /**
1066
+ * One view failing must not take the others down.
1067
+ *
1068
+ * Measured the hard way: `drawCauses` kept a reference to a box that had moved
1069
+ * to its own page, threw on the first line, and every number on the screen —
1070
+ * including the five that have nothing to do with it — rendered as zero. A
1071
+ * console whose KPIs read zero because of a typo is worse than one that is
1072
+ * down, because zero is a plausible answer.
1073
+ */
1074
+ function draw(){
1075
+ [drawRows, drawCfg, drawToml, drawLog, drawReqs, drawCauses, drawFamilies, drawWalls]
1076
+ .forEach(function(f){ try { f(); } catch (e) { console.error("seisin: " + f.name + " failed", e); } });
1077
+ }
758
1078
 
759
1079
  /**
760
1080
  * The page works two ways on purpose.
@@ -791,6 +1111,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
791
1111
  }));
792
1112
  LOG = fromServer(s.log);
793
1113
  REQS = s.requests || [];
1114
+ CAUSES = s.causes || null;
1115
+ WALLS = s.walls || {};
794
1116
  ONDISK = s.toml || null;
795
1117
  // The demo selection does not survive the live load. `sel` starts at "frontend", a role
796
1118
  // from the example policy, and a real repo has no reason to have one. `drawCfg` then
@@ -830,7 +1152,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
830
1152
  policy: {view:"roles", title:"Roles",
831
1153
  sub:"Which folders each agent writes, which keys it may read, and whether it runs sandboxed."},
832
1154
  requests: {view:"requests", title:"Waiting on you",
833
- sub:"What an agent asked for and cannot have yet. Approving is the one action that writes to your policy."},
1155
+ sub:"Permissions an agent asked for and cannot have. Granting one edits your policy."},
1156
+ denied: {view:"denied", title:"Denied",
1157
+ sub:"Every denial in the window, grouped by the path it was about."},
1158
+ causes: {view:"causes", title:"Causes",
1159
+ sub:"The same denials by name, which is the cut that says what kind of day it was."},
1160
+ walls: {view:"walls", title:"Walls",
1161
+ sub:"What each role keeps being denied, and what retrying it cost."},
834
1162
  activity: {view:"activity", title:"Activity",
835
1163
  sub:"Every decision the runtime made, newest last, each with the role that owns the path."},
836
1164
  configfile: {view:"config", title:"Config file",
@@ -838,7 +1166,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
838
1166
  };
839
1167
 
840
1168
  function show(route){
841
- var v = VIEWS[route] || VIEWS.policy;
1169
+ var v = VIEWS[route] || VIEWS.requests;
842
1170
  document.querySelectorAll("main section[data-view]").forEach(function(s){
843
1171
  s.hidden = s.getAttribute("data-view") !== v.view;
844
1172
  });
@@ -858,7 +1186,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
858
1186
  window.scrollTo(0, 0);
859
1187
  }
860
1188
 
861
- function route(){ return (location.hash || "#policy").slice(1); }
1189
+ /**
1190
+ * The landing view is what is waiting on you, not the policy table.
1191
+ *
1192
+ * It opened on Roles, which is reference: a wall of paths you consult when you
1193
+ * have a question, rendered before anybody asked one. What a person opens this
1194
+ * for is "does anything need me", and that has to be the first screen or it is
1195
+ * a screen nobody reaches. Reference is looked up; decisions are shown.
1196
+ */
1197
+ function route(){ return (location.hash || "#requests").slice(1); }
862
1198
  window.addEventListener("hashchange", function(){ show(route()); });
863
1199
  show(route());
864
1200
 
@@ -900,7 +1236,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
900
1236
  // the same way the log does: by polling. Re-rendered only when it changed,
901
1237
  // so a reason someone is halfway through typing is not wiped every 2s.
902
1238
  var fresh = JSON.stringify(s.requests || []);
903
- if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); }
1239
+ if (fresh !== JSON.stringify(REQS)) { REQS = JSON.parse(fresh); drawReqs(); drawKpis(); }
904
1240
  // Only the counters that come from the log are refreshed. Whatever the
905
1241
  // reader has been editing on screen stays put — a page that resets your
906
1242
  // work every two seconds is worse than one that does not update.