Version. 0.1
Date. 2026-06-26
Status. Change request — Phase 1 of the Companion-surface arc, drafted from the settled scoping note loomworks-companion-surface-scoping-note-v0_2 (decisions: unified open; template framing — branding + language axes, ready-not-active). Builds on the merged Engine spine (CR-2026-123) + spoken intents (CR-2026-124), engine main cfeebd0. Targets the loomworks Operator-Layer frontend, main bafdb07. Markdown primary (technical consumer: Claude Code). Halt-before-push; per-step commits with the frontend test suite green at each commit; explicit-path staging (never git add -A); per-push explicit Operator authorization.
CR number caveat (drift-surface). Numbered CR-2026-125 assuming it is the next free number after CR-2026-124. At Step 0, CC confirms the next free number in change-requests/ and renames files / in-document number / branch if taken. Filename and title must agree after any rename.
What this is, in one line. The Engine has the full spoken focus loop (open/close/where-am-I); the frontend consumes none of it. This wires it in — visible as a top-bar focus chip, settable by click (unified with navigation) and by voice — built template-clean so this Companion is the reference template for many custom Companions.
Decisions this CR rests on (settled; do not relitigate):
TopBar.tsx, which today carries inline hex constants — a hole in the template promise (a custom Companion could not re-skin its top bar from token swaps). Cleaning it completes the template requirement on the chip's host; it is not scope-creep. The considered alternative (new-files-only guard, grandfather TopBar) was set aside because it would leave the template's own top bar un-swappable. Caution carried: TopBar is shared (the in-engagement surface renders it), so tokenization is verified against both surfaces, and one-off shades get new tokens rather than a blind find-replace.
Source grounding. Drafted from the Step 0 grounding loomworks-companion-surface-phase1-step0-grounding-v0_1.md (read-only, 2026-06-26), which verified the focus endpoints on engine cfeebd0 and the frontend coordinates on loomworks bafdb07. Every path, signature, and shape below is carried from that grounding and re-confirmed at Step 0 (the frontend may move between draft and execution).
Seed alignment — checked. Seed v0.12, architecture spec v0.6. A Companion surface is the most seed-governed thing in the product; this conforms on every surface-governing commitment: plain-terms discipline (enforced by the existing vocabulary-wall and extended here), only-show-what's-available, identity-not-email (the chip shows number + name + E####, never UUID), Companion-as-entity / AI-invisible, operator-authority over state transitions (open/close set focus, not artifact state). Template-readiness is the consumer-side expression of the architecture's white-label hooks. No conflict visible.
Today, the Engine knows "where you are" — say "open Goosey" and your unqualified commands land in Goosey. But the screen shows none of it: there's no indicator of where you are, and clicking an engagement only navigates to its page without telling the Engine that's your focus. This wires the two together.
After this lands: a top-bar chip reads "here: 7 · Goosey · E0007" (your current engagement by number, name, and E-label — never the hidden UUID). **Clicking an engagement opens it and sets focus as one act. Saying "open Goosey" or "close"** moves focus and the chip follows. Your unqualified commands land where the chip says you are; after "close," the chip clears and the next command makes the Companion ask which engagement.
It is built template-clean: every color is a design token (so a custom Companion re-skins by swapping tokens), every user-facing string is a registered, translatable unit (so a custom Companion translates without code changes), and the Companion's name comes from data. A guard test fails the build if a new component introduces a raw color or an inline string — the template rule guaranteed in code. As part of this, the shared top bar (TopBar) is itself tokenized, because the chip lives in it and a template's top bar must be swappable.
In scope.
src/lib/api/me-current-engagement.ts): typed calls to GET /me/current-engagement (rich detail) and PUT /me/current-engagement (set by engagement_id or handle; neither = close), mapping the 404 (plain miss) and 409 (ambiguous candidates) into a typed result. Added to the vocabulary-wall allowlist (it carries the wire field engagement_id).CurrentEngagementProvider): a context holding the current focus (the rich GET detail), exposing { focus, setFocus, clearFocus, refresh, loading }. Owns GET-on-change (PUT returns only the id, so the provider refetches GET to get the chip labels).TopBar, reading the provider. Number + name + E####; never the UUID. Hidden when not in an engagement (only-show-what's-available) — or shows a plain "not in an engagement" affordance per the mockups (Step 3 decides the exact empty-state shape against the mockup).TopBar.tsx's inline hex/rgba constants to design tokens, adding tokens for any one-off shades, verified against both surfaces that render TopBar.EngagementList.onOpenEngagement gains a focus-set PUT beside the existing router.push — navigation and focus become one act, without regressing navigation.rgba(, hardcoded brand strings outside strings.ts, and inline user-facing text — guaranteeing the template rule in code.FOCUS string group in strings.ts, functions-not-concatenation (chip label, opened/closed confirmations, not-found, ambiguous-disambiguation).Out of scope.
The Step 0 grounding (...-step0-grounding-v0_1.md) confirmed the coordinates below on bafdb07. This re-confirms them live (the frontend may have moved) and settles the two guard specifics. No edits, no branch, no commit in Step 0.
change-requests/; rename if 125 taken.loomworks main SHA (was bafdb07); Vitest baseline (was 470 passed / 62 files); framework versions (Next 16.2.5, React 19.2.4, Tailwind v4).GET/PUT /me/current-engagement shapes on the live engine main (was cfeebd0): GET → CurrentEngagementDetail (in_engagement, current_engagement_id, name, operator_sequence_number, display_identifier); PUT body {engagement_id?, handle?} → {current_engagement_id}; 404 string detail; 409 {reason, handle, candidates[]}.src/lib/api/me-settings.ts shape (result-discriminant + bypassUnauthorizedRedirect); api.ts err.detail carries the parsed 409 object.WorkspaceProvider (fetch-on-mount) and the mount order in src/app/layout.tsx (AuthProvider > SSEProvider > CompanionPanelProvider > WorkspaceProvider); confirm where CurrentEngagementProvider slots (needs useAuth).strings.ts group shape; confirm still English-only, no locale layer.tokens.css --color-* set; enumerate TopBar.tsx's current inline hex/rgba constants (was VELLUM_SOFT="#F0EADB", ACCENT_SOFT, RULE, TEXT_MUTED) and report which map to existing tokens vs. need a new token added. Confirm both render sites (engagement-navigation and InEngagementSkeleton.tsx:31,191).tests/components/vocabulary-wall.test.ts mechanism + WIRE_BOUNDARY_FILES allowlist; confirm the template-clean guard can be the same static-scan + allowlist shape.EngagementList.tsx onOpenEngagement (~129–139) still pure router.push; row: ProjectSummary carries display_identifier + project_id.Step 0 settles two guard specifics (report a recommendation, Operator confirms if non-obvious):
#[0-9a-fA-F]{3,8} and rgba?( in component files under the guard's scope (new files + TopBar). Confirm no token-definition file (tokens.css) is in scope (tokens define the hex; components consume classes)."Loomworks"/"Companion" (the brand literals) outside strings.ts, and user-facing JSX text not routed through a strings.ts group. Confirm the exact detectable pattern against how vocabulary-wall.test.ts walks files, so the guard is precise, not over-broad.Step 0 halt conditions. Halt if: the focus endpoints differ from the grounding in a way that changes the adapter; TopBar's structure has changed such that tokenization is materially different; or the guard cannot be expressed as a static scan without flagging legitimate code. Re-ground, report, do not silently adjust.
Step 0 archival commit. Archive this CR as the branch base; record baseline. Only Step 0 commit. Do not push.
Branch off main at the Step 0 archival commit: cr-2026-125-companion-surface-phase1. Explicit-path staging at every commit.
Create src/lib/api/me-current-engagement.ts matching the me-settings.ts convention:
getCurrentEngagement(): Promise<CurrentEngagementDetail> — api<CurrentEngagementDetail>("/me/current-engagement").setCurrentEngagement(target: { engagementId: string } | { handle: string }): Promise<SetFocusResult> — PUT with {engagement_id} or {handle}; returns a typed result discriminant: { kind: "ok"; currentEngagementId: string | null } | { kind: "not_found"; message: string } | { kind: "ambiguous"; handle: string; candidates: FocusCandidate[] } | { kind: "unauthorized" } | { kind: "error"; status: number; message: string }. Map ApiError: 404 → not_found (detail is string); 409 → ambiguous (detail is the {reason, handle, candidates} object via err.detail); 401/403 → unauthorized; else error. Use bypassUnauthorizedRedirect: true.clearCurrentEngagement() — PUT with empty body (neither field) → close.src/lib/types.ts: CurrentEngagementDetail, FocusCandidate (engagement_id, display_identifier, operator_sequence_number), SetFocusResult.lib/api/me-current-engagement.ts to WIRE_BOUNDARY_FILES in the vocabulary-wall test (it carries engagement_id).
Tests. Adapter unit tests: GET maps the detail; PUT ok/not_found/ambiguous/unauthorized/error each map correctly (mock api/ApiError). Vocabulary-wall green (adapter allowlisted).
Commit: CR-2026-125 step 1: me-current-engagement adapter (typed focus result) + vocab-wall allowlist. Do not push.
Halt condition. If the live endpoint shapes differ from Step 0, or the 409 detail doesn't arrive via err.detail, stop and report.
Create CurrentEngagementProvider matching the WorkspaceProvider pattern:
createContext, useCurrentEngagement() hook with null-guard.CurrentEngagementDetail (or null/loading). GET-on-mount.{ focus, loading, setFocus(target), clearFocus(), refresh() }. setFocus calls the adapter, and on ok refetches GET (PUT returns only the id) so labels are current; returns the SetFocusResult so callers can branch on ambiguous/not_found.src/app/layout.tsx inside AuthProvider (needs the person), beside WorkspaceProvider.
Tests. Provider tests (RTL): mounts → GETs → exposes focus; setFocus ok → refetches → focus updated; setFocus ambiguous → returns candidates, focus unchanged; clearFocus → focus null.
Commit: CR-2026-125 step 2: CurrentEngagementProvider (GET-on-change focus context). Do not push.
Halt condition. If the provider cannot mount within AuthProvider cleanly, or GET-on-change loops, stop and report.
Convert TopBar.tsx's inline hex/rgba constants to tokens. Per Step 0's enumeration: each constant either maps to an existing --color- token (use the Tailwind token class) or, if it's a one-off shade (e.g. a soft* vellum with no existing token), add a token to tokens.css and use it. No blind find-replace — each replacement verified to render identically.
Verification (critical — TopBar is shared). After conversion, verify both render sites are visually unchanged: the engagement-navigation surface and the in-engagement surface (InEngagementSkeleton.tsx:31,191). RTL/snapshot tests for both; manual/visual confirmation noted in the completion record.
Tests. TopBar renders unchanged on both surfaces (snapshot or explicit class assertions); no inline hex remains in TopBar.tsx (the guard, added in Step 5, will enforce this — Step 3 leaves TopBar clean so Step 5's guard passes).
Commit: CR-2026-125 step 3: tokenize TopBar (inline hex -> tokens; new tokens for one-off shades) verified on both surfaces. Do not push.
Halt condition. If a constant has no clean token mapping and adding a token would change the rendered shade (i.e. the inline hex was itself off-brand), stop and report — that's an Operator color decision, not a silent substitution.
(a) The chip. A FocusChip component in/for TopBar, reading useCurrentEngagement(). Renders here: {number} · {name} · {E####} via a FOCUS string-group function (registered, translatable). Tokens only (TopBar is now clean). Hidden when in_engagement is false (only-show-what's-available) — or the mockup's plain empty-state if Step 0/the mockup specifies one; default hidden. UUID never rendered. The chip's click affordance mirrors the existing TopBar toggle-and-PUT precedent (message-order indicator): click → open picker or close, per the mockup (default: click opens the engagement picker; a close control clears focus).
(b) Unified open. In EngagementList.onOpenEngagement, beside the existing router.push, call setFocus({ handle: row.display_identifier ?? row.project_id }) (or { engagementId: row.project_id }). Navigation and focus set together. Navigation must not regress — the router.push still fires; the focus PUT is additive. If the focus PUT fails (not_found/error), navigation still proceeds (the surfaces decouple gracefully) and the failure surfaces quietly, per only-show-what's-available.
(c) Strings. Add the FOCUS group to strings.ts (functions-not-concatenation): hereChip(name, number, label), notInEngagement, opened(name), closed, notFound(handle), ambiguous(handle, candidates). Brand/name from data, never literal.
Tests. Chip renders the three labels from provider state; hidden when not in engagement; never renders UUID. Unified open: click → both router.push and setFocus fire; focus-PUT failure does not block navigation. Strings: chip text comes from the FOCUS group.
Commit: CR-2026-125 step 4: focus chip in TopBar + unified open (focus-set beside navigation). Do not push.
Halt condition. If unified open cannot be added without regressing navigation, or the chip cannot read the provider cleanly, stop and report.
(a) Spoken open/close. The composer/voice path (the converse surfaces that already send utterances) carries "open X"/"close" such that the Engine's classified open/close intents drive focus, and the result updates the provider (and thus the chip). On the Engine's spoken response: a successful open/close updates focus; an ambiguous (409) surfaces the candidates as a spoken choice (rendered by number + E####, never UUID) via the FOCUS.ambiguous string; a not-found (404) surfaces FOCUS.notFound. (The Engine already classifies the intents and resolves; this wires the frontend to reflect the focus change and render the disambiguation — it does not re-implement resolution.)
(b) The template-cleanliness guard. Add tests/components/template-clean.test.ts (the vocabulary-wall static-scan shape): walk the guard scope (the new files from Steps 1–4 + TopBar.tsx), failing on: raw hex (#[0-9a-fA-F]{3,8}) or rgba?( in component files; hardcoded "Loomworks"/"Companion" outside strings.ts; user-facing JSX text not routed through strings.ts (per the Step 0 detectable pattern). An allowlist constant names the scoped files (mirroring WIRE_BOUNDARY_FILES). The guard passes because Steps 1–4 built clean and Step 3 tokenized TopBar.
Tests. Spoken open/close: an "open" utterance updates focus + chip; "close" clears; a 409 renders the spoken candidate choice; a 404 renders not-found. The template-clean guard runs green over its scope (and would fail if a raw hex or inline string were introduced — verified by a deliberate temporary violation in the test's own fixture, then removed, or by asserting the scan finds zero in-scope violations).
Commit: CR-2026-125 step 5: spoken open/close wired to focus + template-cleanliness guard (new files + TopBar). Do not push.
Halt condition. If the guard flags legitimate code it cannot distinguish from a violation (over-broad), or the spoken path cannot update the provider, stop and report.
Run the full Vitest suite (expect baseline + the new tests, green). Then the end-to-end walk (component/integration level, against a mocked or live engine per the repo's e2e convention):
Halt and report. Verification-only — no commit.
router.push; navigation still works if focus-PUT fails.strings.ts only; the guard enforces it.
After Step 6, halt before any push. Report: the confirmed CR number and branch base; the adapter's typed-result mapping (404/409/etc.) and its vocab-wall allowlisting; the provider's GET-on-change behaviour; the TopBar tokenization (which constants → existing tokens, which → new tokens added, both surfaces verified unchanged); the chip (labels from provider, hidden-when-not-in-engagement, no UUID); unified open (focus-set beside navigation, no regression); spoken open/close (open/close/ambiguous/not-found paths); the template-clean guard (scope, what it forbids, green); the full Vitest counts; and the walk result. On the Operator's review and explicit authorisation, push CR-2026-125. The judge-seam follows: the Operator looks at the wired surface and decides whether the frame is sound (→ Phase 2 re-skin CR) or patchwork (→ rebuild). Phase 2 is not drafted until that judgment.
Six steps, each its own commit, each independently revertible to a green Vitest suite. No schema/Engine change (frontend-only). TopBar tokenization (Step 3) is the one change to existing shared code — it is verified unchanged on both render sites and reverts as a single commit. The template-clean guard (Step 5) is additive (a new test). Unified open (Step 4) is additive to onOpenEngagement (the router.push is untouched; the focus-set is added beside it), so a revert restores pure-navigation behaviour exactly.
DUNIN7 — Done In Seven LLC — Miami, Florida Companion surface Phase 1 — change request — CR-2026-125 — v0.1 — 2026-06-26