Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+903
View File
@@ -0,0 +1,903 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hermes Kanban — Native Dialog Prototypes</title>
<style>
/* =========================================================== */
/* Design tokens — host @nous-research/ui, oklch→sRGB approx. */
/* =========================================================== */
:root {
--bg: #ffffff;
--bg-subtle: #f8f9fa;
--bg-muted: #f1f3f5;
--border: #e5e7eb;
--border-strong: #d1d5db;
--fg: #0f172a;
--fg-muted: #64748b;
--fg-subtle: #94a3b8;
--ring: #94a3b8;
--accent: #2563eb;
--accent-fg: #ffffff;
--destructive: #dc2626;
--destructive-fg: #ffffff;
--success: #16a34a;
--shadow-md: 0 4px 12px -2px rgba(15, 23, 42, 0.10),
0 2px 4px -2px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 20px 25px -5px rgba(15, 23, 42, 0.10),
0 8px 10px -6px rgba(15, 23, 42, 0.04);
--radius: 10px;
--radius-sm: 6px;
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b0d10;
--bg-subtle: #11141a;
--bg-muted: #161a22;
--border: #232833;
--border-strong: #2c3140;
--fg: #e6e8ec;
--fg-muted: #9aa3b2;
--fg-subtle: #6b7280;
--ring: #4b5563;
--accent: #3b82f6;
--accent-fg: #ffffff;
--destructive: #ef4444;
--destructive-fg: #ffffff;
--success: #22c55e;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font);
color: var(--fg);
background: var(--bg-subtle);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* =========================================================== */
/* Page chrome */
/* =========================================================== */
header.page {
padding: 24px 32px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
header.page h1 {
font-size: 18px; font-weight: 600; margin: 0 0 4px;
letter-spacing: -0.01em;
}
header.page p {
font-size: 13px; color: var(--fg-muted); margin: 0;
max-width: 920px;
}
header.page .controls {
margin-top: 14px; display: flex; gap: 8px; flex-wrap: wrap;
}
header.page .controls button {
font: inherit; font-size: 12px; font-weight: 500;
padding: 6px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--bg);
color: var(--fg); cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
header.page .controls button:hover { background: var(--bg-muted); }
header.page .controls button.active {
background: var(--accent); border-color: var(--accent); color: var(--accent-fg);
}
.board {
padding: 24px 32px 80px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
max-width: 1700px;
}
@media (max-width: 1400px) { .board { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .board { grid-template-columns: 1fr; } }
section.variant {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex; flex-direction: column;
min-width: 0;
}
section.variant header {
padding: 14px 16px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-subtle);
}
section.variant header h2 {
font-size: 11px; font-weight: 600; margin: 0 0 2px;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--fg-muted);
}
section.variant header .label {
font-size: 15px; font-weight: 600; color: var(--fg);
letter-spacing: -0.01em;
}
section.variant header p {
font-size: 12px; color: var(--fg-muted); margin: 6px 0 0;
line-height: 1.4;
}
section.variant .stage {
flex: 1;
padding: 24px 16px;
display: flex; flex-direction: column; align-items: center;
justify-content: flex-start;
background: var(--bg);
min-height: 540px;
position: relative;
}
section.variant .actions {
padding: 10px 16px;
border-top: 1px solid var(--border);
display: flex; flex-wrap: wrap; gap: 6px;
background: var(--bg-subtle);
}
section.variant .actions button {
font: inherit; font-size: 11px;
padding: 5px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--bg);
color: var(--fg-muted); cursor: pointer;
}
section.variant .actions button:hover {
border-color: var(--border-strong); color: var(--fg);
}
section.variant .actions .trash {
color: var(--destructive); border-color: var(--destructive);
background: color-mix(in srgb, var(--destructive) 6%, var(--bg));
}
section.variant .note {
font-size: 11px; color: var(--fg-muted);
padding: 8px 12px; background: var(--bg-muted);
border-top: 1px solid var(--border);
line-height: 1.45;
}
section.variant .note strong { color: var(--fg); }
section.variant .note code {
font-family: var(--mono); font-size: 10px;
background: var(--bg); padding: 1px 4px; border-radius: 3px;
border: 1px solid var(--border);
}
/* =========================================================== */
/* Backdrop + dialog (shared) */
/* =========================================================== */
.backdrop {
position: absolute; inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center;
z-index: 10;
animation: fade-in 160ms ease both;
padding: 16px;
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes dialog-in {
from { opacity: 0; transform: translateY(4px) scale(0.98); }
to { opacity: 1; transform: none; }
}
.dialog {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
width: 100%; max-width: 440px;
padding: 20px;
animation: dialog-in 180ms cubic-bezier(.2,.8,.2,1) both;
font-size: 14px;
}
.dialog h3 {
margin: 0; font-size: 16px; font-weight: 600;
letter-spacing: -0.01em; display: flex; align-items: center; gap: 10px;
color: var(--fg);
}
.dialog p.desc {
margin: 8px 0 0; color: var(--fg-muted); font-size: 13px;
line-height: 1.45;
}
.dialog .body { margin-top: 14px; }
.dialog .actions {
margin-top: 20px;
display: flex; justify-content: flex-end; gap: 8px;
}
.dialog button {
font: inherit; font-size: 13px; font-weight: 500;
padding: 8px 14px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--bg);
color: var(--fg); cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.dialog button:hover { background: var(--bg-muted); }
.dialog button:focus-visible {
outline: 2px solid var(--ring); outline-offset: 2px;
}
.dialog button.primary {
background: var(--accent); border-color: var(--accent); color: var(--accent-fg);
}
.dialog button.primary:hover {
background: color-mix(in srgb, var(--accent) 88%, black);
}
.dialog button.destructive {
background: var(--destructive); border-color: var(--destructive); color: var(--destructive-fg);
}
.dialog button.destructive:hover {
background: color-mix(in srgb, var(--destructive) 88%, black);
}
.dialog button:disabled { opacity: 0.5; cursor: not-allowed; }
.dialog .spinner {
display: inline-block;
width: 12px; height: 12px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: -2px;
margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.dialog textarea, .dialog input[type="text"] {
font: inherit; font-size: 13px;
width: 100%; padding: 8px 10px;
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
background: var(--bg); color: var(--fg);
resize: vertical;
}
.dialog textarea:focus, .dialog input[type="text"]:focus {
outline: 2px solid var(--ring); outline-offset: -1px; border-color: var(--ring);
}
.dialog textarea.invalid, .dialog input.invalid {
border-color: var(--destructive);
}
.dialog .helper {
font-size: 11px; color: var(--fg-muted);
margin-top: 4px; min-height: 16px;
}
.dialog .helper.error { color: var(--destructive); }
/* =========================================================== */
/* Icons (inline SVG, Lucide-style) */
/* =========================================================== */
.ico { width: 18px; height: 18px; stroke: currentColor; fill: none;
stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
flex-shrink: 0; }
.ico.muted { color: var(--fg-muted); }
.ico.destructive { color: var(--destructive); }
.ico.success { color: var(--success); }
/* =========================================================== */
/* Toast container (Variant C) */
/* =========================================================== */
.toast-container {
position: absolute; right: 16px; bottom: 16px;
display: flex; flex-direction: column-reverse; gap: 8px;
z-index: 11; width: 320px;
pointer-events: none;
}
.toast {
background: var(--fg);
color: var(--bg);
border-radius: var(--radius-sm);
padding: 10px 12px;
box-shadow: var(--shadow-lg);
display: flex; flex-direction: column; gap: 6px;
pointer-events: auto;
animation: toast-in 220ms cubic-bezier(.2,.8,.2,1) both;
font-size: 13px;
}
.toast.error { background: var(--destructive); color: var(--destructive-fg); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.toast .title {
display: flex; align-items: center; gap: 8px;
font-weight: 600;
}
.toast .desc { font-size: 12px; opacity: 0.85; }
.toast .row {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.toast .undo {
font: inherit; font-size: 11px; font-weight: 600;
padding: 4px 10px; border-radius: var(--radius-sm);
background: transparent; color: inherit;
border: 1px solid currentColor;
cursor: pointer;
text-transform: uppercase; letter-spacing: 0.06em;
}
.toast .undo:hover { background: rgba(255,255,255,0.12); }
/* =========================================================== */
/* Bulk-many list (Variant B-refined) */
/* =========================================================== */
.bulk-list {
margin-top: 14px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--bg-subtle);
max-height: 200px; overflow-y: auto;
}
.bulk-item {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
display: flex; align-items: flex-start; gap: 8px;
font-size: 12px;
}
.bulk-item:last-child { border-bottom: none; }
.bulk-item .meta { flex: 1; min-width: 0; }
.bulk-item .meta .title {
font-weight: 500; color: var(--fg);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bulk-item .meta .id {
font-family: var(--mono); font-size: 10px; color: var(--fg-subtle);
margin-top: 1px;
}
.bulk-item textarea {
font: inherit; font-size: 11px;
width: 100%; padding: 4px 6px;
border: 1px solid var(--border); border-radius: 4px;
background: var(--bg); color: var(--fg);
margin-top: 4px;
min-height: 22px; resize: vertical;
}
/* =========================================================== */
/* Decision legend */
/* =========================================================== */
.legend {
padding: 16px 32px 24px;
background: var(--bg);
border-top: 1px solid var(--border);
font-size: 12px; color: var(--fg-muted);
max-width: 1700px;
}
.legend h3 {
font-size: 11px; font-weight: 600; color: var(--fg);
letter-spacing: 0.08em; text-transform: uppercase;
margin: 0 0 8px;
}
.legend table { border-collapse: collapse; width: 100%; max-width: 1100px; }
.legend th, .legend td {
text-align: left; padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.legend th { color: var(--fg); font-weight: 600; }
.legend td.yes { color: var(--success); }
.legend td.no { color: var(--fg-subtle); }
.legend td.partial { color: var(--accent); }
</style>
</head>
<body>
<header class="page">
<h1>Hermes Kanban — Native Dialog Prototypes</h1>
<p>
Four approaches to replacing <code>window.confirm()</code>,
<code>window.prompt()</code>, and <code>window.alert()</code> in
<code>plugins/kanban/dashboard/dist/index.js</code>. Click a page-level
trigger to fire the same flow in every variant simultaneously, or use the
per-variant buttons to fire flows unique to that variant.
</p>
<div class="controls">
<button data-trigger="move-done">Trigger: Mark Done (with summary)</button>
<button data-trigger="move-blocked">Trigger: Mark Blocked</button>
<button data-trigger="bulk-delete">Trigger: Bulk delete (3 tasks)</button>
<button data-trigger="archive-board">Trigger: Archive board</button>
<button data-trigger="remove-attachment">Trigger: Remove attachment</button>
<button data-trigger="error">Trigger: Error toast</button>
<button data-trigger="clear">Clear all stages</button>
</div>
</header>
<div class="board">
<!-- ============================================================ -->
<!-- VARIANT A — Conservative -->
<!-- ============================================================ -->
<section class="variant" data-variant="A">
<header>
<h2>Variant A</h2>
<div class="label">Conservative</div>
<p>Direct 1:1 mapping to the host's <code>ConfirmDialog</code>. Single-line input. Minimal chrome.</p>
</header>
<div class="stage" data-stage="A">
<div class="placeholder">Click a trigger to preview.</div>
</div>
<div class="note">
<strong>Trade-off:</strong> Safest to ship — zero new components, zero new
patterns. Fails the GPT-OSS review on three points: no multi-line
summary, validation triggers a SECOND dialog instead of inline, no bulk
affordance.
</div>
</section>
<!-- ============================================================ -->
<!-- VARIANT B — Strong-fit (Pro's pick) -->
<!-- ============================================================ -->
<section class="variant" data-variant="B">
<header>
<h2>Variant B</h2>
<div class="label">Strong-fit (Pro's pick)</div>
<p>Textarea + contextual SVG icon + inline validation + pluralized copy. What the design brief recommends.</p>
</header>
<div class="stage" data-stage="B">
<div class="placeholder">Click a trigger to preview.</div>
</div>
<div class="note">
<strong>Trade-off:</strong> Best baseline, but Pro suggested <em>either</em>
a disabled-button <em>or</em> an inline error — GPT-OSS caught that both
are needed (button disabled AND error visible) for screen-reader users.
Bottom-sheet on mobile is the right move but still has keyboard edge cases.
</div>
</section>
<!-- ============================================================ -->
<!-- VARIANT B-refined (Pro + GPT-OSS synthesis) -->
<!-- ============================================================ -->
<section class="variant" data-variant="B2">
<header>
<h2>Variant B-refined</h2>
<div class="label">Synthesis (recommended)</div>
<p>All of B's improvements + GPT-OSS fixes: auto-focus, dual-validation, cancellable-spinner, per-task summaries in bulk.</p>
</header>
<div class="stage" data-stage="B2">
<div class="placeholder">Click a trigger to preview.</div>
</div>
<div class="note">
<strong>Why this is the recommendation:</strong> Single contextual icon
(not four) keeps the title readable. Confirm button stays enabled until
textarea has content; the inline error appears on submit-attempted-empty
AND on blur if still empty. Cancel button stays clickable during the
PATCH (only the confirm shows the spinner) so users can abort slow
networks. Bulk-many shows an expandable list with per-task summary
fields.
</div>
</section>
<!-- ============================================================ -->
<!-- VARIANT C — Divergent (undo toast, non-destructive only) -->
<!-- ============================================================ -->
<section class="variant" data-variant="C">
<header>
<h2>Variant C</h2>
<div class="label">Divergent: undo toast</div>
<p>Skip the modal entirely for non-destructive moves. Optimistic UI + 5s undo in a bottom-right toast.</p>
</header>
<div class="stage" data-stage="C">
<div class="placeholder">Click a trigger to preview.</div>
<div class="toast-container" data-toasts></div>
</div>
<div class="note">
<strong>Trade-off:</strong> Radical speedup for routine moves, but breaks
the required-summary flow (you can't optimistically "complete" a task
that's missing required schema data). Best used as a
<em>complement</em> to B-refined — undo toast for the safe moves,
modal for <code>done</code>/<code>blocked</code>/<code>archive</code>.
</div>
</section>
</div>
<div class="legend">
<h3>Decision matrix — recommended pick: B-refined</h3>
<table>
<thead>
<tr><th>Capability</th><th>A</th><th>B</th><th>B-refined</th><th>C</th></tr>
</thead>
<tbody>
<tr><td>Centered modal (Radix)</td> <td class="yes"></td> <td class="yes"></td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Multi-line summary</td> <td class="no"></td> <td class="yes"></td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Single contextual icon</td> <td class="no"></td> <td class="partial">4</td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Inline validation (no second dialog)</td><td class="no"></td> <td class="partial">~</td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Disabled button + persistent error</td> <td class="no"></td> <td class="partial">~</td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Cancellable spinner during PATCH</td> <td class="no"></td> <td class="no"></td> <td class="yes"></td> <td class="yes"></td></tr>
<tr><td>Bulk-many expandable list</td> <td class="no"></td> <td class="no"></td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Auto-focus textarea + mobile scroll</td> <td class="no"></td> <td class="partial">~</td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Toast on success</td> <td class="no"></td> <td class="no"></td> <td class="no"></td> <td class="yes"></td></tr>
<tr><td>Toast on error</td> <td class="no"></td> <td class="no"></td> <td class="partial">opt.</td> <td class="yes"></td></tr>
<tr><td>Survives the required-summary flow</td> <td class="no"></td> <td class="yes"></td> <td class="yes"></td> <td class="no"></td></tr>
</tbody>
</table>
</div>
<script>
/* =========================================================== */
/* Shared icon library (inline SVG) */
/* =========================================================== */
const ICONS = {
check: '<svg class="ico success" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>',
archive: '<svg class="ico muted" viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/><path d="M10 13h4"/></svg>',
pause: '<svg class="ico muted" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M10 9v6M14 9v6"/></svg>',
trash: '<svg class="ico destructive" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>',
paperclip:'<svg class="ico muted" viewBox="0 0 24 24"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>',
info: '<svg class="ico muted" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>',
x: '<svg class="ico" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg>',
undo: '<svg class="ico" viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>',
};
/* =========================================================== */
/* Dialog factories */
/* Each factory: (stage, opts) => void; clears stage, mounts */
/* backdrop + dialog. */
/* =========================================================== */
function clearStage(stage) {
// Remove everything except the toast container (for variant C)
const toastContainer = stage.querySelector('.toast-container');
stage.innerHTML = '';
if (toastContainer) stage.appendChild(toastContainer);
}
function mountBackdrop(stage, dialog) {
const backdrop = document.createElement('div');
backdrop.className = 'backdrop';
backdrop.appendChild(dialog);
stage.appendChild(backdrop);
return backdrop;
}
/* ----- Variant A: Conservative -------------------------------- */
function A_moveDone(stage) {
clearStage(stage);
const dlg = document.createElement('div');
dlg.className = 'dialog';
dlg.innerHTML = `
<h3>Mark this task as done?</h3>
<p class="desc">The worker's claim is released and dependent children become ready.</p>
<div class="body">
<input type="text" placeholder="Completion summary" />
</div>
<div class="actions">
<button data-act="cancel">Cancel</button>
<button data-act="confirm" class="primary">Mark Done</button>
</div>
`;
const backdrop = mountBackdrop(stage, dlg);
dlg.querySelector('[data-act=cancel]').onclick = () => clearStage(stage);
dlg.querySelector('[data-act=confirm]').onclick = () => {
// No validation — just shows another alert if empty (current bug)
const v = dlg.querySelector('input').value.trim();
if (!v) { window.alert('Completion summary is required before marking a task done.'); return; }
clearStage(stage);
};
backdrop.onclick = (e) => { if (e.target === backdrop) clearStage(stage); };
}
/* ----- Variant B: Strong-fit ----------------------------------- */
function B_moveDone(stage) {
clearStage(stage);
const dlg = document.createElement('div');
dlg.className = 'dialog';
dlg.innerHTML = `
<h3>${ICONS.check} Mark this task as done?</h3>
<p class="desc">The worker's claim is released and dependent children become ready.</p>
<div class="body">
<textarea rows="3" placeholder="Completion summary — this is stored as the task result."></textarea>
<div class="helper" data-helper></div>
</div>
<div class="actions">
<button data-act="cancel">Cancel</button>
<button data-act="confirm" class="primary">Mark Done</button>
</div>
`;
const backdrop = mountBackdrop(stage, dlg);
const ta = dlg.querySelector('textarea');
const helper = dlg.querySelector('[data-helper]');
const confirm = dlg.querySelector('[data-act=confirm]');
// Pro suggested either disabled OR error — let's show the error path
confirm.onclick = () => {
if (!ta.value.trim()) {
helper.classList.add('error');
helper.textContent = 'Completion summary is required before marking a task done.';
ta.classList.add('invalid');
ta.focus();
return;
}
// Simulate PATCH
confirm.disabled = true;
confirm.innerHTML = '<span class="spinner"></span>Marking done…';
setTimeout(() => clearStage(stage), 1200);
};
dlg.querySelector('[data-act=cancel]').onclick = () => clearStage(stage);
backdrop.onclick = (e) => { if (e.target === backdrop) clearStage(stage); };
}
/* ----- Variant B-refined: synthesis ---------------------------- */
function B2_moveDone(stage, count) {
clearStage(stage);
const isBulk = count && count > 1;
const label = isBulk ? `${count} selected tasks` : 'this task';
const title = isBulk ? `Mark ${count} tasks as done?` : 'Mark this task as done?';
const dlg = document.createElement('div');
dlg.className = 'dialog';
dlg.innerHTML = `
<h3>${ICONS.check} ${title}</h3>
<p class="desc">The worker's claim is released and dependent children become ready.</p>
<div class="body">
<textarea rows="3" autofocus placeholder="Completion summary for ${label}. This is stored as the task result."></textarea>
<div class="helper" data-helper></div>
${isBulk ? bulkList() : ''}
</div>
<div class="actions">
<button data-act="cancel">Cancel</button>
<button data-act="confirm" class="primary" disabled>Mark Done</button>
</div>
`;
const backdrop = mountBackdrop(stage, dlg);
const ta = dlg.querySelector('textarea');
const helper = dlg.querySelector('[data-helper]');
const confirm = dlg.querySelector('[data-act=confirm]');
const cancel = dlg.querySelector('[data-act=cancel]');
const REQUIRED = 'Completion summary is required before marking a task done.';
// Auto-focus + ensure visible on mobile keyboards (best-effort)
setTimeout(() => {
ta.focus();
ta.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 50);
ta.addEventListener('input', () => {
const valid = ta.value.trim().length > 0;
confirm.disabled = !valid;
if (valid) {
ta.classList.remove('invalid');
helper.classList.remove('error');
helper.textContent = '';
}
});
ta.addEventListener('blur', () => {
if (!ta.value.trim()) {
ta.classList.add('invalid');
helper.classList.add('error');
helper.textContent = REQUIRED;
}
});
confirm.onclick = () => {
if (!ta.value.trim()) {
ta.classList.add('invalid');
helper.classList.add('error');
helper.textContent = REQUIRED;
ta.focus();
return;
}
// Cancel stays enabled; only confirm gets the spinner (GPT-OSS fix)
confirm.disabled = true;
confirm.innerHTML = '<span class="spinner"></span>Marking done…';
setTimeout(() => clearStage(stage), 1200);
};
cancel.onclick = () => clearStage(stage);
backdrop.onclick = (e) => { if (e.target === backdrop) clearStage(stage); };
// Cmd/Ctrl + Enter submits
ta.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !confirm.disabled) {
confirm.click();
}
});
}
/* Helper: bulk list markup for B-refined bulk-many dialogs */
function bulkList() {
const items = [
{ id: 't_8c41', title: 'Refactor kanban dispatcher tick interval' },
{ id: 't_8d12', title: 'Add batch delete endpoint smoke test' },
{ id: 't_8e09', title: 'Document goal_mode lifecycle in user guide' },
];
return `
<div class="bulk-list">
${items.map(t => `
<div class="bulk-item">
<div class="meta">
<div class="title">${t.title}</div>
<div class="id">${t.id}</div>
<textarea rows="1" placeholder="Completion summary for this task…"></textarea>
</div>
</div>
`).join('')}
</div>
`;
}
/* ----- Generic destructive confirm (used by A/B for blocked/archive/delete) -- */
function makeConfirm(stage, opts) {
const { icon, title, desc, confirmLabel, destructive, requireReason } = opts;
const dlg = document.createElement('div');
dlg.className = 'dialog';
dlg.innerHTML = `
<h3>${icon} ${title}</h3>
<p class="desc">${desc}</p>
${requireReason ? `
<div class="body">
<textarea rows="2" placeholder="${requireReason}"></textarea>
<div class="helper" data-helper></div>
</div>
` : ''}
<div class="actions">
<button data-act="cancel">Cancel</button>
<button data-act="confirm" class="${destructive ? 'destructive' : 'primary'}">${confirmLabel}</button>
</div>
`;
const backdrop = mountBackdrop(stage, dlg);
const confirm = dlg.querySelector('[data-act=confirm]');
if (requireReason) {
const ta = dlg.querySelector('textarea');
confirm.disabled = true;
ta.addEventListener('input', () => { confirm.disabled = ta.value.trim().length === 0; });
}
dlg.querySelector('[data-act=cancel]').onclick = () => clearStage(stage);
confirm.onclick = () => {
confirm.disabled = true;
confirm.innerHTML = '<span class="spinner"></span>Working…';
setTimeout(() => clearStage(stage), 800);
};
backdrop.onclick = (e) => { if (e.target === backdrop) clearStage(stage); };
}
const makeA_confirm = (s, o) => makeConfirm(s, o);
const makeB_confirm = (s, o) => makeConfirm(s, o);
const makeB2_confirm = (s, o) => makeConfirm(s, o);
/* ----- Variant C: undo toast ----------------------------------- */
function C_undoToast(stage, message, desc) {
const container = stage.querySelector('.toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `
<div class="title">${ICONS.check} ${message}</div>
${desc ? `<div class="desc">${desc}</div>` : ''}
<div class="row">
<span style="opacity:0.6; font-size: 10px;">Auto-dismiss in 5s</span>
<button class="undo" data-act="undo">Undo</button>
</div>
`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 5200);
toast.querySelector('[data-act=undo]').onclick = () => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 200ms';
setTimeout(() => toast.remove(), 220);
};
}
function C_errorToast(stage, message) {
const container = stage.querySelector('.toast-container');
const toast = document.createElement('div');
toast.className = 'toast error';
toast.dataset.sticky = '1'; // visual marker; doesn't actually stop dismissal
toast.innerHTML = `
<div class="title">${ICONS.x} ${message}</div>
`;
container.appendChild(toast);
// Longer dismiss for screenshot stability
setTimeout(() => toast.remove(), 30000);
}
/* =========================================================== */
/* Dispatch table — fires the right handler per variant */
/* =========================================================== */
const HANDLERS = {
'move-done': () => {
A_moveDone(stage('A'));
B_moveDone(stage('B'));
B2_moveDone(stage('B2'), 1);
C_undoToast(stage('C'), 'Task marked as done', 'The worker claim has been released.');
},
'move-blocked': () => {
const opts = (variant) => ({
icon: variant === 'A' ? '' : ICONS.pause,
title: 'Mark this task as blocked?',
desc: "The worker's claim is released.",
confirmLabel: 'Mark Blocked',
destructive: false,
});
makeA_confirm(stage('A'), opts('A'));
makeB_confirm(stage('B'), opts('B'));
makeB2_confirm(stage('B2'), opts('B2'));
C_undoToast(stage('C'), 'Task marked as blocked', 'Worker claim released. Worker will re-prompt on unblock.');
},
'bulk-delete': () => {
const isBulk = true;
const count = 3;
const titleAll = isBulk
? `Permanently delete ${count} selected tasks?`
: 'Permanently delete this task?';
const opts = (variant) => ({
icon: variant === 'A' ? '' : ICONS.trash,
title: titleAll,
desc: 'This cannot be undone.',
confirmLabel: isBulk ? `Delete ${count} tasks` : 'Delete',
destructive: true,
requireReason: variant === 'B2' ? 'Optional: why are these being deleted?' : null,
});
makeA_confirm(stage('A'), opts('A'));
makeB_confirm(stage('B'), opts('B'));
// B2 gets the bulk list version
clearStage(stage('B2'));
const dlg = document.createElement('div');
dlg.className = 'dialog';
dlg.innerHTML = `
<h3>${ICONS.trash} Permanently delete 3 selected tasks?</h3>
<p class="desc">This cannot be undone.</p>
<div class="body">${bulkList()}</div>
<div class="actions">
<button data-act="cancel">Cancel</button>
<button data-act="confirm" class="destructive">Delete 3 tasks</button>
</div>
`;
const backdrop = mountBackdrop(stage('B2'), dlg);
dlg.querySelector('[data-act=cancel]').onclick = () => clearStage(stage('B2'));
dlg.querySelector('[data-act=confirm]').onclick = () => {
dlg.querySelector('[data-act=confirm]').disabled = true;
dlg.querySelector('[data-act=confirm]').innerHTML = '<span class="spinner"></span>Deleting…';
setTimeout(() => clearStage(stage('B2')), 1200);
};
backdrop.onclick = (e) => { if (e.target === backdrop) clearStage(stage('B2')); };
C_errorToast(stage('C'), 'Bulk delete is destructive — undo toast not appropriate.');
},
'archive-board': () => {
const msg = "Archive board 'atm10-server'? It will be moved to boards/_archived/ so you can recover it later. Tasks on this board will no longer appear anywhere in the UI.";
const opts = (variant) => ({
icon: variant === 'A' ? '' : ICONS.archive,
title: "Archive board 'atm10-server'?",
desc: msg,
confirmLabel: 'Archive',
destructive: true,
});
makeA_confirm(stage('A'), opts('A'));
makeB_confirm(stage('B'), opts('B'));
makeB2_confirm(stage('B2'), opts('B2'));
C_errorToast(stage('C'), 'Archive is destructive — undo toast not appropriate.');
},
'remove-attachment': () => {
const opts = (variant) => ({
icon: variant === 'A' ? '' : ICONS.paperclip,
title: 'Remove this attachment?',
desc: 'The file will be unlinked from this task. Other references are unaffected.',
confirmLabel: 'Remove',
destructive: true,
});
makeA_confirm(stage('A'), opts('A'));
makeB_confirm(stage('B'), opts('B'));
makeB2_confirm(stage('B2'), opts('B2'));
C_undoToast(stage('C'), 'Attachment removed', 'design-spec-v3.pdf');
},
'error': () => {
// Variant C: error toast is the only place toasts really shine.
[stage('A'), stage('B'), stage('B2')].forEach(s => {
const banner = document.createElement('div');
banner.style.cssText = 'position:absolute; top:12px; left:12px; right:12px; padding:10px 12px; background: color-mix(in srgb, var(--destructive) 10%, var(--bg)); border: 1px solid var(--destructive); color: var(--destructive); border-radius: var(--radius-sm); font-size: 12px;';
banner.textContent = 'Move failed: HTTP 409 — task is in status "todo", only running/ready/blocked can be completed.';
s.appendChild(banner);
});
C_errorToast(stage('C'), 'Move failed: HTTP 409');
},
'clear': () => {
[stage('A'), stage('B'), stage('B2'), stage('C')].forEach(s => clearStage(s));
},
};
function stage(name) { return document.querySelector(`[data-stage="${name}"]`); }
/* =========================================================== */
/* Wire up page-level triggers */
/* =========================================================== */
document.querySelectorAll('header.page .controls button').forEach(btn => {
btn.addEventListener('click', () => {
const handler = HANDLERS[btn.dataset.trigger];
if (handler) handler();
});
});
</script>
</body>
</html>
+208
View File
@@ -0,0 +1,208 @@
# Multiplexing Gateway
One gateway process can serve every profile in the install. The mode is opt-in
(`gateway.multiplex_profiles`, default `false`), and everything it changes
reverts the moment the flag is off. This document is the design rationale
referenced from `agent/secret_scope.py` ("Workstream A"): what is isolated per
profile, the mechanism that isolates it, and what deliberately stays
process-global.
## Overview
Without multiplexing, one gateway process serves exactly one profile — its
`.env`, sessions, skills, and platform adapters — and multi-profile installs
run one process per profile. Multiplexing collapses that into a single
process: the default profile plus every served named profile get their own
adapters, secrets, sessions, and cron ticks, while sharing one event loop, one
HTTP listener, one process lock, and one status surface.
The design constraint that shapes everything below: **profile A's turns must
never observe profile B's state**. Secrets, homes, sessions, and adapter lanes
are isolated per profile; anything that cannot yet be isolated fails closed or
is documented as a known limitation at the end of this document.
## The mode flag
- Config: `gateway.multiplex_profiles: true` (also accepted at top level).
Parsed in `gateway/config.py` with precedence env > config > default.
- Env override: `GATEWAY_MULTIPLEX_PROFILES` accepts explicit truthy/falsy
tokens only; a blank or unrecognized value returns "no override" so an empty
deployment secret cannot shadow a config opt-in.
- At startup, `GatewayRunner.__init__` calls
`agent.secret_scope.set_multiplex_active(...)` once. `_MULTIPLEX_ACTIVE` is
a plain module global, not a contextvar: it describes the deployment mode,
not a per-task value. Its only job is to arm the fail-closed behavior in
`get_secret()`.
## Scope composition
Every inbound event composes the same two context-local scopes before any
profile-owned code runs:
```
platform event
profile_routes match ──► served-set check ──► SessionSource.profile stamped
│ (gateway/profile_routing.py)
_profile_runtime_scope(profile_home) (gateway/run.py)
├── set_hermes_home_override(home) config / state.db / skills /
│ memory / sessions resolve here
└── set_secret_scope(profile .env + secret sources)
│ provider keys, platform tokens
agent turn (worker thread via copy_context())
scope unwound in finally
```
`_profile_runtime_scope` wraps every seam where profile-owned code executes:
secondary adapter startup, connect and reconnect, the primary platform event
handler, inbound preprocessing, `/model` and session-info resolution,
background tasks, and the agent turn itself. Config reloads run under the
default profile's scope so global gateway settings (`#64674`) resolve
consistently.
Both scopes are `contextvars`, so they propagate into executor worker threads
via `copy_context()` and unwind deterministically — nothing is written to
`os.environ`, ever.
## Workstream A: context-local secret scope
`agent/secret_scope.py` exists because the obvious implementation — union all
profile `.env` files into `os.environ` — leaks profile A's keys into profile
B's turns and into every subprocess spawned with `env=dict(os.environ)`.
- `build_profile_secret_scope(home)` merges the profile's `.env` with its
configured secret sources, skipping globals.
- `set_secret_scope(mapping)` installs it for the current task.
- `get_secret(name)` resolves: global allowlist → active scope → fallback.
The fallback is the load-bearing part:
- multiplexing **off**: reads `os.environ`, so single-profile gateways and
every non-gateway caller behave exactly as before;
- multiplexing **on**, no scope installed: **raises `UnscopedSecretError`**
rather than silently reading the process environment. An un-migrated call
site fails loud at that exact line instead of leaking another profile's
value.
- A small allowlist (`HERMES_HOME`, `HERMES_PROFILE`, proxy settings,
`API_SERVER_*` listener settings — but deliberately not `API_SERVER_KEY`)
stays global because those describe the process, not a profile.
Because the per-turn `.env` reload is a no-op under multiplexing, rotated
credentials are picked up through the profile scope on the next turn — never
via `os.environ`. This holds at the loader boundary, not just the gateway's
reload helper: `hermes_cli.env_loader.load_hermes_dotenv` skips the
process-global load whenever multiplexing is active *and* a profile-home
override is installed (import-time and cron callers hit it mid-turn), while
still hydrating the profile's external secret sources into its private
snapshot (`#77562`). The unscoped startup load is unchanged.
The same scope-authoritative rule covers the other `os.environ` seams a
routed turn can reach: `${VAR}` / `${env:VAR}` references in a profile's
`config.yaml` resolve through `get_secret` when a scope is installed
(`#84079`), and `.env` writes made under a scope (`save_env_value`, e.g. a
`/pair` grant mirror) update the installed scope mapping instead of the
process environment (`#88441`).
## The HERMES_HOME override
`hermes_constants.py` holds a context-local override consulted by
`get_hermes_home()` before the `HERMES_HOME` env var. Everything that resolves
paths through it — config, `state.db`, skills, memory, SOUL, sessions, kanban,
goals, plugin discovery, MCP startup — follows the active profile
automatically. `get_process_hermes_home()` exists for the few machine-level
assets that must not follow the override. `hermes_home_key()` gives
per-home registries a stable scope key. A one-shot warning (`#18594`) fires if
profile-scoped code runs without the override where one is expected.
## Inbound routing
`gateway.profile_routes` maps `(platform, guild_id, chat_id, thread_id)` to a
profile; matching is conjunctive, most-specific-first, with parent-chain chat
matching for threads. Routing only runs when multiplexing is active, and a
matched route whose target is outside the served set is rejected (the event is
dropped, not misdelivered). Full schema and matching rules:
`docs/profile-routing.md`.
## Serving selected profiles
`profiles_to_serve(multiplex, profile_allowlist)` in `hermes_cli/profiles.py`
is the single chokepoint for which profiles a multiplexer serves: default plus
every valid profile directory, optionally filtered by allowlist. A malformed
allowlist fails safe to default-only. The served set gates adapter startup,
cron ticking (`#69377`), `/p/<profile>/` HTTP admission, route eligibility,
and the runtime status surface. An excluded profile stays installed and can
still run its own standalone gateway.
## Per-profile persistence
`SessionStore` binds no database handle at construction (`#88532`). Session
DB handles are resolved at call time through the active HERMES_HOME override —
one cached handle per resolved `profiles/<name>/state.db` — so sessions land
in the owning profile's store even when the store object itself is shared.
Pairing stores are constructed per served profile.
## Per-bot session lanes
Session keys are namespaced by profile (`agent:main` for default,
`agent:<name>` for named profiles). Adapters carry `_owner_profile`
(installed at adapter configuration time, before any inbound event) because
adapter ingress runs before `SessionSource.profile` is stamped;
`_session_key_profile` resolves source stamp → owner profile → store
resolver. Text/media batching, active-session tracking, and the busy-session
guard are all keyed per lane, so two bots sharing a chat do not share a
session lane.
## Control plane
Desktop plugins reach the gateway only through the ws JSON-RPC door, so
profile enumeration and configuration live in
`tui_gateway/methods_profiles.py`: `profiles.list`, `profiles.create`,
`profiles.describe`, `profiles.configure`, `profiles.set_asset`,
`profiles.get_asset`. Reads and writes run under the target profile's
HERMES_HOME override. Asset writes are atomic, type- and size-capped.
## Failure modes
- Fatal at startup: multiplex config errors and a secondary profile enabling a
port-binding platform (`MultiplexConfigError`,
`SecondaryPortBindingConfigError`) — one shared HTTP listener is owned by
the default profile.
- Skipped, not fatal: a single misconfigured secondary adapter is skipped with
a warning rather than taking down the multiplexer.
- Fail-closed: unscoped `get_secret()` under multiplexing raises; a routed
event targeting an unserved profile is dropped; an unscoped `/p/` request
enters the default profile's scope (`#61276`) rather than an undefined one.
- Fallback: an external `cron.provider` does not support multiplexing and
falls back to the built-in ticker with a warning.
## Known limitations
Process-global state that is not yet profile-scoped:
| Surface | State at time of writing |
| --- | --- |
| MCP discovery and tool registration | Process-global; the first profile to build an agent wins the discovery slot. Full per-profile MCP registries are tracked in `#67605`. |
| Terminal / sandbox env (`TERMINAL_*`) | Global by allowlist; tools read it from the process environment. |
| Built-in tool registry | Built-ins are process-global; plugin-registered tools are overlaid per profile via `hermes_home_key()`. |
| Provider/capability registries | Same hybrid overlay pattern (browser, image-gen, TTS, transcription, video-gen, web-search, secret sources). |
| HTTP listener, relay ingress, process lock | One per process, owned by the default/active profile. Per-profile `runtime_status.json` is still written. |
## Non-goals
Multiplexing isolates *profiles*; it does not authenticate or authorize *end
users*. A profile is a configuration, not a person: the gateway trusts its
transport and its routing table to decide which profile an event belongs to.
Request-level identity and per-user authorization above the profile layer are
out of scope for this document.
## Related
- `docs/profile-routing.md` — inbound routing schema and matching rules.
- `website/docs/user-guide/multi-profile-gateways.md` — user-facing guide,
including the standalone one-gateway-per-profile alternative.
- `agent/secret_scope.py`, `hermes_constants.py`, `gateway/profile_routing.py`,
`gateway/run.py` (`_profile_runtime_scope`), `hermes_cli/profiles.py`
(`profiles_to_serve`), `gateway/session.py`, `tui_gateway/methods_profiles.py`.
+146
View File
@@ -0,0 +1,146 @@
# Profile Builder — Dashboard-Native, Full-Featured Profile Creation
Status: design proposal (not yet implemented)
Author: drafted for Teknium
Supersedes: PR #31781 (prompt_toolkit `hermes profile wizard`)
## Why this, not the CLI wizard
PR #31781 added a keyboard-driven `hermes profile wizard` in the terminal.
The decision is to **not** build the profile-creation experience in the CLI.
The dashboard already owns mature, separate pages for every element a profile
needs, and a profile is just a HERMES_HOME directory — so the dashboard is the
right home for a full-featured builder, and it can reuse everything that
already exists.
A profile = a full `~/.hermes/profiles/<name>/` directory with its own:
- `config.yaml` — holds `model`/`provider`, `mcp_servers`, enabled skills
- `skills/` — physical SKILL.md files (built-in seed + optional + hub installs)
- `.env` — secrets
- `SOUL.md` / `USER.md` — identity
So per-profile scoping of Model, MCPs, and Skills is **native** — no data-model
change needed. The gap is purely UX: creation today is a thin modal
(name + clone + model + description), and you can only compose skills/MCPs
*after* the profile exists, by visiting other pages and remembering to scope
them.
## What already exists (reuse, don't rebuild)
| Element | Existing page | Existing API | Profile-scopable? |
|---|---|---|---|
| Name / Description | ProfilesPage create modal | `POST /api/profiles` (`create_profile`) | yes (args) |
| Model + Provider | ModelsPage | `_write_profile_model(profile_dir, …)` | yes — HERMES_HOME override, already wired into create endpoint |
| MCPs | McpPage | `mcp_config._save_mcp_server` + `/api/mcp/catalog` | yes — wrap with HERMES_HOME override |
| Skills (built-in/optional) | SkillsPage | `GET /api/skills`, `/api/skills/toggle` | yes — config write |
| Skills (hub) | SkillsPage | `/api/skills/hub/search`, `/api/skills/hub/install` | **only via subprocess** — see seam #1 |
## Two architectural seams found while grounding this design
These are load-bearing — they change the implementation, not just the polish.
### Seam #1 — hub-skill install cannot use the HERMES_HOME override
`tools/skills_hub.py` binds `SKILLS_DIR = HERMES_HOME / "skills"` at **module
import time**. The context-local `set_hermes_home_override()` swap (which makes
`_write_profile_model` and the MCP write land in the target profile) does NOT
retroactively rebind that already-imported module global. So a data-layer wrap
of hub install would write into the dashboard's *own* active profile, not the
new one.
The correct mechanism is the existing subprocess path: `_spawn_hermes_action`
runs `python -m hermes_cli.main <subcommand>`, and `_apply_profile_override()`
re-reads `sys.argv` at import in the fresh child. Prepend `-p <profile>`:
```python
_spawn_hermes_action(["-p", profile, "skills", "install", identifier], "skills-install")
```
A fresh subprocess re-imports `skills_hub` with the profile's HERMES_HOME bound
from the start, so `SKILLS_DIR` resolves to `<profile>/skills/`. Correct by
construction.
### Seam #2 — hub installs are async, so create cannot be fully atomic
Built-in/optional skill enabling and MCP writes are **synchronous config ops**
and can be part of the create call. Hub installs are long-running git fetches
spawned detached (`_spawn_hermes_action` returns a PID immediately). So the
create flow is:
1. `create_profile()` — make the dir (synchronous)
2. write model (synchronous, HERMES_HOME override)
3. write selected MCP servers (synchronous, HERMES_HOME override)
4. seed/enable selected built-in + optional skills (synchronous)
5. spawn `hermes -p <profile> skills install <id>` per hub skill (async, returns PIDs)
Steps 14 commit before the response; step 5 returns a list of action PIDs the
UI polls (same pattern as today's SkillsPage hub install). The builder's
"Review → Create" returns `{ok, name, path, hub_installs: [{id, pid}]}` and the
final screen shows live install progress for the hub skills.
## Proposed backend change (small, follows existing patterns)
Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
```python
class ProfileCreate(BaseModel):
name: str
clone_from: Optional[str] = None
# Backward compatibility for older dashboard/desktop clients.
clone_from_default: bool = False
clone_all: bool = False
no_skills: bool = False
description: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
# NEW — all optional, all best-effort post-create (profile already exists)
mcp_servers: List[MCPServerCreate] = [] # synchronous, HERMES_HOME override
builtin_skills: List[str] = [] # synchronous enable/seed
hub_skills: List[str] = [] # async spawn, returns PIDs
```
The endpoint already does best-effort post-create steps (`seed_profile_skills`,
`_write_profile_model`). Add two more best-effort blocks (MCP write, hub-skill
spawn) in the same style — a failure in any of them must not 500 the create,
since the profile dir already exists and the user can fix it from the relevant
page afterward. Mirror `_write_profile_model`'s HERMES_HOME-override helper for
the MCP write (`_write_profile_mcp_servers(profile_dir, servers)`).
## Proposed frontend — dedicated builder page `/profiles/new`
A full page (not the cramped modal), stepped, each step reusing the existing
page's component + API, targeted at the new profile:
```
① Identity Name + Description (+ optional clone-from existing profile)
② Model Provider + model picker (reuse ModelsPage picker)
③ Skills Tabs: Built-in · Optional · Hub-search
multi-select; "Start from default bundle" preset button
④ MCPs Tabs: Catalog browse · Manual add (reuse McpPage form)
⑤ Review Blueprint preview → Create
→ progress screen for async hub installs
```
Nothing writes to disk until ⑤.
## Open product decisions (need Teknium)
1. **Skills seeding default.** Fresh profiles auto-seed the default bundle
today. In the builder, should the skill step **replace** the bundle (pick
exactly what you want; offer a "start from default bundle" preset) or
**augment** it? Recommendation: replace + preset button.
2. **Page vs richer modal.** Dedicated `/profiles/new` page (room to grow:
SOUL editing, multi-agent fleets later) vs a bigger create modal on
ProfilesPage. Recommendation: dedicated page — matches "full-featured / way
more options."
## Verification plan (when built)
- Backend E2E with isolated HERMES_HOME: POST a full create body
(name + model + 2 MCPs + 3 builtin skills + 1 hub skill), assert the new
profile dir has the model in config.yaml, both MCP servers in config.yaml,
the builtin skills enabled, and a spawned PID for the hub skill. Negative:
a bad MCP entry must not 500 the create.
- `cd web && npm run build` (no JS test suite in web/).
- Targeted: `pytest tests/<web_server profile tests> -k profile_create`.