instar 1.3.29 → 1.3.30

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.
@@ -777,6 +777,50 @@
777
777
  display: none !important;
778
778
  }
779
779
 
780
+ /* ── process-health tab (PROCESS-HEALTH-DASHBOARD-TAB-SPEC §4.1) ──
781
+ Large, readable, calm. No monospace, no tables, no alarm colors.
782
+ Measurable bars: headline ≥24px, section headers ≥20px, body ≥17px,
783
+ line-height 1.5–1.6. One neutral accent token, used ONLY for the
784
+ maturation "you're here" marker. */
785
+ .ph-root {
786
+ --ph-accent: #4ade80;
787
+ max-width: 760px;
788
+ font-size: 18px;
789
+ line-height: 1.55;
790
+ color: var(--text-bright);
791
+ }
792
+ .ph-title { font-size: 22px; font-weight: 600; color: var(--text-bright); line-height: 1.5; }
793
+ .ph-stamp { font-size: 14px; color: var(--text-dim); }
794
+ .ph-headline-wrap { margin: 4px 0 8px; }
795
+ .ph-headline { font-size: 26px; font-weight: 600; line-height: 1.5; color: var(--text-bright); }
796
+ .ph-subline { font-size: 18px; color: var(--text-dim); margin-top: 8px; line-height: 1.55; }
797
+ .ph-section { display: flex; flex-direction: column; gap: 10px; }
798
+ .ph-h { font-size: 20px; font-weight: 600; color: var(--text-bright); line-height: 1.5; }
799
+ .ph-empty { font-size: 18px; color: var(--text-dim); line-height: 1.6; }
800
+ .ph-card {
801
+ padding: 14px 16px; border: 1px solid var(--border); border-radius: 10px;
802
+ display: flex; flex-direction: column; gap: 6px;
803
+ }
804
+ .ph-card-summary { font-size: 18px; color: var(--text-bright); line-height: 1.5; }
805
+ .ph-card-frame { font-size: 15px; color: var(--text-dim); font-style: italic; }
806
+ .ph-card-rec { font-size: 17px; color: var(--text); line-height: 1.55; }
807
+ .ph-card-evidence { font-size: 15px; color: var(--text-dim); }
808
+ .ph-record { font-size: 18px; color: var(--text); line-height: 1.6; padding: 5px 0; }
809
+ .ph-record-inert { color: var(--text-dim); }
810
+ .ph-stage { display: flex; align-items: baseline; gap: 10px; font-size: 18px; padding: 3px 0; color: var(--text-dim); line-height: 1.5; }
811
+ .ph-stage-here { color: var(--text-bright); }
812
+ .ph-stage-glyph { color: var(--text-dim); }
813
+ .ph-stage-here .ph-stage-glyph { color: var(--ph-accent); }
814
+ .ph-stage-marker { color: var(--ph-accent); font-size: 15px; }
815
+ .ph-stage-note { color: var(--text-dim); font-size: 15px; }
816
+ .ph-detail { margin-top: 4px; }
817
+ .ph-detail-summary { font-size: 17px; color: var(--text-dim); cursor: pointer; }
818
+ .ph-detail-body { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
819
+ .ph-detail-row { font-size: 17px; color: var(--text); line-height: 1.55; }
820
+ .ph-operator { margin-top: 14px; }
821
+ .ph-operator-summary { font-size: 15px; color: var(--text-dim); cursor: pointer; }
822
+ .ph-operator-hint { font-size: 16px; color: var(--text-dim); margin-top: 8px; line-height: 1.55; }
823
+
780
824
  /* ── File viewer ─────────────────────────────────────── */
781
825
  .files-container {
782
826
  display: flex;
@@ -2529,6 +2573,7 @@
2529
2573
  <button class="tab" data-tab="tokens" onclick="switchTab('tokens')">Tokens</button>
2530
2574
  <button class="tab" data-tab="threadline" onclick="switchTab('threadline')">Threadline</button>
2531
2575
  <button class="tab" data-tab="evidence" onclick="switchTab('evidence')">Evidence</button>
2576
+ <button class="tab" data-tab="process-health" onclick="switchTab('process-health')">Process Health</button>
2532
2577
  </nav>
2533
2578
  </div>
2534
2579
  <div class="vital-signs" id="vitalSigns">
@@ -3012,6 +3057,31 @@
3012
3057
 
3013
3058
  <!-- Evidence Tab (WikiClaim Phase 4) — per-entity evidence + reverse "what cites this?" view.
3014
3059
  Spec: docs/specs/OPENCLAW-IMPORT-WIKICLAIM-EVIDENCE-SPEC.md § Phase 4 (line 343). -->
3060
+ <!-- process-health tab -->
3061
+ <div id="processHealthPanel" class="ph-root" style="display:none;flex-direction:column;padding:28px;gap:24px;overflow-y:auto">
3062
+ <div style="display:flex;justify-content:space-between;align-items:baseline">
3063
+ <h2 class="ph-title">Process Health</h2>
3064
+ <div id="phStamp" class="ph-stamp" aria-live="polite"></div>
3065
+ </div>
3066
+ <div id="phHeadline" class="ph-headline-wrap" aria-live="polite"></div>
3067
+ <section class="ph-section">
3068
+ <h3 class="ph-h">Patterns to know about</h3>
3069
+ <div id="phPatterns"></div>
3070
+ </section>
3071
+ <section class="ph-section">
3072
+ <h3 class="ph-h">What’s been captured</h3>
3073
+ <div id="phCaptured"></div>
3074
+ </section>
3075
+ <section class="ph-section">
3076
+ <h3 class="ph-h">Maturation</h3>
3077
+ <div id="phMaturation"></div>
3078
+ </section>
3079
+ <details class="ph-detail">
3080
+ <summary class="ph-detail-summary">Detail</summary>
3081
+ <div id="phDetail" class="ph-detail-body"></div>
3082
+ </details>
3083
+ </div>
3084
+
3015
3085
  <div id="evidenceTab" style="display:none;flex-direction:column;padding:20px;gap:16px;overflow-y:auto">
3016
3086
  <div style="display:flex;justify-content:space-between;align-items:center">
3017
3087
  <h2 style="margin:0">Evidence</h2>
@@ -4244,6 +4314,13 @@
4244
4314
  display: ['flex'],
4245
4315
  onActivate: null,
4246
4316
  },
4317
+ {
4318
+ id: 'process-health',
4319
+ panels: ['processHealthPanel'],
4320
+ display: ['flex'],
4321
+ onActivate: () => { if (typeof startProcessHealth === 'function') startProcessHealth(); },
4322
+ onDeactivate: () => { if (typeof stopProcessHealth === 'function') stopProcessHealth(); },
4323
+ },
4247
4324
  {
4248
4325
  id: 'secrets',
4249
4326
  panels: ['secretsPanel'],
@@ -7973,13 +8050,48 @@
7973
8050
  return m + 'm';
7974
8051
  }
7975
8052
 
8053
+ // ── Process Health tab (PROCESS-HEALTH-DASHBOARD-TAB-SPEC) ──
8054
+ // Lazy dynamic-import keeps the controller in this (classic) scope so it can
8055
+ // read `token`; the module ships the pure renderers + the polling controller.
8056
+ let __phController = null;
8057
+ let __phModule = null;
8058
+ async function startProcessHealth() {
8059
+ if (!__phModule) {
8060
+ try { __phModule = await import('/dashboard/process-health.js'); }
8061
+ catch (e) { console.error('[process-health] module load failed', e); return; }
8062
+ }
8063
+ if (!__phController) {
8064
+ const els = {
8065
+ headline: document.getElementById('phHeadline'),
8066
+ patterns: document.getElementById('phPatterns'),
8067
+ captured: document.getElementById('phCaptured'),
8068
+ maturation: document.getElementById('phMaturation'),
8069
+ detail: document.getElementById('phDetail'),
8070
+ stamp: document.getElementById('phStamp'),
8071
+ };
8072
+ // Raw fetch (NOT apiFetch) so the controller sees 304/503/ETag itself.
8073
+ const fetchImpl = (url, opts = {}) => fetch(url, {
8074
+ headers: { 'Authorization': `Bearer ${token}`, ...(opts.headers || {}) },
8075
+ signal: opts.signal,
8076
+ });
8077
+ __phController = __phModule.createController({ doc: document, els, fetchImpl });
8078
+ }
8079
+ __phController.start();
8080
+ }
8081
+ function stopProcessHealth() {
8082
+ if (__phController) __phController.stop();
8083
+ }
8084
+
7976
8085
  // Handle tab visibility — close SSE when tab is backgrounded
7977
8086
  document.addEventListener('visibilitychange', () => {
7978
8087
  if (document.hidden) {
7979
8088
  disconnectJobsSSE();
8089
+ if (currentTab === 'process-health') stopProcessHealth();
7980
8090
  } else if (currentTab === 'jobs') {
7981
8091
  connectJobsSSE();
7982
8092
  loadJobs(); // Resync on return
8093
+ } else if (currentTab === 'process-health') {
8094
+ startProcessHealth(); // resume on return: one immediate refresh + re-arm
7983
8095
  }
7984
8096
  // Always refresh vital signs on tab focus
7985
8097
  if (!document.hidden) pollVitalSigns();
@@ -0,0 +1,504 @@
1
+ // Process Health tab — a calm, human-readable read surface for the Failure-Learning Loop.
2
+ // Spec: docs/specs/PROCESS-HEALTH-DASHBOARD-TAB-SPEC.md (v4, converged).
3
+ //
4
+ // Browser-native ESM (no build step; served at /dashboard/process-health.js and
5
+ // loaded by index.html via <script type="module">). The pure functions are
6
+ // exported so the 3-tier jsdom tests exercise the SHIPPED code, not a copy; the
7
+ // controller is also attached to window.ProcessHealth so the (classic) index.html
8
+ // script can drive start/stop on tab activation. This is one self-contained
9
+ // module for the new tab — NOT a modularization of the SPA (that is §10-deferred).
10
+ //
11
+ // Load-bearing safety contract (§4.6): every dynamic value flows through
12
+ // sanitizeForDisplay before the DOM; all DOM writes are textContent only; all
13
+ // presentation chrome is renderer-OWNED static literals (CHROME below) so no
14
+ // attacker codepoint can ever sit where chrome is read.
15
+
16
+ // ── Renderer-owned static chrome (§4.6 r8 belt-and-suspenders, adversarial NEW-1) ──
17
+ // These glyphs are the tab's presentation alphabet. They live HERE as static
18
+ // literals and are NEVER sourced from data — and sanitizeForDisplay strips this
19
+ // exact class from every dynamic value — so a record field can never impersonate
20
+ // a marker/separator/caret.
21
+ export const CHROME = Object.freeze({
22
+ hereMarker: '← you’re here', // "← you're here"
23
+ sep: ' · ', // " · " record-sentence separator
24
+ caret: '▾', // "▾" drawer caret
25
+ stageDone: '●', // "●" completed stage
26
+ stageHere: '●', // "●" current stage (paired with hereMarker)
27
+ stageFuture: '○', // "○" future stage
28
+ });
29
+
30
+ const CAPS = { summary: 240, recommendation: 320, label: 64, detail: 2048 };
31
+
32
+ // Structural presentation-glyph class (NOT an enumerated denylist). Strips, after
33
+ // the NFKC fold, every codepoint in the symbol classes the chrome draws from, so
34
+ // confusable variants (✔ vs ✓, ⇒ vs →, ●︎ with a variation selector) cannot slip
35
+ // past: \p{So} (other-symbol) + arrows + geometric + box-drawing + dingbats +
36
+ // variation-selectors + the bullet/middot set.
37
+ // \p{So} (other-symbol: ★ ● ✓ ✔ ℹ 🔒 …) + arrows U+2190–21FF + geometric
38
+ // U+25A0–25FF + box-drawing U+2500–257F + dingbats U+2700–27BF + variation
39
+ // selectors U+FE00–FE0F + bullet/middot set (U+2022, U+00B7, U+2027, U+2043).
40
+ // Built via RegExp(...) with explicit code points — no invisible source chars.
41
+ const CHROME_GLYPH_RE = new RegExp(
42
+ '[\\p{So}\\u2190-\\u21FF\\u25A0-\\u25FF\\u2500-\\u257F\\u2700-\\u27BF\\uFE00-\\uFE0F\\u2022\\u00B7\\u2027\\u2043]',
43
+ 'gu',
44
+ );
45
+ // Invisible-character classes (also explicit code points, no literal controls):
46
+ // C0/C1 controls except \t (U+0009) and \n (U+000A); Unicode bidi-control marks.
47
+ const CONTROL_RE = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F]', 'g');
48
+ const BIDI_RE = new RegExp('[\\u202A-\\u202E\\u2066-\\u2069]', 'g');
49
+
50
+ /**
51
+ * §4.6 sanitize contract. Rules: 1 null-coerce; 2 NFKC-fold (collapses full-width/
52
+ * variant confusables BEFORE stripping); 3 strip C0/C1 controls (keep \n,\t);
53
+ * 4 strip bidi-control; 5 collapse newlines + cap whitespace runs; 8 structural
54
+ * chrome-glyph strip; 6 grapheme-safe length cap. (Strip precedes the cap so the
55
+ * cap reflects final visible length — closes the cosmetic under-cap the reviewer
56
+ * flagged; safety is unaffected by the 6/8 order.)
57
+ */
58
+ export function sanitizeForDisplay(value, fieldKind = 'summary') {
59
+ let s = value == null ? '' : String(value); // 1
60
+ s = s.normalize('NFKC'); // 2
61
+ s = s.replace(CONTROL_RE, ''); // 3 (keep \n=0A, \t=09)
62
+ s = s.replace(BIDI_RE, ''); // 4 bidi-control
63
+ s = s.replace(/\n{2,}/g, '\n').replace(/[ \t]{5,}/g, ' '); // 5
64
+ s = s.replace(CHROME_GLYPH_RE, ''); // 8 structural chrome-glyph strip
65
+ s = capGraphemes(s, CAPS[fieldKind] ?? CAPS.summary); // 6 grapheme-safe cap
66
+ return s;
67
+ }
68
+
69
+ function capGraphemes(s, max) {
70
+ if (s.length <= max) return s; // UTF-16 length ≥ grapheme count → safe fast path
71
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
72
+ const arr = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(s), (x) => x.segment);
73
+ if (arr.length <= max) return s;
74
+ return arr.slice(0, max - 1).join('') + '…';
75
+ }
76
+ // Fallback: never cut between surrogate halves.
77
+ let cut = max - 1;
78
+ const c = s.charCodeAt(cut - 1);
79
+ if (c >= 0xd800 && c <= 0xdbff) cut -= 1;
80
+ return s.slice(0, cut) + '…';
81
+ }
82
+
83
+ /**
84
+ * Heuristic mixed-script flag (§4.6 r7): Latin letters co-occurring with confusable
85
+ * Cyrillic/Greek. The caller renders such a row inertly (no special glyph) — we
86
+ * never add chrome; this only signals.
87
+ */
88
+ export function isMixedScript(value) {
89
+ const s = value == null ? '' : String(value);
90
+ const latin = /[A-Za-z]/.test(s);
91
+ const confusable = /[Ѐ-ӿͰ-Ͽ]/.test(s);
92
+ return latin && confusable;
93
+ }
94
+
95
+ /**
96
+ * §4.6 safeUrl. v1 renders NO dynamic URL-bearing attributes (defense-in-depth):
97
+ * allow relative (`/`,`#`) and same-origin http(s) only; reject javascript:/data:/
98
+ * vbscript:/file: and all off-origin hosts → empty string.
99
+ */
100
+ export function safeUrl(value) {
101
+ const s = (value == null ? '' : String(value)).trim();
102
+ if (s === '') return '';
103
+ if (s.startsWith('/') || s.startsWith('#')) return s;
104
+ let u;
105
+ try {
106
+ const base = typeof location !== 'undefined' && location.href ? location.href : 'http://localhost/';
107
+ u = new URL(s, base);
108
+ } catch {
109
+ return '';
110
+ }
111
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
112
+ const origin = typeof location !== 'undefined' && location.origin ? location.origin : 'http://localhost';
113
+ if (u.origin !== origin) return '';
114
+ return u.href;
115
+ }
116
+
117
+ // ── Friendly wording (§4.2c) ───────────────────────────────────────────────
118
+ const CATEGORY_WORDS = {
119
+ concurrency: 'A concurrency issue',
120
+ 'config-parse': 'A config problem',
121
+ 'type-error': 'A type error',
122
+ 'logic-error': 'A logic bug',
123
+ 'test-failure': 'A failing test',
124
+ 'build-failure': 'A build break',
125
+ 'integration-gap': 'An integration gap',
126
+ regression: 'A regression',
127
+ 'wiring-gap': 'A wiring gap',
128
+ unknown: 'An issue',
129
+ };
130
+
131
+ export function friendlyCategory(category) {
132
+ const key = typeof category === 'string' ? category : 'unknown';
133
+ return CATEGORY_WORDS[key] || 'An issue';
134
+ }
135
+
136
+ // initiativeId → human label. NEVER expose the raw initiativeId (§4.2c). Seeded
137
+ // with the known instar initiatives; unmapped IDs fall back to a generic phrase.
138
+ export const ATTRIBUTION_LABELS = {
139
+ 'failure-learning-loop': 'the failure-learning loop',
140
+ 'process-health-dashboard-tab': 'the process-health tab',
141
+ 'ledger-spine': 'the ledger spine',
142
+ 'threadline-keystone': 'the agent-network keystone',
143
+ };
144
+
145
+ export function attributionLabel(initiativeId, labelMap = ATTRIBUTION_LABELS) {
146
+ if (typeof initiativeId === 'string' && labelMap[initiativeId]) return labelMap[initiativeId];
147
+ return 'a tracked feature';
148
+ }
149
+
150
+ export function relativeTime(iso, now = Date.now()) {
151
+ const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
152
+ if (Number.isNaN(t)) return 'recently';
153
+ const sec = Math.max(0, Math.floor((now - t) / 1000));
154
+ if (sec < 90) return 'just now';
155
+ const min = Math.floor(sec / 60);
156
+ if (min < 60) return `about ${min} minute${min === 1 ? '' : 's'} ago`;
157
+ const hr = Math.floor(min / 60);
158
+ if (hr < 24) return `about ${hr} hour${hr === 1 ? '' : 's'} ago`;
159
+ const day = Math.floor(hr / 24);
160
+ if (day < 14) return `about ${spellSmall(day)} day${day === 1 ? '' : 's'} ago`;
161
+ const wk = Math.floor(day / 7);
162
+ if (wk < 8) return `about ${spellSmall(wk)} week${wk === 1 ? '' : 's'} ago`;
163
+ const mo = Math.floor(day / 30);
164
+ return `about ${spellSmall(mo)} month${mo === 1 ? '' : 's'} ago`;
165
+ }
166
+
167
+ const SMALL = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
168
+ function spellSmall(n) {
169
+ return n >= 0 && n <= 10 ? SMALL[n] : String(n);
170
+ }
171
+
172
+ // ── DOM helpers (textContent ONLY — never innerHTML; §4.6) ──────────────────
173
+ function el(doc, tag, cls, text) {
174
+ const node = doc.createElement(tag);
175
+ if (cls) node.setAttribute('class', cls); // class is a static literal
176
+ if (text != null) node.textContent = text; // dynamic text → textContent ONLY
177
+ return node;
178
+ }
179
+
180
+ // ── Renderers (pure data→DOM; one section per function) ─────────────────────
181
+
182
+ /**
183
+ * Headline (§4.2a, informational — NOT a verdict) + staleness escalation (§4.1).
184
+ * staleness keyed on the headline endpoint's OWN freshness (adversarial NEW-3).
185
+ */
186
+ export function renderHeadline(doc, target, { failures, stale, staleAgeMin }) {
187
+ if (!target) return;
188
+ target.replaceChildren();
189
+ if (stale) {
190
+ target.appendChild(
191
+ el(doc, 'div', 'ph-headline', `Connection paused — showing the last view${staleAgeMin != null ? ` from ${staleAgeMin}m ago` : ''}`),
192
+ );
193
+ target.appendChild(el(doc, 'div', 'ph-subline', "Couldn't refresh just now — showing the last good view. Will retry."));
194
+ return;
195
+ }
196
+ const list = Array.isArray(failures) ? failures : [];
197
+ const n = list.length;
198
+ const open = list.filter((f) => f && f.status !== 'resolved' && f.status !== 'closed').length;
199
+ target.appendChild(el(doc, 'div', 'ph-headline', `Watching — ${n} issue${n === 1 ? '' : 's'} recorded so far`));
200
+ const subline = n === 0 ? 'Nothing recorded yet — capture-only mode' : open === 0 ? 'all linked to a known cause · capture-only mode' : `${open} still being traced · capture-only mode`;
201
+ target.appendChild(el(doc, 'div', 'ph-subline', subline));
202
+ }
203
+
204
+ /**
205
+ * Disabled (503) state (§4.5). Pinned copy + operator hint as PLAIN PROSE — no
206
+ * `<code>`, no monospace, no config-key string (reconciles §2/§4.5/§6.3).
207
+ */
208
+ export function renderDisabled(doc, els) {
209
+ if (els.headline) {
210
+ els.headline.replaceChildren();
211
+ els.headline.appendChild(el(doc, 'div', 'ph-headline', 'Process Health isn’t turned on for this agent yet.'));
212
+ els.headline.appendChild(el(doc, 'div', 'ph-subline', 'Once it is, this page will show what it’s noticing.'));
213
+ const det = doc.createElement('details');
214
+ det.setAttribute('class', 'ph-operator');
215
+ const sum = doc.createElement('summary');
216
+ sum.setAttribute('class', 'ph-operator-summary');
217
+ sum.textContent = 'for operators';
218
+ det.appendChild(sum);
219
+ det.appendChild(el(doc, 'p', 'ph-operator-hint', 'An operator can enable it by turning the failure-learning monitor on in the agent settings.'));
220
+ els.headline.appendChild(det);
221
+ }
222
+ for (const k of ['patterns', 'captured', 'maturation', 'detail']) {
223
+ if (els[k]) els[k].replaceChildren();
224
+ }
225
+ if (els.stamp) els.stamp.textContent = '';
226
+ }
227
+
228
+ /** Patterns cards (§4.2b — awareness-only, NO action authority). */
229
+ export function renderPatterns(doc, target, insights) {
230
+ if (!target) return;
231
+ target.replaceChildren();
232
+ const list = Array.isArray(insights) ? insights : [];
233
+ if (list.length === 0) {
234
+ target.appendChild(
235
+ el(doc, 'p', 'ph-empty', 'Nothing flagged yet. The monitor needs to see a wider variety of issues before it surfaces a pattern — that’s expected this early.'),
236
+ );
237
+ return;
238
+ }
239
+ for (const ins of list) {
240
+ const card = el(doc, 'div', 'ph-card');
241
+ card.appendChild(el(doc, 'div', 'ph-card-summary', sanitizeForDisplay(ins && ins.summary, 'summary')));
242
+ // Renderer-owned framing line (static literal — NOT a data-restateable prefix).
243
+ card.appendChild(el(doc, 'div', 'ph-card-frame', 'A pattern the monitor noticed — verify before acting.'));
244
+ if (ins && ins.recommendation) {
245
+ card.appendChild(el(doc, 'div', 'ph-card-rec', sanitizeForDisplay(ins.recommendation, 'recommendation')));
246
+ }
247
+ const ds = ins && Number.isFinite(ins.distinctSessions) ? ins.distinctSessions : 0;
248
+ card.appendChild(el(doc, 'div', 'ph-card-evidence', `Seen across ${ds} change${ds === 1 ? '' : 's'}.`));
249
+ target.appendChild(card);
250
+ }
251
+ }
252
+
253
+ /** What's been captured (§4.2c — operator-safe subset only, sentences not rows). */
254
+ export function renderCaptured(doc, target, failures, labelMap = ATTRIBUTION_LABELS, now = Date.now()) {
255
+ if (!target) return;
256
+ target.replaceChildren();
257
+ const list = Array.isArray(failures) ? failures.slice(0, 10) : [];
258
+ if (list.length === 0) {
259
+ target.appendChild(el(doc, 'p', 'ph-empty', 'No issues recorded yet — that just means nothing has come through since this was turned on.'));
260
+ return;
261
+ }
262
+ for (const f of list) {
263
+ if (!f) continue;
264
+ const cat = friendlyCategory(f.category);
265
+ const summary = sanitizeForDisplay(f.summary, 'summary');
266
+ const label = attributionLabel(f.initiativeId, labelMap); // NEVER raw initiativeId
267
+ const when = relativeTime(f.detectedAt, now);
268
+ // ONE plain-English sentence. Separator + wording are renderer-owned literals.
269
+ const text = `${cat}: ${summary}${CHROME.sep}attributed to ${label}${CHROME.sep}${when}.`;
270
+ const row = el(doc, 'p', 'ph-record', text);
271
+ if (isMixedScript(f.summary)) row.setAttribute('class', 'ph-record ph-record-inert');
272
+ target.appendChild(row);
273
+ }
274
+ }
275
+
276
+ /** Maturation track (§4.2d). 4th stage is permanently-future (no per-agent flag). */
277
+ const STAGES = [
278
+ { key: 'dark', label: 'Dark' },
279
+ { key: 'capture-only', label: 'Capture-only' },
280
+ { key: 'insight-push', label: 'Insight push' },
281
+ { key: 'default-on', label: 'Default for all agents' },
282
+ ];
283
+
284
+ export function renderMaturation(doc, target, rollout) {
285
+ if (!target) return;
286
+ target.replaceChildren();
287
+ const stage = rollout && typeof rollout.stage === 'string' ? rollout.stage : 'dark';
288
+ const currentIdx = STAGES.findIndex((s) => s.key === stage);
289
+ for (let i = 0; i < STAGES.length; i++) {
290
+ const s = STAGES[i];
291
+ const isHere = i === currentIdx;
292
+ const done = currentIdx >= 0 && i < currentIdx;
293
+ const row = el(doc, 'div', isHere ? 'ph-stage ph-stage-here' : 'ph-stage');
294
+ // glyph + label + marker are ALL renderer-owned static literals
295
+ row.appendChild(el(doc, 'span', 'ph-stage-glyph', done || isHere ? CHROME.stageDone : CHROME.stageFuture));
296
+ row.appendChild(el(doc, 'span', 'ph-stage-label', s.label));
297
+ if (isHere) row.appendChild(el(doc, 'span', 'ph-stage-marker', CHROME.hereMarker));
298
+ else if (done) row.appendChild(el(doc, 'span', 'ph-stage-note', 'done'));
299
+ target.appendChild(row);
300
+ }
301
+ }
302
+
303
+ /** Detail drawer body (§4.2e — labeled list items, no tables/JSON/monospace). */
304
+ export function renderDetail(doc, target, analysis) {
305
+ if (!target) return;
306
+ target.replaceChildren();
307
+ if (!analysis) {
308
+ target.appendChild(el(doc, 'p', 'ph-empty', 'No detail yet.'));
309
+ return;
310
+ }
311
+ const add = (label, val) => target.appendChild(el(doc, 'div', 'ph-detail-row', `${label}: ${val}`));
312
+ add('Total recorded', String(analysis.total ?? 0));
313
+ add('Linked to a known cause', String(analysis.attributed ?? 0));
314
+ add('Not yet linked to a feature', String(analysis.noFeatureLink ?? 0));
315
+ const byCat = analysis.byCategory && typeof analysis.byCategory === 'object' ? analysis.byCategory : {};
316
+ for (const [k, v] of Object.entries(byCat)) {
317
+ add(`Category — ${sanitizeForDisplay(k, 'label')}`, String(v));
318
+ }
319
+ const unknown = analysis.unknownToolchainByAuthor && typeof analysis.unknownToolchainByAuthor === 'object' ? analysis.unknownToolchainByAuthor : {};
320
+ const unknownTotal = Object.values(unknown).reduce((a, b) => a + (Number(b) || 0), 0);
321
+ add('Records with an unknown toolchain', String(unknownTotal));
322
+ }
323
+
324
+ // ── Polling controller (§4.3 — visibility-gated, abort-safe, coordinated, diff-aware) ──
325
+
326
+ const ENDPOINTS = ['analysis', 'insights', 'failures'];
327
+ const URLS = {
328
+ analysis: '/failures/analysis',
329
+ insights: '/failures/insights?limit=50',
330
+ failures: '/failures?limit=10',
331
+ };
332
+
333
+ /**
334
+ * Factory so tests can inject fetch + clock + DOM. opts:
335
+ * - doc: Document; els: { headline, patterns, captured, maturation, detail, stamp }
336
+ * - fetchImpl: (url, {headers, signal}) => Promise<Response-like>
337
+ * - now: () => ms; cadenceMs (default 60_000); staleMs (default 180_000 = 3×cadence)
338
+ * - schedule/cancel: timer hooks (default setTimeout/clearTimeout)
339
+ */
340
+ export function createController(opts) {
341
+ const {
342
+ doc,
343
+ els = {},
344
+ fetchImpl,
345
+ now = () => Date.now(),
346
+ cadenceMs = 60_000,
347
+ staleMs = 180_000,
348
+ schedule = (fn, ms) => setTimeout(fn, ms),
349
+ cancel = (id) => clearTimeout(id),
350
+ labelMap = ATTRIBUTION_LABELS,
351
+ } = opts;
352
+
353
+ const state = {
354
+ timerId: null,
355
+ inFlight: null,
356
+ active: false,
357
+ snapshot: { analysis: null, insights: null, failures: null },
358
+ etags: { analysis: null, insights: null, failures: null },
359
+ last200At: { analysis: 0, insights: 0, failures: 0 },
360
+ consecutiveFailedTicks: 0,
361
+ consecutive304Ticks: 0,
362
+ renderedSig: { headline: null, patterns: null, captured: null, maturation: null, detail: null, disabled: false },
363
+ nextDelayMs: cadenceMs,
364
+ };
365
+
366
+ async function fetchOne(key, controller) {
367
+ const headers = {};
368
+ if (state.etags[key]) headers['If-None-Match'] = state.etags[key];
369
+ const resp = await fetchImpl(URLS[key], { headers, signal: controller.signal });
370
+ if (resp.status === 304) return { key, status: 304 };
371
+ if (resp.status === 503) return { key, status: 503 }; // feature OFF — distinct from a transient failure
372
+ if (!resp.ok) throw new Error(`${key} ${resp.status}`);
373
+ const etag = resp.headers && typeof resp.headers.get === 'function' ? resp.headers.get('ETag') : null;
374
+ const body = await resp.json();
375
+ return { key, status: 200, etag, body };
376
+ }
377
+
378
+ async function tick() {
379
+ if (!state.active) return;
380
+ if (state.inFlight) {
381
+ try { state.inFlight.abort(); } catch { /* ignore */ }
382
+ }
383
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : { signal: undefined, abort() {} };
384
+ state.inFlight = controller;
385
+ let results;
386
+ try {
387
+ results = await Promise.all(ENDPOINTS.map((k) => fetchOne(k, controller)));
388
+ } catch (err) {
389
+ // Any endpoint hard-failed → drop the whole tick, keep prior render.
390
+ if (controller.signal && controller.signal.aborted) return; // superseded; let the new tick own state
391
+ state.inFlight = null;
392
+ state.consecutiveFailedTicks += 1;
393
+ render();
394
+ reschedule();
395
+ return;
396
+ }
397
+ if (controller.signal && controller.signal.aborted) return;
398
+ state.inFlight = null;
399
+ // Feature OFF (503) → pinned disabled copy, not the "connection paused" path.
400
+ if (results.some((r) => r.status === 503)) {
401
+ state.consecutiveFailedTicks = 0;
402
+ state.consecutive304Ticks = 0;
403
+ if (state.renderedSig.disabled !== true) {
404
+ renderDisabled(doc, els);
405
+ state.renderedSig = { headline: null, patterns: null, captured: null, maturation: null, detail: null, disabled: true };
406
+ }
407
+ reschedule();
408
+ return;
409
+ }
410
+ state.renderedSig.disabled = false;
411
+ let any200 = false;
412
+ let all304 = true;
413
+ for (const r of results) {
414
+ if (r.status === 200) {
415
+ any200 = true;
416
+ all304 = false;
417
+ state.snapshot[r.key] = r.body;
418
+ if (r.etag) state.etags[r.key] = r.etag;
419
+ state.last200At[r.key] = now();
420
+ }
421
+ }
422
+ if (any200) {
423
+ state.consecutiveFailedTicks = 0;
424
+ state.consecutive304Ticks = 0;
425
+ } else if (all304) {
426
+ state.consecutive304Ticks += 1;
427
+ }
428
+ render();
429
+ reschedule();
430
+ }
431
+
432
+ function reschedule() {
433
+ if (!state.active) return;
434
+ state.nextDelayMs = state.consecutive304Ticks >= 5 ? 300_000 : cadenceMs;
435
+ if (state.timerId != null) cancel(state.timerId);
436
+ state.timerId = schedule(() => { void tick(); }, state.nextDelayMs);
437
+ }
438
+
439
+ // Headline staleness keyed on the headline endpoint's OWN last-200 (NEW-3):
440
+ // either 2 hard-fail ticks, OR /failures hasn't produced a fresh 200 within the
441
+ // ceiling — even if siblings keep returning 200/304.
442
+ function headlineStale() {
443
+ if (state.consecutiveFailedTicks >= 2) return true;
444
+ const last = state.last200At.failures;
445
+ if (last === 0) return false; // never loaded yet → not "stale", just initial
446
+ return now() - last > staleMs;
447
+ }
448
+
449
+ function render() {
450
+ const stale = headlineStale();
451
+ const staleAgeMin = state.last200At.failures ? Math.max(0, Math.round((now() - state.last200At.failures) / 60_000)) : null;
452
+ const failuresBody = state.snapshot.failures;
453
+ const failures = failuresBody && Array.isArray(failuresBody.failures) ? failuresBody.failures : [];
454
+ const insightsBody = state.snapshot.insights;
455
+ const insights = insightsBody && Array.isArray(insightsBody.insights) ? insightsBody.insights : [];
456
+ const analysis = state.snapshot.analysis;
457
+ const rollout = analysis && analysis.rollout ? analysis.rollout : null;
458
+
459
+ // Diff-aware: skip a section's DOM writes when its inputs are unchanged
460
+ // (guarantees 0 mutations on identical ticks; no flicker). Sections rebuild
461
+ // via replaceChildren (NOT innerHTML) only when their signature changes.
462
+ sectionRender('headline', sig(stale, staleAgeMin, failures.length, countOpen(failures)), () =>
463
+ renderHeadline(doc, els.headline, { failures, stale, staleAgeMin }),
464
+ );
465
+ sectionRender('patterns', sig(insights), () => renderPatterns(doc, els.patterns, insights));
466
+ sectionRender('captured', sig(failures), () => renderCaptured(doc, els.captured, failures, labelMap, now()));
467
+ sectionRender('maturation', sig(rollout), () => renderMaturation(doc, els.maturation, rollout));
468
+ sectionRender('detail', sig(analysis), () => renderDetail(doc, els.detail, analysis));
469
+
470
+ if (els.stamp) {
471
+ const ageS = state.last200At.failures ? Math.max(0, Math.round((now() - state.last200At.failures) / 1000)) : null;
472
+ const stampText = ageS == null ? '' : ageS < 60 ? `updated ${ageS}s ago` : `updated ${Math.round(ageS / 60)}m ago`;
473
+ if (els.stamp.textContent !== stampText) els.stamp.textContent = stampText;
474
+ }
475
+ }
476
+
477
+ function sectionRender(key, signature, fn) {
478
+ if (state.renderedSig[key] === signature) return; // unchanged → 0 mutations
479
+ state.renderedSig[key] = signature;
480
+ fn();
481
+ }
482
+
483
+ function start() {
484
+ if (state.active) return;
485
+ state.active = true;
486
+ void tick();
487
+ }
488
+ function stop() {
489
+ state.active = false;
490
+ if (state.timerId != null) { cancel(state.timerId); state.timerId = null; }
491
+ if (state.inFlight) { try { state.inFlight.abort(); } catch { /* ignore */ } state.inFlight = null; }
492
+ }
493
+ function onVisible() { if (!state.active) { start(); } }
494
+ function onHidden() { stop(); }
495
+
496
+ return { start, stop, onVisible, onHidden, tick, render, _state: state };
497
+ }
498
+
499
+ function countOpen(failures) {
500
+ return failures.filter((f) => f && f.status !== 'resolved' && f.status !== 'closed').length;
501
+ }
502
+ function sig(...parts) {
503
+ try { return JSON.stringify(parts); } catch { return String(Math.random()); }
504
+ }