@crewhaus/hangar-ui 0.5.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.
Files changed (51) hide show
  1. package/README.md +309 -0
  2. package/assets/hangar.css +1726 -0
  3. package/assets/index.html +21 -0
  4. package/assets/js/api.js +368 -0
  5. package/assets/js/app.js +734 -0
  6. package/assets/js/dom.js +228 -0
  7. package/assets/js/keys.js +159 -0
  8. package/assets/js/markdown.js +131 -0
  9. package/assets/js/notify.js +152 -0
  10. package/assets/js/omnibox.js +307 -0
  11. package/assets/js/router.js +280 -0
  12. package/assets/js/routes.js +793 -0
  13. package/assets/js/shapes.js +36 -0
  14. package/assets/js/supervision.js +1334 -0
  15. package/assets/js/util.js +426 -0
  16. package/assets/js/views/activity.js +140 -0
  17. package/assets/js/views/approvals.js +155 -0
  18. package/assets/js/views/channels.js +455 -0
  19. package/assets/js/views/control.js +253 -0
  20. package/assets/js/views/costs.js +103 -0
  21. package/assets/js/views/creds.js +610 -0
  22. package/assets/js/views/data.js +456 -0
  23. package/assets/js/views/deploy.js +106 -0
  24. package/assets/js/views/evals-lab.js +1029 -0
  25. package/assets/js/views/evals.js +342 -0
  26. package/assets/js/views/feedback.js +555 -0
  27. package/assets/js/views/health.js +176 -0
  28. package/assets/js/views/inbox.js +103 -0
  29. package/assets/js/views/inspect.js +751 -0
  30. package/assets/js/views/jobs.js +110 -0
  31. package/assets/js/views/library.js +605 -0
  32. package/assets/js/views/memory-fabric.js +2026 -0
  33. package/assets/js/views/memory.js +228 -0
  34. package/assets/js/views/onboarding.js +253 -0
  35. package/assets/js/views/overview.js +196 -0
  36. package/assets/js/views/panes.js +132 -0
  37. package/assets/js/views/proc.js +280 -0
  38. package/assets/js/views/review.js +164 -0
  39. package/assets/js/views/runs.js +452 -0
  40. package/assets/js/views/runtime.js +450 -0
  41. package/assets/js/views/schedulers.js +133 -0
  42. package/assets/js/views/security.js +925 -0
  43. package/assets/js/views/sessions.js +265 -0
  44. package/assets/js/views/settings.js +489 -0
  45. package/assets/js/views/spec-edit.js +1289 -0
  46. package/assets/js/views/spec.js +66 -0
  47. package/assets/js/views/thredz.js +1853 -0
  48. package/assets/js/views/token.js +49 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +145 -0
  51. package/package.json +36 -0
