@homeostat/dashboard 0.1.0 → 0.1.1

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.
@@ -0,0 +1,44 @@
1
+ import { type Config, type DecisionRecord, type DecisionsFile, type Heartbeat, type RunReport } from "@homeostat/core";
2
+ /**
3
+ * The Automation page reads the `homeostat/state` branch the daily run writes. It reads the
4
+ * clone's copy (`origin/homeostat/state`), which every sync fetches, so the page makes no network
5
+ * call: it is as current as this clone's last sync.
6
+ */
7
+ export interface AutomationState {
8
+ /** False when no daily run has written the state branch yet (or this clone never fetched it). */
9
+ present: boolean;
10
+ runs: RunReport[];
11
+ heartbeats: Heartbeat[];
12
+ decisions: DecisionsFile[];
13
+ }
14
+ export declare function readAutomation(kbRoot: string, maxRuns?: number): AutomationState;
15
+ export interface SyncHealth {
16
+ member: string;
17
+ role: string;
18
+ heartbeat?: Heartbeat;
19
+ /** "ok", "stale" (no report for STALE_HEARTBEAT_HOURS), "never" (no sync has reported) */
20
+ state: "ok" | "stale" | "never";
21
+ ageHours?: number;
22
+ today?: RunReport["members"][number];
23
+ }
24
+ export interface QueueItem extends DecisionRecord {
25
+ member: string;
26
+ ageDays: number;
27
+ overdue: boolean;
28
+ }
29
+ export interface AutomationView {
30
+ present: boolean;
31
+ lastRun?: RunReport;
32
+ runs: RunReport[];
33
+ health: SyncHealth[];
34
+ queue: QueueItem[];
35
+ /** Lessons decided per day, by outcome, over the runs on record. */
36
+ byDay: {
37
+ day: string;
38
+ merged: number;
39
+ review: number;
40
+ declined: number;
41
+ }[];
42
+ }
43
+ export declare function automationView(state: AutomationState, config: Config, now: Date): AutomationView;
44
+ export declare function automationPage(v: AutomationView): string;
@@ -0,0 +1,142 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { REVIEW_REMINDER_DAYS, STALE_HEARTBEAT_HOURS, STATE_BRANCH, } from "@homeostat/core";
3
+ import { columns, dataTable, esc } from "./charts.js";
4
+ const REF = `refs/remotes/origin/${STATE_BRANCH}`;
5
+ function git(dir, ...args) {
6
+ try {
7
+ return execFileSync("git", ["-C", dir, ...args], {
8
+ encoding: "utf8",
9
+ stdio: ["ignore", "pipe", "ignore"],
10
+ maxBuffer: 32 * 1024 * 1024,
11
+ });
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ function json(dir, path) {
18
+ try {
19
+ return JSON.parse(git(dir, "show", `${REF}:${path}`) ?? "");
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ export function readAutomation(kbRoot, maxRuns = 30) {
26
+ const listing = git(kbRoot, "ls-tree", "-r", "--name-only", REF);
27
+ if (listing === undefined)
28
+ return { present: false, runs: [], heartbeats: [], decisions: [] };
29
+ const paths = listing.split("\n").filter(Boolean);
30
+ const under = (dir) => paths.filter((p) => p.startsWith(`${dir}/`) && p.endsWith(".json"));
31
+ const runs = under("runs")
32
+ .sort()
33
+ .slice(-maxRuns)
34
+ .flatMap((p) => json(kbRoot, p) ?? []);
35
+ const heartbeats = under("heartbeats").flatMap((p) => json(kbRoot, p) ?? []);
36
+ const decisions = under("decisions").flatMap((p) => json(kbRoot, p) ?? []);
37
+ return { present: true, runs, heartbeats, decisions };
38
+ }
39
+ export function automationView(state, config, now) {
40
+ const lastRun = state.runs.at(-1);
41
+ const health = config.team.members.map((m) => {
42
+ const heartbeat = state.heartbeats.find((h) => h.member === m.id);
43
+ const ageHours = heartbeat ? (now.getTime() - Date.parse(heartbeat.at)) / 3_600_000 : undefined;
44
+ const today = lastRun?.members.find((r) => r.member === m.id);
45
+ return {
46
+ member: m.id,
47
+ role: m.role,
48
+ ...(heartbeat ? { heartbeat } : {}),
49
+ ...(ageHours !== undefined ? { ageHours } : {}),
50
+ ...(today ? { today } : {}),
51
+ state: !heartbeat ? "never" : (ageHours ?? 0) > STALE_HEARTBEAT_HOURS ? "stale" : "ok",
52
+ };
53
+ });
54
+ const queue = state.decisions
55
+ .flatMap((d) => d.decided
56
+ .filter((r) => r.decision === "review")
57
+ .map((r) => {
58
+ const ageDays = (now.getTime() - Date.parse(r.at)) / 86_400_000;
59
+ return { ...r, member: d.member, ageDays, overdue: ageDays > REVIEW_REMINDER_DAYS };
60
+ }))
61
+ .sort((a, b) => b.ageDays - a.ageDays);
62
+ const byDay = state.runs.map((r) => ({
63
+ day: r.at.slice(0, 10),
64
+ merged: r.members.reduce((n, m) => n + m.merged, 0),
65
+ review: r.members.reduce((n, m) => n + m.review, 0),
66
+ declined: r.members.reduce((n, m) => n + m.declined, 0),
67
+ }));
68
+ return {
69
+ present: state.present,
70
+ ...(lastRun ? { lastRun } : {}),
71
+ runs: state.runs,
72
+ health,
73
+ queue,
74
+ byDay,
75
+ };
76
+ }
77
+ const age = (hours) => hours === undefined
78
+ ? "—"
79
+ : hours < 1
80
+ ? "under 1h"
81
+ : hours < 48
82
+ ? `${Math.round(hours)}h`
83
+ : `${Math.round(hours / 24)}d`;
84
+ const prLink = (url, label = "PR") => url && /^https:\/\//.test(url) ? `<a href="${esc(url)}">${esc(label)}</a>` : "";
85
+ function healthRow(h) {
86
+ const lamp = h.state === "ok" ? "good" : h.state === "stale" ? "warning" : "";
87
+ const state = h.state === "ok"
88
+ ? "Syncing"
89
+ : h.state === "stale"
90
+ ? `Not syncing for ${age(h.ageHours)}`
91
+ : "No sync yet";
92
+ const hb = h.heartbeat;
93
+ const kb = !hb ? "—" : hb.behind > 0 ? `${hb.behind} behind` : "current";
94
+ const t = h.today;
95
+ const outcome = t
96
+ ? `${t.merged} merged · ${t.review} to review · ${t.declined} declined${t.mergeError ? " · merge refused" : ""}`
97
+ : "—";
98
+ return `<tr><td class="mono">${esc(h.member)}</td><td>${esc(h.role)}</td><td><span class="lamp ${lamp}" aria-hidden="true" style="display:inline-block;vertical-align:middle;margin-right:6px"></span>${esc(state)}</td><td class="num">${age(h.ageHours)}</td><td class="mono">${esc(kb)}</td><td class="num">${hb?.pending ?? "—"}</td><td class="mono">${esc(hb?.version ?? "—")}</td><td>${esc(outcome)} ${prLink(t?.dailyPr, "daily")} ${prLink(t?.reviewPr, "review")}</td></tr>`;
99
+ }
100
+ export function automationPage(v) {
101
+ if (!v.present) {
102
+ return `<section class="panel"><h2>Automation</h2><p class="lede">No daily run on record in this clone yet. The daily run (<code>homeostat daily</code>, scheduled in the KB repo's CI) writes its decisions to the <code>${STATE_BRANCH}</code> branch, and each developer's sync fetches it. Once the first run has happened and this clone has synced, its runs, every developer's sync health and the review queue show up here.</p></section>`;
103
+ }
104
+ const r = v.lastRun;
105
+ const stat = (n, k) => `<div class="stat"><span class="n">${n}</span><span class="k">${k}</span></div>`;
106
+ const total = (k) => r?.members.reduce((n, m) => n + m[k], 0) ?? 0;
107
+ const notSyncing = v.health.filter((h) => h.state !== "ok");
108
+ const overdue = v.queue.filter((q) => q.overdue).length;
109
+ const daily = v.byDay.map((d) => ({
110
+ label: d.day.slice(5),
111
+ value: d.merged + d.review + d.declined,
112
+ title: `${d.day}: ${d.merged} merged, ${d.review} to review, ${d.declined} declined`,
113
+ }));
114
+ return `<div class="grid">
115
+ <section class="panel span-8" aria-labelledby="last"><h2 id="last">Last daily run</h2>
116
+ ${r ? `<p class="lede mono">${esc(r.at.replace("T", " ").slice(0, 16))} UTC</p>` : ""}
117
+ <div class="stats" style="--cols: 4">${stat(total("merged"), "merged")}${stat(total("review"), "to review")}${stat(total("declined"), "declined")}${stat(r?.settled ?? 0, "settled by reviewers")}</div>
118
+ ${daily.length > 1 ? `<div style="margin-top:14px"><span class="label">Lessons decided per run</span>${columns(daily, { title: "Lessons decided per run", labelled: "ends", tickEvery: 7 })}${dataTable(daily, ["Run", "Lessons"])}</div>` : ""}</section>
119
+ <section class="panel span-4" aria-labelledby="attention"><h2 id="attention">Needs attention</h2>
120
+ <div class="stats" style="--cols: 2">${stat(v.queue.length, "in review")}${stat(overdue, `waiting &gt; ${REVIEW_REMINDER_DAYS} days`)}${stat(notSyncing.length, "not syncing")}${stat(r?.members.filter((m) => m.mergeError).length ?? 0, "merges refused")}</div>
121
+ ${notSyncing.length ? `<p class="lede">Not syncing: ${notSyncing.map((h) => `<span class="mono">${esc(h.member)}</span>`).join(", ")}. Their lessons reach the daily run only after a session syncs; check that the Homeostat plugin is installed for them.</p>` : ""}</section>
122
+ <section class="panel span-12" aria-labelledby="queue"><h2 id="queue">Review queue</h2><p class="lede">Lessons the daily run could not decide on its own. Merging the review PR accepts them; closing it declines them. The next daily run records the outcome, and the developer's sync picks it up.</p>
123
+ ${v.queue.length
124
+ ? `<div class="table-wrap"><table><thead><tr><th>Lesson</th><th>Developer</th><th class="num">Confidence</th><th>Why it waits</th><th class="num">Waiting</th><th>PR branch</th></tr></thead><tbody>${v.queue
125
+ .map((q) => `<tr><td class="mono">${esc(q.id)}</td><td class="mono">${esc(q.member)}</td><td class="num">${q.confidence ?? "—"}</td><td>${esc(q.reason)}</td><td class="num">${q.overdue ? '<span class="chip disputed">' : ""}${Math.floor(q.ageDays)}d${q.overdue ? "</span>" : ""}</td><td class="mono">${esc(q.pr ?? "—")}</td></tr>`)
126
+ .join("")}</tbody></table></div>`
127
+ : '<p class="empty">Nothing waiting for a reviewer.</p>'}</section>
128
+ <section class="panel span-12" aria-labelledby="health"><h2 id="health">Developer sync</h2><p class="lede">Each developer's sessions sync their clone in the background and report a heartbeat; the daily run collects them. "Not syncing" means no report for ${STALE_HEARTBEAT_HOURS} hours: normal over a weekend, worth a look after that.</p>
129
+ <div class="table-wrap"><table><thead><tr><th>Member</th><th>Role</th><th>Sync</th><th class="num">Last report</th><th>KB clone</th><th class="num">Pending</th><th>Version</th><th>Last daily run</th></tr></thead><tbody>${v.health.map(healthRow).join("")}</tbody></table></div></section>
130
+ <section class="panel span-12" aria-labelledby="runs"><h2 id="runs">Runs</h2>
131
+ <div class="table-wrap"><table><thead><tr><th>Run</th><th class="num">Merged</th><th class="num">To review</th><th class="num">Declined</th><th class="num">Settled</th><th>Merge refused</th></tr></thead><tbody>${[
132
+ ...v.runs,
133
+ ]
134
+ .reverse()
135
+ .map((run) => {
136
+ const sum = (k) => run.members.reduce((n, m) => n + m[k], 0);
137
+ const refused = run.members.filter((m) => m.mergeError).map((m) => m.member);
138
+ return `<tr><td class="mono">${esc(run.at.replace("T", " ").slice(0, 16))}</td><td class="num">${sum("merged")}</td><td class="num">${sum("review")}</td><td class="num">${sum("declined")}</td><td class="num">${run.settled}</td><td class="mono">${esc(refused.join(", ") || "—")}</td></tr>`;
139
+ })
140
+ .join("")}</tbody></table></div></section>
141
+ </div>`;
142
+ }
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { createServer } from "node:http";
4
4
  import { join } from "node:path";
5
5
  import { CONFIG_FILE, readEvents, readKbFiles, sectionUsage, validate, } from "@homeostat/core";
6
+ import { automationPage, automationView, readAutomation } from "./automation.js";
6
7
  import { agentLiveness, contributions, coverage, desirePaths, health, impact, } from "./metrics.js";
7
8
  import { applySection, SECTIONS } from "./settings.js";
8
9
  import { coveragePage, desirePathsPage, documentsPage, entriesPage, entryPage, eventsPage, impactPage, layout, membersPage, messagePage, overviewPage, settingsIndex, settingsPage, } from "./views.js";
@@ -42,6 +43,14 @@ export function startDashboard(opts) {
42
43
  "[::1]",
43
44
  ...(host === "0.0.0.0" ? [] : [host]),
44
45
  ]);
46
+ const sameSite = (origin) => {
47
+ try {
48
+ return allowed(new URL(origin).host);
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ };
45
54
  const allowed = (hostPort) => {
46
55
  try {
47
56
  return Boolean(hostPort) && allowedNames.has(new URL(`http://${hostPort}`).hostname);
@@ -69,7 +78,9 @@ export function startDashboard(opts) {
69
78
  "cache-control": "no-store",
70
79
  "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'",
71
80
  "x-content-type-options": "nosniff",
72
- "referrer-policy": "no-referrer",
81
+ // same-origin, not no-referrer: under no-referrer Chrome sends "Origin: null" on this
82
+ // page's own form posts, which the write check below must refuse.
83
+ "referrer-policy": "same-origin",
73
84
  });
74
85
  res.end(html);
75
86
  };
@@ -86,8 +97,9 @@ export function startDashboard(opts) {
86
97
  return page("No knowledge base", messagePage("No knowledge base", `${CONFIG_FILE} is missing or invalid in ${opts.kbRoot}. Run homeostat validate there.`), 500);
87
98
  }
88
99
  if (req.method === "POST") {
100
+ // Our own pages send their origin; "null" or anything unreadable is refused, never parsed blind.
89
101
  const origin = req.headers.origin;
90
- if (origin && !allowed(new URL(origin).host))
102
+ if (origin && !sameSite(origin))
91
103
  return send(res, 403, "Cross-origin request refused");
92
104
  const m = /^\/settings\/([a-z]+)$/.exec(url.pathname);
93
105
  const section = m && SECTIONS.find((s) => s.id === m[1]);
@@ -118,6 +130,8 @@ export function startDashboard(opts) {
118
130
  const recent = snap.events.slice(-12).reverse();
119
131
  return page("Overview", overviewPage(health(snap), agentLiveness(snap), impact(snap), recent));
120
132
  }
133
+ case "/automation":
134
+ return page("Automation", automationPage(automationView(readAutomation(opts.kbRoot), snap.config, snap.now)));
121
135
  case "/entries":
122
136
  return page("Entries", entriesPage(snap.entries, url.searchParams.get("status")));
123
137
  case "/members":
@@ -93,6 +93,27 @@ export const SECTIONS = [
93
93
  fields: [
94
94
  ...Object.keys(DEFAULT_WEIGHTS).map((k) => ({ path: ["scoring", "weights", k], label: `Weight: ${k}`, kind: "number" })),
95
95
  { path: ["scoring", "autoMergeThreshold"], label: "Auto-merge threshold", kind: "number" },
96
+ { path: ["scoring", "confidence", "accept"], label: "Merge at confidence", kind: "number" },
97
+ {
98
+ path: ["scoring", "confidence", "coreAccept"],
99
+ label: "Merge at confidence (core or global rules)",
100
+ kind: "number",
101
+ },
102
+ {
103
+ path: ["scoring", "confidence", "decline"],
104
+ label: "Decline below confidence",
105
+ kind: "number",
106
+ },
107
+ ...[
108
+ ["base", ["prFeedbackChangedCode", "prFeedback", "manual", "agentHandoff", "deepDive"]],
109
+ ["roles", ["junior", "member", "senior", "lead"]],
110
+ ["bonus", ["areaOwner", "specific", "goodTitle", "repeat", "modelRating"]],
111
+ ["penalty", ["vagueTitle", "conflict", "flag"]],
112
+ ].flatMap(([group, keys]) => keys.map((k) => ({
113
+ path: ["scoring", "confidence", group, k],
114
+ label: `Confidence ${group}: ${k}`,
115
+ kind: "number",
116
+ }))),
96
117
  { path: ["scoring", "autoMerge", "enabled"], label: "Auto-merge", kind: "checkbox" },
97
118
  {
98
119
  path: ["scoring", "autoMerge", "areas"],
@@ -148,7 +169,7 @@ export const SECTIONS = [
148
169
  optional: true,
149
170
  help: "global, us, eu or a region",
150
171
  },
151
- ...agentFields("curator", "Curator"),
172
+ ...agentFields("curator", "Daily run (curator)"),
152
173
  ...agentFields("deepDive", "Deep dive"),
153
174
  {
154
175
  path: ["agents", "deepDive", "staleAfterDays"],
@@ -257,6 +278,18 @@ export function applySection(yamlText, sectionId, form) {
257
278
  ],
258
279
  };
259
280
  const doc = parseDocument(yamlText);
281
+ // A field whose value did not change is left untouched: defaults stay implicit, formatting stays.
282
+ const before = parseConfig(yamlText).config;
283
+ const canon = (v) => Array.isArray(v)
284
+ ? v.map(canon)
285
+ : v && typeof v === "object"
286
+ ? Object.fromEntries(Object.entries(v)
287
+ .filter(([, x]) => x !== undefined)
288
+ .sort(([a], [b]) => a.localeCompare(b))
289
+ .map(([k, x]) => [k, canon(x)]))
290
+ : v;
291
+ const same = (a, b) => JSON.stringify(canon(a)) === JSON.stringify(canon(b));
292
+ const unchanged = (path, value) => before !== undefined && same(value, currentValue(before, { path }));
260
293
  if (section.rows === "team") {
261
294
  const members = rowIndices(form, "member").flatMap((i) => {
262
295
  const id = form.get(`member.${i}.id`)?.trim();
@@ -274,7 +307,8 @@ export function applySection(yamlText, sectionId, form) {
274
307
  },
275
308
  ];
276
309
  });
277
- doc.setIn(["team", "members"], doc.createNode(members));
310
+ if (!unchanged(["team", "members"], members))
311
+ doc.setIn(["team", "members"], doc.createNode(members));
278
312
  }
279
313
  else if (section.rows === "teams") {
280
314
  const teams = {};
@@ -303,7 +337,8 @@ export function applySection(yamlText, sectionId, form) {
303
337
  continue;
304
338
  areas[name] = lines(form.get(`area.${i}.globs`) ?? "");
305
339
  }
306
- doc.setIn(["areas"], doc.createNode(areas));
340
+ if (!unchanged(["areas"], areas))
341
+ doc.setIn(["areas"], doc.createNode(areas));
307
342
  }
308
343
  else {
309
344
  for (const field of section.fields ?? []) {
@@ -313,27 +348,31 @@ export function applySection(yamlText, sectionId, form) {
313
348
  doc.deleteIn(["sources"]);
314
349
  break;
315
350
  }
316
- if (field.kind === "checkbox")
317
- doc.setIn(field.path, form.get(name) === "on");
318
- else if (field.kind === "checks")
319
- doc.setIn(field.path, doc.createNode(form.getAll(name)));
320
- else if (field.kind === "lines")
321
- doc.setIn(field.path, doc.createNode(lines(raw ?? "")));
322
- else if (field.kind === "number") {
323
- // An empty or missing number means "use the default", so the key is removed.
324
- if (raw === null || raw === "") {
325
- if (doc.hasIn(field.path))
326
- doc.deleteIn(field.path);
327
- }
328
- else
329
- doc.setIn(field.path, Number(raw));
330
- }
331
- else if (field.optional && !raw?.trim()) {
351
+ // An empty number or optional text means "use the default", so the key is removed.
352
+ const clear = (field.kind === "number" && (raw === null || raw === "")) ||
353
+ (field.kind !== "checkbox" &&
354
+ field.kind !== "checks" &&
355
+ field.kind !== "lines" &&
356
+ field.kind !== "number" &&
357
+ field.optional &&
358
+ !raw?.trim());
359
+ if (clear) {
332
360
  if (doc.hasIn(field.path))
333
361
  doc.deleteIn(field.path);
362
+ continue;
334
363
  }
335
- else if (raw !== null)
336
- doc.setIn(field.path, raw.trim());
364
+ const value = field.kind === "checkbox"
365
+ ? form.get(name) === "on"
366
+ : field.kind === "checks"
367
+ ? form.getAll(name)
368
+ : field.kind === "lines"
369
+ ? lines(raw ?? "")
370
+ : field.kind === "number"
371
+ ? Number(raw)
372
+ : raw?.trim();
373
+ if (value === undefined || unchanged(field.path, value))
374
+ continue;
375
+ doc.setIn(field.path, Array.isArray(value) ? doc.createNode(value) : value);
337
376
  }
338
377
  }
339
378
  // Drop sections that became empty maps, so the defaults apply.
@@ -342,7 +381,7 @@ export function applySection(yamlText, sectionId, form) {
342
381
  if (isMap(node) && node.items.length === 0)
343
382
  doc.delete(key);
344
383
  }
345
- const text = doc.toString();
384
+ const text = doc.toString({ flowCollectionPadding: false });
346
385
  const { config, issues } = parseConfig(text);
347
386
  return config ? { text, issues: [] } : { issues };
348
387
  }
package/dist/src/views.js CHANGED
@@ -75,9 +75,9 @@ header.top { display: flex; align-items: baseline; justify-content: space-betwee
75
75
  nav { display: flex; flex-wrap: wrap; gap: 4px 14px; }
76
76
  nav a { color: var(--ink-2); text-decoration: none; font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 0; border-bottom: 1px solid transparent; }
77
77
  nav a[aria-current="page"] { color: var(--ink); border-bottom-color: var(--ink); }
78
- .grid { display: grid; gap: 16px; grid-template-columns: repeat(12, 1fr); }
78
+ .grid { display: grid; gap: 16px; grid-template-columns: repeat(12, minmax(0, 1fr)); }
79
79
  .panel { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; padding: 16px 18px; grid-column: span 12; min-width: 0; }
80
- .span-4 { grid-column: span 4; } .span-6 { grid-column: span 6; } .span-8 { grid-column: span 8; }
80
+ .span-4 { grid-column: span 4; } .span-6 { grid-column: span 6; } .span-8 { grid-column: span 8; } .span-12 { grid-column: span 12; }
81
81
  @media (max-width: 860px) { .span-4, .span-6, .span-8 { grid-column: span 12; } }
82
82
  .label { color: var(--ink-3); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.08em; }
83
83
  .lede { color: var(--ink-2); margin: 4px 0 14px; max-width: 70ch; }
@@ -152,6 +152,7 @@ footer { margin-top: 36px; color: var(--ink-3); font-size: 12.5px; border-top: 1
152
152
  `;
153
153
  const NAV = [
154
154
  ["/", "Overview"],
155
+ ["/automation", "Automation"],
155
156
  ["/entries", "Entries"],
156
157
  ["/members", "Members"],
157
158
  ["/coverage", "Coverage"],
@@ -196,7 +197,7 @@ function lampFor(a) {
196
197
  }
197
198
  function unit(a) {
198
199
  const lamp = lampFor(a);
199
- const name = a.agent === "curator" ? "Curator" : "Deep dive";
200
+ const name = a.agent === "curator" ? "Daily run" : "Deep dive";
200
201
  return `<div class="unit">
201
202
  <div class="head"><span class="lamp ${lamp.cls}" aria-hidden="true"></span><h3>${name}</h3><span class="state">${lamp.text}</span></div>
202
203
  <div class="mono muted">${esc(a.schedule)} UTC</div>
@@ -228,7 +229,7 @@ ${dial(h.stability, { title: "Share of entries accepted, fresh and unflagged", c
228
229
  <section class="panel span-8" aria-labelledby="kb"><h2 id="kb">Knowledge base</h2>
229
230
  <div class="stats" style="--cols: 3">${stat(h.total, "entries")}${stat(h.byStatus.accepted, "accepted")}${stat(h.byStatus.candidate, "awaiting review")}${stat(h.flagged.length, "flagged")}${stat(h.stale.length, "stale")}${stat(h.byStatus.disputed, "disputed")}</div>
230
231
  <div style="margin-top:14px"><span class="label">Injections into sessions, last 14 days</span>${columns(daily, { title: "Injections per day", labelled: "ends", tickEvery: 7 })}${dataTable(daily, ["Day", "Injections"])}</div></section>
231
- <section class="panel span-6" aria-labelledby="agents"><h2 id="agents">Agents</h2><p class="lede">Runs recorded in this checkout's log (shared box or local runs). CI runs show up as their PRs on the KB repo.</p><div class="units">${agents.map(unit).join("")}</div></section>
232
+ <section class="panel span-6" aria-labelledby="agents"><h2 id="agents">Agents</h2><p class="lede">Runs recorded in this checkout's log (shared box or local runs). CI runs show up as their PRs on the KB repo.</p><div class="units">${agents.map(unit).join("")}</div><p><a href="/automation">Daily run decisions, review queue and developer sync</a></p></section>
232
233
  <section class="panel span-6" aria-labelledby="recent"><h2 id="recent">Recent events</h2>${recent.length ? recentTable(recent) : '<p class="empty">No events yet. Sessions, MCP calls and captures will appear here.</p>'}<p><a href="/events">All events</a></p></section>
233
234
  </div>`;
234
235
  }
@@ -356,7 +357,7 @@ function rowEditor(section, config) {
356
357
  if (section.rows === "team") {
357
358
  const members = [...config.team.members, ...Array(3).fill(undefined)];
358
359
  return `<div class="table-wrap"><table class="rows"><thead><tr><th>Id</th><th>Handle</th><th>Role</th><th>Areas</th><th title="The curator never reads this member's pull requests or review comments">Opted out</th><th>Remove</th></tr></thead><tbody>${members
359
- .map((m, i) => `<tr><td><input type="text" name="member.${i}.id" value="${esc(m?.id ?? "")}" aria-label="Member id"></td><td><input type="text" name="member.${i}.handle" value="${esc(m?.handle ?? "")}" aria-label="Handle"></td><td><select name="member.${i}.role" aria-label="Role">${["member", "senior"].map((r) => `<option${(m?.role ?? "member") === r ? " selected" : ""}>${r}</option>`).join("")}</select></td><td><input type="text" name="member.${i}.areas" value="${esc(m?.areas.join(", ") ?? "")}" aria-label="Areas"></td><td><input type="checkbox" name="member.${i}.optOut"${m?.optOut ? " checked" : ""} aria-label="Opted out"></td><td>${m ? `<input type="checkbox" name="member.${i}.remove" aria-label="Remove ${esc(m.id)}">` : ""}</td></tr>`)
360
+ .map((m, i) => `<tr><td><input type="text" name="member.${i}.id" value="${esc(m?.id ?? "")}" aria-label="Member id"></td><td><input type="text" name="member.${i}.handle" value="${esc(m?.handle ?? "")}" aria-label="Handle"></td><td><select name="member.${i}.role" aria-label="Role">${["junior", "member", "senior", "lead"].map((r) => `<option${(m?.role ?? "member") === r ? " selected" : ""}>${r}</option>`).join("")}</select></td><td><input type="text" name="member.${i}.areas" value="${esc(m?.areas.join(", ") ?? "")}" aria-label="Areas"></td><td><input type="checkbox" name="member.${i}.optOut"${m?.optOut ? " checked" : ""} aria-label="Opted out"></td><td>${m ? `<input type="checkbox" name="member.${i}.remove" aria-label="Remove ${esc(m.id)}">` : ""}</td></tr>`)
360
361
  .join("")}</tbody></table></div><p class="muted">Blank rows add members. Areas are comma-separated.</p>`;
361
362
  }
362
363
  if (section.rows === "teams") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeostat/dashboard",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The local dashboard for homeostat.",
5
5
  "keywords": [
6
6
  "homeostat",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "yaml": "^2.9.1",
37
- "@homeostat/core": "0.1.0"
37
+ "@homeostat/core": "0.1.1"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public",