Commit Graph

1757 Commits

Author SHA1 Message Date
Guido Vizoso 2ab26d66d5 docs: beat-5 coverage and reskin skill
Bring the app's own documentation back in line with what the skin now does,
and record in the authoring skill the failure that a live run exposed.

CLAUDE.md had drifted in five separate places, each phrased differently
enough that keyword searches kept missing one: the seed catalog size, the
count of SKIPPED beat-map rows (twice), the beat-matrix cell for
stored-procedure replay, the intro paragraph's list of skipped beats, and a
claim that the seed file seeds "no procedure at all". An exhaustive audit of
every bookstore claim in the file — 36 of them — is what finally closed it.

Also documents a repo-level trap in § Commands: pnpm lint is ESLint only,
while lefthook's pre-commit additionally runs oxlint --fix and oxfmt --write
over staged files and re-stages the result. The two disagree (oxlint enforces
prefer-top-level type imports; ESLint does not), so a contributor can satisfy
the documented gate and still be silently rewritten at commit time. This
already misled a reviewer into filing a finding asking for the exact thing
the hook auto-reverts.

The reskin skill gains an empty-recall requirement for the stored-procedure
beat, in both templates.md and demo-beats.md, which previously documented
only the happy path. A skin author following either would ship the gap this
skin shipped: with the memory empty, the agent reported the miss correctly
and then offered to learn the procedure — beat 6's moment, arriving as an
improvised fallback. Both now require saying so and stopping, with no
guessing and no teach-offer, and cite the worked example.

Note the reference skin has the same gap: banking's beat-5 clause has no
empty-recall branch, and its only such instruction affirmatively calls
offerWorkflowRecording. Correctly scoped to its teach path, but it leaves a
pattern pointing the wrong way for beat 5. Left for a separate change.
2026-08-12 14:18:48 -03:00
Guido Vizoso 79cbc7ef84 feat(bookstore): seeded procedure, prompt and pill
The demo half of beat 5: a procedure the agent already knows, an instruction
to recall rather than improvise it, and a pill so the presenter never types.

- intelligence/seed-memories.ts: a kind "operational", scope "user" memory
  naming addToCart -> swapEdition -> applyPromoCode -> setDeliveryBy in
  order, and explicitly excluding the three distractors. The procedure is
  SEEDED, not taught — it is recalled. Scope is "user" and never "project",
  which would return the memory for every user of a shared instance.
- agent.ts: clause 7 calls recall_memory FIRST, runs all four steps in order
  without confirmation, and states that finding the club is not running the
  procedure — reporting the pick, code or date and stopping is the failure
  mode, not a partial success. It scopes openCheckout out, so the run ends
  with a filled but unpaid cart, and refuses the teach-offer: this is a
  recall, not a teaching moment.
- The empty-recall branch exists because a live run went off-script the
  moment the store was empty. With nothing recalled the model said so
  correctly and then offered to LEARN the procedure — which the clause
  already forbade, and which is beat 6's moment. It now says plainly that
  nothing was found and stops, without guessing the pick, edition, code or
  date from the catalog or cart: an invented answer that looks right is worse
  than an honest failure, because on stage the two are indistinguishable.

The four tool names are frozen string literals shared by the prompt and the
seed, and no test reads either, so renaming one breaks the beat with a green
suite. A drift guard is the next commit's concern, not this one's.
2026-08-12 14:18:48 -03:00
Guido Vizoso f1913bf485 feat(bookstore): cart discount and delivery UI
Price the cart through the three-argument cartTotals and show what the club
run actually did to it.

The discount is rendered as up to TWO rows, not one. discountCents is a
single scalar, so a single row labelled with the club would render
club-plus-credit under the club's name and silently misattribute the credit —
and that combined case is reachable exactly when the applyStoreCredit
distractor misfires, the most scrutinised second of the demo. splitCartDiscount
recomputes the club-only discount and takes the credit as the remainder, so
both parts are attributed honestly and clubPart + creditPart === discountCents
holds for every case, including a credit that exceeds the subtotal (the club
keeps its full percentage; credit takes only the applied remainder).

Also adds the delivery-by badge, wishlist and reminder counts, and the same
figures on the page readable so "what's on my screen" agrees with what the
agent says. card_last4 remains the only card datum that leaves the checkout.
2026-08-12 14:18:48 -03:00
Guido Vizoso 4ee549a616 feat(bookstore): the book club mechanism
Everything the saved book-club procedure needs in order to run: the club
constant and its computed next-meeting date, the edition pair the swap moves
between, discount-aware pricing, the six store writes, and the twelve
registered frontend tools.

- data/club.ts: BOOKSTORE_CLUB (pick, promo code, 15%, meeting weekday),
  nextMeetingDate/nextMeetingISO (UTC-only by design) and localCalendarDay,
  which re-anchors the caller's LOCAL calendar day onto UTC midnight. Without
  it a presenter west of UTC demoing on a Thursday evening gets next
  Thursday: at 2026-12-31T23:00-08:00 the naive path skips a full week.
- data/seed.ts: a 25th book, the club pick's paperback, sharing workId
  "trust" with the hardcover so swapEdition has a real work to move within.
- data/query.ts: cartTotals gains an optional pricing object and returns
  subtotalCents/discountCents alongside totalCents, which stays the
  POST-discount amount charged. Inputs are sanitised so
  0 <= discountCents <= subtotalCents holds for any input, including a
  non-finite credit or discountPercent.
- data/use-data.ts: promoCode, deliverBy, storeCreditCents, wishlist and
  reminders persist under one extras key with a field-by-field validator;
  six writes returning WriteResult; placeOrder prices through cartTotals and
  consumes all three sticky fields. swapEdition merges into an existing
  target line rather than duplicating a bookId, and setDeliveryBy's
  past-check reads the local calendar day so the club's own date is never
  refused.
- tools.tsx: the club readable (the only agent-reachable source of the promo
  code), the three procedure writes, the three distractors that genuinely
  work, and discount-aware pricing in both the cart readable and
  openCheckout's render so the total the agent speaks matches the cart page,
  the checkout form and the order record.

Every registration uses [] deps and reaches the store through dataRef:
useFrontendTool keys its effect on JSON.stringify(deps), so a callback in a
dep array stringifies to a constant and pins the pre-hydration store.

Reskin skill: checked, no impact — skin-internal data, store and tool
wiring; no Skin contract field, registration, routing or gate changed.
2026-08-12 14:18:48 -03:00
Guido Vizoso 1c4003d2a3 fix(bookstore): seed the default memory bucket and stop claiming per-shopper isolation 2026-08-12 14:18:47 -03:00
Guido Vizoso 80727a47f7 docs(reskinnable-demo): document the bookstore skin and correct the reskin skill
Answers the standing question in CLAUDE.md — this work found the skill wrong, so
the fixes ship with it.

Rule 1 on tool deps told authors to 'pass the data each closure reads' without
noting that useFrontendTool keys its effect on JSON.stringify(deps). A Map, a Set
or a function stringifies to a constant, so the registration is inert and the
closure never refreshes — the skill's own words for the bug it warns about
described the fix it recommended.

The useData template taught a bare useState(SEED) and said nothing about a
storage-mirrored variant, so an author needing one writes a hydration effect and
trips react-hooks/set-state-in-effect immediately.

Roster prose across CLAUDE.md, README.md, .env.example and the skill now covers
seven skins. Most count claims were rephrased without a numeral rather than
renumbered, so the next skin cannot re-falsify them — skin-roster-docs.test.ts
is what caught them, and its roster fixtures are updated to match.
2026-08-12 14:18:47 -03:00
Guido Vizoso 3bde5f4443 feat(bookstore): assemble the Skin and register it across the shell
resolvePage uses a Map, never a plain object: segments[0] is untrusted URL
input, and an object lookup walks the prototype chain, so /bookstore/constructor
would resolve a Function where a ComponentType is declared and crash React
instead of 404ing. skin.test.tsx pins that with the prototype-chain keys.

An unknown book slug resolves the detail page and renders a not-found body
rather than 404ing — the agent hands out these links, and 404ing a renamed book
would break a deep link.

Registration is four files, not two: both registries plus skins-config (whose
test asserts skinIds and skinIdentities match the live registry, and which
LOCK_SKIN is validated against) and eslint.config.mjs, where the id joins
LINTED_SKIN_IDS — the array the URL-contract selectors interpolate, so without
it lint is blind to this skin.
2026-08-12 14:18:47 -03:00
Guido Vizoso 02fdf443ea feat(bookstore): the agent prompt, its six tools, catalog and demo pills
The prompt is where the beats are enforced: recall memory before recommending
and name the recalled preference in the note, never ask for or repeat card
digits, never emit a markdown table where a gen-UI component exists.

No temperature is set. gpt-5.4 rejects the parameter and logs that it is
unsupported on every run, so pinning it alongside a comment claiming
determinism would assert a guarantee the model discards.

Tool registrations read live store data through a ref and close with empty deps
where a dep cannot re-register them: useFrontendTool keys its effect on
JSON.stringify(deps), so a Map or a function stringifies to a constant and the
closure keeps its first values forever. openCheckout additionally must not
re-register mid-call — placeOrder mutates the cart, and a teardown would lose
respond() and fail the thread.

Every render keys off the recorded result rather than status: a reopened thread
replays with a stored result and no status transition, so a status-keyed render
looks correct live and blanks on reload.
2026-08-12 14:18:47 -03:00
Guido Vizoso 6855ad9cca feat(bookstore): layout chrome and the browse, book and cart pages
The route readable in the layout plus one readable per page is what makes the
screen-awareness beat work: asking on two pages must give two different correct
answers. All four payloads are deliberately disjoint.

The active segment comes from useSkinSegments, not a pathname slice — the shell
hook strips a leading skin id rather than a fixed offset, so it stays correct
under a LOCK_SKIN deploy where the segment is absent entirely.

The presenter reset is a full-page assign, not a router.push: it clears storage
with removeItem, bypassing the store, and the store has no storage listener, so
only a document load re-reads it. A client navigation would leave the cart
visibly full right after a successful reset.

The cart page has no checkout button by design — checkout is the agent's beat.
2026-08-12 14:18:46 -03:00
Guido Vizoso 7a4f8dde79 feat(bookstore): generated covers, cards and the in-chat surfaces
Covers are typographic and generated rather than sourced images: 24 scans would
be a licensing problem, would not reskin with the theme, and would read as stock
photography in a demo whose argument is that the UI belongs to the product.

checkout-card carries the security boundary — onSubmit receives only the last
four digits, the other digits are cleared from state at that boundary, and both
sensitive inputs are type=password because this card appears on a projector. Its
receipt mode re-derives from a replayed result so a reopened thread shows a
receipt rather than a blank form.

filter-bar keeps the ebook lever even though no seed book has that format: the
agent can set format=ebook via browseWithFilters, and a missing lever would make
an agent-applied filter invisible, which is the one thing the component exists
to prevent.
2026-08-12 14:18:46 -03:00
Guido Vizoso 22c8492922 feat(bookstore): cart and orders store with a per-shopper storage mirror
Reads through useSyncExternalStore rather than useState plus a hydration
effect: the effect form fails react-hooks/set-state-in-effect, and a useState
lazy initialiser that reads storage makes the server and client markup
disagree. layout-preferences.tsx is the shell's sanctioned pattern.

getSnapshot caches the parsed value and getServerSnapshot returns a frozen
module-level constant — cart and orders are arrays, and a fresh array per call
fails Object.is and infinite-loops during hydration.