@@ -0,0 +1,1289 @@
1
+ /**
2
+ * Spec — the M3 half of the Spec tab: the field editor, trust-tier badges,
3
+ * version history with pins and diffs, and the four builders (new-spec
4
+ * wizard, grader builder, dataset builder, MCP connectors).
5
+ *
6
+ * The field editor is honest about its two halves. A SCALAR leaf edits
7
+ * inline. Everything else — a block, a list, a field inside a list item, or
8
+ * a block this spec does not declare at all — edits through the JSON pane,
9
+ * which sends ONE edit (`path` + `value`) down the same `specDiff` →
10
+ * `specEdit` road every other write here takes, so the server's trust tiers
11
+ * and typed confirmation still decide what may land. There is no YAML
12
+ * editor in this console and this screen never says there is: the console is
13
+ * zero-build and dependency-free, so it has no YAML parser to be honest
14
+ * with.
15
+ *
16
+ * The M1 read (`views/spec.js`: YAML, issues, env-ref presence, badges)
17
+ * stays where it is and is rendered above this.
18
+ *
19
+ * The one thing this screen must never let a user do accidentally: write a
20
+ * HUMAN-OWNED path (permissions, the model roster, watchme, plugins, expose,
21
+ * learning.sources, thredz, sandbox, transaction_policy) as if it were a
22
+ * quality knob. Those fields render with the security-surface interstitial —
23
+ * a credential-redacted diff plus a typed confirmation — or hand off to
24
+ * `crewhaus propose`. The server enforces the same split, so the UI is the
25
+ * explanation, never the gate.
26
+ *
27
+ * Two more things this screen is careful about:
28
+ *
29
+ * - it renders the EFFECTIVE configuration, not the YAML. A spec that
30
+ * never mentions a default-ON block still runs it, so every row says
31
+ * whether it was declared or defaulted;
32
+ * - it says which schema validated the spec. A fleet spans crewhaus
33
+ * versions, and "validated against the manager's copy" is a materially
34
+ * different claim from "validated against this harness's own".
35
+ */
36
+
37
+ import { api } from "../api.js";
38
+ import {
39
+ clear,
40
+ collapsible,
41
+ copyBtn,
42
+ dot,
43
+ el,
44
+ emptyState,
45
+ jsonPre,
46
+ skeleton,
47
+ toast,
48
+ } from "../dom.js";
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // small shared pieces
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** A titled card. `badge` is a node (usually a traffic light + its text). */
55
+ function card(title, badge, children) {
56
+ return el("div", { class: "card" }, [
57
+ el("h3", { class: "card-title" }, [el("span", { text: title }), badge]),
58
+ ...(Array.isArray(children) ? children : [children]),
59
+ ]);
60
+ }
61
+
62
+ /** A payload's `note` line, when it has one. */
63
+ function noteLine(body) {
64
+ const note = typeof body?.note === "string" && body.note !== "" ? body.note : null;
65
+ return note === null ? null : el("p", { class: "muted", text: note });
66
+ }
67
+
68
+ /** A table with a scroll container (wide content never scrolls the page). */
69
+ function table(headers, rows) {
70
+ return el("div", { class: "table-scroll" }, [
71
+ el("table", { class: "fleet" }, [
72
+ el(
73
+ "thead",
74
+ null,
75
+ el(
76
+ "tr",
77
+ null,
78
+ headers.map((h) => el("th", { text: h })),
79
+ ),
80
+ ),
81
+ el("tbody", null, rows),
82
+ ]),
83
+ ]);
84
+ }
85
+
86
+ /** Monospace block for a rendered diff or a YAML body. */
87
+ function pre(text) {
88
+ return el("pre", { class: "rawjson", text: text === "" ? "(no change)" : text });
89
+ }
90
+
91
+ /** The tier badge — colour is never alone, the tier word rides with it. */
92
+ function tierBadge(row) {
93
+ if (row.tier === "auto-tunable") return dot("ok", "auto-tunable");
94
+ return dot(row.securitySurface === true ? "bad" : "warn", "human-owned");
95
+ }
96
+
97
+ /**
98
+ * Report the outcome of a write.
99
+ *
100
+ * Three distinct answers live behind one call, and collapsing them would
101
+ * throw away the interesting one: a transport failure, a typed refusal the
102
+ * server answered 200 with (`ok:false` — "that version is not in the
103
+ * registry"), and a 409 gate ("type the spec name"). Each gets its own
104
+ * sentence.
105
+ */
106
+ function reportWrite(res, okMessage) {
107
+ if (res === null || res === undefined) {
108
+ toast("No answer from the manager");
109
+ return false;
110
+ }
111
+ if (!res.ok) {
112
+ const message =
113
+ typeof res.body?.error === "string" ? res.body.error : `HTTP ${res.status ?? "?"}`;
114
+ toast(message);
115
+ return false;
116
+ }
117
+ if (res.body?.ok === false) {
118
+ toast(`${res.body.code ?? "refused"}: ${res.body.reason ?? "refused"}`, "info");
119
+ return false;
120
+ }
121
+ toast(okMessage, "info");
122
+ return true;
123
+ }
124
+
125
+ /**
126
+ * The typed-confirmation dialog: the operator types the spec's name and the
127
+ * SERVER verifies it. The button stays disabled until the text matches, so
128
+ * the dialog cannot be dismissed by muscle memory.
129
+ */
130
+ function confirmModal({ title, lead, detail, confirmName, action, onConfirm }) {
131
+ const backdrop = el("div", { class: "modal-backdrop", role: "presentation" });
132
+ const close = () => backdrop.remove();
133
+ const input = el("input", {
134
+ class: "input",
135
+ type: "text",
136
+ placeholder: confirmName,
137
+ "aria-label": `type ${confirmName} to confirm`,
138
+ });
139
+ const go = el("button", {
140
+ class: "btn btn-danger",
141
+ type: "button",
142
+ text: action,
143
+ disabled: true,
144
+ });
145
+ input.addEventListener("input", () => {
146
+ go.disabled = input.value.trim() !== confirmName;
147
+ });
148
+ go.addEventListener("click", () => {
149
+ close();
150
+ onConfirm();
151
+ });
152
+ backdrop.appendChild(
153
+ el("div", { class: "modal card", role: "dialog", "aria-modal": "true" }, [
154
+ el("h3", { class: "card-title" }, [el("span", { text: title }), dot("warn", "human-owned")]),
155
+ el("p", { class: "muted", text: lead }),
156
+ el("div", { class: "modal-body" }, detail),
157
+ el("label", { class: "field" }, [
158
+ el("span", { text: `Type ${confirmName} to confirm` }),
159
+ input,
160
+ ]),
161
+ el("div", { class: "modal-actions" }, [
162
+ go,
163
+ el("button", { class: "btn btn-ghost", type: "button", text: "Cancel", onClick: close }),
164
+ ]),
165
+ ]),
166
+ );
167
+ backdrop.addEventListener("click", (e) => {
168
+ if (e.target === backdrop) close();
169
+ });
170
+ document.body.appendChild(backdrop);
171
+ return backdrop;
172
+ }
173
+
174
+ /** A plain (untyped) dialog for previews — a version's YAML, a diff. */
175
+ function viewModal(title, children) {
176
+ const backdrop = el("div", { class: "modal-backdrop", role: "presentation" });
177
+ const close = () => backdrop.remove();
178
+ backdrop.appendChild(
179
+ el("div", { class: "modal card", role: "dialog", "aria-modal": "true" }, [
180
+ el("h3", { class: "card-title" }, [el("span", { text: title })]),
181
+ el("div", { class: "modal-body" }, children),
182
+ el("div", { class: "modal-actions" }, [
183
+ el("button", { class: "btn btn-ghost", type: "button", text: "Close", onClick: close }),
184
+ ]),
185
+ ]),
186
+ );
187
+ backdrop.addEventListener("click", (e) => {
188
+ if (e.target === backdrop) close();
189
+ });
190
+ document.body.appendChild(backdrop);
191
+ return backdrop;
192
+ }
193
+
194
+ /** A 501 stub, rendered as the fact it is rather than as a broken panel. */
195
+ function stubCard(title, res) {
196
+ return card(title, dot("off", "not built yet (M3)"), [
197
+ el("p", {
198
+ class: "muted",
199
+ text:
200
+ typeof res?.body?.error === "string"
201
+ ? res.body.error
202
+ : "the server has no handler for this yet",
203
+ }),
204
+ ]);
205
+ }
206
+
207
+ const isStub = (res) => res !== null && res !== undefined && res.status === 501;
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // effective configuration + diagnostics
211
+ // ---------------------------------------------------------------------------
212
+
213
+ function schemaBadge(body) {
214
+ if (body.schemaSource === "harness") {
215
+ return dot("ok", `validated against this harness's schema${suffix(body.schemaVersion)}`);
216
+ }
217
+ return dot("warn", "validated against the manager's schema");
218
+ }
219
+
220
+ const suffix = (version) => (typeof version === "string" && version !== "" ? ` (${version})` : "");
221
+
222
+ /**
223
+ * The effective-config table: what this harness is ACTUALLY configured to
224
+ * do. A `default` row is a value nobody wrote down — which is exactly the
225
+ * question raw YAML cannot answer.
226
+ */
227
+ function effectiveCard(body) {
228
+ const rows = Array.isArray(body.effective) ? body.effective : [];
229
+ const declared = Number(body.declaredCount ?? 0);
230
+ const defaulted = Number(body.defaultedCount ?? 0);
231
+ const inner = [];
232
+ inner.push(
233
+ el("p", { class: "muted" }, [
234
+ el("span", { text: `${declared} declared · ${defaulted} from the schema's defaults` }),
235
+ ]),
236
+ );
237
+ if (typeof body.effectiveNote === "string" && body.effectiveNote !== "") {
238
+ inner.push(
239
+ el("p", { class: "reason" }, [dot("warn", "defaults unavailable"), body.effectiveNote]),
240
+ );
241
+ }
242
+ if (rows.length === 0) {
243
+ inner.push(emptyState("Nothing to show yet — the spec has to validate first", "crewhaus init"));
244
+ } else {
245
+ const body_ = rows.map((row) =>
246
+ el("tr", null, [
247
+ el("td", { class: "mono", text: String(row.path ?? "") }),
248
+ el("td", { class: "mono", text: renderValue(row.value) }),
249
+ el("td", null, [
250
+ row.source === "declared"
251
+ ? dot("ok", "declared")
252
+ : dot("unknown", "default (not in the file)"),
253
+ ]),
254
+ ]),
255
+ );
256
+ inner.push(table(["Path", "Value", "Source"], body_));
257
+ }
258
+ const issues = Array.isArray(body.issues) ? body.issues : [];
259
+ if (issues.length > 0) {
260
+ inner.push(
261
+ el("h4", { class: "sub-title", text: "Diagnostics" }),
262
+ el(
263
+ "ul",
264
+ { class: "issue-list" },
265
+ issues.map((issue) =>
266
+ el("li", null, [
267
+ dot("bad", String(issue.message ?? "issue")),
268
+ el("code", { text: Array.isArray(issue.path) ? issue.path.join(".") : "(root)" }),
269
+ ]),
270
+ ),
271
+ ),
272
+ );
273
+ }
274
+ inner.push(
275
+ el("p", { class: "muted" }, [
276
+ el("span", { text: "Compile warnings (accepted-but-unwired keys) come from " }),
277
+ el("code", { text: String(body.warningsVerb ?? "crewhaus compile") }),
278
+ el("span", { text: " — this view does not run the compiler." }),
279
+ ]),
280
+ );
281
+ return card("Effective configuration", schemaBadge(body), inner);
282
+ }
283
+
284
+ function renderValue(value) {
285
+ if (value === null) return "null";
286
+ if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 79)}…` : value;
287
+ try {
288
+ const rendered = JSON.stringify(value);
289
+ return rendered.length > 80 ? `${rendered.slice(0, 79)}…` : rendered;
290
+ } catch {
291
+ return String(value);
292
+ }
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // the field editor (trust tiers)
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /**
300
+ * One row per declared path, badged by tier, with the edit control the tier
301
+ * allows:
302
+ *
303
+ * auto-tunable → an inline input that saves through `specPatch` (the same
304
+ * restriction the optimizer runs under, enforced server-side);
305
+ * human-owned → "Review & apply", which fetches the redacted diff FIRST
306
+ * and only then offers the typed confirmation, plus "Propose" for the
307
+ * review-PR path;
308
+ * a block or a list → the JSON pane, which takes the same diff → tier →
309
+ * confirm road with the whole value as one edit.
310
+ */
311
+ function trustCard(ctx, trust, valueByPath, reload) {
312
+ const rows = Array.isArray(trust.paths) ? trust.paths : [];
313
+ // Rows are what the spec DECLARES; the pane is how anything else is
314
+ // reached, so it belongs on the empty screen too — a spec with no rows is
315
+ // exactly the one an operator needs to add a block to.
316
+ const addBlock = el("button", { class: "btn", type: "button", text: "Edit any path…" });
317
+ addBlock.addEventListener("click", () => {
318
+ valueDialog(ctx, { path: "", value: undefined, pathEditable: true, reload });
319
+ });
320
+ const addForm = (lead) =>
321
+ el("div", { class: "add-form" }, [el("p", { class: "muted grow", text: lead }), addBlock]);
322
+ if (rows.length === 0) {
323
+ return card("Fields & trust tiers", dot("off", "no fields"), [
324
+ emptyState(
325
+ typeof trust.note === "string" ? trust.note : "This spec declares no editable fields",
326
+ typeof trust.verb === "string" ? trust.verb : "crewhaus init",
327
+ ),
328
+ addForm(
329
+ "A path this spec has not declared is still reachable — the server applies it with the same diff, tier and typed confirmation.",
330
+ ),
331
+ ]);
332
+ }
333
+ const confirmName = String(trust.confirmName ?? "");
334
+ const body = rows.map((row) => {
335
+ const path = String(row.path ?? "");
336
+ const current = valueByPath.get(path);
337
+ const editable = current !== undefined && !isContainer(current);
338
+ return el("tr", null, [
339
+ el("td", { class: "mono", text: path }),
340
+ el("td", null, [tierBadge(row)]),
341
+ el("td", { class: "muted", text: String(row.reason ?? "") }),
342
+ el("td", { class: "cell-actions" }, [
343
+ editable
344
+ ? row.tier === "auto-tunable"
345
+ ? autoTunableEditor(ctx, path, current, reload)
346
+ : humanOwnedActions(ctx, path, current, confirmName, reload)
347
+ : jsonPaneButton(ctx, path, current, reload),
348
+ ]),
349
+ ]);
350
+ });
351
+ return card(
352
+ "Fields & trust tiers",
353
+ dot(
354
+ "ok",
355
+ `${trust.autoTunableCount ?? 0} auto-tunable · ${trust.humanOwnedCount ?? 0} human-owned`,
356
+ ),
357
+ [
358
+ el("p", {
359
+ class: "muted",
360
+ text: "Auto-tunable fields are the ones the optimizer may write, so the console may write them too. Everything else needs a review of the diff and a typed confirmation — the server enforces the same split.",
361
+ }),
362
+ table(["Path", "Tier", "Why", ""], body),
363
+ addForm(
364
+ "Rows list what this spec DECLARES. A block it has never declared, or a field inside a list item, is reachable by path — same diff, same tier, same typed confirmation.",
365
+ ),
366
+ ],
367
+ );
368
+ }
369
+
370
+ const isContainer = (value) => typeof value === "object" && value !== null;
371
+
372
+ /** Inline input + Save for a path the optimizer is allowed to write. */
373
+ function autoTunableEditor(ctx, path, current, reload) {
374
+ const input = el("input", {
375
+ class: "input",
376
+ type: "text",
377
+ value: typeof current === "string" ? current : JSON.stringify(current),
378
+ "aria-label": `value for ${path}`,
379
+ });
380
+ const save = el("button", { class: "btn btn-primary", type: "button", text: "Save" });
381
+ save.addEventListener("click", async () => {
382
+ save.disabled = true;
383
+ const res = await api.specPatch(
384
+ { id: ctx.id },
385
+ { edit: { path, value: coerce(input.value, current) } },
386
+ );
387
+ save.disabled = false;
388
+ if (reportWrite(res, `Saved ${path}`)) reload();
389
+ });
390
+ return el("div", { class: "row-editor" }, [input, save]);
391
+ }
392
+
393
+ /** Keep a number a number and a boolean a boolean — a form field is text,
394
+ * and the spec schema is not. */
395
+ function coerce(text, current) {
396
+ if (typeof current === "number") {
397
+ const n = Number(text);
398
+ return Number.isNaN(n) ? text : n;
399
+ }
400
+ if (typeof current === "boolean") return text === "true";
401
+ return text;
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // the JSON pane — blocks, lists, and paths this spec has not declared
406
+ // ---------------------------------------------------------------------------
407
+
408
+ /**
409
+ * A dotted path as the segment array the server's edit parser takes.
410
+ *
411
+ * A segment that is a non-negative integer becomes a NUMBER, because that is
412
+ * the difference between `candidates.0.id` addressing the first list item
413
+ * and addressing a mapping key called "0". Pure, so the rule is testable.
414
+ */
415
+ export function editPath(dotted) {
416
+ return String(dotted ?? "")
417
+ .split(".")
418
+ .map((segment) => segment.trim())
419
+ .filter((segment) => segment !== "")
420
+ .map((segment) => (/^(?:0|[1-9][0-9]*)$/.test(segment) ? Number(segment) : segment));
421
+ }
422
+
423
+ /**
424
+ * Parse the pane's text into the value to write. Empty text is DELETE (the
425
+ * server treats an edit with no `value` as one), which is how a block is
426
+ * removed; anything else must be JSON — a subset of the YAML the spec is
427
+ * written in, and the only structured format this dependency-free console
428
+ * can parse without guessing.
429
+ */
430
+ export function parseJsonValue(text) {
431
+ const raw = String(text ?? "").trim();
432
+ if (raw === "") return { ok: true, remove: true };
433
+ try {
434
+ return { ok: true, remove: false, value: JSON.parse(raw) };
435
+ } catch (err) {
436
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
437
+ }
438
+ }
439
+
440
+ /** The one-edit batch a path + pane produce — or a refusal carrying the
441
+ * sentence to render, because "nothing happened" is not an answer. */
442
+ export function editsFor(path, text) {
443
+ const segments = editPath(path);
444
+ if (segments.length === 0) return { ok: false, message: "a path is required" };
445
+ const parsed = parseJsonValue(text);
446
+ if (!parsed.ok) return { ok: false, message: `that is not valid JSON: ${parsed.message}` };
447
+ // No `value` key at all ⇒ the server deletes the path. `value: undefined`
448
+ // would not survive JSON.stringify, so the key is omitted deliberately.
449
+ return {
450
+ ok: true,
451
+ edits: [parsed.remove ? { path: segments } : { path: segments, value: parsed.value }],
452
+ removing: parsed.remove === true,
453
+ };
454
+ }
455
+
456
+ /** The row control for a path the inline editor cannot type: a container, or
457
+ * a declared path with no effective value to seed an input from. */
458
+ function jsonPaneButton(ctx, path, current, reload) {
459
+ const open = el("button", { class: "btn btn-ghost", type: "button", text: "Edit as JSON…" });
460
+ open.addEventListener("click", () => {
461
+ valueDialog(ctx, { path, value: current, pathEditable: false, reload });
462
+ });
463
+ return open;
464
+ }
465
+
466
+ /**
467
+ * The pane. It writes NOTHING on its own: Preview asks `specDiff` what would
468
+ * change and which tier the batch lands in, and only the answer decides what
469
+ * the operator is offered — a plain Apply for an auto-tunable path (the
470
+ * optimizer's own surface), or the typed confirmation for a human-owned one.
471
+ * Both roads end at the same `specEdit` PUT, so the server remains the gate
472
+ * and this screen stays the explanation.
473
+ */
474
+ function valueDialog(ctx, { path, value, pathEditable, reload }) {
475
+ let backdrop = null;
476
+ const pathInput = el("input", {
477
+ class: "input grow",
478
+ type: "text",
479
+ value: path,
480
+ placeholder: "agent.model_pool.candidates.0.id",
481
+ "aria-label": "spec path — dotted, and a number is a list index",
482
+ });
483
+ const editor = el("textarea", {
484
+ class: "input notes",
485
+ rows: 12,
486
+ spellcheck: "false",
487
+ "aria-label": "value as JSON",
488
+ });
489
+ editor.value = value === undefined ? "" : JSON.stringify(value, null, 2);
490
+ const out = el("div", { class: "modal-body" });
491
+
492
+ const preview = el("button", { class: "btn btn-primary", type: "button", text: "Preview diff" });
493
+ preview.addEventListener("click", async () => {
494
+ const target = pathInput.value.trim();
495
+ const batch = editsFor(target, editor.value);
496
+ clear(out);
497
+ if (!batch.ok) {
498
+ out.appendChild(el("p", { class: "reason", text: batch.message }));
499
+ return;
500
+ }
501
+ const res = await api.specDiff({ id: ctx.id }, { edits: batch.edits });
502
+ clear(out);
503
+ if (!res.ok) {
504
+ out.appendChild(el("p", { class: "reason", text: res.body?.error ?? `HTTP ${res.status}` }));
505
+ return;
506
+ }
507
+ const body = res.body ?? {};
508
+ if (body.ok === false) {
509
+ out.appendChild(
510
+ el("p", {
511
+ class: "reason",
512
+ text: `${body.code ?? "refused"}: ${body.reason ?? "this edit does not apply"}`,
513
+ }),
514
+ );
515
+ return;
516
+ }
517
+ if (batch.removing) {
518
+ out.appendChild(
519
+ el("p", { class: "muted", text: `an empty pane DELETES ${target} from the spec` }),
520
+ );
521
+ }
522
+ out.appendChild(pre(String(body.diff ?? "")));
523
+ const apply = async (extra) => {
524
+ const wrote = await api.specEdit({ id: ctx.id }, { edits: batch.edits, ...extra });
525
+ if (reportWrite(wrote, `Applied ${target}`)) {
526
+ if (backdrop !== null) backdrop.remove();
527
+ reload();
528
+ }
529
+ };
530
+ if (body.autoApplicable === true) {
531
+ const go = el("button", { class: "btn btn-primary", type: "button", text: "Apply" });
532
+ go.addEventListener("click", () => {
533
+ void apply({});
534
+ });
535
+ out.appendChild(
536
+ el("p", {
537
+ class: "muted",
538
+ text: "every path in this edit is auto-tunable — the surface the optimizer already writes.",
539
+ }),
540
+ );
541
+ out.appendChild(el("div", { class: "modal-actions" }, [go]));
542
+ return;
543
+ }
544
+ const confirmName = String(body.confirmName ?? "");
545
+ const typed = el("input", {
546
+ class: "input",
547
+ type: "text",
548
+ placeholder: confirmName,
549
+ "aria-label": `type ${confirmName} to confirm`,
550
+ });
551
+ const go = el("button", {
552
+ class: "btn btn-danger",
553
+ type: "button",
554
+ text: "Apply to the live spec",
555
+ disabled: true,
556
+ });
557
+ typed.addEventListener("input", () => {
558
+ go.disabled = typed.value.trim() !== confirmName;
559
+ });
560
+ go.addEventListener("click", () => {
561
+ void apply({ confirmName });
562
+ });
563
+ // The same batch, sent for review instead — the road the scalar rows
564
+ // offer, and the reason this pane can name it without lying.
565
+ const propose = el("button", { class: "btn btn-ghost", type: "button", text: "Propose" });
566
+ propose.addEventListener("click", async () => {
567
+ const staged = await api.specPropose(
568
+ { id: ctx.id },
569
+ { edits: batch.edits, title: `hangar: ${target}` },
570
+ );
571
+ if (
572
+ reportWrite(
573
+ staged,
574
+ "Proposal staged — the review bundle is a dry run until you open the PR",
575
+ )
576
+ ) {
577
+ if (backdrop !== null) backdrop.remove();
578
+ reload();
579
+ }
580
+ });
581
+ out.appendChild(
582
+ el("p", {
583
+ class: "muted",
584
+ text: "This batch touches a human-owned path, so the server asks for the spec's name in writing — or send the same edit for review instead.",
585
+ }),
586
+ );
587
+ out.appendChild(
588
+ el("label", { class: "field" }, [
589
+ el("span", { text: `Type ${confirmName} to confirm` }),
590
+ typed,
591
+ ]),
592
+ );
593
+ out.appendChild(el("div", { class: "modal-actions" }, [go, propose]));
594
+ });
595
+
596
+ backdrop = viewModal(pathEditable ? "Edit a spec path" : `Edit ${path}`, [
597
+ pathEditable
598
+ ? el("label", { class: "field grow" }, [
599
+ el("span", { text: "Path (dotted; a number is a list index)" }),
600
+ pathInput,
601
+ ])
602
+ : el("p", { class: "muted mono", text: path }),
603
+ el("p", {
604
+ class: "muted",
605
+ text: "The value is JSON. An empty pane deletes the path. Nothing is written until the diff below has been read.",
606
+ }),
607
+ editor,
608
+ el("div", { class: "modal-actions" }, [preview]),
609
+ out,
610
+ ]);
611
+ return backdrop;
612
+ }
613
+
614
+ /** "Review & apply" (diff → typed confirm) and "Propose" for a human-owned
615
+ * path. Neither writes anything before the operator has seen the diff. */
616
+ function humanOwnedActions(ctx, path, current, confirmName, reload) {
617
+ const input = el("input", {
618
+ class: "input",
619
+ type: "text",
620
+ value: typeof current === "string" ? current : JSON.stringify(current),
621
+ "aria-label": `new value for ${path}`,
622
+ });
623
+ const review = el("button", { class: "btn", type: "button", text: "Review & apply" });
624
+ const propose = el("button", { class: "btn btn-ghost", type: "button", text: "Propose" });
625
+ const editFor = () => ({ path, value: coerce(input.value, current) });
626
+
627
+ review.addEventListener("click", async () => {
628
+ const edits = [editFor()];
629
+ const preview = await api.specDiff({ id: ctx.id }, { edits });
630
+ if (!preview.ok) {
631
+ toast(preview.body?.error ?? `HTTP ${preview.status}`);
632
+ return;
633
+ }
634
+ const body = preview.body ?? {};
635
+ if (body.ok === false) {
636
+ toast(`${body.code ?? "refused"}: ${body.reason ?? "this edit does not apply"}`);
637
+ return;
638
+ }
639
+ confirmModal({
640
+ title: `Change ${path}`,
641
+ lead: "This is a human-owned field. Read the diff — values are credential-redacted server-side — then type the spec name to apply it here, or close this and open a review instead.",
642
+ detail: [pre(String(body.diff ?? ""))],
643
+ confirmName,
644
+ action: "Apply to the live spec",
645
+ onConfirm: async () => {
646
+ const res = await api.specEdit({ id: ctx.id }, { edits, confirmName });
647
+ if (reportWrite(res, `Applied ${path}`)) reload();
648
+ },
649
+ });
650
+ });
651
+
652
+ propose.addEventListener("click", async () => {
653
+ const res = await api.specPropose(
654
+ { id: ctx.id },
655
+ { edits: [editFor()], title: `hangar: ${path}` },
656
+ );
657
+ if (
658
+ reportWrite(res, "Proposal staged — the review bundle is a dry run until you open the PR")
659
+ ) {
660
+ reload();
661
+ }
662
+ });
663
+ return el("div", { class: "row-editor" }, [input, review, propose]);
664
+ }
665
+
666
+ // ---------------------------------------------------------------------------
667
+ // version history
668
+ // ---------------------------------------------------------------------------
669
+
670
+ function versionsCard(ctx, body, reload) {
671
+ const versions = Array.isArray(body.versions) ? body.versions : [];
672
+ const pins = body.pins && typeof body.pins === "object" ? body.pins : {};
673
+ const confirmName = String(body.confirmName ?? "");
674
+ const inner = [];
675
+ const pinEntries = Object.entries(pins);
676
+ inner.push(
677
+ el("p", { class: "muted" }, [
678
+ el("span", { text: "Registry: " }),
679
+ el("code", { text: String(body.dir ?? "") }),
680
+ ]),
681
+ );
682
+ if (pinEntries.length > 0) {
683
+ inner.push(
684
+ el(
685
+ "div",
686
+ { class: "chip-group" },
687
+ pinEntries.map(([env, version]) =>
688
+ el("span", { class: "chip", text: `${env} → ${version}` }),
689
+ ),
690
+ ),
691
+ );
692
+ }
693
+ if (versions.length === 0) {
694
+ inner.push(
695
+ emptyState(
696
+ typeof body.note === "string" && body.note !== ""
697
+ ? body.note
698
+ : "No spec versions registered yet",
699
+ typeof body.verb === "string" ? body.verb : "crewhaus spec put",
700
+ ),
701
+ );
702
+ } else {
703
+ inner.push(
704
+ table(
705
+ ["Version", "Pinned to", "Provenance", "", ""],
706
+ versions.map((version) => versionRow(ctx, version, confirmName, reload)),
707
+ ),
708
+ );
709
+ }
710
+ if (typeof body.changelog === "string" && body.changelog !== "") {
711
+ inner.push(
712
+ collapsible(
713
+ [el("span", { class: "muted", text: "CHANGELOG (credential-redacted)" })],
714
+ [pre(body.changelog)],
715
+ ),
716
+ );
717
+ }
718
+ return card(
719
+ "Version history",
720
+ body.present === true
721
+ ? dot("ok", `${versions.length} versions`)
722
+ : dot("off", "no registry yet"),
723
+ inner,
724
+ );
725
+ }
726
+
727
+ function versionRow(ctx, version, confirmName, reload) {
728
+ const id = String(version.version ?? "");
729
+ const provenance = version.provenance;
730
+ const view = el("button", { class: "btn btn-ghost", type: "button", text: "View" });
731
+ view.addEventListener("click", async () => {
732
+ const res = await api.specVersion({ id: ctx.id, version: id });
733
+ if (!res.ok || res.body?.present === false) {
734
+ toast(res.body?.note ?? "that version's file is gone");
735
+ return;
736
+ }
737
+ viewModal(`${id} (values masked server-side)`, [
738
+ pre(String(res.body?.yaml ?? "")),
739
+ copyBtn(String(res.body?.yaml ?? ""), "copy yaml"),
740
+ ]);
741
+ });
742
+ const diff = el("button", { class: "btn btn-ghost", type: "button", text: "Diff vs live" });
743
+ diff.addEventListener("click", async () => {
744
+ const res = await api.specVersionDiff({ id: ctx.id, version: id });
745
+ if (!res.ok) {
746
+ toast(res.body?.error ?? `HTTP ${res.status}`);
747
+ return;
748
+ }
749
+ viewModal(`${id} vs the live spec`, [
750
+ res.body?.note ? el("p", { class: "muted", text: String(res.body.note) }) : null,
751
+ pre(String(res.body?.diff ?? "")),
752
+ ]);
753
+ });
754
+ const pin = el("button", { class: "btn", type: "button", text: "Pin to…" });
755
+ pin.addEventListener("click", () => pinDialog(ctx, id, confirmName, reload));
756
+ return el("tr", null, [
757
+ el("td", { class: "mono" }, [
758
+ el("span", { text: id }),
759
+ version.isCurrent === true ? el("span", { class: "chip", text: "current" }) : null,
760
+ version.present === false
761
+ ? el("span", { class: "chip chip-warn", text: "file missing" })
762
+ : null,
763
+ ]),
764
+ el("td", null, [
765
+ Array.isArray(version.pinnedEnvs) && version.pinnedEnvs.length > 0
766
+ ? el(
767
+ "div",
768
+ { class: "chip-group" },
769
+ version.pinnedEnvs.map((env) => el("span", { class: "chip", text: env })),
770
+ )
771
+ : el("span", { class: "muted", text: "—" }),
772
+ ]),
773
+ el("td", { class: "muted", text: provenanceText(provenance) }),
774
+ el("td", { class: "cell-actions" }, [view, diff]),
775
+ el("td", { class: "cell-actions" }, [pin]),
776
+ ]);
777
+ }
778
+
779
+ function provenanceText(provenance) {
780
+ if (provenance === null || provenance === undefined || typeof provenance !== "object") {
781
+ return "hand-authored";
782
+ }
783
+ const parts = [];
784
+ if (provenance.runId) parts.push(`optimizer ${provenance.runId}`);
785
+ if (provenance.mutator) parts.push(String(provenance.mutator));
786
+ if (provenance.scoreBefore !== undefined && provenance.scoreAfter !== undefined) {
787
+ parts.push(`score ${provenance.scoreBefore} → ${provenance.scoreAfter}`);
788
+ }
789
+ return parts.length > 0 ? parts.join(" · ") : "optimizer";
790
+ }
791
+
792
+ /** Pin or roll back an environment to a version. Both shell the audited
793
+ * deploy verb server-side; both are typed-confirm here. */
794
+ function pinDialog(ctx, version, confirmName, reload) {
795
+ const envInput = el("input", {
796
+ class: "input",
797
+ type: "text",
798
+ value: "prod",
799
+ "aria-label": "environment",
800
+ });
801
+ const rollback = el("input", { type: "checkbox", "aria-label": "treat this as a rollback" });
802
+ confirmModal({
803
+ title: `Pin ${version}`,
804
+ lead: "Pins move through the deploy verbs, so the audit record and a protected environment's approval quorum are preserved. The manager never writes the manifest itself.",
805
+ detail: [
806
+ el("label", { class: "field" }, [el("span", { text: "Environment" }), envInput]),
807
+ el("label", { class: "field" }, [
808
+ rollback,
809
+ el("span", { text: " this is a rollback (the environment is moving backwards)" }),
810
+ ]),
811
+ ],
812
+ confirmName,
813
+ action: "Move the pin",
814
+ onConfirm: async () => {
815
+ const params = { id: ctx.id };
816
+ const payload = { env: envInput.value.trim(), version, confirmName };
817
+ const res = rollback.checked
818
+ ? await api.specRollback(params, payload)
819
+ : await api.specPin(params, payload);
820
+ if (reportWrite(res, `Queued the pin move for ${payload.env}`)) reload();
821
+ },
822
+ });
823
+ }
824
+
825
+ // ---------------------------------------------------------------------------
826
+ // MCP connectors
827
+ // ---------------------------------------------------------------------------
828
+
829
+ function connectorsCard(ctx, body, catalog, reload) {
830
+ const servers = Array.isArray(body.servers) ? body.servers : [];
831
+ const findings = Array.isArray(body.findings) ? body.findings : [];
832
+ const confirmName = String(body.confirmName ?? "");
833
+ const inner = [];
834
+ if (findings.length > 0) {
835
+ inner.push(
836
+ el(
837
+ "ul",
838
+ { class: "issue-list" },
839
+ findings.map((finding) =>
840
+ el("li", null, [
841
+ dot(finding.level === "bad" ? "bad" : "warn", String(finding.code ?? "finding")),
842
+ el("code", { text: `${finding.server ?? ""}.${finding.envName ?? ""}` }),
843
+ el("span", { text: ` ${finding.message ?? ""}` }),
844
+ ]),
845
+ ),
846
+ ),
847
+ );
848
+ }
849
+ if (servers.length === 0) {
850
+ inner.push(
851
+ emptyState(
852
+ typeof body.note === "string" && body.note !== ""
853
+ ? body.note
854
+ : "This spec declares no MCP servers",
855
+ typeof body.verb === "string" ? body.verb : "crewhaus mcp doctor",
856
+ ),
857
+ );
858
+ } else {
859
+ inner.push(
860
+ table(
861
+ ["Server", "Transport", "Command / URL", "Env refs", ""],
862
+ servers.map((server) => connectorRow(ctx, server, confirmName, reload)),
863
+ ),
864
+ );
865
+ }
866
+ inner.push(connectorForm(ctx, catalog, confirmName, reload));
867
+ return card(
868
+ "MCP connectors",
869
+ findings.some((f) => f.level === "bad")
870
+ ? dot("bad", `${findings.filter((f) => f.level === "bad").length} blocking findings`)
871
+ : dot("ok", `${servers.length} servers`),
872
+ inner,
873
+ );
874
+ }
875
+
876
+ function connectorRow(ctx, server, confirmName, reload) {
877
+ const name = String(server.name ?? "");
878
+ const refs = Array.isArray(server.envRefs) ? server.envRefs : [];
879
+ const remove = el("button", { class: "btn btn-ghost", type: "button", text: "Remove…" });
880
+ remove.addEventListener("click", async () => {
881
+ // The plan first — the server answers what WOULD change, then the same
882
+ // call with the confirmation applies it.
883
+ const plan = await api.mcpConnectorRemove({ id: ctx.id, name });
884
+ if (!plan.ok) {
885
+ toast(plan.body?.error ?? `HTTP ${plan.status}`);
886
+ return;
887
+ }
888
+ if (plan.body?.ok === false) {
889
+ toast(`${plan.body.code}: ${plan.body.reason}`, "info");
890
+ return;
891
+ }
892
+ confirmModal({
893
+ title: `Remove ${name}`,
894
+ lead: "An MCP server is third-party code plus its credentials, so removing one is a human-owned spec edit.",
895
+ detail: [pre(String(plan.body?.diff ?? ""))],
896
+ confirmName,
897
+ action: "Remove it",
898
+ onConfirm: async () => {
899
+ const res = await api.mcpConnectorRemove(
900
+ { id: ctx.id, name },
901
+ undefined,
902
+ `?confirmName=${encodeURIComponent(confirmName)}`,
903
+ );
904
+ if (reportWrite(res, `Removed ${name}`)) reload();
905
+ },
906
+ });
907
+ });
908
+ return el("tr", null, [
909
+ el("td", { class: "mono", text: name }),
910
+ el("td", null, [el("span", { class: "chip", text: String(server.transport ?? "stdio") })]),
911
+ el("td", { class: "mono", text: String(server.command ?? server.url ?? "—") }),
912
+ el("td", null, [
913
+ refs.length === 0
914
+ ? el("span", { class: "muted", text: "—" })
915
+ : el(
916
+ "ul",
917
+ { class: "check-list" },
918
+ refs.map((ref) =>
919
+ el("li", null, [
920
+ dot(ref.set === true ? "ok" : "bad", ref.set === true ? "set" : "unset"),
921
+ el("code", { text: String(ref.name ?? "") }),
922
+ ]),
923
+ ),
924
+ ),
925
+ ]),
926
+ el("td", { class: "cell-actions" }, [remove]),
927
+ ]);
928
+ }
929
+
930
+ /** Add a connector: pick a catalog skeleton or type one, preview the diff,
931
+ * then confirm. Values for secrets are never accepted here — the form takes
932
+ * a command and its arguments, and the server refuses the rest. */
933
+ function connectorForm(ctx, catalog, confirmName, reload) {
934
+ const connectors = Array.isArray(catalog?.connectors) ? catalog.connectors : [];
935
+ const name = el("input", { class: "input", type: "text", "aria-label": "server name" });
936
+ const command = el("input", {
937
+ class: "input",
938
+ type: "text",
939
+ placeholder: "bunx",
940
+ "aria-label": "command",
941
+ });
942
+ const args = el("input", {
943
+ class: "input grow",
944
+ type: "text",
945
+ placeholder: "-y some-server",
946
+ "aria-label": "arguments",
947
+ });
948
+ const preset = el("select", { class: "input", "aria-label": "catalog entry" });
949
+ preset.appendChild(el("option", { value: "", text: "custom…" }));
950
+ for (const entry of connectors) {
951
+ preset.appendChild(el("option", { value: String(entry.id), text: String(entry.title) }));
952
+ }
953
+ preset.addEventListener("change", () => {
954
+ const entry = connectors.find((c) => String(c.id) === preset.value);
955
+ if (entry === undefined) return;
956
+ command.value = typeof entry.command === "string" ? entry.command : "";
957
+ args.value = Array.isArray(entry.args) ? entry.args.join(" ") : "";
958
+ });
959
+ const add = el("button", { class: "btn btn-primary", type: "button", text: "Preview…" });
960
+ add.addEventListener("click", async () => {
961
+ const payload = {
962
+ name: name.value.trim(),
963
+ command: command.value.trim(),
964
+ args: args.value.trim() === "" ? [] : args.value.trim().split(/\s+/),
965
+ };
966
+ const plan = await api.mcpConnectorWrite({ id: ctx.id }, payload);
967
+ if (!plan.ok) {
968
+ toast(plan.body?.error ?? `HTTP ${plan.status}`);
969
+ return;
970
+ }
971
+ if (plan.body?.ok === false) {
972
+ toast(`${plan.body.code}: ${plan.body.reason}`);
973
+ return;
974
+ }
975
+ confirmModal({
976
+ title: `Add ${payload.name}`,
977
+ lead: "Review the diff. Credentials never belong in a connector's env map — put them in this harness's .env and the server will refuse anything that looks like one.",
978
+ detail: [pre(String(plan.body?.diff ?? ""))],
979
+ confirmName,
980
+ action: "Write it to the spec",
981
+ onConfirm: async () => {
982
+ const res = await api.mcpConnectorWrite(
983
+ { id: ctx.id },
984
+ { ...payload, dryRun: false, confirmName },
985
+ );
986
+ if (reportWrite(res, `Added ${payload.name}`)) reload();
987
+ },
988
+ });
989
+ });
990
+ return el("div", { class: "add-form" }, [
991
+ el("label", { class: "field" }, [el("span", { text: "From catalog" }), preset]),
992
+ el("label", { class: "field" }, [el("span", { text: "Name" }), name]),
993
+ el("label", { class: "field" }, [el("span", { text: "Command" }), command]),
994
+ el("label", { class: "field grow" }, [el("span", { text: "Arguments" }), args]),
995
+ add,
996
+ ]);
997
+ }
998
+
999
+ // ---------------------------------------------------------------------------
1000
+ // graders + dataset builder
1001
+ // ---------------------------------------------------------------------------
1002
+
1003
+ function gradersCard(ctx, body, confirmName, reload) {
1004
+ const graders = Array.isArray(body.graders) ? body.graders : [];
1005
+ const inner = [];
1006
+ inner.push(
1007
+ el("p", { class: "muted" }, [
1008
+ el("span", { text: "File: " }),
1009
+ el("code", { text: String(body.path ?? "eval/graders.yaml") }),
1010
+ body.hash ? el("span", { class: "chip", text: String(body.hash).slice(0, 14) }) : null,
1011
+ ]),
1012
+ );
1013
+ if (graders.length === 0) {
1014
+ inner.push(
1015
+ emptyState(
1016
+ typeof body.note === "string" && body.note !== "" ? body.note : "No graders configured yet",
1017
+ typeof body.verb === "string" ? body.verb : "crewhaus graders suggest",
1018
+ ),
1019
+ );
1020
+ } else {
1021
+ inner.push(
1022
+ table(
1023
+ ["Grader", "Kind"],
1024
+ graders.map((grader) =>
1025
+ el("tr", null, [
1026
+ el("td", { class: "mono", text: String(grader.name ?? "—") }),
1027
+ el("td", null, [el("span", { class: "chip", text: String(grader.type ?? "—") })]),
1028
+ ]),
1029
+ ),
1030
+ ),
1031
+ );
1032
+ }
1033
+ const editor = el("textarea", {
1034
+ class: "input notes",
1035
+ rows: 8,
1036
+ "aria-label": "graders.yaml",
1037
+ text: typeof body.yaml === "string" ? body.yaml : "",
1038
+ });
1039
+ const save = el("button", { class: "btn", type: "button", text: "Save graders.yaml…" });
1040
+ save.addEventListener("click", () => {
1041
+ confirmModal({
1042
+ title: "Replace eval/graders.yaml",
1043
+ lead: "Runs scored after this write carry a new graders hash; runs already recorded keep the graders they were scored by.",
1044
+ detail: [el("p", { class: "muted", text: `${editor.value.length} characters` })],
1045
+ confirmName,
1046
+ action: "Write the file",
1047
+ onConfirm: async () => {
1048
+ const res = await api.graderWrite({ id: ctx.id }, { yaml: editor.value, confirmName });
1049
+ if (reportWrite(res, "Wrote eval/graders.yaml")) reload();
1050
+ },
1051
+ });
1052
+ });
1053
+ inner.push(
1054
+ collapsible([el("span", { class: "muted", text: "edit graders.yaml" })], [editor, save]),
1055
+ el("p", {
1056
+ class: "muted",
1057
+ text: "The structured grader builder ships as a published package this manager does not bundle; the YAML pane above is the write path it has.",
1058
+ }),
1059
+ el("p", { class: "muted" }, [
1060
+ el("span", { text: "Render the rubric card with " }),
1061
+ el("code", { text: String(body.cardVerb ?? "crewhaus graders card") }),
1062
+ el("span", { text: "." }),
1063
+ ]),
1064
+ );
1065
+ return card(
1066
+ "Graders",
1067
+ body.present === true ? dot("ok", `${graders.length} graders`) : dot("off", "no graders.yaml"),
1068
+ inner,
1069
+ );
1070
+ }
1071
+
1072
+ function datasetCard(body) {
1073
+ return card("Dataset builder", dot("off", "not in this build"), [
1074
+ emptyState(
1075
+ typeof body.note === "string" && body.note !== ""
1076
+ ? body.note
1077
+ : "The dataset builder is not part of this build",
1078
+ typeof body.verb === "string" ? body.verb : "crewhaus dataset mine",
1079
+ ),
1080
+ el("p", {
1081
+ class: "muted",
1082
+ text: "Datasets themselves live on the Data tab — this panel only ever drove the builder's state machine.",
1083
+ }),
1084
+ ]);
1085
+ }
1086
+
1087
+ // ---------------------------------------------------------------------------
1088
+ // the new-harness wizard
1089
+ // ---------------------------------------------------------------------------
1090
+
1091
+ function wizardCard(body) {
1092
+ const templates = Array.isArray(body.templates) ? body.templates : [];
1093
+ if (templates.length === 0) {
1094
+ return card("New harness", dot("off", "no templates"), [
1095
+ emptyState("No templates in this build", "crewhaus init"),
1096
+ ]);
1097
+ }
1098
+ return card("New harness", dot("ok", `${templates.length} templates`), [
1099
+ noteLine(body),
1100
+ table(
1101
+ ["Template", "Shape", "What it scaffolds", ""],
1102
+ templates.map((template) => wizardRow(template)),
1103
+ ),
1104
+ ]);
1105
+ }
1106
+
1107
+ function wizardRow(template) {
1108
+ const create = el("button", { class: "btn", type: "button", text: "Create…" });
1109
+ create.addEventListener("click", () => wizardDialog(template));
1110
+ return el("tr", null, [
1111
+ el("td", null, [
1112
+ el("div", { text: String(template.title ?? template.id) }),
1113
+ el("div", { class: "muted", text: String(template.summary ?? "") }),
1114
+ ]),
1115
+ el("td", null, [el("span", { class: "shape-badge", text: String(template.target ?? "") })]),
1116
+ el("td", { class: "mono muted", text: (template.scaffolds ?? []).join(", ") }),
1117
+ el("td", { class: "cell-actions" }, [create]),
1118
+ ]);
1119
+ }
1120
+
1121
+ /**
1122
+ * The wizard is dry-run-first: the plan renders the exact spec that would be
1123
+ * written and touches nothing, and only the typed confirmation creates the
1124
+ * directory. Registration is a second, explicit step — the manager does not
1125
+ * adopt a directory the operator has not asked it to.
1126
+ */
1127
+ function wizardDialog(template) {
1128
+ const dir = el("input", {
1129
+ class: "input grow",
1130
+ type: "text",
1131
+ placeholder: "/absolute/path/to/new-harness",
1132
+ "aria-label": "new harness directory",
1133
+ });
1134
+ const nameInput = el("input", { class: "input", type: "text", "aria-label": "spec name" });
1135
+ const plan = el("button", { class: "btn btn-primary", type: "button", text: "Preview" });
1136
+ const out = el("div", { class: "modal-body" });
1137
+ plan.addEventListener("click", async () => {
1138
+ const answers = nameInput.value.trim() === "" ? {} : { name: nameInput.value.trim() };
1139
+ const res = await api.wizardCreate(
1140
+ {},
1141
+ { dir: dir.value.trim(), template: template.id, answers },
1142
+ );
1143
+ clear(out);
1144
+ if (!res.ok) {
1145
+ out.appendChild(el("p", { class: "reason", text: res.body?.error ?? `HTTP ${res.status}` }));
1146
+ return;
1147
+ }
1148
+ const body = res.body ?? {};
1149
+ if (body.ok === false) {
1150
+ out.appendChild(el("p", { class: "reason", text: `${body.code}: ${body.reason}` }));
1151
+ return;
1152
+ }
1153
+ out.appendChild(el("p", { class: "muted", text: String(body.note ?? "") }));
1154
+ out.appendChild(pre(String(body.yaml ?? "")));
1155
+ const confirmName = String(body.confirmName ?? "");
1156
+ const write = el("button", { class: "btn btn-danger", type: "button", text: "Create it" });
1157
+ const typed = el("input", {
1158
+ class: "input",
1159
+ type: "text",
1160
+ placeholder: confirmName,
1161
+ "aria-label": `type ${confirmName} to confirm`,
1162
+ });
1163
+ write.disabled = true;
1164
+ typed.addEventListener("input", () => {
1165
+ write.disabled = typed.value.trim() !== confirmName;
1166
+ });
1167
+ write.addEventListener("click", async () => {
1168
+ const made = await api.wizardCreate(
1169
+ {},
1170
+ {
1171
+ dir: dir.value.trim(),
1172
+ template: template.id,
1173
+ answers: nameInput.value.trim() === "" ? {} : { name: nameInput.value.trim() },
1174
+ dryRun: false,
1175
+ confirmName,
1176
+ },
1177
+ );
1178
+ if (!reportWrite(made, "Created the harness")) return;
1179
+ // Registering is the console's own route — the wizard's answer names it
1180
+ // rather than pretending the manager did it.
1181
+ try {
1182
+ await api.addHarness(String(made.body?.dir ?? dir.value.trim()));
1183
+ toast("Registered — it is in the Library now", "info");
1184
+ } catch (err) {
1185
+ toast(`Created, but not registered: ${err instanceof Error ? err.message : String(err)}`);
1186
+ }
1187
+ });
1188
+ out.appendChild(
1189
+ el("label", { class: "field" }, [
1190
+ el("span", { text: `Type ${confirmName} to create it` }),
1191
+ typed,
1192
+ ]),
1193
+ );
1194
+ out.appendChild(el("div", { class: "modal-actions" }, [write]));
1195
+ });
1196
+ viewModal(`New ${template.title ?? template.id}`, [
1197
+ el("label", { class: "field grow" }, [el("span", { text: "Directory (absolute)" }), dir]),
1198
+ el("label", { class: "field" }, [el("span", { text: "Spec name" }), nameInput]),
1199
+ el("div", { class: "modal-actions" }, [plan]),
1200
+ out,
1201
+ ]);
1202
+ }
1203
+
1204
+ // ---------------------------------------------------------------------------
1205
+ // entry point
1206
+ // ---------------------------------------------------------------------------
1207
+
1208
+ export async function renderSpecEdit(root, ctx) {
1209
+ clear(root).appendChild(skeleton(6));
1210
+ const reload = () => renderSpecEdit(root, ctx);
1211
+ const [schema, trust, versions, connectors, catalog, graders, dataset, templates] =
1212
+ await Promise.all([
1213
+ api.specSchema({ id: ctx.id }),
1214
+ api.specTrust({ id: ctx.id }),
1215
+ api.specVersions({ id: ctx.id }),
1216
+ api.mcpConnectors({ id: ctx.id }),
1217
+ api.mcpCatalog({}),
1218
+ api.graderCatalog({ id: ctx.id }),
1219
+ api.datasetBuilder({ id: ctx.id }),
1220
+ api.wizardTemplates({}),
1221
+ ]);
1222
+ clear(root);
1223
+
1224
+ // path → effective value, so the field editor can seed its inputs from
1225
+ // what the harness is actually running rather than from the raw text.
1226
+ const valueByPath = new Map();
1227
+ if (schema.ok && Array.isArray(schema.body?.effective)) {
1228
+ for (const row of schema.body.effective) valueByPath.set(String(row.path), row.value);
1229
+ }
1230
+ const confirmName = String(
1231
+ trust.body?.confirmName ?? connectors.body?.confirmName ?? versions.body?.confirmName ?? "",
1232
+ );
1233
+
1234
+ root.appendChild(
1235
+ isStub(schema)
1236
+ ? stubCard("Effective configuration", schema)
1237
+ : schema.ok
1238
+ ? effectiveCard(schema.body ?? {})
1239
+ : errorCard("Effective configuration", schema),
1240
+ );
1241
+ root.appendChild(
1242
+ isStub(trust)
1243
+ ? stubCard("Fields & trust tiers", trust)
1244
+ : trust.ok
1245
+ ? trustCard(ctx, trust.body ?? {}, valueByPath, reload)
1246
+ : errorCard("Fields & trust tiers", trust),
1247
+ );
1248
+ root.appendChild(
1249
+ isStub(versions)
1250
+ ? stubCard("Version history", versions)
1251
+ : versions.ok
1252
+ ? versionsCard(ctx, versions.body ?? {}, reload)
1253
+ : errorCard("Version history", versions),
1254
+ );
1255
+ root.appendChild(
1256
+ isStub(connectors)
1257
+ ? stubCard("MCP connectors", connectors)
1258
+ : connectors.ok
1259
+ ? connectorsCard(ctx, connectors.body ?? {}, catalog.ok ? catalog.body : null, reload)
1260
+ : errorCard("MCP connectors", connectors),
1261
+ );
1262
+ root.appendChild(
1263
+ isStub(graders)
1264
+ ? stubCard("Graders", graders)
1265
+ : graders.ok
1266
+ ? gradersCard(ctx, graders.body ?? {}, confirmName, reload)
1267
+ : errorCard("Graders", graders),
1268
+ );
1269
+ root.appendChild(
1270
+ isStub(dataset) ? stubCard("Dataset builder", dataset) : datasetCard(dataset.body ?? {}),
1271
+ );
1272
+ root.appendChild(
1273
+ isStub(templates)
1274
+ ? stubCard("New harness", templates)
1275
+ : templates.ok
1276
+ ? wizardCard(templates.body ?? {})
1277
+ : errorCard("New harness", templates),
1278
+ );
1279
+ }
1280
+
1281
+ /** A route that failed for a reason that is not "not built yet". */
1282
+ function errorCard(title, res) {
1283
+ return card(title, dot("bad", `HTTP ${res.status ?? "?"}`), [
1284
+ el("p", { class: "muted", text: res.body?.error ?? "the manager could not answer" }),
1285
+ res.body
1286
+ ? collapsible([el("span", { class: "muted", text: "raw" })], [jsonPre(res.body)])
1287
+ : null,
1288
+ ]);
1289
+ }