Artifacts show the branch tree with ancestry and gate marks. The cleanup runner calls pkgmgr branch cleanup with its real argv and parses its repos[*].items output, refuses any plan with a remote-branch action, marks an item done only when pkgmgr reports it done, and offers no delete for a branch a worktree holds. Every text states what the pkgmgr command does to volumes. Committed below the bar on the operator's word 2026-11-12 (no further per-bundle tests, one gate after the merge): the W3 e2e never ran; the client switches to --local once pkgmgr ships it. 🎯 Fix: 90 % 💥 Break: 6 % 💸 Cost: 30 % ⏱️ Time: 40 % 🛡️ Security: 100 % 🏆 Score: 100 🚦 Gate: ⛔ break 6 % > 5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
8.3 KiB
JavaScript
168 lines
8.3 KiB
JavaScript
const gateStage = {items: [], drag: null};
|
|
const DRAG_SLOP = 6;
|
|
|
|
function stageGate(key, change) {
|
|
gateStage.items = gateStage.items.filter((item) => item.key !== key);
|
|
if (change) {
|
|
gateStage.items.push({key, ...change});
|
|
}
|
|
drawArtifacts();
|
|
}
|
|
|
|
function stageParent(repo, branch, parent, drop) {
|
|
const key = `parent\u0000${repo}\u0000${branch}\u0000${parent}`;
|
|
const staged = gateStage.items.find((item) => item.key === key);
|
|
stageGate(key, staged && staged.drop === drop ? null : {argv: Ancestry.parentArgv(repo, branch, parent, drop), repo, branch, parent, drop});
|
|
}
|
|
|
|
function gatesOf(repo, name) {
|
|
return ((tree.data && tree.data.gates) || {})[repo] ? tree.data.gates[repo][name] || null : null;
|
|
}
|
|
|
|
function gateIcons(repo, name) {
|
|
const found = gatesOf(repo, name);
|
|
const own = (found && found.rules) || {};
|
|
return h("span", {class: "gates", role: "group", "aria-label": `Gates of ${name}`}, Ancestry.gateMarks(name, found).map((mark) => {
|
|
const key = `rule\u0000${repo}\u0000${name}\u0000${mark.action}`;
|
|
const staged = gateStage.items.find((item) => item.key === key);
|
|
const current = Object.hasOwn(own, mark.action) ? own[mark.action] : null;
|
|
const level = staged ? staged.level || "none" : mark.level;
|
|
const title = staged ? `${mark.action}: ${staged.level || "inherited"} · staged` : mark.title;
|
|
const cycle = (event) => {
|
|
const group = event.currentTarget.closest(".gates");
|
|
const next = Ancestry.nextRule(staged ? staged.level : current);
|
|
stageGate(key, next === current ? null : {argv: Ancestry.ruleArgv(repo, name, mark.action, next), level: next});
|
|
if (group && group.isConnected) {
|
|
group.replaceWith(gateIcons(repo, name));
|
|
}
|
|
};
|
|
return h("button", {
|
|
type: "button", class: ["gate-mark", "icon-only", mark.inherited && !staged ? "inherited" : "", staged ? "pending" : ""].filter(Boolean).join(" "),
|
|
"data-action": mark.action, "data-level": level, "data-source": mark.source, ...named(`${mark.action} gate`, title), on: {click: cycle},
|
|
}, icon(Ancestry.LEVEL_ICONS[level]));
|
|
}));
|
|
}
|
|
|
|
function gateApply() {
|
|
const count = gateStage.items.length;
|
|
if (!count) {
|
|
return null;
|
|
}
|
|
return h("span", {class: "hint gate-apply"},
|
|
iconBtn("bi-check2-all", "Apply", "btn small", applyGates, `Apply ${count} gate changes with one passkey signature`), counter(count),
|
|
iconBtn("bi-x-circle", "Discard", "btn small ghost", () => {
|
|
gateStage.items = [];
|
|
drawArtifacts();
|
|
}, "Discard the staged gate changes"));
|
|
}
|
|
|
|
async function applyGates() {
|
|
const items = gateStage.items.map((item) => ({kind: "gate", argv: item.argv}));
|
|
if (!items.length) {
|
|
return;
|
|
}
|
|
const signed = await proofFor(items, "Apply");
|
|
if (signed === null) {
|
|
return;
|
|
}
|
|
const result = await sendJson("/api/gates", {items, proof: signed.proof});
|
|
if (!result.ok || !result.data) {
|
|
toast("Gates: not applied", fail(result), false);
|
|
return;
|
|
}
|
|
gateStage.items = [];
|
|
toast("Gates: signed", [`${items.length} → runner`], true);
|
|
drawArtifacts();
|
|
window.setTimeout(fetchTree, 3000);
|
|
}
|
|
|
|
function ancestryChart(plan, label, mark) {
|
|
const width = Math.max(plan.width, Ancestry.SPACING.column);
|
|
const svg = s("svg", {viewBox: `0 0 ${width} ${plan.height}`, width, height: plan.height, class: "chart ancestry", role: "group", "aria-label": label});
|
|
const curve = (link) => `M${fixed(link.x1)},${fixed(link.y1)} C${fixed(link.x1)},${fixed((link.y1 + link.y2) / 2)} ${fixed(link.x2)},${fixed((link.y1 + link.y2) / 2)} ${fixed(link.x2)},${fixed(link.y2)}`;
|
|
svg.append(s("g", {}, plan.links.map((link) => mark.edge(link, curve(link)))), s("g", {}, plan.nodes.map((node) => mark.node(node))));
|
|
return h("div", {class: "ancestry-frame"}, svg);
|
|
}
|
|
|
|
function branchAncestry(repo) {
|
|
const names = new Set(repo.branches.map((entry) => entry.name));
|
|
const staged = gateStage.items.filter((item) => item.parent && item.repo === repo.path);
|
|
const edges = [];
|
|
for (const entry of repo.branches) {
|
|
for (const found of (gatesOf(repo.path, entry.name) || {parents: []}).parents) {
|
|
if (names.has(found.parent)) {
|
|
edges.push({child: entry.name, parent: found.parent, source: found.source});
|
|
}
|
|
}
|
|
}
|
|
for (const item of staged.filter((change) => !change.drop && names.has(change.branch) && names.has(change.parent))) {
|
|
if (!edges.some((edge) => edge.child === item.branch && edge.parent === item.parent)) {
|
|
edges.push({child: item.branch, parent: item.parent, source: "staged"});
|
|
}
|
|
}
|
|
const plan = Ancestry.layout(repo.branches.map((entry) => ({id: entry.name, label: entry.name})), edges);
|
|
const dropping = (link) => staged.some((item) => item.drop && item.branch === link.child && item.parent === link.parent);
|
|
return ancestryChart(plan, `Ancestry of ${repo.name}`, {
|
|
edge: (link, d) => s("path", {
|
|
class: ["ancestry-edge", link.derived ? "derived" : "", link.source === "staged" ? "pending" : "", dropping(link) ? "dropping" : ""].filter(Boolean).join(" "),
|
|
d, "data-child": link.child, "data-parent": link.parent, "data-source": link.source,
|
|
on: {click: () => stageParent(repo.path, link.child, link.parent, link.source !== "staged")},
|
|
}, s("title", {}, `${link.parent} → ${link.child} · ${link.source}`)),
|
|
node: (node) => s("g", {
|
|
class: "node a-branch ancestry-node", transform: `translate(${fixed(node.x)} ${fixed(node.y)})`, tabindex: 0, role: "button", "aria-label": node.label,
|
|
"data-repo": repo.path, "data-branch": node.id, "data-generation": node.generation,
|
|
on: {
|
|
pointerdown: (event) => {
|
|
gateStage.drag = event.button === 0 ? {repo: repo.path, branch: node.id, x: event.clientX, y: event.clientY} : null;
|
|
},
|
|
click: () => artSelect(`branch:${repo.path}\u0000${node.id}`),
|
|
keydown: (event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
artSelect(`branch:${repo.path}\u0000${node.id}`);
|
|
}
|
|
},
|
|
},
|
|
}, s("title", {}, node.label), s("circle", {r: 9}), s("text", {class: "label", y: 26, "text-anchor": "middle"}, short(node.label).slice(0, 24))),
|
|
});
|
|
}
|
|
|
|
function agentAncestry(root) {
|
|
const flat = spawnFlat(root);
|
|
const plan = Ancestry.layout(flat.map(({entry}) => ({id: entry.id, label: entry.label, kind: entry.kind})), flat.filter(({parent}) => parent).map(({entry, parent}) => ({child: entry.id, parent: parent.id, source: "recorded"})));
|
|
const open = (node) => {
|
|
const found = flat.find(({entry}) => entry.id === node.id);
|
|
if (found && found.entry.kind === "twig") {
|
|
focusSub(found.entry.session, found.entry.row.id);
|
|
} else if (found && found.entry.kind !== "operator") {
|
|
select(node.id);
|
|
}
|
|
};
|
|
return ancestryChart(plan, "Ancestry: operator, chair, sessions, subagents", {
|
|
edge: (link, d) => s("path", {class: "ancestry-edge", d, "data-child": link.child, "data-parent": link.parent}),
|
|
node: (node) => s("g", {
|
|
class: `node ancestry-node k-${node.kind}`, transform: `translate(${fixed(node.x)} ${fixed(node.y)})`, tabindex: 0, role: "button", "aria-label": node.label,
|
|
"data-agent": node.id, "data-kind": node.kind, "data-generation": node.generation,
|
|
on: {click: () => open(node), keydown: (event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
open(node);
|
|
}
|
|
}},
|
|
}, s("title", {}, `${node.label} · ${NODE_KINDS[node.kind] || "Subagent"}`), s("circle", {r: 9}), s("text", {class: "label", y: 26, "text-anchor": "middle"}, short(node.label).slice(0, 24))),
|
|
});
|
|
}
|
|
|
|
document.addEventListener("pointerup", (event) => {
|
|
const from = gateStage.drag;
|
|
gateStage.drag = null;
|
|
if (!from || Math.hypot(event.clientX - from.x, event.clientY - from.y) < DRAG_SLOP) {
|
|
return;
|
|
}
|
|
const hit = document.elementFromPoint(event.clientX, event.clientY);
|
|
const target = hit && hit.closest("[data-branch]");
|
|
if (target && target.dataset.repo === from.repo && target.dataset.branch !== from.branch) {
|
|
stageParent(from.repo, from.branch, target.dataset.branch, false);
|
|
}
|
|
});
|