diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index da28dcdd..6808f9d0 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -134,7 +134,8 @@ Overview keeps status and routing in one health-first area: Direct Ruflo agents must explicitly select OpenRouter or Ollama together with a provider-native model, and the Ruflo/MCP process must inherit the required credential environment. Served-provider and served-model claims come from **Usage → Scorecard** evidence instead. -- **Runtime** presents operational services, processes, and MCP readiness. +- **Runtime** presents operational services, processes, MCP readiness, and cached + context configuration with host-specific native controls. - **Intelligence** presents memory, learning, and quality-improvement signals machine-wide: an always-visible rollup folded across every project on this machine where memory or intelligence has been activated — a `.claude-flow`, `.agentic-qe` or `.swarm` directory, whichever host created it @@ -149,13 +150,15 @@ Overview keeps status and routing in one health-first area: [Project intelligence](ddd/project-intelligence.md) and [ADR-0024](adr/0024-project-intelligence-telemetry.md) for the full model and the two learning metrics' load-bearing distinction, and [ADR-0027](adr/0027-shared-project-census.md) for project - discovery. + discovery. The machine-wide table and picker share alphabetized Git repository, + worktree, user-level, and other/unclassified subgroups. Each table subgroup shows + five rows before scrolling; all rows remain available inside the bounded panel. ### Why project counts differ between tabs -Every area derives its project list from one census, so a project means the same thing everywhere — -a session run in `myrepo/backend` belongs to `myrepo`, not to a project called `backend`, and an -agent worktree is not a peer of the repository it was cut from. +Project counts reflect different populations. Intelligence uses the learning census, while Usage +Score ranks verified Git identities from the selected session window. Evidence-backed worktree +associations keep a worktree with its repository; display names alone do not establish identity. The totals still differ, because the tabs ask different questions: @@ -204,6 +207,12 @@ evidence at all is structurally zero rather than cheap, and folding it in would toward zero for a reason that is not about spend. A real figure under a cent renders `<$0.01`, never `$0.00`. +**Projects** ranks the top 10 discovered Git projects by spend in the selected +timeframe. Verified worktree usage rolls into its parent project; standalone +worktrees, user-level locations, and unclassified directories are excluded from +this panel. Overall Usage totals retain all activity, including usage outside +these ten rows. + **Rhythm & responsiveness** puts two histograms side by side, session length and response latency, each with its percentile markers laid over the bars. A percentile that lands in the open-ended top bucket renders with a `≥` prefix — the bucket has no upper edge, so the honest claim is a floor @@ -276,13 +285,15 @@ sources: [Usage scorecard metrics](USAGE-SCORECARD-METRICS.md) §2a, §2b, §20 Context answers how much of a runtime-observed window the retained sessions used. The policy strip shows the canonical startup, dynamic and reserve bands. The summary reports exactly how many sessions have a paired input/window pressure observation and how many lack a denominator. Claude, -Codex and OpenCode each keep their own card with evidence state, p90 peak pressure, paired-sample -count, p90 peak input and median observed window. +Codex and OpenCode each keep their own card with coverage state, p90 peak pressure, number of +sessions with pressure measurements, p90 peak input and median observed window. A percentage is rendered only when input and window were observed together for that session. -Claude and OpenCode commonly carry input-only evidence, so they may read **Partial evidence** while -Codex is observed. Missing evidence renders `unknown`; an unknown ARIA meter omits `aria-valuenow` -rather than announcing zero. The attention projection is capped to the top 20 sessions before +The cards distinguish **Input only**, **Partial coverage**, **Not recorded**, and **No sessions**. +Missing token/window values render as an em dash. A pressure meter appears only for a measured +value, and each card explains its coverage gap. Claude transcript input records do not include a +paired window; older Codex records can contain only cumulative totals. OpenCode may have no +sessions in the chosen window even when its installation and model catalog are available. The attention projection is capped to the top 20 sessions before presentation. The browser renders one disclosure row per bounded project and keeps each sanitized conversation label inside the expanded session table, then exposes explicit column headers, an opaque session reference, host, policy-derived recommendation, pressure/input/window, and start date. The session reference links to that retained diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md index 079dab6b..30fbf1d1 100644 --- a/docs/TRANSCRIPTS.md +++ b/docs/TRANSCRIPTS.md @@ -179,8 +179,8 @@ The same parsers serve two very different callers, switched by `withTurns`: | Path | Entry point | `withTurns` | Message bodies | Cached? | |---|---|---|---|---| -| **Scan** — the aggregate index behind the Scorecard/Findings/Sessions views | `buildIndex` (`usage-index.mjs:566`) → `parseFile` (`usage-index.mjs:389`) | `false` | no turn list is built, and no body is retained — holding them would balloon memory across 3,000+ files (`parseClaude`'s own doc comment, `usage-parsers.mjs:679-684`). Since v14 the scan path does *read* one narrow slice: opencode's USER text parts, so a prompt can be fingerprinted (`loadTextParts`, `usage-opencode.mjs:276`). Only the fingerprint is kept; the text is discarded with the row. Measured at 45 µs/session materializing 0.6 MB on a 300-session store, against 125 µs and 61 MB for the reader path's unfiltered join | yes: per-file derived records in `~/.config/agentic-kit/usage-index.json`, keyed `(path, mtime, size)`, invalidated wholesale by `SCHEMA_VERSION` (`usage-index.mjs:142`) | -| **Reader** — one transcript for the Transcript view | `readSession` (`usage-index.mjs:960`) | `true` | full turn list built | **never** — every call re-reads and re-parses the one file | +| **Scan** — the aggregate index behind the Scorecard/Findings/Sessions views | `buildIndex` (`usage-index.mjs:578`) → `parseFile` (`usage-index.mjs:401`) | `false` | no turn list is built, and no body is retained — holding them would balloon memory across 3,000+ files (`parseClaude`'s own doc comment, `usage-parsers.mjs:679-684`). Since v14 the scan path does *read* one narrow slice: opencode's USER text parts, so a prompt can be fingerprinted (`loadTextParts`, `usage-opencode.mjs:276`). Only the fingerprint is kept; the text is discarded with the row. Measured at 45 µs/session materializing 0.6 MB on a 300-session store, against 125 µs and 61 MB for the reader path's unfiltered join | yes: per-file derived records in `~/.config/agentic-kit/usage-index.json`, keyed `(path, mtime, size)`, invalidated wholesale by `SCHEMA_VERSION` (`usage-index.mjs:154`) | +| **Reader** — one transcript for the Transcript view | `readSession` (`usage-index.mjs:972`) | `true` | full turn list built | **never** — every call re-reads and re-parses the one file | ![Figure: one parser, two read paths — the scan path (withTurns false) caches per-file records keyed by path, mtime and size; the reader path (withTurns true) builds full turns and is never cached](assets/transcript-read-paths.svg) @@ -317,7 +317,7 @@ string. ## 4. The `readSession` pipeline — how one session becomes a payload -`readSession(id, opts)` (`usage-index.mjs:960-987`) is the only way +`readSession(id, opts)` (`usage-index.mjs:972-987`) is the only way transcript content leaves the module, and every step is a gate: ### 4.1 Locate, contain, bound @@ -325,7 +325,7 @@ transcript content leaves the module, and every step is a gate: 1. **Id grammar before any filesystem access** — an id must match one of exactly two shapes, or it is rejected with `ERR_INVALID_SESSION_ID` at this call: `invalidId(id)` (`usage-index.mjs:961`) before any read happens: - * `VALID_ID` (`/^[A-Za-z0-9._-]{1,128}$/`, `usage-index.mjs:153`) — a plain + * `VALID_ID` (`/^[A-Za-z0-9._-]{1,128}$/`, `usage-index.mjs:165`) — a plain session id; * `VALID_SUBAGENT_ID` (`usage-index.mjs:169`) — a namespaced nested subagent id, EXACTLY `/` with one slash, where the parent @@ -345,7 +345,7 @@ transcript content leaves the module, and every step is a gate: symlinks; a symlink planted inside a root pointing at `/etc/anything` passes a lexical `startsWith` but fails this. Roots are realpath'd too so a symlinked dotfiles setup still works. -4. **Size cap** — `MAX_SESSION_BYTES` (64 MB, `usage-index.mjs:152`): a +4. **Size cap** — `MAX_SESSION_BYTES` (64 MB, `usage-index.mjs:164`): a transcript is read whole and JSON-expands ~5×, so an unbounded read is a memory-amplification primitive. Oversized reads as unavailable, not risky. diff --git a/docs/USAGE-SCORECARD-METRICS.md b/docs/USAGE-SCORECARD-METRICS.md index 72d65585..a6ac9d77 100644 --- a/docs/USAGE-SCORECARD-METRICS.md +++ b/docs/USAGE-SCORECARD-METRICS.md @@ -73,7 +73,7 @@ Every metric section below follows the same shape: Two transcript stores, read-only, parsed at most once per file — the derived record is cached "keyed by (path, mtime, size)" (`src/lib/usage-index.mjs:10`), and the whole cache is invalidated on a `SCHEMA_VERSION` change -(`usage-index.mjs:142`): +(`usage-index.mjs:154`): | Transcript host | Store | Format | |---|---|---| @@ -784,7 +784,7 @@ session that runs from 23:58 local to 00:05 local is billed to the day its *first* row landed on (test: `tests/kit/usage-index.test.mjs:738`, "a session that opens before midnight is counted on its first billed day"). Accumulation, at this call: `dayBucket(byDay, -row.day)` then `d.cost = round(d.cost + rowCost)` (`usage-aggregate.mjs:747-752`). Bar height: +row.day)` then `d.cost = round(d.cost + rowCost)` (`usage-aggregate.mjs:760-767`). Bar height: `h = maxDay ? max(2, cost/maxDay*100) : 2` (`dashboard/client.mjs`) — every non-empty day gets a visually nonzero bar (floor of 2%), so a very cheap day is never rendered as invisible. @@ -976,25 +976,25 @@ ranking entirely rather than merely re-labelled in place. ## 11. Projects -**Displayed as:** a ranked bar list, top 8 shown, note reading `"top 8 of -N"` when more exist; each row shows `cost`, `N sess · minutes`. +**Displayed as:** a simple ranked bar list of the top 10 discovered Git projects +in the selected timeframe. Each row shows cost, session count, and minutes. +No standalone worktree, user-level, or unclassified directory appears here. -**Formula:** identical shape to §10 (`byProject[project]`), plus a `project -= 'unknown'` fallback and a repo/worktree collapsing rule: -`projectLabel(cwd)` collapses `//worktrees/` (marker ∈ -`.autopilot`, `.claude`, `.git`) to ``, keeping `rest` as a separate -`worktree` field on the session rather than either discarding it or letting -it masquerade as a sibling project. (The mislabelling this rule corrected is -recorded in [Appendix A](#appendix-a--fix-history).) +**Formula:** the additive `gitProjects` projection uses the same filtered session +population as the rest of Usage. It sums session cost, count, tokens, and minutes +by evidenced repository identity. A worktree contributes to its parent only when +Git common-directory/backlink evidence establishes an existing project root. +Exact user/host-state roots and unverifiable repository associations are excluded +from this ranking. Names and remotes alone do not establish eligibility. -**Source:** ranking and truncation, `dashboard/client.mjs` -(`shown = projects.slice(0,8)`); accumulation via the same `addTo()`/ -`entries()` machinery as §10, keyed by `s.project` instead of `s.models`. +**Source:** `usage-project-evidence.mjs`, `usage-project-groups.mjs`, and +`dashboard/client/usage.mjs` (`renderScoreProjects`). The existing `byProject` +aggregate remains available to other consumers, but does not establish Git identity. -**What this does not model:** a session whose working directory could not be -determined (e.g. missing `cwd` in the transcript) lands in a literal -`"unknown"` bucket rather than being dropped — visible in the project list -rather than silently absent from the total. +**Population:** the overall Usage totals still include all recorded usage. They +can exceed the sum of these ten Git-project rows. Missing identity remains in +those totals; it is not guessed into this ranking. Older payloads without Git +identity request a usage refresh instead of displaying arbitrary directories. --- @@ -1753,7 +1753,7 @@ cacheSavedUsd = Σ rows (costOf(1M as input) - costOf(1M as cacheRead)) **Source:** the derived block is `finishTotals` (`usage-aggregate.mjs:1033-1073`), which the previous-window projection calls too so a baseline is never derived a second, drifting way. `median` and `percentile` are exact over the values -(`usage-aggregate.mjs:998-1009`), unlike §15's bucketed percentiles. +(`usage-aggregate.mjs:1020-1032`), unlike §15's bucketed percentiles. Active days come from `byDay`'s key count and the streak from `activeStreak` in `src/lib/dashboard/client/usage.mjs`; the tiles are `cadenceCells` there, and `printScoreCadence` (`src/commands/usage.mjs:219-242`) in the CLI. @@ -1802,9 +1802,9 @@ positive figure that rounds away at two decimals prints `<$0.01`, never "nothing" are different claims. **What the cache saved, asked as a difference.** `cacheSavingPerMillion` -(`usage-aggregate.mjs:718-727`) prices one million tokens twice through the +(`usage-aggregate.mjs:731-742`) prices one million tokens twice through the *injected* pricer — once as fresh input, once as cache reads — and takes the -gap; `cacheSavedFor` (`usage-aggregate.mjs:717-720`) scales that to the tokens +gap; `cacheSavedFor` (`usage-aggregate.mjs:731-749`) scales that to the tokens a row actually read from cache. Nothing in that path knows what the cache multiplier is, so the saving cannot drift out of step with §3's table the way a hard-coded "0.9 × input" would the day the multiplier changed. Both probes @@ -2155,13 +2155,13 @@ unbounded observation list. Codex reads the gross `last_token_usage.input_tokens not add its cached-input subset again. Claude and OpenCode sum their split fresh/cache fields but usually have no runtime window, so their coverage is commonly partial. -The current cache schema is v18 because controlled prompt intent/topic facets also require parser -output. A v17 cache is reparsed; the context evidence contract itself is unchanged. +Cache schema v20 also retains Git-project eligibility evidence. Older cache records are reparsed; +the context evidence contract itself is unchanged. The Context view contains: - the canonical startup/dynamic/reserve policy bands; -- counted coverage, including paired samples and sessions missing a window; +- counted coverage, including sessions with paired measurements and sessions missing a window; - one host card each for Claude, Codex and OpenCode; - p90 peak pressure/input and median observed window where supported; and - at most 20 attention rows carrying a deterministic opaque session reference plus bounded project @@ -2334,7 +2334,7 @@ promise. `byModel` on the first run after the change, purely because the cache predated it; every unit test still passed, since tests only exercise a fresh parse. `SCHEMA_VERSION` went to `4` specifically to force the one-time - re-parse; the constant now reads `17` (`usage-index.mjs:142`), each bump since + re-parse; the constant now reads `17` (`usage-index.mjs:154`), each bump since having forced its own re-parse the same way. Re-querying the same live server after the bump returned `totals.exceptions: 20` with `` absent from `byModel` — diff --git a/docs/adr/0050-dashboard-project-identity-and-context-reporting.md b/docs/adr/0050-dashboard-project-identity-and-context-reporting.md new file mode 100644 index 00000000..1cef9b72 --- /dev/null +++ b/docs/adr/0050-dashboard-project-identity-and-context-reporting.md @@ -0,0 +1,206 @@ +# ADR-0050 — Dashboard project identity and context reporting + +- **Status:** Implemented +- **Date:** 2026-09-09 +- **Related:** [ADR-0036](0036-dashboard-client-modularization-and-shared-loopback-server.md), + [ADR-0048](0048-inventory-led-maintenance-resource-management.md) + +## Problem and boundaries + +Projects can be Git checkouts, linked worktrees, other folders, or unavailable +working directories. Session launch origin is independent: Desktop sessions can +use any of these locations. Treating Desktop and Git as competing project types +loses information. A remote URL alone cannot prove that two checkouts share Git +administration or that a session came from Desktop. + +Overview also rendered Codex configuration and per-model limits as separate +cards. That repeated labels while making cached capacities look comparable to +session usage. Claude, Codex, and OpenCode expose different evidence and controls. + +## Project decision + +Canonical working paths retain their existing identities. Verified Git common +directories establish repository groups; linked worktrees require a readable +Git pointer, common directory, and matching reverse pointer. Missing or malformed +metadata remains unknown. Bare repositories need no invented main checkout. +Independent clones sharing a remote do not become a worktree group. + +Session origin comes from bounded transcript metadata, separate from the existing +`origins` discovery-method field. Exact recognized declarations are: + +- Claude `entrypoint`: `claude-desktop`, `claude-desktop-3p`, `remote_desktop`. +- Codex `session_meta.originator`: `Codex Desktop`, `codex_work_desktop`. +- Everything else, including ambiguous SDK and VS Code markers: unknown. + +These are source declarations, not attestation of the initiating application. +Claude's installed 2.1.266 runtime maps those entrypoints to Claude Desktop; +Codex values were observed in local bounded session metadata. No prompt content, +user project names, or private transcript examples are included in this document. +Desktop history outside the configured discovery roots remains outside coverage. +Origin count basis distinguishes transcript files, database sessions, recovered +project sightings, and mixed observations; UI badges show membership only. + +The System Projects default keeps its existing measured population. An additive +lightweight discovery catalog exposes excluded and missing paths without adding +expensive disk walks. The all-discovered view joins measurements by path, so each +path appears once. Unknown measurements are not zero. Existing ever-seen/on-disk +counts remain unchanged. Rows retain their details without repeated directory-path +headings. Disk and LOC are not +summed across overlapping parent/child paths. + +Maintenance project cards preserve their installation counts and navigation. +Repository groups and Desktop-origin filters use separate dimensions. Only known +Desktop qualifiers appear on cards; unclassified entries remain in the filter. +The fallback heading is Other projects, without repeated uncertainty or technical +explanations on each card. Cards show +folder icon with name, the sidebar's same kind icon/label, then all detected +language icons in a wrapping row. Language icons distinguish source-line evidence +from artifact detection in their accessible labels. Installation count and +navigation arrow remain on the right. + +## Context research and decision + +| Concept | Claude | Codex | OpenCode | +| --- | --- | --- | --- | +| Model capacity | Evidenced cached Anthropic catalog limits | Native default and maximum in cache | Evidenced provider catalog limits | +| Kit-owned context control | None | Opt-in `model_context_window` request | None | +| Configured session request | Not inspected | Read from native configuration | Not inspected | +| Calculated usable window | Unknown here | Allocation × effective percentage, verified profile only | Unknown here | +| Usage evidence elsewhere | Historical input; optional statusline sample | Paired token-count input/window | Historical message input/cache components | +| Actual compaction threshold | Unknown here | User scalar retained; runtime/scope unverified | Unknown here; version-dependent controls | + +Codex inspection remains limited to the verified 0.153.4 profile, fresh dated +cache (seven-day maximum), native OpenAI provider and supported catalog. A request +clamps to each model's maximum before the effective percentage is applied. +An advertised API maximum is not a session allocation. Inspection time and cache +capture time are separate fields. Unknown values remain null. + +Claude's statusline can expose session window, input/cache components, and +percentages. The existing kit tee is conditional on rate-limit data, throttled +to one write/minute, and retains only the most recent writer. This change does +not promote that sample into fleet-wide live context. Claude's compaction-window +override can differ from its statusline denominator. + +OpenCode V1 documents `auto`, `prune`, and `reserved`; V2 documents `auto`, +`keep.tokens`, and `buffer`. Kit does not claim to inspect or manage either set. +Codex additionally documents compaction counting scope; kit's retained scalar +alone does not establish the active scope or runtime trigger. + +Use **one compact Context configuration card with host-specific rows**, plus +expandable bounded model tables where cached capacity evidence exists. Native +control documentation is linked per host; only Codex currently has kit-managed +context ownership. This avoids repeated usage placeholders and long explanations. A shared nullable report names host, management state, source, +inspection/capture times, model values, usage, compaction, and limitations. +The existing model-inventory snapshot supplies Claude/OpenCode capacity and output +limits (and Codex fallback catalog limits). Each numeric field requires matching +source and scope evidence. Stale observations stay marked stale; catalog capacity +never becomes an effective session window. At most 100 valid models per host +render, with omitted count and navigation to the complete inventory. No refresh +commands or network API calls run during dashboard polling. No capacities are summed. The original CLI rows and repair instructions remain; +the dashboard groups them for display and preserves warnings. + +Usage → Context remains the historical pressure view. Only paired input/window +observations establish pressure. Cached Codex input is a subset, not an extra +quantity to add. Kit's 60/70/75% recommendations are policy thresholds, not native +host compaction controls. This change does not reconfigure any host. + +## Authoritative references checked + +- [Claude statusline](https://code.claude.com/docs/en/statusline) +- [Claude environment variables](https://code.claude.com/docs/en/env-vars) +- [Codex configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference) +- [OpenCode configuration](https://opencode.ai/docs/config) +- [OpenCode V2 compaction](https://opencode.ai/v2/docs/compaction) + +Research performed 2026-09-09 against documentation and existing integrations. +Future host versions require renewed evidence rather than inferred parity. + +Claude is not inherently missing context controls: `/model`, `[1m]` selection, +`/autocompact`, `--autocompact`, and persisted `autoCompactWindow` are native +surfaces. The compaction window is bounded by the model window. Its Models API +also exposes nullable capacity/output metadata; API entitlement does not prove +Code session entitlement. `CLAUDE_CODE_MAX_CONTEXT_TOKENS` corrects assumed +windows for gateway/custom model IDs, rather than providing a universal maximum. +OpenCode exposes `/provider`, `/config/providers`, and `/config` HTTP reads, +`models --verbose --pure` catalog reads, and per-model `limit` configuration. +These controls are not interchangeable with Codex's verified per-model clamp. + +- [Anthropic Models API](https://platform.claude.com/docs/en/api/models/list) +- [OpenCode server API](https://opencode.ai/docs/server) +- [OpenCode provider limits](https://opencode.ai/docs/providers) + +Extending kit-owned writes to another host would require a separate native +configuration ownership/restoration contract. This reporting change performs no +host configuration writes. + +## Intelligence picker + +The native select uses alphabetized optgroups for Git repositories, Git worktrees, +User-level learning, and Other/unclassified locations. Option labels contain only +learning-location names; origin metadata is retained without tying the learning +store to the host that initiated a session. +User-level classification requires exact home or configured host-state roots. +The existing cached census supplies metadata; no additional transcript scan is +performed for the picker. Values, selected key, initial recency choice, learning +paths, and history remain unchanged. Names and UUID-like labels do not establish +origin. Native select keyboard behavior and optgroup semantics are retained. + +## Machine-wide Intelligence table + +The table uses the picker's same four learning-scope categories, alphabetized +within each. Metadata comes from the existing project entries, not a label join. +Each subgroup retains all rows in a keyboard-scrollable region with five visible +rows; the surrounding table area is bounded. The layout stacks on narrow screens. +The hero totals and underlying learning population remain unchanged. + +## Historical context coverage + +Usage Context reports recorded session observations, separately from Runtime's +cached model configuration. Claude transcript usage fields supply input/cache +tokens but not a paired window. Claude's native statusline supplies a true pair, +but the existing quota cache retains only one latest payload and is conditional +on quota data; it cannot establish historical coverage for other sessions. +Codex records with paired token-count fields establish pressure; cumulative-only +or older records do not. OpenCode's sessions may simply fall outside the selected +timeframe; its recorded message tokens do not establish the runtime window. + +The cards distinguish input-only records, partial paired coverage, no recorded +measurements, and no sessions. Missing numeric evidence displays as an em dash, +not zero. The count is labelled Sessions with pressure because it counts sessions +with a pair, rather than individual telemetry samples. A meter appears only for +a measured pressure value; otherwise the card states that pressure is unmeasured. + +Future Claude coverage could use bounded per-session statusline snapshots with +session/model/time identity. OpenCode would require validated request/response +correlation across retries and model switches. Neither is implemented here, and +model catalogs are not substituted for historical runtime windows. + +## Usage Score projects + +An additive `gitProjects` projection ranks discovered Git projects using exactly +the sessions admitted by the existing time/host filters. Existing `byProject` +and overall totals remain unchanged. Parse-time metadata carries opaque working +and repository identifiers; display names never establish repository identity. +Git observations describe the filesystem at parse time, with timestamps. +Repository inspection is bounded and memoized; rendering performs no filesystem +inspection. + +The panel shows a simple top 10 by spend, with plain names and cost/session/time +values. Verified worktree spend rolls into an existing parent Git project. +Standalone worktrees, exact user-level roots, bare repositories without a parent +checkout, and unclassified directories do not appear in the ranking. There are no +expanded group lists or Desktop suffixes. Overall Usage totals retain activity +excluded from these ten rows. Older payloads request a usage refresh instead of +guessing Git membership from labels. + +Usage index schema 20 rebuilds the derived cache once to recover metadata missing +from the previous release. This reads retained records without changing originals. +Subsequent reads use incremental cached observations. + +## Verification + +Focused tests cover repository association, unknown metadata, origin attribution, +count preservation, cache/configuration semantics, and display grouping. Browser +fixtures cover populated/empty/unknown states, keyboard filtering/navigation, +wrapping language icons, bounded model-table height, and responsive layouts. +Screenshots and final validation are recorded in the pull request. diff --git a/docs/evidence/dashboard-project-context/README.md b/docs/evidence/dashboard-project-context/README.md new file mode 100644 index 00000000..d2c685ce --- /dev/null +++ b/docs/evidence/dashboard-project-context/README.md @@ -0,0 +1,68 @@ +# Dashboard project and context evidence + +All screenshots use deterministic fixtures, not private project data. The design +and host capability research are in [ADR-0050](../../adr/0050-dashboard-project-identity-and-context-reporting.md). + +## Browser coverage + +The committed browser suites use the real dashboard markup, styles, and client +code with fixture HTTP observations. They verify: + +- Repository/worktree grouping and independent Desktop-origin filtering. +- Existing totals, old payload fallback, empty states, and unknown attribution. +- Maintenance folder/name alignment, shared Git badge, all language icons, and + navigation through installations and back. +- Alphabetized Intelligence optgroups and table subgroups, plain names, stable + selection keys, five-row scroll regions, and empty-history navigation. +- Usage Score top-ten Git-project ranking, timeframe changes, plain names, + excluded unclassified entries, and preserved overall totals. +- Compact collapsed context configuration, bounded expanded model lists, + cache/configuration semantics, and catalog fallback columns. +- Historical context coverage distinguishes input-only, partial paired data, + missing measurements, and no sessions without presenting absent windows as zero. +- Desktop, tablet, and phone overflow checks, keyboard controls, focus retention, + native select semantics, accessible icon labels, and browser errors. + +These are targeted accessibility checks, not a claim of a complete WCAG audit. + +## Screenshots + +| Surface | Desktop | Phone | +| --- | --- | --- | +| Context configuration | [Default](context-desktop.png), [model disclosure](context-models-desktop.png) | [390 px](context-390.png) | +| Historical Context | [Desktop](coverage/context-coverage-1440.png) | [390 px](coverage/context-coverage-390.png) | +| System Projects | [Desktop](system-projects-desktop.png) | [390 px](system-projects-390.png) | +| Maintenance Projects | [Desktop](maintenance/project-cards-desktop.png) | [390 px](maintenance/project-cards-mobile.png) | +| Intelligence table | [Desktop](intelligence/intelligence-table-1360.png) | [390 px](intelligence/intelligence-table-390.png), [scrolled](intelligence/intelligence-table-390-scrolled.png) | +| Intelligence picker | [Desktop](intelligence/intelligence-picker-1360.png) | [390 px](intelligence/intelligence-picker-390.png) | +| Usage project groups | [Desktop](usage/usage-project-groups-1440.png) | [390 px](usage/usage-project-groups-390.png) | + +Native popup rendering belongs to the operating system; the Intelligence test +asserts optgroup contents and ordering directly in the native select. + +## Reproduction + +```sh +npm test +npm run typecheck +npm run lint +npm run lint:cc +npm run lint:md +npm run build +npm run test:ui +``` + +The five focused browser suites are also included in `test:ui`. Ordinary runs +write screenshots only to the ignored UI artifact directory, or to an explicit +`AK_UI_ARTIFACTS` / `AK_DASHBOARD_EVIDENCE_DIR` destination. CI results and the exact +reviewed source commit are linked from the pull request. + +## Remaining boundaries + +- Older footprint snapshots need a remeasurement to acquire new identity/origin + metadata; they remain readable and unclassified in the meantime. +- Usage index schema 20 reparses retained records once; originals are untouched. +- Desktop labels reflect exact recorded declarations, not independently verified + launching applications. Undiscovered Desktop history stays outside coverage. +- Cached model capacities and configuration calculations are not live-session + window/usage observations. Only Codex currently has kit-owned context writes. diff --git a/docs/evidence/dashboard-project-context/context-390.png b/docs/evidence/dashboard-project-context/context-390.png new file mode 100644 index 00000000..a1593b5d Binary files /dev/null and b/docs/evidence/dashboard-project-context/context-390.png differ diff --git a/docs/evidence/dashboard-project-context/context-768.png b/docs/evidence/dashboard-project-context/context-768.png new file mode 100644 index 00000000..c909511f Binary files /dev/null and b/docs/evidence/dashboard-project-context/context-768.png differ diff --git a/docs/evidence/dashboard-project-context/context-desktop.png b/docs/evidence/dashboard-project-context/context-desktop.png new file mode 100644 index 00000000..628ede8a Binary files /dev/null and b/docs/evidence/dashboard-project-context/context-desktop.png differ diff --git a/docs/evidence/dashboard-project-context/context-models-desktop.png b/docs/evidence/dashboard-project-context/context-models-desktop.png new file mode 100644 index 00000000..d53e7758 Binary files /dev/null and b/docs/evidence/dashboard-project-context/context-models-desktop.png differ diff --git a/docs/evidence/dashboard-project-context/coverage/context-coverage-1440.png b/docs/evidence/dashboard-project-context/coverage/context-coverage-1440.png new file mode 100644 index 00000000..d28c609f Binary files /dev/null and b/docs/evidence/dashboard-project-context/coverage/context-coverage-1440.png differ diff --git a/docs/evidence/dashboard-project-context/coverage/context-coverage-390.png b/docs/evidence/dashboard-project-context/coverage/context-coverage-390.png new file mode 100644 index 00000000..b84317ce Binary files /dev/null and b/docs/evidence/dashboard-project-context/coverage/context-coverage-390.png differ diff --git a/docs/evidence/dashboard-project-context/intelligence/intelligence-picker-1360.png b/docs/evidence/dashboard-project-context/intelligence/intelligence-picker-1360.png new file mode 100644 index 00000000..6ac1dbc5 Binary files /dev/null and b/docs/evidence/dashboard-project-context/intelligence/intelligence-picker-1360.png differ diff --git a/docs/evidence/dashboard-project-context/intelligence/intelligence-picker-390.png b/docs/evidence/dashboard-project-context/intelligence/intelligence-picker-390.png new file mode 100644 index 00000000..6add5007 Binary files /dev/null and b/docs/evidence/dashboard-project-context/intelligence/intelligence-picker-390.png differ diff --git a/docs/evidence/dashboard-project-context/intelligence/intelligence-table-1100.png b/docs/evidence/dashboard-project-context/intelligence/intelligence-table-1100.png new file mode 100644 index 00000000..d6031fd3 Binary files /dev/null and b/docs/evidence/dashboard-project-context/intelligence/intelligence-table-1100.png differ diff --git a/docs/evidence/dashboard-project-context/intelligence/intelligence-table-1360.png b/docs/evidence/dashboard-project-context/intelligence/intelligence-table-1360.png new file mode 100644 index 00000000..7a4c2ce5 Binary files /dev/null and b/docs/evidence/dashboard-project-context/intelligence/intelligence-table-1360.png differ diff --git a/docs/evidence/dashboard-project-context/intelligence/intelligence-table-390-scrolled.png b/docs/evidence/dashboard-project-context/intelligence/intelligence-table-390-scrolled.png new file mode 100644 index 00000000..121bfdac Binary files /dev/null and b/docs/evidence/dashboard-project-context/intelligence/intelligence-table-390-scrolled.png differ diff --git a/docs/evidence/dashboard-project-context/intelligence/intelligence-table-390.png b/docs/evidence/dashboard-project-context/intelligence/intelligence-table-390.png new file mode 100644 index 00000000..c6dfce2e Binary files /dev/null and b/docs/evidence/dashboard-project-context/intelligence/intelligence-table-390.png differ diff --git a/docs/evidence/dashboard-project-context/maintenance/project-cards-desktop.png b/docs/evidence/dashboard-project-context/maintenance/project-cards-desktop.png new file mode 100644 index 00000000..1418078d Binary files /dev/null and b/docs/evidence/dashboard-project-context/maintenance/project-cards-desktop.png differ diff --git a/docs/evidence/dashboard-project-context/maintenance/project-cards-mobile.png b/docs/evidence/dashboard-project-context/maintenance/project-cards-mobile.png new file mode 100644 index 00000000..161daecd Binary files /dev/null and b/docs/evidence/dashboard-project-context/maintenance/project-cards-mobile.png differ diff --git a/docs/evidence/dashboard-project-context/maintenance/projects-desktop.png b/docs/evidence/dashboard-project-context/maintenance/projects-desktop.png new file mode 100644 index 00000000..6c5d1c0e Binary files /dev/null and b/docs/evidence/dashboard-project-context/maintenance/projects-desktop.png differ diff --git a/docs/evidence/dashboard-project-context/maintenance/projects-mobile.png b/docs/evidence/dashboard-project-context/maintenance/projects-mobile.png new file mode 100644 index 00000000..baa28097 Binary files /dev/null and b/docs/evidence/dashboard-project-context/maintenance/projects-mobile.png differ diff --git a/docs/evidence/dashboard-project-context/system-projects-390.png b/docs/evidence/dashboard-project-context/system-projects-390.png new file mode 100644 index 00000000..ec625f67 Binary files /dev/null and b/docs/evidence/dashboard-project-context/system-projects-390.png differ diff --git a/docs/evidence/dashboard-project-context/system-projects-768.png b/docs/evidence/dashboard-project-context/system-projects-768.png new file mode 100644 index 00000000..97823622 Binary files /dev/null and b/docs/evidence/dashboard-project-context/system-projects-768.png differ diff --git a/docs/evidence/dashboard-project-context/system-projects-desktop.png b/docs/evidence/dashboard-project-context/system-projects-desktop.png new file mode 100644 index 00000000..69625840 Binary files /dev/null and b/docs/evidence/dashboard-project-context/system-projects-desktop.png differ diff --git a/docs/evidence/dashboard-project-context/usage/usage-project-groups-1440.png b/docs/evidence/dashboard-project-context/usage/usage-project-groups-1440.png new file mode 100644 index 00000000..72011983 Binary files /dev/null and b/docs/evidence/dashboard-project-context/usage/usage-project-groups-1440.png differ diff --git a/docs/evidence/dashboard-project-context/usage/usage-project-groups-390.png b/docs/evidence/dashboard-project-context/usage/usage-project-groups-390.png new file mode 100644 index 00000000..6080b513 Binary files /dev/null and b/docs/evidence/dashboard-project-context/usage/usage-project-groups-390.png differ diff --git a/package.json b/package.json index 9cbd2bae..8f41eada 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ ], "scripts": { "test": "node --test --experimental-test-coverage --test-coverage-lines=70 --test-coverage-branches=70 --test-coverage-functions=70 \"tests/kit/*.test.mjs\" && node tests/statusline-segments.test.cjs && node tests/statusline-brain.test.cjs && node tests/agentdb.test.cjs && node tests/health-history.test.cjs && node tests/harvest.test.cjs && node tests/dashboard.test.cjs && node tests/admin-model.test.cjs && node tests/admin.test.cjs", - "test:ui": "node tests/ui/dashboard-ui.mjs", + "test:ui": "node tests/ui/dashboard-ui.mjs && node --test tests/ui/dashboard-project-context.mjs tests/ui/maintenance-projects.mjs tests/ui/intelligence-picker.mjs tests/ui/usage-project-groups.mjs tests/ui/context-coverage.mjs", "test:surface": "node --test tests/kit/dispatch-surface.test.mjs", "test:aqe-external-provider-live": "node --test tests/live/aqe-external-provider-transport.test.mjs", "test:qe-court-live": "node --test tests/live/qe-court-participant-transport.test.mjs", diff --git a/src/commands/status/sections/codex-context.mjs b/src/commands/status/sections/codex-context.mjs index 8ac6c2d2..11fc84d0 100644 --- a/src/commands/status/sections/codex-context.mjs +++ b/src/commands/status/sections/codex-context.mjs @@ -1,4 +1,5 @@ import { inspectCodexContext } from '../../../lib/codex-context.mjs'; +import { collectContextReport } from '../../../lib/context-report.mjs'; import { row } from '../row.mjs'; export default { @@ -6,13 +7,19 @@ export default { async collect({ cfg }) { if (!cfg.integrations?.hosts?.codex && !cfg.codexContext) return []; const status = inspectCodexContext(cfg); - if (!status.owned) return [row('codex-context', 'info', 'Codex context is unmanaged; opt in with `ak x codex-context max`')]; - if (!status.available) return [row('codex-context', 'warn', `managed Codex context unavailable: ${status.reason}`)]; - if (!status.enabled) return [row('codex-context', 'warn', 'Codex context ownership retained while host is disabled; use `ak x codex-context off` to restore')]; - if (status.drifted) return [row('codex-context', 'warn', 'managed Codex context request has drifted', 'sync restores the native maximum request')]; - const rows = [row('codex-context', 'ok', `native maximum request ${status.configuredWindow}; per-model limits apply to new sessions (cache client ${status.clientVersion}; running client and session unverified)`)]; - for (const m of status.models) rows.push(row('codex-context/model', 'info', `${m.model}: ${m.effectiveWindow} effective / ${m.maximumWindow} native maximum`)); - if (status.autoCompactTokenLimit) rows.push(row('codex-context', 'info', `user auto-compaction threshold ${status.autoCompactTokenLimit} retained`)); + const rows = collectRows(status); + rows[0].contextReport = collectContextReport(cfg, status); return rows; }, }; + +function collectRows(status) { + if (!status.owned) return [row('codex-context', 'info', 'Codex context is unmanaged; opt in with `ak x codex-context max`')]; + if (!status.available) return [row('codex-context', 'warn', `managed Codex context unavailable: ${status.reason}`)]; + if (!status.enabled) return [row('codex-context', 'warn', 'Codex context ownership retained while host is disabled; use `ak x codex-context off` to restore')]; + if (status.drifted) return [row('codex-context', 'warn', 'managed Codex context request has drifted', 'sync restores the native maximum request')]; + const rows = [row('codex-context', 'ok', `native maximum request ${status.configuredWindow}; per-model limits apply to new sessions (cache client ${status.clientVersion}; running client and session unverified)`)]; + for (const m of status.models) rows.push(row('codex-context/model', 'info', `${m.model}: ${m.effectiveWindow} effective / ${m.maximumWindow} native maximum`)); + if (status.autoCompactTokenLimit) rows.push(row('codex-context', 'info', `user auto-compaction threshold ${status.autoCompactTokenLimit} retained`)); + return rows; +} diff --git a/src/commands/status/sections/context.mjs b/src/commands/status/sections/context.mjs new file mode 100644 index 00000000..f697c7f6 --- /dev/null +++ b/src/commands/status/sections/context.mjs @@ -0,0 +1,14 @@ +import { collectContextReport } from '../../../lib/context-report.mjs'; +import { row } from '../row.mjs'; + +export default { + id: 'context', + async collect({ cfg }) { + // The Codex section carries the complete report whenever it is present, + // preserving its existing CLI and actionable sync contracts. + if (cfg.integrations?.hosts?.codex || cfg.codexContext) return []; + const contextReport = collectContextReport(cfg); + if (!contextReport.hosts.length) return []; + return [{ ...row('context', 'info', 'Host context controls not inspected; live session window and usage unverified'), contextReport }]; + }, +}; diff --git a/src/commands/status/sections/index.mjs b/src/commands/status/sections/index.mjs index 5fc90406..46f0245b 100644 --- a/src/commands/status/sections/index.mjs +++ b/src/commands/status/sections/index.mjs @@ -9,6 +9,7 @@ // original monolithic collect(). import models from './models.mjs'; import codexContext from './codex-context.mjs'; +import context from './context.mjs'; import versions from './versions.mjs'; import ruvnetBrain from './ruvnet-brain.mjs'; import ruvector from './ruvector.mjs'; @@ -51,5 +52,5 @@ export const SECTIONS_BEFORE_HOST_DETAIL = [ export const SECTIONS_AFTER_HOST_DETAIL = [ hosts, providersStatus, providersExternalIntent, providersExternalProjection, providersRufloModels, providersLocalBindings, routing, daemons, blocks, - statusline, codexContext, qeCourt, + statusline, codexContext, context, qeCourt, ]; diff --git a/src/lib/codex-context.mjs b/src/lib/codex-context.mjs index 596d8718..64cdcb76 100644 --- a/src/lib/codex-context.mjs +++ b/src/lib/codex-context.mjs @@ -57,6 +57,7 @@ function checkScope(cfg, home) { export function inspectCodexContext(cfg, { home = contextHome(), now = Date.now() } = {}) { const owned = !!cfg.codexContext; + const observedAt = new Date(now).toISOString(); try { checkScope(cfg, home); const evidence = readEvidence(home, now); @@ -65,9 +66,10 @@ export function inspectCodexContext(cfg, { home = contextHome(), now = Date.now( file: evidence.file, requestedWindow: evidence.requestedWindow, configuredWindow: evidence.config.window, autoCompactTokenLimit: evidence.config.autoCompact, clientVersion: evidence.cache.client_version, evidence: 'native-catalog-and-user-config', runtimeVerified: false, + observedAt, cacheFetchedAt: evidence.cache.fetched_at, models: evidence.models.map(m => contextCapacity(m, evidence.config.window)) }; } catch (error) { - return { owned, available: false, drifted: false, reason: error.message, models: [], runtimeVerified: false }; + return { owned, available: false, drifted: false, reason: error.message, models: [], runtimeVerified: false, observedAt, cacheFetchedAt: null }; } } diff --git a/src/lib/context-model-cache.mjs b/src/lib/context-model-cache.mjs new file mode 100644 index 00000000..7f039a03 --- /dev/null +++ b/src/lib/context-model-cache.mjs @@ -0,0 +1,60 @@ +// Read-only projection of existing model inventory. Refresh/CLI/API discovery +// stays exclusively in the model inventory workflow, never dashboard polling. +import { latestSnapshot, readModelStore } from './model-inventory/store.mjs'; + +const SOURCES = new Set(['anthropic-docs', 'claude-config', 'codex-cache', 'opencode-models']); +const tokenValue = value => Number.isSafeInteger(value) && value > 0 ? value : null; +const safeId = value => typeof value === 'string' && value.length <= 256 + && /^[A-Za-z0-9][A-Za-z0-9._:/+[\]-]*$/.test(value) ? value : null; +const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null; +export const MAX_CONTEXT_MODELS = 100; + +export function readContextModelSnapshot() { + try { return latestSnapshot(readModelStore()); } catch { return null; } +} + +function fieldValue(model, field, snapshot) { + const [section, key] = field.split('.'); + const value = tokenValue(model[section]?.[key]); + if (value === null) return null; + const evidence = (model.evidence || []).find(entry => entry.field === field && SOURCES.has(entry.source) + && entry.scopeFingerprint === model.key.scopeId && iso(entry.capturedAt) + && snapshot.sources?.some(source => source.id === entry.source + && source.scopeFingerprint === entry.scopeFingerprint)); + return evidence ? { value, evidence } : null; +} + +function observationFreshness(evidence, now) { + const capturedAt = evidence.map(entry => entry.capturedAt).sort((a, b) => Date.parse(a) - Date.parse(b))[0]; + const age = now - Date.parse(capturedAt); + const freshness = evidence.some(entry => entry.freshness === 'stale') || age > 7 * 86400000 + ? 'stale' : evidence.every(entry => entry.freshness === 'fresh') && age >= -300000 ? 'fresh' : 'unknown'; + return { capturedAt, freshness }; +} + +/** Keep model capacity separate from configured/session windows. Even a + * cached configured variant is only a catalog observation in this report. */ +export function cachedContextModels(snapshot, host, { now = Date.now() } = {}) { + if (!snapshot || !iso(snapshot.capturedAt)) return { models: [], omitted: 0 }; + const rows = new Map(); + for (const model of snapshot.models || []) { + if (model.key?.host !== host || model.visibility === 'hidden' || !safeId(model.key.modelId) + || model.key.scopeId !== snapshot.scope?.fingerprint) continue; + const capacity = fieldValue(model, 'capabilities.contextLimit', snapshot) + ?? fieldValue(model, 'variant.maximumContextWindow', snapshot); + const input = fieldValue(model, 'capabilities.inputLimit', snapshot); + const output = fieldValue(model, 'capabilities.outputLimit', snapshot); + if (!capacity && !input && !output) continue; + const evidence = [capacity, input, output].filter(Boolean).map(item => item.evidence); + const { capturedAt, freshness } = observationFreshness(evidence, now); + const provider = safeId(model.key.provider); + const row = { model: model.key.modelId, provider, capacityWindow: capacity?.value ?? null, + inputLimit: input?.value ?? null, outputLimit: output?.value ?? null, basis: 'catalog', + capturedAt, scopeId: model.key.scopeId, freshness, + sources: [...new Set(evidence.map(entry => entry.source))] }; + const key = JSON.stringify([row.model, provider, row.scopeId]); + if (!rows.has(key)) rows.set(key, row); + } + const models = [...rows.values()].sort((a, b) => a.model.localeCompare(b.model) || String(a.provider).localeCompare(String(b.provider))); + return { models: models.slice(0, MAX_CONTEXT_MODELS), omitted: Math.max(0, models.length - MAX_CONTEXT_MODELS) }; +} diff --git a/src/lib/context-report.mjs b/src/lib/context-report.mjs new file mode 100644 index 00000000..1827e873 --- /dev/null +++ b/src/lib/context-report.mjs @@ -0,0 +1,77 @@ +// Dashboard reporting only: configuration/catalog evidence is never promoted +// to a live-session fact. Host capabilities and kit-owned controls differ. +import { cachedContextModels, readContextModelSnapshot, MAX_CONTEXT_MODELS } from './context-model-cache.mjs'; + +const NATIVE_CONTROLS = { + claude: 'Native model selection, autoCompactWindow and /autocompact; settings not inspected here.', + codex: 'Native per-model context allocation and automatic compaction settings.', + opencode: 'Native provider/model limits and compaction controls; settings differ by host version.', +}; + +export function collectContextReport(cfg, status = null) { + return buildContextReport(cfg, status, { modelSnapshot: readContextModelSnapshot() }); +} + +const tokens = value => Number.isSafeInteger(value) && value > 0 ? value : null; +const timestamp = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null; +const LABELS = { claude: 'Claude', codex: 'Codex', opencode: 'OpenCode' }; + +/** Build a shared, read-only reporting contract. Numeric host-wide windows + * remain null when no active model/session has been established. Per-model + * values describe the inspected native catalog/configuration, not sessions. + * observedAt timestamps inspection; cacheFetchedAt timestamps its source. */ +export function buildContextReport(cfg, codexStatus = null, { now = Date.now(), modelSnapshot = null } = {}) { + const observedAt = new Date(now).toISOString(); + const hosts = Object.keys(LABELS) + .filter(host => cfg.integrations?.hosts?.[host] === true || (host === 'codex' && cfg.codexContext)) + .map(host => { + const cached = cachedContextModels(modelSnapshot, host, { now }); + const base = { + host, label: LABELS[host], enabled: cfg.integrations?.hosts?.[host] === true, + managed: host === 'codex' && !!cfg.codexContext, + state: 'not-inspected', source: 'integration-configuration', observedAt, + cacheFetchedAt: null, runtimeVerified: false, + modelCapacity: null, configuredRequest: null, effectiveWindow: null, usage: null, + compaction: { configuredThreshold: null, scope: 'unverified', runtimeThreshold: null, control: 'not-inspected' }, + models: cached.models, modelsOmitted: cached.omitted, nativeControls: NATIVE_CONTROLS[host], + inventoryCapturedAt: timestamp(modelSnapshot?.capturedAt), inventoryScopeId: modelSnapshot?.scope?.fingerprint ?? null, + limitations: [], + }; + if (host !== 'codex') { + base.limitations = [ + 'Context and compaction configuration not inspected; agentic-kit does not manage these controls.', + 'Live session window and usage unverified; historical input observations remain in Usage → Context.', + ...(host === 'opencode' ? ['Native compaction controls differ by OpenCode version.'] : []), + ]; + return base; + } + const status = codexStatus; + base.observedAt = timestamp(status?.observedAt) ?? observedAt; + if (!status?.available) { + base.state = 'unavailable'; + base.source = 'native-catalog-and-user-config'; + base.limitations = [status?.reason || 'Codex context evidence unavailable.', 'Running client and session unverified.']; + return base; + } + base.state = status.drifted ? 'drifted' : 'observed'; + base.source = 'native-catalog-and-user-config'; + base.cacheFetchedAt = timestamp(status.cacheFetchedAt); + base.configuredRequest = tokens(status.configuredWindow); + base.compaction = { configuredThreshold: tokens(status.autoCompactTokenLimit), + scope: 'unverified', runtimeThreshold: null, control: 'user-owned' }; + base.modelsOmitted = Math.max(0, (status.models || []).length - MAX_CONTEXT_MODELS); + base.models = (status.models || []).slice(0, MAX_CONTEXT_MODELS).map(model => ({ + model: model.model, basis: 'native-codex-config', capacityWindow: tokens(model.maximumWindow), + inputLimit: null, outputLimit: null, capturedAt: base.cacheFetchedAt, scopeId: null, freshness: 'fresh', + nativeWindow: tokens(model.nativeWindow), maximumWindow: tokens(model.maximumWindow), + requestedWindow: tokens(model.requestedWindow), allocatedWindow: tokens(model.allocatedWindow), + effectivePercent: tokens(model.effectivePercent), effectiveWindow: tokens(model.effectiveWindow), + })); + base.limitations = [ + 'Per-model values derive from native catalog and user configuration; running client and session unverified.', + 'Configured compaction threshold is retained; active threshold and counting scope unverified.', + ]; + return base; + }); + return { schemaVersion: 1, observedAt, hosts }; +} diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index c189fbbb..e62f6a0a 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -356,8 +356,11 @@ async function collectData({ cwd, fetchStatus, projectParam, getProjectSnapshot intel: { selectedProjectKey: selected?.key ?? null, selectedProjectLabel: selected?.label ?? null, - projects: projects.map(({ key, label, path: projectPath, source }) => ( - { key, label, path: projectPath, source } + projects: projects.map(({ key, label, path: projectPath, source, learningScope, learningScopeEvidence, learningOrigins, learningObservedAt }) => ( + { key, label, path: projectPath, source, + learningScope: ['repository', 'worktree', 'user'].includes(learningScope) ? learningScope : 'unknown', + learningScopeEvidence: learningScopeEvidence ?? 'unclassified', learningObservedAt: learningObservedAt ?? null, + learningOrigins: ['claude-desktop', 'codex-desktop'].filter((origin) => learningOrigins?.includes(origin)) } )), health: selectedHistory.healthRing, globalStats: selectedHistory.globalStats, diff --git a/src/lib/dashboard/client.mjs b/src/lib/dashboard/client.mjs index 02cb3034..152de68b 100644 --- a/src/lib/dashboard/client.mjs +++ b/src/lib/dashboard/client.mjs @@ -1,3 +1,5 @@ +import { projectView } from './project-groups.mjs'; +import { contextCard } from './context-card.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -155,5 +157,5 @@ const bootSrc = readSplit('boot.mjs'); // sequence) running in the same relative order it always has. export const JS = ` (function(){ -${bootstrapSrc}${overviewSrc}${datetimeSrc}${intelligenceSrc}${pollSrc}${usageRhythmSrc}${usagePromptsSrc}${usageContextHooksSrc}${usageSrc}${modelLifecycleSrc}${usageOrchestratorsSrc}${aboutSrc}${systemReadoutSrc}${systemProjectsSrc}${maintenanceWorkspaceSrc}${maintenanceFiltersSrc}${maintenanceCardsSrc}${maintenanceOperationSrc}${maintenanceLanguageLogosSrc}${maintenanceFocusSrc}${maintenanceInventorySrc}${maintenanceRelationshipsSrc}${maintenanceInspectorSrc}${maintenanceGuidanceSrc}${maintenanceDiscoverySrc}${maintenanceActivitySrc}${systemMaintenanceActionsSrc}${systemMaintenanceSrc}${bootSrc}})(); +${bootstrapSrc}${contextCard.toString()}${projectView.toString()}${overviewSrc}${datetimeSrc}${intelligenceSrc}${pollSrc}${usageRhythmSrc}${usagePromptsSrc}${usageContextHooksSrc}${usageSrc}${modelLifecycleSrc}${usageOrchestratorsSrc}${aboutSrc}${systemReadoutSrc}${systemProjectsSrc}${readSplit('project-group-controls.mjs')}${maintenanceWorkspaceSrc}${maintenanceFiltersSrc}${maintenanceCardsSrc}${maintenanceOperationSrc}${maintenanceLanguageLogosSrc}${maintenanceFocusSrc}${maintenanceInventorySrc}${maintenanceRelationshipsSrc}${maintenanceInspectorSrc}${maintenanceGuidanceSrc}${maintenanceDiscoverySrc}${maintenanceActivitySrc}${systemMaintenanceActionsSrc}${systemMaintenanceSrc}${bootSrc}})(); `; diff --git a/src/lib/dashboard/client/intelligence.mjs b/src/lib/dashboard/client/intelligence.mjs index 0306e764..1ee360ca 100644 --- a/src/lib/dashboard/client/intelligence.mjs +++ b/src/lib/dashboard/client/intelligence.mjs @@ -57,6 +57,40 @@ import { fmtNum, kpi } from './usage.mjs'; box.hidden=false; } + var INTEL_SCOPE_GROUPS=[['repository','Git repositories'],['worktree','Git worktrees'],['user','User-level learning'],['unknown','Other / unclassified']]; + function intelScopeRows(rows,scope){ + return rows.filter(function(p){ + var kind=['repository','worktree','user'].includes(p.learningScope)?p.learningScope:'unknown'; + return kind===scope; + }).sort(function(a,b){return String(a.label||'').localeCompare(String(b.label||''),undefined,{sensitivity:'base',numeric:true}) + ||String(a.key||a.path||'').localeCompare(String(b.key||b.path||''));}); + } + + function machineWideRow(p){ + var lastMs=Number(p.lastAdaptation)||0; + var lastTxt=lastMs?ago(Math.max(0,Math.round((Date.now()-lastMs)/1000))):"—"; + var stores=Array.isArray(p.learningState)?p.learningState:[]; + var storeHtml=stores.length?'' + +stores.map(function(s){return '';}).join('')+'':''; + var label=p.label||'(unlabeled)'; + return '
' + +''+esc(label)+storeHtml+'' + +''+esc(fmtNum(p.patternsLearned))+'' + +''+esc(fmtNum(p.patternStoreCount))+'' + +''+esc(lastTxt)+'
'; + } + + function machineWideGroup(group,rows){ + var id='mw-group-'+group[0]; + return '
' + +'

