feat(crews): trait-by-trait custom ghost avatars for crew members - #7443
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Both tiers reuse existing seams (per-crew override precedent, compose path, authenticated serving), and the staged-upload→PUT-commit protocol earns its complexity against the commit-on-upload alternative. Suggestions
[DESIGN-REVIEWED] c2ea7d1 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All mechanical checks are done. Composing the review. First-Principles-Verdict: CONCERNS Every capability earns its place, but two slivers of wire/API surface ship with zero or one consumer: create-time What this change shipsIntent: let a user choose a crew member's face — hand-picked ghost traits or an uploaded picture — instead of the name-derived one. ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] c2ea7d1 |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of UX-Verdict: CONCERNS Solid builder with honest Apply→Save layering, but the Avatar setting hides inside the Triggers pane and the category tabs degrade to cryptic icon-only at common window sizes. Watch
Suggestions
[UX-REVIEWED] c2ea7d1 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate is disproven. The security layer fences No grounded Step 2 finding at 80+ in the code I opened. No findings. [OPUS-REVIEWED] c2ea7d1 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @CrysisDeu overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
d68de5b to
2b71443
Compare
Two changes: the builder's |
|
Status note — the 5 red checks on this head are inherited from
The one genuine finding on this PR (GPT lane 320px clipping) was fixed in 2b71443. I'll rebase and re-push once |
2b71443 to
3903c30
Compare
3903c30 to
f3b33c8
Compare
|
Finding: temp-screenshots (~1.6MB) committed into history — rejected as a change to this PR: |
|
Finding: |
5df62ac to
2c09868
Compare
|
Finding: spec gap on the new public surface — fixed in Suggestion: promote |
|
Finding: discoverability — the Overview hub face is inert — fixed in Finding: raw exception text in the editor banner — fixed in Suggestion: |
|
Finding / Subtraction: |
2c09868 to
83675b3
Compare
|
Finding: pinless image config can serve an uncommitted avatar — fixed in |
|
Finding: a stray drop wipes the editor — fixed in Finding: an applied avatar discards silently on editor close — accepted as valid, deferred with the concrete reason: the discard confirm Suggestion: Brows ( |
|
Subtraction: drop Watch / Subtraction: Watch: the drained save lands on update/delete only — noted, out of scope: Watch / Subtraction: ghost |
|
Design lane is PASS on this head; two suggestions, both accepted as follow-ups: Extract the avatar file-store protocol from Long |
83675b3 to
c2ea7d1
Compare
|
Finding: header avatar remains editable during saves — fixed in |
|
Per-finding dispositions for the First Principles CONCERNS on Watch / Subtraction: Watch / Subtraction: collapse Watch: 7 sibling inline Subtraction: drop the all-empty-ghost → reset acceptance and |
|
UX lane is PASS on this head. Suggestion: Randomize overwrites a hand-picked draft with no one-step revert — accepted as a follow-up (stash the pre-randomize draft and offer "Restore previous" on the reset row); it needs one new string across 13 catalogs, so it rides with the deferred |
|
Design lane is PASS on this head; both suggestions stand as previously dispositioned: long |
|
/ai-review override gpt c2ea7d1: Maintainer ruling: 5 rounds fixed every adjacent GPT finding (feature map, ErrorNotice, truncated-image check, pinless pin, busy fence). This one is not avatar-specific — description/triggers on the same record take the same unredacted path — so it needs one serializer-level redaction chokepoint, tracked as #8447 rather than a sixth round here. |
Human judgment recorded@CrysisDeu marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
|
Superseding my earlier note on this head (the lane re-ran and moved from PASS to CONCERNS). Per-finding: Finding: the Avatar field lives in the Triggers pane and dirties the "routing" dot — valid, accepted and deferred to the follow-up as an editor-structure change: giving the face its own rail row (or moving it to Overview with its own dirty flag) touches the pane registry, Finding: seven axis tabs collapse to icon-only at ordinary widths — valid, accepted and deferred with the fix you named (let the strip wrap to two labeled rows inside the builder rather than collapsing). It needs a layout option on the shared Suggestion: applying a Picture drops the hand-built ghost draft for the rest of the editor session — accepted, deferred to the same follow-up (keep the last ghost traits as the Ghost-face tab's seed while the editor stays open; pairs with the pre-randomize stash from the previous round). None of these is a BLOCK; the PR's remaining UX debt is tracked in one follow-up alongside the copy fix ( |
|
F2 errors-use-error-notice — fixed in |
|
F3 header-only sniffing — fixed in |
|
Status: CI is fully green on Summary of what changed since that review, across five CI rounds (single squashed commit throughout):
Not merging — leaving that to the reviewers. |
Problem / Motivation
Crew members are positioned as person-like identities — a name, a face, their own memory — but the face is fixed: it is derived deterministically from the crew's name, and renaming the crew is the only way to change it. Users cannot shape the identity they are meant to relate to.
Why it matters
The Members page makes named crew members the primary object users interact with. A face users chose themselves makes members recognizable at a glance across the roster, DM threads, and the editor — and keeps the identity stable even when two crews have similar names.
What changed (motivation → approach → change)
This PR ships the complete per-crew custom avatar feature in two tiers. The second tier was developed as the stacked PR #7702 (reviewed and approved there by @chenmingwei23 and @iamwhatever, merged into this branch), and this description covers both. The branch is a single squashed commit rebased onto current
main.Tier 1 — trait-by-trait ghost builder
Goal: let users customize a member's face without leaving the Kiro ghost family (keeps the roster visually coherent), stored per-crew with zero migration.
Approach: the ghost style module already separates trait drawing from trait picking —
compose(traits)is the exported, test-exercised composition path, and the PRNG is only used to pick traits from the name. So a trait-by-trait builder needs no new art pipeline: it recombines the exported trait vocabulary (13 eyes, 4 brows, 9 mouths, 12 headwear, 8 items, blush, flip, 15 tiles) and pins the chosen set on the crew record as a sparse override, exactly like the existing per-crewmodeloverride. Empty = today's name-derived face.avatarfield onKiroCrewAgentConfigwith a total_safe_avatarcoercer (now inconfig/sections.py, wheremainmoved the config dataclasses;loader.pyre-exports it). Junk collapses to "no override";tileis pinned to hex because it is interpolated into SVG; booleans require real booleans sincebool("false")isTrue; an all-empty ghost trait set collapses to the canonical{}reset spelling. Create/update endpoints validate with a 400invalid_avatarcode, mirroringsession_color. The Members roster allowlist exposes the field.CrewAvatarrenders pinned traits through the samecompose()path (composes with the working animation from feat: animate crew member avatars while a member is working #6979);CrewAvatarBuilderis a nested game-style dialog in the crew editor — category tabs per trait axis (each with a lucide icon so the collapsed form on narrow viewports stays legible), live preview, randomize, "Reset to the default face", and an "Applies here — press Save changes to keep it" hint. Entry points: the editor header avatar and the Overview hub avatar (both clickable) and an Avatar row in the Triggers pane.ghostDataUrilives in the style.tsmodule so no<svgliteral appears in any.tsx. Tile swatcharia-labels are human color names, not hex.Tier 2 — uploaded picture
Goal: an arbitrary picture per crew (photo, team logo, existing art), with the same two-step Apply → Save semantics as ghost traits, and no way for a failed or abandoned Save to damage the previously saved picture.
Approach: staged upload + atomic promote, with the ordinary crew
PUTas the single commit point.routes/agents.py,handlers/agents.py):POST /api/agents/{name}/avatar— stages the file only (<sha256(name)>.pending.<ext>). Owner-gated via_require_owner("agent.avatar_upload")and audited vialog_api_access. Body is multipart, read into memory under a 1 MB cap (413avatar_too_large); format is decided by magic-byte sniffing (clientContent-Typeis ignored; PNG/JPEG/WEBP only, else 400avatar_bad_format); malformed multipart → 400invalid_multipart; unknown crew → 404agent_not_found. Returns a staging token.GET /api/agents/{name}/avatar— owner-gated (agent.avatar_get), audited, serves only the file the config'sfilepin selects and only while the config sayskind: image; content-hash ETag with 304 revalidation. A leftover file the config no longer selects is refused.DELETEroute. Removal is the same commit point:PUT /api/agents/{name}withavatar: {}(or any non-image override) clears the field and reaps the stored files after the config write succeeds.run/avatars/directory; filenames are sha256 digests of the crew name (display strings never touch a path). OnPUTwith{"kind":"image","promote":true,"token":…}, under the config lock: the staged file is installed at a content-addressed path (<stem>.<16-hex digest>.<ext>, so an install can never overwrite the committed file), the config is saved with{"kind":"image","v":<mtime_ns>,"file":"<digest>.<ext>"}, then the previous variants are reaped. If the config save raises, the freshly installed file is rolled back and the prior pin stays live. APUTwith a plain{"kind":"image"}keeps the current picture and discards any stale staging; a stale or missing token fails the save (400avatar_file_missing) rather than silently keeping the old picture. Crew deletion reaps live and staged files inside the same lock. All filesystem work runs off the event loop.kindnow has two constructed variants —{"kind":"ghost","traits":{…}}and{"kind":"image","v":int,"file":"<digest>.<ext>"}— validated by_safe_avatar(vmust be a positive real int,filemust match the digest-pin regex).saveEditsnapshots the sheet epoch before its first await, stages the upload behind a fieldset fence that disables the editor pane during any in-flight save, and lets thePUTcommit.CrewAvatarrenderskind: imagefrom the authenticated endpoint with the?v=cache-buster and falls back to the seeded ghost if the load fails.Shared
components.avatarBuilderacross all 13 catalogs (option labels use literal key maps, no dynamic keys) + en-XA regeneration + context sidecar entries.config-baseline.jsonregenerated on this head — theagents.*.avatarhelp now matches the loader's_metastring including the image-tier clause (scripts/generate_config_baseline.pyyields zero drift).main):mainmoved the config dataclasses toconfig/sections.py, so_safe_avatar, the_AVATAR_*constants and theavatarfield live there; pertest_config_module_boundariesthe loader's name-level re-export list is a frozen pre-split snapshot, so the handler and tests import them fromconfig.sectionsdirectly and the loader reaches_safe_avatarthrough the module. The crewPUT/DELETEhandlers mergemain'sreasoning_effortvalidation and_refresh_session_defaultscalls with this PR's drained save + avatar commit/rollback; the Members drawer's identity moved intomain'sDetailPanelheader, which now receivesavatar={active.avatar}; twojsx-a11ywarnings on the new drop zone were addressed (justified per-line disable on the drag-only div,aria-labelon the hidden file input) to meetmain's zero-warning eslint gate.main's whole-surfaceApiClient.coveragetest now has a Blob-fixture hand test foruploadCrewAvatar; the Crews row ofdocs/feature-map/README.mdlists the avatar capability and endpoints; the upload pane's pick error renders throughErrorNotice(no hand-off, the dialog holds the unsaved avatar draft); and the upload endpoint now rejects a structurally incomplete body — valid magic bytes but no PNGIEND/ JPEGFFD9terminator, or a RIFF length that does not match — with 400avatar_bad_format, so a truncated upload can never be promoted over the committed picture (_image_body_complete, dependency-free; a full decoder is not a dependency of this package).docs/system-specs/modules/learn-cron-dashboard.md(Crew avatars: both routes, the stage → content-addressed install → save → reap protocol, rollback, stale-token and no-promotesemantics, GET pin-only serving) anddocs/system-specs/modules/config.md(Per-crew avatar override: both accepted shapes and the coercion rules), per AGENTS.md's same-commit spec rule; the Overview hub face now opens the builder like the header face (hub-avatar-button, same title); and a transport-level upload exception shows the localized failure string in the editor banner with the raw message kept for the console.filepin (only reachable by hand-editing config.json) now selects no file — GET 404s into the ghost fallback and a picture-keeping save refuses withavatar_file_missing— instead of falling back to "any stored variant", which could have served an orphaned install left by a crash between install and save (GPT lane); the unreferenced_avatar_pathresolver is deleted; and while the Picture pane is open,dragover/dropare cancelled at the window so a file dropped outside the dashed zone no longer navigates the SPA away from the editor (UX lane). Both covered by tests.<fieldset>busy fence — now carriesdisabled={sheetBusy}, so the builder cannot be reopened while an upload or the committingPUTis in flight and a newer Apply can no longer be discarded by the completing save's close (GPT lane).Tests
test/test_agent_avatar.py(66 tests on this head, incl. pinless-record refusal,_image_body_completetable + truncated-upload rejection):_safe_avatarshape/coercion table (junk, unknown kinds, string-typed booleans, tile injection attempt, imagev/filepins, all-empty ghost → reset), dataclass defaults + asdict round-trip, config load round-trip, ghost endpoint tests (persist, 400 on junk, reset via{}, create-with-override), and the upload-tier endpoint tests: stage → commit → serve roundtrip with 304 revalidation; abandoned staging never changes what is served; commit without upload → 400; stale token fails the save; the live picture stays on disk through every promotion step (one spy in this test was tightened on this head to observe only the install step, not the staging write that also lands at a.jpgpath); magic-byte sniff beats a lying Content-Type; 1 MB cap → 413; unknown crew → 404; malformed multipart → 400; format change leaves no stale sibling; same-size replacement changes the ETag; crew deletion reaps live and staged files; GET refuses a file the config no longer selects.test/test_config_baseline.py: committed snapshot is byte-identical to the generator on this head.test/test_members_dm_thread.py: pinsavatarin the roster allowlist.website/src/test/CrewAvatarBuilder.test.tsx(17 tests, the builder on its own): randomize draws from the shipped vocabulary; Blush and Background axes; mirror toggle; Cancel; the picture tier's crop/downscale ladder (PNG → JPEG on a white ground → smaller JPEG, no upscaling), the 20 MB source cap and undecodable/zero-size/no-canvas failures throughErrorNotice, the drop zone, the hidden-input forwarding, the pick-generation guard (a slow earlier decode cannot overwrite a later pick), reopening over a saved picture, and Reset. Lifts the file to 99% line coverage for the per-file floor.website/src/test/CrewRoster.test.tsx(52 tests): pinned override changes the face deterministically; junk falls back to seeded; non-hex tile never reaches the SVG;seededTraitsparity; builder E2E (open from header avatar → pick eye → Apply → Save payload carries the traits; reset round-trips to "no override"); image override renders the authenticated URL with the cache stamp; draft picture previews from its data URI without network; failed image load falls back to the seeded ghost; Picture tier disables Apply until a picture exists and the ghost draft survives tab round-trips.What ran locally against this head (
c2ea7d16d)Black gate, isort, flake8, mypy (config + handler modules), the four baselined backend gates (subprocess-encoding, agent-sdk boundary, sync-io-in-async, lockdown-before-publish), brand gate, scrub-lint,
tsc -b,eslint --max-warnings 0,i18n:check, phantom-classes, jscpd — all pass. Targeted tests:test_agent_avatar.py+test_config_baseline.py+test_members_dm_thread.pyplustest_config_module_boundaries.py(148 passed),CrewRoster.test.tsx(52 passed),ApiClient.coverage.test.tsx(696 passed), and the existingKiroCrewAgentsPage.*suites (25 passed). The full backend and frontend suites are left to CI, which is authoritative.Manual verification
Both tiers were walked on an isolated pod (built dist, real gateway) at the time each was built: ghost tier — created crews via the API, pinned a custom face through the update path, roster/editor/builder all reflect it live; picture tier — picture picked → center-cropped preview → Apply → Save changes → roster shows the uploaded face. The screenshots below are from those sessions; the code paths they exercise are unchanged by the rebase (the conflict resolution touched module placement and the merge of
main'sreasoning_efforthandling, both covered by the tests above).Screenshots / video
More surfaces
Issue link
no linked issue: feature built from a dashboard design session with the maintainer; the picture tier was tracked as stacked PR #7702.