Repository navigation
Add the Work page: spend by kind of work, labeled locally with Gemma - #63
Merged
Merged
Conversation
Label sessions locally (area, work type, complexity, pushback) with Jet in the user's Ollama so the new Work page can show what spend went into: monthly allocation, workstreams, cost by work type, rework, and model right-sizing. Opt-in, paced, pausable, loopback-only; labels persist, text never does. Co-Authored-By: Claude Code <noreply@anthropic.com>
Keep subagent parents in Work, drain and attribute the backlog correctly, retry failed items, pace and gate every model request, re-check the model version, refuse cloud Ollama models, pin localhost to 127.0.0.1, key turns by position only, create nothing until enabled, verify download hashes, and describe exactly which text is read. Co-Authored-By: Claude Code <noreply@anthropic.com>
Clear the wake event after each pacing sleep so queued work cannot turn the rate-limit wait into a busy loop, keep future retry rows when a session is re-parsed, re-check the model whenever the endpoint or name changes, recreate incompatible ledgers instead of dropping tables in place, and pin the model download to a commit with every file hash-verified. Co-Authored-By: Claude Code <noreply@anthropic.com>
Treat lock, open, and I/O errors as transient storage errors instead of an incompatible ledger, record failures in memory before scheduling their retry, fence intake against a concurrent clear, count retries per item, check the local model entry before every item, and stream download hashes. Co-Authored-By: Claude Code <noreply@anthropic.com>
Read the clear generation before computing salted keys and fence again after the ledger is recreated, re-check the local model entry before every request, keep setup state while the queue is empty, schedule one retry per item, and reopen and requeue after a storage failure. Co-Authored-By: Claude Code <noreply@anthropic.com>
Show session outcomes with cost per outcome and an estimate of spend after the first pushback, pushback by turn position, model fit by work type, and evidence-gated "What stands out" cards. Report failed label deletes and retry them before reopening, throttle backlog refills, and fence backlog writes. Co-Authored-By: Claude Code <noreply@anthropic.com>
Write a content-free marker before deleting labels so a failed or interrupted delete is finished before any reopen, including after a restart or while the feature is off; always delete the files even when the ledger is closed. Classify outcomes only once the last follow-up is labeled, add an Unclear outcome, align headline denominators with their modules, mark partial months, memoize per-turn lookups, and describe the new insights in the README. Co-Authored-By: Claude Code <noreply@anthropic.com>
… queue state Record a delete request in memory before writing the marker so a read-only directory cannot bring labels back, serialize the delete retry with clear, report a successful delete whose reopen failed as a storage error, retry and show pending deletes while no service is running, classify session outcomes once no work is queued or backlogged for them, and read memoized per-turn labels from a single snapshot. Co-Authored-By: Claude Code <noreply@anthropic.com>
Open the sessions behind any Work bar, row, or cell (and via #work-sessions deep links), add reasoning-effort right-sizing, cost per resolved session, and a spend-up/value-flat warning. From the live-label audit: give Codex follow-ups the preceding assistant message, strip runtime wrappers, skip continuation and attachment-only turns, label sessions from their first substantive turn, narrow ops/debug options, lower the work-type Unclear cutoff, and version prompts so stale labels keep classifying until relabeled. Declare Work state before the initial route so cold loads work. Co-Authored-By: Claude Code <noreply@anthropic.com>
Replace the stacked horizontal allocation and workstream tables with period KPIs and a mirrored monthly column chart (spend, turns, or sessions by area above the line; outcomes below) whose segments open the matching sessions in All sessions behind a removable Work filter. Add a pushback trend with a model filter and per-session cost bars. Key labels per trace file, since one Codex session id can span several rollout files, and recreate the ledger for it. Co-Authored-By: Claude Code <noreply@anthropic.com>
Replace the estimated rework-spend KPI with spend on unresolved sessions, limit headlines to three change-or-opportunity cards, show judged outcomes only below the ledger, and re-slice the lower page into value by kind of work (cost per resolved session and resolved rate), model choices against the usual model, and a ranked right-sizing opportunities list, with the full grids behind toggles. Co-Authored-By: Claude Code <noreply@anthropic.com>
…view Skip model choices when the usual model lacks labeled evidence, label sessions from the current opener, count only classifiable turns toward outcomes, accept non-Latin requests, compare KPIs with the preceding calendar months, ignore stale Work filter responses, make drillable cells keyboard buttons, and reject repeated or impossible drill-down parameters. Co-Authored-By: Claude Code <noreply@anthropic.com>
…dows Session labels now come from the recorded opener turn (by position), so a stale fallback opener written later cannot win; queued work for turns whose role changed is dropped. The current window is the N calendar months ending this month, labeled sessions with never-labeled follow-ups are Unclear, and drillable table cells use inner buttons instead of role=button cells. Co-Authored-By: Claude Code <noreply@anthropic.com>
Codex resumes and spawned subagents share a logical session id, so the page collapsed 994 trace files into 511 rows, understating cost, and delete-by-id could trash a different file. Rows, routes, Spend links, the Work filter, and deletes now use each file's unique session key. Co-Authored-By: Claude Code <noreply@anthropic.com>
Check the read-only provider on the exact trace that would be trashed, keep the All sessions highlight when a session is opened by logical id, record openers for sessions past the backfill horizon so older labeled sessions keep their outcome, and stop counting pushback labels on turns before a moved opener. Co-Authored-By: Claude Code <noreply@anthropic.com>
A recorded opener now hides other turns' session labels only once it has its own label, so sessions past the backfill horizon keep theirs. The All sessions highlight falls back only for the pinned session, and a read-only session split across files reports read-only before ambiguous. Co-Authored-By: Claude Code <noreply@anthropic.com>
A permanently failed opener now settles its session questions, so an earlier fallback turn's labels no longer stand in for it. Also cover a split session that mixes read-only and deletable files. Co-Authored-By: Claude Code <noreply@anthropic.com>
Work History now offers 1 day, 1 week, and 1 month with daily buckets compared against the span before. Model choices becomes a scorecard plus paired usual-versus-alternative bars, right-sizing shows spend by tier and effort with mismatched segments and an estimated saving, charts use the product's validated hues, and the two-up cards align. Co-Authored-By: Claude Code <noreply@anthropic.com>
Mismatched spend now comes from unique sessions on the server, so a session flagged for both tier and effort no longer counts twice. The 1-month trend keeps all 30 days, cards state they cover sessions started in the period, and the saving note, lowest badge, legend, and trend height behave as labelled. Co-Authored-By: Claude Code <noreply@anthropic.com>
The saving note no longer reports an absence when model price tiers are unavailable, the mismatched share names its complexity-labeled base, KPI tooltips state they cover sessions started in the period, tied cheapest models all get the lowest badge, and flagged-spend tests cover high-impact and unknown-tier sessions. Co-Authored-By: Claude Code <noreply@anthropic.com>
Replace the mirrored monthly column chart with a "Where the spend went" bar list and a single "How sessions ended" bar, remove the headline cards, the paired model comparison, and the measure toggle (and their backend aggregation), shorten copy, and use the dashboard font. Co-Authored-By: Claude Code <noreply@anthropic.com>
Codex resumes and spawned subagents share a logical session id, so All sessions resolved clicks, live markers, and deletes by id against the most active file, and delete-by-id could trash a different rollout. Rows, routes, Spend links, and deletes now use each file's unique session key; an id-only delete spanning several files is refused as ambiguous, and the read-only provider check runs on the exact trace that would be trashed. Co-Authored-By: Claude Code <noreply@anthropic.com>
A 4B local model at 20 requests a minute keeps low-end laptops busy, so new installs start at 5 (now a Settings choice); saved paces are kept. Co-Authored-By: Claude Code <noreply@anthropic.com>
Resumed rollouts and subagents share a logical id, so the header could show a sibling file's title; it now uses only the row with the same trace key. Co-Authored-By: Claude Code <noreply@anthropic.com>
Work insights need a local Ollama model and are supported on macOS only: other platforms report them off, refuse to enable them, hide the Work tab and Settings card, and the setup script exits early. Work gets Option+6 and the rail renumbers top to bottom (Git 7, Learn 8, Tools 9, Settings 0). Right-sizing names each flag instead of saying "looks mismatched". Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Only the 60 most recent rows ride along with the session state, so older traces fell back to a generic title; the header also checks the All sessions inventory, still matching by trace key. Co-Authored-By: Claude Code <noreply@anthropic.com>
The header now reads the inventory, and the initial route rendered it before the declaration ran. Co-Authored-By: Claude Code <noreply@anthropic.com>
# Conflicts: # AGENTS.md # page.html # specs/AGENTS.md # tests/test_meter.py # token_meter/app.py
# Conflicts: # specs/ARCHITECTURE.md # tests/test_meter.py # token_meter/app.py
A short reply such as "go on" released the question before it too early. Requests held on purpose no longer count as left to label, a turn in retry backoff is counted once, and a busy recheck measures the whole wait. Co-Authored-By: Claude Code <noreply@anthropic.com>
A stale hold (session deleted, row dropped, or quiet time passed) could hide another session's pending work from the status. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ace its actions Save stayed live with nothing to save, and the action row had no layout rule, so mixed-height buttons ran together. Area actions, status, and Save now sit in one row, with pause and delete in a separate row below. Co-Authored-By: Claude Code <noreply@anthropic.com>
An empty or partly filled Work page read as broken. A notice under the status card now says how much is labeled and that the charts update as labeling continues, and hides once everything is labeled. Co-Authored-By: Claude Code <noreply@anthropic.com>
…otice A toggle saved at once and reset unsaved model, address, and area edits. The notice no longer reads "100% ... partial" during a relabel, leaves the request count to the status card, and is not a second live region. The status line says when changes are unsaved. Co-Authored-By: Claude Code <noreply@anthropic.com>
The "not saved yet" note now clears when edits are undone, follows the on/off message too, and Restore default areas keeps an unsaved model or address. Co-Authored-By: Claude Code <noreply@anthropic.com>
A fixed 5 a minute left a long backlog even when the Mac sat idle. Automatic labels as fast as the model answers while CPU time is quiet and slows to 5 a minute while the Mac is in use. A 5 saved under the old default becomes Automatic; other chosen paces stay as fixed caps. Co-Authored-By: Claude Code <noreply@anthropic.com>
…reen With the battery pause off, Automatic ran at full speed unplugged. The pace hint is now visible text tied to the select, and the copy says what is measured: CPU time, at most 60 a minute. Co-Authored-By: Claude Code <noreply@anthropic.com>
Main removed numbered page shortcuts, so the Work tab, rail link, and palette entry drop Option+6 and its tests follow the shortcut-free contract. Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
OpenCode's V2 store keeps the user's typed request in the session_message row's own data.text; message.data.content does not exist in that schema, so Work classified every OpenCode session as having no request text. Measured on a real database: 332 of 402 sessions in a six-month window were "No request text" before this change and 11 after, with Work requests rising from 544 to 1797. The part table is V1's -- all 143697 of its message ids resolve to V1 message rows, so joining it to V2 messages only ever worked by id collision -- and 9762 of its text parts are synthetic echoes. Reading data.text instead also keeps context the stale part rows had lost, such as the migration notice on 228 migrated prompts. A usable string content still wins, and the fallback is keyed on that shape rather than on the schema: a user message with missing, null, non-string or blank content falls back to its own text field, in either schema. On real V1 rows that fallback is inert, because their data carries neither key.
Applies the review findings on top of the V2 data.text fix. No change to what the fix resolves; these close the cleanup items around it. - Force work_insights_enabled in every test that calls summarize_legacy, so the file no longer depends on the host's settings file. - Pin the V1 behaviour change the fix also made: a user message with no usable string content now resolves through its own text. - Rename the reused `content` local to `request_text`, cap the prompt before compacting it, and correct the module docstring, the inline comment, and the architecture map so both read paths are described accurately. Tests: 1683 pass, 17 skipped.
…ee claim The map stated both OpenCode read paths stay content-free while, three lines above, naming data.text as where a user message's typed request lives. Scope the claim to what actually holds.
Pi sessions were all "No request text" because the adapter never handed
its typed requests (or tool evidence) to Work insights. Read the user's
own request text from Pi user messages, gated on work_insights_enabled,
and pass {ts, text, model} turns plus content-free tool actions through
the shared attach helper.
Request text passes Pi's existing redaction gate (_has_secret_token,
_is_host_like, _has_account_at); a request that trips any check is
dropped whole, never partially scrubbed. Text is capped at
WORK_REQUEST_TEXT_CAP before the adapter compacts it to a short preview,
and only the leading slice is read, matching the OpenCode adapter.
Actions carry only a tool kind and timestamp, because Pi's adapter keeps
no tool arguments.
OpenCode's storage migration appended a machine-generated "[Attachment unavailable after migration: ...]" notice to user message text when an attachment payload could not be carried across, and the notice is stored verbatim in the V2 message row's `text`. The Work insights classifier therefore spent part of its budget classifying OpenCode's own migration notice rather than the request. Strip the notice from the request text read for the Work insights queue, before the 90-character preview is taken. Stripping first is what makes the fix work: the preview keeps only the first 90 characters, so a leading notice would otherwise be all the classifier ever sees. A message left with nothing of the user's own emits no turn, because scaffolding alone is not a request. The pattern requires the parenthesised media type that the migration emits. That suffix is what separates a real notice from a user merely quoting the phrase, and it also lets the payload span path characters such as "[slug]" that a first-"]" bound would cut short. It is matched anywhere on the line because the migration appended it inline as often as on its own line. Only this marker is stripped, because only this marker has established provenance. Measured read-only over a real database: 396 occurrences across 229 V2 user prompts, 64 of them inside the preview the classifier sees, all of which now resolve to the user's own words. Deliberately not stripped, having checked the same corpus and the filesystem: "<system-reminder>" wrappers (zero genuine occurrences), "<path>"/"<content>" file injection (zero genuine occurrences), and the leading "<tag>" prompt templates such as "<objective>" and "<planning_context>", which are the user's own command templates and whose tag text is the request itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Work insights being off must not read prompt text. The OpenCode summary read, stripped and compacted every user request on each recompute even with the feature disabled, then discarded the result. Pi already skips this work when the flag is off, so the two adapters disagreed about what "off" means and OpenCode did regex and compaction work for a consumer that was switched off. Wrap the read in work_on so the adapter honours the documented contract that adapters skip Work-only extraction unless Work insights are on, and no request text is touched on the off path. last_user_ms still updates because it is timing metadata rather than text. Behaviour with the feature on is unchanged.
…uest The gate test mocked _strip_harness_text, which only proves the strip step was skipped. The request is read out of the message row before that, so a build that read `content` or `text` and then discarded the result would still have passed. Wrap the decoded row instead, so the first request-field access fails, and assert the summary still completes with its normal turn count so the gate is shown to cost no other evidence. Verified red against a partial gate that reads both fields but keeps strip and append behind the flag, and again against a fully ungated block.
The attachment-migration notice pattern used a greedy `[^\n]*` payload run, so when two notices landed on the same line a single match spanned from the first notice's "[" all the way to the last media-type suffix, silently deleting the user's own words between them. Real read-only measurement over the local OpenCode database found one prompt where this removed 14 extra user characters. Over-stripping is data loss, worse than the injection being removed. Make the payload run non-greedy so each match ends at the first media-type suffix on its line and covers exactly one notice. The `[^\n]` class keeps matches on a single line, and the run still reaches the media type rather than the first "]" so paths containing "[" stay handled. Add regression tests for user text between same-line notices, notices on separate lines, a payload path containing "[", same-line scaffolding-only text, and a user quoting the phrase without a media-type suffix.
The OpenCode adapter queued a user's typed request for Work insights without a redaction gate, so a credential-shaped token, a host, or an account address in an OpenCode prompt reached the classifier queue. The Pi adapter already drops such a request whole. Move the credential and host predicates into domain/redaction.py, shared by both adapters, and add a token-bounded account/host matcher. OpenCode now drops a whole request, before the 8 KiB cap and the compaction, when it trips any check. The account matcher is bounded to the address token after each "@" rather than Pi's whole-string remainder, so ordinary prose such as "@scope/pkg@latest" is kept; Pi's matcher and observable behaviour are unchanged.
Three comments claimed OpenCode "passes the same redaction gate Pi applies". It does not, and the difference is the point a reader needs. The credential and host predicates are the shared ones Pi runs; the account check is the shared token-bounded matcher, which keeps @scope/ ids and version tags, while Pi keeps its broader matcher that also fires on any "@" followed by later prose. The two adapters therefore drop different requests, and saying otherwise hides the asymmetry in a privacy boundary. Also drops a stale gate comment that sat inside the branch it described, and names the OpenCode adapter in the Pi note instead of "newer adapters". No behaviour change.
Pi kept its own account matcher, which matched a version or host pattern against the whole remainder of the string after every "@". Any request whose tail happened to read like a version was dropped whole, so the two adapters did not agree on what to keep: measured on the local Pi store it dropped 14 of 60 prompts, 9 of them benign ones such as a scoped package install, a mid-sentence model namespace, or a package version. Use the shared token-bounded matcher at both Pi call sites, the request-text gate and the subagent model-id path, so OpenCode and Pi drop the same requests. The credential and host predicates are untouched: they stay the same shared functions, called at the same places in the same order, and the gate still drops whole before the cap and the compaction. Sharing the matcher loosens the model-id path for a bare-word "@" suffix, which is indistinguishable from a model tag. See the report: one shipped sanitizer-table case, user@host, now follows that tag shape.
0c60fc8 pointed the subagent model-id sanitizer at the shared token-bounded matcher, which is right for request text but wrong here: a model id's "@" introduces a version, a tag, or a leading namespace, never an account, and the shared matcher cannot tell a bare-word tag from a bare-word account. It accepted "user@host", which the shipped sanitizer-table privacy invariant rejects, so the suite was red. Restore an explicit, local allowlist scoped to the model-id path only: after a non-leading "@" only an 8-digit date, a version with at most three numeric components, or the tags latest/default may follow, and a leading namespace such as @cf/meta/... stays valid. The request-text gate keeps the shared has_account_at, so the benign prompts 0c60fc8 recovered stay kept. Credential and host predicates are untouched: same call sites, same order.
Keep both platform power probes: the PR's power_source for Work insights and main's power_constrained/CPU/memory probes for cold indexing. Cold-index workers now hand captured work turns back on the row so parallel indexing still feeds Work insights; the parent pops them before caching. Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
Brings in the Splunk by Cisco branding (#93), the trace-path privacy fix (#89), and the cross-session perf work (#88, #90). - All Sessions rows now key on the server-sent trace key, since #89 removed the path the branch used to hash. - Regenerate the agent_usage parity digests for the new model_runtimes and model_days fields; every pre-existing field still matches main's golden on Python 3.9 and 3.14. Co-Authored-By: Claude Code <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
A new Work page (⌥6, macOS only) shows what your coding-agent spend went into, whether it worked, and where a cheaper model or lower reasoning effort would likely have done. Google's Gemma 4 E4B, running in Ollama on your Mac, labels each request with its kind of work, area, and complexity, and flags follow-ups where you pushed back. Each request carries its share of the session's cost. Prompts never leave the machine; only salted keys, labels, and counts are stored.
How it works, rule by rule:
specs/WORK_INSIGHTS.md. Evaluation history:specs/2026-09-30-work-insights-design.md(iteration 13 covers Gemma).How to use it
./scripts/install.gemma-4-E4B_q4_0-it.gguf(Apache-2.0, about 5 GB, pinned by commit and SHA-256), adds it to Ollama astoken-meter-gemma, and deletes the download.What the page shows
Every bar, tag, and suggestion opens the sessions behind it. The page uses the shared design system from
main: tokens, type, weight, radius, and spacing scales.Memory safety
Labeling never pushes a Mac into swapping on its own account:
Also in this branch
token-meter-gemma. The Work page asks before the new download, and Token Meter's own Ollama removes the retired models.Label accuracy
Real local requests from three session-disjoint sets. Gold labels were set blind, and the data stays private on the developer's machine. Every setting was fitted on dev only.
Pushback on the held-out set is the remaining gap. The pushback decision point keeps reported rates close to the truth: 22% flagged against 18.5% real on test.
Testing
py_compileandgit diff --checkare clean.20f7758: an independent tester and two reviewers (correctness, and resource safety/privacy) pass. The reviews caught and closed a reload loop in the memory guard.20f7758:/healthand/menubarOK, and the runtime matches the source.1bb98e8after it only removes a stale diagram from the spec.Not verified
🤖 Generated with Claude Code