diff --git a/docs/specs/dashboard-v0.2.0.md b/docs/specs/dashboard-v0.2.0.md
deleted file mode 100644
index 4e0088e..0000000
--- a/docs/specs/dashboard-v0.2.0.md
+++ /dev/null
@@ -1,413 +0,0 @@
-# Spec: Shipped Dashboard Template (attune-rag v0.2.0)
-
-Status: shipped in 0.1.6 — implementation diverged from locked
-decisions; see "Implementation Note" below. Original spec body
-preserved as historical design context.
-Target version: 0.2.0 (originally planned; feature landed early in 0.1.6)
-Owner: Patrick
-Created: 2026-04-22
-Reconciled: 2026-04-24
-
----
-
-## Objective
-
-Ship a reusable Cowork-compatible HTML dashboard as part of
-attune-rag itself, so any attune-rag user can render a live
-benchmark/freshness dashboard with a single CLI call. The
-dashboard file is self-contained, embeds its own refresh
-command, and works in any Cowork session that has access to
-the installed attune-rag package.
-
-Today the only copy lives at
-`attune-ai/scripts/attune_rag_dashboard_refresh.py` and is
-coupled to the attune-ai repo layout. v0.2.0 promotes it into
-attune-rag as first-class functionality.
-
----
-
-## Scoping Decisions (Locked)
-
-The following was decided during the planning Q&A. These are
-not open questions — they define v0.2.0's shape.
-
-1. Entry point: CLI only. Exposed as
- `attune-rag dashboard {render,refresh}`. No Python API is
- documented or committed as public; `attune_rag.dashboard`
- is internal.
-2. Refresh strategy: the refresh command is baked into the
- rendered HTML at render time, via a `--refresh-cmd` flag.
- The dashboard invokes it through `window.cowork.callMcpTool`
- exactly as the existing attune-ai artifact does.
-3. Release scope: full refactor into a new `dashboard`
- subpackage with golden tests and this spec doc. Not a hack
- shipped in the scripts directory.
-4. Configurability: only the corpus package name is
- parameterizable (`--corpus-package`, default `attune_help`).
- Queries.yaml path, k, trials, and thresholds stay as
- sensible defaults inside the refresh implementation.
-
----
-
-## Implementation Note (2026-04-24)
-
-The shipped implementation diverged from the locked scoping
-decisions above. Treat this section as the source of truth for
-what's actually in the package; the rest of this document
-captures the original design intent.
-
-**What shipped (as of 0.1.6):**
-
-- `dashboard render` bakes the snapshot itself at render time
- rather than embedding a `--refresh-cmd` that the browser
- invokes via MCP. The rendered HTML is fully self-contained
- with its snapshot payload; no live refresh from the browser.
-- Two sentinels in the template: `__ATTUNE_SNAPSHOT__` and
- `__ATTUNE_TITLE__` (the spec's `__REFRESH_CMD__` /
- `__CORPUS_PACKAGE__` model was dropped).
-- `render(out, snapshot, title) -> Path` signature — takes a
- prebuilt snapshot dict, not a refresh command string.
-- `dashboard render` gained an optional `--open` flag (opens
- the file in the default browser). The spec listed this as an
- open question; answered yes.
-- A third subcommand — `dashboard show` — was added for a Rich
- terminal dashboard (not in original scope). It calls
- `build_snapshot` directly and renders to the console with
- Rich tables.
-- `dashboard refresh` still exists and still emits one JSON
- object to stdout (the Snapshot Shape contract is preserved).
-
-**Why the divergence:**
-
-The MCP-refresh model required a live Cowork session to be
-useful. Baking the snapshot at render time makes the HTML
-portable (shareable, attachable to tickets, viewable offline)
-at the cost of staleness. Re-running `dashboard render`
-regenerates the file — simpler UX than an MCP round-trip.
-
-The path-validation, security, and snapshot-shape requirements
-from the original spec all carried through unchanged.
-
----
-
-## Non-Goals
-
-- No React / framework dependency. Plain HTML + vanilla JS +
- Chart.js UMD from CDN, same as the existing artifact.
-- No server. The dashboard is a static file that hits Cowork's
- MCP bridge directly from the browser.
-- No multi-corpus comparison view. One corpus per dashboard.
-- No persistence beyond what Cowork already provides for its
- artifacts. Snapshots are ephemeral per refresh.
-- No auth / access control — inherits whatever the Cowork
- session has.
-
----
-
-## Module Layout
-
-```
-src/attune_rag/dashboard/
-├── __init__.py # empty; this is internal
-├── render.py # template load + refresh_cmd bake
-├── refresh.py # snapshot generation (moved from attune-ai)
-└── templates/
- └── dashboard.html # self-contained template, loaded via
- # importlib.resources
-```
-
-Rationale: matches attune-rag's existing subpackage style
-(`corpus/`, `providers/`, `eval/`) and keeps the template
-shipped as package data so no filesystem lookup is needed at
-install time.
-
----
-
-## CLI Surface
-
-Added as a new subcommand in `src/attune_rag/cli.py` alongside
-`query`, `corpus-info`, `providers`.
-
-### `attune-rag dashboard render`
-
-Writes a self-contained HTML file with the refresh command
-already embedded.
-
-```
-attune-rag dashboard render \
- --out ~/attune-rag-dashboard.html \
- --refresh-cmd "uv run attune-rag dashboard refresh" \
- [--corpus-package attune_help] \
- [--title "attune-rag dashboard"]
-```
-
-Required flags:
-
-- `--out PATH` — destination file. Validated against path
- traversal and system directories (per attune repo
- standards; reuse `_validate_file_path` or port it).
-- `--refresh-cmd CMD` — command string the dashboard will
- execute via `window.cowork.callMcpTool`'s bash bridge on
- every refresh click / page load.
-
-Optional flags:
-
-- `--corpus-package NAME` — defaults to `attune_help`.
- Passed through to `dashboard refresh` inside the baked
- command if the user doesn't override it themselves.
-- `--title TEXT` — defaults to `attune-rag dashboard`.
-
-Exit codes: `0` success, `2` validation error (bad path,
-missing refresh-cmd).
-
-### `attune-rag dashboard refresh`
-
-Emits a single JSON snapshot to stdout. Shape matches the
-existing attune-ai refresh script output (see Snapshot Shape
-below). This is what the dashboard invokes on every reload.
-
-```
-attune-rag dashboard refresh [--corpus-package attune_help]
-```
-
-Stdout contract: exactly one JSON object, preceded by nothing
-useful. Callers must parse from the first `{` to skip any
-structlog console output (existing lesson in attune-ai's
-CLAUDE.md — structlog writes to stdout by default). The
-rendered dashboard already handles this; keeping the
-convention means the existing JS parser in the template
-doesn't need to change.
-
-Exit codes: `0` success with a complete snapshot, `1` partial
-snapshot (e.g. queries.yaml not found) still emitted but with
-per-section errors, `2` unrecoverable.
-
----
-
-## Refresh Command Embedding
-
-At render time, `--refresh-cmd` is JSON-escaped and substituted
-into a single placeholder in the template:
-
-```html
-
-```
-
-The template uses a minimal substitution — either Jinja2 (already
-in the venv, see jinja2 3.1.6) or a plain `str.replace` of two
-sentinel tokens. Decision deferred to implementation; Jinja2 is
-acceptable but not required. If Jinja2 is used, it stays a dev
-dependency — the rendered output is static HTML with no runtime
-template engine.
-
-Why embed rather than serve: the dashboard is opened as a local
-file from whatever path `--out` writes to; there's no server
-context to read flags from. Baking is the only way to make a
-standalone HTML file remember its own data source.
-
----
-
-## Snapshot Shape
-
-Preserved from the current refresh script so the JS in the
-template doesn't need to change:
-
-```json
-{
- "timestamp": "2026-04-22T14:03:11Z",
- "retrieval": {
- "retriever": "cascade",
- "corpus": "attune_help",
- "precision_at_1": 0.82,
- "recall_at_k": 0.94,
- "mean_latency_ms": 12.3,
- "max_latency_ms": 47.1,
- "total_queries": 40,
- "k": 3,
- "per_difficulty": {"easy": {...}, "medium": {...}, "hard": {...}},
- "per_feature": {"security-audit": {...}, ...},
- "per_query": [{...}, ...]
- },
- "freshness": {
- "attune_help_version": "0.7.1",
- "summaries_by_path_keys": 287,
- "kinds": ["concept", "task", "reference", ...],
- "kind_totals": {"concept": 26, "task": 26, ...},
- "features": ["security-audit", ...],
- "per_feature": {"security-audit": {"kind_counts": {...}}, ...}
- }
-}
-```
-
-Any field the consumer can't render is rendered as "n/a" by
-the template — forward compatible with new sections.
-
----
-
-## Implementation Milestones
-
-Iterative per the Spec-Driven workflow. Each milestone ends
-with a green test suite and a usable artifact.
-
-### M1: Move refresh logic into the package
-
-- Copy `attune_rag_dashboard_refresh.py` logic into
- `src/attune_rag/dashboard/refresh.py` as a proper module
- with typed functions and docstrings.
-- Add `attune_rag dashboard refresh` CLI subcommand that
- wires to it.
-- Unit tests: `tests/unit/test_dashboard_refresh.py` —
- snapshot shape contract, corpus-package override, error
- paths when queries.yaml is missing.
-- Smoke test: `uv run attune-rag dashboard refresh | head -c
- 500` prints valid JSON.
-
-### M2: Ship the template as package data
-
-- Extract the existing artifact HTML (from the attune-ai
- Cowork artifact) into
- `src/attune_rag/dashboard/templates/dashboard.html` with
- two sentinel tokens.
-- Add `[tool.hatch.build.targets.wheel]` or equivalent
- `package-data` config so the template ships in the wheel.
-- Sanity test: `importlib.resources.files("attune_rag.dashboard")
- .joinpath("templates/dashboard.html").read_text()` works
- post-install.
-
-### M3: Render command
-
-- Implement `src/attune_rag/dashboard/render.py::render(out,
- refresh_cmd, corpus_package, title) -> Path`.
-- Add `attune-rag dashboard render` CLI subcommand.
-- Path validation: reject null bytes, system directories,
- and paths outside the user's home unless `--out` is an
- explicit absolute path they provided.
-- Golden test: `tests/unit/test_dashboard_render.py`
- renders to tmp_path, reads the file back, and asserts both
- sentinel values substituted correctly. Uses a small
- deterministic refresh-cmd string and `corpus_package=foo`.
-- No network, no subprocess — rendering is pure string ops.
-
-### M4: Version bump + changelog + README
-
-- Bump `pyproject.toml` to `0.2.0`.
-- Add a CHANGELOG entry under "0.2.0 — Dashboard".
-- Update README with a three-line usage example.
-- Tag and publish per the existing release workflow.
-
-### M5 (post-merge): retire the attune-ai-side script
-
-- Delete `attune-ai/scripts/attune_rag_dashboard_refresh.py`.
-- Update the attune-ai Cowork artifact to invoke
- `uv run attune-rag dashboard refresh` instead of the local
- script. No behavior change — same JSON contract.
-- Document the deprecation in attune-ai's lessons log.
-
----
-
-## Test Plan
-
-All tests live under `tests/unit/` and must pass on the
-existing CI matrix (Python 3.10–3.13, Ubuntu + macOS).
-
-1. `test_dashboard_refresh.py`
- - Happy path: queries.yaml present → full snapshot with
- all sections populated.
- - Missing queries.yaml: exit code 1, JSON still emitted
- with `retrieval.error` populated.
- - Corpus package override: `--corpus-package foo` flows
- through to the freshness section's corpus lookup.
- - Stdout contract: first `{` marks the JSON start.
-
-2. `test_dashboard_render.py`
- - Renders to `tmp_path`, verifies file exists.
- - Verifies both `window.__REFRESH_CMD__` and
- `window.__CORPUS_PACKAGE__` are set to the passed
- values (JSON-escaped).
- - Rejects a path inside `/etc`, `/sys`, `/proc`, `/dev`.
- - Rejects a path containing a null byte.
- - Rejects a path whose parent doesn't exist (with a
- clear error message, not a raw OSError).
-
-3. `test_dashboard_cli.py`
- - `attune-rag dashboard render --help` exits 0.
- - `attune-rag dashboard refresh --help` exits 0.
- - `attune-rag dashboard render` without `--out` exits 2.
- - End-to-end: render to tmp, then grep for the baked
- refresh command.
-
-No integration tests against real Cowork — out of scope.
-
----
-
-## Dependencies
-
-No new required dependencies. Jinja2 is already in the venv
-and may be used for rendering; if adopted, it becomes a core
-runtime dep. A simpler `str.replace` of two sentinel tokens is
-acceptable and preferred if Jinja2 is only used here (avoids
-adding a runtime dep for one substitution). Implementation
-decision made during M3; spec allows either.
-
-Chart.js stays CDN-loaded inside the template — no Python
-dep. SRI hash from the existing artifact is preserved.
-
----
-
-## Security
-
-Path-traversal and null-byte checks on `--out` following the
-pattern documented in
-`attune-ai/.claude/rules/attune/coding-standards-index.md`.
-The refresh command is baked into a static file under the
-user's control, so shell-injection concerns are the user's —
-but the dashboard must not `eval()` or string-concatenate it
-at page load. It must be passed as a single argument to the
-MCP bash tool exactly as written.
-
-The rendered HTML runs in the Cowork sandbox; no privilege
-elevation beyond what Cowork already permits for artifacts.
-
----
-
-## Migration Notes
-
-For users on 0.1.x with their own dashboard copies:
-
-- No breaking API changes. `attune_rag.retrieval`,
- `attune_rag.pipeline`, and the CLI `query` / `corpus-info`
- / `providers` subcommands are untouched.
-- The attune-ai dashboard artifact keeps working during the
- transition. Post-M5 it switches to invoking the installed
- CLI rather than a local script.
-- Users who forked the old refresh script can either keep
- their fork (it still works — private functions in
- `attune_rag.benchmark` are stable for 0.2.x) or migrate to
- `attune-rag dashboard refresh` for zero maintenance.
-
----
-
-## Open Questions
-
-1. Should the render step support a `--open` flag that
- auto-opens the file in the user's browser? Nice-to-have,
- not in v0.2.0 scope. Revisit if users ask.
-2. Do we want a deterministic snapshot mode (fixed seed) for
- reproducible demo screenshots? Skip unless a second use
- case materializes.
-
----
-
-## References
-
-- Existing artifact script:
- `attune-ai/scripts/attune_rag_dashboard_refresh.py`
-- Cowork artifact API: `window.cowork.callMcpTool`, eager
- on-mount refresh pattern
-- attune repo coding standards:
- `attune-ai/.claude/rules/attune/coding-standards-index.md`
-- Spec-driven workflow preference:
- `attune-ai/CLAUDE.md`
diff --git a/docs/specs/dashboard-v0.2.0/design.md b/docs/specs/dashboard-v0.2.0/design.md
new file mode 100644
index 0000000..3c51b7a
--- /dev/null
+++ b/docs/specs/dashboard-v0.2.0/design.md
@@ -0,0 +1,193 @@
+# Spec: Shipped Dashboard Template (attune-rag)
+
+## Phase 2: Design
+
+**Status**: complete
+
+### Drift from original plan (read first)
+
+The shipped 0.1.6 implementation diverged from the locked scoping decisions in the original plan. **The shipped behavior is the source of truth**; the rest of this design.md captures the original design intent for historical context.
+
+**What shipped (as of 0.1.6):**
+
+- `dashboard render` **bakes the snapshot itself at render time** rather than embedding a `--refresh-cmd` that the browser invokes via MCP. The rendered HTML is fully self-contained with its snapshot payload; **no live refresh from the browser**.
+- Two sentinels in the template: `__ATTUNE_SNAPSHOT__` and `__ATTUNE_TITLE__` (the spec's `__REFRESH_CMD__` / `__CORPUS_PACKAGE__` model was dropped).
+- `render(out, snapshot, title) -> Path` signature — takes a prebuilt snapshot dict, not a refresh command string.
+- `dashboard render` gained an optional `--open` flag (opens the file in the default browser). Original spec listed this as an open question; answered yes.
+- A third subcommand — `dashboard show` — was added for a Rich terminal dashboard (not in original scope). Calls `build_snapshot` directly and renders to the console with Rich tables.
+- `dashboard refresh` still exists and still emits one JSON object to stdout (Snapshot Shape contract preserved).
+
+**Why the divergence:**
+
+The MCP-refresh model required a live Cowork session to be useful. **Baking the snapshot at render time makes the HTML portable** (shareable, attachable to tickets, viewable offline) at the cost of staleness. Re-running `dashboard render` regenerates the file — simpler UX than an MCP round-trip.
+
+The path-validation, security, and snapshot-shape requirements from the original spec all carried through unchanged.
+
+### Architecture
+
+#### Module layout
+
+```
+src/attune_rag/dashboard/
+├── __init__.py # empty; this is internal
+├── render.py # template load + sentinel substitution
+├── refresh.py # snapshot generation (moved from attune-ai)
+├── show.py # Rich terminal dashboard (added in shipped impl)
+└── templates/
+ └── dashboard.html # self-contained template, loaded via
+ # importlib.resources
+```
+
+Rationale: matches attune-rag's existing subpackage style (`corpus/`, `providers/`, `eval/`) and keeps the template shipped as package data so no filesystem lookup is needed at install time.
+
+### API changes
+
+CLI-only (no Python API committed as public). Three subcommands added to `src/attune_rag/cli.py` alongside `query`, `corpus-info`, `providers`.
+
+#### `attune-rag dashboard render` (shipped)
+
+Writes a self-contained HTML file with the snapshot already baked in.
+
+```
+attune-rag dashboard render \
+ --out ~/attune-rag-dashboard.html \
+ [--corpus-package attune_help] \
+ [--title "attune-rag dashboard"] \
+ [--open]
+```
+
+Required flags:
+- `--out PATH` — destination file. Validated against path traversal and system directories.
+
+Optional flags:
+- `--corpus-package NAME` — defaults to `attune_help`. Determines which corpus is benchmarked.
+- `--title TEXT` — defaults to `attune-rag dashboard`.
+- `--open` — open the rendered file in the default browser after writing.
+
+Exit codes: `0` success, `2` validation error (bad path, missing required flag).
+
+> **Original-plan signature** (preserved here for historical record): `--refresh-cmd CMD` was required and baked into the HTML for browser-side MCP invocation. Dropped in shipped impl in favor of baked-snapshot model.
+
+#### `attune-rag dashboard refresh` (shipped)
+
+Emits a single JSON snapshot to stdout. Shape matches the existing attune-ai refresh script output (see Snapshot Shape below).
+
+```
+attune-rag dashboard refresh [--corpus-package attune_help]
+```
+
+Stdout contract: exactly one JSON object, preceded by nothing useful. Callers parse from the first `{` to skip any structlog console output (existing lesson; the rendered dashboard already handles this).
+
+Exit codes: `0` success with complete snapshot, `1` partial snapshot (e.g. `queries.yaml` not found) still emitted with per-section errors, `2` unrecoverable.
+
+#### `attune-rag dashboard show` (shipped — added beyond original scope)
+
+Rich terminal dashboard. Calls `build_snapshot` directly and renders to the console with Rich tables. No HTML, no file output.
+
+### Data model changes
+
+#### Snapshot shape (preserved verbatim from existing attune-ai script)
+
+```json
+{
+ "timestamp": "2026-04-22T14:03:11Z",
+ "retrieval": {
+ "retriever": "cascade",
+ "corpus": "attune_help",
+ "precision_at_1": 0.82,
+ "recall_at_k": 0.94,
+ "mean_latency_ms": 12.3,
+ "max_latency_ms": 47.1,
+ "total_queries": 40,
+ "k": 3,
+ "per_difficulty": {"easy": {...}, "medium": {...}, "hard": {...}},
+ "per_feature": {"security-audit": {...}, ...},
+ "per_query": [{...}, ...]
+ },
+ "freshness": {
+ "attune_help_version": "0.7.1",
+ "summaries_by_path_keys": 287,
+ "kinds": ["concept", "task", "reference", ...],
+ "kind_totals": {"concept": 26, "task": 26, ...},
+ "features": ["security-audit", ...],
+ "per_feature": {"security-audit": {"kind_counts": {...}}, ...}
+ }
+}
+```
+
+Any field the consumer can't render is rendered as "n/a" by the template — forward-compatible with new sections.
+
+### UI/UX
+
+The dashboard is a static HTML file. Key UX properties:
+
+- **Self-contained:** opens in any browser; no server required.
+- **Portable:** shareable via attachment / ticket / email; viewable offline.
+- **Refresh model (shipped):** baked at render time. Re-run `attune-rag dashboard render` to regenerate. **No live refresh button** in the rendered HTML.
+- **Chart.js loaded from CDN** with the existing artifact's SRI hash preserved.
+- **Title customisable** via `--title`.
+- **`--open` convenience flag** auto-opens the file in the user's default browser.
+
+### Cross-layer impact
+
+- **attune-rag** (primary): new `dashboard` subpackage, new CLI subcommands, template shipped as package data, version bump, changelog entry, README example.
+- **attune-ai** (M5 only): delete `scripts/attune_rag_dashboard_refresh.py`; update Cowork artifact to invoke `uv run attune-rag dashboard refresh` instead of the local script. No JSON-shape change — the artifact JS is unchanged.
+
+No impact on attune-gui, attune-help, or attune-author.
+
+### Tradeoffs & alternatives
+
+#### Refresh strategy
+
+| Option | Pros | Cons | Chosen? |
+|---|---|---|---|
+| Bake snapshot at render time (shipped) | Portable; offline-viewable; simplest UX; no MCP coupling | Stale until next render; no live updates | **Yes (shipped 0.1.6)** |
+| `--refresh-cmd` embedded; browser invokes via MCP (original plan) | Live updates inside Cowork | Requires live Cowork session to be useful; opaque shell-injection surface; not portable | No (deferred / dropped) |
+| Server-backed dashboard | Always live | Daemon to run; auth concerns; out of scope | No |
+
+#### Sentinel substitution mechanism
+
+| Option | Pros | Cons | Chosen? |
+|---|---|---|---|
+| Plain `str.replace` of sentinel tokens | Zero new runtime deps; obvious; testable | Fragile if template grows complex | **Yes (shipped)** |
+| Jinja2 rendering | Familiar; battle-tested | Adds a runtime dep for one substitution | No |
+
+#### Configurability
+
+Only the corpus package name is parameterizable (`--corpus-package`, default `attune_help`). Queries.yaml path, k, trials, and thresholds stay as sensible defaults inside the refresh implementation. Adding more knobs is explicitly deferred.
+
+### Refresh command embedding (original-plan section, NOT shipped)
+
+> Preserved for historical record. The shipped impl uses snapshot baking instead.
+
+At render time, `--refresh-cmd` would have been JSON-escaped and substituted into a single placeholder in the template:
+
+```html
+
+```
+
+Why embed rather than serve: the dashboard is opened as a local file from whatever path `--out` writes to; there's no server context to read flags from. Baking is the only way to make a standalone HTML file remember its own data source. (This rationale also justifies the snapshot-baking approach that ultimately shipped.)
+
+### Security
+
+Path-traversal and null-byte checks on `--out` following the pattern documented in `attune-ai/.claude/rules/attune/coding-standards-index.md`. The refresh command (in the original plan) would have been baked into a static file under the user's control, so shell-injection concerns are the user's — but the dashboard must not `eval()` or string-concatenate it at page load. It must be passed as a single argument to the MCP bash tool exactly as written.
+
+The rendered HTML runs in the Cowork sandbox (when used inside Cowork); no privilege elevation beyond what Cowork already permits for artifacts.
+
+### Migration notes
+
+For users on 0.1.x with their own dashboard copies:
+
+- **No breaking API changes.** `attune_rag.retrieval`, `attune_rag.pipeline`, and the CLI `query` / `corpus-info` / `providers` subcommands are untouched.
+- **The attune-ai dashboard artifact keeps working** during the transition. Post-M5 it switches to invoking the installed CLI rather than a local script.
+- **Forks of the old refresh script** can either keep their fork (it still works — private functions in `attune_rag.benchmark` are stable for 0.2.x) or migrate to `attune-rag dashboard refresh` for zero maintenance.
+
+### References
+
+- Existing artifact script (pre-M5): `attune-ai/scripts/attune_rag_dashboard_refresh.py`
+- Cowork artifact API: `window.cowork.callMcpTool`, eager on-mount refresh pattern
+- attune repo coding standards: `attune-ai/.claude/rules/attune/coding-standards-index.md`
+- Spec-driven workflow preference: `attune-ai/CLAUDE.md`
diff --git a/docs/specs/dashboard-v0.2.0/requirements.md b/docs/specs/dashboard-v0.2.0/requirements.md
new file mode 100644
index 0000000..cbbe634
--- /dev/null
+++ b/docs/specs/dashboard-v0.2.0/requirements.md
@@ -0,0 +1,77 @@
+# Spec: Shipped Dashboard Template (attune-rag)
+
+**Status**: complete
+
+> Originally `docs/specs/dashboard-v0.2.0.md`. Target version: 0.2.0; **feature actually landed early in 0.1.6**. Implementation diverged from locked scoping decisions — see "Drift from original plan" below. Original design intent is preserved in this spec for historical record; the design.md section flags exactly what shipped differently.
+>
+> - **Owner:** Patrick
+> - **Created:** 2026-04-22
+> - **Reconciled:** 2026-04-24
+
+---
+
+## Phase 1: Requirements
+
+**Status**: complete
+
+### Problem statement
+
+Today, the only working live benchmark/freshness dashboard for attune-rag lives at `attune-ai/scripts/attune_rag_dashboard_refresh.py` and is coupled to the attune-ai repo layout. Users of attune-rag who don't have the attune-ai checkout cannot render a dashboard for their own corpus.
+
+The honest fix is to promote the dashboard into attune-rag itself as first-class functionality:
+
+- A reusable Cowork-compatible HTML dashboard ships as part of the attune-rag package.
+- Any attune-rag user can render the dashboard with a single CLI call (`attune-rag dashboard render`).
+- The dashboard file is self-contained and works in any Cowork session that has access to the installed attune-rag package.
+- The attune-ai-side script is retired, eliminating the duplicate copy.
+
+### Scope
+
+**In scope:**
+
+- New `dashboard` subpackage in `src/attune_rag/dashboard/` (matches attune-rag's existing `corpus/`, `providers/`, `eval/` style).
+- HTML template shipped as package data (loadable via `importlib.resources`).
+- CLI surface: `attune-rag dashboard render` (writes a self-contained HTML file) and `attune-rag dashboard refresh` (emits one JSON snapshot to stdout).
+- Snapshot shape preserved unchanged from the existing attune-ai script — JS in the template needs no changes.
+- Path-traversal and null-byte validation on `--out`.
+- Golden tests + unit tests for render, refresh, and CLI.
+- Version bump, changelog, README usage example.
+- M5 (post-merge): retire the attune-ai-side script; switch the existing artifact to invoke the installed CLI.
+
+**Out of scope (Non-Goals):**
+
+- React / framework dependency. Plain HTML + vanilla JS + Chart.js UMD from CDN.
+- Server. The dashboard is a static file; it hits Cowork's MCP bridge directly from the browser.
+- Multi-corpus comparison view. One corpus per dashboard.
+- Persistence beyond what Cowork already provides for its artifacts. Snapshots are ephemeral per refresh.
+- Auth / access control — inherits whatever the Cowork session has.
+- Public Python API. `attune_rag.dashboard` is internal; CLI is the only documented entry point.
+
+### User stories
+
+1. *As an attune-rag user*, I want one command (`attune-rag dashboard render --out ~/dash.html`) that produces a self-contained HTML file I can open in any browser — so I can see retrieval and freshness metrics for my corpus without cloning attune-ai.
+2. *As a developer demoing attune-rag*, I want the dashboard to be portable (shareable via attachment, viewable offline) — so I can paste it into a ticket or send it to a colleague without telling them to install Cowork.
+3. *As an existing attune-ai user*, I want the existing artifact to keep working through the transition — so my muscle memory doesn't break the day v0.2.0 (or 0.1.6) lands.
+4. *As an attune-rag maintainer*, I want one canonical implementation in the package rather than two copies in two repos — so future changes don't drift.
+
+### Edge cases & open questions
+
+| Question / Edge case | Resolution |
+|---|---|
+| `queries.yaml` missing for the corpus | Refresh emits a partial snapshot with `retrieval.error` populated; CLI exits 1 (still valid JSON). |
+| Path-traversal attempt in `--out` (e.g. `/etc/...`) | Reject with exit code 2 and a clear error message (not a raw `OSError`). Reuses the pattern from `attune-ai/.claude/rules/attune/coding-standards-index.md`. |
+| Null byte in `--out` | Reject. |
+| Parent directory of `--out` doesn't exist | Reject with a clear error message. |
+| structlog console output mixed with snapshot stdout | The dashboard's JS parses from the first `{` to skip noise — convention preserved from attune-ai. |
+| User opens the rendered file outside Cowork | Static HTML loads, but refresh button has no MCP bridge. Dashboard degrades gracefully — last-baked snapshot still visible. |
+| Forward-compatibility with future snapshot fields | Template renders unknown sections as "n/a". |
+| Should `dashboard render` auto-open the file? | Open question at spec time; **answered YES in shipped impl** — added `--open` flag (see drift note in design.md). |
+| Should there be a deterministic-snapshot mode for demo screenshots? | Skip unless a second use case materializes. |
+
+### Affected layers
+
+- [x] attune-rag — new `dashboard` subpackage; new CLI subcommand; template as package data; tests + version bump
+- [x] attune-ai — M5 only: delete `scripts/attune_rag_dashboard_refresh.py`, update Cowork artifact to invoke `attune-rag dashboard refresh`
+- [ ] attune-gui — none
+- [ ] attune-help — none
+- [ ] attune-author — none
diff --git a/docs/specs/dashboard-v0.2.0/tasks.md b/docs/specs/dashboard-v0.2.0/tasks.md
new file mode 100644
index 0000000..35c5703
--- /dev/null
+++ b/docs/specs/dashboard-v0.2.0/tasks.md
@@ -0,0 +1,115 @@
+# Spec: Shipped Dashboard Template (attune-rag)
+
+## Phase 3: Tasks
+
+**Status**: complete
+
+### Implementation order
+
+Iterative per the Spec-Driven workflow. Each milestone ends with a green test suite and a usable artifact.
+
+| # | Task | Layer | Status | Notes |
+|---|------|-------|--------|-------|
+| M1.1 | Copy `attune_rag_dashboard_refresh.py` logic into `src/attune_rag/dashboard/refresh.py` as a proper module with typed functions and docstrings. | attune-rag | done | M1 — Move refresh logic into the package. |
+| M1.2 | Add `attune-rag dashboard refresh` CLI subcommand wired to `dashboard/refresh.py`. | attune-rag | done | |
+| M1.3 | Unit tests in `tests/unit/test_dashboard_refresh.py` — snapshot shape contract, corpus-package override, error paths when `queries.yaml` is missing. | attune-rag | done | |
+| M1.4 | Smoke test: `uv run attune-rag dashboard refresh \| head -c 500` prints valid JSON. | attune-rag | done | |
+| M2.1 | Extract the existing artifact HTML (from the attune-ai Cowork artifact) into `src/attune_rag/dashboard/templates/dashboard.html` with sentinel tokens. | attune-rag | done | M2 — Ship the template as package data. **Drift:** sentinels became `__ATTUNE_SNAPSHOT__` and `__ATTUNE_TITLE__` instead of the spec's `__REFRESH_CMD__` / `__CORPUS_PACKAGE__`. |
+| M2.2 | Add `[tool.hatch.build.targets.wheel]` (or equivalent) `package-data` config so the template ships in the wheel. | attune-rag | done | |
+| M2.3 | Sanity test: `importlib.resources.files("attune_rag.dashboard").joinpath("templates/dashboard.html").read_text()` works post-install. | attune-rag | done | |
+| M3.1 | Implement `src/attune_rag/dashboard/render.py::render(out, snapshot, title) -> Path`. | attune-rag | done | M3 — Render command. **Drift:** signature takes a prebuilt `snapshot` dict, not a `refresh_cmd` string. |
+| M3.2 | Add `attune-rag dashboard render` CLI subcommand. | attune-rag | done | |
+| M3.3 | Path validation: reject null bytes, system directories, and paths outside the user's home unless `--out` is an explicit absolute path the user provided. | attune-rag | done | |
+| M3.4 | Add optional `--open` flag (auto-open in default browser). | attune-rag | done | **Drift:** original spec's open question answered yes; flag added. |
+| M3.5 | Golden test in `tests/unit/test_dashboard_render.py` — render to `tmp_path`, read back, assert sentinel substitution; deterministic title + small fixture snapshot. | attune-rag | done | No network, no subprocess — rendering is pure string ops. |
+| M3.6 | (Bonus, not in original spec) Add `attune-rag dashboard show` CLI subcommand — Rich terminal dashboard. Calls `build_snapshot` directly and renders to console. | attune-rag | done | **Drift:** added beyond original scope; no objection — independent of HTML pipeline. |
+| M4.1 | Bump `pyproject.toml` to target version. | attune-rag | done | **Drift:** feature shipped in **0.1.6**, not 0.2.0 as originally planned. |
+| M4.2 | Add a CHANGELOG entry under the appropriate version. | attune-rag | done | |
+| M4.3 | Update README with a three-line usage example. | attune-rag | done | |
+| M4.4 | Tag and publish per the existing release workflow. | attune-rag | done | |
+| M5.1 | Delete `attune-ai/scripts/attune_rag_dashboard_refresh.py`. | attune-ai | done | M5 — Retire the attune-ai-side script (post-merge). |
+| M5.2 | Update the attune-ai Cowork artifact to invoke `uv run attune-rag dashboard refresh` instead of the local script. No behavior change — same JSON contract. | attune-ai | done | |
+| M5.3 | Document the deprecation in attune-ai's lessons log. | attune-ai | done | |
+
+### Dependencies
+
+```
+M1 → M2 → M3 → M4 → M5
+```
+
+Each milestone is a self-contained release-able unit:
+
+- **M1** ships a working `dashboard refresh` CLI but no rendered HTML.
+- **M2** ships the template as package data; not yet exposed via CLI.
+- **M3** ships `dashboard render` end-to-end.
+- **M4** is the version bump + release.
+- **M5** is post-release cleanup; can land in a follow-up release of attune-ai.
+
+### Testing strategy
+
+All tests live under `tests/unit/` and must pass on the existing CI matrix (Python 3.10–3.13, Ubuntu + macOS).
+
+#### `test_dashboard_refresh.py`
+
+- Happy path: `queries.yaml` present → full snapshot with all sections populated.
+- Missing `queries.yaml`: exit code 1, JSON still emitted with `retrieval.error` populated.
+- Corpus package override: `--corpus-package foo` flows through to the freshness section's corpus lookup.
+- Stdout contract: first `{` marks the JSON start.
+
+#### `test_dashboard_render.py`
+
+- Renders to `tmp_path`, verifies file exists.
+- Verifies the snapshot sentinel and the title sentinel are substituted to the passed values (JSON-escaped).
+- Rejects a path inside `/etc`, `/sys`, `/proc`, `/dev`.
+- Rejects a path containing a null byte.
+- Rejects a path whose parent doesn't exist (with a clear error message, not a raw `OSError`).
+
+#### `test_dashboard_cli.py`
+
+- `attune-rag dashboard render --help` exits 0.
+- `attune-rag dashboard refresh --help` exits 0.
+- `attune-rag dashboard render` without `--out` exits 2.
+- End-to-end: render to tmp, then grep for the baked snapshot fields.
+
+No integration tests against real Cowork — out of scope.
+
+### Dependencies (build/runtime)
+
+No new required dependencies.
+
+- **Jinja2:** considered; **not adopted** for substitution (plain `str.replace` chosen instead — avoids adding a runtime dep for one substitution).
+- **Chart.js:** stays CDN-loaded inside the template — no Python dep. SRI hash from the existing artifact is preserved.
+
+### Rollback plan
+
+The dashboard subpackage is additive — no changes to `attune_rag.retrieval`, `attune_rag.pipeline`, or the existing `query` / `corpus-info` / `providers` CLI subcommands. Rollback strategy:
+
+- **If the dashboard module has a bug:** `git revert` the introducing commit; CLI loses `dashboard` subcommands but everything else is untouched.
+- **If M5 breaks the attune-ai Cowork artifact:** restore `attune-ai/scripts/attune_rag_dashboard_refresh.py` from git; the artifact's `--refresh-cmd` flips back to the local script.
+- **Full revert:** version bump in M4 is the only release-visible change pre-M5; bump back and yank the wheel if pre-distribution release; otherwise publish a patch release with the dashboard module removed.
+
+---
+
+## Phase 4: Implementation
+
+**Status**: complete
+
+### Completion checklist
+
+- [x] All milestones (M1–M5) marked done
+- [x] Tests pass on Python 3.10–3.13 × Ubuntu + macOS
+- [x] Path-validation tests cover system dirs, null bytes, missing parent
+- [x] Snapshot shape contract preserved (existing artifact JS unchanged)
+- [x] Released in attune-rag **0.1.6** (originally targeted 0.2.0; landed early)
+- [x] CHANGELOG + README updated
+- [x] M5: `attune_rag_dashboard_refresh.py` removed from attune-ai; Cowork artifact switched to installed CLI
+- [x] Bonus: `attune-rag dashboard show` (Rich terminal dashboard) shipped in same release
+
+### Drift summary (vs. original locked plan)
+
+1. **Refresh model:** snapshot baked at render time, not `--refresh-cmd` invoked via MCP.
+2. **Template sentinels:** `__ATTUNE_SNAPSHOT__` + `__ATTUNE_TITLE__`, not `__REFRESH_CMD__` + `__CORPUS_PACKAGE__`.
+3. **`render()` signature:** `(out, snapshot, title) -> Path` — takes a dict, not a command string.
+4. **`--open` flag:** added (was an open question in the original spec).
+5. **`dashboard show` subcommand:** added beyond original scope.
+6. **Release version:** shipped in **0.1.6**, not 0.2.0.
diff --git a/docs/specs/template-path-rename/requirements.md b/docs/specs/template-path-rename/requirements.md
new file mode 100644
index 0000000..6413ed9
--- /dev/null
+++ b/docs/specs/template-path-rename/requirements.md
@@ -0,0 +1,125 @@
+# Spec: Template Path Rename
+
+> Phase 1 (requirements) for the `template_path` rename refactor kind.
+> Carried forward from the template-editor spec
+> (`specs/template-editor/`) which deferred this to a follow-up.
+
+---
+
+## Phase 1: Requirements
+
+**Status**: draft
+
+### Problem statement
+
+`attune_rag.editor.plan_rename` currently raises `NotImplementedError`
+for `kind="template_path"`. The template-editor M4 rename refactor
+modal supports the alias and tag kinds end-to-end, but moving a
+template file (changing its rel-path within a corpus) has no path
+through the system.
+
+That gap matters because:
+
+- Template names drift over time. A file named
+ `tasks/use-attune-hub.md` might want to become `tasks/attune-hub.md`
+ when the project's verb conventions change.
+- Reorganizing a corpus (moving `concepts/foo.md` →
+ `references/foo.md`) is currently a manual `mv` followed by a
+ manual sweep of every place that path is referenced.
+- Cross-corpus consistency: if `summaries.json` is path-keyed (it is)
+ and the dashboard's living-docs index references the path, those
+ break silently when a file is renamed.
+
+### Scope
+
+**In scope:**
+
+- Renaming a single template's rel-path within a registered corpus.
+- Atomic file move (tempfile + rename + rollback on partial failure).
+- Updating path-keyed indexes inside the corpus root: `summaries.json`,
+ any `*-index.json` files written by attune-author.
+- Refreshing `Corpus.path_index` and `Corpus.alias_index` after the move.
+- Returning a `RenamePlan` with `FileEdit` entries for every
+ index/sidecar file the rename will touch (so the editor can preview
+ the multi-file diff before the user clicks Apply).
+- Surfacing a "Rename file…" entry in the editor's command palette
+ (or top-bar menu) — distinct trigger from the alias/tag chip context
+ menu.
+
+**Out of scope (defer to a separate spec):**
+
+- Cross-corpus moves (the source path is in corpus A, the target in
+ corpus B). The new path must stay inside the same registered
+ corpus for v1.
+- `cross_links.json` updates — current cross_links are template-name
+ keyed, not path-keyed, so a path-only rename leaves them untouched.
+ If the rename also changes the name (the canonical case where the
+ filename = the alias), that's an alias rename composed with a path
+ rename — out of scope; users can do them sequentially.
+- Updating attune-help's static help index, the staleness pipeline's
+ feature manifests, or any external tools that hard-code template
+ paths. The plan returns affected paths; the user reruns whatever
+ external pipeline depends on them.
+- Git history preservation — a rename is a `move`, but the corpus is
+ user code, not git plumbing.
+
+### User stories
+
+1. *As a corpus author*, I want to rename `tasks/use-attune-hub.md` →
+ `tasks/attune-hub.md` from the template editor and have every
+ path-keyed reference inside the corpus update atomically.
+2. *As a corpus author*, I want a multi-file preview before the
+ rename happens so I can see exactly which files change.
+3. *As a corpus author*, if the new path collides with an existing
+ file, I want a clear error (not a silent overwrite).
+4. *As a corpus author*, if the rename fails halfway through (disk
+ error, permission), I want the corpus to roll back to its
+ pre-rename state.
+
+### Edge cases & open questions
+
+| Question / Edge case | Resolution |
+|----------------------|------------|
+| New path is inside a directory that doesn't exist | Create the directory as part of apply; rollback removes it on failure. |
+| New path equals old path | No-op plan, return empty `edits[]`. (Mirrors alias/tag behavior.) |
+| New path collides with an existing template | `RenameCollisionError(new_path, owning_path=new_path)`. The editor surfaces it as a banner, same as the alias case. |
+| The template currently being edited is the one being moved | After apply, the editor refreshes its `(corpus, path)` to the new path and continues. WS subscribers on the old path get a `file_changed` once + a final close so the tab can reopen on the new path. |
+| `summaries.json` exists in the corpus root | Update the entry; preserve any other keys. Returns one `FileEdit` for `summaries.json`. |
+| `summaries.json` doesn't exist | Skip; not an error. |
+| New path tries to escape the corpus root (`../...`) | Reject with `ValueError`; surfaced as 400 in the editor route. |
+
+### Affected layers
+
+- [x] attune-rag (backend) — implement `_plan_template_path_rename` + `_apply_template_path_rename`
+- [x] attune-gui (frontend) — new "Rename file…" trigger; rename modal already supports `kind: "template_path"` API-wise
+- [ ] attune-help (mobile/docs) — none
+- [ ] attune-author (authoring/infra) — none
+
+---
+
+## Phase 2: Design
+
+> Stubbed. Open the design phase before implementation. Key questions
+> to answer:
+>
+> 1. What is the canonical list of path-keyed sidecar files inside a
+> corpus root? (`summaries.json` confirmed; check for others.)
+> 2. How does the editor's WS infrastructure handle the path change
+> for the active session — does it close-and-redirect, or does it
+> rebind in-place?
+> 3. Is the "Rename file…" trigger on the chip context menu (where
+> alias/tag rename lives) or in a new command palette entry?
+> 4. Should `apply_rename` for `template_path` require an empty
+> target directory, or only an empty target *file*?
+
+---
+
+## Phase 3: Tasks
+
+> Stubbed; fill out after Phase 2 is approved.
+
+---
+
+## Phase 4: Implementation
+
+> Not started.