diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 560403d..26693e6 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -6,6 +6,9 @@ on:
paths:
- "docs/**"
- "assets/**"
+ - "scripts/commit-session-link/**"
+ - "scripts/harness-inspector/**"
+ - "scripts/session-analysis/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
diff --git a/README.md b/README.md
index 4025eff..5c0676e 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+
+
+
+
Better Harness
diff --git a/assets/logo.svg b/assets/logo.svg
new file mode 100644
index 0000000..83ee4b9
--- /dev/null
+++ b/assets/logo.svg
@@ -0,0 +1,13 @@
+
+
diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js
index 3dfa087..36022a8 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -109,6 +109,13 @@ const config = {
position: "left",
label: "Docs",
},
+ {
+ to: "/inspector/",
+ label: "Inspector",
+ position: "left",
+ className: "navbar__link--inspector-new",
+ "aria-label": "Inspector",
+ },
{
to: "/blog",
label: "Blog",
diff --git a/docs/i18n/zh-Hans/code.json b/docs/i18n/zh-Hans/code.json
index 3027a47..4d29614 100644
--- a/docs/i18n/zh-Hans/code.json
+++ b/docs/i18n/zh-Hans/code.json
@@ -1,4 +1,124 @@
{
+ "inspector.meta.description": {
+ "message": "探索 Better Harness 如何以明确的证据边界连接产品意图、Coding Agent 活动、会话、文件与提交。"
+ },
+ "inspector.hero.lead": {
+ "message": "从产品意图追踪到 Agent 活动、会话、文件与提交,同时让证据强度及其限制始终可见。"
+ },
+ "inspector.boundaries.label": {
+ "message": "Demo 边界"
+ },
+ "inspector.boundaries.interactive": {
+ "message": "可交互样例"
+ },
+ "inspector.boundaries.readOnly": {
+ "message": "只读"
+ },
+ "inspector.actions.fullscreen": {
+ "message": "全屏打开"
+ },
+ "inspector.demo.title": {
+ "message": "可交互 Harness Inspector 样例"
+ },
+ "inspector.demo.frameTitle": {
+ "message": "可交互 Harness Inspector 样例"
+ },
+ "inspector.demo.loading": {
+ "message": "正在加载可交互样例…"
+ },
+ "inspector.demo.note": {
+ "message": "此确定性样例使用虚构的英文数据,不会读取你的工作区、Git 历史或 Coding Agent 会话。"
+ },
+ "inspector.features.eyebrow": {
+ "message": "它展示什么"
+ },
+ "inspector.features.title": {
+ "message": "沿着交付链路追踪,同时不越过证据边界"
+ },
+ "inspector.features.intro": {
+ "message": "Inspector 把产品结构、Agent 活动与 Git 结果放进同一个只读工作区,并明确区分强关联与有用但有限的上下文。"
+ },
+ "inspector.features.intent.title": {
+ "message": "从意图到交付"
+ },
+ "inspector.features.intent.body": {
+ "message": "沿 Delivery Tree 从能力和 Story 意图,查看与之关联的会话、文件与提交。"
+ },
+ "inspector.features.session.title": {
+ "message": "把会话放回上下文"
+ },
+ "inspector.features.session.body": {
+ "message": "检查保留的提示词、归一化工具活动、文件路径、提交事件,以及只读会话回放。"
+ },
+ "inspector.features.evidence.title": {
+ "message": "可解释的证据"
+ },
+ "inspector.features.evidence.body": {
+ "message": "同步选择与 Evidence Drawer 会说明关联为何存在、可信度如何,以及它不能证明什么。"
+ },
+ "inspector.how.eyebrow": {
+ "message": "如何使用"
+ },
+ "inspector.how.title": {
+ "message": "先看全局,再沿着证据深入"
+ },
+ "inspector.how.scope.title": {
+ "message": "选择范围"
+ },
+ "inspector.how.scope.body": {
+ "message": "按产品意图查看时使用 Delivery Tree;按时间查看会话与提交时使用 Date。"
+ },
+ "inspector.how.lanes.title": {
+ "message": "阅读三条信息轨"
+ },
+ "inspector.how.lanes.body": {
+ "message": "在不离开当前 Story 或日期的情况下,对照用户提示、检查点活动以及提交或文件。"
+ },
+ "inspector.how.evidence.title": {
+ "message": "检查关联关系"
+ },
+ "inspector.how.evidence.body": {
+ "message": "选择提示、操作、提交或路径。相关项目会保持高亮,Evidence Drawer 则解释关联及其限制。"
+ },
+ "inspector.how.session.title": {
+ "message": "打开 Session View 或 Replay"
+ },
+ "inspector.how.session.body": {
+ "message": "深入查看保留的轮次和归一化工具调用,或回放已观察到的序列。Replay 不会重新运行工具,也不会恢复 Coding Agent 会话。"
+ },
+ "inspector.evidence.eyebrow": {
+ "message": "理解证据标签"
+ },
+ "inspector.evidence.title": {
+ "message": "关联会明确展示,但不会擅自推断作者身份"
+ },
+ "inspector.evidence.explicit": {
+ "message": "保留的引用直接连接 Story、会话或提交。"
+ },
+ "inspector.evidence.observed": {
+ "message": "会话与提交共享完全相同的仓库路径。这支持相关性判断,但不能证明作者身份。"
+ },
+ "inspector.evidence.candidate": {
+ "message": "结构或时间信息提示了有用的关联,但仍需人工确认。"
+ },
+ "inspector.evidence.contextual": {
+ "message": "附近的历史有助于解释交付过程,但不会声称存在直接关联。"
+ },
+ "inspector.local.eyebrow": {
+ "message": "使用你自己的证据"
+ },
+ "inspector.local.title": {
+ "message": "生成私有的自包含 Inspector"
+ },
+ "inspector.local.body": {
+ "message": "安装 Better Harness 后,在仓库中运行高级 Inspector 命令。采集留在本地,生成的报告保持只读。"
+ },
+ "inspector.local.installation": {
+ "message": "查看安装方式"
+ },
+ "inspector.local.output": {
+ "message": "默认输出:.qoder/better-harness-runs/harness-inspector/inspector.html"
+ },
"homepage.dimensions.taskUnderstanding.title": {
"message": "任务理解"
},
diff --git a/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json b/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json
index e5c4bce..51cf7cb 100644
--- a/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json
+++ b/docs/i18n/zh-Hans/docusaurus-theme-classic/navbar.json
@@ -7,6 +7,10 @@
"message": "文档",
"description": "Navbar item with label Docs"
},
+ "item.label.Inspector": {
+ "message": "Inspector",
+ "description": "Navbar item with label Inspector"
+ },
"item.label.Blog": {
"message": "博客",
"description": "Navbar item with label Blog"
diff --git a/docs/scripts/sync-assets.mjs b/docs/scripts/sync-assets.mjs
index b4deda9..f5e7b32 100644
--- a/docs/scripts/sync-assets.mjs
+++ b/docs/scripts/sync-assets.mjs
@@ -1,9 +1,11 @@
// Sync published assets from the repository root into docs/static/.
// assets/ stays the single source of truth; synced targets are gitignored.
-import { cpSync, mkdirSync, rmSync } from "node:fs";
+import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
+import { renderHarnessInspectorDemoHtml } from "../../scripts/harness-inspector/demo-report.mjs";
+
export function syncAssets({ repoRoot, siteRoot }) {
const generatedDemoRoot = join(siteRoot, "static", "demo");
@@ -42,7 +44,11 @@ export function syncAssets({ repoRoot, siteRoot }) {
mkdirSync(dirname(to), { recursive: true });
cpSync(from, to, { recursive: true });
}
- return copies.length;
+ const inspectorDemo = join(generatedDemoRoot, "harness-inspector", "index.html");
+ mkdirSync(dirname(inspectorDemo), { recursive: true });
+ writeFileSync(inspectorDemo, renderHarnessInspectorDemoHtml(), "utf8");
+
+ return copies.length + 1;
}
const currentFile = fileURLToPath(import.meta.url);
diff --git a/docs/specs/2026-08-13-inspector-session-replay.md b/docs/specs/2026-08-13-inspector-session-replay.md
new file mode 100644
index 0000000..3f583af
--- /dev/null
+++ b/docs/specs/2026-08-13-inspector-session-replay.md
@@ -0,0 +1,125 @@
+# Replay retained session evidence
+
+## Traceability
+
+- Spec ID: inspector-session-replay
+- Status: Draft
+
+## Intent
+
+Let a reviewer play through one retained coding-agent session without leaving
+the Inspector's existing evidence model. Session View should keep its current
+chronological Trace and add a Replay mode that synchronizes a current-event
+stage, an event/file index, a compact timeline, and playback controls.
+
+Replay is a read-only presentation of the sanitized report projection. It does
+not rerun tools, restore a worktree, resume a native host session, or invent
+timestamps for content whose time was not observed.
+
+## Acceptance Scenarios
+
+- AC-1: `Open session` exposes semantic `Trace` and `Replay` tabs. Trace remains
+ the default, and a URL with `view=session&session-mode=replay` restores Replay
+ for the named session.
+- AC-2: Each projected session owns a `SessionReplay` model, without a version
+ suffix in its name. It deterministically projects retained prompts,
+ intermediate responses, tool calls, final responses, and directly linked
+ commits from the already-sanitized report model.
+- AC-3: Every replay event declares its timing basis. Observed prompt, tool, and
+ commit timestamps may appear on the wall-clock rail; response boundaries may
+ be labelled as Turn-bound; intermediate responses and other untimed content
+ remain `sequence-only` and are never assigned an invented clock time.
+- AC-4: Selecting an event updates the current-event stage, the event index, the
+ compact timeline cursor when timing exists, and the Inspector selection when
+ the event has a Story, Session, Turn, Tool Call, File, or Commit descriptor.
+ Existing Evidence Drawer relationships and limitations remain authoritative.
+- AC-5: Replay provides previous, play/pause, and next controls plus 1x, 2x, 4x,
+ and 8x event pacing. Playback advances by retained event order, compresses
+ long unobserved gaps instead of making multi-hour sessions wait in real time,
+ stops at the end, and never invokes a host tool.
+- AC-6: Replay exposes `Events` and `Files` index tabs. Choosing a file selects
+ that repository-relative path and moves to the first retained replay event
+ that names it, without claiming the event authored a commit.
+- AC-7: The mode tabs, index tabs, event rows, file rows, playback buttons, and
+ speed controls are keyboard operable; the current event uses non-color state
+ cues, playback respects reduced-motion preference, and controls do not steal
+ shortcuts from form inputs.
+- AC-8: The self-contained HTML retains no raw tool input/output, hidden
+ reasoning, absolute home path, or credential. Missing content and unavailable
+ timing stay explicit in the model and UI.
+- AC-9: Focused behavior tests cover `SessionReplay` ordering and timing bases,
+ direct-commit inclusion, untimed fallbacks, mode restoration, playback
+ controls, and final HTML privacy. A real multi-hour local session is verified
+ in desktop and narrow browser layouts with no page or console errors.
+
+## Non-goals
+
+- Adding Replay as a top-level Inspector scope beside Delivery Tree and Date.
+- Executing, retrying, resuming, or mutating a tool call, Git state, worktree,
+ feature-tree mapping, checkpoint, or native host session.
+- Human annotations, pinned notes, author identity, or report persistence.
+- Fabricating cost, token, test-result, response, or timing evidence that the
+ source projection did not retain.
+- Replacing Trace, the Evidence Drawer, or the existing wall-clock activity
+ chart with a second relationship model.
+
+## Plan and Tasks
+
+1. Extend `scripts/harness-inspector/report-model.mjs` with a bounded
+ `SessionReplay` projection built only after Session/Commit links exist.
+ Preserve explicit timing bases and direct-commit limitations.
+2. Extend `scripts/harness-inspector/ui/workbench.js` with a session-local
+ playback owner separate from evidence selection and chart zoom. Render
+ Trace/Replay tabs, the event stage, Events/Files index, compact rail, and
+ playback controls from the projected model.
+3. Extend `scripts/harness-inspector/ui/workbench.css` with responsive Replay
+ layout and accessible selected/focus treatments that reuse current Inspector
+ tokens and typography.
+4. Extend focused Inspector tests with behavior assertions over the model and
+ rendered interactions. Preserve unrelated working-tree changes in the same
+ files and keep the report self-contained.
+5. Render a real report, exercise mode switching, event/file selection,
+ playback, speed, URL restoration, Trace return, keyboard behavior, desktop
+ and narrow layouts, then inspect console/page errors.
+
+## Test and Review Evidence
+
+- AC-2/AC-3/AC-8: `npx vitest run test/reporting/harness-inspector.test.mjs`
+ asserts the parsed model shape, event ordering, timing bases, direct commits,
+ and sanitized projection rather than matching implementation source text.
+- AC-1/AC-4..AC-7: browser behavior against a real self-contained report,
+ including a copied Replay deep link and an event with no observed timestamp.
+- AC-9 regression: `npm test`, `npm run pack:verify`, documentation link graph,
+ and a Review Readiness Check over the final local/staged split.
+- Privacy risk: Replay consumes only projected safe text and repository-relative
+ paths; final HTML is checked for private-path and credential leakage.
+- Correlation risk: only explicit or observed-commit relationships enter the
+ replay stream. Same-path and contextual commits stay in Trace/Evidence.
+- Scale risk: the index is event-driven and the compact rail renders timed
+ events only; playback advances by event pace so long idle windows do not block
+ review.
+- Interaction risk: Replay state is session-local and URL-addressable, while
+ Evidence selection and activity zoom remain separate owners.
+
+## Replay Legibility Follow-up
+
+Deep-linking to a mid-session event (`replay-event=call:A179`) exposed six
+legibility defects, since fixed and verified in-browser against a real
+multi-hour local session with no console or page errors:
+
+- AC-4 follow-up: `updateReplayPresentation` now keeps the current event row in
+ view inside the index's own scroller only, so following playback never scrolls
+ Session View or the Workbench underneath it, and a tab return re-reveals it.
+- AC-7 follow-up: the current row uses a stronger non-color cue (heavier bar,
+ bold title) in addition to background, and the mode tabs (filled) read
+ distinctly from the index tabs (underlined).
+- Layout: the index column tracks layout width instead of the viewport and keeps
+ its own readable height, so a narrow window or open Drawer narrows the column
+ rather than stacking it where the sticky transport would cover the list. The
+ event card no longer reserves a tall fixed height that dwarfed short evidence.
+- AC-8 follow-up: a bounded (clipped) projection body is flagged `bodyExcerpt`
+ and shown as an `Excerpt` badge instead of a silently truncated command.
+- Orientation: the timeline rail gains a type legend, and duplicated position and
+ timing text collapses to one `Event N / total` label plus the card's Turn line.
+- Overlay: the root scroller locks while Session View is open so wheel gestures
+ cannot scroll the hidden Workbench.
diff --git a/docs/specs/2026-08-13-inspector-trace-view.md b/docs/specs/2026-08-13-inspector-trace-view.md
index 81afff9..ee763e7 100644
--- a/docs/specs/2026-08-13-inspector-trace-view.md
+++ b/docs/specs/2026-08-13-inspector-trace-view.md
@@ -123,3 +123,60 @@ claim the evidence vocabulary forbids.
inactivity, and the outside-the-window commit track states its own limit.
- Scale: binning is bounded by plot width, not by call count, and the removal of
per-session SVG templates reduced report size at the same evidence coverage.
+
+## Amendment (2026-08-13): the timeline is inside Session View and linked
+
+The first slice kept the wall-clock activity chart in the workbench lane and left
+Session View as a vertical Turn list. In use that split the one time-axis
+visualization from the one place calls can be read line by line: they never
+shared a screen, so a reviewer could not go from a busy stretch on the chart to
+the calls under it, and a session whose calls carried no dialogue Turn showed
+"0 tool calls" on every Turn while its whole trace sat in a page-tail bucket.
+
+This amendment moves the same chart (`activityChartMarkup`, re-rendered by
+`renderActivityChart` through the existing `[data-activity-chart]` wiring, so it
+shares the per-session zoom state and needs no second model) into a sticky,
+collapsible strip at the top of Session View, and links it to the list. No new
+data is projected; it reuses `startedAt` and Turn `startMs/endMs` already
+present.
+
+- AC-10: Session View opens with the wall-clock strip visible above the list.
+ A session with no dialogue Turn still renders the strip from observed call
+ times, and its untied calls are held in one bucket ordered by observed time
+ rather than as an unordered pile.
+- AC-11: The strip is a minimap. Clicking a bar scrolls the list to the calls
+ under it (expanding whatever disclosure hides them); a multi-call bar also
+ zooms. Zooming in the strip and in the workbench chart share one state.
+- AC-12: A short session opens every Turn's tool calls; a long session (> 12
+ Turns) stays collapsed and is navigated from the strip. Identical consecutive
+ rows stay collapsed as a run so the default view is concise, not a wall of
+ duplicates. Row virtualization and a node-link canvas remain non-goals.
+- AC-13: Filtering a tool type hides both its rows and the run bands that stand
+ in for it, and the sidebar tool total recomputes to the count that survives
+ the current filters (a run counted once per grouped call), independent of
+ which disclosures are open.
+- AC-14: The idle legend states the shading is a window with no observed call,
+ not a user wait; a session with no observed timing keeps the sequence-axis
+ label rather than implying a time.
+- AC-15: Above the action lanes the chart carries a ribbon that fills the whole
+ domain: observed calls are painted over a continuous band, so the time not
+ spent inside a tool is a visible share of the trace rather than blank canvas.
+ The bare band is labelled unattributed — model work or waiting — and never as
+ a model turn. The ribbon is omitted on the call-order fallback, where spacing
+ would be an artefact of ordinal position rather than elapsed time.
+
+**Why the ribbon.** The lanes plot the instant a call ran, so a reader saw only
+the moments a tool was executing. Measured on a real local report, 1 178 gaps
+between consecutive calls had a median of 6.6 s and a p90 of 29.7 s, while the
+idle threshold that produced any shading was 45 s: 1 113 of those gaps (94.5 %)
+had no visual representation at all. On one 2.2 h session the observed tool time
+totals 14 m — the chart was drawing 21 % of the trace and leaving 79 % blank.
+The ribbon makes that residue a first-class part of the picture without claiming
+to know what happened inside it: `projectDialogue` keeps note text but no note
+timestamp, so the host observed when tools ran, not when the model worked.
+
+Verified on a real local report: on a dialogue-less 235-call session the strip
+renders on open and clicking a bar zooms to ~18 calls in view and scrolls the
+list to the matching rows (timestamps ascending); toggling the Bash tool moves
+the sidebar total 235 → 150 → 235 while its run bands hide and restore; a
+10-Turn session opens all five tool blocks by default; no console errors.
diff --git a/docs/specs/2026-08-13-public-inspector-demo.md b/docs/specs/2026-08-13-public-inspector-demo.md
new file mode 100644
index 0000000..deb2e0f
--- /dev/null
+++ b/docs/specs/2026-08-13-public-inspector-demo.md
@@ -0,0 +1,124 @@
+# Public Harness Inspector demo
+
+## Traceability
+
+- Spec ID: `public-inspector-demo`
+- Status: Implemented
+
+## Intent
+
+Add a first-class Harness Inspector entry to the Better Harness GitHub Pages
+site. Readers should understand the Inspector's evidence-bounded purpose and
+explore the current Workbench interactions without installing the project or
+exposing any maintainer's local sessions, prompts, paths, or repository state.
+
+## Acceptance Scenarios
+
+- AC-1: The website navbar contains an internal `Inspector` link immediately
+ after Docs. A compact green `New` badge sits at the link's upper-right on
+ desktop and beside the link on the mobile menu; `New` is not part of the
+ product name, route, page title, or accessible link name.
+- AC-2: `/inspector/` is a full-width Docusaurus page with a concise product
+ introduction, explicit `Interactive sample`, `Read-only`, and `English sample
+ data` boundaries, an `Open full screen` action, and an embedded current
+ Harness Inspector Workbench.
+- AC-3: The embedded Workbench is generated from a deterministic in-memory
+ fixture. Every user-facing fixture value is English, no local session or Git
+ discovery runs during the docs build, and the sample contains enough Stories,
+ Sessions, Tool Calls, Files, Commits, dates, and evidence kinds to exercise
+ Delivery Tree, Date, Evidence Drawer, Session View, and Replay interactions.
+- AC-4: The embedded page identifies itself as sample data rather than `real
+ local evidence`, carries `noindex, follow`, and preserves the standalone
+ Workbench's escaping and privacy guarantees. The indexable Docusaurus wrapper
+ owns the public description.
+- AC-5: English and Simplified Chinese website builds expose the same English
+ Workbench sample. Only the wrapper introduction and actions are localized.
+ Desktop and narrow viewports retain usable navigation, iframe focus, a
+ full-screen fallback, and no page-level horizontal overflow.
+- AC-6: Changes to the Inspector renderer, UI, fixture, docs page, or publishing
+ workflow trigger or participate in the GitHub Pages build so the published
+ demo cannot silently drift behind the current Workbench.
+- AC-7: The page continues below the interactive sample with concise,
+ indexable documentation that explains the Inspector's core jobs, the
+ difference between direct, observed, candidate, and contextual evidence, and
+ the limits of what those relationships prove.
+- AC-8: A numbered usage guide walks readers through choosing a scope,
+ inspecting the three Workbench lanes, opening evidence details, and using
+ Session View or Replay. A separate local-project callout provides the exact
+ advanced CLI command, default output location, and a link to installation
+ documentation without implying that the hosted sample reads local data.
+
+## Non-goals
+
+- Publishing a maintainer's `.qoder/` report, native session identifiers, real
+ prompts, commit authors, or current repository activity.
+- Reading a visitor's local workspace or offering a server-backed live report.
+- Rewriting the self-contained Workbench as Docusaurus React components.
+- Synchronizing the wrapper URL with every iframe selection in this slice; the
+ standalone Workbench remains the owner of evidence deep links.
+- Adding Compare, editing mappings, recovery, or workspace mutation controls.
+
+## Plan and Tasks
+
+1. Add a pure Inspector demo builder under `scripts/harness-inspector/` with a
+ fixed English fixture and generated timestamp.
+2. Add a bounded rendering presentation option for the sample context label,
+ robots metadata, and optional public-demo marker without changing local
+ report defaults.
+3. Generate `docs/static/demo/harness-inspector/index.html` during the existing
+ docs asset-sync step instead of committing a second Workbench copy.
+4. Add a custom full-width `/inspector/` page, localized wrapper copy, full-screen
+ action, iframe title, clipboard permission, and responsive frame layout.
+5. Add the `Inspector` navbar item and CSS-only green `New` badge while keeping
+ the DOM link text and accessible name equal to `Inspector`.
+6. Extend Pages path triggers to include the Inspector owners and add focused
+ behavior tests for fixture language, privacy, presentation metadata, and
+ generated output.
+7. Add localized feature, usage, evidence-label, and local-project sections
+ below the sample, with responsive cards and semantic headings/lists.
+
+Decision rationale: the Inspector already emits a portable self-contained HTML
+Workbench. Embedding that artifact keeps one UI owner and makes the public sample
+track current interactions. A deterministic fixture prevents CI from depending
+on developer homes or native host state, while the Docusaurus wrapper provides
+the indexable explanation and locale-specific framing.
+
+## Test and Review Evidence
+
+- AC-3/AC-4: focused Vitest coverage builds the demo twice, compares the stable
+ report projection, validates English fixture strings, asserts sample/noindex
+ metadata, and rejects absolute-home or credential-shaped output.
+- AC-1/AC-2/AC-5: `cd docs && npm run build`, followed by Playwright checks of
+ English and `zh-Hans` `/inspector/` routes, navbar/badge placement, iframe
+ interaction, full-screen target, console/page errors, and desktop/narrow
+ screenshots.
+- AC-6: inspect the Pages workflow path filter and run the docs prebuild from a
+ clean generated-static target.
+- AC-7/AC-8: inspect the English and Simplified Chinese page structure in the
+ production build, verify the installation link and literal CLI command, and
+ review the lower-page layout at desktop and narrow breakpoints.
+- Documentation integrity: `node scripts/doc-link-graph/cli.mjs
+ skills/better-harness` and `npx vitest run
+ test/skills-docs/doc-link-graph.test.mjs` after adding this spec.
+- Regression and package boundary: focused Inspector tests, `npm test`,
+ `npm run pack:verify`, and `git diff --check`.
+- Privacy risk: fixture generation must remain pure and fixed; the docs build
+ must never call session discovery or Git history collection.
+- UI risk: nested browser scrolling can obscure the Workbench. Keep the wrapper
+ as a viewport-height flex surface and provide a visible full-screen escape.
+
+Observed on 2026-08-13:
+
+- Focused Inspector coverage: 21 tests passed.
+- Documentation link graph: 6 tests passed after regeneration.
+- Full regression suite: 92 files and 1,309 tests passed.
+- Package verification: npm package and runtime zip contents passed.
+- Production documentation build: English and Simplified Chinese builds passed.
+- Browser verification: the English and Simplified Chinese wrappers loaded with
+ no console errors or page-level horizontal overflow; the embedded sample used
+ English-only fixture data and realistic short commit hashes. The standalone
+ sample opened Session View and switched to Replay successfully.
+- Documentation extension: the English and Simplified Chinese production pages
+ exposed all feature, usage, evidence-label, and local-project sections; the
+ advanced CLI command and installation link were present, with no console
+ errors or page-level horizontal overflow.
diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css
index 50db69e..0e5bf58 100644
--- a/docs/src/css/custom.css
+++ b/docs/src/css/custom.css
@@ -69,3 +69,59 @@ html[data-theme="dark"] {
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
+
+/* `New` is presentation metadata, not part of the Inspector product name or
+ accessible link label. The compact corner tag becomes inline on mobile. */
+.navbar__link--inspector-new {
+ position: relative;
+ margin-right: 10px;
+ padding-right: 12px;
+}
+
+.navbar__link--inspector-new::after {
+ content: "New";
+ position: absolute;
+ top: 2px;
+ right: 0;
+ padding: 2px 5px;
+ border-radius: 999px;
+ color: #fff;
+ background: #218739;
+ box-shadow: 0 0 0 2px var(--ifm-navbar-background-color);
+ font-size: 8px;
+ font-weight: 800;
+ line-height: 1.25;
+ letter-spacing: 0.02em;
+ transform: translate(20%, -20%);
+ pointer-events: none;
+}
+
+@media (max-width: 996px) {
+ .navbar__link--inspector-new {
+ display: flex;
+ margin-right: 0;
+ padding-right: var(--ifm-navbar-item-padding-horizontal);
+ align-items: center;
+ gap: 8px;
+ }
+
+ .navbar__link--inspector-new::after {
+ position: static;
+ box-shadow: none;
+ font-size: 9px;
+ transform: none;
+ }
+}
+
+@media (min-width: 997px) and (max-width: 1250px) {
+ .navbar__item {
+ padding-right: 0.55rem;
+ padding-left: 0.55rem;
+ font-size: 0.88rem;
+ white-space: nowrap;
+ }
+
+ .navbar__search-input {
+ width: 9.5rem;
+ }
+}
diff --git a/docs/src/pages/inspector/index.js b/docs/src/pages/inspector/index.js
new file mode 100644
index 0000000..50eb4b5
--- /dev/null
+++ b/docs/src/pages/inspector/index.js
@@ -0,0 +1,337 @@
+import BrowserOnly from "@docusaurus/BrowserOnly";
+import Link from "@docusaurus/Link";
+import Translate, { translate } from "@docusaurus/Translate";
+import useBaseUrl from "@docusaurus/useBaseUrl";
+import Layout from "@theme/Layout";
+
+import styles from "./inspector.module.css";
+
+export default function InspectorPage() {
+ const demoUrl = useBaseUrl("/demo/harness-inspector/");
+ const installationUrl = useBaseUrl("/docs/installation");
+
+ return (
+
+
+
+
+
+
+
+ Interactive Harness Inspector sample
+
+
+
+
+ Loading the interactive sample…
+
+
+ )}
+ >
+ {() => (
+
+ )}
+
+
+
+
+
+ This deterministic sample uses fictional English data. It does not
+ read your workspace, Git history, or coding-agent sessions.
+
+
+
+
+
+
+ What it shows
+
+
+
+ Follow delivery without losing the evidence boundary
+
+
+
+
+ Inspector brings product structure, agent activity, and Git
+ outcomes into one read-only workspace. It keeps strong links
+ separate from useful—but limited—context.
+
+
+
+
+
+
+ 01
+
+
+ Intent to delivery
+
+
+
+
+ Navigate the Delivery Tree from capability and Story intent
+ to the sessions, files, and commits associated with it.
+
+
+
+
+ 02
+
+
+ A session in context
+
+
+
+
+ Review retained prompts, normalized tool activity, file
+ paths, commit events, and the read-only session replay.
+
+
+
+
+ 03
+
+
+ Explainable evidence
+
+
+
+
+ Synchronized selections and the Evidence Drawer show why a
+ relationship exists, its confidence, and what it does not
+ prove.
+
+
+
+
+
+
+
+
+ How to use it
+
+
+
+ Start broad, then follow the evidence
+
+
+
+
+
+
+
+
+ Choose a scope
+
+
+
+
+ Use Delivery Tree for product intent or Date for a
+ time-based view of sessions and commits.
+
+
+
+
+
+
+
+
+ Read the three lanes
+
+
+
+
+ Compare user prompts, checkpoint activity, and commits or
+ files without leaving the selected Story or date.
+
+
+
+
+
+
+
+
+ Inspect the relationship
+
+
+
+
+ Select a prompt, action, commit, or path. Related items
+ stay highlighted while the Evidence Drawer explains the
+ link and its limits.
+
+
+
+
+
+
+
+
+ Open Session View or Replay
+
+
+
+
+ Drill into retained turns and normalized tool calls, or
+ replay the observed sequence. Replay never reruns tools or
+ resumes the coding-agent session.
+
+
+
+
+
+
+
+
+
+
+ Reading the labels
+
+
+
+ Correlation is visible; authorship is not assumed
+
+
+
+
+
+
Explicit / direct
+
+
+ A retained reference directly connects the Story, session,
+ or commit.
+
+
+
+
+
Observed same-path
+
+
+ The session and commit share exact repository paths. This
+ supports correlation, not proof of authorship.
+
+
+
+
+
Candidate
+
+
+ Structure or timing suggests a useful association that
+ still needs human review.
+
+
+
+
+
Contextual
+
+
+ Nearby history helps explain the delivery, but no direct
+ relationship is claimed.
+
+
+
+
+
+
+
+
+
+ Use your own evidence
+
+
+
+ Generate a private, self-contained Inspector
+
+
+
+
+ After installing Better Harness, run the advanced Inspector
+ command inside a repository. Collection stays local and the
+ generated report is read-only.
+
+
+
+
+ View installation options
+
+
→
+
+
+
+
better-harness harness-inspector render --workspace .
+
+
+ Default output: .qoder/better-harness-runs/harness-inspector/inspector.html
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/src/pages/inspector/inspector.module.css b/docs/src/pages/inspector/inspector.module.css
new file mode 100644
index 0000000..c4c4440
--- /dev/null
+++ b/docs/src/pages/inspector/inspector.module.css
@@ -0,0 +1,449 @@
+.layout {
+ display: flex;
+ min-height: calc(100vh - var(--ifm-navbar-height));
+ flex-direction: column;
+}
+
+.page {
+ width: 100%;
+ min-width: 0;
+ flex: 1;
+ padding: 34px clamp(22px, 3vw, 48px) 42px;
+ background:
+ radial-gradient(circle at 8% -20%, rgba(54, 133, 191, 0.13), transparent 32rem),
+ var(--ifm-background-color);
+}
+
+.intro {
+ width: min(1600px, 100%);
+ margin: 0 auto 28px;
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 28px;
+}
+
+.copy {
+ max-width: 850px;
+}
+
+.eyebrow {
+ margin: 0 0 6px;
+ color: var(--ifm-color-primary);
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.intro h1 {
+ margin: 0;
+ font-size: clamp(2rem, 4vw, 3.4rem);
+ line-height: 1;
+ letter-spacing: -0.045em;
+}
+
+.lead {
+ max-width: 780px;
+ margin: 12px 0 0;
+ color: var(--ifm-color-emphasis-700);
+ font-size: clamp(1rem, 1.35vw, 1.16rem);
+ line-height: 1.55;
+}
+
+.actions {
+ display: grid;
+ justify-items: end;
+ gap: 10px;
+}
+
+.boundaries {
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 7px;
+ list-style: none;
+}
+
+.boundaries li {
+ padding: 5px 9px;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: 999px;
+ color: var(--ifm-color-emphasis-700);
+ background: var(--ifm-background-surface-color, var(--ifm-background-color));
+ font-size: 0.72rem;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.fullscreen {
+ display: inline-flex;
+ min-height: 38px;
+ padding: 8px 12px;
+ align-items: center;
+ gap: 7px;
+ border: 1px solid var(--ifm-color-primary);
+ border-radius: 8px;
+ color: var(--ifm-color-primary);
+ font-size: 0.82rem;
+ font-weight: 800;
+ text-decoration: none;
+ transition: background var(--ifm-transition-fast), color var(--ifm-transition-fast);
+}
+
+.fullscreen:hover,
+.fullscreen:focus-visible {
+ color: #fff;
+ background: var(--ifm-color-primary);
+ text-decoration: none;
+}
+
+.fullscreen:focus-visible {
+ outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 30%, transparent);
+ outline-offset: 3px;
+}
+
+.demo {
+ width: min(1600px, 100%);
+ height: max(720px, calc(100vh - 245px));
+ min-height: 720px;
+ margin: 0 auto;
+ padding: 10px;
+ overflow: hidden;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: 16px;
+ background: var(--ifm-background-surface-color, var(--ifm-background-color));
+ box-shadow: var(--bh-shadow-lg);
+}
+
+.frame {
+ width: 100%;
+ height: 100%;
+ display: block;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: 10px;
+ background: #fff;
+}
+
+.loading {
+ height: 100%;
+ display: grid;
+ place-items: center;
+ color: var(--ifm-color-emphasis-600);
+ font-size: 0.85rem;
+}
+
+.note {
+ width: min(1600px, 100%);
+ margin: 11px auto 0;
+ color: var(--ifm-color-emphasis-600);
+ font-size: 0.75rem;
+}
+
+.documentation {
+ width: min(1220px, 100%);
+ margin: 0 auto;
+ padding: 88px 0 24px;
+}
+
+.sectionIntro {
+ max-width: 760px;
+}
+
+.sectionIntro h2,
+.localProject h2 {
+ margin: 0;
+ font-size: clamp(1.65rem, 3vw, 2.45rem);
+ line-height: 1.12;
+ letter-spacing: -0.035em;
+}
+
+.sectionIntro > p:last-child,
+.localProject > div > p {
+ margin: 14px 0 0;
+ color: var(--ifm-color-emphasis-700);
+ font-size: 1rem;
+ line-height: 1.65;
+}
+
+.featureGrid {
+ margin-top: 32px;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.featureCard {
+ min-height: 240px;
+ padding: 26px;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: 16px;
+ background: var(--ifm-background-surface-color, var(--ifm-background-color));
+ box-shadow: var(--bh-shadow-sm);
+}
+
+.cardIndex {
+ display: block;
+ margin-bottom: 52px;
+ color: var(--ifm-color-primary);
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.1em;
+}
+
+.featureCard h3,
+.steps h3 {
+ margin: 0;
+ font-size: 1.08rem;
+ line-height: 1.3;
+}
+
+.featureCard p,
+.steps p {
+ margin: 9px 0 0;
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.9rem;
+ line-height: 1.6;
+}
+
+.howTo,
+.evidenceGuide {
+ margin-top: 96px;
+}
+
+.steps {
+ margin: 34px 0 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ counter-reset: inspector-step;
+ list-style: none;
+}
+
+.steps li {
+ position: relative;
+ min-width: 0;
+ padding: 58px 25px 0;
+ border-top: 1px solid var(--ifm-color-emphasis-300);
+ counter-increment: inspector-step;
+}
+
+.steps li:first-child {
+ padding-left: 0;
+}
+
+.steps li:last-child {
+ padding-right: 0;
+}
+
+.steps li::before {
+ content: counter(inspector-step);
+ position: absolute;
+ top: -17px;
+ left: 25px;
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ border: 1px solid var(--ifm-color-primary);
+ border-radius: 50%;
+ color: var(--ifm-color-primary);
+ background: var(--ifm-background-color);
+ font-size: 0.78rem;
+ font-weight: 800;
+}
+
+.steps li:first-child::before {
+ left: 0;
+}
+
+.evidenceGrid {
+ margin: 34px 0 0;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1px;
+ overflow: hidden;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: 16px;
+ background: var(--ifm-color-emphasis-300);
+}
+
+.evidenceGrid > div {
+ min-width: 0;
+ padding: 24px;
+ background: var(--ifm-background-surface-color, var(--ifm-background-color));
+}
+
+.evidenceGrid dt {
+ margin: 0 0 11px;
+}
+
+.evidenceGrid dt span {
+ display: inline-flex;
+ padding: 4px 8px;
+ border-radius: 999px;
+ font-size: 0.68rem;
+ font-weight: 800;
+ letter-spacing: 0.055em;
+ text-transform: uppercase;
+}
+
+.evidenceGrid dd {
+ margin: 0;
+ color: var(--ifm-color-emphasis-700);
+ font-size: 0.88rem;
+ line-height: 1.58;
+}
+
+.explicit {
+ color: #235ba0;
+ background: rgba(65, 124, 191, 0.13);
+}
+
+.observed {
+ color: #1f6f63;
+ background: rgba(45, 139, 124, 0.14);
+}
+
+.candidate {
+ color: #92550e;
+ background: rgba(211, 142, 47, 0.16);
+}
+
+.contextual {
+ color: var(--ifm-color-emphasis-700);
+ background: var(--ifm-color-emphasis-200);
+}
+
+.localProject {
+ margin-top: 96px;
+ padding: 34px;
+ display: grid;
+ grid-template-columns: minmax(0, 1.05fr) minmax(360px, 0.95fr);
+ align-items: center;
+ gap: 48px;
+ border: 1px solid color-mix(in srgb, var(--ifm-color-primary) 38%, var(--ifm-color-emphasis-300));
+ border-radius: 18px;
+ background:
+ linear-gradient(135deg, color-mix(in srgb, var(--ifm-color-primary) 8%, transparent), transparent 55%),
+ var(--ifm-background-surface-color, var(--ifm-background-color));
+}
+
+.installLink {
+ margin-top: 18px;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.88rem;
+ font-weight: 800;
+ text-decoration: none;
+}
+
+.commandPanel {
+ min-width: 0;
+ padding: 20px;
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: 12px;
+ background: var(--ifm-color-emphasis-100);
+}
+
+.commandPanel code {
+ display: block;
+ overflow-wrap: anywhere;
+ color: var(--ifm-color-emphasis-900);
+ background: transparent;
+ font-size: 0.84rem;
+ line-height: 1.55;
+}
+
+.commandPanel p {
+ margin: 14px 0 0;
+ color: var(--ifm-color-emphasis-600);
+ font-size: 0.72rem;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 860px) {
+ .page {
+ padding: 24px 16px 30px;
+ }
+
+ .intro {
+ display: grid;
+ gap: 16px;
+ }
+
+ .actions {
+ justify-items: start;
+ }
+
+ .boundaries {
+ justify-content: flex-start;
+ }
+
+ .demo {
+ height: 760px;
+ min-height: 760px;
+ padding: 7px;
+ border-radius: 12px;
+ }
+
+ .documentation {
+ padding-top: 64px;
+ }
+
+ .featureGrid,
+ .evidenceGrid,
+ .localProject {
+ grid-template-columns: 1fr;
+ }
+
+ .featureCard {
+ min-height: 0;
+ }
+
+ .cardIndex {
+ margin-bottom: 34px;
+ }
+
+ .steps {
+ grid-template-columns: 1fr;
+ gap: 0;
+ }
+
+ .steps li,
+ .steps li:first-child,
+ .steps li:last-child {
+ padding: 22px 0 22px 50px;
+ border-top: 0;
+ border-left: 1px solid var(--ifm-color-emphasis-300);
+ }
+
+ .steps li::before,
+ .steps li:first-child::before {
+ top: 19px;
+ left: -17px;
+ }
+
+ .localProject {
+ padding: 26px;
+ gap: 28px;
+ }
+}
+
+@media (max-width: 520px) {
+ .featureCard,
+ .evidenceGrid > div,
+ .localProject {
+ padding: 21px;
+ }
+
+ .howTo,
+ .evidenceGuide,
+ .localProject {
+ margin-top: 72px;
+ }
+}
diff --git a/docs/static/img/favicon.svg b/docs/static/img/favicon.svg
index abc5b38..a4b6351 100644
--- a/docs/static/img/favicon.svg
+++ b/docs/static/img/favicon.svg
@@ -1,6 +1,11 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/scripts/harness-inspector/demo-report.mjs b/scripts/harness-inspector/demo-report.mjs
new file mode 100644
index 0000000..18a5cb5
--- /dev/null
+++ b/scripts/harness-inspector/demo-report.mjs
@@ -0,0 +1,437 @@
+import { parseFeatureTreeMarkdown } from "./feature-tree.mjs";
+import { buildHarnessInspectorReport } from "./report-model.mjs";
+import { renderHarnessInspectorHtml } from "./render-html.mjs";
+
+export const HARNESS_INSPECTOR_DEMO_GENERATED_AT = "2026-08-13T00:00:00.000Z";
+
+const DEMO_COMMIT_HASHES = Object.freeze({
+ retryPolicy: "e330bff085ef8768e621eec86555d08f85556b12",
+ recoveryEvidence: "7a76488f1400af5428d55bbfa4954064c513583b",
+ failureTelemetry: "19a6111aeb86bc5ec4d114f0a6a5cc131366b205",
+});
+
+const DEMO_FEATURE_TREE = `# Feature: Checkout reliability {#checkout-reliability}
+- status: active
+- evidence: declared
+
+## Story: Add bounded payment retries {#bounded-payment-retries}
+- stage: implementation
+- evidence: declared
+- spec: docs/specs/bounded-payment-retries.md
+- prompt: Add a bounded retry policy for transient payment failures.
+- session: codex/demo-retry-policy
+- commit: ${DEMO_COMMIT_HASHES.retryPolicy.slice(0, 7)}
+
+## Story: Explain payment failure telemetry {#payment-failure-telemetry}
+- stage: validation
+- evidence: candidate
+- prompt: Make checkout failure telemetry easier to review.
+- session: qoder/demo-payment-telemetry
+
+# Feature: Delivery confidence {#delivery-confidence}
+- status: active
+- evidence: declared
+
+## Story: Verify checkout recovery evidence {#checkout-recovery-evidence}
+- stage: validation
+- evidence: declared
+- prompt: Verify the retry behavior and keep the evidence reviewable.
+- commit: ${DEMO_COMMIT_HASHES.recoveryEvidence.slice(0, 7)}
+`;
+
+const at = (value) => Date.parse(value);
+
+function call(step, {
+ toolName,
+ operation,
+ actionLabel,
+ family,
+ startedAt,
+ durationMs,
+ detail,
+ filePaths = [],
+ status = "observed",
+}) {
+ return {
+ id: `demo-call-${step}`,
+ step,
+ toolName,
+ operation,
+ actionLabel,
+ family,
+ status,
+ durationStatus: Number.isFinite(durationMs) ? "observed" : "unobserved",
+ ...(Number.isFinite(durationMs) ? { durationMs } : {}),
+ startedAt: at(startedAt),
+ detail,
+ detailKind: "redacted-input-summary",
+ filePaths,
+ };
+}
+
+function session({
+ sessionId,
+ platform,
+ prompt,
+ response,
+ firstSeen,
+ lastSeen,
+ model,
+ calls,
+ notes,
+}) {
+ const files = [...new Set(calls.flatMap((item) => item.filePaths ?? []))];
+ const steps = [];
+ calls.forEach((item, index) => {
+ if (notes[index]) steps.push({ kind: "note", text: notes[index] });
+ steps.push({ kind: "tool", callStep: item.step, toolName: item.toolName });
+ });
+ return {
+ sessionId,
+ platform,
+ firstSeen,
+ lastSeen,
+ durationMs: at(lastSeen) - at(firstSeen),
+ files,
+ prompts: [{ text: prompt, timestamp: firstSeen }],
+ promptCount: 1,
+ promptObservationCount: 1,
+ userTurnCount: 1,
+ retainedUserTurnCount: 1,
+ assistantMessageCount: notes.length + 1,
+ toolCallCount: calls.length,
+ fileEditCount: calls.filter((item) => item.operation === "edit-files").length,
+ models: [model],
+ tokenUsage: { inputTokens: 2_400, outputTokens: 980, cacheReadInputTokens: 320 },
+ source: "public-demo-fixture",
+ toolTrace: {
+ schemaVersion: 2,
+ totalCalls: calls.length,
+ shownCalls: calls.length,
+ truncated: false,
+ calls: calls.map((item) => ({
+ id: `T${item.step}`,
+ step: item.step,
+ toolName: item.toolName,
+ status: item.status,
+ durationStatus: item.durationStatus,
+ ...(Number.isFinite(item.durationMs)
+ ? { durationMs: item.durationMs, timingSource: "transcript-pair" }
+ : {}),
+ })),
+ },
+ toolActivity: {
+ kind: "NormalizedToolActivityV1",
+ schemaVersion: 1,
+ calls,
+ },
+ dialogue: {
+ truncated: false,
+ turns: [{
+ index: 1,
+ anchorId: "turn-1",
+ prompt: { text: prompt, timestamp: firstSeen },
+ steps,
+ toolCallCount: calls.length,
+ messageCount: notes.length + calls.length + 2,
+ response,
+ durationMs: at(lastSeen) - at(firstSeen),
+ startMs: at(firstSeen),
+ endMs: at(lastSeen),
+ }],
+ },
+ };
+}
+
+function commit({ hash, subject, authoredAt, files, matches }) {
+ return {
+ hash,
+ shortHash: hash.slice(0, 7),
+ subject,
+ authorName: "Demo Contributor",
+ authoredAt,
+ committedAt: authoredAt,
+ fileCount: files.length,
+ files,
+ linesAdded: files.reduce((sum, file) => sum + file.added, 0),
+ linesRemoved: files.reduce((sum, file) => sum + file.removed, 0),
+ matches,
+ };
+}
+
+function demoSessions() {
+ return [
+ session({
+ sessionId: "demo-retry-policy",
+ platform: "codex",
+ prompt: "Add a bounded retry policy for transient payment failures and verify the behavior.",
+ response: "Implemented a three-attempt retry policy, added focused coverage, and recorded the resulting commit evidence.",
+ firstSeen: "2026-08-11T09:00:00.000Z",
+ lastSeen: "2026-08-11T09:42:00.000Z",
+ model: "demo-model-a",
+ notes: [
+ "I located the checkout boundary and its existing failure classifications.",
+ "The retry policy should remain isolated from permanent payment failures.",
+ "Focused tests now cover successful recovery and exhausted attempts.",
+ ],
+ calls: [
+ call(1, {
+ toolName: "Search",
+ operation: "search-repository",
+ actionLabel: "Search repository",
+ family: "inspect",
+ startedAt: "2026-08-11T09:03:00.000Z",
+ durationMs: 1_100,
+ detail: "Find the checkout payment boundary and existing retry behavior",
+ filePaths: ["src/payments/checkout-service.ts"],
+ }),
+ call(2, {
+ toolName: "Read",
+ operation: "read-files",
+ actionLabel: "Read files",
+ family: "inspect",
+ startedAt: "2026-08-11T09:08:00.000Z",
+ durationMs: 900,
+ detail: "Inspect payment failure categories and checkout tests",
+ filePaths: ["src/payments/checkout-service.ts", "test/payments/checkout-service.test.ts"],
+ }),
+ call(3, {
+ toolName: "ApplyPatch",
+ operation: "edit-files",
+ actionLabel: "Edit files",
+ family: "change",
+ startedAt: "2026-08-11T09:17:00.000Z",
+ durationMs: 2_400,
+ detail: "Add the bounded retry policy and integrate it with checkout",
+ filePaths: ["src/payments/retry-policy.ts", "src/payments/checkout-service.ts"],
+ }),
+ call(4, {
+ toolName: "ApplyPatch",
+ operation: "edit-files",
+ actionLabel: "Edit files",
+ family: "change",
+ startedAt: "2026-08-11T09:26:00.000Z",
+ durationMs: 1_700,
+ detail: "Add retry success and exhaustion scenarios",
+ filePaths: ["test/payments/retry-policy.test.ts"],
+ }),
+ call(5, {
+ toolName: "Test",
+ operation: "run-tests",
+ actionLabel: "Run tests",
+ family: "verify",
+ startedAt: "2026-08-11T09:33:00.000Z",
+ durationMs: 18_000,
+ detail: "Run the focused checkout retry tests",
+ filePaths: ["test/payments/retry-policy.test.ts"],
+ }),
+ call(6, {
+ toolName: "Git",
+ operation: "create-commit",
+ actionLabel: "Create commit",
+ family: "deliver",
+ startedAt: "2026-08-11T09:39:00.000Z",
+ durationMs: 1_300,
+ detail: "Create the bounded retry policy commit",
+ filePaths: ["src/payments/retry-policy.ts", "src/payments/checkout-service.ts", "test/payments/retry-policy.test.ts"],
+ }),
+ ],
+ }),
+ session({
+ sessionId: "demo-payment-telemetry",
+ platform: "qoder",
+ prompt: "Make checkout failure telemetry easier to review without exposing payment details.",
+ response: "Added bounded failure categories and verified that telemetry excludes customer and payment payloads.",
+ firstSeen: "2026-08-12T10:10:00.000Z",
+ lastSeen: "2026-08-12T10:38:00.000Z",
+ model: "demo-model-b",
+ notes: [
+ "The existing event shape mixes operational categories with provider detail.",
+ "A bounded category field keeps the event useful without retaining sensitive payloads.",
+ ],
+ calls: [
+ call(1, {
+ toolName: "Search",
+ operation: "search-repository",
+ actionLabel: "Search repository",
+ family: "inspect",
+ startedAt: "2026-08-12T10:12:00.000Z",
+ durationMs: 800,
+ detail: "Find checkout failure telemetry producers",
+ filePaths: ["src/telemetry/checkout-events.ts"],
+ }),
+ call(2, {
+ toolName: "Read",
+ operation: "read-files",
+ actionLabel: "Read files",
+ family: "inspect",
+ startedAt: "2026-08-12T10:16:00.000Z",
+ durationMs: 700,
+ detail: "Inspect event fields and privacy tests",
+ filePaths: ["src/telemetry/checkout-events.ts", "test/telemetry/checkout-events.test.ts"],
+ }),
+ call(3, {
+ toolName: "ApplyPatch",
+ operation: "edit-files",
+ actionLabel: "Edit files",
+ family: "change",
+ startedAt: "2026-08-12T10:22:00.000Z",
+ durationMs: 1_900,
+ detail: "Add bounded failure categories to checkout telemetry",
+ filePaths: ["src/telemetry/checkout-events.ts"],
+ }),
+ call(4, {
+ toolName: "Test",
+ operation: "run-tests",
+ actionLabel: "Run tests",
+ family: "verify",
+ startedAt: "2026-08-12T10:31:00.000Z",
+ durationMs: 12_000,
+ detail: "Verify telemetry categories and payload redaction",
+ filePaths: ["test/telemetry/checkout-events.test.ts"],
+ }),
+ ],
+ }),
+ session({
+ sessionId: "demo-release-notes",
+ platform: "codex",
+ prompt: "Summarize the checkout release evidence for the weekly review.",
+ response: "Prepared a review summary and left the session unmapped because no Feature Tree declaration identifies its delivery owner.",
+ firstSeen: "2026-08-12T13:00:00.000Z",
+ lastSeen: "2026-08-12T13:14:00.000Z",
+ model: "demo-model-a",
+ notes: ["The summary is contextual evidence and should not be promoted to authorship proof."],
+ calls: [
+ call(1, {
+ toolName: "Read",
+ operation: "read-files",
+ actionLabel: "Read files",
+ family: "inspect",
+ startedAt: "2026-08-12T13:02:00.000Z",
+ durationMs: 600,
+ detail: "Review the public checkout release notes",
+ filePaths: ["docs/releases/checkout-reliability.md"],
+ }),
+ call(2, {
+ toolName: "ApplyPatch",
+ operation: "edit-files",
+ actionLabel: "Edit files",
+ family: "change",
+ startedAt: "2026-08-12T13:08:00.000Z",
+ durationMs: 1_100,
+ detail: "Clarify the observed retry and telemetry evidence",
+ filePaths: ["docs/releases/checkout-reliability.md"],
+ }),
+ ],
+ }),
+ ];
+}
+
+function demoCorrelation() {
+ return {
+ schemaVersion: 1,
+ commits: [
+ commit({
+ hash: DEMO_COMMIT_HASHES.retryPolicy,
+ subject: "feat(checkout): add bounded payment retries",
+ authoredAt: "2026-08-11T09:40:00.000Z",
+ files: [
+ { path: "src/payments/retry-policy.ts", added: 54, removed: 0 },
+ { path: "src/payments/checkout-service.ts", added: 18, removed: 4 },
+ { path: "test/payments/retry-policy.test.ts", added: 72, removed: 0 },
+ ],
+ matches: [{
+ sessionId: "demo-retry-policy",
+ platform: "codex",
+ confidence: "explicit",
+ evidence: {
+ linkType: "harness-session",
+ commitObservedInCall: "A6",
+ timeOverlap: true,
+ overlappingFileCount: 3,
+ overlappingFiles: ["src/payments/retry-policy.ts", "src/payments/checkout-service.ts", "test/payments/retry-policy.test.ts"],
+ cwdWithinRepo: true,
+ },
+ }],
+ }),
+ commit({
+ hash: DEMO_COMMIT_HASHES.recoveryEvidence,
+ subject: "test(checkout): verify retry recovery evidence",
+ authoredAt: "2026-08-12T09:30:00.000Z",
+ files: [
+ { path: "test/payments/retry-policy.test.ts", added: 36, removed: 5 },
+ { path: "docs/verification/checkout-recovery.md", added: 28, removed: 0 },
+ { path: "docs/releases/checkout-reliability.md", added: 12, removed: 2 },
+ ],
+ matches: [{
+ sessionId: "demo-retry-policy",
+ platform: "codex",
+ confidence: "high",
+ evidence: {
+ linkType: null,
+ commitObservedInCall: null,
+ timeOverlap: false,
+ overlappingFileCount: 1,
+ overlappingFiles: ["test/payments/retry-policy.test.ts"],
+ cwdWithinRepo: true,
+ },
+ }],
+ }),
+ commit({
+ hash: DEMO_COMMIT_HASHES.failureTelemetry,
+ subject: "feat(telemetry): classify checkout failures",
+ authoredAt: "2026-08-12T10:36:00.000Z",
+ files: [
+ { path: "src/telemetry/checkout-events.ts", added: 31, removed: 12 },
+ { path: "test/telemetry/checkout-events.test.ts", added: 44, removed: 3 },
+ ],
+ matches: [{
+ sessionId: "demo-payment-telemetry",
+ platform: "qoder",
+ confidence: "high",
+ evidence: {
+ linkType: null,
+ commitObservedInCall: null,
+ timeOverlap: true,
+ overlappingFileCount: 2,
+ overlappingFiles: ["src/telemetry/checkout-events.ts", "test/telemetry/checkout-events.test.ts"],
+ cwdWithinRepo: true,
+ },
+ }],
+ }),
+ ],
+ };
+}
+
+export function buildHarnessInspectorDemoReport() {
+ const report = buildHarnessInspectorReport({
+ repoRoot: "/demo/atlas-checkout",
+ featureTree: parseFeatureTreeMarkdown(DEMO_FEATURE_TREE, {
+ source: "/demo/atlas-checkout/.better-harness/feature-tree.md",
+ }),
+ sessions: demoSessions(),
+ correlation: demoCorrelation(),
+ providers: [
+ { platform: "codex", status: "ok", discovered: 2 },
+ { platform: "qoder", status: "ok", discovered: 1 },
+ ],
+ filters: {
+ platform: "codex,qoder",
+ since: "2026-08-11",
+ until: "2026-08-12",
+ commitLimit: 3,
+ sessionLimit: 3,
+ },
+ diagnostics: ["This deterministic public sample uses fictional English data and does not read a local workspace."],
+ });
+ report.generatedAt = HARNESS_INSPECTOR_DEMO_GENERATED_AT;
+ return report;
+}
+
+export function renderHarnessInspectorDemoHtml() {
+ return renderHarnessInspectorHtml(buildHarnessInspectorDemoReport(), {
+ contextLabel: "English sample data · no live workspace access",
+ robots: "noindex, follow",
+ sample: true,
+ });
+}
diff --git a/scripts/harness-inspector/index.mjs b/scripts/harness-inspector/index.mjs
index 2c1f28c..a5d9290 100644
--- a/scripts/harness-inspector/index.mjs
+++ b/scripts/harness-inspector/index.mjs
@@ -12,4 +12,9 @@ export {
HARNESS_INSPECTOR_REPORT_SCHEMA_VERSION,
} from "./report-model.mjs";
export { renderHarnessInspectorHtml } from "./render-html.mjs";
+export {
+ buildHarnessInspectorDemoReport,
+ HARNESS_INSPECTOR_DEMO_GENERATED_AT,
+ renderHarnessInspectorDemoHtml,
+} from "./demo-report.mjs";
export { main } from "./cli.mjs";
diff --git a/scripts/harness-inspector/render-html.mjs b/scripts/harness-inspector/render-html.mjs
index e582f2f..7b5b5b8 100644
--- a/scripts/harness-inspector/render-html.mjs
+++ b/scripts/harness-inspector/render-html.mjs
@@ -82,15 +82,25 @@ function platformBadge(report) {
return `${contributing.length} providers`;
}
-export function renderHarnessInspectorHtml(report) {
+export function renderHarnessInspectorHtml(report, {
+ contextLabel = "real local evidence",
+ robots = null,
+ sample = false,
+} = {}) {
if (report?.kind !== "HarnessInspectorReportV1") throw new Error("renderHarnessInspectorHtml requires HarnessInspectorReportV1");
+ if (robots !== null && robots !== "noindex, follow" && robots !== "noindex, nofollow") {
+ throw new Error("Harness Inspector robots must be noindex, follow or noindex, nofollow");
+ }
const hasFeatureEvidence = report.stories.some((story) => story.sessionLinks.length > 0 || story.commitHashes.length > 0);
const initialMode = report.featureTree.nodes.length > 0 && hasFeatureEvidence ? "feature" : "date";
const workspaceName = escapeHtml(report.workspace.name);
return fillHtmlTemplate({
PAGE_TITLE: `Harness Inspector · ${workspaceName}`,
+ ROBOTS_META: robots ? ` ` : "",
STYLES,
+ BODY_ATTRIBUTES: sample ? ' data-report-context="sample"' : "",
WORKSPACE_NAME: workspaceName,
+ CONTEXT_LABEL: escapeHtml(contextLabel),
FEATURE_TAB_CLASS: initialMode === "feature" ? "active" : "",
FEATURE_TAB_SELECTED: initialMode === "feature" ? "true" : "false",
DATE_TAB_CLASS: initialMode === "date" ? "active" : "",
diff --git a/scripts/harness-inspector/report-model.mjs b/scripts/harness-inspector/report-model.mjs
index 4f29408..a904216 100644
--- a/scripts/harness-inspector/report-model.mjs
+++ b/scripts/harness-inspector/report-model.mjs
@@ -395,10 +395,10 @@ const STORY_MATCH_STOP_WORDS = new Set([
]);
function matchTokens(value) {
- return new Set(String(value ?? "")
+ return new Set((String(value ?? "")
.normalize("NFKC")
.toLowerCase()
- .match(/[\p{L}\p{N}_-]{4,}/gu) ?? []
+ .match(/[\p{L}\p{N}_-]{4,}/gu) ?? [])
.filter((token) => !STORY_MATCH_STOP_WORDS.has(token)));
}
@@ -623,6 +623,184 @@ function buildStoryLinks(tree, sessions, commits, diagnostics) {
return stories;
}
+function replayClock(value) {
+ if (Number.isFinite(value)) return Math.round(value);
+ const parsed = Date.parse(value ?? "");
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function buildSessionReplay(session, commits) {
+ const callsById = new Map(session.toolActivity.calls.map((call) => [call.id, call]));
+ const events = [];
+ const placedCallIds = new Set();
+ let sequence = 0;
+ const append = (event) => {
+ sequence += 1;
+ // Bounded projection text keeps its ellipsis, so Replay can say the body is an
+ // excerpt instead of showing a silently clipped command.
+ const bodyExcerpt = typeof event.body === "string" && event.body.endsWith("…");
+ events.push({ ...event, ...(bodyExcerpt ? { bodyExcerpt: true } : {}), order: sequence });
+ };
+
+ for (const turn of session.dialogue.turns) {
+ const promptAt = replayClock(turn.prompt?.timestamp);
+ const turnStart = replayClock(turn.startMs);
+ append({
+ id: `turn:${turn.index}:prompt`,
+ type: "prompt",
+ turnIndex: turn.index,
+ label: "Prompt",
+ title: `User prompt ${turn.index}`,
+ body: safeText(turn.prompt?.text, 1_500, "Prompt unavailable after privacy filtering"),
+ timeBasis: promptAt !== null ? "observed" : turnStart !== null ? "turn-boundary" : "sequence-only",
+ ...(promptAt !== null || turnStart !== null ? { atMs: promptAt ?? turnStart } : {}),
+ selection: { type: "turn", sessionId: session.sessionId, turnIndex: turn.index },
+ files: [],
+ });
+
+ let noteIndex = 0;
+ for (const step of turn.steps) {
+ if (step.kind === "note") {
+ noteIndex += 1;
+ append({
+ id: `turn:${turn.index}:note:${noteIndex}`,
+ type: "intermediate",
+ turnIndex: turn.index,
+ label: "Intermediate",
+ title: `Intermediate response ${noteIndex}`,
+ body: safeText(step.text, 400, "Intermediate response unavailable"),
+ timeBasis: "sequence-only",
+ selection: { type: "turn", sessionId: session.sessionId, turnIndex: turn.index },
+ files: [],
+ });
+ continue;
+ }
+ if (step.kind !== "tool") continue;
+ const call = callsById.get(step.callId);
+ if (!call) continue;
+ placedCallIds.add(call.id);
+ const atMs = replayClock(call.startedAt);
+ const files = [...(call.filePaths ?? (call.filePath ? [call.filePath] : []))];
+ append({
+ id: `call:${call.id}`,
+ type: "tool-call",
+ turnIndex: turn.index,
+ label: call.actionLabel,
+ title: `${call.id} · ${call.actionLabel}`,
+ body: safeText(call.detail, 240, `${call.toolName} input was not retained`),
+ meta: safeText(call.toolName, 64, "Unknown tool"),
+ status: call.status,
+ timeBasis: atMs !== null ? "observed" : "sequence-only",
+ ...(atMs !== null ? { atMs } : {}),
+ ...(call.durationStatus === "observed" && Number.isFinite(call.durationMs) ? { durationMs: call.durationMs } : {}),
+ selection: { type: "tool-call", sessionId: session.sessionId, callId: call.id },
+ files,
+ });
+ }
+
+ const responseAt = replayClock(turn.endMs);
+ append({
+ id: `turn:${turn.index}:response`,
+ type: "response",
+ turnIndex: turn.index,
+ label: "Response",
+ title: "Assistant response",
+ body: safeText(turn.response, 6_000, "Response body was unavailable or removed by privacy filtering."),
+ availability: turn.response ? "retained" : "unavailable",
+ timeBasis: responseAt !== null ? "turn-boundary" : "sequence-only",
+ ...(responseAt !== null ? { atMs: responseAt } : {}),
+ selection: { type: "turn", sessionId: session.sessionId, turnIndex: turn.index },
+ files: [],
+ });
+ }
+
+ const insertByObservedTime = (event) => {
+ const nextIndex = events.findIndex((candidate) => Number.isFinite(candidate.atMs) && candidate.atMs > event.atMs);
+ if (nextIndex === -1) events.push(event);
+ else events.splice(nextIndex, 0, event);
+ };
+
+ const unplacedCalls = session.toolActivity.calls
+ .filter((call) => !placedCallIds.has(call.id))
+ .slice()
+ .sort((left, right) => (replayClock(left.startedAt) ?? Number.POSITIVE_INFINITY)
+ - (replayClock(right.startedAt) ?? Number.POSITIVE_INFINITY) || left.step - right.step);
+ for (const call of unplacedCalls) {
+ const atMs = replayClock(call.startedAt);
+ const files = [...(call.filePaths ?? (call.filePath ? [call.filePath] : []))];
+ const event = {
+ id: `call:${call.id}`,
+ type: "tool-call",
+ turnIndex: null,
+ label: call.actionLabel,
+ title: `${call.id} · ${call.actionLabel}`,
+ body: safeText(call.detail, 240, `${call.toolName} input was not retained`),
+ meta: safeText(call.toolName, 64, "Unknown tool"),
+ status: call.status,
+ timeBasis: atMs !== null ? "observed" : "sequence-only",
+ ...(atMs !== null ? { atMs } : {}),
+ ...(call.durationStatus === "observed" && Number.isFinite(call.durationMs) ? { durationMs: call.durationMs } : {}),
+ selection: { type: "tool-call", sessionId: session.sessionId, callId: call.id },
+ files,
+ };
+ if (atMs === null) events.push(event);
+ else insertByObservedTime(event);
+ }
+
+ const directCommitLinks = session.commitLinks.filter((link) => ["explicit", "observed-commit"].includes(link.evidenceKind));
+ for (const link of directCommitLinks) {
+ const commit = commits.find((candidate) => candidate.hash === link.hash);
+ const atMs = commit ? commitTimeMs(commit) : null;
+ if (!commit || atMs === null) continue;
+ insertByObservedTime({
+ id: `commit:${commit.hash}`,
+ type: "commit",
+ turnIndex: null,
+ label: "Commit",
+ title: `${commit.shortHash} · ${commit.subject}`,
+ body: safeText(`${commit.fileCount} files · +${commit.linesAdded} / -${commit.linesRemoved}. ${link.limitations[0] ?? ""}`, 600),
+ meta: link.evidenceKind,
+ timeBasis: "observed",
+ atMs,
+ selection: { type: "commit", hash: commit.hash, contextSessionId: session.sessionId },
+ files: commit.files.map((file) => file.path),
+ });
+ }
+
+ events.forEach((event, index) => { event.order = index + 1; });
+ const eventTimes = events.map((event) => event.atMs).filter(Number.isFinite);
+ const observedStart = replayClock(session.firstSeen);
+ const observedEnd = replayClock(session.lastSeen);
+ const startCandidates = [...eventTimes, observedStart].filter(Number.isFinite);
+ const endCandidates = [...events.map((event) => Number.isFinite(event.atMs)
+ ? event.atMs + (Number.isFinite(event.durationMs) ? event.durationMs : 0)
+ : null), observedEnd].filter(Number.isFinite);
+ const files = new Map();
+ for (const event of events) {
+ for (const filePath of event.files) {
+ const file = files.get(filePath) ?? { path: filePath, eventIds: [] };
+ file.eventIds.push(event.id);
+ files.set(filePath, file);
+ }
+ }
+ for (const file of session.toolActivity.files) {
+ if (!files.has(file.path)) files.set(file.path, { path: file.path, eventIds: [] });
+ }
+
+ return {
+ kind: "SessionReplay",
+ schemaVersion: 1,
+ timeBasis: eventTimes.length ? "observed-time" : "call-sequence",
+ startMs: startCandidates.length ? Math.min(...startCandidates) : null,
+ endMs: endCandidates.length ? Math.max(...endCandidates) : null,
+ eventCount: events.length,
+ timedEventCount: eventTimes.length,
+ sequenceOnlyCount: events.filter((event) => event.timeBasis === "sequence-only").length,
+ files: [...files.values()].sort((left, right) => left.path.localeCompare(right.path)),
+ events,
+ };
+}
+
function buildDays(sessions, commits) {
const byDay = new Map();
const ensure = (day) => {
@@ -691,6 +869,7 @@ export function buildHarnessInspectorReport({
const commits = (correlation?.commits ?? []).map(projectCommit);
const reportDiagnostics = diagnostics.map((item) => safeText(item, 240)).filter(Boolean);
const stories = buildStoryLinks(tree, projectedSessions, commits, reportDiagnostics);
+ for (const session of projectedSessions) session.replay = buildSessionReplay(session, commits);
const stages = [...new Set(tree.nodes.map((node) => node.stage).filter(Boolean))].sort();
const unmappedSessionIds = projectedSessions.filter((session) => session.storyLinks.length === 0).map((session) => session.sessionId);
diff --git a/scripts/harness-inspector/ui/workbench.css b/scripts/harness-inspector/ui/workbench.css
index 71e7b14..ad58c9b 100644
--- a/scripts/harness-inspector/ui/workbench.css
+++ b/scripts/harness-inspector/ui/workbench.css
@@ -7,6 +7,11 @@
a grid column, so opening it cannot reflow the lanes underneath it. */
body { --drawer-inset:0px; }
body.drawer-open { --drawer-inset:var(--drawer-width); }
+ /* Session View is a fixed overlay with its own scroller. Without locking the
+ root scroller, a wheel gesture near the overlay edge scrolls a Workbench the
+ reviewer cannot see. Programmatic reveal still works, and the Workbench keeps
+ its offset for when the overlay closes. */
+ html:has(body.session-open) { overflow:hidden; }
.visually-hidden { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; }
.app { min-height:100vh; display:grid; grid-template-columns:270px minmax(0,1fr); margin-right:var(--drawer-inset); }
.app.picker-collapsed { grid-template-columns:46px minmax(0,1fr); }
@@ -134,6 +139,11 @@
.commit-bridge.linked { border-left-color:#16805b; color:#155f48; background:#edf8f3; }
.activity-details > summary { min-height:34px; padding:8px 2px; display:flex; align-items:center; justify-content:space-between; gap:12px; cursor:pointer; color:#35445a; font-size:10px; font-weight:700; }
.activity-details > summary small { color:var(--muted); font-size:8px; font-weight:500; }
+ .activity-actions { display:flex; flex-wrap:wrap; gap:7px; padding:2px 2px 9px; }
+ .activity-action { min-height:28px; border:1px solid #cad6e6; border-radius:7px; padding:5px 9px; color:#244f86; background:#f5f9ff; cursor:pointer; font-size:9px; font-weight:700; }
+ .activity-action:hover { background:#eaf2ff; }
+ .activity-action.primary { color:#fff; border-color:#2f5fa8; background:#376fcb; }
+ .activity-action.primary:hover { background:#2f5fa8; }
.trace-target { min-width:0; border-top:1px solid var(--line); }
.chart-card { min-width:0; color:#526072; }
.chart-toolbar { padding:7px 2px 5px; display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:9px; }
@@ -152,6 +162,10 @@
.chart-lane-label { fill:#526072; font-size:10px; font-weight:700; }
.chart-tick, .chart-axis-label { fill:#8a96a6; font-size:9px; }
.chart-axis-label { font-weight:700; }
+ .chart-ribbon-base { fill:#e9edf3; }
+ .chart-ribbon-block { cursor:pointer; }
+ .chart-ribbon-block:hover, .chart-ribbon-block:focus { outline:none; stroke:#163f77; stroke-width:2; }
+ .chart-ribbon-label { fill:#41506a; }
.chart-gap rect { fill:rgba(148,163,184,.13); }
.chart-gap-label { fill:#8a96a6; font-size:8px; font-style:italic; }
.chart-lane, .chart-gap, .chart-grid-line, .chart-tick, .chart-axis-line, .chart-axis-label { pointer-events:none; }
@@ -172,6 +186,7 @@
.legend-dot { width:8px; height:8px; display:inline-block; border-radius:2px; background:var(--blue); }
.legend-dot.failed { background:var(--red); }
.legend-dot.gap { background:rgba(148,163,184,.45); }
+ .legend-dot.between { background:#e9edf3; box-shadow:inset 0 0 0 1px #cbd3de; }
.legend-dot.commit { border-radius:1px; background:#16805b; transform:rotate(45deg); }
.chart-empty { padding:18px; color:var(--muted); text-align:center; font-size:9px; }
.commit-card { margin-bottom:8px; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
@@ -246,7 +261,7 @@
.modal-body { padding:15px 16px 17px; }
.modal-note { padding:9px 10px; border-left:3px solid var(--amber); color:#714317; background:var(--amber-soft); font-size:10px; }
.context-block { margin:12px 0 0; padding:11px; overflow:auto; border:1px solid var(--line); border-radius:8px; color:#334155; background:#f8fafc; white-space:pre-wrap; font:10px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; }
- .session-view { position:fixed; inset:0 var(--drawer-inset) 0 0; z-index:20; overflow:auto; color:#20242b; background:#fff; }
+ .session-view { position:fixed; inset:0 var(--drawer-inset) 0 0; z-index:20; overflow:auto; overscroll-behavior:contain; color:#20242b; background:#fff; }
.session-view[hidden] { display:none; }
.session-nav { min-height:46px; position:sticky; top:0; z-index:2; padding:8px 18px; display:flex; align-items:center; justify-content:space-between; gap:16px; border-bottom:1px solid #e4e7eb; background:rgba(255,255,255,.97); backdrop-filter:blur(10px); }
.session-crumbs { min-width:0; display:flex; align-items:center; gap:8px; color:#747b85; font-size:11px; }
@@ -257,6 +272,15 @@
.session-titlebar h2 { margin:0; font-size:23px; letter-spacing:-.025em; }
.session-meta { margin-top:7px; display:flex; flex-wrap:wrap; gap:6px 10px; align-items:center; color:#747b85; font-size:10px; }
.session-platform { border-radius:6px; padding:3px 6px; color:#9a4f0e; background:#fff0d9; font:700 9px ui-monospace,SFMono-Regular,Menlo,monospace; text-transform:uppercase; }
+ .session-axis-panel { position:sticky; top:46px; z-index:3; margin:0 0 18px; border:1px solid #dfe3e8; border-radius:10px; background:rgba(255,255,255,.98); box-shadow:0 6px 18px rgba(15,23,42,.06); backdrop-filter:blur(8px); }
+ .session-axis-panel > summary { padding:8px 12px; display:flex; align-items:baseline; justify-content:space-between; gap:10px; list-style:none; cursor:pointer; color:#4a5565; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; }
+ .session-axis-panel > summary::-webkit-details-marker { display:none; }
+ .session-axis-panel > summary small { color:#8a929d; font-weight:600; text-transform:none; letter-spacing:0; }
+ .session-axis-panel > summary::after { content:"⌄"; margin-left:6px; color:#7b8490; transition:transform .12s ease; }
+ .session-axis-panel[open] > summary::after { transform:rotate(180deg); }
+ .session-axis { padding:0 12px 8px; }
+ .session-axis .chart-inspector { display:none; }
+ .session-axis .chart-card { max-height:320px; overflow-y:auto; }
.session-layout { display:grid; grid-template-columns:minmax(0,1fr) 250px; gap:24px; align-items:start; }
.session-timeline { position:relative; display:grid; gap:12px; padding-left:28px; }
.session-timeline::before { content:""; position:absolute; left:8px; top:6px; bottom:6px; width:1px; background:#dde2e8; }
@@ -326,6 +350,108 @@
.session-unplaced { padding-bottom:14px; }
.session-file-list { display:grid; gap:5px; padding:9px 11px; }
.session-file-list button { padding:5px 7px; overflow:hidden; border:1px solid #e3e8ee; border-radius:6px; color:#376fcb; background:#fbfcfe; text-align:left; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; font:9px ui-monospace,SFMono-Regular,Menlo,monospace; }
+ .session-mode-tabs { width:max-content; margin:0 0 18px; padding:3px; display:flex; gap:3px; border:1px solid #dfe3e8; border-radius:9px; background:#f5f7fa; }
+ .session-mode-tabs button { min-width:72px; min-height:30px; border:0; border-radius:6px; color:#687383; background:transparent; cursor:pointer; font-size:10px; font-weight:800; }
+ .session-mode-tabs button[aria-selected="true"] { color:#fff; background:#263244; box-shadow:0 3px 8px rgba(15,23,42,.16); }
+ .session-mode-tabs button:focus-visible, .replay-index-tabs button:focus-visible, .replay-event-list button:focus-visible, .replay-file-list button:focus-visible, .replay-controls button:focus-visible, .replay-stage-files button:focus-visible, .replay-explain:focus-visible, .replay-rail-mark:focus-visible { outline:2px solid #376fcb; outline-offset:2px; }
+ .session-mode-panel[hidden] { display:none; }
+ .replay-shell { min-width:0; }
+ .replay-boundary { margin-bottom:14px; padding:10px 12px; display:flex; align-items:baseline; gap:10px 16px; border:1px solid #dbe6f5; border-left:3px solid #376fcb; border-radius:8px; color:#526072; background:#f7faff; font-size:10px; line-height:1.5; }
+ .replay-boundary strong { flex:none; color:#244f86; }
+ /* The index column tracks the real layout width instead of the viewport, so a
+ narrow window or an open Drawer narrows the column rather than stacking it
+ under the stage where the sticky transport would cover it. */
+ .replay-layout { display:grid; grid-template-columns:minmax(0,1fr) clamp(220px,26%,320px); align-items:start; gap:16px; }
+ .replay-stage { min-width:0; display:grid; align-content:stretch; }
+ .replay-event-card { min-height:280px; overflow:hidden; display:flex; flex-direction:column; border:1px solid #dfe3e8; border-top:3px solid #7a8797; border-radius:12px; background:#fff; box-shadow:0 10px 28px rgba(15,23,42,.07); }
+ .replay-event-card.prompt { border-top-color:#4f73b6; }
+ .replay-event-card.intermediate { border-top-color:#b27627; }
+ .replay-event-card.response { border-top-color:#258675; }
+ .replay-event-card.tool-call { border-top-color:#7658b5; }
+ .replay-event-card.commit { border-top-color:#6c9f25; }
+ .replay-event-card > header { min-height:70px; padding:15px 17px 12px; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; border-bottom:1px solid #edf0f3; }
+ .replay-event-card > header small { color:#687383; font-size:9px; font-weight:800; text-transform:uppercase; letter-spacing:.08em; }
+ .replay-event-card > header h3 { margin:4px 0 0; color:#252a31; font-size:17px; letter-spacing:-.015em; overflow-wrap:anywhere; }
+ .replay-event-badges { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:5px; }
+ .replay-status, .replay-availability, .replay-excerpt { border-radius:5px; padding:3px 6px; font-size:8px; font-weight:800; text-transform:uppercase; letter-spacing:.05em; }
+ .replay-status.failed { color:#8f2f2f; background:var(--red-soft); }
+ .replay-availability { color:#714317; background:var(--amber-soft); }
+ .replay-excerpt { color:#5a6675; background:#eef1f4; }
+ .replay-event-meta { min-height:34px; padding:8px 17px; display:flex; flex-wrap:wrap; align-items:center; gap:7px 12px; color:#7a8492; background:#fbfcfd; font-size:9px; }
+ .replay-event-meta code { color:#596579; font:9px ui-monospace,SFMono-Regular,Menlo,monospace; }
+ .replay-event-body { flex:1; padding:18px 17px; overflow:auto; color:#344155; font-size:12px; line-height:1.65; }
+ .replay-event-body p { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
+ .replay-stage-files { padding:0 17px 16px; display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
+ .replay-stage-files strong { margin-right:2px; color:#687383; font-size:9px; text-transform:uppercase; letter-spacing:.06em; }
+ .replay-stage-files button { max-width:100%; padding:4px 7px; overflow:hidden; border:1px solid #dbe6f5; border-radius:6px; color:#376fcb; background:#f7faff; cursor:pointer; text-overflow:ellipsis; white-space:nowrap; }
+ .replay-stage-files code { font:9px ui-monospace,SFMono-Regular,Menlo,monospace; }
+ .replay-event-card > footer { min-height:48px; padding:9px 17px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-top:1px solid #edf0f3; color:#7a8492; background:#fbfcfd; font-size:9px; }
+ .replay-explain { border:1px solid #cad6e6; border-radius:7px; padding:6px 9px; color:#244f86; background:#f5f9ff; cursor:pointer; font-size:9px; font-weight:800; }
+ /* The index keeps its own readable height instead of stretching to match the
+ stage, so a short evidence body cannot shrink the event list. */
+ .replay-index { min-width:0; height:430px; overflow:hidden; display:flex; flex-direction:column; border:1px solid #dfe3e8; border-radius:10px; background:#fff; }
+ /* Underline tabs, unlike the pill mode tabs, so panel switching does not read
+ as a second Trace/Replay control. */
+ .replay-index-tabs { padding:0 6px; display:grid; grid-template-columns:1fr 1fr; gap:0; border-bottom:1px solid #e6e9ed; background:#fff; }
+ .replay-index-tabs button { min-height:34px; border:0; border-bottom:2px solid transparent; border-radius:0; color:#7a8492; background:transparent; cursor:pointer; font-size:10px; font-weight:700; }
+ .replay-index-tabs button span { margin-left:4px; color:#929aa5; font-size:8px; }
+ .replay-index-tabs button[aria-selected="true"] { color:#244f86; border-bottom-color:#376fcb; font-weight:800; }
+ .replay-index-body { min-height:0; flex:1; overflow:auto; }
+ .replay-event-list, .replay-file-list { display:grid; }
+ .replay-event-list button, .replay-file-list button { width:100%; min-width:0; border:0; border-bottom:1px solid #edf0f3; color:#3f4752; background:#fff; text-align:left; cursor:pointer; }
+ .replay-event-list button { min-height:50px; padding:7px 9px; display:grid; grid-template-columns:22px minmax(0,1fr) auto; gap:8px; align-items:center; }
+ .replay-event-list button:hover, .replay-file-list button:hover { background:#f7faff; }
+ .replay-event-list button.replay-current { box-shadow:inset 4px 0 0 #244f86; background:#e2ecfb; }
+ .replay-event-list button.replay-current .replay-event-copy strong { color:#1d3f6d; font-weight:800; }
+ .replay-event-list button.replay-current .replay-event-kind { color:#3a5f92; font-weight:700; }
+ .replay-event-order { width:20px; height:20px; display:grid; place-items:center; border-radius:50%; color:#687383; background:#edf0f3; font:8px ui-monospace,SFMono-Regular,Menlo,monospace; }
+ .replay-current .replay-event-order { color:#fff; background:#244f86; }
+ .replay-event-copy { min-width:0; display:grid; gap:3px; }
+ .replay-event-copy strong, .replay-event-copy small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ .replay-event-copy strong { font-size:9px; }
+ .replay-event-copy small { color:#858e9a; font-size:8px; font-weight:500; }
+ .replay-event-kind { color:#858e9a; font-size:8px; text-transform:capitalize; }
+ .replay-file-list button { min-height:48px; padding:8px 10px; display:grid; gap:4px; }
+ .replay-file-list code { overflow:hidden; color:#376fcb; text-overflow:ellipsis; white-space:nowrap; font:9px ui-monospace,SFMono-Regular,Menlo,monospace; }
+ .replay-file-list span { color:#858e9a; font-size:8px; }
+ .replay-transport { position:sticky; bottom:0; z-index:2; margin-top:16px; padding:12px 14px; border:1px solid #d9dfe6; border-radius:11px; background:rgba(255,255,255,.98); box-shadow:0 -8px 24px rgba(15,23,42,.08); backdrop-filter:blur(10px); }
+ .replay-rail-head { margin-bottom:8px; display:flex; align-items:baseline; justify-content:space-between; gap:12px; }
+ .replay-rail-head strong { color:#4a5565; font-size:9px; text-transform:uppercase; letter-spacing:.06em; }
+ .replay-rail-head span { color:#7a8492; font-size:8px; }
+ .replay-rail { min-height:30px; }
+ .replay-rail-track { height:12px; position:relative; overflow:visible; border-radius:6px; background:#edf0f3; }
+ .replay-rail-fill { position:absolute; inset:0; border-radius:inherit; background:linear-gradient(90deg,#eef3fa,#f1f4f8); }
+ .replay-rail-mark { min-width:2px; height:12px; position:absolute; top:0; z-index:1; margin:0; padding:0; border:0; border-radius:1px; background:#7a8797; cursor:pointer; }
+ .replay-rail-mark.prompt { background:#4f73b6; }
+ .replay-rail-mark.intermediate { background:#b27627; }
+ .replay-rail-mark.response { background:#258675; }
+ .replay-rail-mark.tool-call { background:#7658b5; }
+ .replay-rail-mark.commit { background:#6c9f25; }
+ .replay-rail-mark.failed { box-shadow:0 0 0 2px #b94a48; }
+ .replay-rail-mark.replay-current { z-index:2; box-shadow:0 0 0 2px #fff,0 0 0 4px #244f86; }
+ .replay-rail-cursor { width:2px; height:20px; position:absolute; z-index:3; top:-4px; background:#20242b; transform:translateX(-1px); pointer-events:none; }
+ .replay-rail-labels { margin-top:5px; display:flex; justify-content:space-between; color:#929aa5; font:8px ui-monospace,SFMono-Regular,Menlo,monospace; }
+ /* The rail encodes event type as colour, so the colours need a named legend. */
+ .replay-rail-legend { margin-top:7px; display:flex; flex-wrap:wrap; gap:4px 12px; color:#7a8492; font-size:8px; }
+ .replay-rail-legend span { display:inline-flex; align-items:center; gap:4px; }
+ .replay-rail-legend span::before { content:""; width:8px; height:8px; border-radius:2px; background:#7a8797; }
+ .replay-rail-legend .prompt::before { background:#4f73b6; }
+ .replay-rail-legend .intermediate::before { background:#b27627; }
+ .replay-rail-legend .response::before { background:#258675; }
+ .replay-rail-legend .tool-call::before { background:#7658b5; }
+ .replay-rail-legend .commit::before { background:#6c9f25; }
+ .replay-rail-legend .failed::before { border:2px solid #b94a48; background:#fff; }
+ .replay-sequence-rail { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:8px; color:#858e9a; font:8px ui-monospace,SFMono-Regular,Menlo,monospace; }
+ .replay-sequence-rail div { height:8px; border-radius:4px; background:repeating-linear-gradient(90deg,#aeb8c5 0,#aeb8c5 3px,#edf0f3 3px,#edf0f3 7px); }
+ .replay-controls { margin-top:10px; display:flex; align-items:center; gap:7px; }
+ .replay-controls > button, .replay-speeds button { min-height:30px; border:1px solid #d7dce2; border-radius:7px; color:#455163; background:#fff; cursor:pointer; font-size:9px; font-weight:800; }
+ .replay-controls > button { padding:5px 9px; }
+ .replay-controls .replay-play { color:#fff; border-color:#2f5fa8; background:#376fcb; }
+ .replay-controls kbd { margin-left:4px; border:1px solid currentColor; border-radius:3px; padding:1px 3px; opacity:.62; font:7px ui-monospace,SFMono-Regular,Menlo,monospace; }
+ .replay-position { min-width:0; margin-left:4px; overflow:hidden; color:#687383; text-overflow:ellipsis; white-space:nowrap; font-size:8px; }
+ .replay-speeds { margin-left:auto; display:flex; gap:3px; }
+ .replay-speeds button { min-width:30px; padding:4px 6px; }
+ .replay-speeds button[aria-pressed="true"] { color:#244f86; border-color:#9eb8dc; background:#eef5ff; }
@media (max-width:1080px) { .window-badge { display:none; } }
/* Below three-lane width the lanes stack instead of forcing a 730px page to
scroll sideways, which is what starved the activity chart of room. */
@@ -353,4 +479,5 @@
body.drawer-open .workbench.delivery-collapsed .delivery-title-copy { display:flex; }
body.drawer-open .workbench.delivery-collapsed .delivery-toggle { width:auto; min-height:0; margin:0; padding:4px 6px; writing-mode:horizontal-tb; }
}
- @media (max-width:760px) { body.drawer-open { --drawer-inset:0px; } .app { display:block; margin-right:0; } .scope-picker { position:relative; width:100%; height:auto; max-height:340px; border-right:0; border-bottom:1px solid var(--line); } .picker-footer { display:none; } .workspace-header { position:relative; padding:5px 9px; } .workspace-breadcrumb span:first-child, .workspace-breadcrumb i:first-of-type, .window-badge { display:none; } .workspace-header-meta { gap:0; } .metric { padding:0 4px; } .metric .metric-label { display:none; } .metric .metric-short { display:inline; } .workspace-scroll { padding:8px 7px 36px; } .workbench { overflow-x:auto; } .evidence-drawer { position:fixed; inset:auto 0 0; top:auto; width:100%; height:min(72vh,620px); z-index:41; border-top:1px solid var(--line); border-left:0; border-radius:16px 16px 0 0; box-shadow:0 -18px 50px rgba(15,23,42,.18); } .evidence-drawer-head { min-height:62px; padding:11px 14px; } .session-shell { padding:16px 14px 38px; } .session-layout { grid-template-columns:1fr; } .session-sidebar { position:static; grid-row:1; } }
+ @media (max-width:760px) { body.drawer-open { --drawer-inset:0px; } .app { display:block; margin-right:0; } .scope-picker { position:relative; width:100%; height:auto; max-height:340px; border-right:0; border-bottom:1px solid var(--line); } .picker-footer { display:none; } .workspace-header { position:relative; padding:5px 9px; } .workspace-breadcrumb span:first-child, .workspace-breadcrumb i:first-of-type, .window-badge { display:none; } .workspace-header-meta { gap:0; } .metric { padding:0 4px; } .metric .metric-label { display:none; } .metric .metric-short { display:inline; } .workspace-scroll { padding:8px 7px 36px; } .workbench { overflow-x:auto; } .evidence-drawer { position:fixed; inset:auto 0 0; top:auto; width:100%; height:min(72vh,620px); z-index:41; border-top:1px solid var(--line); border-left:0; border-radius:16px 16px 0 0; box-shadow:0 -18px 50px rgba(15,23,42,.18); } .evidence-drawer-head { min-height:62px; padding:11px 14px; } .session-shell { padding:16px 14px 38px; } .session-titlebar, .replay-boundary { align-items:flex-start; flex-direction:column; } .session-layout { grid-template-columns:1fr; } .session-sidebar { position:static; grid-row:1; } .replay-layout { grid-template-columns:1fr; } .replay-index { height:min(46vh,320px); } .replay-event-card { min-height:260px; } .replay-controls { flex-wrap:wrap; } .replay-position { width:100%; order:4; } .replay-speeds { margin-left:0; } }
+ @media (prefers-reduced-motion:reduce) { .session-view *, .session-view *::before, .session-view *::after { scroll-behavior:auto !important; transition-duration:.001ms !important; animation-duration:.001ms !important; animation-iteration-count:1 !important; } }
diff --git a/scripts/harness-inspector/ui/workbench.html b/scripts/harness-inspector/ui/workbench.html
index e1b10c6..84c3576 100644
--- a/scripts/harness-inspector/ui/workbench.html
+++ b/scripts/harness-inspector/ui/workbench.html
@@ -3,14 +3,15 @@
+{{BH_ROBOTS_META}}
{{BH_PAGE_TITLE}}
-
+
- Better Harness Harness Inspector {{BH_WORKSPACE_NAME}} · real local evidence
Hide Show tree
+ Better Harness Harness Inspector {{BH_WORKSPACE_NAME}} · {{BH_CONTEXT_LABEL}}
Hide Show tree
Delivery Tree Date
Capability tree {{BH_FEATURE_NODE_COUNT}} nodes
{{BH_FEATURE_PICKER}}
UTC activity {{BH_DAY_COUNT}} days
{{BH_DATE_PICKER}}
diff --git a/scripts/harness-inspector/ui/workbench.js b/scripts/harness-inspector/ui/workbench.js
index 5fde614..e46c312 100644
--- a/scripts/harness-inspector/ui/workbench.js
+++ b/scripts/harness-inspector/ui/workbench.js
@@ -37,11 +37,18 @@
// One focused object drives explanation. A future CompareSet must remain a
// separate state owner and full-workspace mode rather than widening Drawer.
selection:null,
+ evidenceDrawerSuppressed:false,
sessionTrigger:null,
sessionItem:null,
sessionOpen:false,
sessionPushed:false,
syncingHistory:false,
+ sessionMode:initialParams.get('session-mode') === 'replay' ? 'replay' : 'trace',
+ replayEventId:initialParams.get('replay-event'),
+ replayIndexTab:'events',
+ replayPlaying:false,
+ replaySpeed:2,
+ replayTimer:null,
// Chart zoom is a per-session view concern, never part of the evidence
// model, so it stays out of the selection and out of the deep link.
zoom:new Map(),
@@ -248,7 +255,7 @@
: directCommits.length
? '' + directCommits.length + ' commit' + (directCommits.length === 1 ? ' was' : 's were') + ' created in this session, but no Edit/Write path was observed before the commit. The files may have entered the session as existing workspace changes.
'
: '';
- return 'Checkpoint activity ' + pathSummary + '
' + activity.totalCalls + ' calls · ' + activity.failedCalls + ' failed' + escape(spanCopy) + '
' + bars + '
' + commitBridge + 'Expand ' + activity.totalCalls + ' normalized actions focus view
';
+ return 'Checkpoint activity ' + pathSummary + '
' + activity.totalCalls + ' calls · ' + activity.failedCalls + ' failed' + escape(spanCopy) + '
' + bars + '
' + commitBridge + 'Expand ' + activity.totalCalls + ' normalized actions focus view Open detail Open session
';
}
function fileTree(commit, link) {
@@ -291,7 +298,7 @@
const title = itemTitle(item);
const kicker = item.story?.featureTitle ?? (item.date ? (session ? 'Session · ' + formatClock(session.firstSeen) : 'Unlinked commits') : 'Unmapped');
const sessionMeta = session ? session.locator + ' · ' + formatDuration(session.durationMs) : 'No linked session';
- const sessionAction = session ? 'Open session ' : '';
+ const sessionAction = session ? 'Open session ' : '';
const headerSelection = session
? { type:'session', sessionId:session.sessionId }
: item.story ? { type:'story', id:item.story.id } : null;
@@ -384,7 +391,14 @@
const labelWidth = 132;
const rowHeight = 26;
- const topPad = 8;
+ // The ribbon fills the whole observed span with no holes, so the time
+ // between calls stays visible instead of reading as blank canvas. It only
+ // makes sense against real clock time; on the call-order fallback the
+ // spacing would be an artefact of ordinal position, so it is omitted.
+ const showRibbon = domain.timeBasis;
+ const ribbonTop = 8;
+ const ribbonHeight = 22;
+ const topPad = showRibbon ? ribbonTop + ribbonHeight + 14 : 8;
const width = Math.max(300,Math.floor(availableWidth || 640));
const plotLeft = labelWidth + 12;
const plotRight = width - 14;
@@ -447,6 +461,42 @@
}
}
+ // The base band is the span itself; every observed call is painted over it,
+ // so whatever stays bare is time this session did not spend inside a tool.
+ // That residue is labelled as unattributed, never as a model turn: the host
+ // timestamps calls, not the model's own work, and the projection drops the
+ // note stamps that would be needed to claim otherwise.
+ let ribbonMarkup = '';
+ if (showRibbon) {
+ const ribbonMid = ribbonTop + ribbonHeight / 2;
+ const blocks = inDomain.map(call => {
+ const position = callPosition(call,true);
+ const timed = call.durationStatus === 'observed' && Number.isFinite(call.durationMs);
+ const left = xFor(position);
+ const blockWidth = timed
+ ? Math.max(1.5,Math.min(plotLeft + plotWidth - left,(call.durationMs / domainWidth) * plotWidth))
+ : 1.5;
+ const failed = call.status === 'failed';
+ const stamp = formatStamp(call.startedAt);
+ const label = call.id + ' · ' + (call.actionLabel ?? call.toolName) + ' · ' + call.toolName
+ + (stamp ? ' · ' + stamp + ' UTC' : '')
+ + ' · ' + (timed ? formatLatency(call.durationMs) : 'timing unavailable')
+ + (failed ? ' · failed' : '');
+ return '' + escape(label) + ' ';
+ }).join('');
+ const toolMs = inDomain.reduce((sum,call) => sum + (call.durationStatus === 'observed' && Number.isFinite(call.durationMs) ? call.durationMs : 0),0);
+ const baseLabel = 'Full observed span. Coloured blocks are observed tool calls; bare band is time not attributed to any tool call — model work or waiting the host did not separately observe.';
+ ribbonMarkup = ''
+ + '' + escape(baseLabel) + ' '
+ + blocks
+ + '' + escape('Observed tool time ' + formatSpan(toolMs) + ' of ' + formatSpan(domain.max - domain.min) + ' in view') + ' All activity '
+ + ' ';
+ }
+
let marksMarkup = '';
if (detailMode) {
marksMarkup = inDomain.map(call => {
@@ -516,7 +566,7 @@
+ ''
+ '' + escape(aria) + ' ' + escape(basisNote + ' Shaded columns are windows with no observed call. Red marks are failed calls. Green diamonds mark directly linked commit times. Drag across the plot to zoom.') + ' '
+ ' '
- + laneMarkup + gapMarkup + tickMarkup + marksMarkup + commitMarkup
+ + laneMarkup + gapMarkup + tickMarkup + ribbonMarkup + marksMarkup + commitMarkup
+ ' '
+ ' '
+ '' + escape(domain.timeBasis ? 'UTC' : 'Call') + ' '
@@ -525,7 +575,8 @@
+ '' + inDomain.length + ' of ' + activity.totalCalls + ' calls in view '
+ (longestGap ? 'longest idle ' + escape(formatSpan(longestGap.gap)) + ' at ' + escape(formatShortClock(longestGap.at)) + ' UTC ' : '')
+ (untimed ? '' + untimed + ' without observed timing ' : '')
- + ' failed idle window '
+ + (showRibbon ? ' between calls (unattributed — model work or wait) ' : '')
+ + ' failed idle window (no observed call, not user wait) '
+ (domain.timeBasis ? ' directly linked commit ' : '')
+ '' + escape(basisNote) + ' ';
}
@@ -616,7 +667,7 @@
const first = run.calls[0];
const last = run.calls.at(-1);
const total = run.calls.reduce((sum,call) => sum + (call.durationStatus === 'observed' ? call.durationMs : 0),0);
- return '' + escape(first.id) + '–' + escape(last.id) + ' '
+ return '' + escape(first.id) + '–' + escape(last.id) + ' '
+ '' + escape(first.actionLabel) + ' ×' + run.calls.length + ' ' + escape(first.toolName) + ' '
+ '' + escape(formatStamp(first.startedAt) ?? '—') + '' + escape(total ? formatLatency(total) + ' total' : '—') + ' '
+ (first.detail ? '' + escape(first.detail) + '' : '')
@@ -649,13 +700,17 @@
});
const turns = session.dialogue?.turns?.length ? session.dialogue.turns : fallbackTurns;
const placement = placeCommits(commits,turns,session);
+ // Short sessions open every Turn's tool calls by default so the trace reads
+ // top to bottom without a click per Turn; long sessions stay collapsed and
+ // lean on the linked timeline strip to jump into a dense stretch instead.
+ const denseTurns = turns.length > 12;
const turnEvents = turns.map(turn => {
const anchor = turn.anchorId ?? ('turn-' + turn.index);
const prompt = 'User prompt ' + turn.index + ' ' + escape(turn.prompt?.timestamp ? formatClock(turn.prompt.timestamp) : '') + ' ' + escape(turn.prompt?.text ?? 'Prompt unavailable after privacy filtering') + '
';
const notes = turn.steps.filter(step => step.kind === 'note').map((step,noteIndex) => 'Intermediate response ' + (noteIndex + 1) + '
' + escape(step.text) + '
').join('');
const calls = turn.steps.filter(step => step.kind === 'tool').map(step => callsById.get(step.callId)).filter(Boolean);
- const toolEvent = calls.length ? '' + calls.length + ' tool call' + (calls.length === 1 ? '' : 's') + ' ' + turn.toolCallCount + ' observed in turn ' + toolListMarkup(session,calls) + ' ' : '';
+ const toolEvent = calls.length ? '' + calls.length + ' tool call' + (calls.length === 1 ? '' : 's') + ' ' + turn.toolCallCount + ' observed in turn ' + toolListMarkup(session,calls) + ' ' : '';
const response = 'Assistant response ' + (turn.response ? 'retained' : 'unavailable') + ' ' + escape(turn.response ?? 'Response body was unavailable or removed by privacy filtering.') + '
';
const clock = Number.isFinite(turn.startMs) ? formatShortClock(turn.startMs) + (Number.isFinite(turn.endMs) ? '–' + formatShortClock(turn.endMs) : '') + ' UTC · ' : '';
const summary = clock + turn.messageCount + ' intermediate events · ' + turn.toolCallCount + ' tool calls' + (Number.isFinite(turn.durationMs) ? ' · ' + formatDuration(turn.durationMs) : '');
@@ -664,13 +719,23 @@
}).join('');
const placedCallIds = new Set(turns.flatMap(turn => turn.steps.filter(step => step.kind === 'tool').map(step => step.callId)));
- const unplacedCalls = session.toolActivity.calls.filter(call => !placedCallIds.has(call.id));
+ // Order the page-tail bucket by observed start time so a session with no
+ // dialogue Turns still reads as a trace. This reuses the same startedAt the
+ // timeline strip plots; it never infers a time the host did not observe.
+ const unplacedCalls = session.toolActivity.calls
+ .filter(call => !placedCallIds.has(call.id))
+ .slice()
+ .sort((left,right) => {
+ const leftTime = Number.isFinite(left.startedAt) ? left.startedAt : Infinity;
+ const rightTime = Number.isFinite(right.startedAt) ? right.startedAt : Infinity;
+ return leftTime - rightTime || String(left.id).localeCompare(String(right.id));
+ });
const unplacedFiles = unplacedCalls.length || turns.length === 0 ? session.toolActivity.files : [];
const unplacedFileEvent = unplacedFiles.length
? '' + unplacedFiles.length + ' attributed file path' + (unplacedFiles.length === 1 ? '' : 's') + ' observed tool evidence ' + unplacedFiles.map(file => '' + escape(file.path) + ' ').join('') + '
'
: '';
const unplacedToolEvent = unplacedCalls.length
- ? '' + unplacedCalls.length + ' unplaced tool call' + (unplacedCalls.length === 1 ? '' : 's') + ' retained without a dialogue Turn ' + toolListMarkup(session,unplacedCalls) + ' '
+ ? '' + unplacedCalls.length + ' tool call' + (unplacedCalls.length === 1 ? '' : 's') + ' not tied to a Turn ordered by observed time ' + toolListMarkup(session,unplacedCalls) + ' '
: '';
const unplacedMarkup = unplacedToolEvent || unplacedFileEvent
? 'Unplaced evidence observed evidence retained outside dialogue ' + unplacedToolEvent + unplacedFileEvent + ' '
@@ -689,7 +754,241 @@
+ (unplacedMarkup ? 'Unplaced evidence ' : '')
+ (outsideMarkup ? 'Commits outside turn windows ' : '');
const timeline = turnEvents + unplacedMarkup + outsideMarkup || 'No retained dialogue or observed evidence exists for this session.
';
- return { title, html:'' };
+ const tracePanel = ''
+ + (session.toolActivity.totalCalls ? 'Activity timeline click a bar to jump to those calls · drag to zoom
' : '')
+ + '' + timeline + '
';
+ const replayPanel = ''
+ + 'Read-only evidence playback Replay advances through retained evidence. It never reruns tools, resumes the host session, or invents missing time.
'
+ + ''
+ + 'Session timeline
' + replayLegendMarkup(session.replay) + '
Previous event J Play Space Next event L ' + [1,2,4,8].map(speed => '' + speed + 'x ').join('') + '
';
+ const modeTabs = 'Trace Replay
';
+ return { title, html:'' + modeTabs + tracePanel + replayPanel + '
' };
+ }
+
+ function replayModel() {
+ return state.sessionItem?.session?.replay ?? null;
+ }
+
+ function replayCurrentEvent() {
+ const replay = replayModel();
+ return replay?.events.find(event => event.id === state.replayEventId) ?? replay?.events[0] ?? null;
+ }
+
+ function replayTiming(event) {
+ if (!event) return 'No event selected';
+ if (event.timeBasis === 'observed') return formatStamp(event.atMs) + ' UTC · observed time';
+ if (event.timeBasis === 'turn-boundary') return formatStamp(event.atMs) + ' UTC · Turn boundary, not exact event time';
+ return 'Sequence only · timestamp unavailable';
+ }
+
+ /* The rail paints one colour per event type, so name only the types this
+ session actually retained. */
+ function replayLegendMarkup(replay) {
+ const present = new Set(replay.events.map(event => event.type));
+ const entries = [['prompt','Prompt'],['intermediate','Intermediate'],['response','Response'],['tool-call','Tool call'],['commit','Commit']]
+ .filter(([type]) => present.has(type));
+ if (replay.events.some(event => event.status === 'failed')) entries.push(['failed','Failed']);
+ return entries.map(([type,label]) => '' + label + ' ').join('');
+ }
+
+ function replayStageMarkup(event) {
+ if (!event) return 'No retained replay event exists for this session.
';
+ const files = event.files?.length
+ ? 'Files ' + event.files.map(file => '' + escape(file) + ' ').join('') + '
'
+ : '';
+ const unavailable = event.availability === 'unavailable' ? 'Content unavailable ' : '';
+ const excerpt = event.bodyExcerpt ? 'Excerpt ' : '';
+ const status = event.status === 'failed' ? 'Failed ' : '';
+ const explain = event.selection ? 'Explain evidence ' : '';
+ return ''
+ + '' + escape(replayTiming(event)) + ' ' + (event.meta ? '' + escape(event.meta) + '' : '') + (Number.isFinite(event.durationMs) ? '' + escape(formatLatency(event.durationMs)) + ' ' : '') + '
'
+ + '' + escape(event.body) + '
' + files
+ + '' + (event.turnIndex ? 'Turn ' + event.turnIndex : 'Outside any observed Turn') + ' ' + explain + ' ';
+ }
+
+ function renderReplayIndex() {
+ const replay = replayModel();
+ const body = document.querySelector('[data-replay-index-body]');
+ if (!replay || !body) return;
+ document.querySelectorAll('[data-replay-index-tab]').forEach(tab => {
+ const selected = tab.dataset.replayIndexTab === state.replayIndexTab;
+ tab.setAttribute('aria-selected',String(selected));
+ tab.tabIndex = selected ? 0 : -1;
+ });
+ body.setAttribute('aria-labelledby','replay-index-tab-' + state.replayIndexTab);
+ if (state.replayIndexTab === 'files') {
+ body.innerHTML = replay.files.length
+ ? '' + replay.files.map(file => '' + escape(file.path) + '' + file.eventIds.length + ' events ').join('') + '
'
+ : 'No repository-relative file was retained for Replay.
';
+ return;
+ }
+ body.innerHTML = '' + replay.events.map(event => { const current = event.id === state.replayEventId; return '' + event.order + ' ' + escape(event.title) + ' ' + escape(replayTiming(event)) + ' ' + escape(event.type.replace('-', ' ')) + ' '; }).join('') + '
';
+ keepReplayIndexRowVisible();
+ }
+
+ function renderReplayRail() {
+ const replay = replayModel();
+ const rail = document.querySelector('[data-replay-rail]');
+ const range = document.querySelector('[data-replay-range]');
+ if (!replay || !rail || !range) return;
+ const timed = replay.events.filter(event => Number.isFinite(event.atMs));
+ const start = replay.startMs;
+ const end = replay.endMs;
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || timed.length === 0) {
+ range.textContent = 'Sequence axis · no observed event timing';
+ rail.innerHTML = '1
' + replay.eventCount + ' ';
+ return;
+ }
+ range.textContent = formatShortClock(start) + ' → ' + formatShortClock(end) + ' UTC · ' + formatSpan(end - start);
+ const span = Math.max(1,end - start);
+ const bins = new Map();
+ const binCount = 120;
+ for (const event of timed) {
+ const index = Math.min(binCount - 1,Math.max(0,Math.floor(((event.atMs - start) / span) * binCount)));
+ const bin = bins.get(index) ?? { index, events:[], failed:false };
+ bin.events.push(event);
+ if (event.status === 'failed') bin.failed = true;
+ bins.set(index,bin);
+ }
+ const marks = [...bins.values()].map(bin => {
+ const first = bin.events[0];
+ const label = bin.events.length + ' event' + (bin.events.length === 1 ? '' : 's') + ' near ' + formatStamp(first.atMs) + ' UTC';
+ return ' ';
+ }).join('');
+ rail.innerHTML = ' ' + marks + '
' + escape(formatShortClock(start)) + ' ' + escape(formatShortClock(end)) + '
';
+ rail.dataset.startMs = String(start);
+ rail.dataset.endMs = String(end);
+ }
+
+ /* Only the index's own scroller moves, so following the current event can never
+ scroll Session View or the page underneath it. */
+ function keepReplayIndexRowVisible() {
+ const body = document.querySelector('[data-replay-index-body]');
+ const row = body?.querySelector('.replay-event-list .replay-current');
+ if (!body || !row) return;
+ const view = body.getBoundingClientRect();
+ const target = row.getBoundingClientRect();
+ if (target.top >= view.top && target.bottom <= view.bottom) return;
+ body.scrollTop += target.top - view.top - Math.max(0,(view.height - target.height) / 2);
+ }
+
+ function updateReplayPresentation() {
+ const replay = replayModel();
+ const event = replayCurrentEvent();
+ if (!replay) return;
+ const stage = document.querySelector('[data-replay-stage]');
+ if (stage) stage.innerHTML = replayStageMarkup(event);
+ document.querySelectorAll('[data-replay-event]').forEach(row => {
+ const current = row.dataset.replayEvent === event?.id;
+ row.classList.toggle('replay-current',current);
+ if (row.closest('.replay-event-list')) row.setAttribute('aria-current',current ? 'step' : 'false');
+ });
+ keepReplayIndexRowVisible();
+ const cursor = document.querySelector('[data-replay-cursor]');
+ if (cursor) {
+ const start = Number(cursor.closest('[data-replay-rail]')?.dataset.startMs);
+ const end = Number(cursor.closest('[data-replay-rail]')?.dataset.endMs);
+ if (Number.isFinite(event?.atMs) && end > start) {
+ cursor.style.left = Math.min(100,Math.max(0,((event.atMs - start) / (end - start)) * 100)) + '%';
+ cursor.hidden = false;
+ } else cursor.hidden = true;
+ }
+ const position = document.querySelector('[data-replay-position]');
+ if (position) position.textContent = 'Event ' + (event ? event.order : 0) + ' / ' + replay.eventCount;
+ const play = document.querySelector('[data-replay-play]');
+ if (play) {
+ play.innerHTML = (state.replayPlaying ? 'Pause' : 'Play') + ' Space ';
+ play.setAttribute('aria-pressed',String(state.replayPlaying));
+ }
+ document.querySelectorAll('[data-replay-speed]').forEach(button => button.setAttribute('aria-pressed',String(Number(button.dataset.replaySpeed) === state.replaySpeed)));
+ }
+
+ function renderReplay() {
+ const replay = replayModel();
+ if (!replay) return;
+ if (!replay.events.some(event => event.id === state.replayEventId)) {
+ const selectedKey = selectionKey(state.selection);
+ state.replayEventId = replay.events.find(event => selectionKey(event.selection) === selectedKey)?.id ?? replay.events[0]?.id ?? null;
+ }
+ renderReplayIndex();
+ renderReplayRail();
+ updateReplayPresentation();
+ }
+
+ function stopReplay() {
+ if (state.replayTimer) clearTimeout(state.replayTimer);
+ state.replayTimer = null;
+ state.replayPlaying = false;
+ updateReplayPresentation();
+ }
+
+ function scheduleReplay() {
+ if (!state.replayPlaying) return;
+ if (state.replayTimer) clearTimeout(state.replayTimer);
+ const reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches === true;
+ const delay = Math.max(reducedMotion ? 220 : 90,900 / state.replaySpeed);
+ state.replayTimer = setTimeout(() => {
+ state.replayTimer = null;
+ const replay = replayModel();
+ const current = replay?.events.findIndex(event => event.id === state.replayEventId) ?? -1;
+ if (!replay || current >= replay.events.length - 1) {
+ stopReplay();
+ return;
+ }
+ setReplayEvent(replay.events[current + 1].id,{ updateHistory:true });
+ scheduleReplay();
+ },delay);
+ }
+
+ function setReplayPlaying(playing) {
+ const replay = replayModel();
+ if (!replay?.events.length) return;
+ if (!playing) {
+ stopReplay();
+ return;
+ }
+ if (replay.events.at(-1)?.id === state.replayEventId) state.replayEventId = replay.events[0].id;
+ state.replayPlaying = true;
+ updateReplayPresentation();
+ updateUrl();
+ scheduleReplay();
+ }
+
+ function setReplayEvent(eventId,{ syncSelection = true, updateHistory = true } = {}) {
+ const replay = replayModel();
+ const event = replay?.events.find(candidate => candidate.id === eventId);
+ if (!event) return;
+ state.replayEventId = event.id;
+ if (syncSelection && event.selection) setSelection(event.selection,{ updateHistory:false, reveal:false });
+ updateReplayPresentation();
+ if (updateHistory) updateUrl();
+ }
+
+ function stepReplay(delta) {
+ const replay = replayModel();
+ if (!replay?.events.length) return;
+ const current = Math.max(0,replay.events.findIndex(event => event.id === state.replayEventId));
+ const next = Math.max(0,Math.min(replay.events.length - 1,current + delta));
+ setReplayEvent(replay.events[next].id);
+ if (state.replayPlaying && next === replay.events.length - 1) stopReplay();
+ }
+
+ function setSessionMode(mode,{ updateHistory = true } = {}) {
+ const next = mode === 'replay' ? 'replay' : 'trace';
+ state.sessionMode = next;
+ if (next !== 'replay') stopReplay();
+ else {
+ state.evidenceDrawerSuppressed = true;
+ renderEvidenceDrawer();
+ }
+ document.querySelectorAll('[data-session-mode]').forEach(tab => {
+ const selected = tab.dataset.sessionMode === next;
+ tab.setAttribute('aria-selected',String(selected));
+ tab.tabIndex = selected ? 0 : -1;
+ });
+ document.querySelectorAll('[data-session-mode-panel]').forEach(panel => { panel.hidden = panel.dataset.sessionModePanel !== next; });
+ if (next === 'replay') renderReplay();
+ if (updateHistory) updateUrl();
}
function applySessionFilters() {
@@ -697,10 +996,30 @@
document.querySelectorAll('[data-session-event="' + CSS.escape(input.dataset.sessionKindFilter) + '"]').forEach(event => event.classList.toggle('session-hidden',!input.checked));
});
document.querySelectorAll('[data-session-tool-filter]').forEach(input => {
- document.querySelectorAll('[data-session-tool-row][data-tool="' + CSS.escape(input.dataset.sessionToolFilter) + '"]').forEach(row => row.classList.toggle('session-hidden',!input.checked));
+ const selector = '[data-tool="' + CSS.escape(input.dataset.sessionToolFilter) + '"]';
+ // A collapsed run stands in for its own tool, so hide the run summary with
+ // the rows it represents; otherwise unchecking a tool leaves the run band
+ // visible and the filter contradicts what is on screen.
+ document.querySelectorAll('.session-tool-row' + selector + ', details.session-tool-run' + selector).forEach(row => row.classList.toggle('session-hidden',!input.checked));
});
const fileFilter = document.querySelector('[data-session-file-filter]');
document.querySelectorAll('.session-tool-file').forEach(file => file.classList.toggle('session-hidden',fileFilter && !fileFilter.checked));
+ // Report how many calls survive the current filters, counting a collapsed
+ // run once per grouped call, so the sidebar total tracks what is shown.
+ const toolsEm = document.querySelector('[data-session-kind-filter="tools"]')?.closest('.session-filter')?.querySelector('em');
+ if (toolsEm) {
+ let visible = 0;
+ // Standalone rows count once; a run's inner rows are represented by the
+ // run's own callCount, so they are excluded here to avoid double counting.
+ // Disclosure state (closed details, "show more" overflow) is deliberately
+ // ignored — the total tracks the filters, not what is expanded.
+ document.querySelectorAll('#session-view .session-tool-row').forEach(row => {
+ if (row.closest('.session-tool-run')) return;
+ if (!row.closest('.session-hidden')) visible += 1;
+ });
+ document.querySelectorAll('#session-view details.session-tool-run').forEach(run => { if (!run.closest('.session-hidden')) visible += Number(run.dataset.callCount) || 0; });
+ toolsEm.textContent = String(visible);
+ }
}
function sessionSelectionTarget(selection,item) {
@@ -753,6 +1072,14 @@
function openSessionView(item,selection = state.selection,trigger = document.activeElement,{ updateHistory = true } = {}) {
const view = sessionViewMarkup(item);
+ const params = new URLSearchParams(location.search);
+ const restoringThisSession = params.get('session') === item.session.sessionId;
+ const changingSession = state.sessionItem?.session?.sessionId !== item.session.sessionId;
+ if (changingSession) {
+ state.replayEventId = restoringThisSession ? params.get('replay-event') : null;
+ state.sessionMode = restoringThisSession && params.get('session-mode') === 'replay' ? 'replay' : 'trace';
+ state.replayIndexTab = 'events';
+ }
state.sessionItem = item;
state.sessionTrigger = trigger;
state.sessionOpen = true;
@@ -761,12 +1088,18 @@
document.getElementById('session-view').hidden = false;
document.body.classList.add('session-open');
document.getElementById('session-view-close').focus();
+ setSessionMode(state.sessionMode,{ updateHistory:false });
if (updateHistory) {
state.sessionPushed = true;
updateUrl({ push:true });
}
renderEvidenceDrawer();
requestAnimationFrame(() => {
+ const axis = document.querySelector('[data-session-mode-panel="trace"] [data-session-axis] [data-activity-chart]');
+ if (axis && !axis.childElementCount) {
+ renderActivityChart(axis);
+ chartObserver?.observe(axis);
+ }
revealSelectionTarget(sessionSelectionTarget(selection,item));
observeTurnsForJump();
applySelectionPresentation();
@@ -774,10 +1107,15 @@
}
function teardownSessionView() {
+ stopReplay();
jumpObserver?.disconnect();
jumpObserver = null;
state.sessionOpen = false;
state.sessionItem = null;
+ state.sessionMode = 'trace';
+ state.replayEventId = null;
+ state.replayIndexTab = 'events';
+ state.evidenceDrawerSuppressed = false;
document.getElementById('session-view').hidden = true;
document.getElementById('session-view-body').innerHTML = '';
document.body.classList.remove('session-open');
@@ -1058,7 +1396,7 @@
function urlForState() {
const url = new URL(location.href);
- ['feature','date','story','session','context-session','turn','call','file','commit','view'].forEach(key => url.searchParams.delete(key));
+ ['feature','date','story','session','context-session','turn','call','file','commit','view','session-mode','replay-event'].forEach(key => url.searchParams.delete(key));
url.searchParams.set('mode',state.mode);
if (state.mode === 'feature' && state.scope) url.searchParams.set('feature',state.scope);
if (state.mode === 'date' && state.scope) url.searchParams.set('date',state.scope);
@@ -1076,6 +1414,10 @@
url.searchParams.set('view','session');
const sessionId = state.sessionItem?.session?.sessionId;
if (sessionId) url.searchParams.set('session',sessionId);
+ if (state.sessionMode === 'replay') {
+ url.searchParams.set('session-mode','replay');
+ if (state.replayEventId) url.searchParams.set('replay-event',state.replayEventId);
+ }
}
return url;
}
@@ -1114,7 +1456,7 @@
function renderEvidenceDrawer() {
const drawer = document.getElementById('evidence-drawer');
const app = document.querySelector('.app');
- if (!state.selection) {
+ if (!state.selection || state.evidenceDrawerSuppressed) {
drawer.hidden = true;
document.body.classList.remove('drawer-open');
if (app.dataset.drawerCollapsedPicker === 'true') setPickerCollapsed(false);
@@ -1142,8 +1484,9 @@
if (!state.sessionOpen && innerWidth > 760 && innerWidth < 1200 && !app.classList.contains('picker-collapsed')) setPickerCollapsed(true,{ automatic:true });
}
- function setSelection(selection,{ updateHistory = true } = {}) {
+ function setSelection(selection,{ updateHistory = true, reveal = true } = {}) {
state.selection = selection;
+ state.evidenceDrawerSuppressed = !reveal;
renderEvidenceDrawer();
applySelectionPresentation();
if (updateHistory) updateUrl();
@@ -1151,6 +1494,7 @@
function clearSelection({ updateHistory = true } = {}) {
state.selection = null;
+ state.evidenceDrawerSuppressed = false;
renderEvidenceDrawer();
applySelectionPresentation();
if (updateHistory) updateUrl();
@@ -1238,12 +1582,9 @@
}
function initializeTree() {
- document.querySelectorAll('[data-tree-item][aria-expanded]').forEach(item => setTreeItemExpanded(item,false));
- let item = document.querySelector('[data-feature-id="' + CSS.escape(state.scope ?? '') + '"]')?.closest('[data-tree-item]') ?? null;
- while (item) {
- setTreeItemExpanded(item,true);
- item = item.parentElement?.closest('[data-tree-item]') ?? null;
- }
+ // The capability tree opens fully expanded so every declared node is
+ // visible without hunting; the picker is short and reviewers scan it whole.
+ document.querySelectorAll('[data-tree-item][aria-expanded]').forEach(item => setTreeItemExpanded(item,true));
}
document.addEventListener('click', event => {
@@ -1254,7 +1595,11 @@
const bin = event.target.closest('[data-chart-bin]');
if (bin) {
const sessionId = bin.dataset.sessionId;
- if (Number(bin.dataset.binCount) === 1) { setSelection({ type:'tool-call', sessionId, callId:bin.dataset.callId }); return; }
+ // Inside Session View the strip is a minimap: a bar always scrolls the
+ // list to the calls under it, and a multi-call bar zooms in as well.
+ const inSession = event.target.closest('#session-view');
+ const locate = () => inSession && revealSelectionTarget(sessionSelectionTarget({ type:'tool-call', sessionId, callId:bin.dataset.callId },state.sessionItem));
+ if (Number(bin.dataset.binCount) === 1) { setSelection({ type:'tool-call', sessionId, callId:bin.dataset.callId }); locate(); return; }
const surface = bin.ownerSVGElement?.querySelector('[data-chart-surface]');
const fullMin = Number(surface?.dataset.fullMin);
const fullWidth = Math.max(1,Number(surface?.dataset.fullMax) - fullMin);
@@ -1262,10 +1607,17 @@
const to = (Number(bin.dataset.binTo) - fullMin) / fullWidth;
const padding = Math.max(0.004,(to - from) * 0.3);
setZoom(sessionId,Math.max(0,from - padding),Math.min(1,to + padding));
+ locate();
+ return;
+ }
+ const mark = event.target.closest('.chart-mark, .chart-ribbon-block');
+ if (mark) {
+ showChartDetail(mark);
+ const descriptor = descriptorFromElement(mark);
+ setSelection(descriptor);
+ if (event.target.closest('#session-view')) revealSelectionTarget(sessionSelectionTarget(descriptor,state.sessionItem));
return;
}
- const mark = event.target.closest('.chart-mark');
- if (mark) { showChartDetail(mark); setSelection(descriptorFromElement(mark)); return; }
const mode = event.target.closest('[data-mode]');
if (mode) { setMode(mode.dataset.mode); return; }
const treeToggle = event.target.closest('[data-tree-toggle]');
@@ -1276,10 +1628,12 @@
}
const feature = event.target.closest('[data-feature-id]');
if (feature) {
+ // Selecting a capability node only navigates scope. It must not set the
+ // evidence selection, so the detail header stays unhighlighted until the
+ // reviewer clicks an actual object inside the workbench.
state.scope = feature.dataset.featureId;
setTreeItemExpanded(feature.closest('[data-tree-item]'),true);
setMode('feature');
- if (feature.dataset.selectionType === 'story') setSelection(descriptorFromElement(feature));
return;
}
const pickerToggle = event.target.closest('[data-toggle-picker]');
@@ -1301,8 +1655,8 @@
}
const openSession = event.target.closest('[data-open-session]');
if (openSession) {
- const item = state.items?.[Number(openSession.dataset.openSession)];
- if (item?.session) openSessionView(item,state.selection,openSession);
+ const item = itemForSession(bySession.get(openSession.dataset.openSession));
+ if (item) openSessionView(item,state.selection,openSession);
return;
}
const openSessionFor = event.target.closest('[data-open-session-for]');
@@ -1332,6 +1686,39 @@
if (!open) document.querySelectorAll('#session-view details.session-tool-run').forEach(details => { details.open = false; });
return;
}
+ const sessionMode = event.target.closest('[data-session-mode]');
+ if (sessionMode) { setSessionMode(sessionMode.dataset.sessionMode); return; }
+ const replayIndexTab = event.target.closest('[data-replay-index-tab]');
+ if (replayIndexTab) {
+ state.replayIndexTab = replayIndexTab.dataset.replayIndexTab === 'files' ? 'files' : 'events';
+ renderReplayIndex();
+ return;
+ }
+ const replayEvent = event.target.closest('[data-replay-event]');
+ if (replayEvent) { setReplayEvent(replayEvent.dataset.replayEvent); return; }
+ const replayFile = event.target.closest('[data-replay-file]');
+ if (replayFile) {
+ const filePath = replayFile.dataset.replayFile;
+ const file = replayModel()?.files.find(candidate => candidate.path === filePath);
+ if (file?.eventIds[0]) setReplayEvent(file.eventIds[0],{ syncSelection:false, updateHistory:false });
+ setSelection({ type:'file', path:filePath, contextSessionId:state.sessionItem?.session?.sessionId ?? null },{ reveal:false });
+ return;
+ }
+ const replayStep = event.target.closest('[data-replay-step]');
+ if (replayStep) { stepReplay(Number(replayStep.dataset.replayStep)); return; }
+ if (event.target.closest('[data-replay-play]')) { setReplayPlaying(!state.replayPlaying); return; }
+ const replaySpeed = event.target.closest('[data-replay-speed]');
+ if (replaySpeed) {
+ state.replaySpeed = Number(replaySpeed.dataset.replaySpeed) || 1;
+ updateReplayPresentation();
+ if (state.replayPlaying) scheduleReplay();
+ return;
+ }
+ if (event.target.closest('[data-replay-explain]')) {
+ const current = replayCurrentEvent();
+ if (current?.selection) setSelection(current.selection);
+ return;
+ }
const selectable = event.target.closest('[data-selectable]');
if (selectable) { setSelection(descriptorFromElement(selectable)); return; }
if (event.target.closest('[data-close-session]')) { closeSessionView(); return; }
@@ -1366,16 +1753,26 @@
});
document.addEventListener('mouseover', event => {
- const mark = event.target.closest?.('.chart-mark, .chart-commit, [data-chart-bin]');
+ const mark = event.target.closest?.('.chart-mark, .chart-ribbon-block, .chart-commit, [data-chart-bin]');
if (mark) showChartDetail(mark);
});
document.addEventListener('focusin', event => {
- const mark = event.target.closest?.('.chart-mark, .chart-commit, [data-chart-bin]');
+ const mark = event.target.closest?.('.chart-mark, .chart-ribbon-block, .chart-commit, [data-chart-bin]');
if (mark) showChartDetail(mark);
});
document.addEventListener('toggle', event => {
+ const axisPanel = event.target.closest?.('[data-session-axis]');
+ if (axisPanel) {
+ if (!axisPanel.open) return;
+ const axis = axisPanel.querySelector('[data-activity-chart]');
+ if (axis && !axis.childElementCount) {
+ renderActivityChart(axis);
+ chartObserver?.observe(axis);
+ }
+ return;
+ }
const details = event.target.closest?.('[data-activity-session]');
if (!details) return;
// Expanding the trace no longer collapses the delivery lane: reading what
@@ -1479,6 +1876,40 @@
});
document.addEventListener('keydown', event => {
+ const sessionModeTab = event.target.closest?.('[data-session-mode]');
+ if (sessionModeTab && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
+ const nextMode = sessionModeTab.dataset.sessionMode === 'trace' ? 'replay' : 'trace';
+ setSessionMode(nextMode);
+ document.querySelector('[data-session-mode="' + nextMode + '"]')?.focus();
+ event.preventDefault();
+ return;
+ }
+ const replayIndexTab = event.target.closest?.('[data-replay-index-tab]');
+ if (replayIndexTab && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
+ state.replayIndexTab = replayIndexTab.dataset.replayIndexTab === 'events' ? 'files' : 'events';
+ renderReplayIndex();
+ document.querySelector('[data-replay-index-tab="' + state.replayIndexTab + '"]')?.focus();
+ event.preventDefault();
+ return;
+ }
+ const shortcutTarget = event.target.closest?.('input, select, textarea, button, [contenteditable="true"]');
+ if (state.sessionOpen && state.sessionMode === 'replay' && !shortcutTarget) {
+ const key = event.key.toLowerCase();
+ if (event.key === ' ') setReplayPlaying(!state.replayPlaying);
+ else if (key === 'j' || event.key === 'ArrowLeft') stepReplay(-1);
+ else if (key === 'l' || event.key === 'ArrowRight') stepReplay(1);
+ else if ([1,2,4,8].includes(Number(event.key))) {
+ state.replaySpeed = Number(event.key);
+ updateReplayPresentation();
+ if (state.replayPlaying) scheduleReplay();
+ } else if (key === 'p') stepReplay(-1);
+ else if (key === 'n') stepReplay(1);
+ else if (event.key !== 'Escape') return;
+ if (event.key !== 'Escape') {
+ event.preventDefault();
+ return;
+ }
+ }
const bin = event.target.closest?.('[data-chart-bin]');
if (bin && (event.key === 'Enter' || event.key === ' ')) {
bin.dispatchEvent(new MouseEvent('click',{ bubbles:true }));
@@ -1534,6 +1965,8 @@
setMode(mode,{ preserveSelection:true, updateHistory:false });
const session = bySession.get(params.get('session'));
if (params.get('view') === 'session' && session) {
+ state.sessionMode = params.get('session-mode') === 'replay' ? 'replay' : 'trace';
+ state.replayEventId = params.get('replay-event');
const item = itemForSession(session);
if (item) openSessionView(item,state.selection,state.sessionTrigger,{ updateHistory:false });
} else if (state.sessionOpen) {
diff --git a/test/reporting/harness-inspector-demo.test.mjs b/test/reporting/harness-inspector-demo.test.mjs
new file mode 100644
index 0000000..985074c
--- /dev/null
+++ b/test/reporting/harness-inspector-demo.test.mjs
@@ -0,0 +1,47 @@
+import assert from "node:assert/strict";
+import { test } from "vitest";
+
+import {
+ buildHarnessInspectorDemoReport,
+ HARNESS_INSPECTOR_DEMO_GENERATED_AT,
+ renderHarnessInspectorDemoHtml,
+} from "../../scripts/harness-inspector/demo-report.mjs";
+
+test("public Inspector demo is deterministic English sample evidence (AC-3)", () => {
+ const first = buildHarnessInspectorDemoReport();
+ const second = buildHarnessInspectorDemoReport();
+
+ assert.deepEqual(first, second);
+ assert.equal(first.generatedAt, HARNESS_INSPECTOR_DEMO_GENERATED_AT);
+ assert.equal(first.workspace.name, "atlas-checkout");
+ assert.equal(first.stories.length, 3);
+ assert.equal(first.sessions.length, 3);
+ assert.equal(first.commits.length, 3);
+ assert.ok(first.commits.every((commit) => !/^([0-9a-f])\1{39}$/u.test(commit.hash)));
+ assert.equal(first.days.length, 2);
+ assert.equal(first.sessions.reduce((sum, session) => sum + session.toolActivity.totalCalls, 0), 12);
+ assert.ok(first.sessions.some((session) => session.storyLinks.length === 0));
+ assert.ok(first.sessions.some((session) => session.replay?.eventCount > 0));
+
+ const evidenceKinds = new Set([
+ ...first.stories.flatMap((story) => story.sessionLinks.map((link) => link.evidenceKind)),
+ ...first.sessions.flatMap((session) => session.commitLinks.map((link) => link.evidenceKind)),
+ ]);
+ assert.ok(evidenceKinds.has("declared"));
+ assert.ok(evidenceKinds.has("candidate"));
+ assert.ok(evidenceKinds.has("explicit"));
+ assert.ok(evidenceKinds.has("observed-overlap"));
+ assert.ok(evidenceKinds.has("file-context"));
+ assert.doesNotMatch(JSON.stringify(first), /\p{Script=Han}/u);
+});
+
+test("public Inspector demo declares sample and indexing boundaries (AC-4)", () => {
+ const html = renderHarnessInspectorDemoHtml();
+
+ assert.match(html, / /u);
+ assert.match(html, //u);
+ assert.match(html, /English sample data · no live workspace access/u);
+ assert.doesNotMatch(html, /real local evidence/u);
+ assert.doesNotMatch(html, /\/Users\/|[A-Za-z]:\\\\Users\\\\/u);
+ assert.doesNotMatch(html, /sk-[A-Za-z0-9]{16,}|BEGIN [A-Z ]+ PRIVATE KEY/u);
+});
diff --git a/test/reporting/harness-inspector.test.mjs b/test/reporting/harness-inspector.test.mjs
index d3d4a45..330cf0f 100644
--- a/test/reporting/harness-inspector.test.mjs
+++ b/test/reporting/harness-inspector.test.mjs
@@ -574,6 +574,81 @@ test("activity projection carries a wall-clock timeline and falls back to call o
assert.equal(Object.hasOwn(untimed.calls[0], "startedAt"), false);
});
+test("SessionReplay preserves retained order and labels observed versus sequence-only time", () => {
+ const report = buildHarnessInspectorReport({
+ repoRoot: "/workspace/repo",
+ featureTree: parseFeatureTreeMarkdown(FEATURE_TREE),
+ sessions: [fixtureSession()],
+ correlation: fixtureCorrelation(),
+ });
+ const replay = report.sessions[0].replay;
+ assert.equal(replay.kind, "SessionReplay");
+ assert.equal(replay.schemaVersion, 1);
+ assert.equal(replay.timeBasis, "observed-time");
+ assert.deepEqual(replay.events.map((event) => event.type), [
+ "prompt",
+ "tool-call",
+ "intermediate",
+ "commit",
+ "tool-call",
+ "response",
+ ]);
+ assert.deepEqual(replay.events.map((event) => event.order), [1, 2, 3, 4, 5, 6]);
+ assert.equal(replay.events.find((event) => event.type === "intermediate").timeBasis, "sequence-only");
+ assert.equal(replay.events.find((event) => event.type === "response").timeBasis, "turn-boundary");
+ assert.equal(replay.events.find((event) => event.type === "commit").title.startsWith("aaaaaaa"), true);
+ assert.equal(replay.events.some((event) => event.id === `commit:${"b".repeat(40)}`), false);
+ assert.deepEqual(
+ replay.files.find((file) => file.path === "scripts/harness-inspector/render-html.mjs").eventIds,
+ ["call:A1", `commit:${"a".repeat(40)}`],
+ );
+
+ const base = fixtureSession();
+ const sequenceReplay = buildHarnessInspectorReport({
+ repoRoot: "/workspace/repo",
+ featureTree: parseFeatureTreeMarkdown(FEATURE_TREE),
+ sessions: [{
+ ...base,
+ dialogue: {
+ ...base.dialogue,
+ turns: base.dialogue.turns.map((turn) => ({
+ ...turn,
+ prompt: { ...turn.prompt, timestamp: null },
+ startMs: null,
+ endMs: null,
+ })),
+ },
+ toolActivity: { ...base.toolActivity, calls: base.toolActivity.calls.map(({ startedAt, ...call }) => call) },
+ }],
+ correlation: { commits: [] },
+ }).sessions[0].replay;
+ assert.equal(sequenceReplay.timeBasis, "call-sequence");
+ assert.equal(sequenceReplay.timedEventCount, 0);
+ assert.equal(sequenceReplay.events.every((event) => event.timeBasis === "sequence-only"), true);
+});
+
+test("SessionReplay flags a clipped body as an excerpt and leaves whole bodies unmarked", () => {
+ const base = fixtureSession();
+ const longDetail = `rg -n ${"pattern ".repeat(80)}`;
+ const replay = buildHarnessInspectorReport({
+ repoRoot: "/workspace/repo",
+ featureTree: parseFeatureTreeMarkdown(FEATURE_TREE),
+ sessions: [{
+ ...base,
+ toolActivity: {
+ ...base.toolActivity,
+ calls: base.toolActivity.calls.map((call, index) => (index === 0 ? { ...call, detail: longDetail } : call)),
+ },
+ }],
+ correlation: fixtureCorrelation(),
+ }).sessions[0].replay;
+ const clipped = replay.events.find((event) => event.id === "call:A1");
+ const prompt = replay.events.find((event) => event.type === "prompt");
+ assert.equal(clipped.body.endsWith("\u2026"), true);
+ assert.equal(clipped.bodyExcerpt, true);
+ assert.equal(Object.hasOwn(prompt, "bodyExcerpt"), false);
+});
+
test("one turn vocabulary is projected and retained prompts resolve to their real Turn", () => {
const base = fixtureSession();
const report = buildHarnessInspectorReport({