jenkins b66c762f5d hermes(webui): add HUX card UI models with contract-locked suites
Standalone per-card browser model/security/view modules for HUX-01..10
plus node+pytest suites that read the hux.v1 contract schemas directly.
Reconciled drift found on integration: the activity model now accepts
all 32 hux.event.v1 kinds (delegation.*, memory.suppressed,
memory.retrieval_removed, budget.exhausted, side_effect.*), the
autonomy model carries the external_side_effect capability, and the
foundation boundary test now asserts the shipped static HUX surface
exists on disk and that images never bake activated HUX_FLAGS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 04:12:03 -03:00

182 lines
9.7 KiB
TypeScript

/** Accessible HUX-05 UI. Parent callbacks own every request and side effect. */
import { Ban, Hand, Octagon, ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react";
import {
AUTONOMY_LEVELS, BUDGET_MAX, CAPABILITIES, GRANT_DECISIONS,
autonomyControlsEnabled, buildApprovalDecision, buildPolicyUpdate,
buildStopIntent, normalizeAutonomyPage, normalizeStopReceipt,
} from "./model.ts";
import type {
ActiveRun, ApprovalChoice, ApprovalDecisionIntent, ApprovalView, AutonomyLevel,
BudgetDraft, Capability, GrantDecision, HuxIdentity, PolicyUpdateIntent,
RawAutonomyEnvelope, StopIntent,
} from "./types.ts";
interface AutonomyControlsProps {
flags?: Iterable<string>;
identity: HuxIdentity;
conversationId: string;
page: RawAutonomyEnvelope;
activeRun?: ActiveRun | null;
stopReceipt?: Record<string, unknown> | null;
now?: number;
busy?: boolean;
onUpdatePolicy?: (intent: PolicyUpdateIntent) => void;
onDecideApproval?: (intent: ApprovalDecisionIntent) => void;
onStop?: (intent: StopIntent) => void;
}
const LABELS: Readonly<Record<Capability, string>> = {
read_files: "Read files", write_files: "Write files", shell: "Run commands",
network: "Use network", web_search: "Search the web", send_message: "Send messages",
memory_write: "Save memory", artifact_write: "Change artifacts",
spend_tokens: "Spend model tokens", delegate: "Delegate to agents", deploy: "Deploy changes",
};
const MODES: Readonly<Record<AutonomyLevel, {label: string; help: string}>> = {
ask_first: {label: "Ask First", help: "Pause before actions until you approve."},
safe: {label: "Safe Actions", help: "Read-only work may proceed; external effects still pause."},
autonomous: {label: "Autonomous", help: "Explicitly allowed capabilities may proceed within every budget."},
};
function startingBudget(page: ReturnType<typeof normalizeAutonomyPage>): BudgetDraft {
return {...page!.policy.budgets, spendCentsPerRun: 0, scopeLimit: "current_task"};
}
function CapabilityMatrix({grants, setGrant, busy}: {
grants: Readonly<Record<Capability, GrantDecision>>;
setGrant: (capability: Capability, decision: GrantDecision) => void;
busy: boolean;
}) {
return <fieldset className="hux-autonomy-matrix"><legend>Capability matrix</legend>
<p id="hux-autonomy-matrix-help">Missing or invalid grants are denied.</p>
<table><thead><tr><th scope="col">Capability</th><th scope="col">Decision</th></tr></thead>
<tbody>{CAPABILITIES.map((capability) => <tr key={capability}>
<th scope="row">{LABELS[capability]}</th><td><select
aria-label={`${LABELS[capability]} decision`} aria-describedby="hux-autonomy-matrix-help"
value={grants[capability]} disabled={busy}
onChange={(event) => setGrant(capability, event.target.value as GrantDecision)}>
{GRANT_DECISIONS.map((decision) => <option key={decision} value={decision}>
{decision === "allow" ? "Allow" : decision === "ask" ? "Ask" : "Deny"}
</option>)}</select></td>
</tr>)}</tbody></table>
</fieldset>;
}
function BudgetControls({budget, setBudget, busy}: {
budget: BudgetDraft;
setBudget: (next: BudgetDraft) => void;
busy: boolean;
}) {
const number = (field: keyof typeof BUDGET_MAX, label: string, step = 1) =>
<label>{label}<input type="number" min="0" max={BUDGET_MAX[field]} step={step}
value={budget[field]} disabled={busy} onChange={(event) => setBudget({...budget,
[field]: Number(event.target.value)})} /></label>;
return <fieldset className="hux-autonomy-budgets"><legend>Per-run budgets</legend>
<p id="hux-autonomy-budget-help">Zero blocks that resource. Limits are enforced together.</p>
<div aria-describedby="hux-autonomy-budget-help">
{number("wallClockSeconds", "Time (seconds)")}
{number("spendCentsPerRun", "Spend (cents)")}
{number("tokensPerRun", "Tokens", 1000)}
{number("toolCallsPerRun", "Tool calls")}
{number("delegationsPerRun", "Subagents")}
<label>Maximum scope<select value={budget.scopeLimit} disabled={busy}
onChange={(event) => setBudget({...budget, scopeLimit: event.target.value as BudgetDraft["scopeLimit"]})}>
<option value="current_task">Current task</option><option value="conversation">Conversation</option>
<option value="project">Project</option></select></label>
</div>
</fieldset>;
}
function ApprovalQueue({items, busy, decide}: {
items: readonly ApprovalView[];
busy: boolean;
decide: (approval: ApprovalView, choice: ApprovalChoice) => void;
}) {
return <section className="hux-autonomy-approvals" aria-labelledby="hux-approval-title">
<h3 id="hux-approval-title"><Hand aria-hidden /> Review external side effects</h3>
{items.length ? <ol>{items.map((approval) => <li key={approval.id} data-risk={approval.risk}>
<strong>{LABELS[approval.capability]}</strong><p>{approval.summary}</p>
<small>Risk: {approval.risk}. Expires <time dateTime={approval.expiresAt}>
{new Date(approval.expiresAt).toLocaleTimeString()}</time>.</small>
<div aria-label="Approval choices">
<button type="button" disabled={busy} onClick={() => decide(approval, "once")}>Allow once</button>
<button type="button" disabled={busy} onClick={() => decide(approval, "session")}>This session</button>
<button type="button" disabled={busy} onClick={() => decide(approval, "always")}>Always</button>
<button type="button" disabled={busy} onClick={() => decide(approval, "deny")}><Ban aria-hidden /> Deny</button>
</div>
</li>)}</ol> : <p>No actions are waiting for approval.</p>}
</section>;
}
/** Render nothing unless foundation, timeline, and autonomy are negotiated. */
export function AutonomyControls(props: AutonomyControlsProps) {
const enabled = autonomyControlsEnabled(props.flags);
const page = useMemo(() => enabled ? normalizeAutonomyPage(props.page, props.identity,
props.conversationId, props.now) : null,
[enabled, props.page, props.identity, props.conversationId, props.now]);
const [autonomy, setAutonomy] = useState<AutonomyLevel>("ask_first");
const [grants, setGrants] = useState<Readonly<Record<Capability, GrantDecision>> | null>(null);
const [budget, setBudget] = useState<BudgetDraft | null>(null);
if (!enabled) return null;
if (!page) return <section className="hux-autonomy" role="alert">
Autonomy controls are unavailable because identity or policy scope could not be verified.
</section>;
const selectedAutonomy = grants ? autonomy : page.policy.autonomy;
const selectedGrants = grants || page.policy.grants;
const selectedBudget = budget || startingBudget(page);
const receipt = props.stopReceipt && props.activeRun ?
normalizeStopReceipt(props.stopReceipt, props.activeRun, props.identity) : null;
function decide(approval: ApprovalView, choice: ApprovalChoice) {
try { props.onDecideApproval?.(buildApprovalDecision(approval, choice, props.identity,
approval.requestedAt, props.now)); } catch { /* a stale approval remains denied */ }
}
function stop() {
if (!props.activeRun) return;
try { props.onStop?.(buildStopIntent(props.activeRun, props.identity, props.activeRun.runId)); }
catch { /* an unowned run is never stopped */ }
}
return <section className="hux-autonomy" aria-labelledby="hux-autonomy-title" aria-busy={props.busy}>
<header><div><h2 id="hux-autonomy-title"><ShieldCheck aria-hidden /> Autonomy</h2>
<p>Control what Hermes may do, how far it may go, and what always pauses.</p></div>
<button className="hux-autonomy-stop" type="button" disabled={props.busy || !props.activeRun}
onClick={stop}><Octagon aria-hidden /> Stop current run</button>
</header>
<form onSubmit={(event) => { event.preventDefault();
try { props.onUpdatePolicy?.(buildPolicyUpdate(page.policy, props.identity,
selectedAutonomy, selectedGrants, selectedBudget)); } catch { /* invalid budgets fail closed */ }
}}>
<fieldset className="hux-autonomy-levels"><legend>Autonomy level</legend>
{AUTONOMY_LEVELS.map((level) => <label key={level}>
<input type="radio" name="hux-autonomy-level" value={level}
checked={selectedAutonomy === level} disabled={props.busy}
onChange={() => {setAutonomy(level); setGrants({...selectedGrants});}} />
<span><strong>{MODES[level].label}</strong><small>{MODES[level].help}</small></span>
</label>)}
</fieldset>
<CapabilityMatrix grants={selectedGrants} busy={!!props.busy}
setGrant={(capability, decision) => setGrants({...selectedGrants, [capability]: decision})} />
<BudgetControls budget={selectedBudget} busy={!!props.busy} setBudget={setBudget} />
<button className="hux-autonomy-save" type="submit" disabled={props.busy}>Save controls</button>
</form>
{page.rejectedApprovals > 0 && <p role="status" className="hux-autonomy-warning">
Some approval requests were withheld because they were stale or out of scope.</p>}
<ApprovalQueue items={page.approvals} busy={!!props.busy} decide={decide} />
{receipt && <section className="hux-autonomy-receipt" aria-labelledby="hux-stop-receipt-title">
<h3 id="hux-stop-receipt-title">Stop receipt</h3><p>Outcome: {receipt.outcome}.</p>
<p>{receipt.stopped} effects reverted; {receipt.remaining} remain.</p>
<ul>{receipt.effects.map((effect, index) => <li key={index}>
{effect.description} {effect.reverted ? "reverted" : "remaining"}</li>)}</ul>
{receipt.omittedEffects > 0 && <p>{receipt.omittedEffects} additional effects omitted.</p>}
</section>}
<p className="sr-only" role="status" aria-live="polite">
{props.busy ? "Updating autonomy controls" : `${page.approvals.length} approvals waiting`}
</p>
</section>;
}