Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5941ae4
feat(report): define observability deck payload and registry
Trecek Oct 4, 2026
9b28b8d
fix(report): construct deck epoch within payload builder
Trecek Oct 4, 2026
3536d82
feat(report): add DOM-free deck routing and cohort logic
Trecek Oct 4, 2026
555bf1f
test(report): evaluate undefined sort input inside V8
Trecek Oct 4, 2026
8aef974
refactor(cli): share report-index refresh and session fixtures
Trecek Oct 4, 2026
2b39954
feat(report): ship self-contained observability deck shell
Trecek Oct 4, 2026
d68a11b
test(report): cascade report changes to CLI consumers
Trecek Oct 4, 2026
b91d944
fix(review): remove unused per-view strike machinery
Trecek Oct 5, 2026
e5c4b3a
fix(review): remove redundant shipped-registry validation test
Trecek Oct 5, 2026
be62521
fix(review): identify each browser global in core dependency assertions
Trecek Oct 5, 2026
b40fc91
fix(review): separate shell registration tests from core JavaScript t…
Trecek Oct 5, 2026
6c08422
fix(review): isolate deck filtering route scenarios
Trecek Oct 5, 2026
6dd6ad5
fix(review): isolate population sentence routes and explanation notes
Trecek Oct 5, 2026
6f2a5e6
fix(review): remove unused session-specific token fixture branch
Trecek Oct 5, 2026
1395d9f
fix(review): include contended lease path in report-index errors
Trecek Oct 5, 2026
fac4568
fix(review): label availability legend values as examples
Trecek Oct 5, 2026
56dc016
test(review): isolate deck sort parser cases
Trecek Oct 5, 2026
62db7eb
test(review): separate deck availability presentation cases
Trecek Oct 5, 2026
c608dc9
test(review): parameterize deck chip presentation cases
Trecek Oct 5, 2026
8f63114
test(review): use the public core path facade
Trecek Oct 5, 2026
11d3bfe
test(review): import deck payload builder through its public facade
Trecek Oct 5, 2026
0f53c60
test(review): share deterministic deck session rows
Trecek Oct 5, 2026
f943b90
test(review): share the deck sample generation timestamp
Trecek Oct 5, 2026
486c1c5
test(review): use an independent expected deck epoch timestamp
Trecek Oct 5, 2026
27d6411
refactor(review): mark the deck CSP hash helper private
Trecek Oct 5, 2026
77e6712
test(review): verify deck CSP hashes independently
Trecek Oct 5, 2026
88503e0
refactor(review): share the cohort view identifier
Trecek Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .autoskillit/test-filter-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ README.md:
src/autoskillit/assets/**/*:
- assets/
- contracts/
- report/