The storage mirror exists for the durable-thread beat: its proof is a hard
reload, and a useState-only cart empties at exactly that moment.
2026-08-12 14:18:46 -03:00
Guido Vizoso f9b649c59e feat(bookstore): per-shopper Intelligence identity, seeded memory and presenter reset
Memory is scoped per shopper, so the same suggestion pill answers differently
for Maya (one seeded taste preference) and Guest (none). That contrast is the
demo's headline claim, so identifyUser never derives a scope from userRole —
both shoppers share the role and a role-derived scope would merge them.

forgetAllMemories deliberately skips scope:'project' rows: project scope is
global to the Intelligence backend instance rather than partitioned per
product, all skins share one instance locally, and banking seeds a
project-scoped procedure memory a bookstore reset must not destroy.

The reset route maps raw shopper ids through resolveBookstoreUserId before
clearing. Passing the raw ids would clear scopes nothing writes to and no-op
while reporting success.
2026-08-12 14:18:46 -03:00
Guido Vizoso 860063ee48 feat(bookstore): brand identity, theme tokens, nav and lock-safe link builders
Theme values are space-separated HSL channels, not hex: globals.css wraps every
token in hsl(), so a hex value yields invalid CSS and the whole skin silently
falls back to the gray :root defaults.

href.ts and nav-target.ts route every URL through useSkinHref. A hardcoded
/bookstore/... path puts the tenant segment back in the address bar on a
LOCK_SKIN deploy, and concatenating onto the builder's base emits the
protocol-relative //book/x because that base is '/' under a lock.
2026-08-12 14:18:45 -03:00
Guido Vizoso e3c6b75293 feat(bookstore): data types, 24-book seed and pure query functions
The seed test encodes the demo's falsifiability rule: the literary and
translated shelves must carry hardcovers and over-$20 titles, or the
recalled 'paperback only, under $20' preference has no visible effect.

cartTotals returns { itemCount, totalCents } — no tax and no shipping, so a
separate subtotal would duplicate the total and could drift.
2026-08-12 14:18:45 -03:00
Maxim fbaa645bad docs(reskinnable-demo): finish the airline and keel beat-map cleanup
Follow-up to the previous commit, which fixed the two beat-maps' headers and their
flagged risks but left the body still speaking in the future tense about work that
has landed. Comment/doc only.

src/skins/airline/data/beat-map.md
- § "It is ADDITIVE" claimed `use-data.ts` (`useAirlineData`) "is untouched and
  still drives the trip, loyalty and disruption pages". Both are DELETED; every
  component reads `useAirlineLedger()` through `components/concierge-view.ts`.
- Risk #5, "Two substrates, one passenger — they must not disagree on stage", told
  a later slot to "migrate BOTH readings" and not to touch either seed's AV1423
  without the other. Marked RESOLVED, with the answer it actually got: the
  duplicate reading was DELETED rather than kept in sync, because a hand-synced
  pair of seeds was never going to survive a reseed and is a pair of figures that
  can contradict each other on a projector with nothing checking.
- The beat table's four "(later slot)" cells, the beat-4 seed note, and the route
  list's "memory re-seed: later slot" — all shipped.

src/skins/keel/data/beat-map.md
- Beat 4's "the seeded memory (a later slot writes the file)" — written, and the
  scope it was written at (`user`, never `project`) named, since that is the choice
  the next author most needs and the one CLAUDE.md and demo-beats.md now flag.

Skill impact: none beyond the previous commit. These two files are per-skin design
records, not part of `.claude/skills/reskin/`, and the generalisable lessons in
them (the two-substrates seed, the second clock) were already lifted into
demo-beats.md § "Seeding memories" and SKILL.md § step 3 in that commit.

Verified: pnpm lint, pnpm exec tsc --noEmit, pnpm test:unit (197 files / 2227
tests), pnpm build — all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:54:18 +02:00
Maxim acac2b7f27 docs(reskinnable-demo): correct the in-code comments the beat-parity wave slots could not reach
Comment-only, no behaviour change. Each of these sat OUTSIDE the boundary of the
slot whose work falsified it, so each described the tree as it was mid-migration.
All verified against the current tree before editing.

src/shell/skin-contract.ts
- `RuntimeProviders`: "airline needs neither, so omits it". Airline omits
  `RuntimeProviders` and DOES supply `useRuntimeProperties` — the two are
  SEPARABLE, and airline is now the worked example: one account holder, no
  switcher, so its hook reads no context and returns a frozen module constant. Do
  not mount an empty provider for symmetry.
- `useData`: "Omit when a skin has no shell-managed data (banking …)". Every skin
  omits it now; documented with the grep, plus WHY the field is kept (the shape is
  legitimate, it just has no worked example left).
- `CanvasSurface`: the "omit if the skin has no report canvas" branch is currently
  unexercised — every shipped skin has one.

src/shell/agent-registry.ts
- The logistics entry said it ships "neither `intelligence/seed-memories.ts` nor
  `intelligence/forget-memories.ts`" and is "identity plumbing only — do NOT read
  it as a durable-memory demo". It ships both (`ls src/skins/*/intelligence/`), and
  its `dev/reset` sweeps and re-seeds through them. Corrected, with the same
  properties-forwarding caveat the other five entries carry, and a note that
  "expensive half built, cheap half skipped" was its state for two releases.

src/skins/keel/data/types.ts
- The `THE REST SUBSTRATE` banner said the two substrates are "deliberately not
  merged yet", that `useKeelData` "holds runs in `useState` and ticks them on a
  900 ms interval", and that "the pages still read it through `useSkinData`". All
  three are false: one substrate, one clock, `useSkinData` returns undefined.
  Rewritten to name the server-settled read (`settle-runs.ts`, called by both
  `GET /ledger` and `GET /runs/[runId]`) and to record WHY the deleted client
  ticker was a defect rather than a design choice — it was a second clock that
  painted progress the server never heard of, which the next re-read after any
  write silently rewound.
- The `KeelData` interface header claimed to be "the interface every page,
  component, and tool codes against". It is not referenced by any code at all
  (`grep -rn KeelData src` returns only comments). Marked HISTORICAL, with a
  do-not-add-a-consumer note. NOT deleted: it is a doc pass, several comments
  across the skin describe the migration in terms of this shape, and removing an
  exported type is a code change for a separate commit. Flagged as a follow-up.

src/skins/keel/data/store.ts
- "it does not advance them on a timer, because the ticker lives in `useKeelData`
  on the client. Whichever slot migrates that hook has to decide where the ticker
  ends up" — decided: the server is the only clock now.

src/skins/keel/data/beat-map.md
- Header: "Keel today is `useKeelData`, an in-memory `useState` store, and it hits
  about one beat." Marked BUILT and reframed as the design record.
- Risk #2 (the two-substrates/ticker question, correctly called "the biggest single
  risk in the migration") marked RESOLVED, with the answer (move the clock, do not
  relocate the ticker) and the generalised lesson.

src/skins/airline/data/beat-map.md
- Header: the tools/prompt/pages/pills were "later slots". All landed.
- § "What this slot did NOT build": every row of the deferral table has shipped.
  Kept as the retrofit record — which is the most useful thing about it — with a
  third column saying where each landed, and the three flagged traps marked
  resolved (including "the reset route says memoryBeats: unarmed on purpose",
  which was removed in exactly the change that added the seed module, as
  instructed).

src/skins/airline/data/fare-waiver-codes.ts
- "⚠️ THE LINT GUARD DOES NOT COVER THIS SKIN YET." It does: both
  `src/skins/airline/tools.tsx` and `agent.ts` are in `withheldGateVocabulary`'s
  `files` glob. Also dropped its "COUNT the selectors" instruction (that count has
  rotted twice) in favour of the resolved-selector table in
  `skins-config.test.ts`, and spelled out that a green lint still leaves the three
  prose channels AND `waiverGround` — which matches no `*_CODES` pattern, so the
  rule cannot see it — as hand-review items.

src/skins/airline/tools.test.ts
- Header said `statusKeyedTerminalRender` "covers logistics only; airline's glob
  entry is a later slot's, so until it lands this file is the whole guard" and that
  `withheldGateVocabulary`'s glob "does not list airline yet either". Both globs
  list airline now. Also fixed "Three defect classes" over a list of five.

src/skins/keel/skin.tsx
- "exactly as it does for the four other REST-backed skins" → every skin; nothing
  sets `useData`.

