@crewhaus/hangar-ui 0.5.7 → 0.6.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/README.md CHANGED
@@ -285,16 +285,60 @@ origin — never part of this one. The asset-hygiene suite pins the exception
285
285
  to that single module, and `paneSandbox` strips `allow-same-origin` even if a
286
286
  payload asks for it.
287
287
 
288
+ ## M5 — the Advisor
289
+
290
+ `views/advisor.js` draws one more harness tab (`#/h/<id>/advisor`, right
291
+ after Overview — the strip order is the triage order) and one more fleet
292
+ screen (`#/advisor`):
293
+
294
+ - **The feed** — every alert, suggestion and optimization signal the manager
295
+ can derive about the harness, in one severity-ranked list. Every item
296
+ carries a hover TOOLTIP (`withTip`/`tipIcon` in `dom.js`: a `data-tip`
297
+ attribute the stylesheet renders via `attr()` — text only, so the
298
+ injection ban holds) explaining why it matters, the fact it was derived
299
+ from, guidance on what to do, and a quick action: an executable button for
300
+ a queued CLI verb (with its CLI twin beside it) or a deep link into the
301
+ tab that owns the fix. Acting opens an inline confirm where a COMMENT can
302
+ be attached — it is recorded in the harness-local decisions ledger next to
303
+ the queued job. Dismissing REQUIRES a reason (the server refuses without
304
+ one), dismissed items keep their recorded reasoning in a collapsible fold,
305
+ and every dismissal can be reopened. Zero open items renders as a
306
+ first-class "running optimally" state, never as an empty screen.
307
+ - **The trend** — "is it improving?": the eval pass-rate bars, the decision
308
+ counts, and a one-line verdict, all folded from durable sources.
309
+ - **Reports** — generate model-usage / costs / usefulness / optimization
310
+ reports (each button tooltipped with what it measures); saved reports are
311
+ listed and re-readable in place.
312
+ - **Issues** — describe a problem and it is tuned into an update ready to
313
+ run: the kind selector (tooltipped per kind) picks what gets queued, with
314
+ `optimize` — the eval→patch loop whose artifact is a reviewable spec
315
+ patch — as the default.
316
+ - **The fleet board** (`#/advisor`) — every harness's rollup, worst first,
317
+ each row deep-linking its Advisor tab.
318
+
319
+ The Library also becomes curatable (M5): harness rows carry a `hidden` flag
320
+ (the row editor's Visibility checkbox), hidden rows fold out of every view
321
+ except an explicit **Hidden** rail bucket, and a **Find harnesses…** panel
322
+ lists the unregistered harnesses under the scan roots (via
323
+ `GET /api/registry/discover`) with per-candidate Add buttons — discovery
324
+ without registration, so the Library only shows what you add. The blanket
325
+ Scan (which registers everything) stays as the labeled bulk fallback.
326
+
288
327
  ## Testing
289
328
 
290
- `bun test src` — the embed-map completeness/hygiene suite plus unit tests
291
- for the pure browser modules (`util.js`, `supervision.js`, `markdown.js`,
292
- `router.js`, `routes.js`, `shapes.js`) and the `api.js` client under a
293
- stubbed `fetch` (every wrapper asserted against the route map's
294
- method/path/body). All DOM-free at import time and imported directly by
295
- bun. Deterministic: clocks are injected, no network, no subprocesses. The
296
- server side of the same contract runs in `@crewhaus/hangar-server`'s
297
- `contract.test.ts`.
329
+ `bun run test` — two `bun test` invocations, DELIBERATELY two processes:
330
+ `bun test src` runs the unit tests for the pure browser modules
331
+ (`util.js`, `supervision.js`, `markdown.js`, `router.js`, `routes.js`,
332
+ `shapes.js`, the view decisions) and the `api.js` client under a stubbed
333
+ `fetch` (every wrapper asserted against the route map's method/path/body);
334
+ `bun test ./src/embed-suite.ts` then runs the embed-map
335
+ completeness/hygiene suite alone. The split is load-bearing, not taste:
336
+ the embed suite imports every asset as TEXT while the view tests import
337
+ the same files as ES modules, and bun's module registry keys a module by
338
+ path alone — in one process whichever load lands first poisons the other.
339
+ All DOM-free at import time and imported directly by bun. Deterministic:
340
+ clocks are injected, no network, no subprocesses. The server side of the
341
+ same contract runs in `@crewhaus/hangar-server`'s `contract.test.ts`.
298
342
 
299
343
  The M2 screens stay testable without a browser by keeping the decisions —
300
344
  state → row, envelope → disabled-with-reason, SSE frame → feed item,
package/assets/hangar.css CHANGED
@@ -786,6 +786,36 @@ table.fleet {
786
786
  font-size: 10.5px;
787
787
  }
788
788
 
789
+ /* 0.6.0 — the Models tab's share meter and its chip row. A share is always
790
+ rendered NEXT TO its number, never instead of one: the bar is the shape of
791
+ the split, the figure is the claim. */
792
+ .meter {
793
+ min-width: 72px;
794
+ height: 8px;
795
+ border-radius: 999px;
796
+ background: var(--panel-2);
797
+ border: 1px solid var(--rule-2);
798
+ overflow: hidden;
799
+ }
800
+ .meter-fill {
801
+ height: 100%;
802
+ background: var(--accent);
803
+ opacity: 0.85;
804
+ }
805
+ .chips {
806
+ display: flex;
807
+ flex-wrap: wrap;
808
+ gap: 2px;
809
+ margin-bottom: 8px;
810
+ }
811
+ .chips .chip {
812
+ cursor: pointer;
813
+ font-family: var(--mono);
814
+ }
815
+ tr.row-best td {
816
+ background: var(--accent-ghost);
817
+ }
818
+
789
819
  /* preflight + folds */
790
820
  .fold {
791
821
  border: 1px solid var(--rule);
@@ -1724,3 +1754,178 @@ tr.deflated td {
1724
1754
  grid-column: 2 / 3;
1725
1755
  }
1726
1756
  }
1757
+
1758
+ /* ── tooltips (M5): data-tip rendered via attr() — text only, never markup */
1759
+
1760
+ .has-tip {
1761
+ position: relative;
1762
+ cursor: help;
1763
+ }
1764
+ .has-tip::after {
1765
+ content: attr(data-tip);
1766
+ position: absolute;
1767
+ left: 0;
1768
+ bottom: calc(100% + 7px);
1769
+ z-index: 60;
1770
+ width: max-content;
1771
+ max-width: min(34rem, 78vw);
1772
+ padding: 7px 10px;
1773
+ border: 1px solid var(--rule-2);
1774
+ border-radius: var(--radius-sm);
1775
+ background: var(--panel-3);
1776
+ color: var(--ink);
1777
+ font-size: 12.5px;
1778
+ font-weight: 400;
1779
+ font-family: var(--sans);
1780
+ line-height: 1.45;
1781
+ white-space: normal;
1782
+ text-align: left;
1783
+ opacity: 0;
1784
+ pointer-events: none;
1785
+ transition: opacity 120ms ease;
1786
+ box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35);
1787
+ }
1788
+ .has-tip:hover::after,
1789
+ .has-tip:focus-visible::after {
1790
+ opacity: 1;
1791
+ }
1792
+ .tip-icon {
1793
+ display: inline-flex;
1794
+ align-items: center;
1795
+ justify-content: center;
1796
+ width: 15px;
1797
+ height: 15px;
1798
+ border: 1px solid var(--rule-2);
1799
+ border-radius: 50%;
1800
+ font-size: 10.5px;
1801
+ color: var(--ink-3);
1802
+ background: var(--panel-2);
1803
+ vertical-align: middle;
1804
+ margin-left: 5px;
1805
+ }
1806
+
1807
+ /* ── advisor (M5): the unified alert/suggestion feed ───────────────────── */
1808
+
1809
+ .adv-item {
1810
+ display: grid;
1811
+ grid-template-columns: 96px minmax(0, 1fr) auto;
1812
+ gap: 8px 12px;
1813
+ align-items: start;
1814
+ padding: 10px 12px;
1815
+ border: 1px solid var(--rule);
1816
+ border-left-width: 3px;
1817
+ border-radius: var(--radius-sm);
1818
+ margin: 8px 0;
1819
+ background: var(--panel-2);
1820
+ }
1821
+ .adv-critical {
1822
+ border-left-color: var(--bad);
1823
+ }
1824
+ .adv-warn {
1825
+ border-left-color: var(--warn);
1826
+ }
1827
+ .adv-suggestion {
1828
+ border-left-color: var(--info);
1829
+ }
1830
+ .adv-main {
1831
+ min-width: 0;
1832
+ }
1833
+ .adv-title {
1834
+ font-weight: 550;
1835
+ }
1836
+ .adv-detail {
1837
+ margin-top: 2px;
1838
+ font-size: 12.5px;
1839
+ }
1840
+ .adv-guidance {
1841
+ margin-top: 4px;
1842
+ font-size: 12.5px;
1843
+ color: var(--ink-2);
1844
+ }
1845
+ .adv-actions {
1846
+ display: flex;
1847
+ flex-direction: column;
1848
+ align-items: flex-end;
1849
+ gap: 6px;
1850
+ }
1851
+ .adv-cli {
1852
+ font-size: 11px;
1853
+ color: var(--ink-3);
1854
+ font-family: var(--mono);
1855
+ max-width: 24rem;
1856
+ overflow: hidden;
1857
+ text-overflow: ellipsis;
1858
+ white-space: nowrap;
1859
+ }
1860
+ .adv-form {
1861
+ grid-column: 1 / -1;
1862
+ display: flex;
1863
+ gap: 8px;
1864
+ align-items: center;
1865
+ flex-wrap: wrap;
1866
+ }
1867
+ .adv-form .input {
1868
+ flex: 1 1 16rem;
1869
+ }
1870
+ .adv-optimal {
1871
+ border: 1px solid var(--ok);
1872
+ border-radius: var(--radius);
1873
+ background: var(--accent-ghost);
1874
+ padding: 16px 18px;
1875
+ margin: 10px 0;
1876
+ }
1877
+ .adv-note {
1878
+ font-size: 12.5px;
1879
+ color: var(--ink-2);
1880
+ }
1881
+ .adv-decision {
1882
+ font-size: 12px;
1883
+ color: var(--ink-3);
1884
+ }
1885
+ .adv-board-row {
1886
+ display: grid;
1887
+ grid-template-columns: minmax(10rem, 1fr) auto auto minmax(0, 2fr);
1888
+ gap: 8px 14px;
1889
+ align-items: center;
1890
+ padding: 9px 12px;
1891
+ border-bottom: 1px solid var(--rule);
1892
+ }
1893
+ .trend-bars {
1894
+ display: flex;
1895
+ align-items: flex-end;
1896
+ gap: 3px;
1897
+ height: 44px;
1898
+ margin: 6px 0;
1899
+ }
1900
+ .trend-bar {
1901
+ width: 14px;
1902
+ min-height: 2px;
1903
+ background: var(--accent-ghost);
1904
+ border-top: 2px solid var(--accent);
1905
+ }
1906
+
1907
+ /* ── find harnesses (M5): discovery without registration ───────────────── */
1908
+
1909
+ .find-row {
1910
+ display: flex;
1911
+ gap: 10px;
1912
+ align-items: center;
1913
+ padding: 7px 10px;
1914
+ border-bottom: 1px solid var(--rule);
1915
+ }
1916
+ .find-row .mono {
1917
+ flex: 1 1 auto;
1918
+ min-width: 0;
1919
+ overflow: hidden;
1920
+ text-overflow: ellipsis;
1921
+ white-space: nowrap;
1922
+ }
1923
+
1924
+ @media (max-width: 720px) {
1925
+ .adv-item {
1926
+ grid-template-columns: minmax(0, 1fr);
1927
+ }
1928
+ .adv-actions {
1929
+ align-items: flex-start;
1930
+ }
1931
+ }
package/assets/js/api.js CHANGED
@@ -262,7 +262,9 @@ export const api = {
262
262
  groupProc: (name, verb, body = {}) => call(ROUTES.groupProc, { name, verb }, body),
263
263
  setTags: (id, tags) => call(ROUTES.setTags, { id }, { tags }),
264
264
  setPin: (id, pinned) => call(ROUTES.setPin, { id }, { pinned }),
265
+ setHidden: (id, hidden) => call(ROUTES.setHidden, { id }, { hidden }),
265
266
  setNotes: (id, notes) => call(ROUTES.setNotes, { id }, { notes }),
267
+ discover: () => call(ROUTES.discover, {}),
266
268
  relocateHarness: (id, newDir) => call(ROUTES.relocate, { id }, { newDir }),
267
269
  removeHarness: (id) => call(ROUTES.removeHarness, { id }),
268
270
 
package/assets/js/app.js CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  import { shapeAccent, shapeLabel } from "./shapes.js";
38
38
  import { procRow } from "./supervision.js";
39
39
  import { renderActivity } from "./views/activity.js";
40
+ import { renderAdvisor, renderAdvisorBoard } from "./views/advisor.js";
40
41
  import { renderApprovals } from "./views/approvals.js";
41
42
  import { renderChannels } from "./views/channels.js";
42
43
  import { renderCosts } from "./views/costs.js";
@@ -51,6 +52,7 @@ import { renderInspect } from "./views/inspect.js";
51
52
  import { renderLibrary } from "./views/library.js";
52
53
  import { renderMemoryFabric } from "./views/memory-fabric.js";
53
54
  import { renderMemory } from "./views/memory.js";
55
+ import { renderModels } from "./views/models.js";
54
56
  import { renderOverview } from "./views/overview.js";
55
57
  import { renderPanes } from "./views/panes.js";
56
58
  import { renderReview } from "./views/review.js";
@@ -68,6 +70,7 @@ import { renderTokenScreen } from "./views/token.js";
68
70
  const THEME_KEY = "hangar.theme";
69
71
  const TAB_LABELS = {
70
72
  overview: "Overview",
73
+ advisor: "Advisor",
71
74
  spec: "Spec",
72
75
  runs: "Runs",
73
76
  schedulers: "Schedulers",
@@ -77,6 +80,7 @@ const TAB_LABELS = {
77
80
  data: "Datasets",
78
81
  feedback: "Feedback",
79
82
  costs: "Costs",
83
+ models: "Models",
80
84
  creds: "Credentials",
81
85
  channels: "Channels",
82
86
  security: "Security",
@@ -94,6 +98,7 @@ const NAV = [
94
98
  { view: "review", label: "Review" },
95
99
  { view: "activity", label: "Activity" },
96
100
  { view: "health", label: "Health" },
101
+ { view: "advisor", label: "Advisor" },
97
102
  { view: "credentials", label: "Credentials" },
98
103
  { view: "feedback", label: "Feedback" },
99
104
  { view: "thredz", label: "Thredz" },
@@ -352,6 +357,8 @@ async function dispatch(route) {
352
357
  await renderThredzGlobal(viewRoot);
353
358
  } else if (route.view === "health") {
354
359
  await renderHealthBoard(viewRoot);
360
+ } else if (route.view === "advisor") {
361
+ await renderAdvisorBoard(viewRoot);
355
362
  } else if (route.view === "settings") {
356
363
  await renderSettings(viewRoot);
357
364
  } else {
@@ -423,7 +430,8 @@ async function renderHarnessPage(root, route) {
423
430
  if (route.tab === "spec") {
424
431
  await renderSpec(tabRoot, ctx);
425
432
  await renderSpecEdit(section(tabRoot), ctx);
426
- } else if (route.tab === "runs") await renderRuns(tabRoot, ctx);
433
+ } else if (route.tab === "advisor") await renderAdvisor(tabRoot, ctx);
434
+ else if (route.tab === "runs") await renderRuns(tabRoot, ctx);
427
435
  else if (route.tab === "schedulers") await renderSchedulers(tabRoot, ctx);
428
436
  else if (route.tab === "sessions") await renderSessions(tabRoot, ctx);
429
437
  else if (route.tab === "evals") {
@@ -435,6 +443,7 @@ async function renderHarnessPage(root, route) {
435
443
  } else if (route.tab === "data") await renderData(tabRoot, ctx);
436
444
  else if (route.tab === "feedback") await renderFeedback(tabRoot, ctx);
437
445
  else if (route.tab === "costs") await renderCosts(tabRoot, ctx);
446
+ else if (route.tab === "models") await renderModels(tabRoot, ctx);
438
447
  else if (route.tab === "creds") await renderCreds(tabRoot, ctx);
439
448
  else if (route.tab === "channels") await renderChannels(tabRoot, ctx);
440
449
  else if (route.tab === "security") await renderSecurity(tabRoot, ctx);
package/assets/js/dom.js CHANGED
@@ -161,6 +161,36 @@ export function copyBtn(value, label = "copy") {
161
161
  return btn;
162
162
  }
163
163
 
164
+ /**
165
+ * Attach a rich hover tooltip. The text rides a `data-tip` attribute the
166
+ * stylesheet renders via `attr()` — always text, never markup, so the
167
+ * injection ban holds — with the native `title` kept as the fallback for
168
+ * assistive tech and anything that never hovers. Returns the node.
169
+ */
170
+ export function withTip(node, tipText) {
171
+ const value = String(tipText ?? "");
172
+ if (value === "") return node;
173
+ node.classList.add("has-tip");
174
+ node.dataset.tip = value;
175
+ if (!node.getAttribute("title")) node.setAttribute("title", value);
176
+ return node;
177
+ }
178
+
179
+ /** A small "?" affordance carrying only its tooltip — guidance on rows where
180
+ * a whole sentence inline would drown the content. Keyboard-focusable, so
181
+ * the tip is reachable without a pointer. */
182
+ export function tipIcon(tipText) {
183
+ return withTip(
184
+ el("span", {
185
+ class: "tip-icon",
186
+ tabindex: "0",
187
+ "aria-label": String(tipText ?? ""),
188
+ text: "?",
189
+ }),
190
+ tipText,
191
+ );
192
+ }
193
+
164
194
  /** A native collapsible (<details>) card. */
165
195
  export function collapsible(summaryChildren, bodyChildren, open = false) {
166
196
  return el("details", { class: "fold", open: open || null }, [
@@ -40,6 +40,9 @@
40
40
 
41
41
  export const HARNESS_TABS = [
42
42
  "overview",
43
+ // M5: the unified alert/suggestion feed — right after Overview because it
44
+ // is the "what needs doing" screen; the strip order is the triage order.
45
+ "advisor",
43
46
  "spec",
44
47
  "runs",
45
48
  "schedulers",
@@ -49,6 +52,9 @@ export const HARNESS_TABS = [
49
52
  "data",
50
53
  "feedback",
51
54
  "costs",
55
+ // 0.6.0 (design §8.3): the models tab sits after Costs because it answers
56
+ // the question Costs raises — WHICH model, under which settings, spent it.
57
+ "models",
52
58
  "creds",
53
59
  "channels",
54
60
  "security",
@@ -70,6 +76,10 @@ export const HARNESS_TABS = [
70
76
  * state that tab renders is a real answer and a shared URL must not 404. */
71
77
  export const M3_TABS = [
72
78
  "data",
79
+ // 0.6.0: `models` takes trailing segments generically too — a route
80
+ // timeline is `#/h/<id>/models/routes/<sess>`, and naming that sub-screen
81
+ // is the view's business, not the router's.
82
+ "models",
73
83
  "feedback",
74
84
  "creds",
75
85
  "channels",
@@ -77,6 +87,9 @@ export const M3_TABS = [
77
87
  "thredz",
78
88
  "inspect",
79
89
  "dev",
90
+ // M5: the advisor's sub-screens (a saved report, say) are the view's to
91
+ // name — the same generic `rest` capture the M3 tabs use.
92
+ "advisor",
80
93
  ];
81
94
 
82
95
  /** The global (fleet-wide) screens, in nav order. `#/` is the Library. */
@@ -92,6 +105,9 @@ export const GLOBAL_VIEWS = [
92
105
  // notification rules, read-only mode, the plugin inventory).
93
106
  "health",
94
107
  "settings",
108
+ // M5: the fleet advisor board — every harness's open alerts/suggestions,
109
+ // worst first.
110
+ "advisor",
95
111
  ];
96
112
 
97
113
  /**
@@ -56,7 +56,15 @@ export const ROUTES = {
56
56
  body: "GroupProc", // {force?, acknowledge?, parallel?}
57
57
  },
58
58
  addScanRoot: { method: "POST", path: "/api/registry/scan-roots", body: "ScanRootCreate" }, // {dir}
59
+ // Discovery WITHOUT registration: the harnesses under the scan roots that
60
+ // are NOT yet registered, each one an Add away — the "find harnesses on
61
+ // this machine" flow. Adding stays the default gesture; a blanket scan
62
+ // (which registers everything) is the bulk fallback, not the front door.
63
+ discover: { method: "GET", path: "/api/registry/discover" },
59
64
  removeHarness: { method: "DELETE", path: "/api/h/:id" },
65
+ // Curation, not removal: hidden entries stay registered (state, groups,
66
+ // history intact) but the Library's default view omits them.
67
+ setHidden: { method: "PUT", path: "/api/h/:id/visibility", body: "SetVisibility" }, // {hidden}
60
68
  relocate: { method: "POST", path: "/api/h/:id/relocate", body: "Relocate" }, // {newDir}
61
69
  setGroups: { method: "PUT", path: "/api/h/:id/groups", body: "SetGroups" }, // {groups}
62
70
  // One member's BOOT position inside a group; `order: null` clears it.
@@ -741,6 +749,73 @@ export const ROUTES = {
741
749
  group: "inspect",
742
750
  },
743
751
 
752
+ // ---- group "advisor": the unified alert/suggestion feed + its loops ----
753
+ // M5. Every alert, suggestion and optimization signal the manager can read
754
+ // about a harness, in ONE feed: each item carries its severity, the fact it
755
+ // came from, a tooltip-grade explanation, guidance, the screen that owns
756
+ // the fix, and a quick action (a queued CLI verb from a closed vocabulary,
757
+ // or a deep link — a suggestion is never an application). Acting takes an
758
+ // optional operator comment; dismissing REQUIRES a reason; both append to
759
+ // a harness-local ledger and reopen supersedes rather than deletes.
760
+ advisor: { method: "GET", path: "/api/h/:id/advisor", group: "advisor" },
761
+ advisorAct: {
762
+ method: "POST",
763
+ path: "/api/h/:id/advisor/:itemId/act",
764
+ body: "AdvisorAct", // {comment?}
765
+ group: "advisor",
766
+ },
767
+ advisorDismiss: {
768
+ method: "POST",
769
+ path: "/api/h/:id/advisor/:itemId/dismiss",
770
+ body: "AdvisorDismiss", // {reason}
771
+ group: "advisor",
772
+ },
773
+ advisorReopen: {
774
+ method: "POST",
775
+ path: "/api/h/:id/advisor/:itemId/reopen",
776
+ body: "Empty", // {}
777
+ group: "advisor",
778
+ },
779
+ // Improvement over time, folded from durable sources only (the eval index,
780
+ // the cost ledger, the decisions ledger) — a read never stores a snapshot.
781
+ advisorTrend: { method: "GET", path: "/api/h/:id/advisor/trend", group: "advisor" },
782
+ advisorReports: { method: "GET", path: "/api/h/:id/advisor/reports", group: "advisor" },
783
+ advisorReportRun: {
784
+ method: "POST",
785
+ path: "/api/h/:id/advisor/reports",
786
+ body: "AdvisorReportRun", // {kind: model-usage|costs|usefulness|optimization|routing|hybrid}
787
+ group: "advisor",
788
+ },
789
+ advisorReport: {
790
+ method: "GET",
791
+ path: "/api/h/:id/advisor/reports/:reportId",
792
+ group: "advisor",
793
+ },
794
+ // The issue inbox: a complaint submitted here is tuned into an update
795
+ // ready to run — `optimize` queues the eval→patch loop whose artifact is a
796
+ // reviewable spec patch; `note` records without queueing anything.
797
+ advisorIssues: { method: "GET", path: "/api/h/:id/advisor/issues", group: "advisor" },
798
+ advisorIssueSubmit: {
799
+ method: "POST",
800
+ path: "/api/h/:id/advisor/issues",
801
+ body: "AdvisorIssue", // {title, detail?, kind?}
802
+ group: "advisor",
803
+ },
804
+ advisorFleet: { method: "GET", path: "/api/advisor", group: "advisor" },
805
+
806
+ // ---- group "models": per-model settings, hybrid routing, the scoreboard -
807
+ // 0.6.0 (design §8.3). READ-ONLY, all four: the console renders the
808
+ // `models:` registry, the declared pool, per-role/per-profile spend, the
809
+ // learned arms and their leaderboard, and one run's route timeline — and
810
+ // it writes none of them. Hangar never touches an arm or a prior; the
811
+ // roster, the rules and the floor stay human-owned behind `propose`.
812
+ models: { method: "GET", path: "/api/h/:id/models", group: "models" },
813
+ // The route timeline for ONE session, read from the session JSONL (§8.1:
814
+ // Hangar reads the durable log, never the bus).
815
+ modelRoutes: { method: "GET", path: "/api/h/:id/models/routes/:sess", group: "models" },
816
+ modelArms: { method: "GET", path: "/api/h/:id/models/arms", group: "models" },
817
+ modelLeaderboard: { method: "GET", path: "/api/h/:id/models/leaderboard", group: "models" },
818
+
744
819
  // ---- group "runtime": the mcp-server + dev run classes -----------------
745
820
  // Both are PROCESSES: they get runfiles, ledger rows and ledger-claimed
746
821
  // ports, and their live output rides the ONE existing SSE feed
@@ -763,8 +838,9 @@ export const ROUTES = {
763
838
  devStop: { method: "POST", path: "/api/h/:id/dev/stop", body: "Empty", group: "runtime" },
764
839
  };
765
840
 
766
- /** The M3 area groups, in tab order. A route's `group` names the module that
767
- * owns it on the server and the view that renders it in the console. */
841
+ /** The grouped areas, in tab order (the M3 areas plus M5's advisor). A
842
+ * route's `group` names the module that owns it on the server and the view
843
+ * that renders it in the console. */
768
844
  export const M3_GROUPS = [
769
845
  "spec",
770
846
  "memory",
@@ -777,6 +853,8 @@ export const M3_GROUPS = [
777
853
  "thredz",
778
854
  "inspect",
779
855
  "runtime",
856
+ "advisor",
857
+ "models",
780
858
  ];
781
859
 
782
860
  /** Every route key belonging to `group`, in map order. The views render
@@ -700,6 +700,15 @@ function shortJson(value, max = 120) {
700
700
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
701
701
  }
702
702
 
703
+ /** `model` first, then the optional facets, then the router's reason — joined with dots. */
704
+ function routeDetail(e, parts) {
705
+ const head = parts
706
+ .filter((part) => part !== null && part !== undefined && part !== "")
707
+ .map(String);
708
+ const reason = typeof e.reason === "string" && e.reason !== "" ? e.reason : null;
709
+ return [...head, ...(reason ? [reason] : [])].join(" · ");
710
+ }
711
+
703
712
  /**
704
713
  * One TraceEvent → its feed card. Known kinds get a written line; unknown
705
714
  * kinds still render (tolerant-reader contract) with a clipped payload
@@ -763,8 +772,68 @@ export function traceCard(event) {
763
772
  remediation: typeof e.remediation === "string" ? e.remediation : null,
764
773
  };
765
774
  case "model_route":
775
+ // 0.6.0 (design §8.3) — the event carries `model` (wire id) and
776
+ // `routeKey`, never `modelId`/`band`; the 0.5.x card read the latter
777
+ // and rendered an empty detail. Profile, policy and reason follow when
778
+ // present so a hybrid decision reads as a sentence.
779
+ return {
780
+ ...base,
781
+ title: `model route${e.stage ? ` · ${String(e.stage)}` : ""}`,
782
+ detail: routeDetail(e, [
783
+ e.model,
784
+ e.profile ? `profile=${String(e.profile)}` : null,
785
+ e.routeKey ? `band=${String(e.routeKey)}` : null,
786
+ e.policy
787
+ ? `policy=${String(e.policy)}${e.explored === true ? " (exploring)" : ""}`
788
+ : null,
789
+ e.ruleId ? `rule=${String(e.ruleId)}` : null,
790
+ ]),
791
+ };
766
792
  case "model_tier_route":
767
- return { ...base, title: "model route", detail: String(e.modelId ?? e.band ?? "") };
793
+ return {
794
+ ...base,
795
+ title: "model tier",
796
+ detail: routeDetail(e, [
797
+ e.model,
798
+ e.tier ? `tier=${String(e.tier)}${e.escalated === true ? " (escalated)" : ""}` : null,
799
+ ]),
800
+ };
801
+ case "model_failover":
802
+ return {
803
+ ...base,
804
+ title: "model failover",
805
+ detail: `${String(e.from ?? "?")} → ${String(e.to ?? "?")}${
806
+ e.reason ? ` · ${String(e.reason)}` : ""
807
+ }`,
808
+ };
809
+ case "model_stage":
810
+ return {
811
+ ...base,
812
+ dot: e.outcome === "failed" ? "warn" : base.dot,
813
+ title: `stage ${String(e.stage ?? "?")} ${String(e.outcome ?? "")}`.trim(),
814
+ detail: [
815
+ e.model ? String(e.model) : null,
816
+ e.profile ? `profile=${String(e.profile)}` : null,
817
+ e.role ? `role=${String(e.role)}` : null,
818
+ e.strategy ? `strategy=${String(e.strategy)}` : null,
819
+ e.cause ? `cause=${String(e.cause)}` : null,
820
+ ]
821
+ .filter((part) => part !== null)
822
+ .join(" · "),
823
+ };
824
+ case "model_directive":
825
+ return {
826
+ ...base,
827
+ dot: e.accepted === false ? "warn" : base.dot,
828
+ title: `/model ${String(e.requested ?? "?")} ${e.accepted === false ? "refused" : "accepted"}`,
829
+ detail: [
830
+ e.resolved ? `→ ${String(e.resolved)}` : null,
831
+ e.source ? `source=${String(e.source)}` : null,
832
+ e.reason ? String(e.reason) : null,
833
+ ]
834
+ .filter((part) => part !== null)
835
+ .join(" · "),
836
+ };
768
837
  case "janitor_action":
769
838
  return { ...base, title: "janitor", detail: String(e.action ?? "") };
770
839
  default:
package/assets/js/util.js CHANGED
@@ -180,6 +180,7 @@ export function normalizeRows(feed) {
180
180
  groups: Array.isArray(src.groups) ? src.groups.map(String) : [],
181
181
  tags: Array.isArray(src.tags) ? src.tags.map(String) : [],
182
182
  pinned: src.pinned === true,
183
+ hidden: src.hidden === true,
183
184
  notes: typeof src.notes === "string" ? src.notes : "",
184
185
  missingSince: typeof src.missingSince === "string" ? src.missingSince : null,
185
186
  lastSeen: typeof src.lastSeen === "string" ? src.lastSeen : null,