@homeostat/dashboard 0.0.0-stage → 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,397 @@
1
+ import { DEFAULT_AGENT_MODEL, DEFAULT_WEIGHTS, EXPORT_TARGETS, parseConfig, } from "@homeostat/core";
2
+ import { isMap, parseDocument } from "yaml";
3
+ const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
4
+ const agentFields = (agent, label) => [
5
+ { path: ["agents", agent, "enabled"], label: `${label}: enabled`, kind: "checkbox" },
6
+ {
7
+ path: ["agents", agent, "schedule"],
8
+ label: `${label}: schedule`,
9
+ kind: "text",
10
+ help: "Five-field cron, UTC",
11
+ },
12
+ {
13
+ path: ["agents", agent, "model"],
14
+ label: `${label}: model`,
15
+ kind: "text",
16
+ help: `Claude model id, e.g. ${DEFAULT_AGENT_MODEL}`,
17
+ },
18
+ {
19
+ path: ["agents", agent, "effort"],
20
+ label: `${label}: effort`,
21
+ kind: "select",
22
+ options: EFFORTS,
23
+ },
24
+ {
25
+ path: ["agents", agent, "lookbackHours"],
26
+ label: `${label}: look back (hours)`,
27
+ kind: "number",
28
+ },
29
+ ];
30
+ /** Every section of homeostat.yaml, so every key is editable without touching YAML (4.2). */
31
+ export const SECTIONS = [
32
+ {
33
+ id: "team",
34
+ title: "Team and members",
35
+ intro: "Who contributes and reviews. Seniors own the config and areas nobody claims; handles feed CODEOWNERS.",
36
+ rows: "team",
37
+ },
38
+ {
39
+ id: "areas",
40
+ title: "Areas",
41
+ intro: "Parts of your code repos, as path globs. Core is implicit and applies everywhere.",
42
+ rows: "areas",
43
+ },
44
+ {
45
+ id: "teams",
46
+ title: "Teams",
47
+ intro: "Which team owns which areas: it approves their rules and documents. Every session still reads every team's rules for the files it touches.",
48
+ rows: "teams",
49
+ },
50
+ {
51
+ id: "sources",
52
+ title: "Repos and sources",
53
+ intro: "Where the curator reads merged pull requests, and the knowledge-base repo it opens PRs on.",
54
+ fields: [
55
+ {
56
+ path: ["sources", "host"],
57
+ label: "Git host",
58
+ kind: "select",
59
+ options: ["", "bitbucket", "github"],
60
+ help: "Empty removes the section",
61
+ },
62
+ {
63
+ path: ["sources", "kbRepo"],
64
+ label: "Knowledge-base repo",
65
+ kind: "text",
66
+ help: "owner/name",
67
+ },
68
+ { path: ["sources", "baseBranch"], label: "Base branch", kind: "text" },
69
+ {
70
+ path: ["sources", "repos"],
71
+ label: "Code repos",
72
+ kind: "lines",
73
+ help: "One owner/name per line",
74
+ },
75
+ {
76
+ path: ["sources", "exclude", "repos"],
77
+ label: "Never read these repos",
78
+ kind: "lines",
79
+ help: "One owner/name per line",
80
+ },
81
+ {
82
+ path: ["sources", "exclude", "paths"],
83
+ label: "Never read comments on these files",
84
+ kind: "lines",
85
+ help: "One glob per line, e.g. **/secrets/**",
86
+ },
87
+ ],
88
+ },
89
+ {
90
+ id: "scoring",
91
+ title: "Scoring rules",
92
+ intro: "Points per signal and the threshold at which a curator's entry is accepted without a reviewer.",
93
+ fields: [
94
+ ...Object.keys(DEFAULT_WEIGHTS).map((k) => ({ path: ["scoring", "weights", k], label: `Weight: ${k}`, kind: "number" })),
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
+ }))),
117
+ { path: ["scoring", "autoMerge", "enabled"], label: "Auto-merge", kind: "checkbox" },
118
+ {
119
+ path: ["scoring", "autoMerge", "areas"],
120
+ label: "Auto-merge areas",
121
+ kind: "lines",
122
+ help: "One per line; empty means every area",
123
+ },
124
+ {
125
+ path: ["scoring", "autoMerge", "excludeAreas"],
126
+ label: "Areas that always need a reviewer",
127
+ kind: "lines",
128
+ help: "One per line, e.g. core",
129
+ },
130
+ { path: ["scoring", "autoMerge", "maxPerRun"], label: "Auto-merges per run", kind: "number" },
131
+ {
132
+ path: ["scoring", "reviewCadence"],
133
+ label: "Review cadence",
134
+ kind: "select",
135
+ options: ["daily", "weekly", "biweekly"],
136
+ },
137
+ ],
138
+ },
139
+ {
140
+ id: "agents",
141
+ title: "Agent schedules",
142
+ intro: "How the agents reach Claude, when they run, and with which model.",
143
+ fields: [
144
+ {
145
+ path: ["agents", "runtime"],
146
+ label: "Claude runtime",
147
+ kind: "select",
148
+ options: ["api", "claude-code", "bedrock", "vertex"],
149
+ help: "api: Console key. claude-code: a Claude Code login (personal use, or a Team/Enterprise plan). bedrock / vertex: your own AWS or Google Cloud account",
150
+ },
151
+ {
152
+ path: ["agents", "bedrock", "region"],
153
+ label: "Bedrock region",
154
+ kind: "text",
155
+ optional: true,
156
+ help: "e.g. eu-west-1; empty uses AWS_REGION",
157
+ },
158
+ {
159
+ path: ["agents", "vertex", "projectId"],
160
+ label: "Vertex project",
161
+ kind: "text",
162
+ optional: true,
163
+ help: "Google Cloud project id",
164
+ },
165
+ {
166
+ path: ["agents", "vertex", "region"],
167
+ label: "Vertex region",
168
+ kind: "text",
169
+ optional: true,
170
+ help: "global, us, eu or a region",
171
+ },
172
+ ...agentFields("curator", "Daily run (curator)"),
173
+ ...agentFields("deepDive", "Deep dive"),
174
+ {
175
+ path: ["agents", "deepDive", "staleAfterDays"],
176
+ label: "Deep dive: retire after (days unconfirmed)",
177
+ kind: "number",
178
+ },
179
+ { path: ["agents", "handoff", "enabled"], label: "Handoff notes: enabled", kind: "checkbox" },
180
+ {
181
+ path: ["agents", "handoff", "minEdits"],
182
+ label: "Handoff notes: edits before asking",
183
+ kind: "number",
184
+ },
185
+ ],
186
+ },
187
+ {
188
+ id: "distribution",
189
+ title: "Distribution",
190
+ intro: "Which agents receive the rules as files, and how much context each session gets.",
191
+ fields: [
192
+ {
193
+ path: ["distribution", "exports"],
194
+ label: "Export targets",
195
+ kind: "checks",
196
+ options: EXPORT_TARGETS,
197
+ },
198
+ { path: ["distribution", "tokenBudget"], label: "Token budget per session", kind: "number" },
199
+ {
200
+ path: ["distribution", "docTokenBudget"],
201
+ label: "Token budget for documents",
202
+ kind: "number",
203
+ },
204
+ ],
205
+ },
206
+ {
207
+ id: "notifications",
208
+ title: "Notifications",
209
+ intro: "The weekly digest. The webhook URL stays in your CI secrets; only its variable name lives here.",
210
+ fields: [
211
+ {
212
+ path: ["notifications", "slackWebhookEnv"],
213
+ label: "Slack webhook variable",
214
+ kind: "text",
215
+ optional: true,
216
+ help: "e.g. HOMEOSTAT_SLACK_WEBHOOK",
217
+ },
218
+ {
219
+ path: ["notifications", "digestDay"],
220
+ label: "Digest day",
221
+ kind: "select",
222
+ options: ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"],
223
+ },
224
+ ],
225
+ },
226
+ {
227
+ id: "security",
228
+ title: "Security",
229
+ intro: "Domains rules may link to. Any other URL in a rule fails validation until a member signs the entry off; the git host is always allowed.",
230
+ fields: [
231
+ {
232
+ path: ["security", "allowedDomains"],
233
+ label: "Allowed domains",
234
+ kind: "lines",
235
+ help: "One domain per line, e.g. docs.example.com",
236
+ },
237
+ {
238
+ path: ["security", "teamOnlyAreas"],
239
+ label: "Team-only areas",
240
+ kind: "lines",
241
+ help: "Never exported; only their members' sessions see them",
242
+ },
243
+ {
244
+ path: ["security", "mcpWrites"],
245
+ label: "Let agents save and flag through MCP",
246
+ kind: "checkbox",
247
+ },
248
+ { path: ["security", "mcpWriteLimit"], label: "MCP writes per session", kind: "number" },
249
+ {
250
+ path: ["security", "eventRetentionDays"],
251
+ label: "Keep local events for (days)",
252
+ kind: "number",
253
+ },
254
+ ],
255
+ },
256
+ ];
257
+ /** The current value of a field, from the parsed (defaulted) config. */
258
+ export function currentValue(config, field) {
259
+ let v = config;
260
+ for (const k of field.path)
261
+ v = v?.[k];
262
+ return v;
263
+ }
264
+ const lines = (s) => s
265
+ .split(/[\n,]/)
266
+ .map((x) => x.trim())
267
+ .filter(Boolean);
268
+ /**
269
+ * Applies one submitted section to the YAML text, keeping comments and untouched keys, and
270
+ * validates the result. Returns the new text only when it is valid.
271
+ */
272
+ export function applySection(yamlText, sectionId, form) {
273
+ const section = SECTIONS.find((s) => s.id === sectionId);
274
+ if (!section)
275
+ return {
276
+ issues: [
277
+ { code: "settings.section", severity: "error", message: `unknown section ${sectionId}` },
278
+ ],
279
+ };
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 }));
293
+ if (section.rows === "team") {
294
+ const members = rowIndices(form, "member").flatMap((i) => {
295
+ const id = form.get(`member.${i}.id`)?.trim();
296
+ if (!id || form.get(`member.${i}.remove`))
297
+ return [];
298
+ const handle = form.get(`member.${i}.handle`)?.trim();
299
+ const areas = lines(form.get(`member.${i}.areas`) ?? "");
300
+ return [
301
+ {
302
+ id,
303
+ ...(handle ? { handle } : {}),
304
+ role: form.get(`member.${i}.role`) ?? "member",
305
+ areas,
306
+ ...(form.get(`member.${i}.optOut`) ? { optOut: true } : {}),
307
+ },
308
+ ];
309
+ });
310
+ if (!unchanged(["team", "members"], members))
311
+ doc.setIn(["team", "members"], doc.createNode(members));
312
+ }
313
+ else if (section.rows === "teams") {
314
+ const teams = {};
315
+ for (const i of rowIndices(form, "teamrow")) {
316
+ const name = form.get(`teamrow.${i}.name`)?.trim();
317
+ if (!name || form.get(`teamrow.${i}.remove`))
318
+ continue;
319
+ const handle = form.get(`teamrow.${i}.handle`)?.trim();
320
+ teams[name] = {
321
+ ...(handle ? { handle } : {}),
322
+ members: lines(form.get(`teamrow.${i}.members`) ?? ""),
323
+ areas: lines(form.get(`teamrow.${i}.areas`) ?? ""),
324
+ repos: lines(form.get(`teamrow.${i}.repos`) ?? ""),
325
+ };
326
+ }
327
+ if (Object.keys(teams).length)
328
+ doc.setIn(["teams"], doc.createNode(teams));
329
+ else if (doc.has("teams"))
330
+ doc.delete("teams");
331
+ }
332
+ else if (section.rows === "areas") {
333
+ const areas = {};
334
+ for (const i of rowIndices(form, "area")) {
335
+ const name = form.get(`area.${i}.name`)?.trim();
336
+ if (!name || form.get(`area.${i}.remove`))
337
+ continue;
338
+ areas[name] = lines(form.get(`area.${i}.globs`) ?? "");
339
+ }
340
+ if (!unchanged(["areas"], areas))
341
+ doc.setIn(["areas"], doc.createNode(areas));
342
+ }
343
+ else {
344
+ for (const field of section.fields ?? []) {
345
+ const name = field.path.join(".");
346
+ const raw = form.get(name);
347
+ if (field.path.join(".") === "sources.host" && !raw) {
348
+ doc.deleteIn(["sources"]);
349
+ break;
350
+ }
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) {
360
+ if (doc.hasIn(field.path))
361
+ doc.deleteIn(field.path);
362
+ continue;
363
+ }
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);
376
+ }
377
+ }
378
+ // Drop sections that became empty maps, so the defaults apply.
379
+ for (const key of ["notifications", "distribution", "scoring", "security"]) {
380
+ const node = doc.get(key, true);
381
+ if (isMap(node) && node.items.length === 0)
382
+ doc.delete(key);
383
+ }
384
+ const text = doc.toString({ flowCollectionPadding: false });
385
+ const { config, issues } = parseConfig(text);
386
+ return config ? { text, issues: [] } : { issues };
387
+ }
388
+ /** Row numbers present in the form for a row editor (`member.0.id`, `member.3.id`, ...). */
389
+ function rowIndices(form, prefix) {
390
+ const seen = new Set();
391
+ for (const key of form.keys()) {
392
+ const m = new RegExp(`^${prefix}\\.(\\d+)\\.`).exec(key);
393
+ if (m)
394
+ seen.add(Number(m[1]));
395
+ }
396
+ return [...seen].sort((a, b) => a - b);
397
+ }
@@ -0,0 +1,36 @@
1
+ import { type Config, type Entry, type Event, type Issue, type SectionUsage } from "@homeostat/core";
2
+ import type { AgentLiveness, Contribution, DesirePath, Health, Impact } from "./metrics.ts";
3
+ import { type Section } from "./settings.ts";
4
+ export declare function layout(opts: {
5
+ title: string;
6
+ path: string;
7
+ kbRoot: string;
8
+ body: string;
9
+ theme?: "light" | "dark";
10
+ }): string;
11
+ export declare function overviewPage(h: Health, agents: AgentLiveness[], imp: Impact, recent: Event[]): string;
12
+ export declare function entriesPage(entries: readonly Entry[], status: string | null): string;
13
+ export declare function entryPage(entry: Entry, config: Config): string;
14
+ export declare function membersPage(rowsData: Contribution[]): string;
15
+ export declare function coveragePage(cov: {
16
+ areas: {
17
+ area: string;
18
+ globs: readonly string[];
19
+ accepted: number;
20
+ candidates: number;
21
+ }[];
22
+ sources: {
23
+ repo: string;
24
+ entries: number;
25
+ }[];
26
+ }): string;
27
+ export declare function impactPage(imp: Impact): string;
28
+ export declare function documentsPage(usage: readonly SectionUsage[]): string;
29
+ export declare function desirePathsPage(paths: DesirePath[]): string;
30
+ export declare function eventsPage(events: Event[]): string;
31
+ export declare function settingsIndex(): string;
32
+ export declare function settingsPage(section: Section, config: Config, token: string, notice?: {
33
+ saved?: boolean;
34
+ issues?: Issue[];
35
+ }): string;
36
+ export declare function messagePage(title: string, text: string): string;