src/proxy.ts
- "matching how the other three skins behave" → numeral-free. This was one of the
  two known-stale instances named in `skin-roster-docs.test.ts`'s header; that
  header is updated in the app-docs commit, and the remaining one
  (`e2e/inset-layout.spec.ts`'s hardcoded four-skin loop) is deliberately left —
  fixing it means adding assertions against skins the spec has never visited, which
  is a coverage change rather than a prose fix.

docs/teach-mode/README.md
- It correctly refuses to write the roster into prose, but its verified-by-role
  paragraph named only banking/commerce/logistics/people and its "so copy commerce
  or logistics" line named the only two skins with pinned replay behaviour. The
  `offerWorkflowRecording` grep now returns every registered skin, and
  `ls src/skins/*/teach-mode-directives.ts` — added as the mechanical
  discriminator for role #3 — returns four. Also: "logistics and commerce both"
  skip project-scoped rows in `forget-memories.ts` is now every skin but banking,
  replaced with the grep that proves it.

Does this make anything in .claude/skills/reskin/ stale? No — the reverse. The
preceding commit updated the skill for exactly these facts, and these comments were
brought into line with it. Checked: `grep -rn "useKeelData\|use-data\|in-memory"
.claude/skills/reskin/` names no path or symbol that no longer exists.

Verified: pnpm lint, pnpm exec tsc --noEmit, pnpm test:unit (197 files / 2227
tests), pnpm build — all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:51:18 +02:00
Maxim a58ea35b19 docs(reskinnable-demo): bring the reskin skill up to six demo-complete REST-backed skins
This IS the answer to CLAUDE.md's standing question for the beat-parity work:
"does this change make anything in .claude/skills/reskin/ wrong, incomplete, or
misleading for the next person authoring a skin?" It did, in all four files.
Every claim replaced was checked against the tree first, and replacements state a
derivation rather than a roster wherever one exists.

SKILL.md
- "`banking`, `people`, `commerce` and `logistics` are the four demo-complete
  skins ... `airline` is the minimal contract surface" — false. Rewritten to "every
  registered skin is demo-complete", so the choice is which one is CLEANEST for a
  given problem, and a new "do not model a new skin on the ABSENCE of a field"
  paragraph with the grep that derives what each skin actually sets. Airline's
  restraint was the single most quoted licence to skip fields in this skill.
- The `useData?` contract row and the "4-2 split ... airline and keel are
  in-memory and SET it" paragraph: zero implementors
  (`ls src/skins/*/data/use-data.ts` is empty).
- The optional-slots list ("airline omits all of these EXCEPT toolLabels and
  useData"): derived per field — the only real omissions in the tree are airline's
  `sandboxFunctions` and `RuntimeProviders`.
- "banking, logistics, keel, people and commerce all five ship them, airline
  none" (identity plumbing): all six ship `useRuntimeProperties` + `identifyUser`.
  Added the point that the three parts are SEPARABLE, with airline as the worked
  example of `useRuntimeProperties` without `RuntimeProviders` — do not mount an
  empty provider for symmetry.
- "Only banking, people and commerce" register route + page readables: all six do.
- The `statusKeyedTerminalRender` glob comment ("logistics today; keel and airline
  still carry the defect") — both are in the glob now; replaced with the grep, so
  the sentence cannot rot again.
- The `agentRegistry` snippet handed out `airline: { createAgent }` as the
  no-identity example. Rewritten so the identity-bearing form is the default.
- Authoring step 3 and the file tree taught `data/` + `useXData` as the substrate.
  Now REST + `ledger-context.tsx`, with a new warning that time-dependent data must
  be settled SERVER-side — a client ticker beside a server store is a second clock,
  which is the defect keel had to be migrated off.
- § Verification step 1 and the authoring-order footer both claimed `pnpm build`
  type-checks the whole app. It does not: `next build` only visits what the module
  graph reaches, so it never opens a test file, and Vitest transpiles without
  type-checking. Both now name `pnpm exec tsc --noEmit` as the only full
  type-check, with four gates in cheapest-first order and the reason it matters
  here specifically (several guards this skill asks for are TYPE-ONLY, so they are
  decoration until tsc runs).

demo-beats.md
- Beat 3b: "this beat is impossible in airline and keel today" — false; both
  register route + page readables. Replaced with the two greps, both of which now
  return every skin, so a MISSING entry is the signal.
- Beat 3d: "In-memory skins can fake half of this" — no in-memory skin exists.
- Beat 6: the FIVE-channel leak list is now explicitly non-exhaustive, because
  airline has a sixth nobody would look for: `Booking.waiverGround` is a
  code-shaped token on a record the ledger publishes, so the LEDGER READABLE is a
  channel. The transferable question is "what does my GET /ledger answer with", not
  only "what did I put in a prompt".
- § "Seeding memories": the scope rule is rewritten and promoted to its own
  flagged subsection. It described banking's `project` scope as the pattern. It is
  now the minority and it is a trap for anyone copying a modern skin:
  `forget-memories.ts` SKIPS project-scoped rows in every skin but banking
  (`grep -ln 'scope !== "project"' src/skins/*/intelligence/forget-memories.ts`),
  because project scope is global to the one shared Intelligence instance and
  sweeping it would delete a sibling skin's seeds — so in such a skin a
  project-scoped learned procedure CANNOT be cleared by the reset, and beat 6 opens
  already-taught on the second run of the day, proving nothing while looking
  perfect. `user` is documented as the default; banking is documented as
  self-consistent the other way (project scope + a sweep that deletes everything)
  and therefore not copyable. The beat-6 walkthrough's `scope: project` mention now
  says so inline.
- § "Which skin to copy for what": every row re-derived. "the four at 9/9 beats",
  "In-memory `useData` substrate → airline, keel" and "Minimal contract surface →
  airline" were all false. Added rows for the three genuinely new references
  (runtime identity with no context to read; a server-settled clock; an
  entitlement-rather-than-authority gate) and for "raising an EXISTING skin", which
  is what three of the six now demonstrate.
- The closing "do not use airline or keel as demo-completeness references" warning
  is gone. What replaces it is the lesson the retrofits actually taught and which
  generalises past any roster: keel shipped the full per-user identity plumbing and
  then no seed file, so it got ZERO demo value from the hardest part of what it
  built — build the seed file in the same phase as the plumbing.
- Pill-count prose: was wrong twice for the same reason. Now derivation-only.

templates.md
- Checked the `resolvePage` scaffolds first, because the `PAGES[key] ?? null`
  object-literal pattern is a live bug (an object literal inherits
  Object.prototype, so `PAGES["constructor"]` is truthy, `?? null` never fires,
  and the shell's `if (!Page) notFound()` is bypassed — a 500 where a 404 belongs).
  Both scaffolds ALREADY use `new Map()` and already explain why, so nothing to
  fix; recorded here so the next reader does not re-check.
- `data/use-data.ts` pointed at `src/skins/airline/data/use-data.ts`, a DELETED
  file. Rewritten to say plainly that nothing sets `useData`, that this template is
  the only reference left, and to give the two reasons both skins migrated (beat
  3d's artifact cannot outlive the tab in client state; a client ticker is a second
  clock).
- The page and tools scaffolds defaulted to `useSkinData<<Id>Data>()` — i.e. they
  taught the one shape no skin uses. Now default to the skin's own ledger context,
  with `useSkinData` as the documented alternative.
- The `skin.tsx` scaffold's "airline omits every one below EXCEPT toolLabels +
  useData — and airline hits one beat of nine" is false on both halves.
- "Mirror src/skins/airline/agent.ts (minimal)": airline's agent is 247 lines and
  none of the six is minimal, because agent.ts is where most beats are enforced.
- The pill-count paragraph.

failure-modes.md — two new classes, in the file's voice
- § 13 "Nothing in this app type-checks a test file unless you run tsc yourself".
  This is a lies-shaped defect, not a commands note: a green gate is a claim, and
  three of them were green over a live TS2352 this run. It also silently voids a
  class of guard this skill asks for, since several of the strongest assertions in
  the tree are type-only. Cross-linked from § 7's "what would have to break for
  this to go red", one level up.
- § 14 "A lookup keyed by URL input must be a Map, or the 404 branch never fires":
  the prototype-chain bug above, why `Record<string, ComponentType>` cannot catch
  it (the annotation is a lie about a plain object), and the § 11 class sweep — the
  second instance was the operator→identity map in `user-id.ts`, keyed by a
  CLIENT-forwarded `properties.userId`, where the same defect hands Intelligence an
  `undefined` memory bucket and silently misroutes beats 4/5/6.
- § 10 now says the five-channel list is not exhaustive and documents airline's
  `waiverGround` sixth channel plus why no identifier selector can see it, and its
  "COUNT the selectors (covered files: four; any other in-skin file: three)"
  instruction is replaced — that count had already rotted (`actions.ts` resolves to
  two, and the number moved again when `statusKeyedTerminalRender` joined the
  block). The check is the resolved-selector table in `skins-config.test.ts`, which
  asserts the list BY NAME.
- The header's "everything here came out of one review of commerce" is now
  "most of this", with §§ 13-14 attributed to the beat-parity run.

Verified: pnpm lint, pnpm exec tsc --noEmit, pnpm test:unit (197 files / 2227
tests), pnpm build — all green. skin-roster-docs.test.ts passes, including over
this skill's three files in its DOC_SET.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:50:31 +02:00
Maxim d7675421a4 docs(reskinnable-demo): retire the two-substrate framing; all six skins are REST-backed and demo-complete
CLAUDE.md, README.md and .env.example described the app as it stood two waves
ago. Every claim below was verified stale against the tree before being changed,
and each replacement carries the command that derives it rather than a number or
a name list.

WHAT WAS FALSE, AND THE DERIVATION THAT PROVED IT

- "two different data substrates — banking/logistics/people/commerce REST-backed,
  airline and keel in-memory ... to prove the contract is substrate-agnostic".
  `ls src/skins/*/data/use-data.ts` returns NOTHING; both in-memory skins now read
  REST ledgers (`ls -d src/app/api/*/v1` names six). Rather than delete the app's
  central architectural claim, it is restated on the stronger footing the
  migration actually earned: the proof is now that TWO SKINS CHANGED SUBSTRATE
  with no change to the `Skin` contract and no change to the shell. Stated
  honestly that no in-memory skin remains, so that shape has no worked example
  left in the tree.

- The `useData` contract row ("the standard mechanism for the two in-memory
  skins ... splits exactly along the substrate line"). It has ZERO implementors:
  `grep -rn useData src/skins/*/skin.tsx` returns only six comments recording the
  omission. Now stated plainly as an optional escape hatch nothing uses — live
  rather than vestigial (the shell still runs the hook), with templates.md named
  as the only remaining reference.

- The beat matrix. `airline` and `keel` showed ❌ on nine of ten rows and hit all
  ten. Every cell updated. The per-skin GEN-UI COUNTS were removed rather than
  corrected, and the derivation printed instead — the old command
  (`grep -A3 useComponent .../tools.tsx | grep -c name:`) under-reports banking
  by one, because banking registers a component in `pages/cards.tsx`; the
  replacement counts the whole skin folder. Same for pill counts.

- Per-skin bullets for airline ("in-memory", "the minimal end of the contract",
  omits eight optional fields) and keel ("in-memory", `useData: useKeelData`).
  Derived per field:
  `grep -nE '^\s+(Providers|CanvasSurface|...)[,:]' src/skins/*/skin.tsx`. Airline
  omits exactly `sandboxFunctions` and `RuntimeProviders`; keel omits nothing.
  Keel's parameterized-routes claim is TRUE and kept.

- "That is five of the six skins; airline is the only one that omits [identifyUser]".
  `ls src/skins/*/intelligence/user-id.ts` returns all six, as do
  `seed-memories.ts` and `forget-memories.ts`. Replaced with the three `ls`
  commands. The "dev/reset is the wider set" note is kept as a WARNING while
  recording that the two sets now coincide.

- "there is no `typecheck` script — `pnpm build` type-checks as part of
  `next build`". Wrong, and it cost this run real time. `next build` type-checks
  only what the app's module graph REACHES, so it never opens any of the 197 test
  files; Vitest transpiles without type-checking; `tsconfig.json` DOES include
  them. `pnpm exec tsc --noEmit` is the only full type-check in the tree and it
  found a live TS2352 in a slot that had reported three green gates. § Commands
  now names four gates in cheapest-first order; README's Testing block too.

- .env.example: "The other presenter-ready skins (commerce, logistics, people)".
  All six ship `v1/dev/reset`; replaced with the route + button greps, and the
  banking-gates-unconditionally difference recorded.

- New: the memory-SCOPE rule. `forget-memories.ts` skips project-scoped rows in
  every skin but banking (`grep -ln 'scope !== "project"'`), so a project-scoped
  learned procedure survives every presenter reset and beat 6 opens already-taught
  on the second run. Banking is self-consistent the other way and documented as
  the historical exception.

FIXTURE COUPLING — src/shell/skin-roster-docs.test.ts

Its "legitimate phrasings" list holds strings labelled "verbatim from the current
docs", three of which this commit made false in the docs ("the two in-memory
skins", "the four REST-backed skins", "five of the six skins"). Worth recording
precisely: the list is a SHAPE fixture passed straight to `findStaleCountClaims`,
not a doc mirror — it reads no file — so correcting CLAUDE.md alone would NOT have
turned the guard red. What it would have done is make the header comment a lie
and invite the next author to prune the entries, silently dropping the
discriminator they pin (an adjective between the numeral and "skins" is what
separates a subset claim from a total claim, and nothing else asserts it). So the
entries were moved to the PAST tense rather than deleted, the header now says
they are shapes and must not be pruned to match the docs, and three present-tense
phrasings from this commit were added.

The guard did its job on the way through: it failed on three sentences this
commit introduced ("the two skins" x2, "two of them"), all reworded numeral-free.
The header's "two known stale instances outside the doc set" note is updated —
`src/proxy.ts` is fixed in the following commit; `e2e/inset-layout.spec.ts`
remains, and is left alone deliberately because fixing it is a coverage change,
not a prose fix.

DOES THIS MAKE THE RESKIN SKILL STALE?

Yes, extensively — that is the point of this slot, and it is answered by the two
commits that follow (the skill, then the stale in-code comments). Per CLAUDE.md's
standing rule the skill is updated in the same PR.

Verified: pnpm lint, pnpm exec tsc --noEmit, pnpm test:unit (197 files / 2227
tests), pnpm build — all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:49:38 +02:00
Maxim a255d9bcf3 chore(reskinnable-demo): put airline and keel under the beat-2 and beat-6 lint guards
Both skins now ship a withheld gate vocabulary and replay-safe terminal
renders, so both belong in the two guards that check those properties. Held
back until now on purpose: a glob covering an unfixed skin turns the tree red
for a whole phase, and a phase that cannot end green is a phase nobody can
bisect.

Verified clean BEFORE widening, not after:

  - beat 2 (statusKeyedTerminalRender). Every remaining ToolCallStatus
    reference in either skin is a comment or a
    `=== ToolCallStatus.Executing && respond` HITL branch -- the interactive
    affordance drawn while a response is awaited. Neither has a `.Complete`
    terminal render, which is the shape the selector catches.
  - beat 6 (withheldGateVocabulary). Each skin contributes exactly its two
    agent-facing files. The three human filing FORMS stay OUT: each
    legitimately imports its label map, because the operator reads it and the
    agent learns the code by watching them choose one. A withheld catalogue
    with no form is an unlearnable gate.

skins-config.test.ts's resolved-selector table is updated in the same commit,
which is the point of that table: it asserts, per file, the selector LIST that
`ESLint#calculateConfigForFile` actually resolves. Its existing keel row
expected three selectors and would have failed. Six rows added or changed,
including both filing forms, so the deliberate exclusion is pinned rather than
merely intended.

Also resolves a bug this commit introduced and then fixed, kept because the
comment is the fix: a glob star followed by a slash inside a block comment
CLOSES the comment, and the rest of the file becomes a syntax error. `pnpm lint`
caught it as `Parsing error: ',' expected` five lines below the real cause.
2026-08-12 17:14:22 +02:00
Maxim 00162f5e1b feat(reskinnable-demo): give keel memory, a stored procedure and a teach loop
Merges blitz slot keel-teach.

Conflict resolution note. Both teach slots hand-edited the same paragraph in
agent-registry.ts describing which skins supply identifyUser, and BOTH sides
were wrong by the time they merged -- air-teach's said "logistics and keel
scope threads only" (keel-teach had just given keel durable memory), and
keel-teach's said "skins without it (e.g. airline)" (air-teach had just given
airline a resolver). Each was made false by the other's slot, in the same hour.

Resolved by deriving instead of picking a side:

  ls src/skins/*/intelligence/user-id.ts        -> all six
  ls src/skins/*/intelligence/seed-memories.ts  -> all six
  ls src/skins/*/intelligence/forget-memories.ts -> all six

All six skins now supply identifyUser AND use it for durable memory, so the
generic-identity fallback is unreachable from the registry and is kept only
for skins that do not exist yet. The comment now says that and points at the
derivation, with the near-miss recorded so the next person does not re-add a
hand-maintained list.
2026-08-12 17:11:47 +02:00
Maxim 2c698c3724 fix(reskinnable-demo): make keel's presenter reset re-arm the memory beats
The route restored the DATA STORE ONLY and said so loudly in its own header,
because keel had no seed/forget pair. Now it has one, so the reset does the other
half — and, critically, VERIFIES it rather than assuming.

- Wipes learned memory in every bucket `memoryScopeUserIds()` reports, ASKED FOR
  rather than hardcoded. Bellwether shipped a hardcoded list and it could not
  possibly be right: `playwright.config.ts` pins `INTELLIGENCE_USER_ID`, which
  collapses the whole set onto one bucket the list did not contain, so the reset
  scrubbed buckets nothing was reading while a taught procedure survived.
- Re-seeds every target bucket, then COMPARES the count against
  `seedTargets.length * SEED_MEMORIES.length`. `seedMemories` never throws — it
  counts stored rows and logs the rest — so without the comparison the route would
  answer `reset: ["store","memory"]` against a backend that had rejected every
  POST, and the presenter would walk on stage believing beats 4/5 were armed.
- `reset: ["store","memory"]` is claimed ONLY when the wipe proved itself complete
  AND every expected seed landed. Partial and total shortfalls both 502, because a
  shortfall does not say WHICH memory is missing and the only caller branches on
  `res.ok`. The interrupted path reports MEASURED counters (`bucketsSwept`, not
  `forgot` — an empty bucket forgets zero rows, which is the normal state of a
  second reset in a row).
- Every free-text field goes through `redactSecrets`: this route's gate is a demo
  convenience, not an authorization boundary, and `memoryError`'s cause can quote
  the backend address verbatim while a 401 body can echo the key it rejected. The
  address stays in the LOG, which is where a human debugging a reset reads it.
- The OSS path (no Intelligence env) still answers `reset: ["store"]` and never the
  word "memory" — the most misleading string this route could return, because a
  presenter reading it stops looking for the reason beat 6 opened already taught.
  The test that pinned the old body is updated to that case rather than deleted.

Does this make anything in `.claude/skills/reskin/` wrong? Checked: no. The skill
already prescribes the seed-then-verify reset shape; keel was the outlier and is no
longer one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:07:45 +02:00
Maxim e381d58d64 feat(reskinnable-demo): give keel one suggestion pill per beat
The pills are the demo's script — the presenter should never have to type — and
keel's four were the whole list, covering one beat. Now twelve, in demo order.

Eight beat-carrying additions (1, 3a, 3b, 3c, 3d, 4, 5, 6) plus keel's original
four, which survive VERBATIM: spec §11's walk-through is scripted against their
copy, and each is the only pill reaching its tool — grounded citation, the run
plan-preview HITL, the persona-scoped approval queue, the a2ui canvas report.

The beat map targeted eight-to-nine, and that figure is incompatible with its own
other two instructions once the pills are counted: eight beat pills plus four
survivors is twelve, and nine is only reachable by dropping an identity pill or
leaving a beat without one. Both were checked against the tools and neither is
available. The arithmetic is written out in the file header so the next reader does
not "fix" the count by deleting a beat.

Two things that would break silently and are now pinned by tests:

- The beat-3d pill's message IS `BULLETIN_MESSAGE`, imported. `onSuggestionSelect`
  keys on that exact string; a retyped sentence takes the default send path, which
  DROPS attachments — the model then invents the bulletin's contents and files a
  durable brief that reads perfectly and proves the opposite of the beat. Asserted
  through the real `onSuggestionSelect` rather than by string comparison, so it
  fails if either side drifts, and every OTHER pill is asserted to return false.
- Beats 3a, 5 and 6 target three DIFFERENT documents (STD-045 endorsed, POL-121
  stale, POL-114 gated). Beat 6's unaided replay on POL-208 deliberately has no
  pill — a scripted sentence would let the room suspect it was rehearsed.

Also asserted: no pill says "Knowledge" (the nav label is Register while the segment
is still `knowledge`), and no pill names a variance code — a pill's message reaches
the model, so it is the prompt's leak channel by another route.

Does this make anything in `.claude/skills/reskin/` wrong? Checked: no. The skill's
"one pill per beat, in demo order" rule is what this implements; the stale number is
in keel's own `data/beat-map.md`, and the reconciliation is documented at the call
site rather than by editing that record of the original design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:07:23 +02:00
Maxim 8b8b08603b feat(reskinnable-demo): wire keel's beats 3c, 4, 5 and 6
The tools, the prompt clauses and the mount points. Keel went from one beat to ten.

BEAT 3c — `showRegister`, a HITL maneuver rather than a link. The Register's four
controls existed and nothing drove them. Space x attention x sort x top-N arrive
from the query string through the one shared `data/register-levers.ts`, the confirm
card draws its chips from the SAME normalized record the URL is built from, and the
controls tint. Every lever is REQUIRED with an explicit not-pulled member ("all", or
0): logistics needed a fix commit for exactly this, because a model facing an
optional enum fills it anyway — omission is not a choice it can state — and it put
an empty board on screen under four confidently tinted controls.

BEAT 4 — `showRegisterSummary(note)`, plus a prompt clause forcing `recall_memory`
BEFORE any question about the library's shape and requiring the recalled preference
in `note`. Without the visible why the beat is invisible on stage.

BEAT 5 — `raiseReviewFlag` -> `sendOwnerNotice` -> `addDocumentNote`, all three
`useFrontendTool` and NOT HITL: banking's equivalent once opened a confirmation card
mid-procedure, a presenter moved on, and the next message failed the whole thread
with "Tool result is missing for tool call ...". Their vocabularies are ENUMERATED on
the schemas — the exact opposite of beat 6 — because the claim is that it already
knows the procedure. Nine distractor tools sit alongside, so "it picked the right
three" means something. The prompt adds FINDING IS NOT HANDLING and states that this
is a DIFFERENT procedure from beat 6's with no offer to record.

BEAT 6 — `fileReleaseVariance` plus the HITL chain `offerWorkflowRecording` ->
`awaitDemonstration` -> `saveLearnedProcedure` -> `save_memory`. All five leak
channels are closed: no readable, no `z.enum` (free `z.string()` whose `.describe()`
states the withholding), no code in any description, none in the prompt, and the
route refusals are relayed verbatim without enumerating the catalogue. The prompt's
ACTION DISCIPLINE clause also shuts the two doors a run gate would open — a persona
switch and the e-signature card — because neither can clear a gate about the
REVISION. The replay lands on POL-208 Rev C, a different record from the POL-114
Rev D taught on stage.

Mounts: `KeelProviders` (new) carries the shell's `RecordingProvider` +
`RecordingVignette` BELOW CopilotKitProvider — the only point enclosing both the app
card where the operator demonstrates and the chat card that reads the feed; a
narrower mount makes every `logStep` a silent no-op. The variance filing form goes
on the Register page and is deliberately ABSENT from that page's readable. The
presenter Reset button goes in the header behind the same gate the route enforces.

Does this make anything in `.claude/skills/reskin/` wrong? Checked: no — but it does
leave `eslint.config.mjs` and `src/shell/skins-config.test.ts` needing keel's globs
(withheldGateVocabulary for `keel/{tools.tsx,agent.ts}`, and keel is now free of
`ToolCallStatus` so it can join statusKeyedTerminalRender). Both files are the
orchestrator's; the exact additions are in the slot report and in
`data/variance-codes.ts`'s header. Until they land, keel's own
`tools-replay-safety.test.ts` and `agent.test.ts` are deliberately stronger than the
rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:06:12 +02:00
Maxim 8aa3f62fa9 feat(reskinnable-demo): add keel's teach-mode surfaces and beat-4 card
The client half of beats 4 and 6. The server gate and unlock already existed; what
was missing was every surface a human or the recorder touches.

- `teach-mode-directives.ts` — the four strings the teach chain settles with, each
  BUILDER beside its READER so they cannot drift. `classifySaveProcedureResult`
  exists because both save buttons settle with a string: branching on presence
  prints "Saved — I'll use this next time" after the presenter clicked "Don't
  save", asserting a durable write that never happened, live and identically on
  every replay. `SAVE_PROCEDURE_CONFIRMED` names scope 'user' — project scope
  survives the forget sweep, so a project-scoped procedure would leave beat 6
  opening already taught.
- `components/variance-form.tsx` — the operator's filing form, and the ONE
  sanctioned consumer of `VARIANCE_CODE_LABELS`. It is the SIXTH channel and the
  one that must be OPEN: the agent learns which code lifts the gate by WATCHING
  the operator pick one. The menu lists justifying codes and decoys together,
  unmarked, in catalogue order — a form that flagged the working ones turns the
  demonstration into a guided tour. The filing step logs the code the operator
  ACTUALLY chose, decoy included, because a recorder that quietly corrected them
  would report a procedure nobody demonstrated.
- `components/demonstration-card.tsx` — owns the OUTER recording bracket, held
  from "show me" to "I'm done" so the two clicks a demonstration takes (file, then
  release) read as one recording. If the ref count reaches zero between them the
  shell clears the feed and STRANDS the code, and `getDemonstratedCode()` then
  reports null on a demonstration that plainly happened.
- `components/register-summary-card.tsx` — beat 4, with the `note` slot that makes
  the recall visible. Without it a grouped list is not evidence of memory; a model
  with none could produce one. Note renders ABOVE the groups, because it is the
  claim they are evidence for.
- `components/presenter-reset-button.tsx` — hard-navigates on success (the thread,
  canvas, levers and recorder feed are all state the reset threw away) and stays
  put on a 502, saying the register WAS restored but memory was not.
- `data/variance-codes.ts` header updated: the form it reserved
  `VARIANCE_CODE_LABELS` for now exists, and the header names the eslint glob keel
  still needs plus the tests standing in until it lands.

Does this make anything in `.claude/skills/reskin/` wrong? Checked: no. It follows
the skill's existing teach-mode guidance (shell recorder, never a private copy;
the sixth channel open) rather than changing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:05:43 +02:00
Maxim a5732a235b feat(reskinnable-demo): ship airline's eight pills and arm its presenter reset
The pills were the pre-beat-map set — check in, pick a seat, loyalty, delay, bags
— which demonstrated none of the nine beats and made a presenter type every one.
Replaced with ONE PILL PER BEAT in demo order, each carrying a comment recording
what it was VERIFIED to produce against the seeded ledger and where verification
stops (anything downstream of the model's tool choice is labelled
runtime-conditional rather than claimed).

Two orderings are load-bearing, not cosmetic. Beat 4 sits BEFORE beat 5, because
beat 5 rebooks the cancelled return that beat 4's seeded preference says to lead
with. Beat 6 is LAST, because the room has to watch the concierge succeed at
everything else before it is shown failing.

The beat-3d pill's message is `HOTEL_CONFIRMATION_MESSAGE` imported from
`./attach-hotel-confirmation`, never a retyped sentence: `onSuggestionSelect`
matches on that exact value, and a drifted string takes the default send path with
the attachment DROPPED — which fails beat 3d while looking like a model problem.
`suggestions.test.ts` asserts the identity, that the interceptor claims that pill
AND ONLY that pill, and that claiming it actually sends.

Presenter reset — `dev/reset` now sweeps and re-seeds durable memory (commerce's
route, verbatim in structure), so a cold reset arms beats 4/5 with no warm-up run
and leaves beat 6 unlearned. `memoryBeats: "unarmed"` and its `memoryNote` are
DELETED in this same change, which is the ordering `data/beat-map.md` trap 3 asked
for: the field existed to stop a store-only reset being a silent trap, and keeping
it now would be the same lie pointing the other way. `route.test.ts` pins its
absence alongside the shortfall/wipe-incomplete/interrupted paths, and keeps the
REAL store for the "puts back everything the beats wrote" case.

Airline had NO reset control at all, so the sidebar gains one behind
`PRESENTER_RESET_ENABLED` — gated exactly as the route is, so a production booth
never shows a button that 403s. A non-ok response alerts and does NOT reload: a
502 means the wipe could not prove it finished or the seed fell short, and both
break a beat silently, so reloading to a clean-looking app is the wrong answer.

Reskin-skill review: checked, no skill impact. The pills, the reset route shape
and the sidebar control all follow patterns `demo-beats.md` § "Presentation
requirements" and § "Seeding memories" already prescribe; nothing about the
contract, the registration sites or the verification commands changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:05:17 +02:00
Maxim 02be94c4a7 feat(reskinnable-demo): add keel's intelligence seed/forget pair
Keel had `intelligence/user-id.ts` and nothing else, so its presenter reset
restored the DATA STORE ONLY: it could not wipe a procedure the agent had been
taught, nor re-arm the memories beats 4 and 5 depend on. Run beat 6 twice and the
second room watched an agent that already knew the answer.

- `seed-memories.ts` — beat 4's TOPICAL reading preference (group by knowledge
  space, overdue first, coverage as a whole percent, owner beside every ref, and
  "not measurable" rather than 0%) and beat 5's OPERATIONAL procedure
  (raiseReviewFlag -> sendOwnerNotice -> addDocumentNote, immediately, no
  confirmation). Both `scope: "user"`, never `project`: the forget sweep skips
  project rows because that scope is global to the shared Intelligence instance,
  so a project-scoped procedure would survive every reset. Beat 6's unlock is
  DELIBERATELY not seeded — that is what must be taught on stage — and the file
  says so where the next author will read it.
- The memory text is addressed to the DESK, not to a named persona. It lands in
  every persona's bucket and keel's role switcher sits in the header, so "when
  Sam asks…" would be recalled while Ana is on screen.
- `forget-memories.ts` — mirrors commerce's: bare-list enumeration, verified
  list->delete passes rather than a guessed page size, per-row failures stepped
  over rather than abandoning the bucket, and project-scoped rows left alone so a
  keel reset cannot destroy banking's seeded memories.
- `user-id.ts` gains `memoryScopeUserIds()` and `memorySeedTargetUserIds()`,
  DERIVED from the persona roster so the reset asks rather than restates. Seeds
  the DEFAULT bucket as well as every mapped persona's, because runs frequently
  resolve to the default and a single-bucket seed recalls nothing while looking
  perfectly stored one id over.

Does this make anything in `.claude/skills/reskin/` wrong? Checked: no. The skill
describes seed/forget as the pair a memory-claiming skin ships, which is now true
of keel; `demo-beats.md`'s "Seeding memories" section still describes banking's
`project` scope, which was already out of date before this change and is called
out in `seed-memories.ts`'s own comment rather than silently followed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:05:15 +02:00
Maxim ffc74d7412 feat(reskinnable-demo): land airline's beats 4, 5 and 6 on the client
Beat 4 — recall with a VISIBLE why. `showTrips` grows a REQUIRED `note` string
that the agent fills with the preference it recalled and applied, rendered as a
"Remembered · …" band above the trip wall. Without that band the room watches a
competent summary and has no way to know anything was recalled, so the beat is
invisible and does not count. The slot is required rather than optional because
an optional one is the one a model silently omits; a blank note still renders no
band, which is the honest state on the OSS path where there is no
`recall_memory`. The prompt gains a "RECALL FIRST, THEN SAY WHAT YOU APPLIED"
clause — recalling after the answer is on screen is not recalling.

Beat 5 — the seeded procedure now has prompt discipline around it: resolve the
booking from context and use its BOOKING ID (AV7QK2 covers two of Camila's legs,
so the confirmation code is genuinely ambiguous), finding is not handling, and an
EXPLICIT statement that this is a DIFFERENT procedure from beat 6's with three
named things it must not do (no `offerWorkflowRecording`, no
`awaitDemonstration`, no offer to record). Conflating the two is the easiest
mistake available in this demo.

Beat 6 — the client half of the teach loop:

- `offerWorkflowRecording` → `awaitDemonstration` → `saveLearnedProcedure`, all
  `followUp: true`, plus `DemonstrationCard`, which OWNS the outer recording
  bracket from "show me" to "I'm done". The two clicks of a demonstration nest
  inside it; a ref count reaching zero between them clears the feed and STRANDS
  the demonstrated category.
- `teach-mode-directives.ts` — each builder beside its reader, so a card states
  only what its producer reported. The save card CLASSIFIES its settle rather
  than testing for presence: both buttons settle with a string, so branching on
  presence prints "Saved" over a decline, live and on every replay.
- `components/fare-exception-form.tsx` — the passenger-facing filing form, the
  ONE sanctioned place the waiver vocabulary appears, mounted on Your account.
  The menu lists justifying categories and decoys together, unmarked, in
  catalogue order, and the form shows the booking's own `fareNotes` prose,
  because the gate is GROUNDED: the learned procedure has to be "read what this
  booking documents, file the matching category, approve, retry", not a memorized
  string. The filing step carries the category as DATA (`logStep(label, code)`),
  which is what `getDemonstratedCode()` reads.
- `components/authorizable.ts` gains `blockedByFare`, deriving the gated cases
  from the same clause order the server runs. It is optimistic about a linked
  approved exception for the same reason `offerableOptions` is — the wire cannot
  see `waiverGround` — so a decoy filing drops the case off the list while the
  server still refuses it; the form keeps the selection so the presenter can
  still press Retry on the case they just filed against.

All SIX leak channels stay closed, and `tools.test.ts` now checks four files
rather than two (the two agent-facing ones plus the directives and the seeded
memories, both of whose text reaches the model). It also adds the positive
assertion the negatives cannot make — that the catalogue IS imported by the form
and the form IS mounted — because absence everywhere with no form is an
unlearnable gate that would pass every negative case.

Reskin-skill review: checked, no skill impact. No contract field, link builder,
lint rule, registration site or beat mechanism changed; this is one skin catching
up to mechanisms `SKILL.md` and `demo-beats.md` already describe. The teach chain
and the filing form are modelled on logistics'/commerce's, which the skill
already names as the references.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:04:52 +02:00
Maxim a12b95e004 feat(reskinnable-demo): give airline durable memory and a per-passenger scope
Aeronova had the full REST substrate for beats 4, 5 and 6 and none of the
Intelligence half, so its `dev/reset` restored the trip record and left a
previous run's learned procedure standing — the most demo-destroying state this
app has, because everything still works and it just proves nothing.

Adds the three modules the other memory-complete skins ship:

- `intelligence/user-id.ts` — server-safe `IdentifyRunUser`, registered as
  airline's `identifyUser` in `agent-registry.ts`. The traveller→bucket map is
  BUILT FROM `data/trip-seed.ts` and is a `Map`, not a plain object: the key is
  client-forwarded, and a prototype-chain hit ("constructor", "__proto__") would
  pass the truthiness guard and then scope memory under `undefined`.
- `intelligence/seed-memories.ts` — beat 4's standing preference (aisle, forward
  of the wing, never Basic Economy, times in America/Santiago, disrupted first)
  and beat 5's cancellation procedure. Beat 6's fare-exception procedure is
  DELIBERATELY absent, with a comment saying so; the beat-5 text also states
  out loud that it is not that procedure.
- `intelligence/forget-memories.ts` — mirrors commerce's, including the
  project-scope SKIP: project rows are global to the shared Intelligence
  instance, so sweeping them would delete banking's seeded memories. That skip
  is why beat 5's procedure and the teach chain both scope `user` — a
  project-scoped row would survive every presenter reset and open beat 6
  already taught.

Both `memorySeedTargetUserIds()` and `memoryScopeUserIds()` are derived from
`resolveUserId`, so a pinned `INTELLIGENCE_USER_ID` collapses them onto the
bucket the runtime will actually read. The seed targets include the DEFAULT
bucket as well as the account holder's, because runs frequently resolve to the
default and a single-bucket seed recalls nothing while looking fine.

Client half: `runtime-properties.ts` forwards `{ userId, userRole }` as a frozen
module constant. No `RuntimeProviders` — Aeronova has one account holder and no
switcher, so the hook reads no context and nothing has to sit above
`CopilotKitProvider`. `runtime-properties.test.ts` is the drift guard for the two
duplicated literals (the seed is not imported client-side on purpose).

`skin.tsx` also picks up the teach chain's `toolLabels` here since it is the same
declaration site.

Reskin-skill review: checked. The `Skin` contract, link builders, registration
and the client/server boundary are untouched; airline now sets two optional
fields the skill already documents. `demo-beats.md` still describes banking's
`project` scope for the stored procedure, which is out of date for the reasons
above — that is a pre-existing skill gap this change does not widen, and it is
reported to the orchestrator rather than edited here (`.claude/skills/**` is
outside this slot's boundary). CLAUDE.md's "airline is the only one that omits
`identifyUser`" line is now false and is likewise reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:04:10 +02:00
Maxim 329c8a7a22 feat(reskinnable-demo): wire keel's skin, tools and agent, and settle runs server-side 2026-08-12 16:27:58 +02:00
Maxim 0d21adea4d feat(reskinnable-demo): give keel's agent the canvas tool and the screen clause
`render_impact_brief` is the SERVER tool beat 3d had no way to reach: without it
nothing ever opens the canvas for a filed brief. It takes a `briefId` and nothing
else — every string, date and citation on that surface is read out of the stored
record, so the brief on the canvas and the brief on the Register page cannot say
different things about the same document. An unknown id returns `{ error }`
naming the id and the tool that mints one, because an agent handed no surface and
no reason retries the same wrong id. The surfaceId is suffixed per call so
dismissing one brief never suppresses a later one, but rooted at the BRIEF's own
id, never the ops report's.

BEAT 3b's third leg: the SCREEN AWARENESS clause now describes THIS skin's
screen. It names the Policy Register's four levers, the admitted-versus-displayed
row counts, the rows in the order shown, and the document sections on a detail
route — and it states the two things in that context that must not be smoothed
over: a null `attestation_coverage_percent` means NOT MEASURABLE rather than 0%,
and `book` figures describe the whole register and never the filtered view. Two
new rules cover the release gate (relay a refusal, never route around it, and it
is not the same gate as a run approval) and the ingest-to-artifact arc. The nav
label is now "Register", so the prompt calls it that everywhere.

`agent.test.ts` is the drift guard `agent.ts` never had. It asserts the resolved
server-tool list EXACTLY (a dropped tool and an added one both matter), executes
`render_impact_brief` against a really-filed brief — including the uncarried-ref
row that proves the document was read — and reads the prompt for the claims a
reviewer would otherwise take on trust, including that it names NONE of the six
publication-variance codes. A tool defined and never registered compiles, lints
and renders; it fails once, on stage, as "the canvas never opened".

Reskin-skill check: no impact. The skill already tells a skin author to
co-locate a server-safe `agent.ts` and warns that it has no drift guard; this
adds one for keel without changing the contract it describes.
2026-08-12 16:25:14 +02:00
Maxim aa07ab5f6d feat(reskinnable-demo): wire keel onto the ledger and land beats 1, 2 and 3a
Mounts `KeelLedgerProvider` in `KeelRuntimeProviders`, moves every consumer onto
the one `GET /ledger` snapshot through a new `useKeelDesk()`, and RETIRES
`data/use-data.ts` — deleting its 900ms `setInterval(() => setRuns(tick(...)))`
in the same change that flips the consumers. Not before (it was the only clock
until then) and not after (that is the two-clocks bug: the client painting
progress the server never heard of, and the next `refresh()` silently rewinding
it). `skin.useData` is gone with it, so `useSkinData<T>()` now correctly returns
undefined for keel, as it does for the four other REST-backed skins. Runs and
the policy register are finally one substrate.

`useKeelDesk` carries the pure derivations across unchanged — `approvals`,
`approvalsForMe`, `kpis`, and the `summaryKey` churn guard, which matters MORE
now that the poll hands back a fresh snapshot object every 900ms. Its mutations
are POST-then-re-read, so they return promises and carry a third outcome an
in-memory store could not have: `stale`, meaning the write LANDED and the
re-read did not. Every caller surfaces `reason` even on success, because "this
view is behind" printed as a green tick is indistinguishable from a slow
network.

BEAT 1 — `showRegisterHealth` renders policy-library health as a card in the
transcript: the four tiles plus a per-space bar with the review debt tinted
inside it. It takes NO figures; the card re-derives every one through the same
`deriveRegisterKpiTiles` / `summarizeRegister` the Register page uses, so the
chat and the page cannot disagree. Coverage stays a tri-state — "Not measured",
never 0%, for a document nobody has been assigned.

BEAT 2 — every render in `tools.tsx` now chooses its terminal branch from the
recorded `result` through one `settledText` helper, and `ToolCallStatus` is no
longer imported at all, so a status-keyed branch is not expressible.
`countersignRelease` CLASSIFIES its result rather than merely detecting one: a
refusal and a cancellation are settled results too, and rendering the success
receipt for either would replay a release that never happened. A new
`AwaitingCard` covers the no-result/no-respond frame, which is streaming when
live and an unanswered interrupt on replay.

BEAT 3a — `countersignRelease` opens a `SigningPinCard` that POSTs the six-digit
e-signature PIN straight to `/countersignatures`. The agent names only the
DOCUMENT (there is deliberately no `revision` parameter), the card reads the
record's own pending revision, and `respond()` gets one sentence. It is NOT an
authority override: the route re-runs the same `checkReleaseAuthority()` gate, so
a valid PIN on an unendorsed revision is still refused, and the card RELAYS that
refusal — one request, one endpoint, no fallback. Weakening it would give beat 6
a second door that nothing would fail on.

Write/HITL tools read the desk through a ref with `[]` deps: a `[data]` dep would
unregister a tool mid-call every time the poll produced a new snapshot.

BEAT 3d's ingest half is wired too — `chatHeaderActions` (the paperclip) and
`onSuggestionSelect` (claims only `BULLETIN_MESSAGE`, because the default
suggestion path drops attachments), plus a `fileImpactBrief` tool that files the
durable record.

Keel's parameterized routes survive: `pages/parameterized-routes.test.tsx` now
seeds its run into the LEDGER and still asserts both `knowledge/<docId>` and
`runs/<runId>` resolve and render.

Reskin-skill check: keel is no longer one of the two in-memory skins, so
`.claude/skills/reskin/SKILL.md`'s `useData` guidance and the substrate lists in
CLAUDE.md/README are now stale. Those files are outside this slot's boundary and
are called out for the doc slot rather than edited here.
2026-08-12 16:24:51 +02:00
Maxim 8e3522b564 feat(reskinnable-demo): settle keel's runs on the server, on read
Time lives on the SERVER for keel's run engine. `settleRuns()` advances runs
through the pure `engine.tick(runs, Date.now())` and COMMITS the result, and
BOTH read routes call it: `GET /ledger` and `GET /runs/<runId>`. Settling only
one is worse than settling neither — the run-detail page and the Runs table
would then contradict each other about a single run in front of the room.

`engine.tick` is pure and duration-driven, so a run's state at an instant is a
total function of its stored steps and the clock. Settling on read therefore
yields exactly what a client ticker would have converged to, which is what makes
this a decision rather than a workaround: the client needs no clock of its own,
only a re-read.

The commit is an in-place splice into the array `store.runs()` hands back,
because `data/store.ts` exports no setter and is another slot's file. That keeps
the settlement durable rather than recomputed per request, so a subsequent
`store.approveStep` composes on settled steps. `tick`'s fast path returns the
same array reference when nothing moved, so an idle read does not touch the
store at all.

`settle-runs.test.ts` is the only thing that can see a regression here: a route
that stopped settling still returns 200 with a well-formed run, and the sole
symptom is a started run that never moves.

Reskin-skill check: no impact — this is keel's own substrate, and nothing in
`.claude/skills/reskin/` describes where a skin's clock lives.
2026-08-12 16:23:51 +02:00
Maxim bbef10a3bc feat(reskinnable-demo): wire airline's skin, tools and agent (beats 1, 2, 3a) 2026-08-12 16:20:43 +02:00
Maxim 12f4a7342c fix(reskinnable-demo): stop resolvePage returning Object.prototype members
`/banking/constructor` answered 500 where it owed 404, and so did
/logistics/constructor, /people/constructor and the same URL for toString,
valueOf, hasOwnProperty and __proto__.

Three skins resolved pages out of an object literal:

    const PAGES: Record<string, ComponentType> = { "": Index, cards: Cards };
    return PAGES[key] ?? null;

An object literal inherits Object.prototype, so PAGES["constructor"] is a
truthy Function and the `?? null` never fires. src/app/[skin]/[[...rest]]/
page.tsx then does `if (!Page) notFound(); return <Page />` -- `!Page` is
false, notFound() is skipped, and React is handed something that is not a
component. Commerce and keel were already Map-backed and unaffected.

Each of the three now uses a Map, which has no prototype keys.

The real fix is the new guard, src/shell/resolve-page-prototype.test.ts: it
walks EVERY registered skin against every own key of Object.prototype, taken
from the prototype itself rather than hand-listed, on both a top-level and a
nested segment. It pins behaviour rather than implementation, so a skin that
prefers `Object.hasOwn` passes too, and it keeps holding for skins that do
not exist yet.

Two things learned writing it, both kept in the file:

  - The property is NOT "returns null for a prototype key". Keel resolves
    knowledge/<docId> for ANY docId on purpose and renders an in-page
    not-found body, so a non-null answer there is correct. The property is
    "never returns a value INHERITED from Object.prototype".
  - It carries a non-vacuity assertion (at least six skins registered) and a
    per-skin companion (the index still resolves), because a resolvePage that
    returned null for everything would otherwise pass every other assertion
    while serving a dead skin.

Mutation-verified: reverting people to the object literal turns it red.

Found by hand while wiring a fourth skin, not by any gate -- it type-checks
(the Record's index signature says ComponentType), it lints, and no test that
walks the REAL segments ever passes a prototype key.

airline has the same defect and is fixed by the air-wire slot merged next;
this guard is what will hold it green afterwards.
2026-08-12 16:20:28 +02:00
Maxim 68e3712e0c test(reskinnable-demo): drop the import() type annotation from airline's skin test
`oxlint`'s `consistent-type-imports` warns on `typeof import("…")` inside the
`vi.mock` factory. Spread the original module through a plain record instead;
`importOriginal` still supplies the REAL `HOTEL_CONFIRMATION_MESSAGE`, so a
pill whose text drifts from the constant still fails the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:10:55 +02:00
Maxim 7720d5c3c7 test(reskinnable-demo): guard airline's wiring, replay safety and withheld gate
Four guards, each for a defect that leaves the app compiling, linting and
rendering — which is why none of them was caught by the gates.

`skin.test.tsx` — the mounted skin object. `Providers` missing throws only when
a page that needs the ledger renders; `CanvasSurface` missing opens the canvas
region and draws nothing; `nav`/`resolvePage` disagreeing 404s a sidebar link;
a re-added `useData` puts a second seed of AV1423 back in the app. It also
pins the Map-backed `resolvePage` against prototype-chain keys, the way keel's
and commerce's do, and asserts the beat-3d pill interception fires the send
rather than merely returning `true`.

`tools.test.ts` — the drift guard `agent.ts` and `tools.tsx` do not otherwise
have. It cross-checks that every tool the beats need is registered, that
`render_trip_brief` is a SERVER tool listed on the agent (a client tool result
never produces an `a2ui-surface` activity), and that the prompt names no tool
nothing registers — a failure whose only other symptom is "I don't have a tool
for that", live. It also holds beat 2's invariant (no terminal render keyed off
`status`, no `result.match`, no `typeof result === "string"`) and beat 6's
withholding across all four greppable channels, including the PROSE one no lint
rule can see: the four justifying categories and the three decoys are checked
by name in both files.

`components/card-confirmation-card.test.tsx` — beat 3a, rendered. The digits go
to `/authorizations` and nowhere else, the sentence handed to `respond()` does
not contain them, an unreadable value is REFUSED with a reason on screen rather
than silently stripped, a double click cannot charge twice, and a
`FARE_NOT_CHANGEABLE` refusal is shown verbatim with nothing reported as
authorized. That last one is the only symptom the "second door around beat 6"
failure has on the client.

`components/concierge-view.test.ts` — one substrate, one AV1423. No
`use-data.ts` to read, no `useAirlineData`/`useSkinData` call site left, and no
read of the four seed constants the REST seed duplicates field for field. Plus
the two derivations that replaced stored values: the disruption follows the
flight (the old seeded alert said "55 minutes" whatever the ledger held) and
the seat map offers only seats the flight lists free.

`readables.test.tsx` gains beat 3b's THIRD LEG, which its own header said a
later slot had to add: the SCREEN AWARENESS clause in `agent.ts`, plus the
`loading` flag on every page readable.

Skill impact: checked, none. No skill file names any of these paths, and
SKILL.md § Verification's commands (`pnpm lint`, `pnpm test:unit`,
`pnpm build`) all still exist and all cover these.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:10:01 +02:00
Maxim 9d601ed03f feat(reskinnable-demo): wire airline onto the ledger and land beats 1, 2, 3a
Aeronova had a complete REST substrate and no UI attached to it. This mounts
the skin object, retires the second seed of Camila's AV1423, and lands the
three beats the substrate could otherwise only half-prove.

MOUNT AND FLIP. `skin.Providers` is now `AirlineProviders` (the one
`GET /ledger` read plus the shell's teach recorder), `nav`/`resolvePage` come
from `nav.ts`/`pages/index.ts` so `/airline/account` and `/airline/rebook`
stop 404ing, and `CanvasSurface` is `AirlineCanvasSurface`. `resolvePage` is
now Map-backed: the object literal it replaced resolved `/airline/constructor`
to `Object.prototype.constructor`, a truthy Function the shell renders as a
page — a 500 where a 404 belongs.

ONE SUBSTRATE. `data/use-data.ts` is deleted and `useData` is dropped from the
skin. `components/concierge-view.ts` projects the REST ledger onto the shapes
the check-in components were written against, so the flight, passenger, seat
map, disruption and rebooking options all come from `GET /ledger` and the REST
seed is the only authority for AV1423. Loyalty mileage, the redemption
catalogue and the bags stay seeded because the ledger models no counterpart —
they are beat 5's distractors — but the member identity is overwritten from
the ledger traveller so the tier appears in two places from one source. The
disruption banner is DERIVED from the flight's own status and delay rather
than stored, so it can no longer outlive the condition it describes.

BEAT 1. `showTrips` leads with the whole account as a trip wall; nine gen-UI
components in all, including the `showSeatMap` distractor `data/beat-map.md`
§ "Beat 5" requires and which was never registered.

BEAT 2. Every terminal render reads the recorded `result` through one shared
`ToolNote`; no `status === ToolCallStatus.Complete`, no `result.match`, no
`typeof result === "string"`. airline can now be added to
`statusKeyedTerminalRender`'s glob in eslint.config.mjs.

BEAT 3a. `authorizeWithCardConfirmation` renders `CardConfirmationCard`, which
POSTs the last four digits straight to `/authorizations` and hands `respond()`
one sentence that does not contain them. It is offered only through
`offerableOptions` — permitted change, money actually due — and it prints the
server's refusal verbatim, so it can never become a second door around
beat 6's gate.

BEAT 3b's third leg. `agent.ts` gains a SCREEN AWARENESS clause telling the
agent its context IS its view of the screen, plus the truncation and loading
rules. Every readable now carries a `loading` flag, so a screen that is still
spinning is not reported as empty.

BEAT 3d. `render_trip_brief` is a SERVER tool emitting
`buildTripBriefOps(briefId)` under `A2UI_OPERATIONS_KEY` — without it nothing
ever opens the canvas — and `fileTripBrief` returns the brief id for it.

Also lands beat 3c's `showRebookingSearch` (four levers plus top-N, confirmed
then navigated through `useSkinHref`), beat 5's three ordered writes, and beat
6's `fileFareException` with a free `z.string()` code and the withholding
stated in its `.describe()`.

Skill impact: checked. The `Skin` contract, the link builders, the
registration sites and the demo beats are all unchanged — this slot only
implements them. `.claude/skills/reskin/` names no file this change deletes
or renames (`data/use-data.ts` is airline's own, not a template path).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:09:07 +02:00
Maxim a3ade9a52a fix(reskinnable-demo): make the beat-3d staging test deterministic
Two wave-2 agents reported `stage-attachment.test.ts > "stages only once the
chip is queued AND finished encoding"` intermittently failing the whole suite
with `expected false to be true`, always while several agents were building
concurrently.

The report is real, and the cause is the test harness rather than the code
under test. The three SUCCESS-path tests shared the tight `FAST` budgets built
for the EXPIRY branches: a 40ms `readyMs` ceiling around a real `encodeMs: 10`
timer. `waitUntil` measures its budget with `Date.now()`, so a descheduled
vitest worker spends the ceiling without the event loop ever running the timer
it is waiting for.

Measured in the real vitest/jsdom environment, reproducing the exact timer
structure, 60 samples per condition:

  quiet                       p50=6ms   p95=12ms  max=12ms  (budget 40)
  60 busy loops on 10 cores   p50=11ms  p95=18ms  max=39ms  (budget 40)

A sample landed 1ms inside a 40ms budget. That is the reported failure.

Fixed WITHOUT weakening the property, and the property is now asserted more
directly than before:

- `PATIENT` budgets for the paths that succeed. A success-path wait is
  condition-based and returns the instant its predicate holds, so a generous
  ceiling costs zero wall clock — it only stops a loaded worker from expiring a
  budget never meant to be reached. `FAST` stays exactly as it was for the
  expiry tests, which still need it small.
- The ordering test no longer TIMES the encode, it DRIVES it: a new
  `encodeMs: "manual"` fixture mode latches the `uploading` -> `ready`
  transition behind a `finishEncoding()` the test calls. So the two halves are
  asserted separately and in order — queued-but-encoding must NOT stage, and
  only finishing the encode may.

The old shape read only the end state, inferring "waited for ready" from "ready
by the time it finished". Verified by mutation: deleting the production ready
wait (`const ready = true`) left the previous assertion GREEN, and leaves the
new one RED. The production file is byte-identical to HEAD.

Evidence: full suite 6/6 green (175 files / 1910 tests); the fixed file 6/6
green under the 60-busy-loop load that pushed the old budget to 39/40ms.
`promotions.test.tsx` drives fetch through held promises and microtask flushes
with no real timers, and `recording.tsx` already clears its hold timer on
unmount, so neither had a wall-clock budget to lose; both stayed green across
all runs, including three full-suite runs under 4x oversubscription.

Reskin-skill check (per CLAUDE.md standing rule): checked, no skill impact. The
skill references this file only for the fifteen-cause exhaustiveness gate, the
`[attach:<cause>]` log regex, and `Beat3dTimings` being injectable to force an
expiry — all three unchanged and still accurate. Nothing in `Skin`, the
production module, or any surface a skin author touches changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:28:26 +02:00
Maxim dbb411ee1f feat(reskinnable-demo): give keel an impact-brief canvas (beat 3d) 2026-08-12 15:01:10 +02:00
Maxim 36d619c4de feat(reskinnable-demo): give keel its policy register surface (beats 3b, 3c) 2026-08-12 15:01:08 +02:00
Maxim 934ed789e6 feat(reskinnable-demo): give airline a trip-brief canvas (beat 3d) 2026-08-12 15:01:07 +02:00
Maxim d7f43ae1ae feat(reskinnable-demo): give keel a route readable and on-screen readables
BEAT 3b, both halves. `layout.tsx` registers the ROUTE readable — the
path, the highlighted nav entry and, on the two parameterized routes, the
id of the record open — so the agent knows WHERE the operator is. Each
page then describes its own contents, so "what's on my screen?" answers
differently on the Register than it does on `knowledge/<docId>`.

The document page grows the register overlay beside the corpus prose,
which is what gives the second ask something of its own to be about: this
document's review debt, its attestation coverage, its pending revision
and the bodies that have not endorsed it. That readable is registered
UNCONDITIONALLY, before the not-found early return, so an unknown docId
is described rather than answered with "I cannot see the screen".

Unmeasurable attestation coverage travels as null, never 0. A model
cannot discount what you omitted and will restate a zero as an all-clear,
out loud.

`pages/on-screen-readables.test.tsx` is the guard that matters: it stubs
`useAgentContext`, renders each page, and asserts the readable's rows
against the rows the DOM actually painted, element for element and in
order — the drift no source grep can see, and the failure that survives a
live demo unnoticed. It also pins beat 3c's four levers against the
rendered board and the four tinted controls, on a 24-row fixture
deliberately larger than the nine-document seed so any later cap is
exercised.

`pages/parameterized-routes.test.tsx` pins the property keel is the only
skin to have: `knowledge/<docId>` and `runs/<runId>` still resolve AND
still render their record, and an unknown id is still an in-page
not-found body rather than a 404.

Skill impact: checked. `.claude/skills/reskin/demo-beats.md` § 3b names
banking, people, commerce and logistics as the skins with a route
readable plus per-page readables, and tells the reader to DERIVE that
list with `grep -rln useAgentContext src/skins/*/layout.tsx` rather than
trust the sentence. Keel now answers that grep, so the derivation is
correct without an edit — and the beat matrix in CLAUDE.md is the
orchestrator's to update once every keel slot has landed, not this one's
to change mid-flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:57:15 +02:00
Maxim e21b6a0689 feat(reskinnable-demo): make keel's knowledge page the policy register
BEAT 3c. Four levers — space, attention class, sort and top-N — all
arriving from the query string, all filtering through
`data/register-levers.ts`, and all four controls VISIBLY tinted when set.
It is the controls that light up rather than the rows, because a filtered
list alone asks the room to take the maneuver on faith.

The page renders the lever module's output and reimplements none of it,
so a value the schema can advertise and the view will not honour is not
expressible. An unrecognised value normalizes to null: the view renders
as it does with the lever absent and the control stays untinted.

One pipeline publishes TWO lengths — `matching` under the levers before
truncation, `visible` after it — and the caption, the rows and the
readable all read that one result, so "Top N of M" cannot report that the
filters did nothing.

Served at the `knowledge` segment rather than a new one: the register IS
the parent of `knowledge/<docId>`, the route a citation lands on. Only
the nav LABEL changes, so `resolvePage`, `navigateTo`'s page enum and
every citation href are untouched. Row links go through `useKeelHref`.

`now` comes from the snapshot's own `asOf` rather than the wall clock —
the rows, the tiles and the readable are then measured at the instant the
server measured the register, a test pins the clock by pinning the
fixture, and no impure clock read happens during render.

Two sections are marked-but-absent at the foot of the page — beat 3d's
filed Impact Briefs and beat 6's operator variance form — so the next
author adds them rather than discovering the page has no room.

Skill impact: checked, none. This changes one skin's page, not the `Skin`
contract, the registration sites, or any gate a skin must pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:56:46 +02:00
Maxim 5cd3a1bbe3 feat(reskinnable-demo): add keel's ledger context over the REST substrate
One snapshot read of `GET /api/keel/v1/ledger`, shared by every consumer
under `KeelLedgerProvider`, so the register board, the KPI tiles and the
beat-3b readables can never describe the register a fetch apart.

Shipped UNMOUNTED on purpose: `skin.tsx` belongs to a later slot,
`useKeelData` is still wired, and both parameterized routes still render
from it. `useKeelLedger()` therefore falls back to a standalone read
outside the provider rather than throwing — keel's own `useRole` takes
the same position — so a page can adopt it before the provider lands.

Decides the migration's open question, WHERE TIME LIVES. Keel's run
engine ticks on a 900ms client interval today while the server holds
runs as state only, and keeping both after the migration would put two
clocks on one set of runs: the client's local advance would paint
progress the server never heard of, and the next refresh after any write
would silently rewind it. Time lives on the SERVER, and the client's
only interval RE-READS. That is defensible rather than tidy because
`engine.tick` is pure and duration-driven, so settling on read yields
exactly the value the client interval would have converged to. The poll
here calls `refresh`, never `tick`, and only while a run is running.

The two follow-ups that must land with the consumer flip are written out
in the module header: settle runs in both read routes, and delete
`useKeelData`'s ticker in the same change.

Skill impact: checked. `.claude/skills/reskin/` describes the `Skin`
contract and the registration sites, none of which this touches — it
adds a skin-internal data hook alongside the substrate the beat map
already documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:56:23 +02:00
Maxim 6f7051deba feat(reskinnable-demo): give airline on-screen readables and a lever board
Beats 3b and 3c, which `data/beat-map.md` records airline has never hit.

BEAT 3b — a route readable in `layout.tsx` (via `useSkinSegments`, never a
fixed-offset `pathname.split`, which reports the wrong page under LOCK_SKIN),
plus a per-page on-screen readable on all five pages. The three in-memory pages
gain theirs immediately and are LIVE — Trip, Aeronova Club and Disruptions now
answer "what's on my screen?" differently, which is the beat.

`seat-map.tsx` grows an exported `orderedSeats` + `isSelectableSeat` so the Trip
readable lists the seats the map actually painted, in paint order, rather than
re-deriving them — the commerce 5-rows-against-6 bug, which fails silently.

BEAT 3c — `pages/rebook.tsx`, a passenger's rebooking search with window, stops,
cabin and sort levers plus a top-N, all read off the query string through the
shared `readLevers`/`applyLevers` the API route also runs, and all five controls
tinted when set. ONE pipeline publishes `matching` and `visible`, so the caption
("Top 5 of N matching flights") can never say the filters did nothing while the
rows say they did. The trip picker is the search's SUBJECT, not a lever, and
never tints.

`pages/account.tsx` keeps the account visibly CAMILA'S — her name, tier and card
in the header, her own trips first under "Your trips", and the two companions
nested under their own named cards as saved travellers described by their
relationship to her. `data/beat-map.md` names this page as where the rejected
operations-desk reframe would creep back in, so a test asserts the shape: no
table, no traveller column, a per-traveller list each.

Both new pages are UNREACHABLE until a later slot wires `skin.tsx` — they read
`useAirlineLedger()`, and `nav.ts` + `pages/index.ts` exist to make that a
two-import swap. `useAirlineData` and the three existing pages are untouched.

Tests: `readables.test.tsx` guards OMISSION (source grep, anchored inside the
`useAgentContext` call so it cannot pass on a heading or an import) and
`pages/on-screen-readables.test.tsx` guards DRIFT (renders each page and
compares the readable's rows against the painted DOM, element for element and in
order). Note the third leg of beat 3b — the agent's SCREEN AWARENESS prompt
clause — is NOT yet written: `agent.ts` belongs to another slot, and
`readables.test.tsx` says so where the assertion would go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:53:55 +02:00
Maxim 7fc55dbf01 feat(reskinnable-demo): add airline's REST ledger context, unmounted
`AirlineLedgerProvider` + `useAirlineLedger()` over `GET /api/airline/v1/ledger`
— one fetch, shared, plus the cross-instance revalidation bus logistics uses so
a write that goes straight from a chat card to a route (beat 3a's card
confirmation) still refreshes the screen.

A context rather than logistics' per-instance hook: Aeronova publishes ONE
cross-cutting snapshot, and beat 3b asks the agent to describe exactly what the
passenger can see, so two panels disagreeing about the ledger is the specific
failure this must not have.

SHIPPED UNMOUNTED on purpose. `skin.tsx` belongs to a later slot, so nothing
renders the provider yet and `useAirlineData` remains the live substrate for the
Trip / Loyalty / Disruptions pages — `data/beat-map.md` § "It is ADDITIVE". The
hook THROWS outside its provider and names the three edits that mount it, rather
than returning an empty ledger: a blank account on stage is indistinguishable
from a seed that failed to load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:53:07 +02:00
Maxim 4ecfeeaaff feat(reskinnable-demo): give airline a durable trip-brief canvas
Beat 3d, the "out" half — airline's first `CanvasSurface`. It renders the Trip
Brief `POST /api/airline/v1/briefs` filed, read back off the app over
`GET /briefs`, so what the room sees is the artifact that survives deleting the
conversation rather than a replay of what the model said.

The two-column split IS the argument, not decoration: the left column is what
only a reader of the attachment could know (hotel, confirmation number, the
22:30 desk cutoff, the cancellation deadline), the right is what only Aeronova
holds (booking, traveler, arrival station, scheduled arrival), and the headline
banner on top is the collision of the two — "AV1423 gets into Lima at 23:00;
Casa Miraflores stops taking arrivals at 22:30". A flat list of the same fields
would render identically and prove nothing.

`arrivesAfterLastCheckIn` is honoured as the tri-state it is: an unmatched
document paints the muted UNCHECKED banner and prints its dropped ledger fields
as "not on file", never the reassuring green one over a comparison nobody made.

Why there is no `A2UIProvider`/`A2UIRenderer` here, unlike the other four
skins: their briefs are compositions the agent selects, so their ops carry a
component tree and their catalogs carry the renderers. Aeronova's brief is one
durable server-settled record of fixed shape — the only selection left is WHICH
brief, so that is all `canvas/trip-brief-ops.ts` carries. `readBriefId` is
deliberately tolerant, so a later slot that emits a richer tree still resolves
the right brief; a named-but-missing brief refuses to fall through to the
newest, because showing a different brief under this run's headline is worse
than showing none.

Shipped UNMOUNTED: `skin.tsx` still omits `CanvasSurface`, and no tool emits
the ops yet. Both file headers name exactly what the later slot must wire.

Tests drive the real substrate end to end — the document facts go through the
route, the server settles the ledger half, and the canvas renders what `GET
/briefs` returns — rather than a hand-written brief object, which would pass
while the two halves disagreed about field names.

Reskin-skill check: no impact on the `Skin` contract, lint rules, registries,
routing or beat mechanisms. Worth the orchestrator's attention though: SKILL.md
and CLAUDE.md both describe `CanvasSurface` as "this skin's own a2ui report
surface", and this one activates off the same `a2ui-surface` activity while
rendering the durable record directly. Flagged, not edited — `.claude/skills/`
is outside this slot.
2026-08-12 14:52:06 +02:00
Maxim ad2780f3db feat(reskinnable-demo): stage the airline hotel confirmation as an attachment
Beat 3d, the "in" half. Aeronova now has the beat-3d attachment wrapper the
other three demo-complete skins have, built on the shell primitive rather than
a fourth private copy of the chain:

- `HOTEL_CONFIRMATION_MESSAGE` — one string shared by the pill and the
  interception, so a drift cannot send the prompt WITHOUT the file (the failure
  that leaves the model inventing the document's contents).
- `sendHotelConfirmationMessage` — the pill path.
- `attachHotelConfirmationByHand` — the presenter's paperclip fallback.

The booking is NAMED (`?booking=bkg-av1423`) rather than left to the route's
default, so a reseed that moves Camila's Lima trip 404s the fetch and ABORTS
the pill loudly instead of quietly attaching some other traveler's room.

Shipped UNMOUNTED: `skin.tsx`, `suggestions.ts` and `tools.tsx` belong to a
later slot, and the file header names exactly what that slot has to wire.

Everything load-bearing — composer lookup, PDF byte check, the four bounded
condition waits, the abort rule, the dual console/alert reporting — is
`@/shell/attach`, verified once in `src/shell/attach/stage-attachment.test.ts`.
The new tests cover only what is genuinely airline's and silent when wrong: the
three parameter values, that the named booking still resolves a confirmation
through `hotelConfirmationFor`, and that the composer chip's filename matches
the one `GET /hotel-confirmation` derives from the confirmation number.

Reskin-skill check: no impact. The `Skin` contract, the lint rules, the
registries, routing and the beat mechanisms are untouched; `templates.md`'s
"DO NOT IMPLEMENT THE CHAIN" guard is what this file follows.
2026-08-12 14:51:18 +02:00
Maxim 54e05fb3f7 fix(reskinnable-demo): type the empty-items case so tsc can check it
`BriefImpacts({ props: { items: [] } } as Parameters<...>[0])` is a TS2352
error: an empty array literal infers `never[]`, which does not sufficiently
overlap `RendererProps<{ items: string[] }>`, so the assertion is a mistake
rather than a widening. `[] as string[]` restores the overlap.

Worth knowing WHY this survived a green slot. Nothing in this app's gates
type-checks test files:

  - `pnpm build` (next build) only type-checks what the app's module graph
    reaches, and no test file is imported by the app;
  - vitest does not type-check at all, so the test passed while being
    ill-typed;
  - there is no `typecheck` script, and CLAUDE.md names `pnpm build` as THE
    type-check gate.

`tsconfig.json` DOES include `**/*.tsx`, so `pnpm exec tsc --noEmit` catches
it -- that command is the only thing in the tree that does.

Found by an explicit tsc run over the slot before merge, not by the slot's
own three green gates.
2026-08-12 14:45:28 +02:00
Maxim d4ed9072d7 feat(reskinnable-demo): render keel's filed Impact Brief on the shared canvas
Beat 3d's outbound half. `canvas/impact-brief-ops.ts` expands a brief the store
already holds into a2ui operations under its own surface id
(`keel-impact-brief`), and `canvas/impact-brief-components.tsx` contributes the
three renderers. `canvas-surface.tsx` spreads both into keel's ONE report
catalog, so the ops report is untouched and the two surfaces are told apart by
surfaceId rather than by catalog.

Where this deliberately departs from ops-report.ts: the figures ARE in the ops.
Run KPIs tick, so the report binds live `useSkinData`; a filed Impact Brief is
immutable the instant `POST /briefs` returns, so its values are read out of the
stored record. The tool takes a `briefId` and nothing else, which is what keeps
every string on the canvas server-sourced rather than the model's second telling
of what it just filed — and it makes the surface replay-safe with no fetch.

`carried` is re-derived against the LIVE register instead of stored: "the
library does not hold POL-118" is a claim about the register NOW, and a reseed
that adds the ref must be able to change the answer. That is the row the beat
rests on, so it is drawn as a finding, and "never released" is kept distinct
from "not in the library" — two facts `POST /briefs` goes out of its way not to
merge.

`bulletin-citations.ts` now EXPORTS its canonical-ref reduction (previously a
private `canonical`) so the canvas asks the same question of the same refs; a
third private copy is how the two surfaces come to disagree about POL-118.

Shipped UNMOUNTED for the tool half: `renderImpactBriefParams` and
`buildImpactBriefOps` are exported for a later slot's `agent.ts`. The canvas
half needs no wiring — keel already sets `CanvasSurface`.

Skill check: no impact on `.claude/skills/reskin/`. The `Skin` contract, the
link builders, registration and the lint gates are untouched; the skill's one
reference to `canvas-surface.tsx` (SKILL.md:536, naming it as the file behind
`CanvasSurface`) is still accurate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:38:58 +02:00