tests/arch/fixtures/*:
- arch/
Expand Down
3 changes: 1 addition & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
# Vendored binary assets — suppress diff spam on updates
src/autoskillit/assets/**/*.js binary
src/autoskillit/assets/**/*.css binary
src/autoskillit/assets/mermaid/*.js binary
11 changes: 11 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ required report-index or source lease.

---

## autoskillit sessions deck

autoskillit sessions deck <output>

Incrementally refreshes the derived report index, then writes a self-contained HTML
observability deck to `<output>`. Open the file directly in a browser; it needs no
server or network connection. The command exits with status 1 without writing the file
when another operation holds a required report-index or source lease.

---

## autoskillit codex-orphans

autoskillit codex-orphans [--reap] [--output-json]
Expand Down
50 changes: 50 additions & 0 deletions docs/decisions/0017-deck-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ADR-0017: Hand-written SVG renders the observability deck

**Status:** Accepted
**Date:** 2026-10-04
**Issue:** [#4650](https://github.com/TalonT-Org/AutoSkillit/issues/4650)

## Context

The observability deck must open as one self-contained file, work without a network
connection, and print its charts as vector graphics. Research recommended Apache
ECharts for charting, so the prototype's “Where do the tokens go?” view was built both
with ECharts and with first-party SVG using the same real data.

## Decision

Render charts with hand-written SVG. Include no third-party chart library in the deck.

## Rationale

Both prototype files were checked in offline headless Chromium and printed to PDF. The
PDF content streams were inspected for vector and raster content.

| | Hand-written SVG | Apache ECharts 6.1.0, SVG renderer |
|---|---:|---:|
| Total HTML | 13,283 B | 532,044 B |
| Chart-drawing code | 44 lines | 43 lines |
| Toolchain | None | npm and `npx esbuild` |
| Light PDF | 60,760 B; vector, 0 raster | 588,351 B; vector, 0 raster |
| Theme colors | Standard CSS variables | CSS variable passthrough is undocumented; default theme hardcodes hex |
| SVG accessibility | ARIA and a named list can be authored | `AriaComponent` emitted no ARIA attributes |

ECharts contributed 518,096 B (97.4%) of its HTML. Its often-cited 177,738 B figure is
the gzip size; a `file://` page receives no transport compression. ECharts also embedded
duplicated font subsets in the PDF. The hand-written version needs no build tool and
keeps colors in the deck's CSS palettes.

The alternatives survey found ApexCharts under a proprietary revenue-gated license;
Chart.js and uPlot use canvas and therefore do not meet the vector-print requirement.
Frappe Charts supports SVG under MIT, but had no repository push in the preceding 15
months at the time of the survey.

## Re-evaluation trigger

Re-run the build comparison if a view needs zoom, brushing, or linked hover across charts
shown at once. Compare both approaches against that concrete interaction need.

## Enforcement

Deck tests enforce the asset closure, prohibit network access, and check the content
security policy. The deck assets must not acquire a third-party chart library.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
- [0010-systemd-scope-defense-in-depth.md](0010-systemd-scope-defense-in-depth.md) — systemd scope wrapping is a best-effort kernel backstop on top of the tether sweep, never the ceiling of record
- [0015-write-containment.md](0015-write-containment.md) — typed skill write scopes composed by union across interactive and headless sessions
- [0016-owner-scope.md](0016-owner-scope.md) — a fleet dispatch settles every funnel-spawned descendant of its L2, selected by owner-scope registration, before it persists or returns
- [0017-deck-renderer.md](0017-deck-renderer.md) — hand-written SVG, no chart library, for the self-contained observability deck
78 changes: 78 additions & 0 deletions docs/developer/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,84 @@ Codex OTLP token events are not projected: they do not carry a stable request
identity for deduplication. Codex token measures enter the report index through
the session rows.

## Observability deck

`autoskillit sessions deck <output>` incrementally refreshes the report index and writes
one self-contained HTML deck. It can be opened from `file://` without a server or network
connection. The deck embeds the index's session rows and filters them in the browser.
ADR-0017 records why charts use first-party SVG.

### Payload contract

The payload keys are `generated_at_ms`, `index_schema_version`, `landing`, and `history`;
`tables.sessions{columns,rows}`; `facets[{id,label,column,kind}]`;
`views[{id,question,decision,group,status,issue}]`;
`chips{view:{facet:[{key,label,state,count,reason,issue,match|days}]}}`; and
`availability[{state,label,description}]`. `match` is used for value facets and `days`
for windows. `views[].issue` tracks the issue for building a planned view; a chip's
`issue` identifies why that choice is unavailable. These fields have separate meanings.

### Chip states

Each cohort choice is `live`, `struck`, or `absent`. Live choices filter the rows.
Struck choices remain visible with a reason and cannot be selected. Absent choices have
no matching rows. L0 is struck because L0 leaf agents write no session row and exist
only as subagent transcripts. On a legacy index, unavailable orchestration levels cite
[#4622](https://github.com/TalonT-Org/AutoSkillit/issues/4622); windows extending before
the retained history cite [#4621](https://github.com/TalonT-Org/AutoSkillit/issues/4621).

### Routing

Routes use `#/<view>[/<entity>][?k=v1,v2]`. Cohort selections survive navigation between
views and entities; the `sort` choice applies only to its view. Every state change is a
hash navigation, so browser back and forward restore prior selections. Planned views
show a notice while keeping the cohort bar available.

### Offline rendering and client code

The generated HTML embeds its assets and JSON payload. Its content security policy
disallows network requests, and the JSON is escaped before embedding. The asset set is
`deck.html`, `deck.css`, `core.js`, `shell.js`, and the built view scripts under
`assets/deck/views/`; no other files are allowed. See
[ADR-0017](../decisions/0017-deck-renderer.md) for the renderer decision.

`core.js` is DOM-free and tested in V8 with `mini-racer`. `shell.js` renders the page
without putting data in `innerHTML` or using inline event handlers. Availability uses
the five producer states: `measured`, `measured_zero`, `unavailable`, `unknown`, and
`not_applicable`.

### Adding a view

Mark its `DeckViewDef` as built by providing its table and script, add
`assets/deck/views/<id>.js`, and register it with `DeckShell.registerView`. Use
`ctx.sortableTable`, `ctx.href` or `entityLink`, `ctx.availabilityCell`, and
`ctx.barChart` for shared rendering. Pair each `ctx.barChart` with a visible
`ctx.sortableTable` over the same rows so exact values remain available in a sortable
list. Do not give the chart `role="img"`; that hides its bar labels and links from
assistive technology.

### Manual check

1. Generate a deck at a repository-local temporary path:

autoskillit sessions deck .autoskillit/temp/deck.html

2. Open that file from `file://` with DevTools Network set to offline. The landing view
and cohort bar should render with no hash, and the page should make zero requests.
3. Toggle harness and provider chips; confirm the population sentence changes.
4. Confirm L0 is struck with its reason. On a legacy index, L1–L3 should show ✕ with
#4622; windows longer than retained history should show ✕ with #4621.
5. Follow a harness link, then use back and forward; confirm the cohort remains selected.
6. Open a planned route such as `#/spend`; confirm its notice appears and the cohort bar
remains visible.
7. Emulate `prefers-color-scheme: dark`, then `light`; confirm both palettes render.
8. In print preview, confirm the light palette, vector bars, printed cohort sentence, and
hidden navigation rail.
9. In the Accessibility tree, confirm that “Runs by harness and provider” is a named
list whose items expose their label link and value (with the SVG rectangles hidden),
each chip button has a name, disabled-chip reasons are exposed, and planned nav
entries are `aria-disabled`.

## Child Terminal Reasons

Issue #4623. Every observed L0 child run (native Claude subagent, native Codex
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -483,4 +483,5 @@ api-simulator = { git = "https://github.com/TalonT-Org/api-simulator", rev = "8f
[dependency-groups]
dev = [
"hypothesis>=6.156.2",
"mini-racer>=0.14.1",
]
205 changes: 205 additions & 0 deletions src/autoskillit/assets/deck/core.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
globalThis.DeckCore = (() => {
"use strict";
const CHIP_STATES = Object.freeze({LIVE: "live", STRUCK: "struck", ABSENT: "absent"});
const DAY_MS = 86400000;
const enc = encodeURIComponent;
const formatCount = n => new Intl.NumberFormat("en-US").format(n);
const formatDate = ms => new Date(ms).toISOString().slice(0, 10);
const decodeTable = ({columns, rows}) => rows.map(r =>
Object.fromEntries(columns.map((c, i) => [c, r[i]])));

function encodeRoute(route, cohortKeys) {
let path = "#/" + enc(route.view);
if (route.entity != null) path += "/" + enc(route.entity);
const params = route.params || {};
const keys = [...new Set([...cohortKeys, "sort", ...Object.keys(params).sort()])];
const pairs = [];
for (const key of keys) {
const values = params[key];
if (values == null || values.length === 0) continue;
pairs.push(enc(key) + "=" + values.map(enc).join(","));
}
return path + (pairs.length ? "?" + pairs.join("&") : "");
}

function decodeRoute(hash, landing) {
const text = hash.replace(/^#/, "").replace(/^\//, "");
const split = text.indexOf("?");
const path = (split < 0 ? text : text.slice(0, split)).split("/");
const query = split < 0 ? "" : text.slice(split + 1);
let view = landing, entity = null;
try { view = decodeURIComponent(path[0]) || landing; } catch (_) {}
if (path.length > 1) {
try { entity = decodeURIComponent(path[1]); } catch (_) {}
}
const params = {};
for (const pair of query.split("&")) {
const at = pair.indexOf("=");
if (at <= 0 || at === pair.length - 1) continue;
try {
const key = decodeURIComponent(pair.slice(0, at));
const values = pair.slice(at + 1).split(",").map(decodeURIComponent);
if (key && values.every(v => v !== "")) {
Object.defineProperty(params, key, {value: values, enumerable: true,
writable: true, configurable: true});
}
} catch (_) {}
}
return {view, entity, params};
}

function hrefFor(route, target, cohortKeys) {
const params = Object.fromEntries(cohortKeys.filter(k =>
Object.hasOwn(route.params, k)).map(k => [k, [...route.params[k]]]));
for (const [key, value] of Object.entries(target.params || {})) {
if (value === null) delete params[key];
else Object.defineProperty(params, key, {value, enumerable: true,
writable: true, configurable: true});
}
return encodeRoute({view: target.view, entity: target.entity ?? null, params}, cohortKeys);
}

function effectiveSelection(chips, selected) {
const live = chips.filter(c => c.state === "live").map(c => c.key);
if (selected == null) return {keys: live, dropped: [], widened: false};
const keys = live.filter(k => selected.includes(k));
const dropped = selected.filter(k => !live.includes(k));
return {keys: keys.length ? keys : live, dropped, widened: keys.length === 0};
}

function toggleSelection(chips, selected, key) {
const live = chips.filter(c => c.state === "live").map(c => c.key);
const current = effectiveSelection(chips, selected).keys;
const next = live.filter(k => k === key ? !current.includes(k) : current.includes(k));
if (!next.length) return current;
return next.length === live.length ? null : next;
}

function windowSelection(chips, selected) {
const want = selected?.[0];
const chip = chips.find(c => c.state === "live" && c.key === want) ||
chips.find(c => c.key === "all");
return {chip, dropped: want && chip.key !== want ? [want] : []};
}

function filterRows(model, rows, chips, route) {
let filtered = rows;
for (const facet of model.facets.filter(f => f.kind === "values")) {
const keys = effectiveSelection(chips[facet.id], route.params[facet.id] ?? null).keys;
const allowed = new Set(chips[facet.id].filter(c => keys.includes(c.key)).map(c => c.match));
filtered = filtered.filter(r => allowed.has(r[facet.column] ?? null));
}
let untimed = 0;
const window = windowSelection(chips.window, route.params.window ?? null).chip;
if (window.days != null) {
filtered = filtered.filter(row => {
if (row.time_ms == null) { untimed += 1; return false; }
return row.time_ms >= model.generated_at_ms - window.days * DAY_MS;
});
}
return {rows: filtered, untimed};
}

const reasonText = chip => chip.reason + (chip.issue != null ? " (#" + chip.issue + ")" : "");
function droppedNote(chips, key) {
const chip = chips.find(c => c.key === key);
return chip ? chip.label + " is not selectable on this view — " + reasonText(chip) :
key + " does not appear in this index";
}

function populationSentence(model, chips, route, result) {
const n = result.rows.length;
const notes = [], parts = [];
for (const facet of model.facets.filter(f => f.kind === "values")) {
const values = chips[facet.id];
const selection = effectiveSelection(values, route.params[facet.id] ?? null);
const live = values.filter(c => c.state === "live");
const labels = values.filter(c => selection.keys.includes(c.key)).map(c => c.label);
parts.push(facet.label + " " + (!live.length ? "none recorded" : labels.join(" + ") +
(selection.keys.length === live.length ? " (all)" : "")));
notes.push(...selection.dropped.map(k => droppedNote(values, k)));
if (selection.widened) notes.push("no selected " + facet.label +
" is selectable here — showing every selectable " + facet.label);
}
const selection = windowSelection(chips.window, route.params.window ?? null);
const window = selection.chip;
if (window.days != null) {
parts.push("window last " + window.label + " (" +
formatDate(model.generated_at_ms - window.days * DAY_MS) + " → " +
formatDate(model.generated_at_ms) + ")");
} else {
parts.push("window all history" + (model.history.first_ms == null ? "" :
" (" + formatDate(model.history.first_ms) + " → " + formatDate(model.generated_at_ms) + ")"));
}
notes.push(...selection.dropped.map(k => droppedNote(chips.window, k)));
if (result.untimed) notes.push(formatCount(result.untimed) + (result.untimed === 1 ?
" run without a timestamp falls outside every window" :
" runs without a timestamp fall outside every window"));
return {headline: formatCount(n) + (n === 1 ? " run" : " runs"), detail: parts.join(" · "), notes};
}

function summarizePairs(rows) {
const groups = new Map();
for (const row of rows) {
const key = JSON.stringify([row.harness, row.provider]);
if (!groups.has(key)) groups.set(key, {harness: row.harness, provider: row.provider,
runs: 0, skills: new Set(), first_ms: null, last_ms: null});
const group = groups.get(key);
group.runs += 1;
if (row.skill != null) group.skills.add(row.skill);
if (row.time_ms != null) {
group.first_ms = group.first_ms == null ? row.time_ms : Math.min(group.first_ms, row.time_ms);
group.last_ms = group.last_ms == null ? row.time_ms : Math.max(group.last_ms, row.time_ms);
}
}
return [...groups.values()].map(g => ({...g, skills: g.skills.size})).sort((a, b) =>
b.runs - a.runs || compare(a.harness, b.harness) || compare(a.provider, b.provider));
}

const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
function sortRows(rows, key, dir) {
return rows.map((row, i) => ({row, i})).sort((a, b) => {
const av = a.row[key], bv = b.row[key];
if (av == null || bv == null) {
return av == null && bv == null ? a.i - b.i : av == null ? 1 : -1;
}
const order = typeof av === "number" && typeof bv === "number" ? compare(av, bv) :
compare(String(av), String(bv));
return order * (dir === "desc" ? -1 : 1) || a.i - b.i;
}).map(x => x.row);
}

function parseSort(value, keys, fallback) {
const [key, dir] = (value || "").split(":");
return keys.includes(key) && (dir === "asc" || dir === "desc") &&
value === key + ":" + dir ? {key, dir} : fallback;
}

function chipPresentation(chip, selected) {
const live = chip.state === "live";
return {text: chip.label + (chip.state === "absent" ? " ✕" : ""),
className: "chip" + (live ? "" : " chip--" + chip.state), disabled: !live,
pressed: live && selected, reason: live ? null : reasonText(chip)};
}

function availabilityPresentation(measure, vocabulary) {
const entry = vocabulary.find(v => v.state === measure.state);
if (!entry) throw new Error("unknown availability state: " + measure.state);
return {text: measure.state === "measured" ? formatCount(measure.value) :
measure.state === "measured_zero" ? "0" : entry.label,
className: "av av--" + measure.state, title: entry.description};
}

function barLayout(items, {width, labelWidth, valueWidth, rowHeight, gap}) {
const plot = width - labelWidth - valueWidth;
const max = items.reduce((max, item) => item.value == null ? max : Math.max(max, item.value), 0);
return items.map((item, i) => ({label: item.label, value: item.value, href: item.href,
x: labelWidth, y: i * (rowHeight + gap), w: item.value == null ? null :
max > 0 ? item.value / max * plot : 0, h: rowHeight}));
}

return Object.freeze({decodeTable, encodeRoute, decodeRoute, hrefFor, effectiveSelection,
toggleSelection, windowSelection, filterRows, populationSentence, summarizePairs, sortRows,
parseSort, chipPresentation, availabilityPresentation, barLayout, formatCount, formatDate,
CHIP_STATES, DAY_MS});
})();
Loading
Loading