Conversation
…name (#2914) Assigning a library skill whose name matched a `.claude/skills/<name>/` the agent wrote itself overwrote the agent's copy, gitignored and untracked the directory, and buried an `unmanaged_dir_overwritten` warning in the assignment response. The library and the agent's repo share one flat namespace on the agent side, so a name match is not proof of the same skill — the library silently won. The inject path now refuses a directory that exists without the platform's `.trinity-skill.json` marker before a byte is staged: no archive, no restore, no `.gitignore` line, no untracking, not listed under CLAUDE.md Platform Skills. `force` (manual Sync) does not override it; a platform-managed dir keeps upgrading in place; an unreadable probe keeps the pre-existing fail-open direction. The per-skill status is `conflict` — neither `injected` nor `failed` (overall `success` stays true so the fleet re-inject does not alarm on a standing conflict every auto-sync); `skills_conflict` + `conflicts[]` carry it, and `deliver_assigned` reports `conflict` / `partial` + `conflicts[]`. The verdict is recorded on the assignment row (`agent_skills.delivery_status`, SQLite `agent_skills_delivery_status` + Alembic `0065`), carried across the bulk-replace PUT for retained names, cleared by the next injection where the name lands, and gone on unassign. The Skills tab reads it off the rows so the badge, the explanation (which skill, the agent's own copy is what runs) and an inline "Unassign library skill" action show on a fresh load; `inject()` re-reads the rows so a resolved conflict clears without a reload. MCP passes the delivery block through, `sync_agent_skills` names conflicts on its success branch, and `get_agent_skills` carries `delivery_status` per row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce-text specs over components (#2918) Two PRs ejected from the 2026-09-20 merge train and three from the previous one landed safety-critical UI logic — a fleet-delete confirmation's `canRemove`, a modal's Esc/focus-trap contract — in the one tier with no executable coverage: regexes over the component's source, which an inverted predicate passes byte-identically. Each justified itself with "this repo's vitest is node-only, nothing can mount", which has been false since `vitest.config.js` grew `plugins: [vue()]` + jsdom + @vue/test-utils for a per-file `// @vitest-environment jsdom` opt-in (precedent: portalThemeSwitch.spec.js). The capability was never the gap; its discoverability at the decision point was. Three things close it: 1. The design-system contract (read before any src/frontend change) gains a "Testing a component — the harness mounts" section and a PR self-check item naming the opt-in, the copyable precedent, and the bar: source-text- only coverage of a predicate that gates a destructive verb, a keyboard contract or a store write is not acceptable. design-system.md §9 and docs/testing/STRATEGY.md carry the same rule. 2. The methodology names the tell (.claude submodule bump → trinity-dev 5b09829 + 437674f): /validate-pr §5.4, /review Step 2.5 and the /implement checklist state the verdict rule and the escape, and merge-train lists the new baseline among the regenerable ratchets. 3. A ratchet, the same shape as raw colours and loading gates: scripts/scan-source-text-specs.mjs counts, per spec, non-import `.vue` literals under components/ or views/ in a spec that calls readFileSync; source-text-baseline.json freezes today's 86 specs / 271 reads; tests/unit/sourceTextRatchet.spec.js fails when a spec grows, when a NEW spec reads SFC source without a `@source-text-pin: <reason>` marker (the reviewable escape for an AST-shaped call-site guard or a parity table), or when an entry is stale. The failure message names the harness and the precedent — the point-of-decision hint the issue asked for. Negative- controlled: a new unpinned spec fails, the same spec pinned passes, and a +1 read in a baseline spec fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…; delivery copy and draft fixes (#2914) Merge-train review on #2920: marker-absence was read as "the agent authored this" and refused, but the platform itself wrote marker-less directories on two live paths — the pre-#384 legacy fallback (SKILL.md only) and a restore that dropped the meta member — so each would have turned its own package into a permanent, unrepairable conflict on the next sync (AC#6 violated). Both are closed at the write: `_legacy_fallback` writes the marker beside SKILL.md (manifest = what was actually written), and `_restore_skill` writes the marker back directly when the agent's `restored` list lacks it (`marker_written_directly`). If the marker cannot be written the injection is reported `failed` (`marker_not_written`; the legacy write is rolled back) rather than a half-managed directory. Five tests cover the two paths, their failure arms, and the legacy-marker round trip that closes AC#6. Also from the review: `deliveryText` grammar ("its own a skill") with a real plural arm; the `not_delivered` arm now names conflicts beside the failure it retries; the Skills tab resets its draft only when the assigned SET changes, so a Sync (which re-reads the rows for the verdict) no longer wipes unsaved ticks; the conflict copy says "a directory the platform did not create — usually a skill it authored" rather than asserting authorship. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…urn (#1923) Six bespoke overlays each re-implemented the same markup and each omitted the same two behaviours: Esc did nothing, and Tab walked out behind the overlay. Verified still true on `dev` before starting — **zero** `Escape` references across all six files (the `@keydown` hits there are Enter-to-submit), no shell primitive, no focus trap anywhere. `BaseModal` owns exactly four things — overlay, Esc, focus, scroll lock — and nothing about content, so adopting it is deleting two wrapper divs rather than rewriting a dialog. It teleports to `<body>`: several of these modals are declared inside panels that establish a stacking context, where a `z-50` overlay nested in one renders BEHIND its siblings. **The decidable half is a separate pure module, and that is the point.** This repo's vitest runs `environment: 'node'` with no jsdom/happy-dom and no `@vue/test-utils` — I checked, because a focus trap written entirely inside an SFC would be a rule no unit test could reach, and source-text assertions would prove only that it had been TYPED. `utils/focusTrap.js` holds every decision as a function over plain data (tabbable filtering, the Tab wrap, the dismiss-key predicate, safe-action selection, backdrop identity) with 25 tests. What that leaves uncovered is the wiring itself — listener attachment, the focus() calls — which needs a browser and belongs to e2e. Said plainly rather than implied by a green suite. Three decisions worth naming: * initial focus goes to the SAFE action, and destructiveness is DECLARED (`data-destructive`) rather than guessed from label text, which would be wrong in every language but English. A dialog that opens with Delete focused turns a reflexive Enter into data loss. * backdrop dismissal compares identity against the overlay node, not a rectangle — a rectangle test mis-fires for a select popup or date picker rendered at the document root and closes the modal under the user. * a modified Escape (Ctrl/Cmd/Alt/Shift) does not dismiss; that is a browser or OS gesture, not an intent to close. Migrated in this commit: `SystemViewEditor.vue` and `NavBar.vue`'s build-info modal. The remaining four files are enumerated in the PR with their exact overlay bounds; they are 80-230 line tag surgeries and are deliberately left for a reviewed pass rather than done blind in one go. One acceptance-criteria item is stale: `views/Agents.vue` was deleted by ent#260 (the Agents page folded into the Dashboard list view), so its bulk-tag popover no longer exists. Related to #1923 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…y, and a mounted spec for the wiring (#1923) Merge-train review on #2778, twice: the shell's rules were tested, its wiring was not, and the docblocks justified that with a claim that was false on this head — the repo mounts components (per-file jsdom opt-in, @vue/test-utils, #2918). Two defects sat in exactly that untested wiring. 1. The scroll lock was global state written per instance: `immediate: true` ran the else-branch on every mount and `onBeforeUnmount` cleared unconditionally, so a nested dialog (the #2780 shape — a ConfirmDialog inside another modal's slot) unlocked the page the moment the outer opened, and any modal's unmount clobbered another component's lock. Now `utils/focusTrap.js::createScrollLock` is a ref count over one value (first holder saves and hides, last release restores) and every BaseModal shares `bodyScrollLock`, taking and releasing exactly its own count. 2. The overlay had no `tabindex="-1"`, so `overlay.focus()` was a no-op and Esc did nothing once focus left a control (a click on dialog text lands focus on the nearest focusable ancestor, which was <body>). `tests/unit/baseModal.spec.js` mounts the shell: initial focus on the safe control, Tab wraps, Esc emits close and focus returns, the overlay is focusable, a modal with no tabbable child is still dismissable, backdrop semantics, the lock is taken/restored, two nested modals share one lock, and unmount releases only what the instance held. Mutation-checked: gutting @keydown, the focus return, the tabindex or the lock each turns it red. Also: the false "node-only vitest" claims are rewritten in all three docblocks; the overlay's p-4 now IS the 16px phone gutter (the two adopted panels drop their mx-4, which had doubled it to 32); the design-system docs name BaseModal as the modal shell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…irm() sites move onto it (#1924) Stacked on #1923 — `ConfirmDialog` adopts the `BaseModal` shell built there. The issue reads as "replace confirm() calls", but the thread rules otherwise (webmixgamer, 2026-08-21), and I re-verified it still holds on dev: ConfirmDialog.vue has no focus trap, no initial focus on the safe action, no Esc handler, and its DOM order is confirm-first — so this issue's AC "Initial focus lands on the safe action" needs the primitive itself fixed, not just the call sites. Confirmed: zero Escape / @keydown / focus() / role="dialog" in the file, and `variant="danger"` at :58 ahead of `variant="secondary"` at :66. **The confirm-first DOM order is fixed WITHOUT reordering the buttons.** The confirm is marked `data-destructive`, which is what `focusTrap.initialFocusIndex` skips, so focus lands on Cancel while the DOM order and `sm:flex-row-reverse` stay exactly as they were and nothing moves on screen. Reordering was the obvious fix and the worse one: it would have changed the rendered layout of a dialog seven components already use. Adopting BaseModal gives all seven existing callers Esc-to-close, a focus trap and focus return at once — none of them change. Every `data-testid` is preserved. `confirm-dialog-backdrop` is the one that goes (BaseModal owns the overlay now); checked first — no test or component referenced it, and the two that ARE used (`confirm-dialog`, `confirm-dialog-confirm`) are untouched. Call sites migrated here: `SystemViewEditor` delete-view and `GitPanel` clear-PAT. Each names the verb ("Delete view", "Clear token"), restates the consequence, and offers a named safe action instead of "Cancel/OK". Remaining on the issue and NOT done here: Settings.vue (3) and SchedulesPanel.vue (3), plus MobileAdmin's bespoke overlay and the two unconfirmed single-click actions. Not touched, by the thread's ruling: `/m`'s approval submit step (#2370) is an inline p19-shaped flow by design and must not be re-modalised. **Baseline edited by hand, not regenerated.** ConfirmDialog IMPROVED (raw_gray 9 -> 7 — its bespoke backdrop and the gray-500/gray-900 overlay went away), and a stale ceiling fails the guard too. A wholesale `--baseline` run deleted the entire `refrozen` provenance block and absorbed canvas/CanvasDocument.vue and views/SharedCanvas.vue — two files that landed on dev after this branch was cut — so the edit is scoped to the two entries that actually moved, per the rule the 2638 note already states. Related to #1924 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
… and NavBar (#1925) Three audited surfaces hand-rolled a tab/nav strip instead of using the `OverflowTabs` primitive (#1114). Settings and Operations now render their tabs through it; NavBar gets the same measured priority+ split without mounting it, because its items are `<router-link>`s that must keep a real href and the primitive's are `<button>`s that emit a selection. NavBar is the case the audit called worst. Its link row was an `overflow-x-auto` scroller with the scrollbar suppressed inside the 64px bar (#1789), so an overflowed link stayed reachable but became invisible and undiscoverable — the bar gave no signal that more navigation existed. It now renders the links that fit plus a counted "N more ▾" disclosure, with the active state reflected on the trigger when the current route's link is one of the hidden ones. The fit arithmetic is SHARED, not re-derived: `utils/overflowFit.js` holds the rule verbatim from `OverflowTabs::recompute`, and both surfaces call it. It also gained a `gap` term, which NavBar needs and the tab strip does not — the tabs are padding-spaced, while the nav row carries a real flex gap that is invisible to the per-item rects, and a gap-blind sum hides ~120px on a six-link row (one whole link) and so keeps the last one inline to clip. Both new modules are pure because vitest runs `environment: 'node'` with no mount harness, so a rule inside an SFC is one no test can reach (the ent#392 precedent). `utils/navLinks.js` likewise derives the link set once — a strip that can hide a link renders the same link three times (inline, menu, hidden mirror), and three hand-written copies is three places for an active predicate or a badge to drift. Colours: the new code is semantic-token only, so the active underline moves from raw `blue-500/400` to `action-primary` (the primitive's own active token) and the Enterprise PRO pill from raw `purple-*` to `accent-purple-*`. Visible as a slight indigo shift on the nav's active indicator. Triage of the audit's nine scanner candidates: every one is a bounded wide table or a preformatted code block, where horizontal scroll inside the container is the correct treatment (design-system principle 7) — collapsing columns into a "More" menu would hide data, not navigation. None migrate; each is marked in place so a later audit does not re-flag it. The advisory two-pill strip in Audit.vue is a segmented control, a different primitive, and is recorded as such. `workspaceNewTab.spec.js` (ent#456) asserted `target`/`rel` against NavBar's template text; that truth moved into `navLinks.js`, so the NavBar half now drives the data and additionally pins that the strip binds both fields onto its links — the half a data-driven refactor can get wrong is correct data that never reaches the DOM. Baseline: NavBar raw_nongray 35 -> 19, raw_gray 125 -> 105. Edited by hand rather than regenerated, per the `refrozen` note — a wholesale run deletes that block. Fixes #1925 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
Caught by a live check, not by the suite: at 1440px the strip hid four of five links behind "4 more". The row and its parent cluster sized to content, so `clientWidth` reported the row's own min-content — and because the inner nav is `overflow-hidden`, that is near zero — making the fit rule correct about a container that was lying to it. The old #1789 row never needed this: a row that SCROLLS sizes to its content and lets the overflow happen, while a row that COLLAPSES has to be told how much space it actually has. `flex-1` on both supplies it; `justify-between` plus the controls' `flex-shrink-0` keeps the right-hand cluster where it was. Verified against the local dev instance across 1440/1300/1200/1100/1000/900/800/700 and back up again: one row at every width, never clipped, collapsing at 800 and 700 and re-expanding on the way back up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…ll row (#1925) `navbar-overflow.spec.js` is #1789's guard, and its third test asserts `overflow-x: auto` on the link row — the hidden-scrollbar scroller this issue deliberately replaced. It went red the moment the strip started collapsing, and I missed it because I grepped `src/` for that pattern and never `e2e/`. Confirmed it is this branch and not the tree: the spec passes against plain `origin/dev` built and served the same way, and fails here. Properties 1 and 2 — links never collide with the controls, controls stay on-screen and the user menu stays hit-testable — are unchanged and still pass; they were never about the mechanism. Property 3 now pins what #1925 actually guarantees: the links that fit render inline, the rest collapse into a counted "N more" disclosure, the count agrees with what the menu holds, every hidden link is a real href, and Escape closes it. It also pins the ABSENCE of the old mechanism — no `overflow-x: auto`, no scrollWidth past the box. A future refactor reintroducing the scroll row would restore exactly the undiscoverable-link failure this issue removed, and this is where that gets caught. `settleNav` is not politeness: the strip re-measures on a rAF after a resize, so a snapshot taken in the same tick as `setViewportSize` reports the previous split — "it fits" one frame before the links move into the menu. That is how the first version of this rewrite failed, and the fix is to sample until two reads agree rather than to widen a timeout. Verified against this branch's build: 4/4 pass, and the collapsed branch is genuinely exercised — at 640px the trigger reads "4 more" with 1 link inline and 4 in the menu, at 1440px there is no trigger and 5 links inline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…anical, per the merge-train note on the PR CodeQL js/incomplete-multi-character-sanitization (train #2896, alert 364) flagged the one-shot replace in a source-text guard. A fixpoint loop is the shape the rule accepts; behaviour on every real input is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…md (#2914) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ds and writes their memory (Abilityai/trinity-enterprise#637) A role companion's proactive brief is a scheduled run, and a scheduled run could not touch an individual user's memory: `write_user_memory` refused every `schedule` trigger, so a brief could not carry the open loops and commitments that make the next brief better than the last. That gap is what forced "one agent per seat"; operator ruling R26 removed it. The seat is ent#498's address, read off the execution row, never sent. The ent#498 stamp already writes `source_channel='portal'` + `source_channel_client=<email>` after the roster and block checks; `services/schedule_seat_memory.seat_for_execution` reads it back for a `schedule`-triggered row and nothing else, and `routers/public_memory.py` accepts the write for exactly that case. `source_user_email` is deliberately not stamped — `client_portal/work` reads it as "work I started" and two stream-ownership checks key on it. No address → refused as before, now saying "names no one". The run reads before it writes: the tool is whole-blob replace, so the internal dispatch composes the seat's MEM-001 memory block plus a seat note into `execute_task(system_prompt=…)` on both branches, only after the stamp landed. The public persona prompt is not folded in. One write boundary, now with history: `db.write_public_user_memory_agent_notes` replaces the notes and records `public_user_memory_writes` (previous + new, execution, trigger, schedule) in one transaction — ent#419's screen lands there once, and its rollback layer is this table. The Workspace agent details gain "What it remembers about you" (`PortalAgentMemory.vue`, `GET /agents/{name}/memory`) listing the writes with schedule name and time, and Undo (`POST …/memory/writes/{id}/undo`) — latest-first, a named 409 for a later write, uniform 404 for a foreign id. Dual-track migration (SQLite `public_user_memory_writes_table` + Alembic `0065`), CASCADE in AGENT_REFS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erve the same seat (Abilityai/trinity-enterprise#637) The manual trigger stamps triggered_by='manual' with the operator's own source_user_email (#1970); the brief is still addressed to the seat, so the seat resolver keys on a real schedule_id + the ent#498 portal stamp across all three fire paths and never consults source_user_email. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…bilityai/trinity-enterprise#637) Found live: the Workspace projection keyed the write's kind on triggered_by == 'schedule', and a Run-now fire is 'manual', so the seat's own write rendered as "In a conversation". The boundary records schedule_id only for a seat run, so that is the key. Pinned with the projection test and the named-refusal test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the time and the undone mark (Abilityai/trinity-enterprise#637) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…065 — the three 2026-09-21 schema PRs are stacked (Abilityai/trinity-enterprise#637) Three open PRs each added a 0065_* off 0064, which alembic-heads reads as three heads and `upgrade head` would apply nothing. #2924 now stacks on #2920 (base branch fix/2914-skill-name-conflict) and its revision is 0066 off 0065_agent_skills_delivery_status. The migrations.py append collision is resolved by keeping both entries, #2920's first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…th metric freshness, readiness (Abilityai/trinity-enterprise#527) When a companion has a role (Tandem, ent#497), the Info rail shows a Role card: the role it fills, the objectives it owns or supports with each metric's value / target / freshness, the viewer's relationship, and its readiness. Framework §8's organisation UI shrunk to one agent — files are truth, the card is a projection. The Role + Readiness half; the relationship line reads ent#500's assignment when it lands and states "no assignment recorded" until then. Files are truth, read through the platform, never a second store: `client_portal/role_card.py` reads template.yaml (`x-role`, `x-canon`), `<canon>/roles/<id>.yaml`, `<canon>/objectives/*.yaml` and the agent's own `/api/metrics` through the agent door on every request; ids and paths that reach a read are validated, text is bounded, and every failure is named (`role.error`, `unavailable`) — no `x-role` means no card at all. A metric is stale when its value is missing, unstamped, or older than the framework's 30-day bound, and stale renders as stale, never as current. Readiness is the agent owner's stamp (#663, ruled 2026-09-20). `x-role.status` is agent-writable, so `agent_role_readiness` (dual-track: SQLite + Alembic 0065, CASCADE in AGENT_REFS) holds the state, when it changed and who flipped it; a template that says `ready` with no stamp is shown as calibrating with the note that no owner stamped it. The flip is `POST …/role/readiness`, gated on the platform's owner of the agent record (named 403 for everyone else, no route for the agent), behind a confirm that says it does not switch any schedule on. While calibrating the card shows the viewer's own walkthrough count (asks in their Main, capped at ten, and their thumbs-down). `PortalAgentRole.vue` is mounted-tested (#2918); the backend card is driven through a fake agent door; the stamp against a real SQLite file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…) — mechanical, per the merge-train note on the PR The train (#2922's source-text ratchet + this stack) found the joint break: this spec reads 20 SFCs by text and is a NEW spec with no baseline entry. It is a legitimate pin — an adoption guard over a set of call sites a mount cannot prove — so it declares itself one, per the ratchet's escape hatch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g the page (#2197) Three rigid flex rows scrolled the page body below 1024px, against the design-system rule that wide content scrolls inside its own container. The report blamed the tab strips, and that is not what was wrong. `OverflowTabs` was collapsing correctly the whole time, at 375px included: the hidden mirror row it measures against lives in a 0x0 `overflow: hidden` box and is invisible to the user and to `scrollWidth` alike, but a probe that does not skip clipped subtrees reports it as the widest thing on the page — which is how a measurement sweep came to name `NAV.-mb-px.flex` and the "More" button. Measured with clipped subtrees excluded, the real offenders are: * **Agent Detail** — three `AgentHeader` rows, each a non-wrapping flex row of two rigid clusters: identity + Workspace/Talk/Running, toggles + Tags + the live CPU/MEM/uptime readout, and the lifetime-cost line. Together ~790px of min-content, so the page widened at every width below ~1024px (+496px at 375). * **Settings** — the Default Model `<select>`, `flex-1` with the default `min-width: auto`. A select's min-content is its widest OPTION ("Claude Opus 5 — Most capable Opus (latest) (recommended)", ~417px), so one control scrolled the page by 157px at 375. `min-w-0` is the whole fix. The Dashboard overlap is a different failure with the same root shape. #1830 made the stats cluster elastic so it degrades instead of being clipped — but elastic with no floor means it collapses to ZERO, so the pressure never reached the controls and the row never wrapped. Measured at 640px: stats 0px wide (its clipped children still sitting under the controls' first button — the 8px the spec reports), controls pinned at their 692px max-content and running ~70px past the row's right edge. The row now wraps, the stats cluster carries the ladder's own agents-only floor (~71px) so the overflow becomes a wrap, and the controls may shrink and wrap their own buttons rather than being pinned wider than the row. Rendering above ~900px is unchanged — measured single 30px row at 1600/1440/1280/ 1024/900, wrapping to 91px at 768 and below, zero overlap at every width. `space-x-*` becomes `gap-*` on each row that can now wrap: a wrapped `space-x` row mis-indents every line after the first. Verified against a local dev instance in BOTH themes at 375 and 640 (light and dark, `dark` class asserted): `scrollWidth <= clientWidth` on Settings, Agent Detail, Dashboard, Operations and Library at 375/640/768/1024. `e2e/dashboard-stats-overflow.spec.js` (#1830), red on `dev`, is green. The new `e2e/body-horizontal-overflow.spec.js` measures the one thing the contract states — the document scroll width — and names the widest UNCLIPPED element on failure, because an element-shaped assertion is exactly what produced a report naming the wrong element. Proven to bite: with these three fixes reverted it fails Settings at +157px naming the `<select>`, and Agent Detail at +496px naming the action cluster. Its `ready` predicates are load-bearing, not politeness. The widest markup on both pages is gated behind a later render — Settings' admin-only `<select>` behind `/api/users/me`, Agent Detail's live stats behind the stats poll — and on a fixed settle the Settings arm PASSED with the fix reverted. That vacuous green was caught by running the negative control, not by reading the test. Fixes #2197 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
A `table` block's cells were plain text, so `**Deploy**`, `` `done` `` and `[runbook](https://example.com)` — what agents routinely put in status and reference columns — showed their literal characters. The same canvas rendered a GFM pipe table written in `markdown` prose correctly, so an agent saw two tables behaving differently, and the one the MCP tool guide recommends was the broken one. Both structured paths are fixed at once: `CanvasBlock` delegates `table` to `ReportRenderer`, and a ```table fence inside a `markdown` block delegates to the same component. Cells render INLINE markdown only. `marked.parseInline` never emits a block element, and the cell policy drops the ones raw HTML could smuggle in, so a heading or a list in a cell degrades to its own text (DOMPurify's `KEEP_CONTENT`) rather than breaking the row the #2583 gallery pins. The split is about testability, not taste. `markdown.js` cannot be imported without a DOM — DOMPurify's DOM-less stub has no `addHook` — and vitest runs `environment: 'node'`, so anything decided inside it is unreachable by a unit test. `utils/inlineMarkdown.js` holds the decidable half (the parse, the markdown-vs-text decision, the escape, the allowlist) and is executed by `inlineMarkdown.spec.js`; `renderInlineMarkdown` stays in `markdown.js` beside every other DOMPurify call, on the SAME instance and hooks, so a cell link inherits the app-wide `target="_blank"` / `rel="noopener noreferrer"` hardening and there is no second sanitizer (H-005). Only strings are parsed. A number, boolean or object keeps exactly its pre-#2771 rendering (`JSON.stringify` for an object, `String()` otherwise) and is escaped rather than parsed — running a JSON blob through a markdown parser would let its own `*` and `_` italicise a value nobody wrote as prose. Headers render on the same terms as cells: an agent that bolds a column name and bolds the values under it should not get two behaviours. `ReportTable` is shared with reports (ent#537 / #1535), so report tables gain the same rendering — the same defect, fixed once. `ReportRenderer.vue` is untouched, so the `display_hint` / `shapeOk` pins in `test_1535_report_prompt_guidance.py` are unaffected. Verified: 17 unit cases over the real configured parser; a new e2e seeds a canvas through the real `PUT .../canvas/{id}` route and asserts the rendered DOM — bold, code, a hardened link, em/del, the fenced path, and the half a unit test structurally cannot reach: a `<script>` + `<img onerror>` payload in a cell leaves `window.__2771` undefined with zero `script`/`img` nodes in the table. Red without the fix (the `<strong>` never appears), green with it. Full frontend unit suite 133 files / 2933 tests green. Fixes #2771 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
#2202) Two papercuts found together, on the one page a 31-page sweep flagged as the only source of a page-level console error — and it produced one on every tab. **The 404.** Settings read `public_chat_url` through the generic `GET /api/settings/{key}`, which answers 404 for a key nobody has written. The store already treated 404 as "unset", so nothing was broken; what was lost was the signal — a real failure on that call looked exactly like the ordinary case — and no client-side handling can suppress the browser's own network log, which is why the fix is a route and not a try/catch. `GET /api/settings/public-chat-url` answers 200 with `value: null` when unset, following the `/mcp-url` precedent: a named route for a named setting, declared above `/{key}` (Invariant #4). The generic route's 404 is deliberately unchanged — it is the documented contract for every other key and is read outside this repo. Writing the spec found a SECOND key with the same defect, unnamed in the issue: `platform_default_model`, 404ing eight times per Settings load. It needed no new route — `/api/settings/feature-flags` already carries the resolved value — so the page now reads it from there, which is also more correct: the control shows what the platform will actually use instead of blank. **The unbounded list.** MCP Keys rendered every key an instance had ever minted: measured 306, of which 294 revoked (96%), ~71KB of DOM text, no filter, no bound. Agent keys accumulate structurally — one per agent, one per #1854 rotation, one per ephemeral ghost — so the page grows for the life of the instance and the 12 keys that still work are buried in the 294 that do not. Revoked keys are now hidden behind an explicit toggle that STATES the count, the list is searchable by name/prefix/agent, and rendering is bounded at 25 rows with a "Show more". The rules are pure (`utils/mcpKeyList.js`) because vitest runs `environment: 'node'`; the non-admin agent-key filter is carried through unchanged and asserted, since it is an access rule wearing a filter's clothes. Three empties, three next actions — "No API keys" was a lie to an operator holding 294 revoked ones — but ONE piece of chrome: the wording is computed and only the action row branches, because three copies of the markup would have tripled this file's palette-class count. The new controls are built from the Base* primitives (#2122), and the two container borders they need are paid for by converting the create form's hand-rolled name input and description textarea to `BaseInput`/`BaseTextarea` in the same component: `McpKeysTab.vue` raw_gray 97 -> 83. Baseline edited by hand, not regenerated. Verified against a local dev instance (a key created and revoked to exercise the toggle, removed afterwards): every tab loads with zero 404s and zero console errors, the list renders 2 of 3 keys with "Show revoked (1)" and "2 active", the toggle reveals the revoked row while staying bounded, search narrows to a named empty state that offers the way back, and the converted create form renders and binds in both themes. Red without the fix on both halves. Frontend unit suite 134 files / 2949 tests green. Fixes #2202 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…route-set guard (#2202) — mechanical, per the merge-train note on the PR Train #2896's regression diff: the four route tests raised 'no current event loop' on the CI interpreter (get_event_loop() on a fresh main thread), and test_1028_settings_package pinned the post-split route set, so the new GET /api/settings/public-chat-url read as a route invented by the split. Neither could surface on this PR's own CI — a PR against a feature branch does not run backend-unit-test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On every cold load the conversation column rendered full width, then lost the rail's width the moment the roster arrived — sliding the composer, the thread and the header left. Since #2676 that is a 300ms animated slide rather than a one-frame jump, which is prettier and still a shift: the contract's layout-stability rule is that loading and loaded share ONE footprint and nothing moves on arrival. Measured on a local instance before the fix, the Send button at 1440px goes 1327 → 1300 (27px) as the roster lands, and 916 → 884 (32px) at 1024px. `railVisibleFor` answers "may the rail RENDER", and false for a non-ready stage is right — its tabs need the roster. But the column's WIDTH does not need the roster: it comes from the persisted rail state and is known synchronously at first paint. So the wrapper now renders on `railHasColumn || railColumnReserved` and gates only `PortalRail` inside. Space is reserved; content is not faked. `railColumnReservedFor` is deliberately NARROWER than `railVisibleFor`'s route set, and the exclusions are the interesting part: * an agent page never carries a rail, so reserving there would invent a gap; * a ROOM route is excluded even though a ready room usually has a rail, because that depends on `roomsAvailable`, which arrives ON the roster payload (#2128). Reserving against a capability we have not been told about yet would trade this shift for the opposite one on every install without rooms. So it reserves for exactly the case the bug is about: a 1:1 conversation route, mid-load. If the rail then turns out not to render, the column leaves through the existing width transition — a shrink, not a jump. Nothing about the motion changes: the `<Transition>` classes, the #2676 voice-canvas swap and `motion-reduce` are untouched, and the reserved column is present from the first frame so its enter never runs. Verified: the composer's x is stable within 1px across 24 samples through roster arrival at 1440/1024/768/640, the column keeps the same width when the rail lands, and `workspace-model-choice.spec.js` — the spec whose measurements the #2676 transition split — stays green. Reverted, the same spec fails with the 27px/32px slides above, so it bites. Unit suite 135 files / 2957 tests. One e2e case was written and then removed rather than left skipping: "an agent page carries no rail column" asserted a premise the router does not hold — `/workspace/a/:name` REDIRECTS into a conversation, which legitimately has a rail. The rule it meant to check is covered in the unit spec, which does not depend on which URL the router settles on. Fixes #2711 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
#2711) Review finding, reproduced: the reservation fires for any non-room, non-agent- page route while the stage loads, but `stageZone` keys on the ROSTER — so a caller with no rostered agents settles `empty` and a failed roster fetch settles `failed`. In both the rail never arrives, the reservation drops, and the column left through the 300ms width transition: EMPTY roster -> none -> reserved(48) -> reserved(22) -> reserved(3) -> none FAILED roster -> none -> reserved(48) -> reserved(10) -> reserved(1) -> none A 48px animated shift on first run — nobody has shared an agent yet — and on every roster error, landing beside an error state. That is the same defect this PR removes, handed back, and it is exactly the hazard the docblock reasons about for ROOMS without applying it to `empty` / `failed`. The fix is not to reserve less. Narrowing to `/workspace/c/:id` would protect the AC's route and leave the common path — the nav's `/workspace`, which redirects into a conversation — shifting as before, because the redirect happens after the roster lands. Instead the LEAVE is animated only when the column actually held a rail (`railEverHeldRail`): a reservation that paid off animates as before, one that did not is given back in a single frame. Measured after: EMPTY roster -> none -> reserved(48) -> none FAILED roster -> none -> reserved(48) -> none and the normal load is unchanged: `reserved(48) -> rail(48)`, no shift. The ENTER keeps its transition unconditionally — it only ever runs for the voice-canvas swap (#2676), since a reserved column is present from the first frame and never enters. The flag resets per route, because the question is about THIS stage: navigating from a conversation to an empty roster must not inherit the conversation's verdict. Two spec arms added for the second finding — the suite measured bare `/workspace`, not the `/workspace/c/<session>` the AC names, so it was right only by accident of the fixture: * a direct load of `/workspace/c/<id>` (resolved from the sessions route) keeps Send within 1px; * an empty and a failed roster give the column back with NO intermediate width. The assertion is about the shape of the removal, not its speed: every width observed must be the full reserved width or nothing, because an intermediate width IS an animation frame. Negative control: forcing the old unconditional leave fails it with "animated away through 6,22px". `portalVoiceLayoutMotion.spec.js` pinned the literal `leave-to-class="!w-0"`, which is now a binding. Updated rather than deleted: the #2676 property it protects is intact, since the canvas only ever takes the row from a rail that was on screen, so `railEverHeldRail` is true wherever that spec cares. Frontend unit suite 135 files / 2957 tests; the rail and model-choice e2e suites 12/12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
) Caught by chasing a red CI run rather than dismissing it. The reset watcher added with the reservation read `route.value.fullPath`, but `useRoute()` returns a reactive OBJECT, not a ref — every other route read in this 2,000-line file is the plain form, and this line was the only `route.value.` in it. It is not a silent no-op, it is a silent THROW: Vue routes a watch-getter error to its error handler instead of aborting setup, so the page rendered, the rail e2e passed 12/12, the unit suite passed 2957, and the watcher was dead the whole time while every Workspace load logged TypeError: Cannot read properties of undefined (reading 'fullPath') Verified in a browser before and after: the error is on the previous build and gone from this one. The measured rail behaviour is unchanged (`reserved(48) -> none` on an empty or failed roster, `reserved(48) -> rail(48)` normally) because the flag is only ever SET by the other watcher on a cold load; what was broken is the per-route reset, which is the in-session case. Guarded rather than just fixed: `portalRailReserve.spec.js` now fails on any `route.value.` in the shell, and the guard was proven to bite by reintroducing the bug (it fails) — a dead watcher is invisible to every test that does not read the console, so the spelling is worth pinning in the one file that mixes it. Unit suite 135 files / 2959 tests. The three workspace e2e suites CI flagged — rail-reserved, model-choice, stick-to-bottom — 15/15 locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…urce-text-pin (#2711) — mechanical, per the merge-train note on the PR The train (#2922's source-text ratchet + this stack) found the joint break: the `route.value` spelling guard reads Portal.vue by text and this is a NEW spec with no baseline entry. It is a legitimate pin — only the text proves a spelling is absent — so it declares itself one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… rest (#2201) Several recurring treatments fell below WCAG AA. Fixed at the tier, not per component, and the tiers are now measured rather than asserted in prose. **The inverted tertiary pair.** `text-gray-400 dark:text-gray-500` is below AA on BOTH sides — 2.54:1 on white, 2.13:1 on gray-700 — and it is the contract's own ladder written backwards: light tertiary is gray-500, dark tertiary gray-400, which is what 890 other call sites already use. 187 occurrences across 62 files swapped. The class COUNT per file is unchanged, so the raw-colour ratchet does not move. **The warm families' light text tier.** On a light surface the 600 tier fails for success (3.30), warning (2.94), autonomous (3.19) and urgent (3.56) while the cool families pass (danger 4.83, info 5.17, purple 5.38, primary 6.29) — so the rule is per TIER, not per family: light text is 700 (4.92–7.90 across all nine), dark text is 400 (4.92–9.59 on gray-800). 128 paired occurrences across 52 files. **The Operations count badge.** White ink on a 500 solid is 2.80:1 (urgent) and 3.76:1 (danger). Now urgent-700 (5.18) and danger-600 (4.83). It is small, high-salience and carries a number someone is meant to read, so AA-normal is the bar rather than the 3:1 large-text allowance. **Host telemetry percentages** are read as numbers, so they are text: the 500 tier they used measures 2.28 (success) through 3.56 (urgent) on white. Raised to 700/400. The meter separators between them are decoration and are now marked `aria-hidden` rather than darkened — styling a dot nobody reads would be the wrong fix — and their one class string is hoisted, which pays for the dark half the no-data placeholder was missing: HostTelemetry raw_gray 7 -> 4. **The mechanical half (AC 6), in two layers.** `utils/contrast.js` is pure WCAG arithmetic — linearized, because a channel-average scan is wrong in both directions, which is why this issue's own first numbers had to be recalculated. `tests/unit/contrast.spec.js` drives it over the REAL Tailwind palette the tokens alias, so a palette bump or a token remap turns red instead of silently darkening the app below AA; it pins both ink ladders, all nine status families at both tiers, the badge recipe, and — deliberately — the two grounds the tertiary tier does NOT clear, so a future palette change that fixes them prompts a revisit instead of leaving a stale prohibition in the doc. `e2e/contrast-ratchet.spec.js` is the page-level layer: distinct failing text treatments per page and theme against a checked-in baseline, counted per TREATMENT rather than per node so the number does not move with how much data an instance holds. A page with no entry is held to zero, and an improvement that is not banked fails as a stale ceiling — the #2605 rules. What it freezes rather than fixes, stated plainly: a long tail of `text-gray-400` written with no `dark:` sibling. Adding the missing half at ~1,500 sites would grow the raw-colour ratchet by ~1,500 raw classes, so the contrast guard and the palette guard pull against each other and the way out is a semantic ink token, not a baseline bump. That is written into the contract beside the measured ladders. Verified in both themes against a local instance. Reverting the sweeps grows the ratchet on all ten page/theme pairs (light dashboard 13 -> 16, settings 6 -> 10, dark settings 2 -> 5), so it bites. Frontend unit suite 136 files / 2973 tests. Fixes #2201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
|
Train green on the combined tree — 27 pass, 3 skipped by design ( What the batch gate paid for, both fixed on member branches before this run: the three-way Alembic fork (#2920/#2924/#2927 → stacked, Members merge individually after @vybe's review — this PR is never merged. Suggested order: #2922 · #2920 → #2924 → #2927 · #2778 → #2780 → … → #2787 (retarget each stack member to |
Integration surface for #2778, #2780, #2781, #2782, #2783, #2784, #2785, #2787, #2922, #2920, #2924, #2927. Never merged; members merge individually — and only after @vybe's review — once this is green.
Assembled from each member's head with
--no-ff(one merge commit per member, bisectable). Local gate on the combined tree: alembic heads 1 (0067_agent_role_readiness), vitest 3408/3408, tokens OK, build OK, the members' backend suites 219/219.What the dry run already caught and fixed on member branches (announced on each PR):
0065_*Alembic revisions off0064→upgrade headwould apply zero revisions. fix(skills): refuse to overwrite an agent-authored skill of the same name (#2914) #2920 → feat(schedules): a schedule that names a user runs as that seat — reads and writes their memory (abilityai/trinity-enterprise#637) #2924 → feat(workspace): the role card in Agent details — role, objectives with metric freshness, readiness (abilityai/trinity-enterprise#527) #2927 are now a stack (0065 → 0066 → 0067), feat(schedules): a schedule that names a user runs as that seat — reads and writes their memory (abilityai/trinity-enterprise#637) #2924/feat(workspace): the role card in Agent details — role, objectives with metric freshness, readiness (abilityai/trinity-enterprise#527) #2927 retargeted accordingly.tabStripAdoption.spec.jsrefactor(ui): adopt the priority+ tab pattern on Settings, Operations and NavBar (#1925) #2781,portalRailReserve.spec.jsfix(workspace): reserve the rail column while the stage loads (#2711) #2785) — green alone, red together. Both are legitimate pins; they now declare@source-text-pinwith a reason.Members: #2778 → #2780 → #2781 → #2782 → #2783 → #2784 → #2785 → #2787 (UI stack, retarget each to
devas its parent lands) · #2922 · #2920 → #2924 → #2927 (schema stack).