Repository navigation
genie-ui — dash-pattern PTY fleet + wish menu + hired agents + group chat (Model B) - #2609
Conversation
… G1-G4) Author the executor-facing DESIGN for the genie-ui wish from the ratified brainstorm (DRAFT/RESEARCH/COUNCIL): two channels (PTY viewing + ACP control), Model-B-corrected contract verbatim, module/interface/isolation architecture, the 8 council acceptance criteria verbatim, G1-G4 execution groups, risks incl. council dissent as future work, and the OUT list (conductor auto-pilot, write-promotion toggle, broadcast routing, Electron). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gate/build/residue tightenings) Fix the design-review gaps without altering the ratified verbatim texts (council contract, AC1-8): - MAJOR: pin the exact wish<->worktree mapping. The coherence contract binds per hired agent, not per wish: an agent's two faces cd into that agent's ready-group worktree (reused from `genie launch`, one per group), never a parallel wish-level worktree. Wish-level coherence rides on the shared `.genie` git artifacts (git-tracked wishes + worktree-shared genie.db), not a single path. Reconciles the "compose, don't duplicate" promise. Updated worktree wall, knot gloss, Scope IN, chat prose, `worktreeFor(rosterEntry)` signature, and G2 validation. - MINOR: G1 must wire `packages/**` into the gates (tsconfig include, complexity-budget scope, biome complexity override) or typecheck + the budget silently skip the package. - MINOR: "zero build step" scoped to the server; G1 replaces the prototype's Vite client transpile with a bun-native serve path (Bun.serve + import map). - MINOR: soften the rlmx-acp "all four exist today" overclaim (in flight). - MINOR: reframe G4/AC8 to verify no LIVE refs remain (already true) and preserve historical CHANGELOG/wish records rather than scrub them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Curated from .genie/brainstorms/genie-ui/DESIGN.md (SHIP, digest-verified): the fleet floor (PTY viewing) + genie lane + wish group chat (ACP control). 8 ratified ACs, D1-D13, R1-R11, four sequential waves G1->G2->G3->G4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make CLAUDE.md's "src/** AND packages/**" complexity claim true and stop
typecheck + the complexity budget silently skipping the new package before
any feature code lands.
- tsconfig: add packages/genie-ui project reference; the package has its own
tsconfig (DOM lib for the client) so the typecheck script runs both.
- biome: add a packages/** override (noExcessiveCognitiveComplexity warn @ 25,
matching src/**).
- complexity-budget: extend the suppression grep scope from src to src+packages
(biome check . already reports packages/** complexity warnings).
- knip: add the package entries + project glob so dead-code resolves the new
runtime imports instead of flagging them unused.
- deps: node-pty, ws, @xterm/{xterm,addon-fit,addon-clipboard,headless,addon-serialize}
land in root package.json (no workspaces field in the repo); node-pty added to
trustedDependencies so a fresh install builds its native binary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port the A/B "fresh" substrate into packages/genie-ui/server as TypeScript: - fleet-config: loadFleet() -> PaneSpec[], grown with the genie seams wishId + role. - pty-session: PtySession + PtySessionManager (startAll/spawn/kill/restart/write/ resize/replay/list/killAll; events data/exit/status = idle/running/exited). THE single node-pty importer. - transport: the ws protocol codec (FLEET/DATA/EXIT/STATUS/REPLAY/INPUT/RESIZE/ SPAWN/KILL/RESTART/LIST) as a typed pure codec. - reused/TerminalMirror.ts: salvaged verbatim from dash (syv-ai/dash, MIT) as the replay path, replacing fresh's 256 KB raw-byte ring. Verified under bun: the createRequire loader for @xterm/headless + @xterm/addon-serialize round-trips serialize() with SGR styling (design R4) — no loader change, no ring fallback. Imported only by pty-session. - index: thin composition root + the bun-native serve path (no Vite) — one node http server serves the client (Bun.build of the single entry) and upgrades to the bare ws PTY protocol on the same port. Colocated bun:test: pty-session (spawn/kill/restart + status/exit events) and the TerminalMirror serialize/replay round-trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The browser client, served by the bun-native path (no Vite):
- pane: one @xterm/xterm 5.5 Terminal + FitAddon per pane, ClipboardAddon wired
through the salvaged Utf8Base64 (UTF-8 OSC-52), and the salvaged FitScheduler
debouncing refits via a ResizeObserver. Each pane is a split-cell with a header
(role badge, status dot, per-pane spawn/kill/restart/split/max/close).
- layout: vanilla reimplementation of Lane A's grid concept — tabs (swap the
visible agent), horizontal splits (2+ side-by-side cells), maximize. Panes stay
mounted; the layout only toggles visibility so scrollback survives switching.
- transport: typed ws client, same-origin (server serves assets + ws on one port),
reconnecting.
- main: roster-driven composition; talks to the server exclusively over transport.
- reused/{Utf8Base64,FitScheduler}.ts: salvaged verbatim from dash (MIT).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README: module map + provenance, the pinned no-Vite serve decision, the TerminalMirror-under-bun (R4) outcome, and the no-workspaces deps call. - qa.md: the AC2-substrate manual QA — local pane render -> TerminalMirror -> reattach-identical replay (proven), ssh localhost key auth (proven), the bun-native serve path + ws FLEET handshake (proven), and the lifecycle events. Records the nested-sandbox PTY-SIGHUP limitation that blocks live btop/ssh -t frames + browser splits here, flagged operator-confirm on the real box. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MSG.INPUT feeds arbitrary keystrokes into live login shells, so the ws upgrade is a remote-control surface. Close it by default: - server.listen now binds HOST (default 127.0.0.1); LAN reach (mac + phone) is opt-in via HOST=0.0.0.0, not the implicit all-interfaces bind. - WebSocketServer gains verifyClient: a browser Origin must be same-origin with the page host (any LAN hostname, no config) or on the explicit GENIE_UI_ALLOWED_ORIGINS allowlist. Browsers always send Origin, so this defeats a cross-origin drive-by into the shells; no-Origin CLI/test clients stay gated by the loopback bind. Records the trust boundary as an explicit decision in the package README (the seam G2/G3 build on) and adds colocated verifyClient regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refreshTab and renderMeta interpolated PaneInfo fields (name, role, command, args, wishId) straight into innerHTML. Config-trusted today, but the SEAM comment marks this tab strip as where G2 feeds genie state (wish slugs, markdown-derived roles) — a wish title must not inject markup. Add an esc() helper and wrap every string interpolation before G2 lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uctor substrate)
The wish group chat's ACP control channel: one lazily-spawned, read-only ACP
ClientSideConnection per hired agent, @-mention-only routing (message + room
transcript -> streamed reply), a checked-in capability table -> minimal badges,
and named fail-loud spawn/delivery events.
- chat-backend.ts: the pool + router. Lazy spawn on first @-mention, seeded with
wish context + room transcript; non-mutating chat face (fs write caps off +
cancels every permission request); per-agent prompt serialization (sidesteps
rlmx's single-session -32600). The load-bearing wall (AC6/D7): imports NOTHING
from pty-session/TerminalMirror/transport/client — greppable test enforces it.
The vendor SDK is reached via acp.ts so the wall grep stays honest.
- capability-table.ts: checked-in per-harness data -> "shared memory" badge for
Hermes only, where demonstrated. v1 rides session/prompt + session/update only.
- chat-room.ts: composition-root glue binding chat-backend <-> transport (per-wish
transcript + roster) so neither imports the other; wired into index.ts beside
the PTY manager. fleet-config grows the `harness` seam.
- client chat-drawer.ts: wish-scoped roster + badges, streamed replies, named
fail-loud lines ("@codex could not start: ...; check PATH"), never silence.
- @agentclientprotocol/sdk@0.26.0 (matches rlmx's pin).
R1 proven live over `ssh localhost ... acp`: hermes (clean streamed PONG) and rlmx
(handshake + streamed reply). AC4b: Hermes session bridging demonstrated via
~/.hermes/state.db (the read-only chat face's turn persisted to the same db the
terminal face reads). v1 default is the co-located fallback; launcher is injectable.
Colocated bun:test: 14 chat-backend + 4 chat-room, all green. bun run check green
with no new failures vs the pre-existing 4-failure agent-sync baseline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ents The codex ACP default launched `codex acp`, but the installed codex has no `acp` subcommand — the Zed adapter is the `codex-acp` binary (DESIGN D9 names `codex-acp exited …`; mirrors the claude `claude-code-acp` shape). @codex can now connect when codex-acp is on PATH; a missing binary still fails loud. failFace lacked a face.failed guard, so one child failure fired spawn-failed twice (child 'error' → onChildDown → failFace, then the handshake catch → failFace). Add the same idempotency guard onChildDown uses so exactly one named event surfaces per failure. First-failure-is-loud behavior unchanged. Colocated tests assert exactly one spawn-failed per failure and that the codex default resolves to codex-acp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Code Review
This pull request introduces packages/genie-ui, a browser-served, PTY-faithful terminal UI that integrates a fleet of coding agents with genie's wish and board state, alongside a wish-scoped inter-agent group chat. The implementation features a Node-based PTY server, a vanilla TypeScript client, a read-only database layer for wish contexts, and an isolated ACP-based chat backend. The review feedback highlights several critical robustness improvements: preventing cross-talk of active chat streams across different wishes in the UI, allowing failed agent faces to self-heal and recreate on subsequent mentions, wrapping PTY spawning in a try-catch block to prevent server crashes from missing directories, and defensively guarding ResizeObserver entries against empty lists.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| addLine(line: ChatLine): void { | ||
| const kind = line.from === 'human' ? 'human' : 'agent'; | ||
| this.commit(line.wish, { from: line.from, text: line.text, kind }); | ||
| if (kind === 'agent') this.live.delete(line.from); // committed → drop its live bubble | ||
| if (line.wish === this.wish) this.render(); | ||
| } | ||
|
|
||
| /** A streamed chat event: a chunk to a live bubble, or a NAMED fail-loud system line (D9). */ | ||
| applyEvent(wish: string, event: ChatEventWire): void { | ||
| if (event.kind === 'message-chunk') this.live.set(event.agentId, (this.live.get(event.agentId) ?? '') + event.text); | ||
| else if (event.kind === 'spawn-failed' || event.kind === 'delivery-failed') | ||
| this.commit(wish, { from: event.agentId, text: event.message, kind: 'error' }); | ||
| // reply-done needs no action here — the committed line arrives as a chat-message. | ||
| if (wish === this.wish) this.render(); | ||
| } |
There was a problem hiding this comment.
There is a potential cross-talk/leakage bug where active streams in this.live are shared across all wishes. Since this.live is a single map, applyEvent and addLine modify it without checking if the event or line belongs to the currently active wish. This can cause in-flight streams from other wishes to render in the active wish's chat log. Guarding these updates with wish === this.wish prevents this cross-talk.
| addLine(line: ChatLine): void { | |
| const kind = line.from === 'human' ? 'human' : 'agent'; | |
| this.commit(line.wish, { from: line.from, text: line.text, kind }); | |
| if (kind === 'agent') this.live.delete(line.from); // committed → drop its live bubble | |
| if (line.wish === this.wish) this.render(); | |
| } | |
| /** A streamed chat event: a chunk to a live bubble, or a NAMED fail-loud system line (D9). */ | |
| applyEvent(wish: string, event: ChatEventWire): void { | |
| if (event.kind === 'message-chunk') this.live.set(event.agentId, (this.live.get(event.agentId) ?? '') + event.text); | |
| else if (event.kind === 'spawn-failed' || event.kind === 'delivery-failed') | |
| this.commit(wish, { from: event.agentId, text: event.message, kind: 'error' }); | |
| // reply-done needs no action here — the committed line arrives as a chat-message. | |
| if (wish === this.wish) this.render(); | |
| } | |
| addLine(line: ChatLine): void { | |
| const kind = line.from === 'human' ? 'human' : 'agent'; | |
| this.commit(line.wish, { from: line.from, text: line.text, kind }); | |
| if (kind === 'agent' && line.wish === this.wish) this.live.delete(line.from); // committed → drop its live bubble | |
| if (line.wish === this.wish) this.render(); | |
| } | |
| /** A streamed chat event: a chunk to a live bubble, or a NAMED fail-loud system line (D9). */ | |
| applyEvent(wish: string, event: ChatEventWire): void { | |
| if (event.kind === 'message-chunk' && wish === this.wish) this.live.set(event.agentId, (this.live.get(event.agentId) ?? '') + event.text); | |
| else if (event.kind === 'spawn-failed' || event.kind === 'delivery-failed') | |
| this.commit(wish, { from: event.agentId, text: event.message, kind: 'error' }); | |
| // reply-done needs no action here — the committed line arrives as a chat-message. | |
| if (wish === this.wish) this.render(); | |
| } |
| /** A child that dies before it was ready is a spawn failure; after ready it is a delivery failure. */ | ||
| private onChildDown(face: AgentFace, spawnMessage: string): void { | ||
| if (face.failed) return; | ||
| if (face.conn) { | ||
| face.failed = true; | ||
| this.dispatch({ | ||
| type: 'delivery-failed', | ||
| agentId: face.reg.agentId, | ||
| message: `@${face.reg.agentId} disconnected`, | ||
| }); | ||
| } else { | ||
| this.failFace(face, spawnMessage); | ||
| } | ||
| } | ||
|
|
||
| private failFace(face: AgentFace, message: string): void { | ||
| if (face.failed) return; // idempotency: one child-down + one handshake-catch must emit ONE spawn-failed | ||
| face.failed = true; | ||
| face.child?.kill('SIGKILL'); | ||
| this.dispatch({ type: 'spawn-failed', agentId: face.reg.agentId, harness: face.reg.harness, message }); | ||
| } | ||
|
|
There was a problem hiding this comment.
Once face.failed is set to true, any future @mention to that agent will silently do nothing because deliverMessage returns early without attempting to recreate the face. Deleting the face from this.faces on failure allows a fresh process to be spawned on the next mention, enabling self-healing and recovery from transient crashes or disconnections.
private onChildDown(face: AgentFace, spawnMessage: string): void {
if (face.failed) return;
if (face.conn) {
face.failed = true;
this.faces.delete(face.reg.agentId); // Allow recreation on next message
this.dispatch({
type: 'delivery-failed',
agentId: face.reg.agentId,
message: '@' + face.reg.agentId + ' disconnected',
});
} else {
this.failFace(face, spawnMessage);
}
}
private failFace(face: AgentFace, message: string): void {
if (face.failed) return; // idempotency: one child-down + one handshake-catch must emit ONE spawn-failed
face.failed = true;
face.child?.kill('SIGKILL');
this.faces.delete(face.reg.agentId); // Allow recreation on next message
this.dispatch({ type: 'spawn-failed', agentId: face.reg.agentId, harness: face.reg.harness, message });
}|
|
||
| start(): void { | ||
| if (this.proc) return; // already running | ||
| // Fresh screen on (re)start: drop the previous mirror so replay never shows | ||
| // pre-restart content (the sync equivalent of fresh's `buffer = ''`). | ||
| this.mirror.dispose(); | ||
| this.mirror = new TerminalMirror(this.cols, this.rows); | ||
| const env = { ...process.env, ...this.spec.env } as Record<string, string>; | ||
| this.proc = pty.spawn(this.spec.command, this.spec.args, { | ||
| name: env.TERM || 'xterm-256color', | ||
| cols: this.cols, | ||
| rows: this.rows, | ||
| cwd: this.spec.cwd, | ||
| env, | ||
| }); | ||
| this.status = 'running'; | ||
| this.lastExitCode = null; | ||
|
|
||
| this.proc.onData((data) => { | ||
| this.mirror.write(data); | ||
| this.emit('data', data); | ||
| }); | ||
|
|
||
| this.proc.onExit(({ exitCode }) => { | ||
| this.status = 'exited'; | ||
| this.lastExitCode = exitCode; | ||
| this.proc = null; | ||
| // Emit `status:exited` BEFORE `exit`: restart() listens on `exit` and calls | ||
| // start() synchronously, which emits `status:running`. Emitting status first | ||
| // keeps the observable sequence honest (exited -> running), never reversed. | ||
| this.emit('status', this.status); | ||
| this.emit('exit', exitCode); | ||
| }); | ||
|
|
||
| this.emit('status', this.status); |
There was a problem hiding this comment.
pty.spawn throws synchronously if the cwd directory does not exist or is inaccessible (e.g., if a worktree was deleted). Wrapping it in a try-catch block prevents the entire server from crashing and allows the error to be written directly to the terminal mirror so the user is informed of the failure.
start(): void {
if (this.proc) return; // already running
// Fresh screen on (re)start: drop the previous mirror so replay never shows
// pre-restart content (the sync equivalent of fresh's 'buffer = ""').
this.mirror.dispose();
this.mirror = new TerminalMirror(this.cols, this.rows);
const env = { ...process.env, ...this.spec.env } as Record<string, string>;
try {
this.proc = pty.spawn(this.spec.command, this.spec.args, {
name: env.TERM || 'xterm-256color',
cols: this.cols,
rows: this.rows,
cwd: this.spec.cwd,
env,
});
this.status = 'running';
this.lastExitCode = null;
this.proc.onData((data) => {
this.mirror.write(data);
this.emit('data', data);
});
this.proc.onExit(({ exitCode }) => {
this.status = 'exited';
this.lastExitCode = exitCode;
this.proc = null;
// Emit `status:exited` BEFORE `exit`: restart() listens on `exit` and calls
// start() synchronously, which emits `status:running`. Emitting status first
// keeps the observable sequence honest (exited -> running), never reversed.
this.emit('status', this.status);
this.emit('exit', exitCode);
});
this.emit('status', this.status);
} catch (err) {
this.status = 'exited';
this.lastExitCode = -1;
this.proc = null;
this.mirror.write('Error spawning PTY: ' + (err as Error).message + '\r\n');
this.emit('status', this.status);
this.emit('exit', -1);
}
}| new ResizeObserver((entries) => { | ||
| this.scheduler.onResize(entries[0].contentRect.height); | ||
| }).observe(this.mount); |
There was a problem hiding this comment.
In some environments or browsers, ResizeObserver can occasionally fire with an empty entries list. Adding a defensive guard for entries[0] prevents potential runtime errors.
| new ResizeObserver((entries) => { | |
| this.scheduler.onResize(entries[0].contentRect.height); | |
| }).observe(this.mount); | |
| new ResizeObserver((entries) => { | |
| if (entries[0]) this.scheduler.onResize(entries[0].contentRect.height); | |
| }).observe(this.mount); |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 332f1e51ad
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| /** Extract @-mentioned ids from a message (deduped, order-preserving). @-mention-only (D4). */ | ||
| export function parseMentions(text: string): string[] { | ||
| const seen = new Set<string>(); | ||
| const re = /(?:^|[^\w@])@([a-zA-Z0-9][\w.-]*)/g; |
There was a problem hiding this comment.
Trim trailing punctuation from mention IDs
When a user writes a normal sentence like please ask @codex., this regex captures codex. as the agent id. ChatRoom then looks up that exact id, treats it as an unknown mention, and silently skips delivery, which is especially confusing because the @-mention-only path intentionally does not surface unknown mentions as errors.
Useful? React with 👍 / 👎.
| const face = this.faces.get(agentId) ?? this.createFace(reg); | ||
| face.queue = face.queue.then(async () => { | ||
| await face.ready; | ||
| if (face.failed || !face.conn || !face.sessionId) return; |
There was a problem hiding this comment.
Do not swallow retries after a chat face fails
After a missing adapter, unlaunched worktree, or later disconnect marks face.failed, any subsequent @mention to that rostered agent reaches this return without emitting another spawn-failed/delivery-failed event or attempting a respawn. The human line is still recorded, so retries after fixing PATH or restarting the adapter appear as silence in the drawer.
Useful? React with 👍 / 👎.
| if (visible && firstVisible === null) firstVisible = id; | ||
| } | ||
| const activeInfo = activeId ? infos.get(activeId) : undefined; | ||
| if (firstVisible && (!activeInfo || !paneInWish(activeInfo))) layout.solo(firstVisible); |
There was a problem hiding this comment.
Hide the terminal when a selected wish has no panes
When the operator selects a wish that has no matching PaneInfo.wishId entries, firstVisible remains null, all tabs are hidden, but the LayoutManager keeps rendering the previously active terminal. Since the drawer and context now point at the newly selected wish, this can leave the user typing into an unrelated agent shell under the wrong wish context; clear the active layout or render an empty state in this branch.
Useful? React with 👍 / 👎.
packages/genie-ui was the original fresh-substrate UI (PR automagik-si#2609), superseded 2026-07-21 when Felipe rejected the substrate live and the UI direction moved to genie-ui-dash in the private khal-os/genie-desktop repo. it was never promoted to main; the docs superseded it but the code kept squatting on dev. removes the standalone package (@automagik/genie-ui, private) and its 9 root-package deps that nothing in src/ imports (@agentclientprotocol/sdk, node-pty-prebuilt-multiarch, 5x @xterm/*, ws, @types/ws), plus the gate touchpoints: typecheck leg, tsconfig reference, biome packages/** override, knip entries/project, complexity-budget scan scope. the sanctioned genie ui-bridge command (src/term-commands/ui-bridge.ts) stays — it is the stdio MCP channel the private UI spawns, not the shell. gate: typecheck/lint/dead-code/complexity-budget clean; 2343 tests pass (the single ui-bridge 'ss' failure is a pre-existing linux-only test, absent on darwin, byte-unchanged here).
The genie UI, council-ratified Model B: the wish is the workspace, each wish hires a crew of agents shown as their real terminals (dash pattern — render THE agent, no abstraction), with a wish-scoped group chat where read-only ACP chat faces collaborate. Full design + evidence:
.genie/brainstorms/genie-ui/DESIGN.md(digest-stamped SHIP),.genie/wishes/genie-ui/WISH.md. New package:packages/genie-ui.Groups (all SHIP — acceptance + quality + Fable final gate)
onDatais dead — 40454-vs-0 isolated, regression-guarded), vanilla xterm client (tabs/splits/maximize), salvaged dashTerminalMirrorreplay, loopback+Origin-guarded ws, gates extended to coverpackages/**.worktreeFor(reusesgenie launchgroup worktrees, never mints); runtime-adaptive read-only sqlite (node:sqlite server / bun:sqlite tests), proven on 25 real wishes.chat-backend(one lazy read-onlyClientSideConnectionper hired agent, @-mention-only, named fail-loud events) behind the isolation wall; capability-table badges (shared-memory = Hermes only). R1 ssh-ACP proven live (hermes acp→ "PONG",rlmx acptransport overssh localhost); Hermes two-facesstate.dbbridging demonstrated..docs-vendorsubmodule).Verification
bun run checkat the pre-existing 4-failure agent-sync baseline (zero genie-ui failures).Merge notes
.docs-vendorpointer bump — the docs branchdocs/genie-ui-two-faces(submodule commitb8942eb) must be pushed + PR'd toautomagik-dev/docsfirst; recursive-submodule CI would break on an unmerged SHA. Bump the pointer after that merges.startAll()(record or refine); conductor auto-advance is the next wish (register theblocksedge then).@hermeschat round-trip, non-mutation spot check — full checklist in the final-gate evidence.🤖 Generated with Claude Code