'+esc(group[1])+''+esc(fmtNum(rows.length))+'

' + +'
' + +'
' + +'ProjectPatterns learned' + +'Pattern storeLast active
' + +'
'+rows.map(machineWideRow).join('')+'
'; + } + function renderMachineWide(mw){ var totals=(mw&&mw.totals)||{}; var perProject=Array.isArray(mw&&mw.perProject)?mw.perProject.slice():[]; @@ -65,35 +99,24 @@ import { fmtNum, kpi } from './usage.mjs'; kpi("patterns learned",fmtNum(totals.patternsLearnedLifetime),"lifetime · every tracked project","") +kpi("projects tracked",fmtNum(totals.projectCount),"with memory or intelligence state","") +kpi("most active project",totals.mostActiveProject||"—","by most recent learning adaptation","accent"); - perProject.sort(function(a,b){return (Number(b&&b.patternsLearned)||0)-(Number(a&&a.patternsLearned)||0);}); var table=document.getElementById("mw-table"); if(!table)return; if(!perProject.length){table.innerHTML='
no projects discovered on this machine.
';return;} - var html='
projectpatterns learned' - +'pattern storelast active
'; - for(var i=0;i' - +stores.map(function(s){return '';}).join("") - +"" - : ""; - html+='
' - +''+esc(p.label||"(unlabeled)")+storeHtml+"" - +''+esc(fmtNum(p.patternsLearned))+"" - +''+esc(fmtNum(p.patternStoreCount))+"" - +''+esc(lastTxt)+"" - +"
"; - } - table.innerHTML=html; + var positions={}; + var active=document.activeElement,focusedScope=active&&active.classList&&active.classList.contains('mw-group-scroll') + ?active.closest('.mw-group').getAttribute('data-learning-scope'):null; + if(table.querySelectorAll)Array.from(table.querySelectorAll('.mw-group')).forEach(function(section){ + positions[section.getAttribute('data-learning-scope')]=section.querySelector('.mw-group-scroll').scrollTop; + }); + table.innerHTML=INTEL_SCOPE_GROUPS.map(function(group){ + var rows=intelScopeRows(perProject,group[0]); + return rows.length?machineWideGroup(group,rows):''; + }).join(''); + if(table.querySelectorAll)Array.from(table.querySelectorAll('.mw-group')).forEach(function(section){ + var region=section.querySelector('.mw-group-scroll'),scope=section.getAttribute('data-learning-scope'); + region.scrollTop=positions[scope]||0; + if(scope===focusedScope)region.focus({preventScroll:true}); + }); } // The picker's option list AND its default selection come from the SAME @@ -119,9 +142,13 @@ import { fmtNum, kpi } from './usage.mjs'; return; } sel.disabled=false; - sel.innerHTML=intelProjects.map(function(p){ - return '"; - }).join(""); + sel.innerHTML=INTEL_SCOPE_GROUPS.map(function(group){ + var rows=intelScopeRows(intelProjects,group[0]); + if(!rows.length)return ''; + return ''+rows.map(function(p){ + return '"; + }).join('')+''; + }).join(''); } export function wireIntelPicker(){ @@ -299,4 +326,3 @@ import { fmtNum, kpi } from './usage.mjs'; var btn=document.getElementById("poll-now"); if(btn)btn.disabled=inflight||(Date.now()-lastAttempt)'+mntIcon(kind==='git'||kind==='worktree'?'git':kind==='folder'?'project':'info')+''+esc(MNT_PROJECT_KIND_LABELS[kind]||MNT_PROJECT_KIND_LABELS.unknown)+''; } function mntRowContext(row){ diff --git a/src/lib/dashboard/client/maintenance-filters.mjs b/src/lib/dashboard/client/maintenance-filters.mjs index 413298a1..8184b773 100644 --- a/src/lib/dashboard/client/maintenance-filters.mjs +++ b/src/lib/dashboard/client/maintenance-filters.mjs @@ -4,12 +4,12 @@ import { esc } from './bootstrap.mjs'; import { MNT, MNT_GUIDANCE_LANE_LABELS, MNT_CONFLICT_EXPLANATIONS, MNT_CREDENTIAL_READINESS_LABELS, MNT_CURATED_VIEW_LABELS, MNT_SCOPE_LABELS, mntHumanize, mntKindLabel } from './maintenance-workspace.mjs'; var MNT_CURATED_VIEWS=Object.keys(MNT_CURATED_VIEW_LABELS); var MNT_FACET_ORDER=[ - "scope","environment","project","kind","adapter","consumer","carrier","provenance","packageManager", + "scope","environment","project","sessionOrigin","kind","adapter","consumer","carrier","provenance","packageManager", "versionState","guidance","dependencyRole","conflict","credentialReadiness","channel", "evidenceFields","recentlyChanged", ]; var MNT_FACET_LABEL={ - family:"Resource",scope:"Scope",environment:"Environment",project:"Project",projectType:"Project type",kind:"Type",adapter:"Adapters",consumer:"Hosts", + family:"Resource",scope:"Scope",environment:"Environment",project:"Project",sessionOrigin:"Session origin",projectType:"Project type",kind:"Type",adapter:"Adapters",consumer:"Hosts", carrier:"Carrier",provenance:"Source",packageManager:"Package manager",versionState:"Version state", guidance:"Guidance",dependencyRole:"Dependency role",conflict:"Conflict", credentialReadiness:"Credential",channel:"Channel",evidenceFields:"Evidence available", @@ -24,6 +24,7 @@ import { MNT, MNT_GUIDANCE_LANE_LABELS, MNT_CONFLICT_EXPLANATIONS, MNT_CREDENTIA } export function mntFacetValueLabel(facet,value){ if(facet==="adapter"||facet==="consumer")return MNT_ADAPTER_LABELS[value]||mntHumanize(value); + if(facet==="sessionOrigin")return ({"claude-desktop":"Claude Desktop","codex-desktop":"Codex Desktop",unknown:"Unclassified"})[value]||"Unclassified"; if(facet==="projectType")return ({git:'Git',folder:'Folder',worktree:'Worktree',unknown:'Not checked'})[value]||'Not checked'; if(facet==="scope")return MNT_SCOPE_LABELS[value]||mntHumanize(value); if(facet==="kind")return mntKindLabel(value); @@ -81,12 +82,12 @@ import { MNT, MNT_GUIDANCE_LANE_LABELS, MNT_CONFLICT_EXPLANATIONS, MNT_CREDENTIA return ''; }).join('')+(!values.length?'

Enable worktrees to see project options.

':'')+''; var selected=(MNT.facets[facet]||[]).length; - return mntDisclosure(facet,label+(selected?' ('+selected+')':''),body,['project','kind','consumer','adapter'].indexOf(facet)>=0||selected>0); + return mntDisclosure(facet,label+(selected?' ('+selected+')':''),body,['project','sessionOrigin','kind','consumer','adapter'].indexOf(facet)>=0||selected>0); } export function renderMntFacets(){ var counts=Object.assign({},(MNT.query&&MNT.query.facetCounts)||{}); counts.adapter=Object.assign({hermes:0},counts.adapter||{}); - var common=['project','kind','consumer','adapter']; + var common=['project','sessionOrigin','kind','consumer','adapter']; var other=MNT_FACET_ORDER.filter(function(facet){return common.indexOf(facet)<0;}); var advanced=other.map(function(facet){return renderMntFacetGroup(facet,counts[facet]);}).join(''); var active=other.some(function(facet){return (MNT.facets[facet]||[]).length>0;}); diff --git a/src/lib/dashboard/client/maintenance-focus.mjs b/src/lib/dashboard/client/maintenance-focus.mjs index dc2cc40a..2d481051 100644 --- a/src/lib/dashboard/client/maintenance-focus.mjs +++ b/src/lib/dashboard/client/maintenance-focus.mjs @@ -2,7 +2,7 @@ import { mntLanguageLogo } from './maintenance-language-logos.mjs'; import { esc } from './bootstrap.mjs'; import { MNT, MNT_SCOPE_LABELS, mntKindLabel } from './maintenance-workspace.mjs'; -import { mntIcon, mntAvailableTo } from './maintenance-cards.mjs'; +import { mntIcon, mntAvailableTo, mntProjectKindBadge } from './maintenance-cards.mjs'; import { mntFacetValueLabel } from './maintenance-filters.mjs'; export function mntFocusNavigation(){return MNT.query&&MNT.query.navigation;} @@ -47,16 +47,35 @@ import { mntFacetValueLabel } from './maintenance-filters.mjs'; return ''+esc(language.name)+''; }).join('')+''; } + function mntProjectOrigins(node){ + var origins=(node.sessionOrigins||[]).filter(function(item){return item.origin==='claude-desktop'||item.origin==='codex-desktop';}); + if(!origins.length)return ''; + return ''+origins.map(function(item){ + return esc(mntFacetValueLabel('sessionOrigin',item.origin)); + }).join(' · ')+''; + } + function mntProjectGroups(nodes,busy){ + var groups=new Map(),index=0; + nodes.forEach(function(node){ + var key=node.repositoryId||(node.projectKind==='folder'?'folders':'unassociated'); + if(!groups.has(key))groups.set(key,{label:node.repositoryLabel||(key==='folders'?'Other folders':'Other projects'),nodes:[]}); + groups.get(key).nodes.push(node); + }); + return Array.from(groups.values()).map(function(group){ + return '

'+esc(group.label)+'

    ' + +group.nodes.map(function(node){return mntFocusNode(node,index++,'project',busy);}).join('')+'
'; + }).join(''); + } function mntFocusNode(node,index,level,busy){ var icon=level==='scope'?node.value:level==='project'?'project':level==='kind'?node.value:node.kind; - var note=level==='project'?({git:'Git repository',worktree:'Worktree',folder:'Folder',unknown:'Project type not checked'}[node.projectKind]||'') - :level==='resource'&&!(MNT.facets.kind||[]).length?mntKindLabel(node.kind):''; + var note=level==='resource'&&!(MNT.facets.kind||[]).length?mntKindLabel(node.kind):''; if(level==='resource'&&node.installationSource)note=node.installationSource; return '
  • '+(level==='project'&&node.languages&&node.languages.length>3?'
    +'+(node.languages.length-3)+' more languages'+mntLanguageBadges(node.languages.slice(3))+'
    ':'')+'
  • '; + +(note?''+esc(note)+'':'')+''+esc(node.count)+' installation'+(node.count===1?'':'s')+''+mntIcon('chevron')+''; } function mntFocusInstallation(row,index){ var crumbs=(row.breadcrumb||[]).slice(),scope=row.scope||{}; @@ -73,6 +92,7 @@ import { mntFacetValueLabel } from './maintenance-filters.mjs'; } export function renderMntFocusResults(busy){ var nav=mntFocusNavigation();if(!nav)return null; + if(nav.level==='project')return mntProjectGroups(nav.nodes||[],busy); if(nav.level!=='installation')return '
      '+(nav.nodes||[]).map(function(node,index){return mntFocusNode(node,index,nav.level,busy);}).join('')+'
    '; var rows=(MNT.query.groups||[]).reduce(function(all,group){return all.concat(group.placements||[]);},[]); var family=(MNT.query.groups||[])[0],allLink=family&&family.knownPlacementCount>MNT.query.total?'

    ':''; diff --git a/src/lib/dashboard/client/project-group-controls.mjs b/src/lib/dashboard/client/project-group-controls.mjs new file mode 100644 index 00000000..fc581cf9 --- /dev/null +++ b/src/lib/dashboard/client/project-group-controls.mjs @@ -0,0 +1,29 @@ +// @ts-nocheck — browser bundle source. +import { esc } from './bootstrap.mjs'; +import { renderSysProjects } from './system-projects.mjs'; +import { SYSTEM } from './system-readout.mjs'; +export var projectPopulation='measured', projectOrigin='all'; +export function projectControls(payload) { + var options=[['all','All session origins'],['claude-desktop','Claude Desktop'],['codex-desktop','Codex Desktop'],['unknown','Unknown / unclassified']]; + return '
    ' + +'
    ' + +(!payload.discoveryProjects?'

    Older snapshot: full discovery and origin evidence unavailable. Rescan to populate.

    ':''); +} +export function projectIdentityCell(pr) { + if(!pr.repository&&!pr.sessionOrigins)return ''; + var origins=(pr.sessionOrigins||[]).map(function(item){var labels={'claude-desktop':'Claude Desktop','codex-desktop':'Codex Desktop',unknown:'Unknown origin'}; + return esc(labels[item.origin]||item.origin);}).join(' · '); + return ''+esc(pr.repository&&pr.repository.kind||'unknown') + +(origins?' · '+origins:'')+''+esc(pr.path||'')+''; +} +document.addEventListener('change',function(event){ + var id=event.target&&event.target.id; + if(id!=='project-population'&&id!=='project-origin')return; + if(id==='project-population')projectPopulation=event.target.value; + else projectOrigin=event.target.value; + if(SYSTEM)renderSysProjects(SYSTEM); + var control=document.getElementById(id);if(control)control.focus(); +}); diff --git a/src/lib/dashboard/client/system-projects.mjs b/src/lib/dashboard/client/system-projects.mjs index 4b3801cd..673d7653 100644 --- a/src/lib/dashboard/client/system-projects.mjs +++ b/src/lib/dashboard/client/system-projects.mjs @@ -1,6 +1,8 @@ +/* global projectView */ // @ts-nocheck — browser bundle source (never node-imported; client.mjs // reads it as text). See src/lib/dashboard/client/**'s eslint.config.mjs // override comment for why this directory isn't run through the node lib. +import { projectControls, projectIdentityCell, projectPopulation, projectOrigin } from './project-group-controls.mjs'; import { authHeaders, esc } from './bootstrap.mjs'; import { formatLocalDateTime, formatLocalDateTimeLong, shortSessionId } from './datetime.mjs'; import { ago } from './intelligence.mjs'; @@ -613,7 +615,7 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; var filtered=items.length!==all.length; matrix.innerHTML='
    ' +'' - +""+head+""+body+"
    Capabilities and the hosts that carry them.
    Name and source
    " + +"Name and source"+head+""+body+"" +'
    ' +(filtered ?esc(fmtNum(items.length))+" of "+esc(fmtNum(all.length))+" deduplicated items shown" @@ -793,7 +795,7 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; function sysProjectRowHtml(pr){ var name=sysProjectNameCell(pr); var last=mval(pr.lastActivity); - return ""+name+"" + return ""+name+projectIdentityCell(pr)+"" +''+mhtml(pr.loc&&pr.loc.total,function(v){return "~"+fmtTok(v);})+"" +""+langCell(pr.loc)+"" +''+mhtml(pr.totalBytes,fmtBytes)+"" @@ -808,10 +810,9 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; +(p.onDisk?mhtml(p.onDisk):"some")+" still on disk" :mhtml(p.count)+" projects measured (this snapshot predates the ever-seen count)") +", "+esc(fmtNum(list.length))+" listed here." - +(excluded + +(excluded&&projectPopulation==="measured" ? " Excluded "+esc(fmtNum(excluded))+" measured director"+(excluded===1?"y":"ies") - +" with no remote or no recorded session \u2014 agent worktrees, sub-folders of a " - +"repository already listed, and repositories with no remote." + +" without an HTTPS remote or recorded host. Full discovery retains other known paths." : "") +" Line counts are approximate: extension-bucketed, with node_modules and vendored " +"trees excluded. Disk is the whole project directory, .git and node_modules included.
    "; @@ -823,39 +824,16 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; var p=d.projects; if(!p){el.innerHTML=sysEmpty(NOT_SCANNED);return;} var all=p.projects||[]; - if(!all.length){el.innerHTML=sysEmpty("no project was discovered on this machine.");return;} - // Repositories, not directories. Two conditions, both required: - // - // a remote — a row with no https remote is, in practice, never a project - // you would recognise: it is an ephemeral .claude/worktrees/agent-* - // checkout, a sub-directory a session happened to run in - // (myrepo/backend), or a home directory someone once launched a session - // from. Those sat beside their own parent repo as if they were peers of - // it, each with its own multi-gigabyte disk figure. - // a session — this table is about projects you have actually worked in - // with a host. Discovery is session-derived today, so this holds by - // construction; asserting it anyway keeps that true if a future - // discovery source is not. - // - // The session test excludes only an EMPTY host list, never a missing one. A - // snapshot written before rows carried a hosts field cannot answer the question, - // and reading "absent" as "no sessions" would blank the whole table for - // anyone holding one — treating unmeasured as zero, which is the one thing - // this area may not do. - // - // A genuine local-only repository is excluded too. That is the cost of the - // rule, and it is why the count is stated below rather than left implied. - var elig=sysProjectsEligible(all),list=elig.list,excluded=elig.excluded; - if(!list.length){ - el.innerHTML=sysEmpty("no project with a remote and a recorded session was measured \u2014 " - +fmtNum(excluded)+" measured director"+(excluded===1?"y was":"ies were")+" excluded."); - return; - } - list=sortProjects(list,projSort.key,projSort.dir); - var body=list.map(sysProjectRowHtml).join(""); + if(!all.length&&!(p.discoveryProjects||[]).length){el.innerHTML=projectControls(p)+sysEmpty("no project was discovered on this machine.");return;} + var elig=sysProjectsEligible(all),excluded=elig.excluded; + var groups=projectView({projects:elig.list,discoveryProjects:p.discoveryProjects},projectPopulation,projectOrigin); + var list=groups.reduce(function(rows,group){return rows.concat(group.rows);},[]); + var body=groups.map(function(group){ + return ''+sortProjects(group.rows,projSort.key,projSort.dir).map(sysProjectRowHtml).join('')+''; + }).join(''); // Legend covers only what still renders: the language ramp. The disk column // is a single figure now, and there are no chips left to explain. - el.innerHTML='
    ' + el.innerHTML=projectControls(p)+'
    ' +'lines: top '+LANG_TOP+' languages, darkest first' +'' +'' @@ -867,11 +845,13 @@ import { fmtNum, fmtTok, limAge, pct } from './usage.mjs'; +projSortHeader("language","By language",false) +projSortHeader("disk","Disk",true) +projSortHeader("active","Last active",true) - +""+body+"
    " + +""+body+"
    " // Three numbers now, and the gap between the last two is a filter rather // than a fact about the machine — so it is named. Leaving the reader to // subtract 25 from 16 and guess is the silent exclusion ADR-0023 forbids. - +sysProjectsLinerHtml(p,list,excluded); + +sysProjectsLinerHtml(p,list,excluded) + +'

    '+list.length+' directories match. Origins can overlap within a directory; each directory appears once. Unmeasured values stay unknown; disk and line counts are not summed across nested paths.

    ' + +(!list.length?sysEmpty('No directories match these filters.'):''); } export function renderSystemFreshness(){ diff --git a/src/lib/dashboard/client/usage-context-hooks.mjs b/src/lib/dashboard/client/usage-context-hooks.mjs index 85118b6a..aba3c777 100644 --- a/src/lib/dashboard/client/usage-context-hooks.mjs +++ b/src/lib/dashboard/client/usage-context-hooks.mjs @@ -6,8 +6,9 @@ import { authHeaders, esc } from './bootstrap.mjs'; export var HOOKS=null,hooksBusy=null; function ctxTokens(value){ + if(value===null||value===undefined||value==="")return "—"; var n=Number(value); - if(!Number.isFinite(n))return "unknown"; + if(!Number.isFinite(n))return "—"; if(n>=1000000)return (n/1000000).toFixed(1)+"M"; if(n>=1000)return Math.round(n/1000)+"K"; return String(Math.round(n)); @@ -30,22 +31,36 @@ import { authHeaders, esc } from './bootstrap.mjs'; +''+(known?actual.toFixed(1)+"%":"unknown")+''; } + function contextCount(value){ + return Number.isInteger(value)&&value>=0?value.toLocaleString('en-US'):'—'; + } + + function contextCoverageDescription(coverage){ + var sessions=coverage.sessions,paired=coverage.pressureMeasured||0; + if(sessions===0)return {label:'No sessions',reason:'No sessions in the selected timeframe.'}; + if(!Number.isFinite(sessions))return {label:'Unavailable',reason:'Session coverage is unavailable.'}; + if(paired>0)return {label:paired===sessions?'Measured':'Partial coverage',reason:paired+' of '+sessions+' sessions have paired input/window measurements.'}; + if(coverage.inputMeasured>0&&!(coverage.windowMeasured>0))return {label:'Input only',reason:'Input tokens are available without a recorded context window.'}; + if(coverage.windowMeasured>0)return {label:'Unpaired data',reason:'Input and window were not recorded together, so pressure cannot be calculated.'}; + return {label:'Not recorded',reason:'No input/window measurements were found in these sessions.'}; + } + function contextHostCard(host,fold){ fold=fold||{}; - var coverage=fold.coverage||{},state=coverage.state||"not-observed"; + var coverage=fold.coverage||{},state=coverage.state||"not-observed",description=contextCoverageDescription(coverage); var peak=fold.pressureBps&&fold.pressureBps.peak&&fold.pressureBps.peak.p90; var windowMedian=fold.windowTokens&&fold.windowTokens.median; var inputPeak=fold.inputTokens&&fold.inputTokens.peak&&fold.inputTokens.peak.p90; + var label=({claude:'Claude',codex:'Codex',opencode:'OpenCode'})[host]||host; + var pressure=peak!==null&&peak!==undefined&&Number.isFinite(Number(peak)); return '
    ' - +'

    '+esc(host)+'

    '+esc(ctxState(state))+'
    ' - +contextMeter(host+' p90 peak context pressure',peak) - +'
    sessions
    '+esc(coverage.sessions||0)+'
    ' - +'
    paired samples
    '+esc(coverage.pressureMeasured||0)+'
    ' + +'

    '+esc(label)+'

    '+esc(description.label)+'
    ' + +(pressure?contextMeter(label+' p90 peak context pressure',peak):'

    Pressure not measured

    ') + +'
    sessions
    '+esc(contextCount(coverage.sessions))+'
    ' + +'
    Sessions with pressure
    '+esc(contextCount(coverage.pressureMeasured))+'
    ' +'
    p90 peak input
    '+esc(ctxTokens(inputPeak))+'
    ' +'
    median window
    '+esc(ctxTokens(windowMedian))+'
    ' - +'

    '+(state==="observed"?'Input and window were observed together for every session in this slice.' - :state==="partial"?'Some input or window evidence exists, but not every session has a paired pressure sample.' - :'No paired runtime context evidence was recorded for this host in the selected window.')+'

    '; + +'

    '+esc(description.reason)+'

    '; } function contextAttentionAction(state){ @@ -111,7 +126,7 @@ import { authHeaders, esc } from './bootstrap.mjs'; var hostsEl=document.getElementById("u-ctx-hosts"),attentionEl=document.getElementById("u-ctx-attention"); if(!policyEl||!summaryEl||!hostsEl||!attentionEl)return; var policy=context&&context.policy||{}; - function percent(key){return Number.isFinite(Number(policy[key]))?(Number(policy[key])/100).toFixed(0)+"%":"unknown";} + function percent(key){return policy[key]!==null&&policy[key]!==undefined&&Number.isFinite(Number(policy[key]))?(Number(policy[key])/100).toFixed(0)+"%":"unknown";} policyEl.innerHTML='startup target '+percent("startupTargetBps")+' · warning '+percent("startupWarningBps")+' · critical '+percent("startupCriticalBps")+'' +'dynamic warn '+percent("dynamicWarningBps")+' · compact '+percent("dynamicCompactBps")+' · handoff '+percent("dynamicHandoffBps")+'' +'reserve '+percent("reserveBps")+''; @@ -130,7 +145,7 @@ import { authHeaders, esc } from './bootstrap.mjs'; var openGroups=Object.create(null),open=attentionEl.querySelectorAll("details[data-context-group][open]"); for(var i=0;iNo session crossed a configured attention threshold in this window.'; + :'
    '+(coverage.pressureMeasured>0?'No session crossed a configured attention threshold in this window.':'No sessions have paired context measurements in this timeframe.')+'
    '; } function hookKpi(label,value,detail){ diff --git a/src/lib/dashboard/client/usage-orchestrators.mjs b/src/lib/dashboard/client/usage-orchestrators.mjs index 48cb3bc5..a9ca9951 100644 --- a/src/lib/dashboard/client/usage-orchestrators.mjs +++ b/src/lib/dashboard/client/usage-orchestrators.mjs @@ -30,8 +30,12 @@ import { MODEL_PAGE, USAGE, fmtNum, loadLimits, loadModelInventory, loadModelLif if(b)setUsageView(b.getAttribute("data-view")); }); if(seg)seg.addEventListener("keydown",function(e){if(!/^(ArrowLeft|ArrowRight|Home|End)$/.test(e.key))return;var i=USAGE_NAV_VIEWS.indexOf(usageView);if(i<0)i=USAGE_NAV_VIEWS.indexOf("sessions");i=e.key==="Home"?0:e.key==="End"?USAGE_NAV_VIEWS.length-1:(i+(e.key==="ArrowRight"?1:USAGE_NAV_VIEWS.length-1))%USAGE_NAV_VIEWS.length;setUsageView(USAGE_NAV_VIEWS[i]);var b=seg.querySelector('[data-view="'+USAGE_NAV_VIEWS[i]+'"]');if(b)b.focus();e.preventDefault();}); - var summary=document.getElementById("mli-summary"); - if(summary)summary.addEventListener("click",function(e){e.preventDefault();setTab("usage");setUsageView("models");}); + document.addEventListener("click",function(e){ + var link=e.target&&e.target.closest?e.target.closest("#mli-summary, [data-model-inventory]"):null; + if(!link)return; + e.preventDefault();setTab("usage");setUsageView("models"); + var tab=document.getElementById("usage-tab-models");if(tab)tab.focus(); + }); function resetModelPage(){ var region=document.querySelector(".mli-ledger .mli-table-wrap");if(region){region.scrollTop=0;region.scrollLeft=0;} loadModelInventory(0,false,false); diff --git a/src/lib/dashboard/client/usage.mjs b/src/lib/dashboard/client/usage.mjs index 4f13c7c0..67a0eb00 100644 --- a/src/lib/dashboard/client/usage.mjs +++ b/src/lib/dashboard/client/usage.mjs @@ -977,15 +977,16 @@ import { renderUsage } from './usage-orchestrators.mjs'; } function renderScoreProjects(d){ - var projects=entries(d.byProject), pMax=projects.length?projects[0].cost:0; - var shown=projects.slice(0,8); - document.getElementById("u-projects-note").textContent= - projects.length>8?("top 8 of "+projects.length):(projects.length+" project"+(projects.length===1?"":"s")); - document.getElementById("u-projects").innerHTML=shown.length?shown.map(function(pr){ - return bar(esc(pr.name),fmtUsd(pr.cost),fmtNum(fld(pr.v,"sessions"))+" sess · "+fmtMins(fld(pr.v,"minutes")), - pct(pr.cost,pMax),true); - }).join(""):'
    no projects in window.
    '; - + var target=document.getElementById('u-projects'),note=document.getElementById('u-projects-note'); + if(!Array.isArray(d.gitProjects)){ + note.textContent='';target.innerHTML='
    Refresh usage to identify Git projects.
    ';return; + } + var projects=d.gitProjects.slice().sort(function(a,b){return b.cost-a.cost||String(a.label).localeCompare(String(b.label));}); + var max=projects.length?projects[0].cost:0,shown=projects.slice(0,10); + note.textContent=projects.length>10?'top 10 of '+projects.length:projects.length+' project'+(projects.length===1?'':'s'); + target.innerHTML=shown.length?shown.map(function(project){ + return bar(esc(project.label),fmtUsd(project.cost),fmtNum(project.sessions)+' sess · '+fmtMins(project.minutes),pct(project.cost,max),true); + }).join(''):'
    No Git-project usage in this timeframe.
    '; } function renderScoreCategories(d){ diff --git a/src/lib/dashboard/context-card.mjs b/src/lib/dashboard/context-card.mjs new file mode 100644 index 00000000..ff465409 --- /dev/null +++ b/src/lib/dashboard/context-card.mjs @@ -0,0 +1,50 @@ +import { esc, rowLine } from './groups.mjs'; + +// Also injected into the browser bundle: one tested formatter for both paths. +export function contextCard(group) { + const report = group.rows.find(row => row.contextReport)?.contextReport; + const tokens = value => Number.isFinite(value) && value >= 0 ? value.toLocaleString('en-US') : '—'; + const controls = { + claude: ['Model & compaction', 'https://code.claude.com/docs/en/model-config'], + codex: ['Model & compaction', 'https://learn.chatgpt.com/docs/config-file/config-reference'], + opencode: ['Per-model limits', 'https://opencode.ai/docs/config'], + }; + const hostHtml = host => { + const models = host.models || []; + const control = controls[host.host]; + const requested = host.configuredRequest != null ? tokens(host.configuredRequest) + ' tokens requested' : null; + const summary = '

    ' + esc(host.label || host.host) + + (host.enabled === false ? ' (disabled)' : '') + '

    ' + + (host.managed ? 'Kit-managed' : control ? 'Native controls ↗' : 'Native') + '
    ' + + (requested ? '

    ' + requested + '

    ' : ''); + const codex = host.host === 'codex' && models.some(model => model.nativeWindow != null); + const columns = codex ? [['nativeWindow','Default'],['maximumWindow','Maximum'],['effectiveWindow','Usable¹']] + : [['capacityWindow','Context'],['inputLimit','Input'],['outputLimit','Output']].filter(([field]) => models.some(model => model[field] != null)); + const header = columns.map(([,label]) => '' + label + '').join(''); + const cells = model => columns.map(([field]) => model[field]); + const freshnessNote = models.some(model => model.freshness === 'stale') ? ' · stale' + : models.some(model => model.freshness === 'unknown') ? ' · freshness unverified' : ''; + const table = models.length ? '
    ' + models.length + (host.modelsOmitted ? ' of ' + (models.length + host.modelsOmitted) : '') + ' cached model limit' + (models.length === 1 ? '' : 's') + freshnessNote + '' + + '
    ' + + '' + header + '' + + models.map(model => '' + cells(model).map(value => '').join('') + '').join('') + + '
    Model
    ' + esc(model.provider ? model.provider + '/' + model.model : model.model) + + '' + tokens(value) + '
    ' + (codex ? '

    ¹ Calculated configuration; session application unverified.

    ' : '') + '
    ' : ''; + const threshold = host.compaction?.configuredThreshold; + const compact = threshold != null ? '

    Configured compaction: ' + tokens(threshold) + ' tokens

    ' : ''; + const freshness = codex ? host.cacheFetchedAt : host.inventoryCapturedAt; + const basis = models.length && freshness ? '

    Cache

    ' : ''; + return summary + compact + table.replace('', basis + '') + '
    '; + }; + const warnings = group.rows.filter(row => row.level === 'warn' || row.level === 'fail'); + const content = report ? (report.hosts || []).map(hostHtml).join('') + + 'Open model inventory →' + + (warnings.length ? '
      ' + warnings.map(rowLine).join('') + '
    ' : '') + : '
      ' + group.rows.map(rowLine).join('') + '
    '; + return '
    ' + + 'Context configuration
    ' + + content + '
    '; +} diff --git a/src/lib/dashboard/groups.mjs b/src/lib/dashboard/groups.mjs index d9a7f8ae..1c87893a 100644 --- a/src/lib/dashboard/groups.mjs +++ b/src/lib/dashboard/groups.mjs @@ -1,3 +1,4 @@ +import { contextCard } from './context-card.mjs'; // Pure status-row classification, grouping, and card/notice HTML for the // dashboard. This module is the ONE source of truth for both consumers: // - node unit tests import it directly (deterministic, no DOM, no browser); @@ -58,7 +59,7 @@ export const PREF = ['versions', 'self', 'natives', 'security', 'learning', 'mem export function groupRows(rows) { const map = {}; const seq = []; for (let i = 0; i < rows.length; i++) { - const r = rows[i]; const k = r.subsystem || 'other'; + const r = rows[i]; const k = ['codex-context', 'codex-context/model', 'context'].includes(r.subsystem) ? 'context' : r.subsystem || 'other'; if (!map[k]) { map[k] = { subsystem: k, rows: [], level: 'info' }; seq.push(k); } map[k].rows.push(r); if ((RANK[r.level] || 0) > (RANK[map[k].level] || 0)) map[k].level = r.level; @@ -84,6 +85,7 @@ export function rowLine(r) { /** One subsystem group as a card: level dot + name + rows. */ export function groupCard(g) { + if (g.subsystem === 'context') return contextCard(g); const lvl = g.level || 'info'; const calm = (lvl === 'ok' || lvl === 'info'); const count = g.rows.length > 1 ? ('' + g.rows.length + '') : ''; const badge = calm ? '' : ('' + esc(lvl) + ''); diff --git a/src/lib/dashboard/intel-history.mjs b/src/lib/dashboard/intel-history.mjs index 6e7aa416..61afb664 100644 --- a/src/lib/dashboard/intel-history.mjs +++ b/src/lib/dashboard/intel-history.mjs @@ -192,12 +192,13 @@ export function readIntelHistory(cwd) { * "never adapted", matching readGlobalStats' own `?? 0` default), or null * when no project has adaptation data. This is a plain on-demand scan with * no caching/TTL of its own — a later caller adds that at the server layer. - * @param {Array<{ path: string, label: string, learningState?: string[] }>} projects + * @param {Array<{ path: string, label: string, learningState?: string[], key?: string, + * identityKey?: string, learningScope?: string }>} projects * @returns {{ * totals: { patternsLearnedLifetime: number, patternStoreEntries: number, * trajectoriesRecorded: number, projectCount: number, * mostActiveProject: string|null }, - * perProject: Array<{ path: string, label: string, + * perProject: Array<{ path: string, label: string, key: string|null, learningScope: string, * patternsLearned: number|null, patternStoreCount: number, * trajectoriesRecorded: number|null, * graphLatest: { nodes: number, edges: number }|null, @@ -253,6 +254,8 @@ export function readMachineWideIntel(projects) { perProject.push({ path: cwd, label, + key: entry?.key ?? entry?.identityKey ?? null, + learningScope: ['repository', 'worktree', 'user'].includes(entry?.learningScope) ? entry.learningScope : 'unknown', patternsLearned, patternStoreCount: patternStore.length, trajectoriesRecorded: trajectories, diff --git a/src/lib/dashboard/maintenance-api.mjs b/src/lib/dashboard/maintenance-api.mjs index 41ae525c..8d95e506 100644 --- a/src/lib/dashboard/maintenance-api.mjs +++ b/src/lib/dashboard/maintenance-api.mjs @@ -449,6 +449,12 @@ function projectNode(node, value) { } const [ID, LABEL, STAMP] = [T.text(128), T.text(200), T.text(40)]; +const PROJECT_PRESENTATION = { + repositoryId: ID, repositoryLabel: LABEL, repositoryEvidence: T.oneOf(['git-directory', 'git-pointer', 'git-common-directory-and-backlink', 'project-discovery']), + repositoryObservedAt: T.int, + sessionOrigins: T.list(T.obj({ origin: T.oneOf(['claude-desktop', 'codex-desktop', 'unknown']), sessions: T.int, + countBasis: T.oneOf(['transcript-files', 'database-sessions', 'recovered-project-sighting', 'mixed-observations']) }), 3), +}; const PROVIDER = T.obj({ id: T.text(80), version: T.text(40) }); const COVERAGE = T.obj({ sourceId: ID, environmentId: ID, state: T.oneOf(SOURCE_COVERAGE_STATES), label: LABEL, visited: T.int, estimated: T.int, @@ -456,6 +462,7 @@ const COVERAGE = T.obj({ lastCompletedAt: STAMP, filesystem: T.bool, }); const ROW = T.obj({ + ...PROJECT_PRESENTATION, installationSource: LABEL, description: T.text(1024), placementId: ID, projectId: ID, projectKind: T.oneOf(['git','folder','worktree','unknown']), displayName: LABEL, kind: T.oneOf(RESOURCE_KINDS), scope: T.obj({ value: T.oneOf(SCOPE_LENSES), label: LABEL, icon: T.text(32) }), @@ -480,7 +487,7 @@ const PARTIAL_SOURCES = T.either(T.list(COVERAGE, 100), T.obj({ })); const INVENTORY_PAGE = T.obj({ navigation: T.obj({ level: T.oneOf(['scope', 'project', 'kind', 'resource', 'installation']), - nodes: T.list(T.obj({ value: T.token(128), label: LABEL, installationSource: LABEL, description: T.text(1024), descriptionSource: LABEL, languages: T.list(T.obj({ id: T.token(64), name: LABEL, icon: T.text(8), evidence: T.oneOf(['source','artifact']) }), 100), count: T.int, kind: T.oneOf(RESOURCE_KINDS), projectKind: T.oneOf(['git', 'folder', 'worktree', 'unknown']) }), MAX_PAGE_ROWS), + nodes: T.list(T.obj({ ...PROJECT_PRESENTATION, value: T.token(128), label: LABEL, installationSource: LABEL, description: T.text(1024), descriptionSource: LABEL, languages: T.list(T.obj({ id: T.token(64), name: LABEL, icon: T.text(8), evidence: T.oneOf(['source','artifact']) }), 100), count: T.int, kind: T.oneOf(RESOURCE_KINDS), projectKind: T.oneOf(['git', 'folder', 'worktree', 'unknown']) }), MAX_PAGE_ROWS), }), scanRequired: T.bool, lastRefresh: LAST_REFRESH, schema: T.text(80), inventoryId: ID, total: T.int, groups: T.list(GROUP, MAX_PAGE_ROWS), facetCounts: T.dict(T.dict(T.int, 500), 16), nextCursor: T.text(512), diff --git a/src/lib/dashboard/page.mjs b/src/lib/dashboard/page.mjs index 7dfc5a6c..9eafd604 100644 --- a/src/lib/dashboard/page.mjs +++ b/src/lib/dashboard/page.mjs @@ -8,9 +8,16 @@ import { LIVE_CSS, LIVE_HTML, LIVE_JS } from './live-view.mjs'; // --ink*, --r-sm, --accent) so the new rows/picker match the Apple system // motif everywhere else, without duplicating any of styles.mjs's own rules. const INTEL_CSS = ` -.mw-table{display:flex; flex-direction:column; gap:1px; background:var(--line); border:1px solid var(--line); border-radius:var(--r-sm); overflow:hidden; margin-top:14px} +.mw-table{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;max-height:520px;overflow:auto;align-items:start;margin-top:14px;padding:2px} +.mw-group{min-width:0;border:1px solid var(--line);border-radius:var(--r-sm);overflow:hidden} +.mw-group h3{display:flex;justify-content:space-between;gap:8px;margin:0;padding:10px 14px;background:var(--panel);font-size:12px;font-weight:600;color:var(--ink)} +.mw-group h3 span{color:var(--ink-dim);font-weight:400} +.mw-group-scroll{max-height:212px;overflow:auto;overscroll-behavior:contain} +.mw-table:focus-visible,.mw-group-scroll:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} .mw-row{display:grid; grid-template-columns:minmax(140px,1.6fr) repeat(3,minmax(96px,1fr)); gap:10px; align-items:center; padding:8px 14px; background:var(--panel); font-size:12.5px} -.mw-row.mw-head{background:var(--panel-2); color:var(--ink-dim); font-size:10.5px; font-weight:600; text-transform:uppercase; letter-spacing:.06em} +.mw-data-row{height:34px;box-sizing:border-box;border-top:1px solid var(--line)} +.mw-data-row .mw-val{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.mw-row.mw-head{height:42px;box-sizing:border-box;position:sticky;top:0;z-index:1;background:var(--panel-2); color:var(--ink-dim); font-size:10.5px; font-weight:600; text-transform:uppercase; letter-spacing:.06em;line-height:12px} .mw-row:not(.mw-head):hover{background:var(--panel-2)} .mw-name{color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap} /* Which learning stores a project carries — three tiny dots, one per store, @@ -23,20 +30,22 @@ const INTEL_CSS = ` .mw-store[data-store="swarm"]{background:var(--purple)} .mw-val{color:var(--ink-2); text-align:right} .mw-row.mw-head .mw-val{color:var(--ink-dim)} -@media(max-width:560px){.mw-row{grid-template-columns:1fr repeat(3,minmax(60px,1fr)); gap:6px}} -.mw-picker{display:flex; align-items:center; gap:9px; flex-wrap:wrap} +@media(max-width:1100px){.mw-table{grid-template-columns:minmax(0,1fr)}} +@media(max-width:560px){.mw-row{grid-template-columns:minmax(0,1.4fr) repeat(3,minmax(0,1fr));gap:6px;padding-left:9px;padding-right:9px;font-size:11px}.mw-row.mw-head{font-size:9px;letter-spacing:0}.mw-group h3{padding-left:9px;padding-right:9px}} +.mw-picker{display:flex; align-items:center; gap:9px; flex-wrap:wrap; min-width:0; max-width:100%} .mw-picker label{color:var(--ink-dim); font-size:11.5px} .mw-picker select{ background:var(--panel-2); border:1px solid var(--line); color:var(--ink); font-family:inherit; font-size:12.5px; padding:6px 12px; border-radius:100px; - cursor:pointer; max-width:100%; + cursor:pointer; min-width:0; max-width:100%; } .mw-picker select:focus-visible{outline:2px solid var(--accent); outline-offset:1px} .mw-picker select:disabled{opacity:.5; cursor:not-allowed} /* The picker moved INTO this strip's head (it is a control on "learning over time", not a panel of its own), so the head's baseline alignment has to give way to centre alignment or the select sits low against the heading. */ -#history .strip-head{align-items:center} +#history .strip-head{align-items:center; flex-wrap:wrap} +#history .strip-title{min-width:0; overflow-wrap:anywhere} #history #strip-note{margin:0 0 14px} /* The census explainer: how this panel's project count relates to the counts the other tabs show. Sits under the hero because it explains the number in @@ -382,7 +391,7 @@ export function renderPage({ name, version }) { how these projects were counted
    -
    +
    diff --git a/src/lib/dashboard/project-groups.mjs b/src/lib/dashboard/project-groups.mjs new file mode 100644 index 00000000..6034abba --- /dev/null +++ b/src/lib/dashboard/project-groups.mjs @@ -0,0 +1,17 @@ +// Pure view projection; repository membership is supplied by the collector, +// never inferred from a display label or remote. Injected unchanged in browser. +export function projectView(payload, population = 'measured', origin = 'all') { + const byPath = new Map(); + if (population === 'all') for (const row of payload.discoveryProjects || []) byPath.set(row.path || row, row); + for (const row of payload.projects || []) byPath.set(row.path || row, {...byPath.get(row.path || row), ...row}); + const groups = new Map(); + for (const row of byPath.values()) { + const origins = row.sessionOrigins?.length ? row.sessionOrigins : [{origin:'unknown'}]; + if (origin !== 'all' && !origins.some(item => item.origin === origin)) continue; + const repo = row.repository; + const key = repo?.repositoryId || (repo?.kind === 'folder' ? 'folders' : 'unknown'); + if (!groups.has(key)) groups.set(key, {key, label:repo?.root || repo?.commonDir || (key === 'folders' ? 'Other folders' : 'Repository not established'), rows:[]}); + groups.get(key).rows.push(row); + } + return [...groups.values()]; +} diff --git a/src/lib/dashboard/styles/maintenance.mjs b/src/lib/dashboard/styles/maintenance.mjs index 9e18d0e5..4c5e1c69 100644 --- a/src/lib/dashboard/styles/maintenance.mjs +++ b/src/lib/dashboard/styles/maintenance.mjs @@ -270,7 +270,6 @@ export const MAINTENANCE_CSS = ` .mnt-language-list{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px} .mnt-language-badge{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:#fff;flex:none} .mnt-language-icon{display:block;width:24px;height:24px;object-fit:contain;flex:none} -.mnt-language-more{padding:8px 16px;font-size:12px} .mnt-resource-description{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} .mnt-row-context{font-size:11.5px;line-height:1.5;color:var(--ink-2);overflow-wrap:anywhere} .mnt-row-action{margin-left:auto;display:flex;flex:none} @@ -370,6 +369,9 @@ body:has(#panel-sys-maintenance:not([hidden])) #sys-rescan{display:none} .mnt-focus-list{display:grid;gap:8px;list-style:none;margin:0;padding:0} .mnt-focus-list>.mnt-row,.mnt-focus-list>li>.mnt-row{border:1px solid var(--line);border-radius:10px;min-height:68px;padding:14px 16px} .mnt-focus-list .mnt-row-name{overflow-wrap:anywhere} +.mnt-project-title{display:flex;align-items:center;gap:7px} +.mnt-project-title>.mnt-icon{width:16px;height:16px;flex:none} +.mnt-focus-node .mnt-project-kind{display:flex;margin:5px 0 0} .mnt-node-count{font-size:11px;color:var(--ink-2);margin-left:auto;white-space:nowrap} .mnt-main>.mnt-inspector:not([hidden]){position:static;inset:auto;max-height:none;margin-top:20px;border-radius:12px;padding:20px;overflow:visible} .mnt-relationships{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:10px;margin-top:12px} @@ -380,4 +382,8 @@ body:has(#panel-sys-maintenance:not([hidden])) #sys-rescan{display:none} .mnt-relationship-link{border:0;background:none;color:var(--accent);font:inherit;text-align:left;padding:7px 0;cursor:pointer;overflow-wrap:anywhere} .mnt-relationship-link span{display:block;color:var(--ink-2);font-size:11px} .mnt-related-context{border-left:2px solid var(--accent);padding-left:10px} + +.mnt-repository-group { margin:0 0 18px; } +.mnt-repository-group h3 { font-size:13px; color:var(--ink-2); margin:12px 0 8px; overflow-wrap:anywhere; } +.mnt-project-origins { display:block; font-size:11px; color:var(--ink-2); margin:3px 0; } `; diff --git a/src/lib/dashboard/styles/system.mjs b/src/lib/dashboard/styles/system.mjs index 487cf607..8c1577b3 100644 --- a/src/lib/dashboard/styles/system.mjs +++ b/src/lib/dashboard/styles/system.mjs @@ -387,4 +387,25 @@ export const SYSTEM_CSS = ` *{animation:none !important; transition:none !important} .card{opacity:1; transform:none} } + +.context-card { align-self:start; min-width:0; } +.context-host { padding:9px 0; border-bottom:1px solid var(--line); } +.context-host h3 { margin:0 0 4px; font-size:13px; } +.context-host p,.context-notes { font-size:11px; line-height:1.5; margin:4px 0; } +.context-basis { color:var(--ink-2); overflow-wrap:anywhere; } +.context-card summary { cursor:pointer; font-size:12px; padding:4px 0; } +.context-model-scroll { overflow:auto; max-height:220px; } +.context-model-scroll table { border-collapse:collapse; font-size:11px; width:100%; } +.context-model-scroll th,.context-model-scroll td { padding:5px; text-align:right; white-space:nowrap; } +.context-model-scroll th:first-child { text-align:left; } +.context-notes ul { padding-left:18px; } + +.project-controls { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:12px; } +.project-controls label { font-size:12px; display:flex; align-items:center; gap:6px; } +.project-controls select { max-width:100%; background:var(--bg); color:var(--ink); border:1px solid var(--line); border-radius:5px; padding:6px; } +.project-identity,.project-path { display:block; font-size:11px; color:var(--ink-2); overflow-wrap:anywhere; white-space:normal; } + +.context-host-heading { display:flex; align-items:baseline; justify-content:space-between; gap:8px; } +.context-host-heading span { font-size:11px; color:var(--ink-2); } +.context-control { display:inline-block; color:var(--accent); font-size:11px; margin:2px 0; } `; diff --git a/src/lib/dashboard/styles/usage.mjs b/src/lib/dashboard/styles/usage.mjs index bc3522bd..d5ede66e 100644 --- a/src/lib/dashboard/styles/usage.mjs +++ b/src/lib/dashboard/styles/usage.mjs @@ -777,19 +777,19 @@ export const USAGE_CSS = ` .ctx-grid{display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:11px; margin-bottom:12px} .ctx-card{border:1px solid var(--line); border-radius:var(--r-sm); padding:13px 14px; background:var(--panel)} .ctx-card[data-state="partial"]{border-color:color-mix(in srgb,var(--warn) 45%,var(--line))} -.ctx-card[data-state="not-recorded"],.ctx-card[data-state="not-observed"]{opacity:.78} + .ctx-card-head{display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:12px} .ctx-card-head h2{margin:0; font-size:14px; text-transform:capitalize} -.ctx-state{font-size:10px; color:var(--ink-dim); border:1px solid var(--line); border-radius:999px; padding:2px 7px} +.ctx-state{font-size:10px; color:var(--ink-2); border:1px solid var(--line); border-radius:999px; padding:2px 7px} .ctx-meter{display:grid; grid-template-columns:1fr auto; gap:9px; align-items:center} .ctx-meter-track{height:9px; overflow:hidden; border-radius:999px; background:var(--panel-2)} .ctx-meter-track i{display:block; height:100%; border-radius:inherit; background:linear-gradient(90deg,var(--ok),var(--warn),var(--fail))} .ctx-meter-value{min-width:52px; text-align:right; color:var(--ink-2); font-size:11px} .ctx-facts{display:grid; grid-template-columns:1fr 1fr; gap:7px 11px; margin:12px 0 0} .ctx-facts div{min-width:0} -.ctx-facts dt{font-size:9px; color:var(--ink-dim); text-transform:uppercase; letter-spacing:.05em} +.ctx-facts dt{font-size:9px; color:var(--ink-2); text-transform:uppercase; letter-spacing:.05em} .ctx-facts dd{margin:2px 0 0; font-family:var(--mono); font-size:11.5px; color:var(--ink)} -.ctx-caveat{margin:11px 0 0; padding-top:9px; border-top:1px solid var(--line); color:var(--ink-dim); font-size:10.5px; line-height:1.45} +.ctx-caveat{margin:11px 0 0; padding-top:9px; border-top:1px solid var(--line); color:var(--ink-2); font-size:10.5px; line-height:1.45} .ctx-attention{max-height:420px; overflow:auto; scrollbar-width:thin} .ctx-att-group{border-top:1px solid var(--line)} .ctx-att-group:last-child{border-bottom:1px solid var(--line)} @@ -871,4 +871,6 @@ export const USAGE_CSS = ` .hook-finding-placement-wrap{padding-left:0} } + +.ctx-no-pressure{color:var(--ink-2);font-size:12px;margin:15px 0;min-height:18px} `; diff --git a/src/lib/footprint/project-identity.mjs b/src/lib/footprint/project-identity.mjs new file mode 100644 index 00000000..ce5f8951 --- /dev/null +++ b/src/lib/footprint/project-identity.mjs @@ -0,0 +1,71 @@ +// Additive grouping evidence. Working paths remain the discovery/count identity; +// a shared Git directory relates them without merging their measurements. +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; + +function canonical(file, fsImpl) { + return (fsImpl.realpathSync.native ?? fsImpl.realpathSync)(file); +} + +function boundedText(file, fsImpl) { + const stat = fsImpl.lstatSync(file); + if (!stat.isFile() || stat.size > 4096) throw new Error('unreadable-git-metadata'); + return fsImpl.readFileSync(file, 'utf8').trim(); +} + +function directory(file, fsImpl) { + if (!fsImpl.statSync(file).isDirectory()) throw new Error('invalid-git-directory'); + return canonical(file, fsImpl); +} + +function linkedIdentity(marker, current, fsImpl) { + const match = /^gitdir:\s*([^\r\n]+)\s*$/u.exec(boundedText(marker, fsImpl)); + if (!match) throw new Error('invalid-git-pointer'); + const gitDir = directory(path.resolve(current, match[1]), fsImpl); + let common; + try { common = boundedText(path.join(gitDir, 'commondir'), fsImpl); } + catch (error) { if (error?.code !== 'ENOENT') throw error; } + if (common === undefined) return { commonDir: gitDir, kind: 'git', evidence: 'git-pointer', root: current }; + if (!common) throw new Error('empty-git-common-directory'); + const commonDir = directory(path.resolve(gitDir, common), fsImpl); + const backlink = boundedText(path.join(gitDir, 'gitdir'), fsImpl); + if (canonical(path.resolve(gitDir, backlink), fsImpl) !== canonical(marker, fsImpl)) { + throw new Error('git-backlink-mismatch'); + } + // Bare repositories group by commonDir without inventing a main checkout. + const candidate = path.basename(commonDir) === '.git' ? path.dirname(commonDir) : null; + const root = candidate && directory(path.join(candidate, '.git'), fsImpl) === commonDir ? candidate : null; + return { commonDir, kind: 'worktree', evidence: 'git-common-directory-and-backlink', root }; +} + +/** No retained-path/name/remote inference. Unknown means association unverified. */ +export function inspectProjectIdentity(cwd, { fsImpl = fs, observedAt = Date.now() } = {}) { + const base = { kind: 'unknown', repositoryId: null, commonDir: null, root: null, + worktreeRoot: null, evidence: 'unavailable', observedAt }; + if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return base; + let current; + try { current = directory(cwd, fsImpl); } catch { return base; } + for (let depth = 0; depth < 128; depth++) { + const marker = path.join(current, '.git'); + let stat; + try { stat = fsImpl.lstatSync(marker); } + catch (error) { + if (error?.code !== 'ENOENT') return { ...base, evidence: 'git-metadata-unreadable' }; + const parent = path.dirname(current); + if (parent === current) return { ...base, kind: 'folder', evidence: 'no-git-boundary' }; + current = parent; + continue; + } + try { + let identity; + if (stat.isDirectory()) identity = { commonDir: directory(marker, fsImpl), kind: 'git', evidence: 'git-directory', root: current }; + else if (stat.isFile()) identity = linkedIdentity(marker, current, fsImpl); + else throw new Error('invalid-git-marker'); + const { commonDir, kind, evidence, root } = identity; + const repositoryId = `repository:${createHash('sha256').update(commonDir).digest('hex').slice(0, 20)}`; + return { kind, repositoryId, commonDir, root, worktreeRoot: current, evidence, observedAt }; + } catch { return { ...base, evidence: 'git-metadata-unverified' }; } + } + return { ...base, evidence: 'ancestor-budget-exhausted' }; +} diff --git a/src/lib/footprint/project-sources.mjs b/src/lib/footprint/project-sources.mjs index d351f63c..53634da9 100644 --- a/src/lib/footprint/project-sources.mjs +++ b/src/lib/footprint/project-sources.mjs @@ -17,10 +17,10 @@ // ones a byte/LOC measurement can be taken of at all. // // Content boundary. This is DISCOVERY, invariant 9's candidate-path source, not -// a measurement: it reads ONE field out of a transcript — the session's `cwd` — -// and nothing else. The same read `native-transcript-discovery.mjs` already +// a measurement: it reads a session's cwd and explicit launch-origin declaration +// from the bounded head. The same read `native-transcript-discovery.mjs` already // performs for Observability at the same trust boundary. No message, prompt or -// tool payload is parsed, retained or emitted; every figure the System area +// tool payload is retained or emitted; every figure the System area // renders is measured downstream by walk.mjs-backed collectors from the paths // this module returns. // @@ -37,6 +37,8 @@ import { resolveProjectLabel } from '../live/index.mjs'; import { withDb } from '../sqlite.mjs'; import { defaultOpencodeDbPath } from '../usage-opencode.mjs'; import { presenceOf, statNode, UNKNOWN, walkTree } from './walk.mjs'; +import { inspectProjectIdentity } from './project-identity.mjs'; +import { transcriptSessionOrigin } from './session-origin.mjs'; /** Hosts in the order every payload lists them. */ export const PROJECT_SOURCE_HOSTS = Object.freeze(['claude', 'codex', 'opencode']); @@ -299,7 +301,7 @@ export function scanTranscriptCwds(root, host, { const cwd = firstCwd(lines, host); if (!cwd) { withoutCwd += 1; continue; } withCwd += 1; - sightings.push({ cwd, mtimeMs, origin: 'cwd' }); + sightings.push({ cwd, mtimeMs, origin: 'cwd', sessionOrigin: transcriptSessionOrigin(lines, host) }); if (group) group.withCwd = true; } @@ -456,12 +458,25 @@ export function discoverProjectSources({ const resolved = resolvePath(cwd, fsImpl); let row = byPath.get(resolved); if (!row) { - row = { path: resolved, hosts: new Set(), origins: new Set(), sessions: 0, lastSeenMs: null }; + row = { path: resolved, hosts: new Set(), origins: new Set(), sessionOrigins: new Map(), sessions: 0, lastSeenMs: null }; byPath.set(resolved, row); } row.hosts.add(host); row.origins.add(sighting.origin ?? 'cwd'); - row.sessions += Number.isFinite(sighting.weight) ? sighting.weight : 1; + const weight = Number.isFinite(sighting.weight) ? sighting.weight : 1; + row.sessions += weight; + const declared = sighting.sessionOrigin; + const origin = ['claude-desktop', 'codex-desktop'].includes(declared?.origin) + && declared.origin.startsWith(`${host}-`) ? declared.origin : 'unknown'; + let membership = row.sessionOrigins.get(origin); + if (!membership) { + membership = { origin, sessions: 0, evidence: new Set(), countBases: new Set() }; + row.sessionOrigins.set(origin, membership); + } + membership.sessions += weight; + membership.countBases.add(sighting.origin === 'encoded-dir' ? 'recovered-project-sighting' + : host === 'opencode' ? 'database-sessions' : 'transcript-files'); + membership.evidence.add(origin === 'unknown' ? 'desktop-origin-not-declared' : declared.evidence); const at = sighting.mtimeMs; if (Number.isFinite(at) && (row.lastSeenMs === null || at > row.lastSeenMs)) row.lastSeenMs = at; } @@ -482,6 +497,11 @@ export function discoverProjectSources({ isGitRepo: exists && gitPresence(row.path, fsImpl), lastSeenMs: row.lastSeenMs, sessions: row.sessions, + sessionOrigins: [...row.sessionOrigins.values()].map((entry) => ({ + origin: entry.origin, sessions: entry.sessions, evidence: [...entry.evidence].filter(Boolean).sort(), + countBasis: entry.countBases.size === 1 ? [...entry.countBases][0] : 'mixed-observations', + })).sort((a, b) => a.origin.localeCompare(b.origin)), + repository: inspectProjectIdentity(row.path, { fsImpl, observedAt: asOf }), }; }); // Most-recently-seen first; a project with no usable timestamp sorts last but diff --git a/src/lib/footprint/projects.mjs b/src/lib/footprint/projects.mjs index 4deb0460..1a30578f 100644 --- a/src/lib/footprint/projects.mjs +++ b/src/lib/footprint/projects.mjs @@ -415,6 +415,8 @@ function missingProject(project, reason, presence = 'absent') { projectKind: 'unknown', label: project.label, source: project.source ?? null, + repository: project.repository ?? null, + sessionOrigins: project.sessionOrigins ?? null, hosts: Array.isArray(project.hosts) ? [...project.hosts] : null, remote: { status: 'unknown', name: null, raw: null, hostname: null, host: null, slug: null, webUrl: null, reason }, loc: locNotMeasured(reason), @@ -446,7 +448,8 @@ function notify(onProgress, payload) { * the only thing discovery contributes (invariant 9) — `hosts` rides along as * attribution (which hosts saw this project), never as a measurement. * - * @param {{ path: string, label: string, source?: string, hosts?: string[], remote?: object }} project + * @param {{ path: string, label: string, source?: string, hosts?: string[], remote?: object, + * repository?: object, sessionOrigins?: Array<{origin:string,sessions:number,evidence?:string[]}> }} project * @param {{ walk?: Function, limits?: object, detect?: Function, loc?: boolean, * asOf?: number|null, fsImpl?: typeof fs }} [options] * `loc: false` skips the stack pass entirely — the expensive part of a project @@ -562,6 +565,8 @@ export function measureProject(project, { projectKind: measureProjectKind(root, { fsImpl }), label: project.label, source: project.source ?? null, + repository: project.repository ?? null, + sessionOrigins: project.sessionOrigins ?? null, hosts: Array.isArray(project.hosts) ? [...project.hosts] : null, // collectProjects preflights the remote to choose the stated hosted-repo // population. Reuse that exact evidence instead of opening .git/config a @@ -692,7 +697,7 @@ function aggregateUnrecognized(rows) { */ function resolveProjectCatalog({ projects, sources, discover, fsImpl }) { if (Array.isArray(projects)) { - return { catalog: projects, counts: summarizeCatalog(projects, fsImpl), discoveryReason: null }; + return { catalog: projects, discoveryProjects: projects, counts: summarizeCatalog(projects, fsImpl), discoveryReason: null }; } try { const payload = (isSourcesPayload(projects) ? projects : sources) ?? discover({ fsImpl }); @@ -701,6 +706,7 @@ function resolveProjectCatalog({ projects, sources, discover, fsImpl }) { const catalog = (payload?.projects ?? []).filter((project) => project?.exists); return { catalog, + discoveryProjects: payload?.projects ?? [], counts: { everSeen: payload?.everSeen ?? 0, onDisk: payload?.onDisk ?? 0, @@ -713,7 +719,7 @@ function resolveProjectCatalog({ projects, sources, discover, fsImpl }) { discoveryReason: null, }; } catch (error) { - return { catalog: [], counts: null, discoveryReason: error?.code ?? 'discovery failed' }; + return { catalog: [], discoveryProjects: [], counts: null, discoveryReason: error?.code ?? 'discovery failed' }; } } @@ -771,7 +777,7 @@ function measureSelectedProjects(selected, { walk, limits, detect, loc, asOf, fs } /** Assemble the ProjectFootprint section from a completed measurement pass. */ -function buildProjectsSection({ asOf, out, eligible, selected, excluded, counts, discoveryReason, loc }) { +function buildProjectsSection({ asOf, out, eligible, selected, excluded, counts, discoveryProjects, discoveryReason, loc }) { // A count whose sweep hit an unreadable transcript or an unrecoverable project // directory is a FLOOR, not a total — `partial` is what makes a surface render // it as "≥ N" instead of quietly overstating certainty. @@ -781,6 +787,9 @@ function buildProjectsSection({ asOf, out, eligible, selected, excluded, counts, return { asOf, projects: out, + // Discovery-only rows carry no byte/LOC measurements. This preserves the + // measured population while exposing folders, missing paths and worktrees. + discoveryProjects, // Retained under its original name for existing consumers; it has always // meant "how many projects discovery found", which is now everSeen. count: kpi(counts?.everSeen ?? 0), @@ -848,7 +857,7 @@ export function collectProjects({ fsImpl = fs, } = {}) { const asOf = now(); - const { catalog, counts, discoveryReason } = resolveProjectCatalog({ projects, sources, discover, fsImpl }); + const { catalog, counts, discoveryProjects, discoveryReason } = resolveProjectCatalog({ projects, sources, discover, fsImpl }); const rows = Array.isArray(catalog) ? catalog : []; const population = selectHostedPopulation(rows, fsImpl); const selected = typeof limit === 'number' && limit >= 0 @@ -856,6 +865,6 @@ export function collectProjects({ const out = measureSelectedProjects(selected, { walk, limits, detect, loc, asOf, fsImpl, onProgress }); return buildProjectsSection({ asOf, out, eligible: population.eligible, selected, excluded: population.excluded, - counts, discoveryReason, loc, + counts, discoveryProjects, discoveryReason, loc, }); } diff --git a/src/lib/footprint/session-origin.mjs b/src/lib/footprint/session-origin.mjs new file mode 100644 index 00000000..064a9353 --- /dev/null +++ b/src/lib/footprint/session-origin.mjs @@ -0,0 +1,25 @@ +// Explicit host declarations only. The same Desktop origin can belong to any +// Git repository/worktree/folder. SDK, app-server and vscode are ambiguous. +const CLAUDE_DESKTOP = new Set(['claude-desktop', 'claude-desktop-3p', 'remote_desktop']); +const CODEX_DESKTOP = new Set(['Codex Desktop', 'codex_work_desktop']); + +/** Classify one bounded transcript head; never retain arbitrary metadata. */ +export function transcriptSessionOrigin(lines, host) { + for (const line of lines ?? []) { + let record; + try { record = JSON.parse(line); } catch { continue; } + if (!record || typeof record !== 'object') continue; + if (host === 'codex' && record.type === 'session_meta') { + const value = record.payload?.originator; + return CODEX_DESKTOP.has(value) + ? { origin: 'codex-desktop', evidence: `session_meta.originator:${value}` } + : { origin: 'unknown', evidence: 'desktop-origin-not-declared' }; + } + if (host === 'claude' && typeof record.entrypoint === 'string') { + return CLAUDE_DESKTOP.has(record.entrypoint) + ? { origin: 'claude-desktop', evidence: `entrypoint:${record.entrypoint}` } + : { origin: 'unknown', evidence: 'desktop-origin-not-declared' }; + } + } + return { origin: 'unknown', evidence: 'desktop-origin-not-declared' }; +} diff --git a/src/lib/maintenance/management/focus-navigation.mjs b/src/lib/maintenance/management/focus-navigation.mjs index 9940ce89..5197f615 100644 --- a/src/lib/maintenance/management/focus-navigation.mjs +++ b/src/lib/maintenance/management/focus-navigation.mjs @@ -11,6 +11,13 @@ function navigationLevel(scope, facets) { : facets.kind?.length !== 1 ? 'kind' : 'resource'; } +function projectDescriptor(placement, kind) { + return { projectKind: kind ?? 'unknown', languages: placement.projectLanguages ?? [], + repositoryId: placement.repositoryId ?? null, repositoryLabel: placement.repositoryLabel ?? null, + repositoryEvidence: placement.repositoryEvidence ?? null, repositoryObservedAt: placement.repositoryObservedAt ?? null, + sessionOrigins: placement.sessionOrigins ?? [] }; +} + function descriptor(level, placement, resource, projectLabels, projectKinds) { const value = level === 'scope' ? placement.administrativeScope : level === 'project' ? placement.projectId @@ -21,7 +28,7 @@ function descriptor(level, placement, resource, projectLabels, projectKinds) { : level === 'kind' ? RESOURCE_KIND_LABELS[value] : resource?.capabilityLabel ?? resource?.displayName ?? placement.displayName; return { value, label, count: 0, ...(level === 'resource' ? { kind: placement.kind, installationSource: resource?.installationSource ?? null, description: Object.hasOwn(placement, 'description') ? placement.description : resource?.description ?? null, descriptionSource: resource?.descriptionSource ?? null } : {}), - ...(level === 'project' ? { projectKind: projectKinds[value] ?? 'unknown', languages: placement.projectLanguages ?? [] } : {}), + ...(level === 'project' ? projectDescriptor(placement, projectKinds[value]) : {}), }; } diff --git a/src/lib/maintenance/management/model.mjs b/src/lib/maintenance/management/model.mjs index 28547449..e8b15590 100644 --- a/src/lib/maintenance/management/model.mjs +++ b/src/lib/maintenance/management/model.mjs @@ -185,7 +185,7 @@ export const CURATED_VIEW_LABELS = Object.freeze({ 'evidence-only': 'Inventory evidence only', }); export const FACETS = Object.freeze([ - 'scope', 'environment', 'project', 'projectType', 'family', 'kind', 'adapter', 'consumer', 'carrier', 'provenance', + 'scope', 'environment', 'project', 'projectType', 'sessionOrigin', 'family', 'kind', 'adapter', 'consumer', 'carrier', 'provenance', 'packageManager', 'versionState', 'guidance', 'dependencyRole', 'conflict', 'credentialReadiness', 'channel', 'evidenceFields', 'recentlyChanged', ]); diff --git a/src/lib/maintenance/management/projection-projects.mjs b/src/lib/maintenance/management/projection-projects.mjs index c55c9d81..84d1aaec 100644 --- a/src/lib/maintenance/management/projection-projects.mjs +++ b/src/lib/maintenance/management/projection-projects.mjs @@ -10,7 +10,7 @@ import path from 'node:path'; import { PROJECT_KINDS } from '../../footprint/project-kind.mjs'; import { artifactIdentity, bindingIdentity, placementIdentity, projectIdentity, resourceIdentity } from './identity.mjs'; import { assertion, scorecardFor } from './evidence.mjs'; -import { finalizePlacement, hostLabel } from './projection-builder.mjs'; +import { finalizePlacement, hostLabel, scrubTechnicalDetails } from './projection-builder.mjs'; function segmentsOf(rawPath) { return String(rawPath ?? '').split(/[\\/]+/).filter(Boolean); @@ -49,11 +49,49 @@ function computeBreadcrumbs(rows) { } function repositoryKeyFor(row) { - if (row.repositoryRoot) return `root:${row.repositoryRoot}`; - if (row.remote?.status === 'linked' && row.remote.webUrl) return `remote:${row.remote.webUrl}`; + if (['git', 'worktree'].includes(row.repository?.kind) + && /^repository:[a-f0-9]{20}$/.test(row.repository.repositoryId) + && ['git-directory', 'git-pointer', 'git-common-directory-and-backlink'].includes(row.repository.evidence)) { + return row.repository.repositoryId; + } return null; } +/** Add display evidence after opaque action/project identities are assigned. */ +export function enrichProjectPresentation(builder, registry, rows, { installationKey }) { + for (const row of rows ?? []) { + const entry = registry.get(row.path); + if (!entry) continue; + const key = repositoryKeyFor(row); + if (key) { + entry.repositoryResourceId = resourceIdentity({ kind: 'related-storage', sourceSelector: `repository:${key}` }, installationKey); + entry.repositoryLabel = scrubTechnicalDetails([segmentsOf(row.repository.root ?? row.repository.commonDir).at(-1) ?? 'Repository'])[0] ?? 'Repository'; + entry.repositoryEvidence = row.repository.evidence; + entry.repositoryObservedAt = Number.isFinite(row.repository.observedAt) ? row.repository.observedAt : null; + builder.upsertResource(entry.repositoryResourceId, { kind: 'related-storage', displayName: `${entry.repositoryLabel} repository` }); + } + if (['git', 'worktree', 'folder'].includes(row.repository?.kind)) entry.projectKind = row.repository.kind; + if (Array.isArray(row.sessionOrigins)) entry.sessionOrigins = row.sessionOrigins + .filter((origin) => ['claude-desktop', 'codex-desktop', 'unknown'].includes(origin.origin) + && Number.isInteger(origin.sessions) && origin.sessions > 0) + .map(({ origin, sessions, countBasis }) => ({ origin, sessions, + ...(['transcript-files', 'database-sessions', 'recovered-project-sighting', 'mixed-observations'].includes(countBasis) ? { countBasis } : {}), + })); + } +} + +export function projectPresentation(entry) { + return { + projectLanguages: entry?.projectLanguages ?? [], projectKind: entry?.projectKind ?? 'unknown', + projectBreadcrumb: [...(entry?.breadcrumb ?? [])], + ...(entry?.repositoryResourceId ? { repositoryId: entry.repositoryResourceId } : {}), + repositoryLabel: entry?.repositoryLabel ?? null, + repositoryEvidence: entry?.repositoryEvidence ?? (entry?.repositoryResourceId ? 'project-discovery' : null), + repositoryObservedAt: entry?.repositoryObservedAt ?? null, + sessionOrigins: entry?.sessionOrigins ?? [], + }; +} + /** A placement-less `related-storage` resource id per repository key that * groups two or more members — a lone member has no group to join. Shared * between the lexical (footprint.projects) and authoritative @@ -243,7 +281,7 @@ function emitInstructionFilePlacement(builder, ctx, { file, projectEntry, locato ]), displayName: file.name, kind: 'instruction-context-file', consumerHosts: [file.host], technicalDetails, versions: file.digest ? { contentDigest: file.digest } : {}, - extra: { projectLanguages: projectEntry?.projectLanguages ?? [], projectKind: projectEntry?.projectKind ?? 'unknown', ...(projectEntry?.repositoryResourceId ? { repositoryId: projectEntry.repositoryResourceId } : {}) }, + extra: projectPresentation(projectEntry), }); if (typeof file.path === 'string' && (path.isAbsolute(file.path) || path.win32.isAbsolute(file.path))) builder.locate(placementId, { path: file.path }); return placementId; diff --git a/src/lib/maintenance/management/projection.mjs b/src/lib/maintenance/management/projection.mjs index 0252c124..c3ec74c3 100644 --- a/src/lib/maintenance/management/projection.mjs +++ b/src/lib/maintenance/management/projection.mjs @@ -113,7 +113,7 @@ import { classifyConflicts } from './conflicts.mjs'; import { createBuilder, finalizePlacement, hostLabel, scrubTechnicalDetails } from './projection-builder.mjs'; import { deriveSubmoduleEdges, mapDiscoveryProjectInstructionFiles, mapInstructionFiles, mapProjects, - registerFallbackProjectPaths, projectInstallationLocation, + registerFallbackProjectPaths, projectInstallationLocation, enrichProjectPresentation, projectPresentation, } from './projection-projects.mjs'; // ── small pure helpers ────────────────────────────────────────────────────── @@ -343,7 +343,7 @@ function mapCatalogGroup(builder, item, group, ctx) { evidenceScorecard: { ...catalogScorecard(placementId, consumerHosts, now), ...(versions.installed ? { installedVersion: 'verified' } : {}), ...(versions.candidate ? { candidateSource: 'verified' } : {}) }, displayName: item.name, kind, hostNamespace: item.pluginRef ?? undefined, consumerHosts, versions, - technicalDetails, extra: { description: scrubTechnicalDetails([first.description])[0] ?? null, ...(projectEntry ? { projectLanguages: projectEntry.projectLanguages ?? [], projectKind: projectEntry.projectKind ?? 'unknown', projectBreadcrumb: [...projectEntry.breadcrumb] } : {}), ...(transportKey ? { transportKey } : {}) }, + technicalDetails, extra: { description: scrubTechnicalDetails([first.description])[0] ?? null, ...(projectEntry ? projectPresentation(projectEntry) : {}), ...(transportKey ? { transportKey } : {}) }, }); if (first.itemPath || first.path) builder.locate(placementId, { path: first.itemPath ?? first.path }); const probeMatches = catalogDependencyProbeMatches(item, dependencyProbes, consumerHosts); @@ -1077,6 +1077,8 @@ export function buildManagementInventory({ // null one. Filled in from the presence's own lexical root before catalog // mapping runs, so mapCatalogGroup's ordinary registry lookup finds it. registerFallbackProjectPaths(projects, projectPathsIn(footprint.catalog), { installationKey }); + enrichProjectPresentation(builder, projects, + [...(footprint?.projects?.projects ?? []), ...(footprint?.projects?.discoveryProjects ?? [])], { installationKey }); // Add presentation evidence after identity assignment; a better label must // not change lexical project IDs or their action and receipt targets. for (const row of footprint?.catalog?.projectMetadata ?? []) { diff --git a/src/lib/maintenance/management/query.mjs b/src/lib/maintenance/management/query.mjs index 76aff7c2..67079d0d 100644 --- a/src/lib/maintenance/management/query.mjs +++ b/src/lib/maintenance/management/query.mjs @@ -272,6 +272,8 @@ const FACET_EXTRACTORS = Object.freeze({ environment: (placement) => [placement.environmentId], project: (placement) => (placement.projectId ? [placement.projectId] : []), projectType: (placement) => placement.projectId ? [PROJECT_KINDS.includes(placement.projectKind) ? placement.projectKind : 'unknown'] : [], + sessionOrigin: (placement) => placement.projectId + ? [...new Set(placement.sessionOrigins?.length ? placement.sessionOrigins.map((entry) => entry.origin) : ['unknown'])] : [], kind: (placement) => [placement.kind], consumer: (placement) => (placement.consumerHosts ?? []).filter((host) => ['claude', 'codex', 'opencode'].includes(host)), adapter: (placement) => [...new Set([...(placement.consumerHosts ?? []), ...(placement.kind === 'host-adapter' ? [placement.hostNamespace] : [])].filter((host) => typeof host === 'string' && host && !['claude', 'codex', 'opencode', 'agentic-kit'].includes(host)))], @@ -401,6 +403,9 @@ function buildPlacementRow(placement, index) { placementId: placement.placementId, projectId: placement.projectId ?? null, ...(placement.projectId ? { projectKind: PROJECT_KINDS.includes(placement.projectKind) ? placement.projectKind : 'unknown' } : {}), + ...(placement.projectId ? { repositoryId: placement.repositoryId ?? null, repositoryLabel: placement.repositoryLabel ?? null, + repositoryEvidence: placement.repositoryEvidence ?? null, repositoryObservedAt: placement.repositoryObservedAt ?? null, + sessionOrigins: placement.sessionOrigins ?? [] } : {}), displayName: placement.displayName, ...(index.resourcesById.get(placement.resourceId)?.installationSource ? { installationSource: index.resourcesById.get(placement.resourceId).installationSource } : {}), ...(description ? { description } : {}), diff --git a/src/lib/project-census.mjs b/src/lib/project-census.mjs index 73be9ac7..063209b2 100644 --- a/src/lib/project-census.mjs +++ b/src/lib/project-census.mjs @@ -18,7 +18,7 @@ // ── The census ────────────────────────────────────────────────────────────── // The census itself is discoverProjectSources() (footprint/project-sources.mjs), // reused verbatim rather than reimplemented. It is already the widest and most -// carefully bounded of the four: it reads exactly one field (the session `cwd`) +// carefully bounded of the four: it reads cwd and explicit launch-origin metadata // out of the head of every Claude and Codex transcript plus the OpenCode session // store, dedupes by resolved real path, and reports three deliberately distinct // figures — everSeen / onDisk / gitRepos — instead of one lossy total. @@ -39,8 +39,10 @@ // count without one. import fs from 'node:fs'; import path from 'node:path'; +import os from 'node:os'; import { discoverProjectSources } from './footprint/project-sources.mjs'; import { resolveProjectIdentity } from './live/project-label.mjs'; +import { claudeDir, codexDir, opencodeDir, configDir } from './paths.mjs'; /** Directories that mean "memory/intelligence has been activated in this * project". Deliberately the SAME list storage.mjs already treats as a @@ -93,8 +95,10 @@ export function hasLearningState(projectPath, opts) { * @returns the discoverProjectSources payload, plus `learning` (a count) and a * `learningState` array on every project row. */ -export function projectCensus({ discover = discoverProjectSources, fsImpl = fs, ...opts } = {}) { +export function projectCensus({ discover = discoverProjectSources, fsImpl = fs, + userRoots = [os.homedir(), claudeDir(), codexDir(), opencodeDir(), configDir()], ...opts } = {}) { const census = discover({ fsImpl, ...opts }); + const userRootSet = new Set(userRoots.map((root) => canonicalLearningPath(root, fsImpl))); const projects = census.projects.map((p) => { // A path that is gone cannot be probed; [] is the only honest reading, and // `exists` sits beside it so no consumer can confuse "no learning state" @@ -103,7 +107,8 @@ export function projectCensus({ discover = discoverProjectSources, fsImpl = fs, // The identity key groups every DIRECTORY that belongs to one project — a // sub-directory a session happened to run in, and an agent worktree under // .claude/worktrees/, are the same project as the repo root. - return { ...p, learningState, identityKey: identityKeyOf(p.path) }; + return { ...p, learningState, identityKey: identityKeyOf(p.path), + ...learningPresentation(p, userRootSet, fsImpl), learningObservedAt: census.asOf ?? null }; }); return { ...census, @@ -116,6 +121,28 @@ export function projectCensus({ discover = discoverProjectSources, fsImpl = fs, }; } +function canonicalLearningPath(candidate, fsImpl) { + let resolved; + try { resolved = (fsImpl.realpathSync.native ?? fsImpl.realpathSync)(candidate); } + catch { resolved = path.resolve(candidate); } + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + +/** Scope and client origin are independent; only explicit recorded evidence is used. */ +function learningPresentation(project, userRoots, fsImpl) { + const repository = project.repository; + const verified = ['git-directory', 'git-pointer', 'git-common-directory-and-backlink'].includes(repository?.evidence); + const user = userRoots.has(canonicalLearningPath(project.path, fsImpl)); + const learningScope = user ? 'user' + : verified && repository.kind === 'git' ? 'repository' + : verified && repository.kind === 'worktree' ? 'worktree' : 'unknown'; + return { learningScope, + learningScopeEvidence: user ? 'exact-user-state-root' : verified ? repository.evidence : 'unclassified', + learningOrigins: ['claude-desktop', 'codex-desktop'].filter((origin) => + project.sessionOrigins?.some((entry) => entry.origin === origin && entry.sessions > 0)), + }; +} + function identityKeyOf(projectPath) { try { return resolveProjectIdentity(projectPath).key; } catch { return `path:${projectPath}`; } @@ -144,13 +171,17 @@ function mergeByIdentity(rows) { existing.paths.push(row.path); existing.hosts = [...new Set([...(existing.hosts ?? []), ...(row.hosts ?? [])])]; existing.learningState = [...new Set([...(existing.learningState ?? []), ...(row.learningState ?? [])])]; + existing.learningOrigins = [...new Set([...(existing.learningOrigins ?? []), ...(row.learningOrigins ?? [])])].sort(); existing.sessions = (existing.sessions ?? 0) + (row.sessions ?? 0); if ((row.lastSeenMs ?? -1) > (existing.lastSeenMs ?? -1)) existing.lastSeenMs = row.lastSeenMs; // Prefer the shallowest path that carries learning state: a repo root over // one of its sub-directories, and never an ephemeral agent worktree when a // real root is available. const better = row.learningState.length > 0 && row.path.length < existing.path.length; - if (better) { existing.path = row.path; existing.label = row.label; existing.isGitRepo = row.isGitRepo; } + if (better) { + existing.path = row.path; existing.label = row.label; existing.isGitRepo = row.isGitRepo; + existing.learningScope = row.learningScope; existing.learningScopeEvidence = row.learningScopeEvidence; + } } for (const row of byKey.values()) row.paths.sort(); return [...byKey.values()]; diff --git a/src/lib/usage-aggregate.mjs b/src/lib/usage-aggregate.mjs index fc7e8e6c..96705a64 100644 --- a/src/lib/usage-aggregate.mjs +++ b/src/lib/usage-aggregate.mjs @@ -16,6 +16,7 @@ // already-decoded fingerprints, which is exactly this module's own subject. import { PROVENANCE_TAGS } from './usage-provenance.mjs'; import { buildContextProjection } from './usage-context.mjs'; +import { buildUsageProjectGroups, buildUsageGitProjects } from './usage-project-groups.mjs'; import { crossSessionClusters, exactRepeatGroups, nearDupClusters, reAskPairs, } from './usage-prompt-patterns.mjs'; @@ -843,6 +844,8 @@ function buildSessionRow(rec, usage, verdict) { transcriptProvider: rec.provider, providerProvenance: rec.providerProvenance ?? 'unknown', title: rec.title, project: rec.project, + projectEvidence: rec.projectEvidence ? { ...rec.projectEvidence } : null, + sessionOrigin: rec.sessionOrigin ? { ...rec.sessionOrigin } : null, worktree: rec.worktree ?? null, start: new Date(rec.start ?? rec.end).toISOString(), minutes: Math.round(((rec.end - (rec.start ?? rec.end)) / 60_000) * 10) / 10, @@ -1251,7 +1254,8 @@ export function aggregate(records, { days, now, cutoff, deps, previous = false, pricesAsOf: deps.pricesAsOf ?? null, totals, byDay, engagedByDay, byModel, byHost, byProvider, byMode, bySource, byTool, - byProject, byCategory, + byProject, byCategory, projectGroups: buildUsageProjectGroups(sessions), + gitProjects: buildUsageGitProjects(sessions), // v16 prompt layer. `promptStatsByDay` is a SIBLING of byDay for the same // reason engagedByDay is: byDay's keys are billed days, and a prompt series // keyed on them would have to invent zero-token rows or drop real prompts. diff --git a/src/lib/usage-index.mjs b/src/lib/usage-index.mjs index 69a7cf23..8cee03db 100644 --- a/src/lib/usage-index.mjs +++ b/src/lib/usage-index.mjs @@ -147,7 +147,11 @@ export { MAX_TURN_CHARS, mergeIntervals, maskSecrets, normalizeSessionIdentity, * (`i`/`d`) derived while prompt text is transient. Cached v17 records * cannot recover those facets because raw prompt text was correctly not * retained, so they must be reparsed. */ -export const SCHEMA_VERSION = 18; +// v19 preserves opaque working-path/Git association and explicit session origin +// from parsing. Earlier cache entries discarded this metadata and must reparse. +// v20 records exact user-root exclusion and verified real-parent eligibility +// for the Git-only score ranking. A v19 cache cannot establish either fact. +export const SCHEMA_VERSION = 20; const DAY_MS = 86_400_000; // One day of slack past dashboard-server.mjs's 365-day clampDays ceiling — diff --git a/src/lib/usage-opencode.mjs b/src/lib/usage-opencode.mjs index b18609db..93e6a1e6 100644 --- a/src/lib/usage-opencode.mjs +++ b/src/lib/usage-opencode.mjs @@ -33,6 +33,7 @@ import { addUsage, blankSession, noteContextSample, noteLatencySample, notePromptFingerprint, } from './usage-parsers.mjs'; import { normalizeMode } from './usage-modes.mjs'; +import { observeUsageProject } from './usage-project-evidence.mjs'; /** The live opencode store. Overridable via roots in tests. */ export function defaultOpencodeDbPath() { @@ -312,6 +313,7 @@ function initSessionRecord(srow) { // blankSession's default host/provider ('opencode' for both) already // matches this source; only the opencode-specific fields are overridden. const rec = blankSession(srow.id, 'opencode'); + rec.projectEvidence = observeUsageProject(srow.directory); rec.title = clip(srow.title) || '(untitled)'; rec.project = project; rec.sidechain = !!srow.parent_id; diff --git a/src/lib/usage-parsers.mjs b/src/lib/usage-parsers.mjs index cb7905c4..55aaa5dc 100644 --- a/src/lib/usage-parsers.mjs +++ b/src/lib/usage-parsers.mjs @@ -17,6 +17,7 @@ import { toMs, maskSecrets } from './usage-aggregate.mjs'; import { normalizeMode } from './usage-modes.mjs'; import { provenanceOf } from './usage-provenance.mjs'; import { promptSemantics } from './usage-prompt-semantics.mjs'; +import { observeUsageProject, usageSessionOrigin } from './usage-project-evidence.mjs'; export { promptSemantics } from './usage-prompt-semantics.mjs'; @@ -187,6 +188,7 @@ export function blankSession(id, provider) { return { id, provider, host: provider, inferenceProvider: null, providerProvenance: 'unknown', title: '', project: 'unknown', start: null, end: null, + projectEvidence: null, sessionOrigin: { origin: 'unknown', evidence: 'desktop-origin-not-declared' }, prompts: 0, responses: 0, exceptions: 0, sidechain: false, threadSource: null, models: [], tools: {}, skill: null, plugin: null, worktree: null, usage: [], punchcard: {}, active: [], stamps: [], // Codex-only detail (v6): reasoning tokens inside output, and the last @@ -687,6 +689,7 @@ function recordClaudeAssistantTurn(rec, turns, latState, ms, decoded, withTurns) */ export function parseClaude(raw, { id, dirName, withTurns = false }) { const rec = blankSession(id, 'claude'); + rec.sessionOrigin = usageSessionOrigin(raw, 'claude'); const turns = []; const titleState = { firstPrompt: '', aiTitle: '' }; // Open by the most recent human prompt, closed by the first real assistant @@ -700,6 +703,7 @@ export function parseClaude(raw, { id, dirName, withTurns = false }) { if (typeof e.attributionPlugin === 'string' && !rec.plugin) rec.plugin = e.attributionPlugin; const decoded = decodeClaudeRecord(e); if (decoded.isSidechain) rec.sidechain = true; + if (!rec.projectEvidence && typeof e.cwd === 'string') rec.projectEvidence = observeUsageProject(e.cwd); if (rec.project === 'unknown' && typeof e.cwd === 'string') applyProject(rec, projectLabel(e.cwd, dirName, repoRootOf(e.cwd))); if (decoded.role === 'user') { @@ -768,6 +772,7 @@ function handleCodexMeta(rec, metaState, decoded) { metaState.seen = true; if (typeof decoded.sessionId === 'string' && decoded.sessionId) rec.id = decoded.sessionId; if (typeof decoded.cwd === 'string') applyProject(rec, projectLabel(decoded.cwd, null, repoRootOf(decoded.cwd))); + if (typeof decoded.cwd === 'string') rec.projectEvidence = observeUsageProject(decoded.cwd); if (typeof decoded.threadSource === 'string') rec.threadSource = decoded.threadSource; if (decoded.provider) { rec.inferenceProvider = decoded.provider; @@ -776,6 +781,7 @@ function handleCodexMeta(rec, metaState, decoded) { } function handleCodexTurnContext(rec, decoded, payload) { + if (!rec.projectEvidence && typeof decoded.cwd === 'string') rec.projectEvidence = observeUsageProject(decoded.cwd); if (typeof decoded.model === 'string' && !rec.models.includes(decoded.model)) rec.models.push(decoded.model); if (decoded.provider) { rec.inferenceProvider = decoded.provider; @@ -1066,6 +1072,7 @@ function finalizeCodexUsage(rec, usageState) { */ export function parseCodex(raw, { id, withTurns = false }) { const rec = blankSession(id, 'codex'); + rec.sessionOrigin = usageSessionOrigin(raw, 'codex'); const turns = []; const stats = codexParseStats(); const usageState = { lastUsage: null, lastUsageAt: null }; diff --git a/src/lib/usage-project-evidence.mjs b/src/lib/usage-project-evidence.mjs new file mode 100644 index 00000000..2821e886 --- /dev/null +++ b/src/lib/usage-project-evidence.mjs @@ -0,0 +1,62 @@ +// Parse-time evidence only. Aggregation/rendering never probes a filesystem. +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { createHash } from 'node:crypto'; +import { inspectProjectIdentity } from './footprint/project-identity.mjs'; +import { transcriptSessionOrigin } from './footprint/session-origin.mjs'; +import { safeProjectLabel } from './live/project-label.mjs'; +import { claudeDir, codexDir, opencodeDir, configDir } from './paths.mjs'; + +const CACHE = new Map(); +const keyFor = (value) => `working:${createHash('sha256').update(value).digest('hex').slice(0, 20)}`; +let rootObservation = null; + +function canonicalPath(candidate) { + if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) return null; + let resolved; + try { resolved = (fs.realpathSync.native ?? fs.realpathSync)(candidate); } catch { resolved = path.resolve(candidate); } + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} +function userRootObservation(roots) { + const signature = JSON.stringify(roots); + if (rootObservation?.signature !== signature) { + rootObservation = { signature, roots: new Set(roots.map(canonicalPath).filter(Boolean)) }; + } + return rootObservation; +} +function hasRealParentRoot(repository) { + if (!repository.root || !repository.commonDir) return false; + try { + if (!fs.statSync(repository.root).isDirectory()) return false; + const file = path.join(repository.commonDir, 'HEAD'), stat = fs.lstatSync(file); + if (!stat.isFile() || stat.size > 4096) return false; + return /^(?:ref: refs\/[^\s]+|[a-f0-9]{40}|[a-f0-9]{64})$/iu.test(fs.readFileSync(file, 'utf8').trim()); + } catch { return false; } +} + +export function observeUsageProject(cwd, { observedAt = Date.now(), cache = CACHE, + userRoots = [os.homedir(), claudeDir(), codexDir(), opencodeDir(), configDir()] } = {}) { + if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return null; + const scope = userRootObservation(userRoots), cacheKey = `${cwd}\0${scope.signature}`; + const prior = cache.get(cacheKey); + if (prior && observedAt - prior.observedAt >= 0 && observedAt - prior.observedAt < 60_000) return prior; + const repository = inspectProjectIdentity(cwd, { observedAt }); + let workingPath = repository.worktreeRoot ?? cwd; + try { workingPath = (fs.realpathSync.native ?? fs.realpathSync)(workingPath); } catch { workingPath = path.resolve(workingPath); } + const value = { key: keyFor(workingPath), label: safeProjectLabel(workingPath), kind: repository.kind, + repositoryId: repository.repositoryId, + repositoryLabel: repository.repositoryId ? safeProjectLabel(repository.root ?? repository.commonDir) : null, + parentRootExists: hasRealParentRoot(repository), + userLevel: [cwd, workingPath, repository.root].some((candidate) => scope.roots.has(canonicalPath(candidate))), + evidence: repository.evidence, observedAt, observationBasis: 'current-filesystem' }; + if (cache.size >= 2048) cache.delete(cache.keys().next().value); + cache.set(cacheKey, value); + return value; +} + +/** Same bounded head and exact origin allowlists as footprint discovery. */ +export function usageSessionOrigin(raw, host) { + const head = Buffer.from(String(raw).slice(0, 256 * 1024)).subarray(0, 256 * 1024).toString('utf8'); + return transcriptSessionOrigin(head.split('\n').filter((line) => line.trim()).slice(0, 40), host); +} diff --git a/src/lib/usage-project-groups.mjs b/src/lib/usage-project-groups.mjs new file mode 100644 index 00000000..89f41e6c --- /dev/null +++ b/src/lib/usage-project-groups.mjs @@ -0,0 +1,74 @@ +// Additive read model over precisely the sessions the usage window admitted. +// Legacy label-keyed aggregates and session navigation stay unchanged. +const round = (value) => Math.round(value * 1e6) / 1e6; +const originValues = (session) => ['claude-desktop', 'codex-desktop'].includes(session.sessionOrigin?.origin) + && session.sessionOrigin.origin === `${session.host}-desktop` + ? [session.sessionOrigin.origin] : []; +function empty(key, label, kind) { + return { key, label, kind, cost: 0, sessions: 0, minutes: 0, tokens: 0, origins: new Set() }; +} +function add(row, session) { + row.cost += Number(session.cost) || 0; + row.sessions++; + row.minutes += Number(session.minutes) || 0; + row.tokens += Number(session.tokens) || 0; + for (const origin of originValues(session)) row.origins.add(origin); +} +function finish(row) { + return { ...row, cost: round(row.cost), minutes: round(row.minutes), origins: [...row.origins].sort() }; +} +const ranked = (a, b) => b.cost - a.cost || a.label.localeCompare(b.label) || a.key.localeCompare(b.key); + +function groupDescriptor(session) { + const evidence = session.projectEvidence, repo = evidence?.repositoryId; + return empty(repo ?? evidence?.key ?? 'unclassified', + repo ? evidence.repositoryLabel ?? 'Repository' : evidence?.label ?? 'Unclassified', + repo ? 'repository' : evidence?.kind ?? 'unknown'); +} +function memberDescriptor(session) { + const evidence = session.projectEvidence; + return { ...empty(evidence?.key ?? `${session.host ?? 'unknown'}:${session.id}`, + evidence?.label ?? session.project ?? 'Unclassified', evidence?.kind ?? 'unknown'), + reportedLabels: new Set(), sessionRefs: [], evidence: evidence?.evidence ?? 'unclassified', + observedAt: evidence?.observedAt ?? null, observationBasis: evidence?.observationBasis ?? null }; +} + +export function buildUsageProjectGroups(sessions) { + const groups = new Map(); + for (const session of sessions ?? []) { + const descriptor = groupDescriptor(session); + if (!groups.has(descriptor.key)) groups.set(descriptor.key, { ...descriptor, members: new Map() }); + const group = groups.get(descriptor.key); + add(group, session); + const memberInfo = memberDescriptor(session); + if (!group.members.has(memberInfo.key)) group.members.set(memberInfo.key, memberInfo); + const member = group.members.get(memberInfo.key); + add(member, session); + if (session.project) member.reportedLabels.add(session.project); + member.sessionRefs.push({ id: session.id, host: session.host ?? 'unknown', cost: session.cost, start: session.start ?? null }); + } + return [...groups.values()].map((group) => ({ ...finish(group), members: [...group.members.values()] + .map((member) => ({ ...finish(member), reportedLabels: [...member.reportedLabels].sort() })).sort(ranked) })).sort(ranked); +} + +/** Ranking population: existing Git projects only, with verified worktrees + * charged to their real parent. Unknown/legacy/user-level observations remain + * in overall usage totals, but cannot become a Git-ranking candidate. */ +export function buildUsageGitProjects(sessions) { + const repositories = new Map(); + for (const session of sessions ?? []) { + const evidence = session.projectEvidence; + if (!evidence || evidence.parentRootExists !== true || evidence.userLevel !== false + || !/^repository:[a-f0-9]{20}$/u.test(evidence.repositoryId ?? '') + || !(evidence.kind === 'git' && ['git-directory', 'git-pointer'].includes(evidence.evidence) + || evidence.kind === 'worktree' && evidence.evidence === 'git-common-directory-and-backlink')) continue; + const key = evidence.repositoryId; + if (!repositories.has(key)) repositories.set(key, { + key, label: evidence.repositoryLabel ?? 'Repository', cost: 0, sessions: 0, minutes: 0, tokens: 0, + }); + const row = repositories.get(key); + row.sessions++; + for (const field of ['cost', 'minutes', 'tokens']) row[field] += Number(session[field]) || 0; + } + return [...repositories.values()].map((row) => ({ ...row, cost: round(row.cost), minutes: round(row.minutes) })).sort(ranked); +} diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs index 776f2b88..4fcf88a0 100644 --- a/tests/dashboard.test.cjs +++ b/tests/dashboard.test.cjs @@ -38,21 +38,26 @@ function contains(hay, needle) { // anchors the user clicks in their own browser, which is a stated design point // (docs/ddd/component-directory.md §6 — "Links are outbound and user-initiated; // the kit stays offline"). So the invariant is pinned to the directory itself — -// every external URL baked into the page must be one the directory declares. +// every external URL baked into the page must be a declared directory or native +// context-control documentation anchor. // A CDN script, webfont, tracking beacon, or any other new external host still // fails here, because its URL is not in that set. let directoryUrls = null; async function assertSelfContained(body) { if (!directoryUrls) { const { directoryEntries } = await import('../src/lib/dashboard/about-directory.mjs'); - directoryUrls = new Set(); + directoryUrls = new Set([ + 'https://code.claude.com/docs/en/model-config', + 'https://learn.chatgpt.com/docs/config-file/config-reference', + 'https://opencode.ai/docs/config', + ]); for (const e of directoryEntries()) for (const l of e.links || []) directoryUrls.add(l.url); } const unexpected = (body.match(/https?:\/\/[^"'`\s\\)]+/g) || []) .filter((u) => !/^https?:\/\/127\.0\.0\.1/.test(u) && !/w3\.org/.test(u)) .filter((u) => !directoryUrls.has(u)); assert(unexpected.length === 0, - 'page must not reference external hosts beyond the About directory anchors; found: ' + 'page must not reference external hosts beyond the declared documentation anchors; found: ' + unexpected.slice(0, 5).join(', ')); assert(!/]+stylesheet/i.test(body), 'no external stylesheet links'); assert(!/]+src=/i.test(body), 'no external script src'); diff --git a/tests/kit/codex-context-command.test.mjs b/tests/kit/codex-context-command.test.mjs index bb6e2559..ef053e84 100644 --- a/tests/kit/codex-context-command.test.mjs +++ b/tests/kit/codex-context-command.test.mjs @@ -37,6 +37,7 @@ test('max survives reload, status creates an actionable sync repair, and off res assert.equal(cfg.codexContext.lastProjection, 872000); const current = await section.collect({ cfg }); assert.equal(current[0].level, 'ok'); + assert.equal(current[0].contextReport.hosts.find(h => h.host === 'codex').runtimeVerified, false); fs.writeFileSync(path.join(process.env.CODEX_HOME, 'config.toml'), 'model = "gpt-6-astra"\n'); const drift = await section.collect({ cfg }); assert.ok(drift[0].fix); diff --git a/tests/kit/codex-context.test.mjs b/tests/kit/codex-context.test.mjs index ae46a3f7..2db55910 100644 --- a/tests/kit/codex-context.test.mjs +++ b/tests/kit/codex-context.test.mjs @@ -41,6 +41,8 @@ test('unmanaged inspection never writes or requests a repair', (t) => { const f = fixture(t); const result = inspectCodexContext(f.cfg, f.options); assert.equal(result.owned, false); + assert.equal(result.observedAt, new Date(now).toISOString()); + assert.equal(result.cacheFetchedAt, f.cache.fetched_at); assert.equal(result.drifted, false); assert.equal(f.read(), f.source); }); diff --git a/tests/kit/context-display-semantics.test.mjs b/tests/kit/context-display-semantics.test.mjs new file mode 100644 index 00000000..f9204d88 --- /dev/null +++ b/tests/kit/context-display-semantics.test.mjs @@ -0,0 +1,34 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import {esc} from '../../src/lib/dashboard/groups.mjs'; +const source=fs.readFileSync(new URL('../../src/lib/dashboard/client/usage-context-hooks.mjs',import.meta.url),'utf8') + .replace(/^import .*;$/gm,'').replace(/\bexport /g,''); +const scope=vm.createContext({esc}); +vm.runInContext(source+'\nglobalThis.api={ctxTokens,contextHostCard};',scope); +const {ctxTokens,contextHostCard}=scope.api; +test('missing token evidence is not rendered as zero',()=>{ + for(const value of [null,undefined,'',NaN])assert.equal(ctxTokens(value),'—'); + assert.equal(ctxTokens(0),'0'); +}); +test('input-only history explains missing pressure and leaves missing window blank',()=>{ + const html=contextHostCard('claude',{coverage:{sessions:1000,inputMeasured:1000,windowMeasured:0,pressureMeasured:0,state:'partial'}, + inputTokens:{peak:{p90:326000}},windowTokens:null,pressureBps:null}); + assert.match(html,/Input only/);assert.match(html,/326K/); + assert.match(html,/median window<\/dt>
    —/); + assert.doesNotMatch(html,/role="meter"/); + assert.match(html,/without a recorded context window/); +}); +test('no sessions is distinct from sessions lacking context measurements',()=>{ + const empty=contextHostCard('opencode',{coverage:{sessions:0,inputMeasured:0,windowMeasured:0,pressureMeasured:0,state:'not-observed'}}); + assert.match(empty,/No sessions/);assert.match(empty,/No sessions in the selected timeframe/); + const missing=contextHostCard('opencode',{coverage:{sessions:5,inputMeasured:0,windowMeasured:0,pressureMeasured:0,state:'not-recorded'}}); + assert.match(missing,/Not recorded/);assert.doesNotMatch(missing,/No sessions in/); +}); +test('partial paired coverage preserves measured pressure without claiming every session was measured',()=>{ + const html=contextHostCard('codex',{coverage:{sessions:12,inputMeasured:12,windowMeasured:4,pressureMeasured:4,state:'partial'}, + pressureBps:{peak:{p90:9130}},windowTokens:{median:258000},inputTokens:{peak:{p90:236000}}}); + assert.match(html,/91.3%/);assert.match(html,/258K/);assert.match(html,/Sessions with pressure/); + assert.match(html,/4 of 12 sessions/); +}); diff --git a/tests/kit/context-model-cache.test.mjs b/tests/kit/context-model-cache.test.mjs new file mode 100644 index 00000000..ce2b4514 --- /dev/null +++ b/tests/kit/context-model-cache.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cachedContextModels, MAX_CONTEXT_MODELS } from '../../src/lib/context-model-cache.mjs'; +import { buildContextReport } from '../../src/lib/context-report.mjs'; +const at = '2026-09-09T14:00:00Z', now = Date.parse(at); +function snapshot() { + const models = ['claude', 'codex', 'opencode'].map(host => ({ + key: { host, modelId: `${host}-model`, provider: 'provider', scopeId: 'scope-1' }, + capabilities: { contextLimit: 200000, outputLimit: 32000 }, + variant: { effectiveContextWindow: 999999 }, + evidence: ['contextLimit', 'outputLimit'].map(field => ({ field: `capabilities.${field}`, + source: host === 'claude' ? 'anthropic-docs' : host === 'codex' ? 'codex-cache' : 'opencode-models', + class: 'catalog', capturedAt: at, scopeFingerprint: 'scope-1', freshness: 'fresh' })), + rawConfig: { apiKey: 'DO-NOT-PROJECT' }, + })); + return { capturedAt: at, scope: { fingerprint: 'scope-1' }, models, + sources: models.map(model => ({ id: model.evidence[0].source, scopeFingerprint: 'scope-1' })) }; +} +test('cached per-host capacity preserves provenance without deriving effective session limits', () => { + const data = snapshot(), rows = cachedContextModels(data, 'opencode', { now }).models; + assert.equal(rows.length, 1); + assert.deepEqual(rows[0], { model: 'opencode-model', provider: 'provider', capacityWindow: 200000, + inputLimit: null, outputLimit: 32000, basis: 'catalog', capturedAt: at, + scopeId: 'scope-1', freshness: 'fresh', sources: ['opencode-models'] }); + assert.equal(JSON.stringify(rows).includes('DO-NOT-PROJECT'), false); + assert.equal(Object.hasOwn(rows[0], 'effectiveWindow'), false); +}); +test('missing or cross-scope field evidence cannot become a model capacity', () => { + const data = snapshot(); + data.models[0].evidence = []; + assert.deepEqual(cachedContextModels(data, 'claude', { now }).models, []); + data.models[1].key.scopeId = 'scope-other'; + assert.deepEqual(cachedContextModels(data, 'codex', { now }).models, []); +}); +test('stale observations retain their age instead of becoming fresh on dashboard inspection', () => { + const data = snapshot(); + const result = cachedContextModels(data, 'claude', { now: now + 8 * 86400000 }); + assert.equal(result.models[0].freshness, 'stale'); + assert.equal(result.models[0].capturedAt, at); +}); +test('model rows are bounded and unsafe identifiers are not forwarded', () => { + const data = snapshot(), template = data.models[0]; + data.models = Array.from({ length: MAX_CONTEXT_MODELS + 3 }, (_, index) => ({ ...template, key: { ...template.key, modelId: `model-${index}` } })); + data.models.push({ ...template, key: { ...template.key, modelId: '' } }); + const result = cachedContextModels(data, 'claude', { now }); + assert.equal(result.models.length, MAX_CONTEXT_MODELS); + assert.equal(result.omitted, 3); +}); +test('host reports use cached catalogs even without live or managed context inspection', () => { + const report = buildContextReport({ integrations: { hosts: { claude: true, codex: true, opencode: true } } }, + { available: false, reason: 'unverified native client' }, { now, modelSnapshot: snapshot() }); + assert.deepEqual(report.hosts.map(host => [host.host, host.models[0].basis, host.usage]), + [['claude', 'catalog', null], ['codex', 'catalog', null], ['opencode', 'catalog', null]]); + assert.ok(report.hosts.every(host => host.inventoryScopeId === 'scope-1')); +}); diff --git a/tests/kit/context-report.test.mjs b/tests/kit/context-report.test.mjs new file mode 100644 index 00000000..6278a45a --- /dev/null +++ b/tests/kit/context-report.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildContextReport } from '../../src/lib/context-report.mjs'; + +const now = Date.parse('2026-09-09T14:00:00Z'); +const cfg = { integrations: { hosts: { claude: true, codex: true, opencode: true } } }; +const status = { available: true, owned: false, configuredWindow: 872000, + autoCompactTokenLimit: 150000, observedAt: new Date(now).toISOString(), + cacheFetchedAt: '2026-09-08T14:00:00Z', models: [ + { model: 'large', nativeWindow: 272000, maximumWindow: 872000, + requestedWindow: 872000, allocatedWindow: 872000, effectivePercent: 95, effectiveWindow: 828400 }, + { model: 'small', nativeWindow: 272000, maximumWindow: 272000, + requestedWindow: 872000, allocatedWindow: 272000, effectivePercent: 95, effectiveWindow: 258400 }, + ] }; + +test('configured catalog values never become a verified active-session window', () => { + const host = buildContextReport(cfg, status, { now }).hosts[1]; + assert.equal(host.effectiveWindow, null); + assert.equal(host.modelCapacity, null); + assert.equal(host.usage, null); + assert.equal(host.runtimeVerified, false); + assert.equal(host.models[1].effectiveWindow, 258400); + assert.equal(host.configuredRequest, 872000); +}); + +test('unmanaged Codex retains catalog evidence with original freshness', () => { + const host = buildContextReport(cfg, status, { now }).hosts[1]; + assert.equal(host.managed, false); + assert.equal(host.models.length, 2); + assert.equal(host.cacheFetchedAt, status.cacheFetchedAt); + assert.equal(host.observedAt, status.observedAt); +}); + +test('compaction scalar is distinct from verified threshold and scope', () => { + const host = buildContextReport(cfg, status, { now }).hosts[1]; + assert.deepEqual(host.compaction, { configuredThreshold: 150000, scope: 'unverified', + runtimeThreshold: null, control: 'user-owned' }); +}); + +test('other hosts preserve unknown values and distinguish unsupported inspection', () => { + const hosts = buildContextReport(cfg, status, { now }).hosts.filter(h => h.host !== 'codex'); + assert.deepEqual(hosts.map(h => [h.host, h.state, h.usage, h.compaction.control]), [ + ['claude', 'not-inspected', null, 'not-inspected'], + ['opencode', 'not-inspected', null, 'not-inspected'], + ]); +}); + +test('unavailable evidence cannot leak cached values into the current report', () => { + const host = buildContextReport(cfg, { ...status, available: false, reason: 'stale cache' }, { now }).hosts[1]; + assert.equal(host.configuredRequest, null); + assert.deepEqual(host.models, []); + assert.equal(host.state, 'unavailable'); + assert.ok(host.limitations.includes('stale cache')); +}); + +test('disabled hosts are omitted except retained Codex ownership', () => { + assert.deepEqual(buildContextReport({}, null, { now }).hosts, []); + const report = buildContextReport({ codexContext: {} }, status, { now }); + assert.equal(report.hosts.length, 1); + assert.equal(report.hosts[0].enabled, false); +}); + +test('fallback section reports enabled non-Codex hosts without duplicate reports', async () => { + const section = (await import('../../src/commands/status/sections/context.mjs')).default; + assert.deepEqual(await section.collect({ cfg }), []); + const rows = await section.collect({ cfg: { integrations: { hosts: { claude: true } } } }); + assert.equal(rows[0].contextReport.hosts[0].host, 'claude'); + assert.deepEqual(await section.collect({ cfg: {} }), []); +}); diff --git a/tests/kit/dashboard-context-card.test.mjs b/tests/kit/dashboard-context-card.test.mjs new file mode 100644 index 00000000..672bac38 --- /dev/null +++ b/tests/kit/dashboard-context-card.test.mjs @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { groupRows, groupCard } from '../../src/lib/dashboard/groups.mjs'; + +test('context rows share one card while retaining severity and original rows', () => { + const rows = [{subsystem:'codex-context',level:'warn',message:'drift'}, + {subsystem:'codex-context/model',level:'info',message:'model detail'}]; + const groups = groupRows(rows); + assert.equal(groups.length, 1); + assert.equal(groups[0].level, 'warn'); + assert.deepEqual(groups[0].rows, rows); +}); +test('structured context renders host differences and model columns once behind disclosure', () => { + const report = {hosts:[{host:'codex',label:'Codex',managed:true,source:'native-catalog-and-user-config', + observedAt:'2026-09-09T15:00:00Z',cacheFetchedAt:'2026-09-09T14:00:00Z',configuredRequest:1000000, + compaction:{configuredThreshold:800000},models:[{model:'sample',nativeWindow:200000,maximumWindow:1000000,effectiveWindow:950000}], + limitations:['Running session unverified']}, {host:'claude',label:'Claude',managed:false,models:[],limitations:['Native controls not inspected']} ]}; + const html = groupCard(groupRows([{subsystem:'codex-context',level:'ok',message:'old repeated label',contextReport:report}])[0]); + assert.match(html, /
    { + const html = groupCard(groupRows([{subsystem:'codex-context',level:'warn',message:'unavailable',fix:'repair'}])[0]); + assert.match(html,/unavailable/); + assert.match(html,/repair/); +}); + +test('catalog fallback uses capacity columns without inventing native allocation or effective values',()=>{ + const contextReport={hosts:[{host:'codex',label:'Codex',models:[{model:'catalog',capacityWindow:200000,outputLimit:32000,basis:'catalog',freshness:'stale'}]}]}; + const html=groupCard(groupRows([{subsystem:'codex-context',level:'info',contextReport}])[0]); + assert.match(html,/Context<\/th>/); + assert.match(html,/200,000/); + assert.match(html,/stale/); + assert.doesNotMatch(html,/Usable|Default|Input/); +}); diff --git a/tests/kit/dashboard-intel-integration.test.mjs b/tests/kit/dashboard-intel-integration.test.mjs index f72a3269..ea73909f 100644 --- a/tests/kit/dashboard-intel-integration.test.mjs +++ b/tests/kit/dashboard-intel-integration.test.mjs @@ -128,6 +128,29 @@ function fixtureProject(root, name, { const tempRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ak-dash-intel-')); +test('picker metadata survives the cached API catalog without changing selection or learning history', async (t) => { + const root = tempRoot(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const newest = { ...fixtureProject(root, 'Zulu', { lastAdaptation: 5000, patternsLearned: 10, storeEntries: 2 }), + learningScope: 'repository', learningScopeEvidence: 'git-directory', learningObservedAt: 17, + learningOrigins: ['claude-desktop', 'codex-desktop'] }; + const empty = { path: path.join(root, 'Alpha'), label: 'Alpha', learningScope: 'user', learningOrigins: [] }; + let discoveries = 0; + const { url, close, token } = await startDashboard({ port: 0, cwd: root, fetchStatus: async () => STUB_STATUS, + discoverProjects: () => { discoveries++; return [newest, empty]; } }); + try { + const first = JSON.parse((await get(`${url}api/status`, token)).body); + assert.deepEqual(first.intel.projects.map((entry) => entry.label), ['Zulu', 'Alpha']); + assert.equal(first.intel.selectedProjectLabel, 'Zulu'); + assert.deepEqual(first.intel.projects[0].learningOrigins, ['claude-desktop', 'codex-desktop']); + assert.equal(first.intel.projects[0].learningObservedAt, 17); + const selected = JSON.parse((await get(`${url}api/status?project=${encodeURIComponent(first.intel.projects[1].key)}`, token)).body); + assert.equal(selected.intel.selectedProjectLabel, 'Alpha'); + assert.deepEqual(selected.intel.patternStore, []); + assert.equal(discoveries, 1); + } finally { await close(); } +}); + // ── default selection (no ?project=) ──────────────────────────────────── test('GET /api/status with no ?project= defaults to the first discovered project (discoverRuvfloProjects\' own most-recently-active-first sort)', async () => { @@ -243,7 +266,9 @@ test('intel.machineWide sums correctly across multiple fixture projects, keeping // Cross-checked against the same reader intel-history.test.mjs exercises // directly — the seam under test here is collectData()'s wiring/caching, // not readMachineWideIntel's own arithmetic. - assert.deepEqual(body.intel.machineWide, readMachineWideIntel([beta, alpha])); + assert.deepEqual(body.intel.machineWide, readMachineWideIntel([beta, alpha].map(project => ( + { ...project, key: resolveProjectIdentity(project.path).key } + )))); assert.equal(body.intel.machineWide.totals.patternsLearnedLifetime, 800); // 500 + 300 assert.equal(body.intel.machineWide.totals.patternStoreEntries, 3); // 2 + 1 entries on disk diff --git a/tests/kit/dashboard-project-groups.test.mjs b/tests/kit/dashboard-project-groups.test.mjs new file mode 100644 index 00000000..0311ded8 --- /dev/null +++ b/tests/kit/dashboard-project-groups.test.mjs @@ -0,0 +1,21 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {projectView} from '../../src/lib/dashboard/project-groups.mjs'; +const repo = {repositoryId:'/repo/.git',root:'/repo',kind:'git'}; +const main = {path:'/repo',label:'repo',repository:repo,sessionOrigins:[{origin:'claude-desktop',sessions:1},{origin:'unknown',sessions:1}]}; +const work = {path:'/work',label:'work',repository:{...repo,kind:'worktree'},sessionOrigins:[{origin:'codex-desktop',sessions:2}]}; +test('repository and worktree share one group without counting the measured copy twice',()=>{ + const view=projectView({projects:[main],discoveryProjects:[main,work]},'all','all'); + assert.equal(view.length,1); assert.deepEqual(view[0].rows.map(r=>r.path),['/repo','/work']); +}); +test('desktop origin filters intersect repository grouping and preserve unknown membership',()=>{ + assert.equal(projectView({projects:[main,work]},'measured','codex-desktop')[0].rows[0].path,'/work'); + assert.equal(projectView({projects:[main,work]},'measured','unknown')[0].rows[0].path,'/repo'); +}); +test('unclassified older snapshots survive unknown filtering',()=>{ + assert.equal(projectView({projects:[{path:'/old',label:'old'}]},'measured','unknown')[0].rows.length,1); +}); + +test('legacy rows without paths retain independent identity',()=>{ + assert.equal(projectView({projects:[{label:'first'},{label:'second'}]})[0].rows.length,2); +}); diff --git a/tests/kit/dashboard-project-identity.test.mjs b/tests/kit/dashboard-project-identity.test.mjs new file mode 100644 index 00000000..f00d60da --- /dev/null +++ b/tests/kit/dashboard-project-identity.test.mjs @@ -0,0 +1,154 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { inspectProjectIdentity } from '../../src/lib/footprint/project-identity.mjs'; +import { transcriptSessionOrigin } from '../../src/lib/footprint/session-origin.mjs'; +import { discoverProjectSources, scanTranscriptCwds } from '../../src/lib/footprint/project-sources.mjs'; +import { collectProjects } from '../../src/lib/footprint/projects.mjs'; + +// Native realpath expands Windows 8.3 temp paths (RUNNER~1), matching the +// collector's canonical identity. The JavaScript variant may retain them. +const realpath = (file) => (fs.realpathSync.native ?? fs.realpathSync)(file); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-dashboard-identity-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} +function worktree(root, { bare = false } = {}) { + const main = path.join(root, bare ? 'bare.git' : 'main'); + const common = bare ? main : path.join(main, '.git'); + const linked = path.join(root, 'unrelated-name'); + const metadata = path.join(common, 'worktrees', 'checkout'); + fs.mkdirSync(metadata, { recursive: true }); + fs.mkdirSync(path.join(linked, 'src'), { recursive: true }); + fs.writeFileSync(path.join(linked, '.git'), `gitdir: ${metadata}\n`); + fs.writeFileSync(path.join(metadata, 'commondir'), '../..\n'); + fs.writeFileSync(path.join(metadata, 'gitdir'), `${path.join(linked, '.git')}\n`); + return { main, common, linked, metadata }; +} +function lines(...records) { return records.map((record) => JSON.stringify(record)); } + +test('should_group_nested_worktree_paths_by_verified_common_directory', (t) => { + const { main, linked, common } = worktree(fixture(t)); + const primary = inspectProjectIdentity(main, { observedAt: 17 }); + const nested = inspectProjectIdentity(path.join(linked, 'src'), { observedAt: 17 }); + assert.deepEqual({ kind: nested.kind, id: nested.repositoryId, root: nested.root, + checkout: nested.worktreeRoot, common: nested.commonDir, observedAt: nested.observedAt }, + { kind: 'worktree', id: primary.repositoryId, root: realpath(main), + checkout: realpath(linked), common: realpath(common), observedAt: 17 }); +}); +test('should_group_bare_worktrees_without_inventing_a_main_checkout', (t) => { + const { linked, common } = worktree(fixture(t), { bare: true }); + const identity = inspectProjectIdentity(linked); + assert.deepEqual({ kind: identity.kind, root: identity.root, common: identity.commonDir }, + { kind: 'worktree', root: null, common: realpath(common) }); +}); +test('should_keep_submodule_git_directories_distinct_from_parent_repository', (t) => { + const root = fixture(t), main = path.join(root, 'main'), sub = path.join(main, 'sub'); + fs.mkdirSync(path.join(main, '.git', 'modules', 'sub'), { recursive: true }); + fs.mkdirSync(sub); + fs.writeFileSync(path.join(sub, '.git'), 'gitdir: ../.git/modules/sub\n'); + const identity = inspectProjectIdentity(sub); + assert.equal(identity.kind, 'git'); + assert.notEqual(identity.repositoryId, inspectProjectIdentity(main).repositoryId); +}); +test('should_reject_worktree_association_when_backlink_is_mismatched', (t) => { + const { linked, metadata, main } = worktree(fixture(t)); + fs.writeFileSync(path.join(metadata, 'gitdir'), path.join(main, '.git')); + const identity = inspectProjectIdentity(linked); + assert.deepEqual([identity.kind, identity.repositoryId], ['unknown', null]); +}); +test('should_preserve_unknown_for_missing_named_worktrees_and_unreadable_markers', (t) => { + const root = fixture(t); + fs.mkdirSync(path.join(root, '.git')); + const missing = inspectProjectIdentity(path.join(root, '.worktrees', 'missing')); + assert.equal(missing.repositoryId, null); + const denied = Object.assign(Object.create(fs), { + lstatSync() { throw Object.assign(new Error('denied'), { code: 'EACCES' }); }, + }); + assert.equal(inspectProjectIdentity(root, { fsImpl: denied }).kind, 'unknown'); +}); +test('should_canonicalize_symlink_aliases_without_merging_same_named_repositories', (t) => { + const root = fixture(t), first = path.join(root, 'a', 'same'), second = path.join(root, 'b', 'same'); + fs.mkdirSync(path.join(first, '.git'), { recursive: true }); + fs.mkdirSync(path.join(second, '.git'), { recursive: true }); + fs.symlinkSync(first, path.join(root, 'alias'), process.platform === 'win32' ? 'junction' : 'dir'); + assert.equal(inspectProjectIdentity(first).repositoryId, inspectProjectIdentity(path.join(root, 'alias')).repositoryId); + assert.notEqual(inspectProjectIdentity(first).repositoryId, inspectProjectIdentity(second).repositoryId); +}); +test('should_attribute_only_explicit_desktop_metadata_and_ignore_names_and_ambiguous_sources', () => { + for (const entrypoint of ['claude-desktop', 'claude-desktop-3p', 'remote_desktop']) { + assert.equal(transcriptSessionOrigin(lines({ entrypoint }), 'claude').origin, 'claude-desktop'); + } + for (const originator of ['Codex Desktop', 'codex_work_desktop']) { + assert.equal(transcriptSessionOrigin(lines({ type: 'session_meta', payload: { originator } }), 'codex').origin, 'codex-desktop'); + } + for (const entrypoint of ['sdk-cli', 'sdk-py', 'cli', 'local-agent', 'desktop-like']) { + assert.equal(transcriptSessionOrigin(lines({ entrypoint, cwd: '/Claude Desktop/project' }), 'claude').origin, 'unknown'); + } + assert.equal(transcriptSessionOrigin(lines({ type: 'session_meta', payload: { source: 'vscode' } }), 'codex').origin, 'unknown'); +}); +test('should_latch_first_declared_origin_and_not_promote_later_resumed_metadata', () => { + assert.equal(transcriptSessionOrigin(lines( + { type: 'session_meta', payload: { originator: 'codex-tui' } }, + { type: 'session_meta', payload: { originator: 'Codex Desktop' } }, + ), 'codex').origin, 'unknown'); +}); +test('should_read_origin_from_the_same_bounded_head_without_searching_later_records', (t) => { + const root = fixture(t), file = path.join(root, 'session.jsonl'); + fs.writeFileSync(file, lines( + { type: 'session_meta', payload: { cwd: root, originator: 'Codex Desktop' } }, + { type: 'session_meta', payload: { cwd: '/other', originator: 'codex-tui' } }, + ).join('\n')); + const scan = scanTranscriptCwds(root, 'codex', { maxLines: 1 }); + assert.deepEqual(scan.sightings.map(({ cwd, sessionOrigin }) => [cwd, sessionOrigin.origin]), [[root, 'codex-desktop']]); + fs.writeFileSync(file, lines( + { type: 'turn_context', payload: { cwd: root } }, + { type: 'session_meta', payload: { cwd: root, originator: 'Codex Desktop' } }, + ).join('\n')); + assert.equal(scanTranscriptCwds(root, 'codex', { maxLines: 1 }).sightings[0].sessionOrigin.origin, 'unknown'); +}); +test('should_preserve_path_totals_while_partitioning_overlapping_host_origins', (t) => { + const root = fixture(t), { main, linked } = worktree(root); + const alias = path.join(root, 'alias'); + fs.symlinkSync(main, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const records = { + claude: [ + { cwd: main, origin: 'cwd', sessionOrigin: { origin: 'claude-desktop', evidence: 'entrypoint:claude-desktop' } }, + { cwd: linked, origin: 'cwd' }, + ], + codex: [{ cwd: alias, origin: 'cwd', sessionOrigin: { origin: 'codex-desktop', evidence: 'session_meta.originator:Codex Desktop' } }], + }; + const result = discoverProjectSources({ + scanTranscripts: (_root, host) => ({ sightings: records[host], complete: true }), + scanOpencode: () => ({ sightings: [{ cwd: main, weight: 4 }], complete: true }), + }); + const primary = result.projects.find((row) => row.path === realpath(main)); + assert.equal(result.everSeen, 2); + assert.equal(primary.sessions, 6); + assert.deepEqual(primary.sessionOrigins.map(({ origin, sessions }) => [origin, sessions]), + [['claude-desktop', 1], ['codex-desktop', 1], ['unknown', 4]]); + assert.equal(primary.repository.repositoryId, result.projects.find((row) => row !== primary).repository.repositoryId); + assert.equal(result.projects.reduce((sum, row) => sum + row.sessions, 0), + result.projects.flatMap((row) => row.sessionOrigins).reduce((sum, origin) => sum + origin.sessions, 0)); +}); +test('should_expose_missing_and_unmeasured_catalog_without_changing_measurement_population', (t) => { + const root = fixture(t), missing = path.join(root, 'gone'); + const catalog = [{ path: root, label: 'folder', exists: true, hosts: ['claude'] }, + { path: missing, label: 'gone', exists: false, hosts: ['codex'] }]; + const result = collectProjects({ sources: { projects: catalog, everSeen: 2, onDisk: 1, gitRepos: 0, complete: true } }); + assert.deepEqual(result.discoveryProjects, catalog); + assert.deepEqual([result.everSeen.value, result.onDisk.value, result.projects.length, result.population.excluded.total], [2, 1, 0, 1]); +}); +test('should_qualify_encoded_directory_recovery_as_a_sighting_instead_of_a_verified_session', (t) => { + const root = fixture(t); + const result = discoverProjectSources({ scanTranscripts: (_root, host) => ({ + complete: true, sightings: host === 'claude' ? [{ cwd: root, origin: 'encoded-dir' }] : [], + }), scanOpencode: () => ({ complete: true, sightings: [] }) }); + assert.deepEqual({ sessions: result.projects[0].sessions, origin: result.projects[0].sessionOrigins[0].origin, + countBasis: result.projects[0].sessionOrigins[0].countBasis }, + { sessions: 1, origin: 'unknown', countBasis: 'recovered-project-sighting' }); +}); diff --git a/tests/kit/fixtures/status-golden.json b/tests/kit/fixtures/status-golden.json index 90809861..12b193cd 100644 --- a/tests/kit/fixtures/status-golden.json +++ b/tests/kit/fixtures/status-golden.json @@ -118,5 +118,47 @@ "level": "info", "message": "no project statusline here (created by setup)", "fix": null + }, + { + "subsystem": "context", + "level": "info", + "message": "Host context controls not inspected; live session window and usage unverified", + "fix": null, + "contextReport": { + "schemaVersion": 1, + "observedAt": "", + "hosts": [ + { + "host": "claude", + "label": "Claude", + "enabled": true, + "managed": false, + "state": "not-inspected", + "source": "integration-configuration", + "observedAt": "", + "cacheFetchedAt": null, + "runtimeVerified": false, + "modelCapacity": null, + "configuredRequest": null, + "effectiveWindow": null, + "usage": null, + "compaction": { + "configuredThreshold": null, + "scope": "unverified", + "runtimeThreshold": null, + "control": "not-inspected" + }, + "models": [], + "modelsOmitted": 0, + "nativeControls": "Native model selection, autoCompactWindow and /autocompact; settings not inspected here.", + "inventoryCapturedAt": null, + "inventoryScopeId": null, + "limitations": [ + "Context and compaction configuration not inspected; agentic-kit does not manage these controls.", + "Live session window and usage unverified; historical input observations remain in Usage → Context." + ] + } + ] + } } ] diff --git a/tests/kit/helpers/status-observations.mjs b/tests/kit/helpers/status-observations.mjs new file mode 100644 index 00000000..87cf28b6 --- /dev/null +++ b/tests/kit/helpers/status-observations.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; + +// Collection timestamps legitimately change between read-only inspections. +// Normalize only those fields: source cache dates and every semantic field +// remain part of golden/equivalence assertions. +export function normalizeStatusObservations(rows) { + return rows.map(row => { + if (!row.contextReport) return row; + const report = structuredClone(row.contextReport); + const normalize = record => { + assert.equal(typeof record.observedAt, 'string'); + assert.ok(Number.isFinite(Date.parse(record.observedAt)), 'inspection timestamp must be valid'); + record.observedAt = ''; + }; + normalize(report); + for (const host of report.hosts) normalize(host); + return { ...row, contextReport: report }; + }); +} diff --git a/tests/kit/intel-history.test.mjs b/tests/kit/intel-history.test.mjs index 759336a9..c9d313e0 100644 --- a/tests/kit/intel-history.test.mjs +++ b/tests/kit/intel-history.test.mjs @@ -289,12 +289,12 @@ test('readMachineWideIntel aggregates totals and perProject rows across multiple }); assert.deepEqual(result.perProject, [ { - path: cwdAlpha, label: 'Alpha', patternsLearned: 10, patternStoreCount: 2, + path: cwdAlpha, label: 'Alpha', key: null, learningScope: 'unknown', patternsLearned: 10, patternStoreCount: 2, trajectoriesRecorded: 4, graphLatest: { nodes: 5, edges: 8 }, lastAdaptation: 1000, learningState: [], }, { - path: cwdBeta, label: 'Beta', patternsLearned: 20, patternStoreCount: 3, + path: cwdBeta, label: 'Beta', key: null, learningScope: 'unknown', patternsLearned: 20, patternStoreCount: 3, trajectoriesRecorded: 6, graphLatest: null, lastAdaptation: 2000, learningState: [], }, @@ -355,11 +355,11 @@ test('readMachineWideIntel degrades a project with missing/malformed data to nul mostActiveProject: 'Good', }); assert.deepEqual(result.perProject[1], { - path: cwdEmpty, label: 'Empty', patternsLearned: null, patternStoreCount: 0, + path: cwdEmpty, label: 'Empty', key: null, learningScope: 'unknown', patternsLearned: null, patternStoreCount: 0, trajectoriesRecorded: null, graphLatest: null, lastAdaptation: null, learningState: [], }); assert.deepEqual(result.perProject[2], { - path: cwdMalformed, label: 'Malformed', patternsLearned: null, patternStoreCount: 0, + path: cwdMalformed, label: 'Malformed', key: null, learningScope: 'unknown', patternsLearned: null, patternStoreCount: 0, trajectoriesRecorded: null, graphLatest: null, lastAdaptation: null, learningState: [], }); }); diff --git a/tests/kit/intelligence-picker-groups.test.mjs b/tests/kit/intelligence-picker-groups.test.mjs new file mode 100644 index 00000000..0b50f002 --- /dev/null +++ b/tests/kit/intelligence-picker-groups.test.mjs @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import vm from 'node:vm'; +import { projectCensus, projectsInScope } from '../../src/lib/project-census.mjs'; + +const esc = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); +function picker() { + const elements = { 'intel-project-select': {}, 'history-project-name': {} }; + const source = fs.readFileSync(new URL('../../src/lib/dashboard/client/intelligence.mjs', import.meta.url), 'utf8') + .replace(/^import .*;$/gm, '').replace(/\bexport /g, ''); + const context = vm.createContext({ document: { getElementById: (id) => elements[id] }, esc, + intelProjects: [], selectedProjectKey: null, selectedProjectLabel: null }); + vm.runInContext(`${source}\nglobalThis.renderPicker=renderProjectPicker;`, context); + return { elements, render: context.renderPicker, context }; +} + +test('should_segment_and_alphabetize_picker_options_without_changing_selection_keys', () => { + const { elements, render } = picker(); + render({ selectedProjectKey: 'z', selectedProjectLabel: 'Zulu', projects: [ + { key: 'z', label: 'Zulu', learningScope: 'repository', learningOrigins: ['codex-desktop'] }, + { key: 'a', label: 'alpha', learningScope: 'repository', learningOrigins: ['claude-desktop'] }, + { key: 'u', label: 'Settings', learningScope: 'user' }, + { key: 'w', label: 'Feature', learningScope: 'worktree' }, + { key: 'x', label: 'uuid-looking-123', learningScope: 'unknown' }, + ] }); + const html = elements['intel-project-select'].innerHTML; + for (const label of ['Git repositories', 'Git worktrees', 'User-level learning', 'Other / unclassified']) { + assert.ok(html.includes(``)); + } + assert.ok(html.indexOf('value="a"') < html.indexOf('value="z"')); + assert.match(html, /value="z" selected>Zulu { + const { elements, render } = picker(); + render({ projects: [], selectedProjectKey: null }); + assert.equal(elements['intel-project-select'].disabled, true); + render({ projects: [{ key: 'x', label: '', learningScope: 'unknown' }], selectedProjectKey: 'x' }); + assert.equal(elements['intel-project-select'].disabled, false); + assert.ok(!elements['intel-project-select'].innerHTML.includes(' { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-intel-groups-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const user = path.join(root, 'user'), nested = path.join(user, 'child'), repo = path.join(root, 'repo'); + for (const dir of [user, nested, repo]) fs.mkdirSync(path.join(dir, '.claude-flow'), { recursive: true }); + const alias = path.join(root, 'user-alias'); + fs.symlinkSync(user, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const projects = [user, nested, repo].map((dir) => ({ path: dir, label: path.basename(dir), exists: true, + repository: dir === repo ? { kind: 'git', evidence: 'git-directory' } : { kind: 'folder', evidence: 'no-git-boundary' }, + sessionOrigins: dir === repo ? [{ origin: 'claude-desktop', sessions: 1 }, { origin: 'codex-desktop', sessions: 1 }] : [], + })); + const census = projectCensus({ userRoots: [alias], discover: () => ({ projects, asOf: 17 }) }); + assert.deepEqual(census.projects.map((entry) => entry.learningScope), ['user', 'unknown', 'repository']); + assert.deepEqual(census.projects[2].learningOrigins, ['claude-desktop', 'codex-desktop']); + assert.equal(projectsInScope(census, 'learning').length, 3); +}); +test('should_preserve_merged_learning_anchor_and_origin_memberships_from_each_working_path', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-intel-anchor-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const nested = path.join(root, 'src'); + fs.mkdirSync(path.join(root, '.git')); + for (const dir of [root, nested]) fs.mkdirSync(path.join(dir, '.claude-flow'), { recursive: true }); + const repository = { kind: 'git', evidence: 'git-directory' }; + const census = projectCensus({ userRoots: [], discover: () => ({ asOf: 17, projects: [ + { path: nested, label: 'src', exists: true, repository, sessions: 1, + sessionOrigins: [{ origin: 'claude-desktop', sessions: 1 }] }, + { path: root, label: 'repo', exists: true, repository, sessions: 1, + sessionOrigins: [{ origin: 'codex-desktop', sessions: 1 }] }, + ] }) }); + const [merged] = projectsInScope(census, 'learning'); + assert.equal(merged.path, root); + assert.deepEqual(merged.learningOrigins, ['claude-desktop', 'codex-desktop']); + assert.equal(merged.sessions, 2); + assert.equal(census.learning, 1); +}); diff --git a/tests/kit/intelligence-table-groups.test.mjs b/tests/kit/intelligence-table-groups.test.mjs new file mode 100644 index 00000000..5124824c --- /dev/null +++ b/tests/kit/intelligence-table-groups.test.mjs @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import vm from 'node:vm'; +import { readMachineWideIntel } from '../../src/lib/dashboard/intel-history.mjs'; + +test('machine-wide rows retain their own scope and key without name-based attribution', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-intel-table-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const rows = [{ path: path.join(root, 'repo'), key: 'repository-key', label: 'same', learningScope: 'repository' }, + { path: path.join(root, 'user'), key: 'user-key', label: 'same', learningScope: 'user' }, + { path: path.join(root, 'missing'), key: 'unknown-key', label: 'same' }]; + const result = readMachineWideIntel(rows); + assert.deepEqual(result.perProject.map((row) => [row.key, row.learningScope]), + [['repository-key', 'repository'], ['user-key', 'user'], ['unknown-key', 'unknown']]); + assert.equal(result.totals.projectCount, 3); +}); + +test('machine-wide groups alphabetize every retained row and preserve KPI totals', () => { + const elements = { 'mw-table': {}, 'mw-hero': {} }; + const source = fs.readFileSync(new URL('../../src/lib/dashboard/client/intelligence.mjs', import.meta.url), 'utf8') + .replace(/^import .*;$/gm, '').replace(/\bexport /g, ''); + const esc = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); + const context = vm.createContext({ document: { getElementById: (id) => elements[id] }, esc, + fmtNum: (value) => String(value ?? 0), kpi: (label, value) => `${label}:${value};` }); + vm.runInContext(`${source}\nglobalThis.renderTable=renderMachineWide;`, context); + const perProject = Array.from({ length: 8 }, (_, i) => ({ key: `key-${i}`, label: `Repository ${8 - i}`, + learningScope: 'repository', patternsLearned: i, patternStoreCount: 1 })); + perProject.push({ key: 'user', label: '', learningScope: 'user' }); + context.renderTable({ totals: { patternsLearnedLifetime: 28, projectCount: 9, mostActiveProject: 'Repository 8' }, perProject }); + const html = elements['mw-table'].innerHTML; + assert.match(html, /Git repositories/); + assert.match(html, /User-level learning/); + assert.equal((html.match(/class="mw-row mw-data-row"/g) || []).length, 9); + assert.ok(html.indexOf('Repository 1') < html.indexOf('Repository 8')); + assert.ok(html.includes('title="<User>"')); + assert.ok(html.includes('role="columnheader"')); + assert.ok(html.includes('tabindex="0"')); + assert.equal(elements['mw-hero'].innerHTML, 'patterns learned:28;projects tracked:9;most active project:Repository 8;'); +}); diff --git a/tests/kit/maintenance-dashboard-v2-api.test.mjs b/tests/kit/maintenance-dashboard-v2-api.test.mjs index 2fc30888..d3598970 100644 --- a/tests/kit/maintenance-dashboard-v2-api.test.mjs +++ b/tests/kit/maintenance-dashboard-v2-api.test.mjs @@ -597,7 +597,8 @@ test('v2 inventory projection keeps the nine inspector sections and the page env assert.equal(page.total, 2); assert.equal(page.groups.length, 1); assert.deepEqual(Object.keys(page.groups[0].placements[0]).sort(), [ - 'breadcrumb', 'carrier', 'consumerHosts', 'displayName', 'guidanceLane', 'kind', 'placementId', 'projectId', 'projectKind', 'rowAction', 'scope', 'versions', + 'breadcrumb', 'carrier', 'consumerHosts', 'displayName', 'guidanceLane', 'kind', 'placementId', 'projectId', 'projectKind', + 'repositoryEvidence', 'repositoryId', 'repositoryLabel', 'repositoryObservedAt', 'rowAction', 'scope', 'sessionOrigins', 'versions', ]); const inspector = publicInspector(inspectorFor(inventory, PLACEMENT)); assert.deepEqual(Object.keys(inspector).sort(), [ diff --git a/tests/kit/maintenance-focus-client.test.mjs b/tests/kit/maintenance-focus-client.test.mjs index da1aa6f8..6f778dca 100644 --- a/tests/kit/maintenance-focus-client.test.mjs +++ b/tests/kit/maintenance-focus-client.test.mjs @@ -7,7 +7,7 @@ function load(name,deps,exports){ const source=fs.readFileSync(new URL('../../src/lib/dashboard/client/'+name+'.mjs',import.meta.url),'utf8').replace(/^import\s[\s\S]*?from ['"][^'"]+['"];\s*$/gm,'').replace(/\bexport (?=(?:function|var)\b)/g,''); return new Function(...Object.keys(deps),source+'\nreturn {'+exports.join(',')+'};')(...Object.values(deps)); } -function focus(state){return load('maintenance-focus',{MNT:state,esc,mntLanguageLogo,MNT_SCOPE_LABELS:{user:'User',across:'All scopes',project:'Projects'},mntKindLabel:s=>s,mntFacetValueLabel:(_,v)=>v,mntIcon:()=>'',mntAvailableTo:()=>''},['mntFocusChoose','mntFocusBack','mntFocusCrumbs','renderMntFocusResults']);} +function focus(state){return load('maintenance-focus',{MNT:state,esc,mntLanguageLogo,MNT_SCOPE_LABELS:{user:'User',across:'All scopes',project:'Projects'},mntKindLabel:s=>s,mntFacetValueLabel:(_,v)=>v,mntIcon:()=>'',mntProjectKindBadge:kind=>esc(kind),mntAvailableTo:()=>''},['mntFocusChoose','mntFocusBack','mntFocusCrumbs','renderMntFocusResults']);} test('navigation turns User and resource type into explicit filters while retaining host refinements',()=>{ const state={scope:'across',facets:{consumer:['claude']}};const api=focus(state); api.mntFocusChoose('scope','user');api.mntFocusChoose('kind','mcp-registration');api.mntFocusChoose('resource','res_1'); @@ -37,11 +37,19 @@ test('resource cards separate source from name and escape declared descriptions' const html=focus(state).renderMntFocusResults(false); assert.match(html,/Provided by brain/);assert.match(html,/<script>text<\/script>/);assert.match(html,/title="Plugin manifest"/); }); -test('polyglot project cards show three labelled icons and expand the remaining languages',()=>{ +test('polyglot project cards show all labelled icons without a language disclosure',()=>{ const languages=['Java','TypeScript','SQL','Python'].map((name,i)=>({id:String(i),name,icon:name.slice(0,2),evidence:'source'})); const state={facets:{},query:{navigation:{level:'project',nodes:[{value:'prj_1',label:'Polyglot',count:2,projectKind:'git',languages}]},groups:[]}}; const html=focus(state).renderMntFocusResults(false); assert.match(html,/mnt-language-icon/);assert.match(html,/]*src="data:image\/svg\+xml;base64,/);assert.match(html,/alt="Java"/);assert.doesNotMatch(html,/>Ja<|>Java<|>Python\+1 more languages/); + assert.equal((html.match(/class="mnt-language-icon"/g)||[]).length,4); + assert.doesNotMatch(html,/mnt-language-more/); assert.match(html,/Python/);assert.doesNotMatch(html,/]*>[^]*/); }); + +test('unclassified project cards omit origin noise and implementation guidance',()=>{ + const state={facets:{},query:{navigation:{level:'project',nodes:[{value:'prj_1',label:'Project',count:1,projectKind:'git'}]},groups:[]}}; + const html=focus(state).renderMntFocusResults(false); + assert.doesNotMatch(html,/Sessions:|mnt-project-origins|Projects appear once|Repository association unknown/); + assert.match(html,/Other projects/); +}); diff --git a/tests/kit/maintenance-management-projection.test.mjs b/tests/kit/maintenance-management-projection.test.mjs index aedf9b46..f103e70a 100644 --- a/tests/kit/maintenance-management-projection.test.mjs +++ b/tests/kit/maintenance-management-projection.test.mjs @@ -501,12 +501,13 @@ test('MNT-INV-010: two projects sharing a basename get the shortest distinguishi assert.ok(inventory); // the first (no-projects) call still produced a valid inventory }); -test('MNT-INV-011: linked worktrees group under one repositoryId; an unrelated project does not', () => { +test('MNT-INV-011: verified common Git identity groups worktrees; an unrelated project does not', () => { + const repository = { kind: 'git', repositoryId: 'repository:0123456789abcdef0123', root: '/repo/main', evidence: 'git-directory', observedAt: 17 }; const footprint = { projects: { projects: [ - { path: '/repo/main', label: 'kit', hosts: ['claude'], remote: { status: 'linked', webUrl: 'https://github.com/a/kit' } }, - { path: '/repo/feature-wt', label: 'kit', hosts: ['claude'], remote: { status: 'linked', webUrl: 'https://github.com/a/kit' } }, + { path: '/repo/main', label: 'kit', hosts: ['claude'], repository, remote: { status: 'linked', webUrl: 'https://github.com/a/kit' } }, + { path: '/repo/feature-wt', label: 'kit', hosts: ['claude'], repository: { ...repository, kind: 'worktree', evidence: 'git-common-directory-and-backlink' }, remote: { status: 'linked', webUrl: 'https://github.com/a/kit' } }, { path: '/elsewhere/kit', label: 'kit', hosts: ['claude'], remote: null }, ], }, diff --git a/tests/kit/maintenance-project-grouping.test.mjs b/tests/kit/maintenance-project-grouping.test.mjs new file mode 100644 index 00000000..4169a9c5 --- /dev/null +++ b/tests/kit/maintenance-project-grouping.test.mjs @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildManagementInventory } from '../../src/lib/maintenance/management/projection.mjs'; +import { runInventoryQuery } from '../../src/lib/maintenance/management/query.mjs'; +import { publicInventoryPage } from '../../src/lib/dashboard/maintenance-api.mjs'; +import { validateMaintenanceV2Query } from '../../src/lib/dashboard/maintenance-security.mjs'; + +const options = { installationKey: 'maintenance-grouping-fixture-key', environment: { platform: 'darwin' }, now: () => 1700000000000 }; +const repository = { kind: 'git', repositoryId: 'repository:0123456789abcdef0123', root: '/work/repo', + commonDir: '/work/repo/.git', evidence: 'git-directory', observedAt: 1700000000000 }; +function catalog(paths) { + return { items: paths.map((project, index) => ({ + canonicalId: `skill::skill-${index}`, kind: 'skill', name: `skill-${index}`, capabilityName: `skill-${index}`, + presence: [{ host: 'claude', scope: 'project', project, artifactId: `artifact-${index}`, + consumer: { mechanism: 'claude-project-skill', enabled: true, configScope: 'project' } }], + })) }; +} +function build(projects, paths) { + return buildManagementInventory({ ...options, footprint: { projects, catalog: catalog(paths) } }).inventory; +} +test('should_enrich_existing_fallback_identities_without_changing_installation_totals', () => { + const paths = ['/work/repo', '/work/checkout', '/work/unknown']; + const before = build({ projects: [] }, paths); + const after = build({ projects: [], discoveryProjects: [ + { path: paths[0], repository, sessionOrigins: [{ origin: 'claude-desktop', sessions: 2 }, { origin: 'codex-desktop', sessions: 3 }] }, + { path: paths[1], repository: { ...repository, kind: 'worktree', evidence: 'git-common-directory-and-backlink' }, sessionOrigins: [{ origin: 'unknown', sessions: 1 }] }, + ] }, paths); + assert.deepEqual(after.placements.map((row) => row.placementId), before.placements.map((row) => row.placementId)); + const page = publicInventoryPage(runInventoryQuery(after, { scope: 'project', presentation: 'focus', includeWorktrees: true })); + assert.equal(page.total, 3); + assert.equal(page.navigation.nodes.length, 3); + const grouped = page.navigation.nodes.filter((node) => node.repositoryId); + assert.equal(grouped.length, 2); + assert.equal(grouped[0].repositoryId, grouped[1].repositoryId); + assert.equal(grouped[0].repositoryLabel, 'repo'); + assert.equal(grouped[0].repositoryObservedAt, 1700000000000); + assert.equal(JSON.stringify(page).includes('/work/'), false); +}); +test('should_never_associate_independent_clones_only_because_remotes_match', () => { + const projects = ['/clone/one', '/clone/two'].map((path) => ({ path, label: 'clone', hosts: ['claude'], + remote: { status: 'linked', webUrl: 'https://github.com/example/repo' } })); + const inventory = build({ projects }, projects.map((row) => row.path)); + assert.ok(inventory.placements.every((row) => !row.repositoryId)); +}); +test('should_filter_origin_memberships_as_overlapping_facets_without_duplicate_placements', () => { + const paths = ['/one', '/two']; + const inventory = build({ projects: [], discoveryProjects: [{ path: paths[0], repository, + sessionOrigins: [{ origin: 'claude-desktop', sessions: 2 }, { origin: 'codex-desktop', sessions: 3 }] }] }, paths); + const query = validateMaintenanceV2Query('inventory', new URLSearchParams('scope=project&presentation=focus&facet.sessionOrigin=claude-desktop')); + const claude = publicInventoryPage(runInventoryQuery(inventory, query)); + assert.equal(claude.total, 1); + assert.deepEqual(claude.navigation.nodes[0].sessionOrigins, + [{ origin: 'claude-desktop', sessions: 2 }, { origin: 'codex-desktop', sessions: 3 }]); + const either = runInventoryQuery(inventory, { scope: 'project', facets: { sessionOrigin: ['claude-desktop', 'codex-desktop'] } }); + assert.equal(either.total, 1); + assert.equal(runInventoryQuery(inventory, { scope: 'project', facets: { sessionOrigin: ['unknown'] } }).total, 1); +}); +test('should_report_session_counts_once_per_project_despite_multiple_installed_resources', () => { + const inventory = build({ projects: [], discoveryProjects: [{ path: '/project', repository, + sessionOrigins: [{ origin: 'codex-desktop', sessions: 7 }] }] }, ['/project', '/project']); + const page = publicInventoryPage(runInventoryQuery(inventory, { scope: 'project', presentation: 'focus' })); + assert.equal(page.navigation.nodes[0].count, 2); + assert.equal(page.navigation.nodes[0].sessionOrigins[0].sessions, 7); +}); diff --git a/tests/kit/status-command.test.mjs b/tests/kit/status-command.test.mjs index a6bdff1e..93095a39 100644 --- a/tests/kit/status-command.test.mjs +++ b/tests/kit/status-command.test.mjs @@ -4,6 +4,7 @@ // exactly when `ak sync` should act — a stray `fix` on a row the user // deliberately disabled makes sync heal something they turned off. import { test } from 'node:test'; +import { normalizeStatusObservations } from './helpers/status-observations.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -986,7 +987,8 @@ test('collect(): with nothing admitted, rows are unaffected (flag-off/no-admitte seedHome(); const before = await collect(); const after = await withFlag('1', async () => collect()); - assert.deepEqual(after, before, 'no admitted lifecycle host registered — the fallback must add nothing'); + assert.deepEqual(normalizeStatusObservations(after), normalizeStatusObservations(before), + 'no admitted lifecycle host registered — the fallback must add nothing beyond a new inspection time'); }); test('collect(): a built-in host (opencode) never gets the generic admitted-host fallback row, even when enabled', async () => { diff --git a/tests/kit/status-golden.test.mjs b/tests/kit/status-golden.test.mjs index c2c8a691..106c66b3 100644 --- a/tests/kit/status-golden.test.mjs +++ b/tests/kit/status-golden.test.mjs @@ -8,6 +8,7 @@ // Regenerate deliberately, never casually: // STATUS_GOLDEN_UPDATE=1 node --test tests/kit/status-golden.test.mjs import { test } from 'node:test'; +import { normalizeStatusObservations } from './helpers/status-observations.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -31,7 +32,7 @@ paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': // The kit's own version appears in the `self` row and changes every release; // pin it to a token so the golden survives version bumps. const SELF_VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version; -const normalize = (rows) => rows.map((r) => ({ +const normalize = (rows) => normalizeStatusObservations(rows).map((r) => ({ ...r, message: r.message.split(SELF_VERSION).join(''), })); diff --git a/tests/kit/usage-git-projects.test.mjs b/tests/kit/usage-git-projects.test.mjs new file mode 100644 index 00000000..672cc6af --- /dev/null +++ b/tests/kit/usage-git-projects.test.mjs @@ -0,0 +1,78 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { buildUsageGitProjects } from '../../src/lib/usage-project-groups.mjs'; +import { observeUsageProject } from '../../src/lib/usage-project-evidence.mjs'; +import { aggregate } from '../../src/lib/usage-aggregate.mjs'; +import { blankSession, addUsage } from '../../src/lib/usage-parsers.mjs'; + +const id = (n) => `repository:${n.toString(16).padStart(20, '0')}`; +function record(n, cost, extra = {}) { + return { id: `session-${n}`, cost, minutes: 2, tokens: 100, project: `legacy-${n}`, + projectEvidence: { repositoryId: id(n), repositoryLabel: `Repo ${n}`, kind: 'git', + parentRootExists: true, userLevel: false, evidence: 'git-directory', ...extra } }; +} +test('should_rank_twelve_verified_git_projects_without_truncating_backend_candidates', () => { + const rows = buildUsageGitProjects(Array.from({ length: 12 }, (_, i) => record(i, i + 1))); + assert.equal(rows.length, 12); + assert.deepEqual(rows.map((row) => row.cost), [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]); + assert.deepEqual(Object.keys(rows[0]).sort(), ['key', 'label', 'cost', 'sessions', 'minutes', 'tokens'].sort()); +}); +test('should_fold_verified_worktree_usage_into_its_real_parent_once', () => { + const sessions = [record(1, 3), record(1, 5, { kind: 'worktree', evidence: 'git-common-directory-and-backlink' })]; + const before = structuredClone(sessions); + assert.deepEqual(buildUsageGitProjects(sessions), [{ key: id(1), label: 'Repo 1', cost: 8, sessions: 2, minutes: 4, tokens: 200 }]); + assert.deepEqual(sessions, before); +}); +test('should_exclude_missing_unknown_user_and_parentless_worktree_evidence_without_name_heuristics', () => { + const sessions = [record(1, 1, { parentRootExists: false }), record(2, 2, { userLevel: true }), + record(3, 3, { kind: 'worktree', parentRootExists: false }), record(4, 4, { parentRootExists: undefined }), + record(5, 5, { kind: 'unknown' }), { id: 'legacy', cost: 6, project: 'real-looking-repo' }, + record(6, 7, { repositoryLabel: 'agent-real-repository' })]; + assert.deepEqual(buildUsageGitProjects(sessions).map((row) => row.label), ['agent-real-repository']); +}); +test('should_observe_a_real_parent_root_and_exclude_only_exact_canonical_user_roots', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-usage-git-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const parent = path.join(root, 'parent'), tree = path.join(root, 'agent-123'), user = path.join(root, 'user'); + for (const dir of [parent, user]) { + fs.mkdirSync(path.join(dir, '.git'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n'); + } + const target = path.join(parent, '.git', 'worktrees', 'agent-123'); + fs.mkdirSync(target, { recursive: true }); + fs.mkdirSync(tree); + fs.writeFileSync(path.join(tree, '.git'), `gitdir: ${target}\n`); + fs.writeFileSync(path.join(target, 'commondir'), '../..\n'); + fs.writeFileSync(path.join(target, 'gitdir'), path.join(tree, '.git')); + const alias = path.join(root, 'user-alias'); + fs.symlinkSync(user, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const options = { observedAt: 17, cache: new Map(), userRoots: [alias] }; + const main = observeUsageProject(parent, options), linked = observeUsageProject(tree, options); + assert.equal(main.parentRootExists, true); + assert.equal(linked.parentRootExists, true); + assert.equal(main.repositoryId, linked.repositoryId); + assert.equal(main.userLevel, false); + assert.equal(observeUsageProject(user, options).userLevel, true); + fs.mkdirSync(path.join(root, 'incomplete', '.git'), { recursive: true }); + assert.equal(observeUsageProject(path.join(root, 'incomplete'), options).parentRootExists, false); +}); +test('should_limit_git_ranking_to_active_window_sessions_while_preserving_overall_non_git_usage', () => { + const now = Date.parse('2026-09-09T00:00:00Z'), day = 86400000; + const make = (name, age, evidence) => { + const rec = blankSession(name, 'claude'); + Object.assign(rec, { project: name, responses: 1, start: now - age - 60000, end: now - age, projectEvidence: evidence }); + addUsage(rec, '2026-09-08', 'model', { input: 100, output: 20, cacheRead: 0, cacheWrite: 0, responses: 1 }); + return rec; + }; + const evidence = record(1, 1).projectEvidence; + const projection = aggregate([make('current-git', day, evidence), make('older-git', 30 * day, evidence), make('current-folder', day, null)], + { days: 7, now, cutoff: now - 7 * day, deps: { pricesAsOf: null, costOf: () => 2, + classify: () => ({ category: 'Build', confidence: 1 }), detectInsights: () => [] } }); + assert.equal(projection.totals.cost, 4); + assert.equal(projection.totals.sessions, 2); + assert.deepEqual(projection.gitProjects, [{ key: id(1), label: 'Repo 1', cost: 2, sessions: 1, minutes: 1, tokens: 120 }]); + assert.equal(projection.byProject['current-folder'].cost, 2); +}); diff --git a/tests/kit/usage-project-groups.test.mjs b/tests/kit/usage-project-groups.test.mjs new file mode 100644 index 00000000..7de818bd --- /dev/null +++ b/tests/kit/usage-project-groups.test.mjs @@ -0,0 +1,93 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import vm from 'node:vm'; +import { buildUsageProjectGroups } from '../../src/lib/usage-project-groups.mjs'; +import { observeUsageProject } from '../../src/lib/usage-project-evidence.mjs'; +import { parseClaude, parseCodex, blankSession, addUsage } from '../../src/lib/usage-parsers.mjs'; +import { aggregate } from '../../src/lib/usage-aggregate.mjs'; + +const repositoryId = 'repository:0123456789abcdef0123'; +function session(id, cost, evidence, origin = 'unknown') { + return { id, host: origin === 'codex-desktop' ? 'codex' : 'claude', project: 'same-name', cost, minutes: 2, tokens: 100, + projectEvidence: evidence, sessionOrigin: { origin } }; +} +test('should_group_verified_worktrees_and_preserve_independent_desktop_memberships_without_double_counting', () => { + const input = [session('a', 2, { key: 'path:a', label: 'main', repositoryId, repositoryLabel: 'Repo', kind: 'git' }, 'claude-desktop'), + session('b', 3, { key: 'path:b', label: 'feature', repositoryId, repositoryLabel: 'Repo', kind: 'worktree' }, 'codex-desktop'), + session('c', 5, { key: 'path:c', label: 'same-name', kind: 'folder' })]; + const groups = buildUsageProjectGroups(input); + assert.equal(groups.length, 2); + assert.equal(groups.reduce((sum, group) => sum + group.cost, 0), 10); + assert.equal(groups.reduce((sum, group) => sum + group.sessions, 0), 3); + const repo = groups.find((group) => group.key === repositoryId); + assert.equal(repo.members.length, 2); + assert.deepEqual(repo.origins, ['claude-desktop', 'codex-desktop']); + assert.equal(repo.members.reduce((sum, member) => sum + member.tokens, 0), repo.tokens); +}); +test('should_preserve_existing_totals_and_byProject_while_grouping_only_the_filtered_window', () => { + const now = Date.parse('2026-09-09T00:00:00Z'), day = 86400000; + const make = (id, age) => { + const rec = blankSession(id, 'claude'); + Object.assign(rec, { project: 'legacy-label', responses: 1, start: now - age - 60000, end: now - age }); + addUsage(rec, '2026-09-08', 'model', { input: 100, output: 20, responses: 1 }); + return rec; + }; + const records = [make('current', day), make('old', 30 * day)]; + const opts = { days: 7, now, cutoff: now - 7 * day, deps: { pricesAsOf: null, costOf: () => 2, + classify: () => ({ category: 'Build', confidence: 1 }), detectInsights: () => [] } }; + const before = aggregate(records, opts); + records[0].projectEvidence = { key: 'working:a', label: 'repo', repositoryId, repositoryLabel: 'Repository', kind: 'git' }; + const after = aggregate(records, opts); + assert.deepEqual(after.totals, before.totals); + assert.deepEqual(after.byProject, before.byProject); + assert.equal(after.projectGroups[0].sessions, 1); + assert.equal(after.projectGroups[0].cost, after.totals.cost); + assert.deepEqual(after.projectGroups[0].members[0].sessionRefs.map((ref) => ref.id), ['current']); + after.sessions[0].projectEvidence.label = 'changed'; + assert.equal(records[0].projectEvidence.label, 'repo'); +}); +test('should_render_only_ten_ranked_git_projects_and_escape_labels', () => { + const elements = { 'u-projects': {}, 'u-projects-note': {} }; + const source = fs.readFileSync(new URL('../../src/lib/dashboard/client/usage.mjs', import.meta.url), 'utf8') + .replace(/^import .*;$/gm, '').replace(/\bexport /g, ''); + const esc = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); + const context = vm.createContext({ window: {}, document: { getElementById: (id) => elements[id] }, esc, formatLocalDateTime: () => null }); + vm.runInContext(`${source}\nglobalThis.renderProjects=renderScoreProjects;`, context); + const gitProjects = Array.from({length:12},(_,i)=>({key:'repo-'+i,label:'Repo '+i+'',cost:i+1,sessions:1,minutes:1})); + context.renderProjects({gitProjects,byProject:{'agent-xxx':{cost:1000}}}); + assert.match(elements['u-projects-note'].textContent,/top 10 of 12/); + assert.doesNotMatch(elements['u-projects'].innerHTML,/Show all|Desktop||agent-xxx/); + assert.match(elements['u-projects'].innerHTML,/Repo 11&lt;|Repo 11</); + assert.doesNotMatch(elements['u-projects'].innerHTML,/Repo 0</); +}); +test('should_keep_same_named_paths_separate_and_retain_every_legacy_session_in_unclassified_group', () => { + const groups = buildUsageProjectGroups([session('one', 1, { key: 'path:a', label: 'same', kind: 'folder' }), + session('two', 2, { key: 'path:b', label: 'same', kind: 'folder' }), session('legacy-a', 3), session('legacy-b', 4)]); + assert.equal(groups.length, 3); + assert.equal(groups.find((group) => group.kind === 'unknown').sessions, 2); + assert.equal(groups.reduce((sum, group) => sum + group.cost, 0), 10); +}); +test('should_aggregate_only_supplied_window_sessions_without_mutating_them', () => { + const input = [session('current', 5, { key: 'path:a', label: 'repo', kind: 'folder' })]; + const before = structuredClone(input); + assert.equal(buildUsageProjectGroups(input)[0].sessions, 1); + assert.deepEqual(input, before); + assert.deepEqual(buildUsageProjectGroups([]), []); +}); +test('should_record_opaque_current_filesystem_evidence_and_explicit_origin_without_exposing_cwd', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-usage-project-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, '.git')); + const evidence = observeUsageProject(root, { observedAt: 17, cache: new Map() }); + assert.equal(evidence.kind, 'git'); + assert.equal(evidence.observedAt, 17); + assert.equal(JSON.stringify(evidence).includes(root), false); + const claude = parseClaude(JSON.stringify({ type: 'user', cwd: root, entrypoint: 'claude-desktop', timestamp: '2026-09-09T00:00:00Z' }), { id: 'a' }).session; + const codex = parseCodex(JSON.stringify({ type: 'session_meta', payload: { id: 'b', cwd: root, originator: 'Codex Desktop' } }), { id: 'b' }).session; + assert.equal(claude.sessionOrigin.origin, 'claude-desktop'); + assert.equal(codex.sessionOrigin.origin, 'codex-desktop'); + assert.equal(claude.projectEvidence.repositoryId, codex.projectEvidence.repositoryId); +}); diff --git a/tests/ui/context-coverage.mjs b/tests/ui/context-coverage.mjs new file mode 100644 index 00000000..278d1c6e --- /dev/null +++ b/tests/ui/context-coverage.mjs @@ -0,0 +1,45 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import {chromium} from 'playwright'; +import {renderPage} from '../../src/lib/dashboard/page.mjs'; +import {buildContextProjection} from '../../src/lib/usage-context.mjs'; +import {blankSession,noteContextSample} from '../../src/lib/usage-parsers.mjs'; +const claude=blankSession('cl','claude'),codex=blankSession('cx','codex'),older=blankSession('old','codex'); +noteContextSample(claude,326000);noteContextSample(codex,90000,100000);noteContextSample(older,50000); + +test('Context distinguishes input-only, partial paired coverage and no sessions without zero windows',async t=>{ + const browser=await chromium.launch({channel:'chrome',headless:true});t.after(()=>browser.close()); + const page=await browser.newPage({viewport:{width:1440,height:1000}}),errors=[]; + page.on('pageerror',error=>errors.push(error.message)); + const projection=buildContextProjection([claude,codex,older],{windowDays:30}); + await page.route('http://context-coverage.test/**',async route=>{ + const url=new URL(route.request().url()); + if(url.pathname==='/')return route.fulfill({contentType:'text/html',body:renderPage({name:'Context coverage fixture',version:'test'})}); + const body=url.pathname==='/api/usage'?{context:projection,totals:{},sessions:[]}: + url.pathname==='/api/status'?{overall:'ok',rows:[]}:{}; + return route.fulfill({contentType:'application/json',body:JSON.stringify(body)}); + }); + await page.emulateMedia({reducedMotion:'reduce',colorScheme:'dark'}); + await page.goto('http://context-coverage.test/#token=fixture'); + await page.click('#tab-usage');await page.click('#usage-tab-context'); + await page.locator('#u-ctx-hosts .ctx-card').first().waitFor(); + const cards=page.locator('#u-ctx-hosts .ctx-card'); + assert.equal(await cards.count(),3); + assert.match(await cards.nth(0).innerText(),/Input only/); + assert.equal(await cards.nth(0).locator('[role="meter"]').count(),0); + assert.equal(await cards.nth(0).locator('.ctx-facts dd').nth(3).innerText(),'—'); + assert.match(await cards.nth(1).innerText(),/Partial coverage/); + assert.equal(await cards.nth(1).locator('[role="meter"]').getAttribute('aria-valuenow'),'90.0'); + assert.match(await cards.nth(1).innerText(),/1 of 2 sessions/); + assert.match(await cards.nth(2).innerText(),/No sessions in the selected timeframe/); + assert.equal(await cards.nth(2).locator('.ctx-facts dd').nth(2).innerText(),'—'); + const shots=process.env.AK_UI_ARTIFACTS;if(shots)fs.mkdirSync(shots,{recursive:true}); + for(const width of [1440,390]){ + await page.setViewportSize({width,height:1000}); + assert.equal(await page.evaluate(()=>globalThis.document.documentElement.scrollWidth<=globalThis.innerWidth),true); + if(shots)await page.screenshot({path:path.join(shots,'context-coverage-'+width+'.png'),fullPage:true,animations:'disabled'}); + } + assert.deepEqual(errors,[]); +}); diff --git a/tests/ui/dashboard-project-context.mjs b/tests/ui/dashboard-project-context.mjs new file mode 100644 index 00000000..79a67aa9 --- /dev/null +++ b/tests/ui/dashboard-project-context.mjs @@ -0,0 +1,80 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import {chromium} from 'playwright'; +import {renderPage} from '../../src/lib/dashboard/page.mjs'; +import {buildContextReport} from '../../src/lib/context-report.mjs'; + +const measured=value=>({status:'measured',value}); +const repo={kind:'git',repositoryId:'repo-one',root:'/projects/example',commonDir:'/projects/example/.git'}; +const main={path:'/projects/example',label:'example',hosts:['claude'],remote:{status:'linked',webUrl:'https://example.com/repo'},repository:repo, + sessionOrigins:[{origin:'claude-desktop',sessions:2},{origin:'unknown',sessions:1}],totalBytes:measured(1024),loc:{total:measured(42),languages:[]}}; +const work={path:'/worktrees/feature',label:'feature',repository:{...repo,kind:'worktree'},sessionOrigins:[{origin:'codex-desktop',sessions:1}]}; +const other={path:'/missing/project',label:'Unavailable folder',repository:{kind:'unknown'},sessionOrigins:[{origin:'unknown',sessions:1}]}; +const capturedAt='2026-09-09T14:00:00Z'; +const catalogModels=['claude','opencode'].map(host=>({key:{host,modelId:host+'-example',scopeId:'fixture-scope',provider:host==='opencode'?'example':null}, + capabilities:{contextLimit:200000,outputLimit:32000},evidence:['contextLimit','outputLimit'].map(field=>({field:'capabilities.'+field, + source:host==='claude'?'anthropic-docs':'opencode-models',capturedAt,scopeFingerprint:'fixture-scope',freshness:'fresh'}))})); +const modelSnapshot={capturedAt,scope:{fingerprint:'fixture-scope'},models:catalogModels, + sources:['anthropic-docs','opencode-models'].map(id=>({id,scopeFingerprint:'fixture-scope'}))}; +const report=buildContextReport({integrations:{hosts:{claude:true,codex:true,opencode:true}},codexContext:{}},{available:true,configuredWindow:1000000, + cacheFetchedAt:'2026-09-09T14:00:00Z',models:Array.from({length:12},(_,i)=>({model:'model-'+i,nativeWindow:200000,maximumWindow:1000000,effectiveWindow:950000}))},{now:Date.parse(capturedAt),modelSnapshot}); + +test('context and project grouping stay readable, keyboard operable and evidence-aware',async t=>{ + const browser=await chromium.launch({channel:'chrome',headless:true});t.after(()=>browser.close()); + const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[]; + page.on('pageerror',e=>errors.push(e.message)); + let projects={projects:[main],discoveryProjects:[main,work,other],everSeen:measured(3),onDisk:measured(2),count:measured(3)}; + await page.route('http://dashboard.test/**',async route=>{ + const url=new URL(route.request().url()); + if(url.pathname==='/')return route.fulfill({contentType:'text/html',body:renderPage({name:'Dashboard fixture',version:'test'})}); + const body=url.pathname==='/api/status'?{overall:'ok',rows:[{subsystem:'codex-context',level:'ok',message:'configured',contextReport:report},{subsystem:'codex-context/model',level:'info',message:'legacy model'}]}: + url.pathname==='/api/system'?{projects,runtime:{},snapshot:null,scan:null}:{}; + return route.fulfill({contentType:'application/json',body:JSON.stringify(body)}); + }); + await page.emulateMedia({reducedMotion:'reduce',colorScheme:'dark'}); + await page.goto('http://dashboard.test/#token=fixture'); + await page.click('[data-overview-view="runtime"]'); + await page.waitForSelector('#cards-runtime .context-card'); + assert.equal(await page.locator('#cards-runtime .context-card').count(),1); + assert.doesNotMatch(await page.locator('#cards-runtime .context-card').innerText(),/Current usage|Reporting limits/); + const card=await page.locator('#cards-runtime .context-card').boundingBox();assert.ok(card.height<520,JSON.stringify(card)); + await page.locator('#cards-runtime .context-host').filter({hasText:'Codex'}).locator('details summary').focus();await page.keyboard.press('Enter'); + assert.equal(await page.locator('#cards-runtime .context-host').filter({hasText:'Codex'}).locator('.context-model-scroll tbody tr').count(),12); + assert.ok((await page.locator('#cards-runtime .context-host').filter({hasText:'Codex'}).locator('.context-model-scroll').boundingBox()).height<=220); + const shots=path.resolve(process.env.AK_DASHBOARD_EVIDENCE_DIR || '.ui-artifacts/project-context');fs.mkdirSync(shots,{recursive:true}); + await page.screenshot({path:path.join(shots,'context-models-desktop.png'),fullPage:true,animations:"disabled"}); + await page.locator('#cards-runtime .context-host').filter({hasText:'Codex'}).locator('details summary').click(); + await page.screenshot({path:path.join(shots,'context-desktop.png'),fullPage:true,animations:"disabled"}); + await page.locator('#cards-runtime [data-model-inventory]').click(); + await page.waitForSelector('#v-models',{state:'visible'}); + assert.equal(await page.locator('#usage-tab-models').getAttribute('aria-selected'),'true'); + assert.equal(await page.evaluate(()=>globalThis.document.activeElement.id),'usage-tab-models'); + await page.click('#tab-system');await page.click('[data-system-view="projects"]'); + await page.waitForSelector('#project-population'); + assert.equal(await page.locator('#sys-projects tbody tr:not(.project-group)').count(),1); + await page.selectOption('#project-population','all'); + assert.equal(await page.locator('#sys-projects tbody tr:not(.project-group)').count(),3); + assert.equal(await page.locator('#sys-projects .project-group').count(),0); + assert.equal(await page.locator('#sys-projects tbody').count(),2); + await page.selectOption('#project-origin','codex-desktop'); + assert.equal(await page.locator('#sys-projects tbody tr:not(.project-group)').count(),1); + assert.match(await page.locator('#sys-projects').innerText(),/feature/); + assert.equal(await page.evaluate(()=>globalThis.document.activeElement.id),'project-origin'); + await page.selectOption('#project-origin','all'); + await page.screenshot({path:path.join(shots,'system-projects-desktop.png'),fullPage:true,animations:"disabled"}); + for(const width of [768,390]){ + await page.setViewportSize({width,height:900}); + assert.ok(await page.evaluate(()=>globalThis.document.documentElement.scrollWidth<=globalThis.innerWidth),`overflow at ${width}`); + await page.screenshot({path:path.join(shots,'system-projects-'+width+'.png'),fullPage:true,animations:"disabled"}); + await page.click('#tab-overview');await page.click('[data-overview-view="runtime"]'); + assert.ok(await page.evaluate(()=>globalThis.document.documentElement.scrollWidth<=globalThis.innerWidth),`context overflow at ${width}`); + await page.screenshot({path:path.join(shots,'context-'+width+'.png'),fullPage:true,animations:"disabled"}); + await page.click('#tab-system');await page.click('[data-system-view="projects"]'); + } + projects={projects:[],discoveryProjects:[],everSeen:measured(0)}; + await page.reload();await page.click('#tab-system');await page.click('[data-system-view="projects"]'); + await page.waitForSelector('#project-population');assert.match(await page.locator('#sys-projects').innerText(),/no project was discovered/); + assert.deepEqual(errors,[]); +}); diff --git a/tests/ui/dashboard-ui.mjs b/tests/ui/dashboard-ui.mjs index 1f799b16..1408b770 100644 --- a/tests/ui/dashboard-ui.mjs +++ b/tests/ui/dashboard-ui.mjs @@ -4288,6 +4288,7 @@ async function main() { const contextView = await page.evaluate(() => ({ policy: document.getElementById('u-ctx-policy')?.textContent, states: [...document.querySelectorAll('#u-ctx-hosts .ctx-state')].map((node) => node.textContent), + unavailable: document.querySelectorAll('#u-ctx-hosts .ctx-no-pressure').length, meters: [...document.querySelectorAll('#u-ctx-hosts [role="meter"]')].map((node) => ({ text: node.textContent.trim(), now: node.getAttribute('aria-valuenow'), min: node.getAttribute('aria-valuemin'), max: node.getAttribute('aria-valuemax'), @@ -4306,8 +4307,8 @@ async function main() { /5%.*7%.*10%/.test(contextView.policy) && /60%.*70%.*75%/.test(contextView.policy) && /25%/.test(contextView.policy), `Context policy was ${JSON.stringify(contextView.policy)}`); - check('Context exposes one evidence state and one semantic meter per supported host', - contextView.states.length === 3 && contextView.meters.length === 3, + check('Context exposes one coverage state and a measurement or explicit absence per host', + contextView.states.length === 3 && contextView.meters.length + contextView.unavailable === 3, `Context evidence was ${JSON.stringify(contextView)}`); check('unknown Context meters omit aria-valuenow while observed meters include it', contextView.meters.every((meter) => meter.min === '0' && meter.max === '100' diff --git a/tests/ui/intelligence-picker.mjs b/tests/ui/intelligence-picker.mjs new file mode 100644 index 00000000..517bb006 --- /dev/null +++ b/tests/ui/intelligence-picker.mjs @@ -0,0 +1,156 @@ +// Real dashboard/browser regression; only HTTP observations are fixtures. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { chromium } from 'playwright'; + +// Optional source checkout supports isolated implementation/test workers. +const pageModule = process.env.AK_UI_SOURCE_ROOT + ? pathToFileURL(path.join(process.env.AK_UI_SOURCE_ROOT, 'src/lib/dashboard/page.mjs')) + : new URL('../../src/lib/dashboard/page.mjs', import.meta.url); +const { renderPage } = await import(pageModule.href); +const projects = [ + { key: 'repo-z', label: 'Zulu', learningScope: 'repository', learningOrigins: ['claude-desktop'] }, + { key: 'unknown-z', label: 'Zulu unknown' }, + { key: 'tree-z', label: 'Zulu tree', learningScope: 'worktree' }, + { key: 'user-z', label: 'Zulu user', learningScope: 'user' }, + { key: 'repo-a', label: 'alpha', learningScope: 'repository', learningOrigins: ['claude-desktop', 'codex-desktop'] }, + { key: 'tree-a', label: 'Alpha tree', learningScope: 'worktree', learningOrigins: ['codex-desktop'] }, + { key: 'user-a', label: 'Alpha user', learningScope: 'user' }, + { key: 'unknown-a', label: 'Alpha unknown', learningScope: 'unsupported' }, +]; + +test('Intelligence picker groups and sorts learning scopes without changing selection or hiding empty history', async t => { + const browser = await chromium.launch({ channel: 'chrome', headless: true }); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width: 1360, height: 980 } }); + const errors = [], requests = []; + page.on('pageerror', error => errors.push(error.message)); + let available = projects; + await page.route('http://intelligence.test/**', async route => { + const url = new URL(route.request().url()); + if (url.pathname === '/') return route.fulfill({ contentType: 'text/html', body: renderPage({ name: 'Intelligence fixture', version: 'test' }) }); + const selected = available.find(project => project.key === url.searchParams.get('project')) || available.find(project => project.key === 'repo-z'); + if (url.pathname === '/api/status') requests.push(selected?.key ?? null); + const body = url.pathname === '/api/status' ? { + overall: 'ok', rows: [], intel: { projects: available, selectedProjectKey: selected?.key ?? null, + selectedProjectLabel: selected?.label ?? null, health: [], graph: [], patternStore: [], + machineWide: { totals: { projectCount: available.length }, perProject: [] } }, + } : {}; + return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }); + }); + await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' }); + await page.goto('http://intelligence.test/#token=fixture'); + await page.click('[data-overview-view="intel"]'); + const picker = page.getByLabel('select project', { exact: true }); + await picker.locator('optgroup').first().waitFor({ state: 'attached' }); + assert.equal(await picker.evaluate(el => el.tagName), 'SELECT'); + const groups = await picker.locator('optgroup').evaluateAll(nodes => nodes.map(node => ({ label: node.label, + values: Array.from(node.children, option => option.value), labels: Array.from(node.children, option => option.textContent) }))); + assert.deepEqual(groups.map(group => [group.label, group.values]), [ + ['Git repositories', ['repo-a', 'repo-z']], ['Git worktrees', ['tree-a', 'tree-z']], + ['User-level learning', ['user-a', 'user-z']], ['Other / unclassified', ['unknown-a', 'unknown-z']], + ]); + assert.equal(groups[0].labels[0], 'alpha'); + assert.equal(groups[1].labels[0], 'Alpha tree'); + assert.equal(await picker.locator('option').count(), projects.length); + assert.equal(await picker.inputValue(), 'repo-z', 'sorting must retain the server-selected project'); + await picker.focus(); + assert.equal(await picker.evaluate(el => el === el.ownerDocument.activeElement), true); + await picker.selectOption('tree-a'); + await page.waitForFunction(() => globalThis.document.getElementById('history-project-name').textContent === 'Alpha tree'); + await page.waitForFunction(() => globalThis.document.getElementById('history-empty').textContent.includes('Alpha tree')); + assert.equal(requests.at(-1), 'tree-a', 'selection fetch preserves the opaque project key'); + assert.equal(await picker.inputValue(), 'tree-a'); + assert.equal(await page.locator('#history').isVisible(), true); + assert.equal(await picker.isVisible(), true); + assert.match(await page.locator('#history-empty').innerText(), /no learning history recorded for Alpha tree yet/); + const shots = process.env.AK_UI_ARTIFACTS; + if (shots) fs.mkdirSync(shots, { recursive: true }); + for (const width of [1360, 390]) { + await page.setViewportSize({ width, height: 980 }); + const overflowing = await page.evaluate(() => Array.from(globalThis.document.querySelectorAll('#panel-intel *')).filter(el => el.getBoundingClientRect().right > globalThis.innerWidth).map(el => ({tag:el.tagName,id:el.id,cls:el.className,right:el.getBoundingClientRect().right})).slice(0,12)); + if (shots) await page.screenshot({ path: path.join(shots, `intelligence-picker-${width}.png`), fullPage: true }); + assert.equal(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth), true, `no overflow at ${width}px: ${JSON.stringify(overflowing)}`); + assert.equal(await picker.isVisible(), true); + assert.equal(await picker.inputValue(), 'tree-a'); + } + available = []; + await page.reload(); + await page.click('[data-overview-view="intel"]'); + await page.waitForFunction(() => globalThis.document.getElementById('intel-project-select').disabled); + assert.equal(await picker.isVisible(), true); + assert.equal(await picker.locator('option').textContent(), 'no projects discovered'); + assert.equal(await page.locator('#history-empty').isVisible(), true); + assert.deepEqual(errors, []); +}); + +test('Intelligence table keeps every grouped row in five-row scroll regions with stable KPIs', async t => { + const browser = await chromium.launch({ channel: 'chrome', headless: true }); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width: 1360, height: 980 } }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + const scopes = ['repository', 'worktree', 'user', 'unknown']; + const rows = scopes.flatMap(learningScope => Array.from({ length: 8 }, (_, i) => ({ + key: `${learningScope}-${8-i}`, label: `Project ${8-i}`, learningScope, + patternsLearned: i+1, patternStoreCount: 1, learningState: ['.claude-flow'], lastAdaptation: 1700000000000, + }))); + await page.route('http://intelligence-table.test/**', async route => { + const url = new URL(route.request().url()); + if (url.pathname === '/') return route.fulfill({ contentType: 'text/html', body: renderPage({ name: 'Intelligence table fixture', version: 'test' }) }); + return route.fulfill({ contentType: 'application/json', body: JSON.stringify(url.pathname === '/api/status' ? { + overall: 'ok', rows: [], intel: { projects: rows, selectedProjectKey: rows[0].key, selectedProjectLabel: rows[0].label, + health: [], graph: [], patternStore: [], machineWide: { + totals: { projectCount: 32, patternsLearnedLifetime: 144, mostActiveProject: 'Project 8' }, perProject: rows, + } }, + } : {}) }); + }); + await page.emulateMedia({ reducedMotion: 'reduce', colorScheme: 'dark' }); + await page.goto('http://intelligence-table.test/#token=fixture'); + await page.click('[data-overview-view="intel"]'); + await page.locator('.mw-data-row').first().waitFor(); + const table = page.locator('#mw-table'), hero = await page.locator('#mw-hero').innerText(); + assert.equal(await table.locator('.mw-data-row').count(), 32); + assert.deepEqual(await table.locator('.mw-group h3').allTextContents(), + ['Git repositories8', 'Git worktrees8', 'User-level learning8', 'Other / unclassified8']); + assert.equal(await table.getByRole('columnheader').count(), 16); + for (const scope of scopes) { + assert.deepEqual(await table.locator(`[data-learning-scope="${scope}"] .mw-name`).allTextContents(), + ['Project 1', 'Project 2', 'Project 3', 'Project 4', 'Project 5', 'Project 6', 'Project 7', 'Project 8']); + } + const shots = process.env.AK_UI_ARTIFACTS; + if (shots) fs.mkdirSync(shots, { recursive: true }); + for (const width of [1360, 1100, 390]) { + await page.setViewportSize({ width, height: 980 }); + const sizes = await table.locator('.mw-group-scroll').evaluateAll(regions => regions.map(region => ({ + height: region.clientHeight, scroll: region.scrollHeight, + header: region.querySelector('.mw-head').getBoundingClientRect().height, + row: region.querySelector('.mw-data-row').getBoundingClientRect().height, + }))); + for (const size of sizes) { + assert.equal(size.row, 34); + assert.equal((size.height-size.header)/size.row, 5); + assert.ok(size.scroll > size.height); + } + assert.ok(await table.evaluate(el => el.getBoundingClientRect().height) <= 520); + assert.equal(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth), true, `no horizontal overflow at ${width}px`); + assert.equal(await page.locator('#mw-hero').innerText(), hero); + if (shots) await page.screenshot({ path: path.join(shots, `intelligence-table-${width}.png`), fullPage: true }); + } + await table.focus(); + await page.keyboard.press('End'); + await page.waitForFunction(() => globalThis.document.getElementById('mw-table').scrollTop > 0); + assert.equal(await table.evaluate(el => el === el.ownerDocument.activeElement), true); + if (shots) await page.screenshot({ path: path.join(shots, 'intelligence-table-390-scrolled.png'), fullPage: true }); + const region = table.getByRole('region', { name: 'Git repositories learning rows', exact: true }); + await region.focus(); + await page.keyboard.press('End'); + await page.waitForFunction(() => globalThis.document.querySelector('.mw-group-scroll').scrollTop > 0); + assert.equal(await region.locator('.mw-name').last().getAttribute('title'), 'Project 8'); + assert.equal(await region.evaluate(el => el === el.ownerDocument.activeElement), true); + assert.equal(await table.locator('.mw-data-row').count(), 32); + assert.deepEqual(errors, []); +}); diff --git a/tests/ui/maintenance-projects.mjs b/tests/ui/maintenance-projects.mjs index 52bd48d7..3d549aeb 100644 --- a/tests/ui/maintenance-projects.mjs +++ b/tests/ui/maintenance-projects.mjs @@ -15,6 +15,8 @@ import { publicInventoryPage, publicInspector } from '../../src/lib/dashboard/ma function fixture() { const projects = ['ampel', 'boon-worthy', 'emailibrium', 'finima', 'keel', 'prompt-genie', 'ampel-feature'].map((name) => ({ loc: { languages: (name === 'ampel' ? ['javascript', 'python', 'rust', 'java', 'ada'] : ['typescript']).map(id => ({ id })) }, + repository: ['ampel','ampel-feature'].includes(name)?{repositoryId:'repository:0123456789abcdef0123',kind:name==='ampel-feature'?'worktree':'git',root:'/fixture/projects/ampel',evidence:name==='ampel-feature'?'git-common-directory-and-backlink':'git-directory',observedAt:Date.parse('2026-09-09T12:00:00Z')}:null, + sessionOrigins: [{origin:name==='ampel-feature'?'codex-desktop':name==='ampel'?'claude-desktop':'unknown',sessions:1}], path: '/fixture/projects/'+name, label: name, hosts: ['claude', 'codex'], projectKind: name==='ampel-feature'?'worktree':'git', })); const copies = projects.map((project) => ({ project: project.path, itemPath: project.path+'/.claude/skills/a11y-ally', host: 'claude', scope: 'project' })); @@ -78,6 +80,14 @@ test('project worktree visibility and all-installations navigation work on deskt await page.waitForFunction(() => !globalThis.mntInventoryBusy); assert.equal(await page.locator(worktree).count(), 1); assert.equal(await page.locator('[data-mnt-focus="'+worktreeId+'"]').count(), 1); + const sharedGroup=page.locator('.mnt-repository-group').filter({has:page.locator('[data-mnt-focus="'+worktreeId+'"]')}); + assert.equal(await sharedGroup.locator('[data-mnt-level="project"]').count(),2); + const originFilter=page.locator('#mnt-facets input[data-mnt-facet="sessionOrigin"][value="codex-desktop"]'); + await originFilter.check();await page.waitForFunction(()=>!globalThis.mntInventoryBusy); + assert.equal(await page.locator('#mnt-results [data-mnt-level="project"]').count(),1); + assert.match(await page.locator('#mnt-results').innerText(),/Codex Desktop/); + await originFilter.uncheck();await page.waitForFunction(()=>!globalThis.mntInventoryBusy); + await page.locator('#mnt-facets [data-mnt-include-worktrees]').uncheck(); await page.waitForFunction(() => !globalThis.mntInventoryBusy); await page.locator('#mnt-facets [data-mnt-facet-search="project"]').fill('feature'); @@ -92,17 +102,21 @@ test('project worktree visibility and all-installations navigation work on deskt tooltip: image.parentElement.title, width: image.getBoundingClientRect().width, height: image.getBoundingClientRect().height, loaded: image.complete && image.naturalWidth > 0, }))); - assert.equal(logoFacts.length, 3); - assert.deepEqual(logoFacts.map(logo => logo.alt), ['JavaScript', 'Python', 'Rust']); + assert.equal(logoFacts.length, 5); + assert.deepEqual(logoFacts.map(logo => logo.alt), ['JavaScript', 'Python', 'Rust', 'Java', 'Ada']); assert.ok(logoFacts.every(logo => logo.source.startsWith('data:image/svg+xml;base64,') && logo.tooltip.startsWith(logo.alt) && logo.label.startsWith(logo.alt) && logo.width === 24 && logo.height === 24 && logo.loaded)); assert.equal(await ampelCard.locator('.mnt-language-list').innerText(), '', 'language initials and names do not crowd the card'); - const moreLanguages = ampelCard.locator('..').locator('.mnt-language-more'); - assert.equal(await moreLanguages.getAttribute('open'), null); - await moreLanguages.locator('summary').click(); - assert.deepEqual(await moreLanguages.locator('img').evaluateAll(images => images.map(image => image.alt)), ['Java', 'Ada']); - await moreLanguages.locator('summary').click(); + assert.equal(await ampelCard.locator('..').locator('.mnt-language-more').count(), 0); + assert.equal(await ampelCard.locator('.mnt-project-title > .mnt-icon').count(), 1); + assert.equal(await ampelCard.locator('.mnt-project-kind').innerText(), 'Git'); + assert.equal(await ampelCard.locator('.mnt-project-kind .mnt-icon').count(), 1); + assert.doesNotMatch(await ampelCard.innerText(), /Git repository/); + if (process.env.AK_UI_ARTIFACTS) { + fs.mkdirSync(process.env.AK_UI_ARTIFACTS, { recursive: true }); + await page.screenshot({ path: path.join(process.env.AK_UI_ARTIFACTS, 'project-cards-desktop.png') }); + } await page.locator('[data-mnt-focus="'+ampelId+'"]').click(); await page.locator('[data-mnt-focus="skill"]').click(); await page.locator('#mnt-results [data-mnt-focus]').first().click(); @@ -125,7 +139,11 @@ test('project worktree visibility and all-installations navigation work on deskt assert.equal(await page.locator('#mnt-results [data-mnt-plc]').count(), 9); await page.locator('[data-mnt-back="root"]').click(); await page.locator('[data-mnt-focus="project"]').click(); + await ampelCard.waitFor(); await page.setViewportSize({ width: 390, height: 844 }); + assert.equal(await ampelCard.locator('img.mnt-language-icon').count(), 5); + assert.equal(await ampelCard.locator('.mnt-language-list').evaluate(el => globalThis.getComputedStyle(el).flexWrap), 'wrap'); + if (process.env.AK_UI_ARTIFACTS) await page.screenshot({ path: path.join(process.env.AK_UI_ARTIFACTS, 'project-cards-mobile.png') }); await page.getByRole('button', { name: 'Filters', exact: true }).click(); const sheet = page.locator('#mnt-facets-sheet'); await sheet.locator('[data-mnt-include-worktrees]').check(); diff --git a/tests/ui/usage-project-groups.mjs b/tests/ui/usage-project-groups.mjs new file mode 100644 index 00000000..5f198182 --- /dev/null +++ b/tests/ui/usage-project-groups.mjs @@ -0,0 +1,54 @@ +// Real dashboard: a bounded Git-project spend ranking, never inferred by name. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { chromium } from 'playwright'; +import { renderPage } from '../../src/lib/dashboard/page.mjs'; + +const gitProjects = Array.from({length:12},(_,index)=>({key:'repo-'+index,label:'Git project '+index, + cost:120-index*10,sessions:3,minutes:90,tokens:1000})).reverse(); +const totals={cost:1000,sessions:40,spanMinutes:500,tokens:12000}; + +test('Usage Projects shows top ten Git projects and follows the timeframe without changing overall totals',async t=>{ + const browser=await chromium.launch({channel:'chrome',headless:true});t.after(()=>browser.close()); + const page=await browser.newPage({viewport:{width:1440,height:1050}}),errors=[],windows=[]; + page.on('pageerror',error=>errors.push(error.message)); + let legacy=false; + await page.route('http://usage-projects.test/**',async route=>{ + const url=new URL(route.request().url()); + if(url.pathname==='/')return route.fulfill({contentType:'text/html',body:renderPage({name:'Usage Projects fixture',version:'test'})}); + let body={}; + if(url.pathname==='/api/status')body={overall:'ok',rows:[]}; + if(url.pathname==='/api/usage'){ + windows.push(url.searchParams.get('days')); + body={totals,sessions:[],byProject:{'agent-xxx':{cost:220},'user-root':{cost:100}}}; + if(!legacy)body.gitProjects=url.searchParams.get('days')==='7'?gitProjects.slice(0,2):gitProjects; + } + return route.fulfill({contentType:'application/json',body:JSON.stringify(body)}); + }); + await page.emulateMedia({reducedMotion:'reduce',colorScheme:'dark'}); + await page.goto('http://usage-projects.test/#token=fixture');await page.click('#tab-usage'); + const panel=page.locator('#u-projects');await panel.locator('.mrow').first().waitFor(); + assert.equal(await panel.locator('.mrow').count(),10); + assert.match(await page.locator('#u-projects-note').innerText(),/top 10 of 12/); + assert.match(await panel.locator('.mrow').first().innerText(),/Git project 0.*\$120/s); + assert.doesNotMatch(await panel.innerText(),/agent-xxx|user-root|Desktop|Show all/); + assert.equal(await panel.locator('details').count(),0); + assert.match(await page.locator('#u-hero').innerText(),/\$1,000/); + const shots=process.env.AK_UI_ARTIFACTS; + if(shots)fs.mkdirSync(shots,{recursive:true}); + for(const width of [1440,390]){ + await page.setViewportSize({width,height:1050}); + assert.equal(await page.evaluate(()=>globalThis.document.documentElement.scrollWidth<=globalThis.innerWidth),true); + if(shots)await page.screenshot({path:path.join(shots,'usage-project-groups-'+width+'.png'),fullPage:true,animations:'disabled'}); + } + // The existing timeframe control remains the only scope for this ranking. + const seven=page.locator('#usage-days [data-days="7"]'); + await seven.click();await page.waitForFunction(()=>globalThis.document.querySelectorAll('#u-projects .mrow').length===2); + assert.equal(windows.at(-1),'7'); + legacy=true;await page.reload();await page.click('#tab-usage'); + await page.waitForFunction(()=>globalThis.document.getElementById('u-projects').textContent.includes('Refresh usage')); + assert.doesNotMatch(await panel.innerText(),/agent-xxx|user-root/); + assert.deepEqual(errors,[]); +});