feat(viz): the mission becomes a map — centre, stations, and agents at theirs

The clump was structural, not cosmetic. Four causes, each fixed here.

`homes` (an agent's resting node) was written only by `seed()`, so every agent
homed to the mission centre and orbited the same dot regardless of which phase
it was on. `setHome` points each agent at its CURRENT phase, and the existing
physics does the rest for free: the pawn rests at its station, the pawn→home
line tethers it there, and a touch becomes a visible departure and return. No
new motion code.

The mission node was seeded as `level: "team"` (my own bug from the focus
work). `seed()` casts that straight to a Tier and `ensureNode` is
first-write-wins, so it was created as a small teal team dot that the later
`node.activity` could never upgrade. That dot at the centre of the scene was
one line.

`mission`/`phase` replace the retired `repo`/`loop` tiers rather than adding a
parallel set. The backend stopped emitting repo:/loop: ids, which left their
whole landmark treatment — bigger radius, distinct colour, always-labelled, 60s
fade instead of 22s — orphaned on prefixes nothing sends. Missions and phases
need exactly that treatment. The two separately-written prefix→tier ternaries
in onTouch and onNodeActivity are now one `tierFor`: they agreed only by luck,
and whichever path saw a node first fixed its tier forever.

Phase stations spring out at 190 rather than the shared 64, or they pack into a
rosette around the centre and the point — agents moving BETWEEN stations — is
invisible. Idle roam is off under a mission scope: wandering to a random node
keeps an idle workspace alive, but inside one mission it sends agents to files
nobody opened, which reads as work and isn't.

Palette is injected at construction and keyed on template_kind, so a benchmark
run and a security sweep no longer render identically to a research mission.
It also collapses two uncoordinated kind→colour maps that had drifted:
LEVEL_COLOR by tier, and the fireColor if-chain by id prefix.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-10 18:17:12 -07:00
co-authored by Claude Opus 5
parent 5f85dbb718
commit 006432c2dc
6 changed files with 377 additions and 69 deletions
+4
View File
@@ -1706,6 +1706,7 @@ pub async fn workforce(
"SELECT m.id::text AS mission_id, "SELECT m.id::text AS mission_id,
m.title AS mission_title, m.title AS mission_title,
m.status AS mission_status, m.status AS mission_status,
m.template_kind AS template_kind,
m.created_at AS created_at, m.created_at AS created_at,
a.id::text AS agent_id, a.id::text AS agent_id,
a.name AS agent_name, a.name AS agent_name,
@@ -1736,6 +1737,9 @@ pub async fn workforce(
"mission_id": mid, "mission_id": mid,
"title": r.get::<String, _>("mission_title"), "title": r.get::<String, _>("mission_title"),
"status": r.get::<String, _>("mission_status"), "status": r.get::<String, _>("mission_status"),
// Drives the World's palette: what the mission is FOR
// should be visible before any label is read.
"templateKind": r.get::<String, _>("template_kind"),
"agents": Vec::<Value>::new(), "agents": Vec::<Value>::new(),
})); }));
seen_mission.insert(r.get::<String, _>("mission_id"), missions.len() - 1); seen_mission.insert(r.get::<String, _>("mission_id"), missions.len() - 1);
+22 -12
View File
@@ -489,8 +489,10 @@ pub async fn world_live(
let mut last: std::collections::HashMap<String, String> = std::collections::HashMap::new(); let mut last: std::collections::HashMap<String, String> = std::collections::HashMap::new();
// Per-run journal cursor so we stream only NEW run_events each poll. // Per-run journal cursor so we stream only NEW run_events each poll.
let mut cursors: std::collections::HashMap<String, i64> = std::collections::HashMap::new(); let mut cursors: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
// Last emitted signature per phase, so the plan is sent once and then // Last emitted signature per mission / per phase, so the plan is sent
// only when a phase actually moves. // once and then only when something actually moves.
let mut last_mission: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut last_phase: std::collections::HashMap<String, String> = let mut last_phase: std::collections::HashMap<String, String> =
std::collections::HashMap::new(); std::collections::HashMap::new();
// Audit-log cursor for edge-initiated inter-agent events (delegation, // Audit-log cursor for edge-initiated inter-agent events (delegation,
@@ -555,16 +557,24 @@ pub async fn world_live(
let mut seen_missions: HashSet<String> = HashSet::new(); let mut seen_missions: HashSet<String> = HashSet::new();
for m in &missions { for m in &missions {
if seen_missions.insert(m.mission_id.clone()) { if seen_missions.insert(m.mission_id.clone()) {
yield sse( // Delta-guarded like every other stateful event here. Without
"mission.update", // this it re-sent the same mission on every poll — harmless
json!({ // to a client that keys on missionId, and pure noise on the
"missionId": m.mission_id, // wire that hides the events that DID change.
"title": m.title, let sig = format!("{}|{}", m.status, m.completed_at.as_deref().unwrap_or(""));
"status": m.status, if last_mission.get(&m.mission_id).map(|s| s != &sig).unwrap_or(true) {
"templateKind": m.template_kind, last_mission.insert(m.mission_id.clone(), sig);
"completedAt": m.completed_at, yield sse(
}), "mission.update",
); json!({
"missionId": m.mission_id,
"title": m.title,
"status": m.status,
"templateKind": m.template_kind,
"completedAt": m.completed_at,
}),
);
}
// A terminal mission is a map to read, not a scene to // A terminal mission is a map to read, not a scene to
// animate: it still gets an orb, but no heat. // animate: it still gets an orb, but no heat.
let running = m.status == "running"; let running = m.status == "running";
@@ -124,7 +124,13 @@ export interface WorkforceAgent {
status: string; status: string;
} }
export interface Workforce { export interface Workforce {
missions: { mission_id: string; title: string; status: string; agents: WorkforceAgent[] }[]; missions: {
mission_id: string;
title: string;
status: string;
templateKind?: string | null;
agents: WorkforceAgent[];
}[];
unassigned: WorkforceAgent[]; unassigned: WorkforceAgent[];
} }
@@ -768,6 +774,9 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
return grouped?.mission_id ?? (workforce?.missions ?? [])[0]?.mission_id ?? null; return grouped?.mission_id ?? (workforce?.missions ?? [])[0]?.mission_id ?? null;
})(); })();
const focusedMission = (workforce?.missions ?? []).find((m) => m.mission_id === focusedMissionId) ?? null; const focusedMission = (workforce?.missions ?? []).find((m) => m.mission_id === focusedMissionId) ?? null;
// Palette input. Read from the plan channel rather than the SSE because the
// engine takes its palette at construction, before any event has arrived.
const focusedMissionTemplate = focusedMission?.templateKind ?? null;
const focusAgentIds: Set<string> | null = focusedMission const focusAgentIds: Set<string> | null = focusedMission
? new Set(focusedMission.agents.map((a) => a.id)) ? new Set(focusedMission.agents.map((a) => a.id))
: null; : null;
@@ -779,7 +788,11 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
const worldSeedRoots: WorldSeed[] = focusedMission const worldSeedRoots: WorldSeed[] = focusedMission
? [{ ? [{
id: `mission:${focusedMission.mission_id}`, id: `mission:${focusedMission.mission_id}`,
level: "team", // "mission", not "team". `seed()` casts this straight to a Tier and
// `ensureNode` is first-write-wins, so seeding it as a team created a
// small teal team-sized dot that the later `node.activity` could never
// upgrade — the generic dot at the centre of the scene was this line.
level: "mission",
label: focusedMission.title?.trim() || "Untitled mission", label: focusedMission.title?.trim() || "Untitled mission",
status: focusedMission.status, status: focusedMission.status,
children: focusedMission.agents.map((a) => ({ children: focusedMission.agents.map((a) => ({
@@ -1089,6 +1102,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
focusAgents={focusAgentIds} focusAgents={focusAgentIds}
focusMissionId={focusedMissionId} focusMissionId={focusedMissionId}
focusLabel={focusedMission?.title ?? null} focusLabel={focusedMission?.title ?? null}
templateKind={focusedMissionTemplate}
/> />
{/* Open the slide-out (top-right) when it's closed. */} {/* Open the slide-out (top-right) when it's closed. */}
{!worldPanelOpen ? ( {!worldPanelOpen ? (
+80 -18
View File
@@ -22,6 +22,7 @@ import { WorldFlow, type WorldItem } from "../dashboard/flow/WorldFlow";
import { BRAIN, agentRegion } from "./brain"; import { BRAIN, agentRegion } from "./brain";
import { WorldEngine, type Formation, type GNode, type WorldSeed } from "./engine"; import { WorldEngine, type Formation, type GNode, type WorldSeed } from "./engine";
import { paletteFor } from "./palette";
type ReplayEvent = { t: number; type: string; data: Record<string, unknown> }; type ReplayEvent = { t: number; type: string; data: Record<string, unknown> };
type ReplayState = { type ReplayState = {
@@ -86,6 +87,9 @@ interface WorldCanvasProps {
focusMissionId?: string | null; focusMissionId?: string | null;
/// Shown in the HUD so a filtered view never looks like an empty world. /// Shown in the HUD so a filtered view never looks like an empty world.
focusLabel?: string | null; focusLabel?: string | null;
/// `missions.template_kind` — picks the palette. A benchmark run and a
/// security sweep should not read the same as a research mission.
templateKind?: string | null;
} }
const FORMATIONS: { id: Formation; label: string }[] = [ const FORMATIONS: { id: Formation; label: string }[] = [
@@ -96,6 +100,15 @@ const FORMATIONS: { id: Formation; label: string }[] = [
const mono = "'Geist Mono', ui-monospace, monospace"; const mono = "'Geist Mono', ui-monospace, monospace";
/// Phase kind → what a person calls it. Mirrors the mission list so the two
/// surfaces do not name the same phase differently.
const PHASE_LABEL: Record<string, string> = {
research: "Research",
coding: "Coding",
benchmark: "Benchmark",
security_scan: "Security",
};
const VIEW_TITLE: Record<Formation, string> = { const VIEW_TITLE: Record<Formation, string> = {
live: "LIVE — GOURCE", live: "LIVE — GOURCE",
flat: "FLAT — 2D GRAPH", flat: "FLAT — 2D GRAPH",
@@ -107,7 +120,7 @@ const VIEW_LEGEND: Record<Formation, string> = {
hierarchy: "agents are neurons in a mesh; pathways fire as they work", hierarchy: "agents are neurons in a mesh; pathways fire as they work",
}; };
export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExpand, onOpenRuns, focusAgents, focusMissionId, focusLabel }: WorldCanvasProps) { export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExpand, onOpenRuns, focusAgents, focusMissionId, focusLabel, templateKind }: WorldCanvasProps) {
const mountRef = useRef<HTMLDivElement>(null); const mountRef = useRef<HTMLDivElement>(null);
const labelLayerRef = useRef<HTMLDivElement>(null); const labelLayerRef = useRef<HTMLDivElement>(null);
const live = useClawmatesLive(); const live = useClawmatesLive();
@@ -132,6 +145,14 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
const playingRef = useRef(playing); const playingRef = useRef(playing);
const speedRef = useRef(speedMult); const speedRef = useRef(speedMult);
const replayRef = useRef<ReplayState | null>(null); const replayRef = useRef<ReplayState | null>(null);
/// Accumulated mission plan. Kept in a ref rather than state: it is written
/// from SSE handlers many times a second and only ever read to push into the
/// engine, so re-rendering on every phase update would be pure cost.
const planRef = useRef<{
missionId: string | null;
title: string;
phases: Map<string, { phaseId: string; label: string; orderIdx: number; color?: string }>;
}>({ missionId: null, title: "", phases: new Map() });
useEffect(() => { useEffect(() => {
onSelectRef.current = onSelect; onSelectRef.current = onSelect;
selectedRef.current = selectedId; selectedRef.current = selectedId;
@@ -155,11 +176,15 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
// every Dashboard render, so depending on it directly would throw the scene // every Dashboard render, so depending on it directly would throw the scene
// away continuously. // away continuously.
useEffect(() => { useEffect(() => {
const engine = new WorldEngine(); // The palette is a constructor arg because `ensureNode` bakes colour at
// creation and never re-tints. Recreating on `templateKind` too means a
// palette change lands on a scene built with it, rather than half-applied.
const engine = new WorldEngine(paletteFor(templateKind));
engine.seed(roots); engine.seed(roots);
engine.setRoam(!focusMissionId);
engineRef.current = engine; engineRef.current = engine;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusMissionId]); }, [focusMissionId, templateKind]);
// poll the engine for the HUD counts (agents + currently working) // poll the engine for the HUD counts (agents + currently working)
useEffect(() => { useEffect(() => {
@@ -187,6 +212,18 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
// //
// `focusAgents` empty/absent means the whole world, which is the previous // `focusAgents` empty/absent means the whole world, which is the previous
// behaviour and the default. // behaviour and the default.
// One setter for the plan, so a REST-sourced row and an SSE event cannot
// produce different scenes.
const pushPlan = () => {
const plan = planRef.current;
if (!plan.missionId) return;
e.applyMissionPlan({
missionId: plan.missionId,
title: plan.title || "Mission",
color: paletteFor(templateKind).mission,
phases: [...plan.phases.values()],
});
};
const inFocus = (agentId?: string | null) => const inFocus = (agentId?: string | null) =>
!focusAgents || focusAgents.size === 0 || (!!agentId && focusAgents.has(agentId)); !focusAgents || focusAgents.size === 0 || (!!agentId && focusAgents.has(agentId));
// A node is in focus when it is THIS mission's landmark, or when it is not // A node is in focus when it is THIS mission's landmark, or when it is not
@@ -220,6 +257,31 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
// Room chat: fan out from the poster to every other participant. // Room chat: fan out from the poster to every other participant.
// Same edge palette so participants see themselves reciprocally // Same edge palette so participants see themselves reciprocally
// connected in the emergent shape. // connected in the emergent shape.
// The plan channel. `mission.phase` arrives once per phase up front
// (including phases that have not started) and again whenever one moves,
// so the whole shape is on screen before any work happens.
live.on("mission.update", (d) => {
if (focusMissionId && d.missionId !== focusMissionId) return;
planRef.current.title = d.title;
planRef.current.missionId = d.missionId;
pushPlan();
}),
live.on("mission.phase", (d) => {
if (focusMissionId && d.missionId !== focusMissionId) return;
const pal = paletteFor(templateKind);
planRef.current.missionId = d.missionId;
planRef.current.phases.set(d.phaseId, {
phaseId: d.phaseId,
label: PHASE_LABEL[d.kind] ?? d.kind,
orderIdx: d.orderIdx,
color: pal.phase[d.kind],
});
// Agents rest at the phase they are actually on. Without this every
// pawn homes to the mission centre and the scene is a clump no matter
// how many stations it has.
for (const a of d.agentIds ?? []) e.setHome(a, `phase:${d.phaseId}`);
pushPlan();
}),
live.on("room.message", (d) => { live.on("room.message", (d) => {
if (!d.fromAgentId || !inFocus(d.fromAgentId)) return; if (!d.fromAgentId || !inFocus(d.fromAgentId)) return;
for (const pid of d.participantIds ?? []) { for (const pid of d.participantIds ?? []) {
@@ -228,7 +290,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
}), }),
]; ];
return () => offs.forEach((off) => off()); return () => offs.forEach((off) => off());
}, [live, mode, focusAgents, focusMissionId]); }, [live, mode, focusAgents, focusMissionId, templateKind]);
// replay mode: fetch the run-history timeline and feed it back through the engine // replay mode: fetch the run-history timeline and feed it back through the engine
useEffect(() => { useEffect(() => {
@@ -511,8 +573,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
sel.startsWith("file:") || sel.startsWith("file:") ||
sel.startsWith("dir:") || sel.startsWith("dir:") ||
sel.startsWith("run:") || sel.startsWith("run:") ||
sel.startsWith("repo:") || sel.startsWith("mission:") ||
sel.startsWith("loop:") sel.startsWith("phase:")
) { ) {
onSelectRef.current(""); onSelectRef.current("");
} }
@@ -705,8 +767,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
(focusRoot.startsWith("file:") || (focusRoot.startsWith("file:") ||
focusRoot.startsWith("dir:") || focusRoot.startsWith("dir:") ||
focusRoot.startsWith("run:") || focusRoot.startsWith("run:") ||
focusRoot.startsWith("repo:") || focusRoot.startsWith("mission:") ||
focusRoot.startsWith("loop:")); focusRoot.startsWith("phase:"));
let visibleNodes: Set<string> | null = null; let visibleNodes: Set<string> | null = null;
if (isRepoFocus && focusRoot && engine.nodes.has(focusRoot)) { if (isRepoFocus && focusRoot && engine.nodes.has(focusRoot)) {
visibleNodes = new Set<string>([focusRoot]); visibleNodes = new Set<string>([focusRoot]);
@@ -770,8 +832,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
n.tier === "org" || n.tier === "org" ||
n.tier === "company" || n.tier === "company" ||
n.tier === "team" || n.tier === "team" ||
n.tier === "repo" || n.tier === "mission" ||
n.tier === "loop"; n.tier === "phase";
gm.opacity = (struct ? 0.08 + n.heat * 0.9 : 0.22 + n.heat * 0.8) * n.alpha; gm.opacity = (struct ? 0.08 + n.heat * 0.9 : 0.22 + n.heat * 0.8) * n.alpha;
if (effects && n.heat > 0.35 && Math.random() < n.heat * dt * 8) { if (effects && n.heat > 0.35 && Math.random() < n.heat * dt * 8) {
const a = Math.random() * Math.PI * 2; const a = Math.random() * Math.PI * 2;
@@ -815,7 +877,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
// still reads as a clean file tree. // still reads as a clean file tree.
// 2. Pawn → home team (thin persistent line so every agent visibly // 2. Pawn → home team (thin persistent line so every agent visibly
// "lives" in their team's neighborhood). // "lives" in their team's neighborhood).
// 3. Pawn → its current landmark target (repo: or loop:) when the // 3. Pawn → its current landmark target (mission: or phase:) when the
// pawn is actively engaging one, so the agent↔project affinity // pawn is actively engaging one, so the agent↔project affinity
// stays visible between world.touch bumps. // stays visible between world.touch bumps.
const epos: number[] = []; const epos: number[] = [];
@@ -841,7 +903,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
const t = engine.nodes.get(p.targetId); const t = engine.nodes.get(p.targetId);
if ( if (
t && t &&
(t.tier === "repo" || t.tier === "loop") && (t.tier === "mission" || t.tier === "phase") &&
(!visibleNodes || (visibleNodes.has(p.id) && visibleNodes.has(t.id))) (!visibleNodes || (visibleNodes.has(p.id) && visibleNodes.has(t.id)))
) { ) {
epos.push(p.x, p.y, 0, t.x, t.y, 0); epos.push(p.x, p.y, 0, t.x, t.y, 0);
@@ -972,8 +1034,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
n.tier === "org" || n.tier === "org" ||
n.tier === "company" || n.tier === "company" ||
n.tier === "team" || n.tier === "team" ||
n.tier === "repo" || n.tier === "mission" ||
n.tier === "loop"; n.tier === "phase";
const hot = (n.tier === "service" || n.tier === "event") && (n.heat > 0.12 || n.id === selectedRef.current); const hot = (n.tier === "service" || n.tier === "event") && (n.heat > 0.12 || n.id === selectedRef.current);
if (struct || hot) if (struct || hot)
wantLabels.set(n.id, { x: n.x, y: n.y - n.r - 6, text: n.label, color: n.color, big: n.tier === "org" }); wantLabels.set(n.id, { x: n.x, y: n.y - n.r - 6, text: n.label, color: n.color, big: n.tier === "org" });
@@ -1079,8 +1141,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
(selectedId.startsWith("file:") || (selectedId.startsWith("file:") ||
selectedId.startsWith("dir:") || selectedId.startsWith("dir:") ||
selectedId.startsWith("run:") || selectedId.startsWith("run:") ||
selectedId.startsWith("repo:") || selectedId.startsWith("mission:") ||
selectedId.startsWith("loop:")) ? ( selectedId.startsWith("phase:")) ? (
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@@ -1101,8 +1163,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
backdropFilter: "blur(6px)", backdropFilter: "blur(6px)",
}} }}
> >
<span style={{ color: selectedId.startsWith("loop:") ? "#f0b866" : "#ff8a7a", letterSpacing: ".08em" }}> <span style={{ color: selectedId.startsWith("phase:") ? "#8ec5ff" : "#ff8a7a", letterSpacing: ".08em" }}>
{selectedId.startsWith("loop:") ? "LOOP FOCUS" : "REPO FOCUS"} {selectedId.startsWith("phase:") ? "PHASE FOCUS" : "MISSION FOCUS"}
</span> </span>
<span style={{ color: "#cfcfd5", maxWidth: 340, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <span style={{ color: "#cfcfd5", maxWidth: 340, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{selectedId.replace(/^(file|dir|repo|run|loop):/, "")} {selectedId.replace(/^(file|dir|repo|run|loop):/, "")}
+134 -37
View File
@@ -7,9 +7,15 @@
// renderer-independent. // renderer-independent.
import type { TaxonomyEvents } from "@/lib/live/taxonomy"; import type { TaxonomyEvents } from "@/lib/live/taxonomy";
import { colorFor, fireFor, paletteFor, type Palette } from "./palette";
export type Formation = "hierarchy" | "flat" | "live"; export type Formation = "hierarchy" | "flat" | "live";
export type Tier = "root" | "org" | "company" | "team" | "repo" | "loop" | "service" | "event"; // `mission` and `phase` replace the retired `repo`/`loop` landmark tiers. The
// backend stopped emitting repo:/loop: ids (world.rs), leaving their whole
// landmark treatment — bigger radius, distinct colour, always-labelled, slow
// fade — orphaned on prefixes nothing sends. Missions and phases need exactly
// that treatment, so the tiers are renamed rather than a parallel set added.
export type Tier = "root" | "org" | "company" | "team" | "mission" | "phase" | "service" | "event";
export interface GNode { export interface GNode {
id: string; id: string;
@@ -98,6 +104,16 @@ export interface CommEdge {
* before it fades to nothing (roughly this many seconds). */ * before it fades to nothing (roughly this many seconds). */
const COMM_EDGE_DECAY_PER_SEC = 1 / 30; const COMM_EDGE_DECAY_PER_SEC = 1 / 30;
/// A mission's shape, as the World draws it: the centre plus its ordered
/// stations. Assembled by the client from `mission.update` / `mission.phase`
/// (and the REST plan poll) so both sources land in one setter.
export interface MissionPlan {
missionId: string;
title: string;
color?: string;
phases: { phaseId: string; label: string; orderIdx: number; color?: string }[];
}
export interface WorldSeed { export interface WorldSeed {
id: string; id: string;
level: string; // "org" | "company" | "team" | "claw" level: string; // "org" | "company" | "team" | "claw"
@@ -110,12 +126,27 @@ const LEVEL_COLOR: Record<string, string> = {
org: "#c98af0", org: "#c98af0",
company: "#8a9af0", company: "#8a9af0",
team: "#6fd0c0", team: "#6fd0c0",
repo: "#8ec5ff", // R&D projects — soft sky-blue, distinct landmark tone // Fallbacks only — a focused mission supplies real colours from its palette,
loop: "#f0b866", // scheduled loops — warm amber, reads as "recurring / cyclical" // which is keyed on the mission's template_kind.
mission: "#ff8a7a",
phase: "#8ec5ff",
service: "#5ec8d8", service: "#5ec8d8",
event: "#ff8a7a", event: "#ff8a7a",
root: "#ff6f61", root: "#ff6f61",
}; };
/// Tier for a node id, shared by `onTouch` and `onNodeActivity`.
///
/// These two derived the tier from the id prefix with two separately-written
/// ternaries. They agreed only by luck, and a node created by whichever path
/// saw it first keeps that tier forever — `ensureNode` never re-tiers an
/// existing node. One function, so they cannot drift.
export function tierFor(nodeId: string, kind?: string): Tier {
if (nodeId.startsWith("mission:")) return "mission";
if (nodeId.startsWith("phase:")) return "phase";
if (kind === "event" || nodeId.startsWith("finding:")) return "event";
return "service";
}
const PAWN_COLORS = ["#ff5f57", "#4aa3b8", "#5fd08a", "#e8b465", "#c98af0", "#8a9af0"]; const PAWN_COLORS = ["#ff5f57", "#4aa3b8", "#5fd08a", "#e8b465", "#c98af0", "#8a9af0"];
const ROOT = "__root__"; const ROOT = "__root__";
@@ -127,11 +158,20 @@ export class WorldEngine {
* pawn ids joined by "|" so A↔B is a single edge regardless of direction. */ * pawn ids joined by "|" so A↔B is a single edge regardless of direction. */
commEdges = new Map<string, CommEdge>(); commEdges = new Map<string, CommEdge>();
formation: Formation = "live"; formation: Formation = "live";
private homes = new Map<string, string>(); // agentId → teamId private homes = new Map<string, string>(); // agentId → its resting node
/// Idle drift. Off under a mission scope — see `setRoam`.
private roam = true;
private now = 0; private now = 0;
private pawnIdx = 0; private pawnIdx = 0;
constructor() { /// Palette for this scene. Injected rather than read from a module constant
/// because `ensureNode` bakes colour at creation and never re-tints — the
/// engine is already recreated when the focused mission changes, which makes
/// the constructor the one place a palette swap lands cleanly.
private palette: Palette;
constructor(palette: Palette = paletteFor(undefined)) {
this.palette = palette;
this.nodes.set(ROOT, { this.nodes.set(ROOT, {
id: ROOT, id: ROOT,
tier: "root", tier: "root",
@@ -167,6 +207,65 @@ export class WorldEngine {
roots.forEach((r) => walk(r, ROOT, 1)); roots.forEach((r) => walk(r, ROOT, 1));
} }
/// Draw a mission's plan: the mission at the centre, its phases as stations.
///
/// Called on every plan refresh (SSE `mission.phase` or the REST poll), so it
/// must be idempotent. `ensureNode` is first-write-wins for tier/colour/radius
/// — deliberately, so a later generic event cannot downgrade a landmark — which
/// means everything MUTABLE (status, colour, heat floor) is written here
/// directly rather than through it.
applyMissionPlan(plan: MissionPlan) {
const missionNode = `mission:${plan.missionId}`;
const m = this.ensureNode(missionNode, "mission", plan.title, ROOT, 1);
// The mission is the frame of reference; pinning it stops the whole scene
// drifting as phases are added.
m.fixed = true;
m.x = 0;
m.y = 0;
m.lastSeen = this.now;
if (plan.color) m.color = plan.color;
const ordered = [...plan.phases].sort((a, b) => a.orderIdx - b.orderIdx);
ordered.forEach((ph, i) => {
const id = `phase:${ph.phaseId}`;
const existed = this.nodes.has(id);
const n = this.ensureNode(id, "phase", ph.label, missionNode, 2);
if (!existed) {
// Seed the position by plan order. `ensureNode` spreads new nodes on a
// golden angle keyed to node COUNT, which scatters a mission's phases in
// arrival order — so the plan read as a random rosette rather than a
// sequence. Placed once, at creation; the spring owns it afterwards.
const a = (i / Math.max(1, ordered.length)) * Math.PI * 2 - Math.PI / 2;
n.x = Math.cos(a) * 190;
n.y = Math.sin(a) * 190;
}
n.label = ph.label;
n.lastSeen = this.now;
if (ph.color) n.color = ph.color;
});
}
/// Where a pawn rests when it is not touching anything.
///
/// This is what dissolves the clump. `homes` was written only by `seed()`, so
/// every agent's home was the mission centre and they all orbited the same
/// dot no matter which phase they were on. Pointed at the agent's CURRENT
/// phase, the existing physics does the rest for free: the pawn rests at its
/// station, the pawn→home line tethers it there, and a touch becomes a visible
/// departure and return.
setHome(agentId: string, nodeId: string) {
this.homes.set(agentId, nodeId);
const p = this.pawns.get(agentId);
if (p) p.homeId = nodeId;
}
/// Whether idle pawns drift to random nodes. Off under a mission scope: the
/// roam picks any service node, so agents wandered into files nobody opened
/// and it read as work.
setRoam(on: boolean) {
this.roam = on;
}
ensureNode(id: string, tier: Tier, label: string, parentId: string | null, depth: number): GNode { ensureNode(id: string, tier: Tier, label: string, parentId: string | null, depth: number): GNode {
let n = this.nodes.get(id); let n = this.nodes.get(id);
if (!n) { if (!n) {
@@ -186,11 +285,11 @@ export class WorldEngine {
r: r:
tier === "org" ? 15 tier === "org" ? 15
: tier === "company" ? 12 : tier === "company" ? 12
: tier === "repo" ? 11 : tier === "mission" ? 14
: tier === "loop" ? 11 : tier === "phase" ? 11
: tier === "team" ? 9 : tier === "team" ? 9
: 7, : 7,
color: LEVEL_COLOR[tier] ?? "#9a9aa2", color: colorFor(this.palette, id, tier) ?? LEVEL_COLOR[tier] ?? "#9a9aa2",
heat: 0, heat: 0,
burst: 0, burst: 0,
alpha: 1, alpha: 1,
@@ -276,16 +375,7 @@ export class WorldEngine {
} }
label = segs[segs.length - 1] || path; label = segs[segs.length - 1] || path;
} }
// Landmark orbs (repo:, loop:) keep their distinct tiers so we can const tier = tierFor(e.nodeId, e.kind);
// render them as solid project/schedule orbs — see LEVEL_COLOR and the
// label rules in WorldCanvas.
const tier: Tier = e.nodeId.startsWith("repo:")
? "repo"
: e.nodeId.startsWith("loop:")
? "loop"
: e.kind === "event"
? "event"
: "service";
const node = this.ensureNode(e.nodeId, tier, label, parentId, depth); const node = this.ensureNode(e.nodeId, tier, label, parentId, depth);
const p = this.ensurePawn(e.agentId); const p = this.ensurePawn(e.agentId);
p.targetId = e.nodeId; p.targetId = e.nodeId;
@@ -308,11 +398,10 @@ export class WorldEngine {
} }
// activity type → signal colour: file=coral, tool=cyan, run=green, // activity type → signal colour: file=coral, tool=cyan, run=green,
// repo=sky-blue, loop=amber // repo=sky-blue, loop=amber
if (e.nodeId.startsWith("tool:")) p.fireColor = w >= 0.9 ? "#ff6f61" : "#5ec8d8"; // One palette answers this now. It used to be a second kind→colour map,
else if (e.nodeId.startsWith("run:")) p.fireColor = "#5fd08a"; // written separately from LEVEL_COLOR and free to disagree with it.
else if (e.nodeId.startsWith("file:")) p.fireColor = "#ff8a7a"; const fire = fireFor(this.palette, e.nodeId, w);
else if (e.nodeId.startsWith("repo:")) p.fireColor = "#8ec5ff"; if (fire) p.fireColor = fire;
else if (e.nodeId.startsWith("loop:")) p.fireColor = "#f0b866";
} }
/** A reasoning token from an agent — marks it active and tints its signals. */ /** A reasoning token from an agent — marks it active and tints its signals. */
@@ -354,17 +443,10 @@ export class WorldEngine {
} }
} }
onNodeActivity(e: TaxonomyEvents["node.activity"]) { onNodeActivity(e: TaxonomyEvents["node.activity"]) {
// Preserve landmark tiers (repo:, loop:) so the SSE stream keeps the // Preserve landmark tiers so the SSE stream keeps the distinct colour +
// distinct color + radius treatment — otherwise the tier collapses back // radius treatment — otherwise the tier collapses to service and a mission
// to service/event and R&D projects and scheduled loops look identical // looks identical to a tool call.
// to a tool call. const tier = tierFor(e.nodeId, e.kind);
const tier: Tier = e.nodeId.startsWith("repo:")
? "repo"
: e.nodeId.startsWith("loop:")
? "loop"
: e.kind === "event"
? "event"
: "service";
// file:<path> events (e.g. pre-seeded from the SSE loop's repo tree // file:<path> events (e.g. pre-seeded from the SSE loop's repo tree
// snapshot) also need the dir:<partial> chain synthesized, otherwise // snapshot) also need the dir:<partial> chain synthesized, otherwise
// the tree renders as flat leaves under ROOT. Mirrors onTouch — kept // the tree renders as flat leaves under ROOT. Mirrors onTouch — kept
@@ -439,7 +521,18 @@ export class WorldEngine {
const dy = parent.y - n.y; const dy = parent.y - n.y;
const d = Math.hypot(dx, dy) || 1; const d = Math.hypot(dx, dy) || 1;
const desired = const desired =
F === "flat" ? 130 + n.depth * 26 : parent.r + n.r + (n.tier === "org" ? 130 : n.tier === "company" ? 95 : 64); F === "flat"
? 130 + n.depth * 26
: parent.r +
n.r +
(n.tier === "org" ? 130
: n.tier === "company" ? 95
// Phase stations orbit the mission far enough out to read as
// separate places to travel to. At the shared 64 they packed into
// a rosette around the centre and the whole point — agents moving
// BETWEEN stations — was invisible.
: n.tier === "phase" ? 190
: 64);
const f = (d - desired) * springK; const f = (d - desired) * springK;
n.vx += (dx / d) * f * dt * 4; n.vx += (dx / d) * f * dt * 4;
n.vy += (dy / d) * f * dt * 4; n.vy += (dy / d) * f * dt * 4;
@@ -459,7 +552,7 @@ export class WorldEngine {
// window keeps a brief SSE hiccup from evicting the workspace. // window keeps a brief SSE hiccup from evicting the workspace.
for (const n of arr) { for (const n of arr) {
const shortLived = n.tier === "service" || n.tier === "event"; const shortLived = n.tier === "service" || n.tier === "event";
const landmark = n.tier === "repo" || n.tier === "loop"; const landmark = n.tier === "mission" || n.tier === "phase";
if (shortLived || landmark) { if (shortLived || landmark) {
const idleAge = this.now - n.lastSeen; const idleAge = this.now - n.lastSeen;
const window = landmark ? 60 : 22; const window = landmark ? 60 : 22;
@@ -495,7 +588,11 @@ export class WorldEngine {
: [...this.nodes.values()].filter((n) => n.tier !== "root"); : [...this.nodes.values()].filter((n) => n.tier !== "root");
for (const p of pawns) { for (const p of pawns) {
p.retime -= dt; p.retime -= dt;
if (!p.targetId && p.retime <= 0 && targetPool.length) { // Roam is off under a mission scope. Wandering to a random node keeps an
// idle WORKSPACE alive, but inside one mission it sends agents to files
// nobody opened and tools nobody ran — motion that reads as work and
// isn't. An idle agent should sit at its station.
if (this.roam && !p.targetId && p.retime <= 0 && targetPool.length) {
p.targetId = targetPool[Math.floor(Math.random() * targetPool.length)].id; p.targetId = targetPool[Math.floor(Math.random() * targetPool.length)].id;
p.retime = 1.5 + Math.random() * 2.5; p.retime = 1.5 + Math.random() * 2.5;
} }
+121
View File
@@ -0,0 +1,121 @@
// Colour, keyed on what the mission is FOR.
//
// A benchmark run and a security sweep were rendered in the same palette as a
// research mission, so the only thing distinguishing them on screen was the
// label. Colour is the cheapest signal a viewer reads before any text, and it
// was carrying no information.
//
// This also collapses two uncoordinated kind→colour maps that had drifted
// apart: `LEVEL_COLOR` in engine.ts (node colour by tier) and the `fireColor`
// if-chain in `onTouch` (beam colour by id prefix). They answered the same
// question in different places with different answers.
import type { Tier } from "./engine";
export type PhaseKind = "research" | "coding" | "benchmark" | "security_scan";
export interface Palette {
/** The mission landmark at the centre. */
mission: string;
/** Station colour per phase kind. */
phase: Record<PhaseKind, string>;
dir: string;
file: string;
tool: string;
finding: string;
comm: { message: string; delegate: string; room: string; web: string };
}
/** The default — what the World looked like before palettes, so an unrecognised
* template renders exactly as it always did rather than as something odd. */
const BASE: Palette = {
mission: "#ff8a7a",
phase: {
research: "#c98af0",
coding: "#8ec5ff",
benchmark: "#f0b866",
security_scan: "#ff5f57",
},
dir: "#5ec8d8",
file: "#5ec8d8",
tool: "#5ec8d8",
finding: "#ff8a7a",
comm: { message: "#ff5eae", delegate: "#ff8a7a", room: "#c98af0", web: "#5ec8d8" },
};
/** Measurement work reads amber/green — instrument tones, not creative ones. */
const BENCHMARK: Palette = {
...BASE,
mission: "#f0b866",
phase: { ...BASE.phase, benchmark: "#5fd08a", coding: "#e8b465" },
dir: "#a8b06a",
file: "#d8c06a",
tool: "#5fd08a",
comm: { ...BASE.comm, web: "#a8b06a" },
};
/** Adversarial work reads crimson/steel. */
const SECURITY: Palette = {
...BASE,
mission: "#ff5f57",
phase: { ...BASE.phase, security_scan: "#ff5f57", coding: "#8a9af0" },
dir: "#7a8a9a",
file: "#9aa8b8",
tool: "#ff8a7a",
finding: "#ff3b30",
comm: { ...BASE.comm, web: "#7a8a9a" },
};
/** Reading and mapping an existing codebase — cooler, quieter. */
const REFACTOR: Palette = {
...BASE,
mission: "#6fd0c0",
phase: { ...BASE.phase, coding: "#6fd0c0" },
dir: "#4aa3b8",
file: "#6fd0c0",
comm: { ...BASE.comm, web: "#4aa3b8" },
};
/** Pure research — violet and sky, the existing landmark tones. */
const RESEARCH: Palette = {
...BASE,
mission: "#c98af0",
phase: { ...BASE.phase, research: "#c98af0" },
dir: "#8a9af0",
file: "#8ec5ff",
};
const PALETTES: Record<string, Palette> = {
research_only: RESEARCH,
research_and_code: BASE,
security_hardening: SECURITY,
refactor: REFACTOR,
benchmark: BENCHMARK,
custom: BASE,
};
export function paletteFor(templateKind?: string | null): Palette {
return (templateKind && PALETTES[templateKind]) || BASE;
}
/** Node colour for an id + tier. Replaces `LEVEL_COLOR[tier]` for everything a
* mission scope draws; structural tiers keep their own constants. */
export function colorFor(p: Palette, nodeId: string, tier: Tier): string | undefined {
if (tier === "mission") return p.mission;
if (nodeId.startsWith("finding:")) return p.finding;
if (nodeId.startsWith("dir:")) return p.dir;
if (nodeId.startsWith("file:")) return p.file;
if (nodeId.startsWith("tool:")) return p.tool;
// `phase` deliberately absent: a station's colour comes from its KIND, which
// the node id does not carry. `applyMissionPlan` passes it explicitly.
return undefined;
}
/** Beam colour when a pawn touches a node — the old `fireColor` if-chain. */
export function fireFor(p: Palette, nodeId: string, weight: number): string | undefined {
if (nodeId.startsWith("file:")) return weight >= 0.9 ? p.file : p.tool;
if (nodeId.startsWith("tool:")) return p.tool;
if (nodeId.startsWith("phase:")) return p.mission;
if (nodeId.startsWith("finding:")) return p.finding;
return undefined;
}