Skip to content

feat(dash): per-project cost attribution, and a usage-over-time chart - #578

Open
vz-ibm wants to merge 42 commits into
mainfrom
feat/insights-per-item-inventory
Open

vz-ibm wants to merge 42 commits into
mainfrom
feat/insights-per-item-inventory

Conversation

@vz-ibm

@vz-ibm vz-ibm commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator

What this adds

Two things the Cleanup view could not do before: report cost per project, and show when a capability was actually used rather than only what it costs in total.

Per-project attribution

Several projects route through one proxy, so every figure the dashboard reported was a blend across them. Each request now records the directory the agent was started in, read from the request's own prompt, and the view scopes to one project.

  • Per request, not per session. Measured on real transcripts: 40 of 48 sessions stay in one directory but 8 move, one across 21. Session-level attribution would charge one project for work done in another.
  • Sanitised at the write. A launch directory is a client-chosen string, so a folder named after a token would otherwise reach the database and the SSE feed. ProjectPath composes the same credential scan metaEnum uses with a path shape.
  • Additive migration, no version bump. A bump renames the file aside and discards every request row. Rehearsed on a copy of a live database: 2,750 rows in, 2,750 out.
  • Back-fillable. cmd/cg-backfill-project recovers the project for rows written before the column existed, joining the agent's own transcripts on session id. Dry by default; applied to a real store it attributed 3,012 rows. Scratch directories are filed under the session's home rather than becoming projects of their own — replaying a transcript literally put 485 requests across thirteen /private/tmp/* entries.

A usage-over-time chart

One stacked bar per removable capability, divided into days, each day green where the capability was invoked and red where it was carried and never called. A bar that turns red partway is something you stopped using.

Grouped by removal unit, so a plugin is one bar: skillOverrides has no effect on a plugin's skills, so fifteen separate bars would offer fifteen removals that do not exist. Built-ins get their own tab — real weight, no action attached.

Notable fixes found while building it

Several of these were defects in the chart that only a screenshot caught; structural assertions passed throughout.

  • A grouped row's per-day cost was 15× too high. sum(carried) * sum(tokens) multiplied every member's carriage by every member's weight, so one plugin's day reported $5.35 against a window total of $1.31. Each member now contributes its own product, so days sum to the total by construction.
  • Twenty items were invisible. The built-in group was capped at five bars, silently dropping the second most expensive capability on the page.
  • Hover dimmed the whole chart. focus: 'self' was added to stop a series-wide highlight and caused something worse: focus works by blurring everything else, so pointing at an empty cell faded the chart to near-invisible. A test was holding the bug in place by asserting the flag.
  • The tooltip reported the window total on every day. The arithmetic was right; the label read the wrong field.
  • Shade and position measured different quantities. Position came from cost, shade from raw request count, so a 15-skill plugin was the darkest column whatever it cost. Both now measure cost.
  • Red and green were inverted. remove was green and keep was red, so the one line a reader should act on was coloured as good news.

Testing

TDD throughout, with mutation testing on each new rule — several tests initially proved nothing and were corrected:

  • A round trip read the raw SQL column, so an Exec argument written one position early still passed. It now reads back through scanRequest, which required adding the column to the read path at all.
  • No test drove a credential through insertBatch, so removing the sanitiser survived. A canary test pins it.
  • The insRender cap counted the function name in comments, so writing a comment about avoiding it failed the test for avoiding it.

Verified end to end in headless Chrome against a running proxy, and by screenshot — which is what finally caught the chart defects that every config-level check had passed.

Known pre-existing failure, unrelated and out of scope: TestCalculatorPricesFromTheRowsModelNotABlend (float equality), confirmed failing at the merge base.

Still open

  • Only one project currently routes through the proxy on the machine this was built on, so the picker shows one project plus unattributed until others are pointed at it. No back-fill is possible for traffic the proxy never saw.
  • Verdicts are strictly per-project by choice, so a new project reads consider — not enough evidence until it clears the 5-session/7-day floor.

🤖 Generated with Claude Code

vz-ibm added 30 commits October 11, 2026 15:36
Two things the aggregate inventory cannot answer, both because a long-lived
project's history outlives its configuration.

`?sessions=N` windows a report to the N most recently ACTIVE sessions, via the
new DB.RecentSessions. A folder worked in for three months, with a plugin
installed three weeks ago, has an inventory dominated by the ninety days before
that plugin existed: the aggregate averages its skills over sessions that never
declared them and reports them as nearly free. Ordered by MAX(ts) and not
MIN(ts) — a session opened on Monday and still worked in on Friday is a current
session, and ordering by its first request ranks it behind every session opened
since, so a "last 10" window would exclude the one the user is sitting in.

`?breakdown=session` keeps the per-session cost attribution that ToolReportFor
already computed and then discarded. "This skill cost you $X" is a figure nobody
can check; "in these four sessions it cost $a, $b, $c, $d and you never called
it" is one a user can audit against their own memory. Measured on a real store,
the same unused tool cost 63,903 billed tokens in a 21-request session and
873,341 in a 287-request one — a spread the per-request weight hides entirely.

Both are opt-in. /api/tools is read by the dashboard UI, the status line and the
plugin, and an array per row on a large inventory is a materially bigger payload
for readers that do not use it.

The per-session rows are accumulated from the identical values the totals use
rather than recomputed afterwards, so a breakdown cannot disagree with the total
it belongs to; a test pins that they reconcile. An unpriced session contributes
tokens and no dollars, because a zero there would read as free.

Every assertion in the new tests was mutation-verified. One was initially
worthless: it passed against a MIN(ts) implementation because the fixture had
the straddling session starting between the other two, where both orderings
return the same pair. The fixture now makes that session oldest by start and
newest by end, which is the only shape that discriminates.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The command reported `skills_declared=44` and `suggestions=0` and named not one
item. Measured on a real machine, zero lines of its output named an individual
capability, carried a description, or named a plugin owner — so a user asking
"what am I carrying and never using" got a count and no list.

The per-item data was already fetched and then dropped. /api/tools carries name,
tokens, unused_usd, calls, sessions_used and a full removal object per row; the
floor on /api/toolfilter withheld the per-item FINDINGS, and nothing else
reported them.

THE FLOOR NOW GATES ADVICE, NOT DISCLOSURE. A removal is still withheld until an
item has been seen unused across enough sessions and days — five sessions in one
afternoon are five sessions on one task — but the inventory is listed anyway.
Refusing to recommend is honest; refusing to say what is there is unhelpful.

What each item now carries:

  * scope — item, server, plugin or none, derived from the row's own removal
    kind. Load-bearing, not cosmetic: skillOverrides has NO effect on a plugin's
    skills, so a per-skill instruction there is a setting that silently does
    nothing. 29 of 44 skills on one real machine are in that case.
  * verdict + why — remove past the floor, consider below it, keep for a
    built-in or anything invoked. The two keep cases are worded apart.
  * desc — from the item's own SKILL.md, trimmed to a scannable first sentence
    with the full text beside it. Absent, never invented: a skill built into
    Claude Code has no file to read, and desc_unknown says so.
  * per-session cost, when --sessions N is given.

Owners roll up to the thing that removes them, in separate namespaces for
plugins and servers — `mcp__giveinfo__info` and `giveinfo:blah` can share a word
with no relationship, and merging on the string would invent one. The subtotals
are a VIEW, not an addend: they re-count the same tokens one level up, so the
removable total is computed over items, each counted once, and a test pins that
the two figures are not equal by construction.

This plugin's own skills are held out of every table and reported separately. A
cost report that counts its own weight as a saving it recommends is arguing for
its own removal mid-advice; left in, the removable total read $0.0353 where only
$0.005 was really removable.

The floor is answered from /api/toolfilter rather than recomputed, because a
/api/tools row carries no dates at all — a local check could only test the
session count, which passes for five sessions in one afternoon, the exact case
the seven-day span exists to reject.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
`--report` fills a template that ships beside the script and writes it under the
state directory, then prints the path. Nothing is uploaded, nothing is fetched
when the page opens, and no browser is launched: on a headless box or over SSH
that is an error rather than a convenience, so opening it stays the user's
decision.

The page groups by owner, because the owner is the unit of removal. Each plugin
and server is one collapsed row carrying its own summary, price and command;
its children are folded away behind a details element and open on demand. With
58 items across five owners the flat list buried the decision the reader came
for, which is whether a whole plugin is worth its price.

SELECTABLE MEANS REMOVABLE, and the inverse. A tick box is a promise that the
generated command acts on that thing, so only `scope: item` rows get one. A
plugin's skill and a server's tool have no per-item switch, so those rows carry
a marker pointing at the owner's box instead — the page no longer lets a reader
express a choice it cannot honour and then argue with it in a warning.

The selection assembles into one block routed by scope, never by kind: an MCP
tool to its server, a plugin's skill to the plugin, a standalone skill to
skillOverrides, a client tool to permissions.deny. A settings fragment naming a
plugin's skill would be a line the user pastes that does nothing.

Below the inventory, not above it, a review section itemises what was picked,
totals what it cost over the measured window, and gives the steps that follow —
adapted to the selection, since a settings-only pick needs a new session rather
than /reload-plugins.

Descriptions are on the item name's title rather than in a column: measured on a
real inventory, 41 of 58 rows had none, and the column took width from the three
number columns the page exists for.

insights-report.sh is the one command to remember. The page used to print
`python3 insights.py …`, which resolves from nowhere — this script lives in a
versioned plugin-cache directory nobody cd's into — so report_script now carries
its absolute path and the wrapper finds its own directory.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
TestNavHashCompatibility failed on a green run: all ten JS subtests passed and
the Go wrapper reported failure, because it grepped for "# fail 0" and node 24
writes "ℹ fail 0". The spec reporter's prefix changed at node 23 and was never
part of node's contract.

A wrapper that fails while the thing it wraps passes is the worst shape a test
can have — the reflex is to doubt the code it covers, and this one covers the
URL contract deciding what a pasted dashboard link opens. The counts are now
read with either prefix stripped, and a summary this test cannot parse reports
the node version that produced it, so the next reporter change is one line to
diagnose instead of a day.

Verified by mutation: a deliberately broken assertion in navhash.test.mjs still
fails the wrapper, so this did not neuter it.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The first half of serving the insights view from the proxy. The inventory
answers "what is declared and what was used"; this answers "what should you do
about it", which is a different kind of claim and a far easier one to get subtly
wrong. Every rule below is pinned, because each failure mode produces a report
that still reads correctly.

  * Scope comes from the row's own removal MECHANISM, never from its kind.
    skillOverrides has no effect on a skill contributed by a plugin — verified
    on real request traffic, where the entry was still sent with the override
    set while the same override removed a standalone skill entirely. Two rows
    of the same kind therefore need different instructions, and a report that
    offers the setting anyway hands the user a line that silently does nothing.
  * A verdict never outruns its evidence. `remove` only for an item the
    server's own floor has cleared; below it the item is still LISTED and
    marked `consider`, because the floor gates advice and not disclosure. A
    built-in is never `remove` however long it has been observed, and the two
    `keep` reasons are worded apart: "you use this" and "this would break your
    agent" are different facts.
  * Owners roll up to the thing that removes them, in separate namespaces for
    plugins and servers, since two names can share a word with no relationship.
    The subtotals are a VIEW, not an addend — they re-count the same tokens one
    level up — so the removable total is computed over items, each counted once.
  * This plugin's own weight is held out of the tables and reported separately.
    A cost report counting itself as a saving it recommends is arguing for its
    own removal mid-advice.

Why in Go as well as in Python: the static report's shaping lives in the
plugin's insights.py, and a browser can neither read SKILL.md off disk nor hold
the honesty rules anywhere a test can reach them. The duplication is the known
hazard and needs an equivalence test next.

Every rule was mutation-verified, and two of the six tests initially proved
nothing. The built-in guard was unreachable because every fixture built-in also
mapped to scope "none" through the mechanism table, so deleting the guard
passed; a built-in carrying a different removal kind now covers it. And the two
`keep` reasons differed only because one interpolated a different session
count, so giving both the same wording passed; the test now asserts on the
distinguishing claim instead.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The read behind a served insights view. Honours the same ?sessions=N window and
reads the same aggregates /api/tools does, then shapes them into advice.

Two things it does that page JavaScript could not:

  * The floor comes from the server's own suggestion list rather than a
    reimplementation, because a ToolStat carries no dates — a local check could
    only test the session count, which passes for five sessions in one
    afternoon, the exact case the seven-day span exists to reject. Best-effort
    and failing closed: an unreadable floor demotes every item to `consider`,
    since the alternative is recommending a removal whose evidence nobody read.
  * Descriptions are read from each item's own SKILL.md. A browser cannot, so a
    page shaping its own data would simply lose the "what will I lose" answer a
    reader needs before changing their configuration.

skillDescriptions states its own limits rather than pretending to be complete.
Three kinds of skill reach a prompt and only two leave a file: a plugin's under
the plugin cache, a marketplace's standalone skills under the marketplace
directory, and the ones built into Claude Code nowhere at all. For the third
there is nothing to read, so the item reports its description as unknown — a
plausible sentence invented here would be indistinguishable from a real one. On
a hosted proxy the map is empty and every item says so, which is correct rather
than degraded.

The price lookup is deliberately not bound to the request. serveJSON detaches
its computation from the caller on purpose and says so in its own comment, and a
cancelled pricer does not error — it reports every model unpriced, which renders
as $0.00. Latent rather than observed, since every pricer in use ignores its
context, but a zero meaning "we could not read the rate" is indistinguishable
from one meaning "this is free".

The route is in a.routes() and not mounted beside it: that table is what the
scoping tests walk, so a route registered elsewhere is one whose scope class
nothing checks.

Four of the five assertions were mutation-verified. The fifth — that the handler
joins what it reads from disk onto the item — initially proved nothing, because
the test exercised skillDescriptions directly and replacing the handler's
argument with nil still passed; it now asserts through the endpoint.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Two surfaces answer "what should I remove": the static report, shaped in
Python, and the served view, shaped in Go. They read the same endpoints and
apply the same rules, and nothing but this test makes them agree. The failure
mode is not a crash — it is one surface saying "remove this, it cost you $3"
while the other says "keep it", and a user with no way to tell which is lying.

The duplication is not going away. The plugin ships as scripts with no Go in
it, and a browser can neither read SKILL.md off disk nor hold the honesty rules
anywhere a test can reach them. So it is accepted and pinned, the same bargain
TestPluginPresetPipelinesAgreeWithConfig strikes for the preset table.

Both sides are run over one stubbed inventory and one stubbed floor, so the only
difference between their outputs can be the rules. Asserted: the same items
exist, with the same scope, verdict, tokens and cost; the same removable total;
the same self-exclusion; the same owners with the same subtotals and commands.
NOT asserted: identical key names — Python emits a flat key space for a
line-oriented reader and Go nested JSON for a page, which are deliberate and
different presentations of one document.

The fixture qualifies exactly one item past the floor, and the test fails if any
verdict branch goes unexercised. On real data nothing qualifies, so a fixture
drawn from this machine would have left `remove` untested and the agreement
would have been vacuous.

It found a divergence immediately, and Python was right: an invoked item carries
no cost columns there, because every one of them means "spent carrying this for
nothing" — not a figure that exists for a capability that was used. Go was
reporting its declared weight, putting a real-looking price on something that
earned its place. Go now withholds those fields too, and the test compares an
invoked item on its verdict alone rather than asserting both agree about absent
fields.

Verified by mutation across four drift modes: a scope change, an ignored floor,
a dropped self-exclusion and a double-counted total. Each fails with a message
naming the exact disagreement.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The insights view inside the dashboard, reading GET /api/insights. Independent
of the static report: it shares no file with it and keeps working if that one is
deleted, which is the point — the static report can go whenever its owner
decides, and nothing here breaks.

The look is the static report's on purpose. The two are the same view of the
same question, and a user moving between them should not have to relearn it. It
is built from the dashboard's own tokens and components rather than a ported
palette — .panel, .tile with .k/.v/.s, .tblwrap + .tbl + .num, .pill, .chip,
.copyblock, .hint — so dark mode needs nothing here and the tab does not read as
a foreign page. A test pins that every token it uses exists in style.css, since
an undefined custom property resolves to nothing rather than erroring: a renamed
token would silently strip a colour and only a human looking at it would notice.

What this surface has and a file cannot: the session selector re-queries. The
static page could only print a command to regenerate itself.

The invariant carried over whole: selectable means removable, and the inverse.
Only a `scope: "item"` row gets a checkbox; a plugin's skill and a server's tool
have none, because the owner's own header line carries the single box that maps
to a real command. Verified against the live endpoint by executing the renderer
headlessly over the real document — 0 rows offered a box they could not honour,
and a mixed selection routed all four scopes into one block, with a standalone
skill in skillOverrides and a client tool in permissions.deny rather than the
two confused.

CSP-clean by construction: separate .css and .js files, no inline style or
script, no external reference, and no innerHTML — el() refuses raw HTML, so that
one is structural rather than a convention.

insights.css is registered in assetversion_test's runtimeRefs, which its own
comment asks of any view added later: a stylesheet linked from JS is versioned
by the same rewrite, and an asset outside that set is one a deploy can serve
stale against new markup.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Reported from use: every tick collapsed the open folds and scrolled the reader
down to the command block, so selecting a second item meant re-opening the fold
you were working in. Multi-select was unusable, which is most of what the view
is for.

One cause for both halves. insSetMany called insRender, which clears
#insights-body and builds it again — destroying every <details open> and the
focused checkbox. The browser then moved focus to the only focusable thing in
the new tree, the command block, and scrolled it into view.

Only two things actually depend on the selection, so only those are updated now:
the checkboxes that mirror it, through a registry each one adds itself to, and
the review and buttons, in hosts of their own. The folds, the tables, the scroll
position and the focused element are left exactly as the reader left them.

Focus is restored only when it was ALREADY inside the review. Restoring it
unconditionally would steal it from the checkbox just ticked, which is the same
bug reversed.

`Clear` went through insRender too, and had the same effect; it routes through
insSetMany like everything else, and now carries the count it will clear.

Verified headlessly over the real 68-item document: three consecutive ticks in
the panel that was reported, six folds open before and six after, with the same
details elements still in the tree. The regression test asserts on the call
graph rather than driving a DOM — the defect is structural, and routing a
selection change through the function that clears the host is the thing to
forbid. Mutation-verified by restoring the old call.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The columns were "tok / req", "billed reads" and "cost", and the tiles
"recoverable" and "built-ins (keep)". None of those says WASTE, so a reader
reasonably takes them for what each item cost in total — and sums them into a
total spend this page never computed.

It only ever measures waste: tokens sent on every request for a capability that
was never invoked in the sessions that carried it. What you did use also costs
tokens, and those were the price of using it rather than money thrown away, so
they are not reported here at all. That is the right scope for a page about
reducing waste; it just was not stated anywhere.

Now stated three times, at the three places a reader stops: a lede above the
tiles for anyone who skims them, the tile labels themselves (wasted/recoverable
and wasted/unrecoverable), and the column headers — weight/req, tokens wasted,
wasted.

An invoked item's three empty cells now carry a reason on hover rather than
reading as missing data: its tokens bought something, so there is no waste to
report. Empty by design, not by omission.

The review block said these items "cost over the measured window"; it now says
they WASTED it, and adds that nothing you actually used is in the figure.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Reported as "I don't see explanations and clear presentation", and the layout
was the problem rather than the words. The page had no title at all. It opened
with the session chips — a control, before anything had been explained — then
the one sentence defining every figure on the page, set as a muted caption, then
the tiles. The sentence that matters most was the least prominent text there.

It now opens the way every other tab does, with a titled panel, and the
definition is body text inside it: what waste means here, that what you actually
used is not counted, and how to read a row. The controls come after, because a
reader has to know what is being counted before choosing how much of it to
count.

The section headings were h3 under no h2, which is both wrong structurally and
why they read as captions; they are h2 now, each with a line saying what the
group is and why its removal works the way it does. Two sections had no
explanation at all: standalone items, which is usually where most of the waste
sits, and the built-ins, which are shown precisely because they are NOT
available — worth saying out loud next to a figure nobody can act on.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Two reports, one cause: a figure with no word next to it gets read as whatever
the reader assumes, and the assumption here is always "what this cost".

"weight / req" was a carrying metaphor the page never explains, and a reader
asked what it meant — which is the column failing, not the reader. The three
number columns are one figure at three scales, so they are named for it now:
tokens / request (the size of the declaration, sent whether you use it or not),
billed for nothing (that size times the requests that re-read it without
invoking it), wasted (those tokens priced at what each request really paid).
Each header carries the full sentence as a tooltip, because three words cannot.
The intro says the same thing in prose, so the two agree rather than the page
explaining itself in one place and naming things differently in another.

The owner header showed a bare `$0.4964`, which reads as what the plugin cost —
a different number, and one this page never computes. It now says "wasted
$0.4964", with the word set quieter than the figure, and a tooltip stating it is
the sum of that group's never-invoked children.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The question was "how many sessions back should we analyse". A user working
three months in one project — planning, then development, now research — has
planning skills used heavily in month one and dead since month two. Over all
history they look used; over the last month they are waste.

Neither candidate window answers it, and the user said so first. Measured on a
real store, session spans here are 0.0h, 1.1h and 99.1h, with one session
covering four days: N sessions is anywhere from minutes to weeks, and N days
anywhere from two sessions to sixty. Any fixed window is a judgement the report
cannot defend.

The store already answered it without one. tool_uses carries last_ts per
capability and tool_declarations carries ts per declaration, both populated and
neither read. The gap between them is how long an item has been paid for while
doing nothing — a property of the ITEM, so it holds however anybody structures
their work.

So there is a fourth verdict. `stale`: used once, not since, still carried on
every request. It was previously filed under `keep` because calls > 0, next to a
tool invoked a minute ago, so the report never mentioned it — which is exactly
the complaint. Unlike `keep` it carries a scope and its cost columns, because it
IS removable, and that is the whole difference.

Its evidence names a DATE: "last used 14 Jul 2026 — 90 days carried on every
request since, invoked in none of them". That is checkable against the reader's
own memory, which "unused in the last 10 sessions" is not.

The gap runs from last-used to last-CARRIED, never to now: a report read a week
after the traffic stopped would otherwise accuse every capability of going stale
during the gap in which nothing happened. A built-in is never stale — the
question is academic for something that cannot be removed.

Fourteen days, as one constant with its reasoning attached. Shorter catches a
fortnight's holiday; much longer and a project that changed phase a month ago
still shows its old tooling as current.

Both surfaces emit it, verified to produce identical output over the same aged
store. The main equivalence test could not have caught the divergence — its
fixture has no use timestamps, so `stale` was unreachable there and it stayed
green while the two had genuinely diverged. A new test asserts on the verdict
VOCABULARY instead, and failed on exactly that gap before insights.py gained it.

Every rule mutation-verified. One of the four mutants initially survived because
removing the date from the sentence also orphaned an import, so the build failed
rather than the test — re-run with a mutant that compiles, it is caught.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Two series per capability over one shared day axis: when it was USED, and how
many requests CARRIED it. Where the carried line runs on and the calls stop, the
gap is the waste — as a shape, with no arithmetic asked of the reader.

WHY A PICTURE RATHER THAN ANOTHER VERDICT. `stale` answers "is this abandoned"
with a fourteen-day threshold, and a threshold is a judgement this report cannot
defend: a fortnight off work looks identical to a capability finished with. A
timeline needs none. A block of invocations at the left and a flat carried line
since IS the answer, and the reader decides what counts as abandoned for their
own work — which is the right division of labour, because only they know whether
they are done with the planning phase.

It also surfaces what the table buries. Sorted by cost, the one stale item on a
real store landed 26th of 26 inside a collapsed fold, because "abandoned ninety
days ago" has weak cost and strong evidence — a combination the ranking cannot
express. That is why the feature read as "I don't see any changes".

THE RESOLUTION IS WHAT THE STORE HAS. tool_uses holds one accumulated row per
(session, name) — 961 Bash calls in a 101-hour session is a single row — so
there is no per-invocation timestamp and never was. All of a session's calls
therefore land in the bucket of its last use, and spreading them across the span
is refused rather than approximated: a shape nobody measured is worse than a bar
in the wrong place, because it looks like evidence. A test pins that refusal.

The axis includes days with no traffic at all. A compacted axis would close up
exactly the gap this view exists to show.

Buckets are LOCAL days. The reader is comparing bars against their own memory of
which days they worked, and a UTC boundary splits an evening across two bars for
anybody west of Greenwich.

Opt-in on the endpoint (`?timeline=1`), because two int arrays per item over
every day held is a bigger payload than the verdicts, and most readers want the
table. `timeline_days` narrows it; the default is unbounded, since a default
window would hide the phase before the change.

Verified on a real store: 79 items, a five-day axis, and the carriage dips land
on the two days that genuinely had no traffic. One known limit, surfaced by that
run: an invocation older than the oldest REQUEST falls outside the axis, because
the axis spans requests. Worth a label on the page rather than an empty row.

Every rule mutation-verified. One test initially failed against a correct
implementation because the fixture put every request of a four-day session at
one timestamp, a session that cannot occur. The fixture was wrong, not the code.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
One small picture per row: invocations as bars against how much the declaration
was carried, as a wash behind them. Where the bars stop and the wash runs on,
the gap is the waste — read as a shape, with no arithmetic asked of anybody.

THE FORM IS EMPHASIS, NOT TWO CATEGORIES, and that decision is what makes it
readable. The calls are the subject and the carriage is context, so the bars
take a chart hue and the wash takes the same recessive ink as the grid at 16%.
Two saturated series of equal weight would invite a comparison the data does not
support: carriage is the baseline calls are read against, not a second measure.

The colour was validated rather than chosen. --pal-blue against the chart
surface clears the lightness band, the chroma floor, CVD separation and 3:1
contrast in both light and dark. A test pins both halves, because the pull to
make the second series "a real colour too" is strong and invisible in a
screenshot of one row.

ONE SCALE ACROSS EVERY ROW. Per-row scaling would make a skill invoked twice
look as busy as Bash invoked nine hundred times, which is the most misleading
thing a column of sparklines can do.

A row the axis cannot reach says "outside the window" in words rather than
drawing an empty chart. An invocation older than the oldest request falls
outside an axis that spans requests, and a flat line there would read as "never
used" — a stronger and different claim than "before the window this can show".

Inline SVG with no library, which is this dashboard's existing convention: the
series are rectangles and the alternative is tens of kilobytes of vendored chart
code. Every sparkline carries a title and an aria-label, since the row has no
space for a table view and a chart only sighted readers can use is half a chart.

Verified by rendering against the real document: 59 sparklines, 59 washes, 59
axes, zero marks outside the viewBox, zero bars under the 2.5px floor, and an
accessible label on every one. Two of those runs reported zero charts and both
times my harness was wrong, not the code — first a shim with no createElementNS,
then counting SVG children in the wrong array.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
A heatmap of capability against time above the tables, with a volume strip and a
range control. Narrowing the range re-queries, so every table below describes
the period the reader picked rather than an average over months of mixed phases.

THE FORM WAS CHOSEN AGAINST SIMULATED DATA, because the real store holds six
days and six days cannot show a phase change. Four months were simulated from
the user's own description — planning, documentation, testing, TDD, with phases
that overlap at their boundaries — and three forms were rendered against it:

  * a total-activity area shows WHEN you worked and hides the boundary
    completely, because volume is flat across it: the same number of calls, to a
    different set of tools. This was my first instinct and it answers nothing.
  * a distinct-capabilities count shows churn but not identity, so six tools in
    June and six in October look identical though they share none.
  * the heatmap reads as the story with no explanation.

ROWS ARE ORDERED BY FIRST USE. That is the design, not a detail: it produces a
diagonal, and the diagonal IS the progression. Sorted by volume the same data
looks like noise.

Only capabilities ever INVOKED get a row — 11 of 79 on the real machine, and the
same ratio in the simulation. A never-invoked item has nothing to plot and
belongs in the table, where it already is. That answers the row-count objection
without filtering anything away.

A cell with carriage but no calls is drawn as a faint neutral tint rather than
left blank: "you were paying for this and not using it" is the finding, and an
empty cell says only "nothing happened".

THE RANGE RE-QUERIES rather than filtering rendered rows. The verdicts, the
observation floor and the owner rollups are computed server-side over the
filter's window, so filtering in JS would leave the verdicts describing all
history while the rows describe a slice. `?since=`/`?until=` already worked.

It replaces the session-count selector, which was never defensible: a session is
not a unit of time — spans on one real store were 0.0h, 1.1h and 99.1h — so "the
last N sessions" was a window nobody could reason about.

Narrowing weakens the verdicts, because the floor is computed over the range.
The scope line says so, or it reads as the recommendations breaking.

The ramp is sequential — one hue, five steps — because a cell encodes magnitude
and nothing else. Both ramps were computed from the dashboard's own --pal-blue
and checked: luminance monotonic across all five steps, end step clearing 3:1 on
its surface (4.30:1 light, 4.79:1 dark), anchor FLIPPED in dark mode because on
a dark ground darker means less ink rather than more.

The stylesheet tests were refined, not relaxed: a feature may define its own
tokens, and a line that DEFINES one may carry a literal. A line that STYLES
something still may not, and a token nobody defines anywhere is still a failure.
Both halves re-verified by mutation.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Four reports in one change: the chart was too small, the selection did not drive
the tables, the range was not visible, and the figures did not follow it.

SIZE. 640px was a constant I picked with no reason, and the cell width was
capped at 34px on top of it, so on a wide screen the chart used a third of its
panel. It now measures the host at render time with a floor, and the cap is
gone: six days should draw six wide columns and fill the panel rather than
huddle left. Measured, it goes from 640px to 1111px here, with 26px rows.

SELECTION. Drag across the chart; release re-queries and every table below
describes that period. The selection resolves to exact DAYS rather than to the
rolled bucket, and `until` is the end of the last day so the final day is
included rather than silently dropped.

RANGE. A banner between the chart and the tables, because a reader who scrolled
past the chart still has to know which period the numbers describe. A narrowed
table that LOOKS like an all-time table is the worst failure this view has: it
would have somebody remove a capability on the strength of one quiet week. The
all-history state is deliberately quiet — a loud banner on first load trains the
reader to ignore the banner, and it is the narrowed state that must shout.

FIGURES. The headline tiles relabel themselves to "wasted in range" while a
selection is active, since that is where the eye lands first.

WRITTEN BY HAND RATHER THAN VENDORED, measured rather than assumed. uPlot is the
right library — 49KB, MIT, native drag-to-select, against 200KB for Chart.js,
273KB for d3 and 1,005KB for ECharts. But it charts time series and has no
heatmap, so it could only brush a strip above this grid, and the grid is what
shows the phase progression: three forms were rendered against four months of
simulated work and the time-series form was the one that HID the phase
boundary. Pointer events on an SVG we already own cost forty lines, no vendored
bytes, and no exception to the policy in dash/ui.go. If it proves rough in use,
uPlot goes in as a second panel and the heatmap stays either way.

Pointer events rather than mouse events, so touch and pen work from the same
handlers, with setPointerCapture so the drag survives leaving the chart — which
it will, because the natural gesture overshoots the edge. Client x is mapped
through the SVG's own box ratio rather than read off offsetX, which would be
wrong at any browser zoom.

Verified by driving the real handlers over the simulated history: 1111x341
chart, 517 cells, 47 columns, 11 rows, nothing outside the viewBox, and a drag
across columns 4 to 10 resolving to 22 Jun -> 7 Jul and sending both bounds in
the query.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Reported: the chart was not beautiful enough, it was not clear how to select a
range, and a second selection grid beside it was unwanted.

THE BRUSH IS WHY d3 IS HERE. The first version was forty lines of pointer events
and the gesture worked — a drag did select, and the tables did follow. What it
had no answer for was the AFFORDANCE: nothing on screen said "you may drag
here", so the feature was invisible. Grab handles, drag-to-move, keyboard
access and a selection that survives leaving the plot are what d3.brushX has
already solved, and they are not tooltips.

That reverses a written rule in dash/ui.go, so the rule and its exception now
sit together there with the measurement: d3 7.9.0, ISC, 273 KB raw and 90 KB
gzipped against a 300 KB gzipped baseline, in a dashboard whose app.js alone is
548 KB. Every other chart in this UI is still drawn by hand.

The per-module builds were tried first and do NOT work in a browser: d3-brush's
UMD build calls require('d3-dispatch'), so the sixteen modules needed here —
119 KB, tempting — cannot chain without a bundler. Verified rather than assumed.
It is the full bundle or nothing. Vendored, not a CDN, because this page serves
script-src 'self' and must keep working air-gapped.

THE SECOND SELECTION GRID IS GONE. The preset range chips were a second control
beside the chart when the chart is the control. Clearing a selection is now the
standard gesture — one click outside it — plus the banner's button, because
getting back to all history needs a target that is not a gesture.

The brush SNAPS to whole columns on release, and that matters twice: the data is
bucketed, so a selection ending mid-bucket would describe a period the store
cannot answer for, and the band visibly settling onto column edges is also what
tells the reader the selection is discrete. It still resolves to exact days
underneath, with `until` at the end of the last day so the final day is not
silently dropped.

x is a band scale over the rolled columns rather than a time scale, for the same
reason: a continuous axis would imply a resolution that does not exist. Axis
ticks fall on month boundaries once the span is long, never on every column.

The ramp is read from CSS custom properties with getComputedStyle, so it is
defined once for both themes in the stylesheet rather than duplicated in JS —
and dark mode re-steps against its own surface instead of inverting.

Verified by running the real d3 against the simulated four-month history in a
DOM shim: a 1156x358 chart, 517 cells, 11 lanes, 47 volume bars, 5 month ticks,
and a brush group carrying handle--e, handle--w, selection and overlay. A drag
across the middle third resolves to 7 Jul -> 18 Aug.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The Cleanup view's activity chart is now a configured ECharts heatmap
rather than a hand-assembled one, and the range control is the library's
dataZoom rather than ours.

Two hand-built versions came before this and failed the same way. The
first was forty lines of pointer events; the second assembled the same
thing from d3 primitives. Both times the gesture worked and the
affordance did not -- "it is not clear how to select" -- because d3 is a
toolkit, so adopting it still left the heatmap to build out of rects and
the range control to approximate with brushX. ECharts ships both as
configuration, which is the whole point of the dependency.

Replaces d3 7.9.0 (273 KB raw / 90 KB gz, ISC), now unused.

The full ECharts build, not the smaller "common" one: common does not
register the heatmap series, and ECharts drops an unregistered series
type silently -- setOption throws nothing, getOption reports zero
series, and the chart draws its axes with no cells. The string "heatmap"
does appear in the common build, in the visualMap and dataZoom plumbing,
so grepping for it says yes while the series is absent; the only check
that means anything is reading the series count back. Measured in a
browser: 1,005 KB raw / 326 KB gz, against a 300 KB gz baseline.

Three defects found by driving the live page in headless Chrome, each
invisible to a unit test:

  - The chart consumed its own output. Narrowing re-queries, the
    response carries only the selected days, and the rebuilt chart
    spanned only the selection -- 6 columns became 2 -- so a reader
    could narrow once and never widen again. The axis now comes from the
    unwindowed load and survives every narrowing after it.
  - Widening back to the full extent left the window set, so the banner
    read SELECTED RANGE with no way back except its own escape hatch.
  - The slider snapped to full width after the re-render it caused, so
    the banner named a range the chart no longer showed.

Also removes a duplicated copy of insRangeBanner and insScopeLine, where
the second definition had been silently winning.

Verified in Chrome 155 against the running proxy: 12 capability rows,
both heatmap series carrying data, visualMap and both dataZoom controls
present, and the narrow/widen round trip driving /api/insights with and
without since/until.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Three defects in the activity chart, all reported by the reader and all
invisible to the probes that passed before them.

THE AXIS RESCALED UNDER THE RULER. A dataZoom bound to an axis zooms
that axis, so narrowing the range re-stretched the heatmap's remaining
cells to fill the plot while the slider beneath kept its full width and
merely moved its window -- "I lose correlation between ruler and graph,
because graph range shrinked while ruler stay on same size just moved".
Measured: cells went from 174px to 225px wide and eleven of them left
the plot. Two scales for one time axis, stacked, is worse than no ruler.

The heatmap now has an axis nothing zooms, and the slider drives a
hidden second x axis that carries no series. Measured against the
alternatives: bound to the shared axis, six date ticks collapsed to
three and the spacing stretched; detached with `xAxisIndex: []` the
control is not painted at all.

WHICH IS HOW THE SLIDER VANISHED -- the first attempt at the fix. The
option object still reported start and end for an unpainted control, so
a probe reading those values passed while the reader saw no slider.
Nothing was drawn in its region; now there are 22px of track and two
handles.

THE EXCLUDED RANGE IS DIMMED, NOT DELETED. Two markArea bands shade
what falls outside the selection, so every day keeps its position under
its own tick and the reader can see what they are leaving out.

Verified in Chrome against the running proxy: all six date ticks hold
identical pixel positions across a narrowing, the slider paints and
reports 40-80%, and the narrow/widen round trip drives /api/insights
with and without since/until.

Also drops the per-row "used / carried" sparklines. With a full-size
chart above the tables they duplicated it and crowded every row, and
the picture belongs in one place at a size you can read. The `stale`
verdict already carries its own evidence as a date and a day count,
which is what the sparkline was really for. Removes the column, both
functions, their constants and stylesheet block, the test that pinned
their form, and the dead rules left behind by the hand-drawn chart.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Two things the reader hit while dragging the range.

THE SHADING LOOKED BROKEN. The excluded range was drawn as a markArea
band over the heatmap, and a band runs to a category BOUNDARY: it
covered some cells and clipped others down the middle. Being attached to
one series, it also left every carried-but-unused cell unshaded -- so
"parts of chart painted gray but not all".

The selection is now encoded in the CELLS instead of drawn over them.
Four series rather than two: invocations and carriage, each split into
inside the range and outside it. Inside keeps the colour ramp, outside
goes grey, so the boundary is always a cell edge, every cell is treated
alike, and what the tables are counting is the part that still has
colour -- the reader's own suggestion, and a better one.

Outside-the-range invocations stay darker than the carried-only tint:
they are excluded from the count, not absent from the record, and the
reader is deciding whether their range is the right one, which they
cannot do if the rest of the history is blank.

THE HANDLES STOPPED BETWEEN DAYS. A column is a day and a range is a
count of columns, so 11.75 is not a selectable thing -- "rule should be
defined well 11-12-13, not allow to select 11.75". ECharts coerces the
value to a category eventually, but mid-drag the handle sits between two
days and the reader cannot tell which they are about to get.

minValueSpan keeps the span at a whole column, and a settled drag whose
window did not change now writes the rounded position back, so the
handle lands on the boundary it selected rather than where the mouse
stopped.

Verified in Chrome against the running proxy: a drag to 23.7-71.4%
settles at 20-80%, both ends exact multiples of one column step, day
index 1 to 4, banner reading 9 Oct to 12 Oct over four days; and the
split puts 7 invocations and 19 carried cells inside the range against
8 and 18 outside it.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The handle that reported "8" did not sit under the 8 on the chart, and
the error was inconsistent: measured 29px one way at one handle and 13px
the other way at the other, which is why it read as broken rather than
as merely offset.

The cause is what a category axis means. A column covers a SPAN -- the
one labelled 8 holds everything from the 8th up to the 9th -- but a
category axis centres its label under the band, so the boundary the
handle snaps to is the band's left edge while the label sits in its
middle. Two different things called "8", a half-column apart.

The axis is now the BOUNDARIES between columns, which is the model the
reader described:

    |8|--------^--------|9|--------^--------|10|

A date labels an edge, a cell fills the gap between two edges, and a
selection is a whole number of gaps. Implemented as a value axis with
interval 1, so ticks land on the integers, with n+1 edge labels for n
columns and each cell placed at i+0.5 so it straddles its gap instead of
centring on an edge.

Measured on the live page: labels at 105/199/293/386/480/573/667 with
the handles at 203 and 388 against edges at 199 and 386 -- 4px and 2px,
down from 29px and 13px -- and cells spanning 105 to 199, filling the
gap between two labelled edges with none hanging off the plot.

Also fixes insSelectedCols, which compared a column's END against the
window's start. The handler sets `since` from a column's own `from`, so
for single-day columns the end of one column and the start of the next
are the same instant and the comparison selected the column before the
intended one. Compared start-to-start now.

Verified across three selections: the last two columns keep 5
invocations coloured against 10 greyed, the first column keeps 6 against
9, and widening restores all 15 with nothing greyed.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The verdict palette was inverted and the chart did not share it.

RED AND GREEN MEANT THE BACKWARDS THING. `.ins-vd.remove` was green and
`.ins-vd.keep` was red, so the one line a reader is meant to act on --
money spent every request on a capability nothing invokes -- was
coloured as good news, while a built-in they cannot remove and need not
worry about was coloured as an alarm. Reported from use: "tools that can
be removed should be in red even if its waste money".

The pull to re-green it is real, because "removable" is good news from
the perspective of the saving. That is not the perspective the reader
has: they opened the page to find what to fix.

THE CHART AND THE TABLES DISAGREED. A built-in read red in the tables
and neutral-faint in the chart; a removable item read green in the
tables and sat on a blue magnitude ramp in the chart. Three colours for
one item, so the picture could not be checked against the table beneath
it -- "if i see build in tools in red in tables in chart it should be
same color".

Hue now carries the VERDICT in both places, read from the same
stylesheet tokens, so there is one definition and nothing to keep in
sync. Magnitude moves to opacity within the verdict hue, which frees the
channel a reader actually looks at for the fact they act on. The
visualMap ramp is gone; a legend names the states in words instead.

Owner identity deliberately does NOT get its own hue. A per-plugin
palette needs ~15 distinct colours where 8 are validated, and the rule
is never to cycle a categorical scale; row grouping gives unlimited
owners and still lets a wasteful one read as a solid block.

The legend lists only the states actually drawn. The chart draws a row
only for a capability invoked at least once, and anything invoked is
`keep` by definition -- so on a store where nothing has cleared the
evidence floor, `removable waste` is honestly empty, and advertising it
would make a correct chart look broken.

Verified in Chrome against a probe instance: the `consider` pill
computes rgb(138,88,0) and its chart series paints #8a5800; `keep`
computes rgb(86,96,114) against #566072; --ins-vd-remove resolves to
--bad; no visualMap; the legend names the two states present.

Both new tests were mutation-tested, and two mutants initially survived:
asserting `.ins-vd.keep` avoided --bad proved nothing once the rule went
through a token, and asserting the chart merely MENTIONED a token passed
when it hardcoded the hex and named the token only in its fallback.
Both assertions now check the token definition and the lookup form.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Every cell came out grey -- reported as "why all come grey" -- and the
cause was a tautology between two decisions made months apart.

insRollup drew a row only for a capability invoked at least once. Once
hue carried the verdict, that filter decided the colour: an invoked
capability is `keep` by definition, so every drawn row was grey, every
time, whatever the palette said.

The worse half is which rows it dropped. On the measured store it drew
the 15 capabilities the reader USES and hid the 43 they carry on every
request and never call -- the waste this page exists to report. A row
with carriage and no calls is not an empty row; it is the finding.

Three changes:

  - Every carried row is charted, not only the invoked ones.
  - A carried-but-never-called day takes the VERDICT colour at low
    opacity instead of the neutral wash, so the money is visible in the
    picture. Low, not full: a strong mark is reserved for a real
    invocation, and a solid block on a row that was never called would
    overstate one day's share of the cost.
  - Rows sort waste-first, heaviest carriage first; invoked rows keep
    their first-use order below, so the diagonal still reads.

That made the chart 2,174px tall -- taller than the window, so the
reader scrolled past the picture to reach the tables it explains. Capped
at 28 rows, and the cap reserves a share for in-use rows: taking a plain
head of the waste-first list produced a chart of nothing but waste, with
every in-use row below the cut, and "everything is amber" is as
uninformative as "everything is grey". The reader needs the baseline to
read the waste against. The caption says what is not charted.

Measured in Chrome against a probe instance, before and after:
amber waste cells 0 -> 46, grey in-use cells 19 -> 11, rows 15 -> 28,
height 2,174px -> 848px, and the legend names all three states.
Unused tools (CronCreate, CronDelete, CronList) sort to the top; the
ones actually used (Read, Edit, Write, Agent) sit at the bottom.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Several projects route through one proxy, so every cost the dashboard
reports is a blend across them. Nothing in a request identified the
project: the session id is a hash of the conversation head, and one
session can span directories -- measured, 8 of 48 sessions do, one of
them across 21.

Claude Code states the launch directory in the prompt it sends, as a
line reading `Primary working directory: /abs/path`. This reads it, and
returns "" for a request that does not carry one, which records as
unattributed. An invented project is worse than an absent one.

Not a one-line TrimPrefix. Two measured hazards shape it:

  - The marker frequently carries a ` (was /old/path)` suffix, written
    when the directory changes mid-session -- 57 occurrences for a
    single folder. Kept, `/a (was /b)` and `/a` would be two projects
    and one folder would fragment across the tables.
  - 47 of 589 occurrences are not paths. The prefix appears inside prose
    and inside a table of instructions, so a parser that trusts the
    marker records garbage that then appears in the project picker as a
    real folder.

Refusing anything that is not an absolute path without interior
whitespace rejected all 47 and accepted all 542 real paths. The
whitespace rule has a known cost -- a directory name containing a space
is refused and the request is unattributed -- and that is the safe
direction, since a missing project is visibly missing while a garbage
one silently pollutes every per-project figure.

Scans every message rather than indexing the one the marker occupies
today, so a reordered preamble does not silently stop the dimension
recording. First marker wins: a transcript accumulates, later turns
quote other directories, and the last match would attribute a request to
whatever folder was most recently mentioned in conversation.

Verified against every marker occurrence in the local transcripts: 54
well-formed project keys, 108 junk occurrences refused, no non-path key
produced. All five mutants killed -- the whitespace guard initially
SURVIVED, because the fixtures that exercised it did not start with a
slash while the real garbage does, so two fixtures taken verbatim from
traffic were added.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Several projects route through one proxy, so every cost the dashboard
reports is a blend across them. This adds the dimension end to end, from
the request body to a queryable column, so a later per-project view is a
query rather than a client-side guess.

PER REQUEST, NOT PER SESSION. Measured on real transcripts: 40 of 48
sessions stay in one directory but 8 move, one of them across 21.
Attributing a project to the session would label every request of those
8 with whichever folder happened to be first, charging one project for
work done in another. Pinned by a proxy test that sends two requests in
one session from two directories and asserts two projects.

OFF THE PRISTINE BODY, beside noteMeta, not from the rewritten one. A
pipeline that compacts the environment block out of the transcript it
forwards would otherwise record "unattributed" on exactly the requests
it acted on -- the ones whose cost is most worth attributing.

SANITIZED AT THE WRITE. A launch directory is a client-chosen string, so
it is attacker-influenced in the way Meta.redact() already documents: a
folder named after a token would otherwise write that token to the
requests table and the SSE feed permanently. ProjectPath composes the
same credential scan metaEnum uses with a path shape, since metaEnumShape
demands a short identifier and a path is neither.

ADDITIVE MIGRATION, NO VERSION BUMP. A bump renames the file aside and
discards every request row. Rehearsed on a copy of the live database:
2,750 rows before, 2,750 after, with the column added. No index, which
would force the bump. No back-fill -- the bodies those rows came from
are gone, so they read '' and group as unattributed.

'' means UNATTRIBUTED, not "no project", and an unset FILTER means every
project rather than only the unattributed ones. Absence is not a value;
reading it as one would hide every attributed request behind a default.

Verified by mutation testing throughout, and two tests initially proved
nothing:

  - The round trip read the raw SQL column, so an Exec argument written
    one position early still passed. It now reads back through
    Requests()/scanRequest(), which required adding the column to the
    read path -- it was write-only.
  - No test drove a credential through insertBatch, so removing the
    ProjectPath call survived. A canary test now pins it.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
/api/insights now honours ?project=, the projects can be enumerated so a
picker populates itself, and the document states which scope produced
it.

THE BASIS CHANGES WITH THE SCOPE, and that is the point rather than a
nicety. Per-project evidence is thinner than pooled evidence -- measured
on real transcripts, 1 to 5 sessions per project against a floor of 5
sessions and 7 days -- so a verdict here rests on one folder's sessions
alone. An item used daily in another project can legitimately read as
unused in this one: true, and dangerously misleading if the scope is not
stated. So a scoped document says "in this project only" and echoes the
folder it describes.

An unknown project is an empty view, never a silent widening. A widening
is the dangerous direction: it would report every project's spend under
one project's name.

Facets exclude the unattributed rows rather than listing a project named
"". Those rows are the UI's own option with its own wording, and an
empty entry in a dropdown is unselectable noise.

selfBlanked gets a `project` case, without which the project list is
scoped by the project already chosen -- so it would contain exactly one
entry, the selected one, and switching projects would be impossible.
Mutation testing found this unguarded: the facet query still worked and
the list was still non-empty, so the bug only appeared once something
was selected. Now pinned by a test that selects one project and asserts
the other is still offered.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Completes the project dimension: a picker above the chart, scoping both
the chart and every table under it.

ALL PROJECTS IS THE DEFAULT, so this takes nothing away -- the view
opens on the same pooled numbers it showed before, which also carry the
thickest evidence. One folder is the narrower truth, and both the hint
beside the picker and the document's basis say which the reader is
looking at. That matters here more than usual: per project there are one
to five sessions against a floor of five sessions and seven days, so
most verdicts in a single project will honestly read "not enough
evidence yet" rather than "remove".

A VIEW, NOT A FILTER DIMENSION. It stays out of DIMS, the shared filter
bar and the URL hash -- the same call state.dim makes for the Usage
breakdown -- because navhash.test.mjs pins that list at exactly fourteen
entries, so registering a fifteenth breaks the nav contract rather than
extending it. It re-queries rather than filtering locally, since the
verdicts, the observation floor and the owner rollups are all computed
server-side over the filter's window.

Options come from the facets endpoint, so a folder the user starts
working in appears with no UI change. Labels are the last two path
segments with the full path as the title: `/Users/v/OpenSource/
context-guru` does not fit in a select, and `notes` alone would not
distinguish `notes` from `notes/server`, which are different projects.
One project recorded is stated as prose instead -- a one-entry dropdown
is a control whose only possible outcome is no change.

Two bugs found by running it rather than by reading it:

  - `project=none` was INERT. The flag was parsed into the Filter and
    never read in where(), so the unattributed view returned the pooled
    one under a scoped label. An inert filter is the dangerous kind,
    because the page still looks narrowed. It needs its own predicate
    because the value it matches is "", which as a filter field means
    unset and therefore every project.
  - The served picker still carried a '\u0000unattributed' sentinel from
    an earlier draft. A NUL survives neither a URL nor a bookmark, so the
    server would have matched it against nothing. The Go test missed it
    by asserting on the bare string 'none', which occurs elsewhere in the
    file -- it now asserts on the option itself and rejects the sentinel.

Also rewrites the chart's hint, which still described a blue magnitude
ramp and a volume strip above the heatmap. Neither has existed since the
colour change, and a hint explaining an older version of the picture is
worse than none because the reader trusts it.

Verified in Chrome against a probe seeded with three projects: the picker
paints and is visible, lists all projects plus `all` and `unattributed`,
fires /api/insights?project=... on change, and the hint flips from
"Every project pooled, which is the strongest evidence" to "Verdicts
below use this project's sessions only". All four mutants killed,
including the inert-filter one.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The picker was invisible on the real store -- "where i see projects
picker" -- replaced by the sentence "Every request recorded so far came
from /Users/vitalyz/OpenSource/context-guru."

Both the control and the sentence were wrong.

It stood down whenever fewer than two project FOLDERS were recorded, on
the reasoning that a one-entry dropdown is a control whose only outcome
is no change. That ignored the unattributed requests, which are the
other side of the choice: the live store has one folder and 2,985 rows
written before the column existed, so there were two scopes worth
comparing and no control to compare them with.

And the sentence was false. It claimed every request came from that
folder; 2,985 of 2,987 did not.

The only honest stand-down is when nothing has been recorded at all.

The hint now names the evidence each scope rests on, which is what a
reader cannot see for themselves, and `unattributed` gets its own
wording: those requests predate the column, so they cannot be attributed
later and that option is not going to empty itself.

Verified in Chrome against the live proxy: the dropdown is painted and
visible with all projects / the folder / unattributed requests, and
choosing the folder fires /api/insights?project=... with the tables
changing. Pinned by a test asserting on the GUARD rather than the
wording, so the picker may only hide when there is genuinely nothing to
choose between.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Rows recorded before the project column existed could not be attributed
from anything the dashboard holds: the project is read from a request's
prompt, and request bodies are not stored. I told the user those 3,000
rows were unrecoverable. That was wrong.

The agent keeps a transcript of every session under ~/.claude/projects/,
each entry carrying an explicit cwd and timestamp, keyed by the same
session id the dashboard already records. The information was on disk,
just not in our database -- which makes this a join, not a guess.

A DIFFERENT SOURCE, SO A DELIBERATE ONE-OFF. Every other figure in the
dashboard comes from traffic that passed through the proxy; this reads
the user's filesystem. It is a hand-run command, never a startup
migration, and it only works where the transcripts are -- the user's own
machine, the same limit skillDescriptions documents. Dry by default.

SCRATCH DIRECTORIES ARE NOT PROJECTS. Replaying a transcript literally
was accurate and useless: a dry run over the real store put 2,503
requests in the project and 485 across THIRTEEN /private/tmp/*
directories, because an agent running a command in a temp directory
records it as the cwd. Thirteen throwaway folders would make the picker
worse than no picker, and the work belonged to the project either way.

So a request whose moment lands in scratch space is filed under the
session's home -- the non-scratch directory it spent the most entries
in. This is deliberately a DIFFERENT rule from the live request path's,
and the difference is the point: the live path sees one request and must
answer from it, while a back-fill sees the whole session and knows which
directory the work was in. A move between two real directories is still
split per request, since that is a genuine change of project rather than
a tool call wandering.

Three invariants it keeps: the live request path's own attribution is
never overwritten (guarded in the SELECT and again in the UPDATE, since
the two sources can disagree); a session with no transcript stays
unattributed and is named rather than rounded away; and a directory read
off the filesystem is sanitized exactly as one off the wire, because a
folder can be named after a token.

Applied to the real store: 3,012 rows attributed, two real projects,
zero scratch entries, row count preserved. The main project's span goes
from 14-15 October to 8-15 October.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
vz-ibm added 12 commits October 15, 2026 10:41
Reported from use: "All colors used in project should be in Legend.
Currently i have colors that not clear to me" and "grey light grey white
all not clar". Three defects, all introduced by me.

TWO GREYS A HAIR APART. `in use / built in` was --fg-muted and
`carried, never used` was --fg-faint at low opacity -- #566072 against
#626b79, indistinguishable on screen, meaning opposite things: a
capability earning its weight versus one that is pure waste.

OPACITY AS MAGNITUDE. I encoded invocation count as opacity inside the
verdict hue, so one legend entry became a dozen shades on screen
(#566072 at 0.35, 0.36, 0.39...). A legend can name a colour; it cannot
name a gradient the reader is left to infer. The count belongs in the
tooltip, where a number is readable. Carried-never-invoked cells took
the verdict colour at 30% as well, producing a second unnamed shade of
every hue.

So: in use is now --pal-green and carried-never-used is --pal-violet,
both from the validated categorical palette, both flat. Violet because
the waste must not be confused with the three verdict colours -- it is a
different KIND of statement, an observation about one day rather than a
recommendation about a capability -- and because the single most
important state on the page should not be the faintest thing on it.

THE SLIDER WAS AN UNEXPLAINED BLUE. --accent and white are control
furniture rather than data, so they cannot be legend entries, but they
were still unexplained colours on screen. The hint now names them as the
range control.

That hint has been rewritten twice: it described a blue magnitude ramp
and a volume strip after both were removed, then "grey means in use"
after the greys were replaced. A hint explaining an older version of the
picture is worse than none, because the reader trusts it.

Verified in Chrome against a probe: two data colours on screen,
#008300 (8 cells) and #4a3aa7 (160), both named in the legend, and no
unnamed data colour at all.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Two things reported from use.

THE UNATTRIBUTED OPTION IS GONE -- "failed on selection of
'anatributed requests', it shouldb be there at all just projects".

Selecting it returned HTTP 200 with zero items: the rows written before
the project column existed carry no tool inventory to shape a report
from, so the document came back empty under a scoped heading, which
reads as a broken view rather than an empty one. It was my own
back-fill's leftovers presented as if it were a place the user works.

Those rows are not lost. They are in `all projects`, which is where a
figure nobody can attribute belongs, and there is simply nothing a
per-project REPORT can say about them.

THE CARRIED COLOUR IS LIGHTER. Full-strength --pal-violet is dark in the
light theme, and this is the most common cell on the grid, so it read as
a heavy slab. Mixed 42% with the page's own background instead: same
hue, still a token, and it re-lightens against the dark surface
automatically rather than needing a second hand-picked value. The
series-level opacity goes to 1 to match, since dimming an
already-lightened colour was double-diluting it.

Verified in Chrome against a copy of the live database: the picker
offers all projects, OpenSource/context-guru and Research/notes and
nothing else, and the carried cells paint as the lightened mix.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
TWO FIXES, one reported and one delegated.

HOVERING A CELL FLASHED THE WHOLE GRID -- "when o hover mouse on light
violed cells all cell start blinking". ECharts defaults a heatmap's
emphasis to focus:'series', so pointing at any cell highlighted every
cell sharing its colour: 160 of them on the real grid, which reads as
the chart blinking rather than as a highlight. focus:'self' narrows it
to the cell under the pointer, which is the only one the tooltip is
describing anyway. Set on every series, including the two that are empty
until a range is selected -- an emphasis default that depends on a
series happening to be empty is the kind of thing that comes back.

Measured in Chrome before and after: marks changing on hover went from
the whole series to exactly 1.

THE SCHEME, now stated once on one principle: this page is about waste,
so only waste is saturated and everything that is fine recedes. That
gives colour one job -- "what is costing me nothing but money" -- and is
why `in use` is deliberately the dullest state here even though it is
the good news. On a grid where most cells are waste, a vivid healthy
state spends the eye's attention on the part that needs none.

So `in use` goes from green to slate: green competed with the waste
colours while saying "ignore me", and beside red and amber it read as a
third traffic-light state rather than as the absence of a finding.

Chart cells also get their own softened steps, derived from the pill
tokens with color-mix rather than picked separately. A table pill is
small text on a soft background and needs the full-strength hue; a chart
cell is a large flat block where that same hue is a slab. One scheme,
two densities -- and a test asserts each cell token still derives from
its pill token, since a cell colour that stops tracking its pill colour
is exactly how the two surfaces drift apart again.

The previous token comments had accreted into a changelog of three
successive colour decisions; replaced with the scheme itself.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
THE FLICKER WAS THE TOOLTIP, not the highlight. `position: 'top'` anchors
the tooltip above whichever cell is hovered, so dragging across a grid of
168 small cells makes it leap from anchor to anchor -- "horrous
flicking". focus:'self' had already stopped the whole-series highlight;
this is the other half, and the one the reader was actually seeing.

The tooltip is now pinned to the top-right of the plot with confine and
transitionDuration 0, so the pointer can cross a whole row and the only
thing that changes is the text inside it. Top-right because the rows are
labelled down the left, and a tooltip over the labels hides the name of
the thing it describes.

THE CELLS WERE TOO SMALL TO READ. 28 rows at 26px packed the capability
names until they touched, which is the difference between a chart you
scan and one you have to hover to use. 20 rows at 32px, with real gutters
for the row labels, which are the y axis and the thing a reader actually
reads.

AMBER CAME FROM THE WRONG TOKEN. --warn is a dark brown chosen for
legible small text on a pale pill; softened into a large cell it reads as
mud rather than amber ("not light amber"). The pill keeps --warn, where
it is right; the cell takes --pal-yellow, the same signal at the density
a block of colour needs. The derivation test now asserts a cell colour is
a color-mix of palette tokens rather than tracking one specific token,
since the property worth protecting is "derived and softened, never a
literal".

Also reverts a cell-label experiment of mine. Printing the invocation
count in each cell would have removed the need to hover at all, and the
identical config renders in an isolated instance but not on the live
page -- I could not find why within a reasonable number of attempts, and
it was my own addition rather than anything asked for. The count stays
in the tooltip, which now holds still.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The chart is now one horizontal bar per removable capability, longest
first, labelled with what it costs. The heatmap is gone.

WHY THE FORM WAS WRONG, not the library. A capability-by-day heatmap
answers "when did I use this", which is worth seeing once there are
months of history with phase changes in them. The store it runs against
holds eight days in which nearly every capability was carried every
single day, so it rendered as a wall of one colour -- and four rounds of
colour and geometry fixes could not change that, because the form was
answering a question the data cannot yet support.

The question the page exists to answer has a clean ranking behind it: 55
removable items, $31.70 in total, the worst single one at $7.17. A bar
per capability says that at a glance, needs no hover, and puts the thing
to act on first.

PatternFly was suggested and would not have helped: @patternfly/react-
charts requires React, ReactDOM and 19 Victory packages, and lists
`echarts ^5.6` as a peer dependency -- it is a React wrapper around
ECharts, not a replacement. This dashboard has no React, no npm and no
bundler, and serves script-src 'self'. The dependency stays; the chart
TYPE changed, which is a config change.

Horizontal because the labels are capability names, and an explicit left
gutter measured from the longest one rather than ECharts' containLabel,
which CLIPS when it runs out of room: `mcp__web-search__google_pse_
search` rendered as `ch/google_pse_search`, a label missing its own
beginning with nothing to say so. MCP names also shorten from
`mcp__server__tool` to `server/tool`, the same two facts in half the
width.

Each bar carries its verdict colour, keyed off the row's own verdict, so
red is removable in both the chart and the tables. The value is printed
at the end of the bar, so the chart does not depend on hover. Only
removable items get a bar -- a built-in at the top of a chart about what
to remove is the one misreading this view must not invite -- and a
footnote names how many are not shown.

Verified by screenshot rather than by structural assertion, which is how
the heatmap's wall of colour went unnoticed through four fixes: every
label renders in full, bars descend from $7.17 to $0.14, and red and
amber both appear.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Rebuilt to the reader's own design: capabilities along X, days stacking
upward on Y, one thin line per day, bars adjacent with no gaps. A line is
green where the capability was invoked that day and red where it was
carried and never called, so a single bar mixes both and a tool used in
some periods and not others shows that in its own surface.

This replaces a cost ranking, which answered "what is expensive" and
said nothing about when — and before that a heatmap, which carried the
same information as this but at a density nobody could read.

HOVER NO LONGER DIMS THE CHART. `focus: 'self'` was added to stop a
hover highlighting a whole series, and it caused something worse: focus
works by BLURRING everything that is not the hovered item, so pointing
anywhere — an empty cell especially — faded the entire chart to
near-invisible, and moving the pointer in and out flashed it. Reported
as "when i move mouse on empty cell all chart dissapear". Emphasis is
now disabled and blur pinned to full opacity, so hovering changes
nothing but the tooltip, which is pinned and names the date it describes.

A test had been holding that bug in place: it asserted `focus: 'self'`
on every series, written on the wrong diagnosis. It now pins the
property instead — hovering must not change anything except the thing
under the pointer — which is what the reader was asking for both times.

BUILT-INS APPEAR AS A BASELINE, separated and desaturated, because
excluding them made the chart useless on real data: zero removable
capabilities have ever been invoked here, so every bar was red and the
picture carried no pattern. The green that makes the form legible comes
entirely from tools that cannot be removed, so they are drawn as context
and no removal is offered for them.

Empty periods are dropped rather than drawn. Two idle days put a white
band straight through all 28 bars, which reads as a broken chart rather
than as time off; the axis is "periods in which you worked".

Grouped by removal unit, so a plugin is one bar and its skills are in
the tables below: `skillOverrides` has no effect on a plugin's skills,
so fifteen separate bars would offer fifteen removals that do not exist.
Measured: 55 removable items become 28 bars.

Adds a coarse window picker — weeks and months, not dates — since the
question is what pattern a capability shows over time.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The Y axis was hidden, which left the chart's whole vertical dimension
unexplained: a reader could see that a bar changed colour partway up but
not WHEN, and when is the question the form exists to answer.

Each stacked segment is exactly one unit tall, so axis value n is day n
and the formatter maps the position back to that bucket's own date.
Ticks land roughly eight to a chart rather than on every day, because at
one segment per day the labels would overlap -- the axis carries enough
scale to read a bar against, and the pinned tooltip names the exact day
under the pointer.

Verified by screenshot: 8 Oct at the bottom through 15 Oct at the top,
reading upward, which is the direction time stacks in this chart.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The reader found a real defect: "How it possible that i spent same
amount of money Wasted on same plugin each day ? if every day i doind
different amount of requests is should be different wast amount".

They were right. The per-day counts were always in the timeline and do
track request volume -- 138, 601, 358, 403, 326, 359 for one superpowers
skill against 206, 860, 562, 650, 488, 578 requests per day -- and the
chart discarded them. It collapsed each day to a boolean, `carried[ci] =
1`, so a day with 860 requests rendered identically to one with 206.

Carriage is now summed per day and the shade within the day's own colour
carries the amount, so a deep red is an expensive wasted day and a pale
red a cheap one. Hue still answers "was it used", shade answers "how
much did it cost" -- two questions, two channels.

Scaled against the busiest day on the chart rather than linearly from
zero: measured counts run 1 to 601 with a median of 358, so a linear
ramp would put nearly every day in the top two shades and show nothing.
Square-rooted, because the distribution is top-heavy and the raw ratio
bunched the mid-volume days together.

The floor went to 0.62, raised from 0.35 after looking at a screenshot:
the lower floor spread the shades well and left the whole chart washed
out, so red stopped reading as red. A narrower band costs resolution
between a quiet day and a middling one and buys back telling waste from
use at a glance. The exact figure for any day is in the tooltip, which
now names how many requests carried it and what that cost.

Also labels the baseline bars "(built in)" in the axis itself. `Skill`
is one of Claude Code's own TOOLS -- the one the agent calls to invoke a
skill -- not a skill the user installed, and it appears in the chart
while being absent from the tables because the tables list only what can
be removed. Both were right and the reader still had to ask, so the
label now carries the distinction rather than a colour the eye must
decode.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Two bugs, both found by the reader, both real.

THE TOOLTIP REPORTED THE WINDOW TOTAL ON EVERY DAY. It printed
`usd(r.usd)`, so hovering any day of ArtifactData said $7.23 when 11
October cost $2.00 and 8 October cost $0.31 -- "Event caption itself
inside Hint is same 7.23 it's definetly bug and not chart issue". It
was: the arithmetic was right and the label was reading the wrong field.

Each day is now apportioned by its share of the reads, which is exact
rather than an estimate, since cost is linear in tokens carried:
day_cost = total * day_tokens / total_tokens. Verified against the
store: ArtifactData's six days sum to $7.2035, its reported total, to
the cent. The window total is still shown, labelled as a total and
placed after the day's own figure so the two cannot be read for each
other again.

THE PER-DAY SHADE NEVER REACHED THE SCREEN. The opacity this code set
per data point was recomputed by ECharts: a 0.55-1.00 spread painted as
0.47-0.74, a near-uniform column where every day looked the same despite
costing between $0.31 and $2.00. Measured by reading fill-opacity back
out of the rendered SVG rather than trusting the option object -- which
reported exactly what had been asked for.

The shade is now mixed into the colour with color-mix, a channel the
library does not touch, and the painted values track the real per-day
costs: 69%, 59%, 71%, 64%, 74%, 47% across one column.

The scale also changed from a square-rooted ratio to a RANK over the
day-costs present. A value-proportional scale maps value to colour, and
the eye needs difference mapped to colour: measured, five of six days
fell between 326 and 403 requests, so any such scale rendered them as
one shade -- faithfully and uselessly. Ranking spreads whatever values
occur across the band, which turned the smallest gap between two days
from 0.006 into 0.11. The cost is that shade shows order rather than
magnitude, and the tooltip carries the figure for anyone asking how much
more.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
TWENTY ITEMS WERE INVISIBLE, which is the serious half of this change.
The baseline group was capped at the five most-used built-ins, on the
reasoning that they were context rather than subject -- and that
silently dropped SendMessage at $3.17, CronCreate at $2.32,
EnterWorktree at $2.23 and seventeen more. The reader found it: "Some
items are missed - SendMessage for example". A chart that omits the
second most expensive thing on the page is worse than a crowded one, and
the cap is gone.

Which made the crowding real: 53 columns across a page, with "(built
in)" repeated on half the labels to tell the two sets apart. So they are
two charts now, on two tabs -- what you installed and can switch off,
and what Claude Code ships and you cannot. Two questions, only one with
an action attached, and the actionable set no longer shares its width
with the set the reader cannot touch. The suffix goes away with the
split, which gives every column back a third of its label width.

Switching tabs swaps the chart node in place rather than calling
insRender(), which rebuilds #insights-body and collapses every open fold
-- a bug reported twice and capped by a test for exactly that reason.

Three smaller corrections, all reported:

  - Columns now ascend left to right, so the most expensive capability
    sits where the eye finishes rather than where it starts.
  - The window total left the tooltip. It is printed above its column,
    which is where a figure meant for comparison belongs, and showing it
    in both places is what let it be misread as the day's own cost.
  - A column with no waste figure says "in use" rather than nothing. An
    invoked capability carries no cost columns by design -- every figure
    here means "spent for nothing" -- but a blank where every neighbour
    has a number reads as missing data, which is how fifteen
    genuinely-used built-ins looked.

Also fixes the insRender cap test, which counted the function name in
PROSE: adding a comment explaining why something avoids insRender()
failed the test for avoiding it. It now counts calls in code, and was
mutation-checked to confirm it still catches a real extra call.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
The reader found a figure that cannot be true: "How in superpoer plugin
west per day is 3 USD and totals is less?" A day cannot cost more than
the window it sits in.

The cause was cross-multiplication. A grouped row holds every skill of a
plugin, and the tooltip derived a day's tokens as sum(carried) *
sum(tokens) -- so each of superpowers' 15 skills had its carriage
multiplied by all 15 skills' weights. Measured: 11 October reported
$5.35 against a group total of $1.31, and the six days summed to $19.61
against that same $1.31.

Each member now contributes its own carriage times its own weight, so
the days sum to the total by construction rather than by coincidence.
Verified against the store: superpowers' six days now sum to $1.3036,
exactly its reported total, where they previously summed to $19.61.

Also replaces the "in use" placeholder with the number of days a
capability was called. The reader asked for the total instead, to judge
removal themselves, and there is no total to give: the shaping layer
withholds every cost field for an invoked capability on purpose, because
each one means "spent for nothing" and a used capability earned its
weight -- so usd and tokens arrive as 0 and printing $0.00 would claim
it was free. The invocation count is available and is the figure that
actually bears on the decision for these: a built-in used on six days is
plainly earning its place, one used on one day is worth a look.

Emitting a weight for invoked items as well would mean changing the
shaping layer and the honesty rule it documents, which is a larger
decision than a chart label.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>
Shade and position measured different things, and the contradiction was
visible: "Why superpower is darker then others if it from left to right
logically it should be lighter". Position came from a column's total, so
superpowers sat mid-left at $1.29 -- while the shade came from the raw
CARRIAGE COUNT, and a 15-skill plugin sums fifteen carriages per
request, so it was the darkest column on the chart.

Worse than inconsistent, the count made every single-item column
identical: they are all carried on exactly the same requests, so all
601-per-day, so all one shade whatever they cost. The chart was spending
its strongest visual channel on how many requests a day held -- which
the day rows already show -- instead of on money.

A cell's shade is now its cost that day, ranked on the same quantity the
column header and the left-to-right order use. One quantity, three
encodings that agree: the columns darken as they climb.

`carried` still decides WHETHER a day is drawn, since presence and
magnitude are different questions.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Vitaly Zabershinsky <VITALYZ@il.ibm.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New/ToDo

Development

Successfully merging this pull request may close these issues.

2 participants