From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: lucascouts <lucascs@protonmail.com>
Date: Sun, 18 Oct 2026 00:38:25 -0300
Subject: [PATCH] epic_panel: a read-only dock panel of the project's epic
 stories

A project planned with the epic plugin keeps its stories under .epic/,
and the only overview of them was .epic/EPIC.md: an index the plugin
regenerates only when it runs epic-index.sh, so it is usually stale.
This adds an "Epic Stories" dock panel that reads the story files
themselves and follows them while they change. Story 042, phase 1.

What it adds:

  * crate epic_panel, a dock panel on the 0035 pattern: action
    `epic panel: toggle focus`, the right dock by default, the position
    kept in the KVP store, registered in initialize_panels. Its dock
    button shows only while a local worktree has .epic/ at its root.
  * One row per directory of .epic/stories/ and, under a collapsed
    "Archived (N)" header, of .epic/archive/: number, slug, status and
    the tasks.md checkbox progress "<closed>/<total> (+<n> deferred)".
    Spikes show their verdict ("promote to <N>"), superseded stories
    "superseded by <N>", with " (missing)" when no directory carries
    that number; an unknown status is shown as written, with a warning.
    The tooltip carries the title and type/scale/engineering. Enter or a
    click opens story.md (else tasks.md) in a tab; "Open EPIC.md" opens
    the index. Several .epic worktrees get one header each; rows stop at
    2000 with "N more not shown". Keyboard: Up/Down, Enter.
  * Live updates: Fs::watch on .epic/ plus Watcher::add on every area
    and story directory it reads (.epic/ is usually gitignored, so the
    worktree scanner cannot be relied on for it), one rescan per 500 ms.
    A watch that fails shows "Live updates are off: <reason>. Use
    Refresh." and Refresh keeps working.

Why a patch: a Zed extension cannot render a panel or dock button.

The grammar is the plugin's, not YAML: format.rs ports epic plugin
0.13.1's epic-index.sh line for line (front_value, census,
parse_verdict, the status and progress cells; ASCII trim, as the
script runs under LC_ALL=C). The script is byte-identical in plugin
0.18.0. parity_tests.rs is the contract that guards it: with
EPIC_PARITY_DIR and EPIC_INDEX_SH set, it runs the real epic-index.sh on
a temp copy of a corpus and compares every row's number, status and
progress with the panel's; zero rows fails, every disagreement is
listed. On this workspace's .epic/: 57 rows, 0 mismatches.

It writes nothing and runs nothing. .epic/ is read only through
Arc<dyn Fs> (metadata, read_dir, load); a file over 1 MiB is left
unread ("too large"), a symlinked story directory or file is never
followed, and an unreadable file marks its row, never the scan.
never_writes_tests.rs scans a RealFs corpus (symlinks and a FIFO
included) and a panel-session test drives a whole session over one;
both find every name, byte, mtime and link target unchanged. A static
guard checks the crate's non-test sources name no mutating Fs method,
no std::fs and no process API. Logs are key=value lines
(event=scan ... duration_ms=, event=problem, event=watch_failed) and
never carry file content or an absolute path.

Phase 2 (not in this patch) lets a row send its slash command
(`/epic:epic stories run <id>`, the id being the row's directory name)
to an agent thread, prefilled and never sent, so the plugin stays the
only writer of .epic/. It goes through 0045's
AgentPanel::new_thread_with_prefilled_prompt, which needs a source
session the panel does not have, so phase 2 adds a `pub` sibling
without one, the way 0052 added open_new_thread_for_devtools. Phase 1
takes no agent_ui dependency.

Packaging: USE epic, its own series group after devtools-panel and
before test. Generated on top of the claude-agent-acp-plus group (its
zed wiring sits beside 0035's agent_tasks_panel), so REQUIRED_USE ties
the two. It applies with claude-code-ide and devtools-bridge each on or
off, and 0050/0051 apply on top of it unchanged. No new third-party
crate: Cargo.lock gains only the epic_panel entry.

One hunk sits outside the crate, in crates/fs: RealFs::metadata warns
when a symlink's target cannot be read, and that warning printed the
whole path. A hostile symlink inside an untrusted repository's .epic/
would put an absolute path in the log the panel promises never to
write one to, so the warning now names the file only. Behaviour is
otherwise unchanged; the panel_fs_log test pins it.

The plugin was renamed Epico (2026-11-03): it now keeps `.epico/` with
the index in `EPICO.md`, between `epico:index` markers, written by
`epico-index.sh`, and its migrate-dir.sh moves a project's `.epic/` to
`.epico/` the next time it runs there. The panel reads `.epico/` first
and falls back to `.epic/` (index `EPIC.md`) for a project not moved
yet; with both present the plugin moves nothing and writes `.epico/`, so
that is the one read. The labels follow ("Epico Stories", "Open
EPICO.md", "No .epico/ directory in this project"); the action and the
panel's persistence key keep `epic_panel`/`EpicPanel`, so saved keymaps
and dock positions survive. The grammar did not change: epico 0.25.1's
epico-index.sh is epic 0.21.0's epic-index.sh with the names replaced,
and the parity contract now runs against it.

Tests: epic_panel, 188 tests (grammar 5 files, scan, watch, the panel's rows,
shell, open, live updates and field contract, never-writes, parity,
the fs symlink log line).
---

diff --git a/Cargo.lock b/Cargo.lock
index a1ce3fc..9dfc504 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5862,6 +5862,30 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "epic_panel"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "db",
+ "editor",
+ "fs",
+ "futures 0.3.34",
+ "gpui",
+ "log",
+ "menu",
+ "parking_lot",
+ "project",
+ "regex",
+ "serde_json",
+ "settings",
+ "tempfile",
+ "ui",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "equator"
 version = "0.4.2"
@@ -22616,6 +22640,7 @@ dependencies = [
  "editor",
  "encoding_selector",
  "env_logger 0.11.8",
+ "epic_panel",
  "etw_tracing",
  "extension",
  "extension_host",
diff --git a/Cargo.toml b/Cargo.toml
index 3b46ad6..8a9bf11 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -71,6 +71,7 @@ members = [
     "crates/editor_benchmarks",
     "crates/encoding_selector",
     "crates/env_var",
+    "crates/epic_panel",
     "crates/etw_tracing",
     "crates/eval_cli",
     "crates/eval_utils",
@@ -357,6 +358,7 @@ edit_prediction_ui = { path = "crates/edit_prediction_ui" }
 editor = { path = "crates/editor" }
 encoding_selector = { path = "crates/encoding_selector" }
 env_var = { path = "crates/env_var" }
+epic_panel = { path = "crates/epic_panel" }
 etw_tracing = { path = "crates/etw_tracing" }
 eval_utils = { path = "crates/eval_utils" }
 extension = { path = "crates/extension" }
diff --git a/crates/epic_panel/Cargo.toml b/crates/epic_panel/Cargo.toml
new file mode 100644
index 0000000..3c53c61
--- /dev/null
+++ b/crates/epic_panel/Cargo.toml
@@ -0,0 +1,47 @@
+[package]
+name = "epic_panel"
+version = "0.1.0"
+edition.workspace = true
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/epic_panel.rs"
+doctest = false
+
+[features]
+test-support = []
+
+[dependencies]
+anyhow.workspace = true
+collections.workspace = true
+db.workspace = true
+fs.workspace = true
+futures.workspace = true
+gpui.workspace = true
+log.workspace = true
+menu.workspace = true
+parking_lot.workspace = true
+project.workspace = true
+regex.workspace = true
+settings.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true
+
+[dev-dependencies]
+db = { workspace = true, features = ["test-support"] }
+editor = { workspace = true, features = ["test-support"] }
+fs = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+log.workspace = true
+menu.workspace = true
+project = { workspace = true, features = ["test-support"] }
+serde_json.workspace = true
+settings = { workspace = true, features = ["test-support"] }
+tempfile.workspace = true
+ui.workspace = true
+workspace = { workspace = true, features = ["test-support"] }
diff --git a/crates/epic_panel/LICENSE-GPL b/crates/epic_panel/LICENSE-GPL
new file mode 120000
index 0000000..89e542f
--- /dev/null
+++ b/crates/epic_panel/LICENSE-GPL
@@ -0,0 +1 @@
+../../LICENSE-GPL
\ No newline at end of file
diff --git a/crates/epic_panel/src/epic_panel.rs b/crates/epic_panel/src/epic_panel.rs
new file mode 100644
index 0000000..51cb705
--- /dev/null
+++ b/crates/epic_panel/src/epic_panel.rs
@@ -0,0 +1,26 @@
+//! The Epic panel: a read-only view of a worktree's `.epic/` stories.
+
+pub mod format;
+mod panel;
+pub mod scan;
+pub mod watch;
+
+pub use panel::{DisplayEntry, EpicPanel, Refresh, ToggleFocus, init};
+
+// The test file is pre-authored and frozen byte for byte, so rustfmt must not rewrap it, and
+// clippy's ban on blocking process spawns cannot be met inside it (it runs `mkfifo`). It is
+// `pub(crate)` because the panel-session test of task 5.4 reuses `build_corpus` and
+// `tree_state`.
+#[cfg(test)]
+#[path = "never_writes_tests.rs"]
+#[rustfmt::skip]
+#[allow(clippy::disallowed_methods)]
+pub(crate) mod never_writes_tests;
+
+// Frozen like the one above; it runs `bash epic-index.sh` through
+// `std::process::Command::output`, which the workspace's disallowed-methods list rejects.
+#[cfg(test)]
+#[path = "parity_tests.rs"]
+#[rustfmt::skip]
+#[allow(clippy::disallowed_methods)]
+mod parity_tests;
diff --git a/crates/epic_panel/src/format.rs b/crates/epic_panel/src/format.rs
new file mode 100644
index 0000000..a2d9fed
--- /dev/null
+++ b/crates/epic_panel/src/format.rs
@@ -0,0 +1,459 @@
+//! The epic plugin grammar, as pure functions: no paths, no I/O.
+//!
+//! Every function here ports one helper of the plugin's `scripts/epic-index.sh` (0.13.1, which
+//! sets `LC_ALL=C`), so the panel and `EPIC.md` read the same bytes the same way. Where the
+//! plugin's behaviour looks odd it is ported as written (design.md, Assumption A7).
+
+use std::borrow::Cow;
+use std::sync::LazyLock;
+
+use regex::Regex;
+
+/// The line that opens and closes a frontmatter block.
+const FENCE: &str = "---";
+
+/// The prefixes the plugin puts in front of a document's `# ` heading (R2.9).
+const TITLE_PREFIXES: [&str; 3] = ["Story - ", "Implementation Plan - ", "Design - "];
+
+/// `[[:space:]]` under `LC_ALL=C`: ASCII whitespace only, so a non-breaking space is content.
+fn is_c_space(c: char) -> bool {
+    matches!(c, ' ' | '\t' | '\n' | '\x0b' | '\x0c' | '\r')
+}
+
+/// epic-index.sh `trim`.
+fn trim_c_locale(text: &str) -> &str {
+    text.trim_matches(is_c_space)
+}
+
+/// The file's lines as `read -r` sees them: split at `\n`, no empty line after a final `\n`,
+/// and one trailing `\r` removed from each.
+fn lines(text: &str) -> impl Iterator<Item = &str> {
+    text.split_terminator('\n')
+        .map(|line| line.strip_suffix('\r').unwrap_or(line))
+}
+
+/// epic-index.sh `front_value`: the file must open with `---`; key at column 0, first match;
+/// value trimmed; one pair of "…" or '…' stripped; unquoted value cut at " #" (and a value
+/// starting with '#' is empty); unterminated block -> None; empty value -> None; CRLF tolerated.
+pub fn front_value<'a>(text: &'a str, key: &str) -> Option<Cow<'a, str>> {
+    let mut lines = lines(text);
+    if lines.next()? != FENCE {
+        return None;
+    }
+    let mut found = None;
+    for line in lines {
+        if line == FENCE {
+            return found.and_then(scalar).map(Cow::Borrowed);
+        }
+        // The first line naming the key wins, even when its value turns out empty.
+        if found.is_none() {
+            found = line
+                .strip_prefix(key)
+                .and_then(|rest| rest.strip_prefix(':'));
+        }
+    }
+    // No closing line: every key of the file is absent (R3.3).
+    None
+}
+
+/// The value part of `front_value`, after the key and its colon.
+fn scalar(raw: &str) -> Option<&str> {
+    let value = trim_c_locale(raw);
+    let quoted = ['"', '\''].into_iter().find_map(|quote| {
+        value
+            .strip_prefix(quote)
+            .and_then(|inner| inner.strip_suffix(quote))
+    });
+    let value = match quoted {
+        // A quoted scalar keeps its content verbatim.
+        Some(inner) => inner,
+        None if value.starts_with('#') => "",
+        // A plain scalar ends at an inline ` #` comment (YAML's rule).
+        None => match value.find(" #") {
+            Some(comment) => trim_c_locale(&value[..comment]),
+            None => value,
+        },
+    };
+    (!value.is_empty()).then_some(value)
+}
+
+/// Checkbox counts of a `tasks.md`: every box, the closed ones, and the deferred ones.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct Census {
+    pub total: u32,
+    pub closed: u32,
+    pub deferred: u32,
+}
+
+// The census and verdict patterns of epic-index.sh, verbatim except for the POSIX classes,
+// which are spelled out as `LC_ALL=C` defines them: `[[:space:]]` is `[ \t\n\r\x0B\x0C]` and
+// `[[:alnum:]]` is `[A-Za-z0-9]`. They are applied to one line at a time, so `^` and `$` are
+// the line's own ends, as in `[[ $line =~ … ]]`.
+
+/// box_re `^[[:space:]]*- \[([ x~])\]`.
+static BOX_RE: LazyLock<Regex> = LazyLock::new(|| compile(r"^[ \t\n\r\x0B\x0C]*- \[([ x~])\]"));
+/// deferred_re `(^|[^[:alnum:]_-])deferred:`.
+static DEFERRED_RE: LazyLock<Regex> = LazyLock::new(|| compile(r"(^|[^A-Za-z0-9_-])deferred:"));
+/// terminal_re `(^|[^[:alnum:]_-])(waived|n-a|superseded-by):`.
+static TERMINAL_RE: LazyLock<Regex> =
+    LazyLock::new(|| compile(r"(^|[^A-Za-z0-9_-])(waived|n-a|superseded-by):"));
+/// heading_re `^#{2,}[[:space:]]`: any level-two-or-deeper heading ends a section.
+static HEADING_RE: LazyLock<Regex> = LazyLock::new(|| compile(r"^#{2,}[ \t\n\r\x0B\x0C]"));
+/// verdict_re `^#{2,}[[:space:]]+[Vv]erdict([[:space:]].*)?$`.
+static VERDICT_RE: LazyLock<Regex> =
+    LazyLock::new(|| compile(r"^#{2,}[ \t\n\r\x0B\x0C]+[Vv]erdict([ \t\n\r\x0B\x0C].*)?$"));
+/// status_re `^[[:space:]]*(-[[:space:]]+)?status:[[:space:]]*([^[:space:]|]+)`.
+static STATUS_RE: LazyLock<Regex> = LazyLock::new(|| {
+    compile(
+        r"^[ \t\n\r\x0B\x0C]*(-[ \t\n\r\x0B\x0C]+)?status:[ \t\n\r\x0B\x0C]*([^ \t\n\r\x0B\x0C|]+)",
+    )
+});
+/// promoted_re `^[[:space:]]*(-[[:space:]]+)?promoted-to:[[:space:]]*([0-9][^[:space:]#]*)`.
+static PROMOTED_RE: LazyLock<Regex> = LazyLock::new(|| {
+    compile(
+        r"^[ \t\n\r\x0B\x0C]*(-[ \t\n\r\x0B\x0C]+)?promoted-to:[ \t\n\r\x0B\x0C]*([0-9][^ \t\n\r\x0B\x0C#]*)",
+    )
+});
+
+/// Compiles one of the fixed patterns above. They are literals checked by the tests, so a
+/// failure is a programming error, never a runtime condition.
+fn compile(pattern: &str) -> Regex {
+    Regex::new(pattern).expect("fixed epic grammar pattern compiles")
+}
+
+/// The file's lines as `IFS= read -r` sees them: split at `\n`, a last line without `\n`
+/// included, and a trailing `\r` kept (the patterns treat it as whitespace).
+fn raw_lines(text: &str) -> impl Iterator<Item = &str> {
+    text.split_terminator('\n')
+}
+
+/// epic-index.sh `census`: box_re `^[[:space:]]*- \[([ x~])\]`; deferred_re
+/// `(^|[^[:alnum:]_-])deferred:`; terminal_re `(^|[^[:alnum:]_-])(waived|n-a|superseded-by):`.
+/// No fence handling (A7).
+///
+/// `[x]` is closed. `[~]` with `deferred:` is deferred, even when a terminal qualifier is also
+/// present; `[~]` with a terminal qualifier alone is closed; any other `[~]` counts only in
+/// the total.
+pub fn census(text: &str) -> Census {
+    let mut census = Census::default();
+    for line in raw_lines(text) {
+        let Some(mark) = BOX_RE.captures(line).and_then(|captures| captures.get(1)) else {
+            continue;
+        };
+        census.total += 1;
+        match mark.as_str() {
+            "x" => census.closed += 1,
+            "~" if DEFERRED_RE.is_match(line) => census.deferred += 1,
+            "~" if TERMINAL_RE.is_match(line) => census.closed += 1,
+            _ => {}
+        }
+    }
+    census
+}
+
+/// A spike's `## Verdict` section: the first recorded `status:` and `promoted-to:`.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct Verdict {
+    pub status: Option<String>,
+    pub promoted_to: Option<String>,
+}
+
+/// epic-index.sh `parse_verdict`: section from a heading `^#{2,}\s+[Vv]erdict(\s.*)?$` to the
+/// next `^#{2,}\s` heading; first `status:` (value cut at whitespace or '|'), first
+/// `promoted-to:` whose value starts with a digit (cut at whitespace or '#').
+///
+/// A line that does not match a key's pattern (an empty `status:`, a `promoted-to: NNN`) does
+/// not take that key's slot, and a later verdict section fills what an earlier one lacked.
+pub fn parse_verdict(text: &str) -> Verdict {
+    let mut verdict = Verdict::default();
+    let mut in_verdict = false;
+    for line in raw_lines(text) {
+        if HEADING_RE.is_match(line) {
+            in_verdict = VERDICT_RE.is_match(line);
+            continue;
+        }
+        if !in_verdict {
+            continue;
+        }
+        // One key per line, status first, exactly like the plugin's `if … elif`.
+        if verdict.status.is_none()
+            && let Some(value) = second_group(&STATUS_RE, line)
+        {
+            verdict.status = Some(value.to_string());
+        } else if verdict.promoted_to.is_none()
+            && let Some(value) = second_group(&PROMOTED_RE, line)
+        {
+            verdict.promoted_to = Some(value.to_string());
+        }
+    }
+    verdict
+}
+
+/// `BASH_REMATCH[2]` of a match of `re` against `line`.
+fn second_group<'a>(re: &Regex, line: &'a str) -> Option<&'a str> {
+    re.captures(line)
+        .and_then(|captures| captures.get(2))
+        .map(|group| group.as_str())
+}
+
+/// `NNN-slug` -> (Some("NNN" as written), "slug"); anything else -> (None, "").
+///
+/// epic-index.sh `scan_area` matches `^([0-9]+)-(.+)$` under `LC_ALL=C`, so only ASCII digits
+/// make a number and everything after the first hyphen is the slug.
+pub fn split_dir_name(name: &str) -> (Option<&str>, &str) {
+    let (number, rest) = leading_digits(name);
+    match rest.strip_prefix('-') {
+        Some(slug) if !number.is_empty() && !slug.is_empty() => (Some(number), slug),
+        _ => (None, ""),
+    }
+}
+
+/// `text` split after its leading ASCII digits, as `^([0-9]+)` matches under `LC_ALL=C`.
+fn leading_digits(text: &str) -> (&str, &str) {
+    let digits_end = text
+        .find(|c: char| !c.is_ascii_digit())
+        .unwrap_or(text.len());
+    text.split_at(digits_end)
+}
+
+/// Leading-zero strip for numeric comparison ("007" -> "7", "000" -> "0"); digits only,
+/// never parsed into an integer (epic-index.sh `strip_zeros`; a 20-digit name must not wrap).
+pub fn strip_zeros(digits: &str) -> &str {
+    match digits.trim_start_matches('0') {
+        "" => "0",
+        stripped => stripped,
+    }
+}
+
+/// First line starting with "# " (outside the frontmatter), without the "# " and without a
+/// leading "Story - " / "Implementation Plan - " / "Design - " prefix (R2.9).
+///
+/// A frontmatter block that never closes has no outside, so it has no title either.
+pub fn title(text: &str) -> Option<&str> {
+    let mut lines = lines(text).peekable();
+    if lines.peek() == Some(&FENCE) {
+        lines.next();
+        lines.by_ref().find(|line| *line == FENCE)?;
+    }
+    let heading = lines.find_map(|line| line.strip_prefix("# "))?;
+    let heading = TITLE_PREFIXES
+        .iter()
+        .find_map(|prefix| heading.strip_prefix(prefix))
+        .unwrap_or(heading);
+    let heading = trim_c_locale(heading);
+    (!heading.is_empty()).then_some(heading)
+}
+
+/// What epic-index.sh writes into a row's Status column, before its md_cell escaping.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum StatusCell {
+    /// No status recorded anywhere, or a spike without a verdict: "—".
+    Absent,
+    Lifecycle(Lifecycle),
+    /// A status outside the lifecycle, shown as written (R3.8).
+    Unknown(String),
+    /// A `scale: spike` story's `## Verdict`. `promoted_to` is kept only for a `promote`
+    /// verdict, the one the plugin renders a target for.
+    Verdict {
+        status: String,
+        promoted_to: Option<String>,
+        resolved: bool,
+    },
+    /// `status: superseded`, with story.md's `superseded-by:` pointer when there is one.
+    Superseded {
+        by: Option<String>,
+        resolved: bool,
+    },
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Lifecycle {
+    Draft,
+    InProgress,
+    Done,
+    Validated,
+    Superseded,
+    Archived,
+}
+
+impl Lifecycle {
+    /// The exact frontmatter spelling; anything else is not a lifecycle value.
+    fn parse(value: &str) -> Option<Self> {
+        match value {
+            "draft" => Some(Self::Draft),
+            "in-progress" => Some(Self::InProgress),
+            "done" => Some(Self::Done),
+            "validated" => Some(Self::Validated),
+            "superseded" => Some(Self::Superseded),
+            "archived" => Some(Self::Archived),
+            _ => None,
+        }
+    }
+
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::Draft => "draft",
+            Self::InProgress => "in-progress",
+            Self::Done => "done",
+            Self::Validated => "validated",
+            Self::Superseded => "superseded",
+            Self::Archived => "archived",
+        }
+    }
+}
+
+/// The absent cell: U+2014 EM DASH. An empty cell would read as a rendering bug.
+const ABSENT_CELL: &str = "\u{2014}";
+
+impl StatusCell {
+    /// Exactly the Status cell text epic-index.sh writes, before its md_cell escaping:
+    /// "—", "done", "promote to 12", "promote to 12 (missing)", "superseded by 7 (missing)" …
+    pub fn text(&self) -> String {
+        match self {
+            Self::Absent => ABSENT_CELL.to_string(),
+            Self::Lifecycle(lifecycle) => lifecycle.as_str().to_string(),
+            Self::Unknown(value) => value.clone(),
+            Self::Verdict {
+                status,
+                promoted_to: None,
+                ..
+            } => status.clone(),
+            Self::Verdict {
+                promoted_to: Some(target),
+                resolved,
+                ..
+            } => pointer_text("promote to", target, *resolved),
+            Self::Superseded { by: None, .. } => Lifecycle::Superseded.as_str().to_string(),
+            Self::Superseded {
+                by: Some(target),
+                resolved,
+            } => pointer_text("superseded by", target, *resolved),
+        }
+    }
+}
+
+/// A pointer is shown as written; one that resolves nowhere is named, not hidden.
+fn pointer_text(lead: &str, target: &str, resolved: bool) -> String {
+    if resolved {
+        format!("{lead} {target}")
+    } else {
+        format!("{lead} {target} (missing)")
+    }
+}
+
+/// Whether `pointer`'s leading ASCII digits name a known story. Resolution is numeric, so
+/// `7` finds `007-slug`; a pointer with no leading digits names nothing.
+fn resolves(pointer: &str, known: &dyn Fn(&str) -> bool) -> bool {
+    let (digits, _) = leading_digits(pointer);
+    !digits.is_empty() && known(strip_zeros(digits))
+}
+
+/// story.md's value for `key`, else tasks.md's: a `fast` or `spike` story has no story.md.
+pub fn story_then_tasks<'a>(
+    story_md: Option<&'a str>,
+    tasks_md: Option<&'a str>,
+    key: &str,
+) -> Option<Cow<'a, str>> {
+    story_md
+        .and_then(|text| front_value(text, key))
+        .or_else(|| tasks_md.and_then(|text| front_value(text, key)))
+}
+
+/// epic-index.sh `status_cell` precedence (R3.1, R3.4–R3.7). `known` answers whether a
+/// stripped number names a story directory in stories/ or archive/.
+///
+/// Reading `scale:` with story.md first is the plugin's registered exception to its shared
+/// scale rule; it is affordable because a cell gates nothing (A7).
+pub fn status_cell(
+    story_md: Option<&str>,
+    tasks_md: Option<&str>,
+    known: &dyn Fn(&str) -> bool,
+) -> StatusCell {
+    if story_then_tasks(story_md, tasks_md, "scale").as_deref() == Some("spike") {
+        return verdict_cell(tasks_md, known);
+    }
+    let Some(status) = story_then_tasks(story_md, tasks_md, "status") else {
+        return StatusCell::Absent;
+    };
+    match Lifecycle::parse(&status) {
+        Some(Lifecycle::Superseded) => {
+            // The pointer is read from story.md only, as the plugin does.
+            let by = story_md.and_then(|text| front_value(text, "superseded-by"));
+            let resolved = by.as_deref().is_some_and(|by| resolves(by, known));
+            StatusCell::Superseded {
+                by: by.map(Cow::into_owned),
+                resolved,
+            }
+        }
+        Some(lifecycle) => StatusCell::Lifecycle(lifecycle),
+        None => StatusCell::Unknown(status.into_owned()),
+    }
+}
+
+/// epic-index.sh `verdict_cell`: a spike's verdict, read from tasks.md only. No verdict is the
+/// em dash, never the frontmatter status a spike does not use.
+fn verdict_cell(tasks_md: Option<&str>, known: &dyn Fn(&str) -> bool) -> StatusCell {
+    let Verdict {
+        status,
+        promoted_to,
+    } = tasks_md.map(parse_verdict).unwrap_or_default();
+    let Some(status) = status else {
+        return StatusCell::Absent;
+    };
+    let promoted_to = promoted_to.filter(|_| status == "promote");
+    let resolved = promoted_to
+        .as_deref()
+        .is_some_and(|target| resolves(target, known));
+    StatusCell::Verdict {
+        status,
+        promoted_to,
+        resolved,
+    }
+}
+
+/// epic-index.sh `progress_cell`: None -> "—"; Some(c) -> "c/t" + " (+d deferred)" when d > 0.
+///
+/// An empty tasks.md is a measurement (`0/0`); only an absent one is the em dash.
+pub fn progress_text(census: Option<Census>) -> String {
+    let Some(Census {
+        total,
+        closed,
+        deferred,
+    }) = census
+    else {
+        return ABSENT_CELL.to_string();
+    };
+    if deferred > 0 {
+        format!("{closed}/{total} (+{deferred} deferred)")
+    } else {
+        format!("{closed}/{total}")
+    }
+}
+
+// The test files are pre-authored and frozen byte for byte, so rustfmt must not rewrap them.
+#[cfg(test)]
+#[path = "format_front_tests.rs"]
+#[rustfmt::skip]
+#[allow(clippy::needless_lifetimes)]
+mod front_tests;
+
+#[cfg(test)]
+#[path = "format_dir_tests.rs"]
+#[rustfmt::skip]
+mod dir_tests;
+
+#[cfg(test)]
+#[path = "format_census_tests.rs"]
+#[rustfmt::skip]
+mod census_tests;
+
+#[cfg(test)]
+#[path = "format_verdict_tests.rs"]
+#[rustfmt::skip]
+mod verdict_tests;
+
+#[cfg(test)]
+#[path = "format_cell_tests.rs"]
+#[rustfmt::skip]
+#[allow(clippy::manual_contains)]
+mod cell_tests;
diff --git a/crates/epic_panel/src/format_cell_tests.rs b/crates/epic_panel/src/format_cell_tests.rs
new file mode 100644
index 0000000..3322452
--- /dev/null
+++ b/crates/epic_panel/src/format_cell_tests.rs
@@ -0,0 +1,398 @@
+// Story 042, task 2.3 — the Status and Progress cells (R3.1, R3.4-R3.8, R4.3, R4.4).
+//
+// Mounted from `src/format.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "format_cell_tests.rs"]
+//     mod cell_tests;
+//
+// so the Validation filter `cargo test -p epic_panel format::cell` selects this file.
+//
+// Oracle: epic-index.sh `status_cell` (786-825), `verdict_cell` (714-739), `progress_cell`
+// (830-841). Texts are compared byte for byte, before md_cell escaping. The absent cell is
+// U+2014 EM DASH.
+//
+// Contract used beyond the text: `StatusCell::{Absent, Lifecycle(Lifecycle), Unknown(String),
+// Verdict { .. }, Superseded { .. }}` and `Lifecycle::{Draft, InProgress, Done, Validated,
+// Superseded, Archived}` (design.md §Components `format`). Matched with `matches!`, so no
+// derive is required of them.
+
+use std::cell::RefCell;
+
+use super::*;
+
+const EM_DASH: &str = "\u{2014}";
+
+fn fm(pairs: &[(&str, &str)], body: &str) -> String {
+    let mut text = String::from("---\n");
+    for (key, value) in pairs {
+        text.push_str(&format!("{key}: {value}\n"));
+    }
+    text.push_str("---\n\n# Story - X\n\n");
+    text.push_str(body);
+    text
+}
+
+fn cell(story: Option<&str>, tasks: Option<&str>, known: &[&str]) -> StatusCell {
+    let known_fn = |number: &str| known.iter().any(|candidate| *candidate == number);
+    status_cell(story, tasks, &known_fn)
+}
+
+fn text(story: Option<&str>, tasks: Option<&str>, known: &[&str]) -> String {
+    cell(story, tasks, known).text()
+}
+
+fn spike_tasks(verdict: &str) -> String {
+    fm(
+        &[("scale", "spike"), ("status", "in-progress")],
+        &format!("## Verdict\n{verdict}\n"),
+    )
+}
+
+// ── where status comes from (R3.1) ──────────────────────────────────────────
+
+#[test]
+fn cell_status_comes_from_story_md() {
+    let story = fm(&[("status", "done")], "");
+    assert_eq!(text(Some(&story), None, &[]), "done");
+}
+
+#[test]
+fn cell_story_md_wins_over_tasks_md() {
+    let story = fm(&[("status", "validated")], "");
+    let tasks = fm(&[("status", "draft")], "");
+    assert_eq!(text(Some(&story), Some(&tasks), &[]), "validated");
+}
+
+#[test]
+fn cell_falls_back_to_tasks_md_when_story_md_lacks_the_key() {
+    let story = fm(&[("type", "feature")], "");
+    let tasks = fm(&[("status", "in-progress")], "");
+    assert_eq!(text(Some(&story), Some(&tasks), &[]), "in-progress");
+}
+
+#[test]
+fn cell_falls_back_to_tasks_md_when_there_is_no_story_md() {
+    let tasks = fm(&[("status", "draft")], "");
+    assert_eq!(text(None, Some(&tasks), &[]), "draft");
+}
+
+#[test]
+fn cell_falls_back_to_tasks_md_when_story_md_frontmatter_is_unterminated() {
+    let story = "---\nstatus: done\n# Story - never closed\n";
+    let tasks = fm(&[("status", "draft")], "");
+    assert_eq!(text(Some(story), Some(&tasks), &[]), "draft");
+}
+
+#[test]
+fn cell_no_status_anywhere_is_the_em_dash() {
+    let story = fm(&[("type", "feature")], "");
+    for (story, tasks) in [
+        (None, None),
+        (Some(story.as_str()), None),
+        (None, Some("# no frontmatter\n")),
+    ] {
+        let shown = text(story, tasks, &[]);
+        assert_eq!(shown, EM_DASH);
+        assert_eq!(
+            shown.as_bytes(),
+            [0xE2, 0x80, 0x94],
+            "U+2014, not '-' or '--'"
+        );
+        assert!(matches!(cell(story, tasks, &[]), StatusCell::Absent));
+    }
+}
+
+// ── lifecycle vs unknown (R3.8) ─────────────────────────────────────────────
+
+#[test]
+fn cell_lifecycle_values_are_recognised() {
+    let cases: [(&str, fn(&StatusCell) -> bool); 5] = [
+        ("draft", |c| {
+            matches!(c, StatusCell::Lifecycle(Lifecycle::Draft))
+        }),
+        ("in-progress", |c| {
+            matches!(c, StatusCell::Lifecycle(Lifecycle::InProgress))
+        }),
+        ("done", |c| {
+            matches!(c, StatusCell::Lifecycle(Lifecycle::Done))
+        }),
+        ("validated", |c| {
+            matches!(c, StatusCell::Lifecycle(Lifecycle::Validated))
+        }),
+        ("archived", |c| {
+            matches!(c, StatusCell::Lifecycle(Lifecycle::Archived))
+        }),
+    ];
+    for (status, is_expected) in cases {
+        let story = fm(&[("status", status)], "");
+        let got = cell(Some(&story), None, &[]);
+        assert!(is_expected(&got), "{status} maps to its Lifecycle variant");
+        assert_eq!(got.text(), status);
+    }
+    let story = fm(&[("status", "superseded")], "");
+    let got = cell(Some(&story), None, &[]);
+    assert!(
+        !matches!(got, StatusCell::Unknown(_)),
+        "superseded is a known status"
+    );
+    assert_eq!(got.text(), "superseded");
+}
+
+// Hostile half: spellings one character away from a lifecycle value are NOT that value.
+#[test]
+fn cell_near_miss_statuses_are_unknown_and_shown_verbatim() {
+    for status in [
+        "Done",
+        "DONE",
+        "in_progress",
+        "inprogress",
+        "validated!",
+        "blocked",
+        "done-ish",
+    ] {
+        let story = fm(&[("status", status)], "");
+        let got = cell(Some(&story), None, &[]);
+        assert!(
+            matches!(&got, StatusCell::Unknown(value) if value == status),
+            "{status:?} must be Unknown, verbatim"
+        );
+        assert_eq!(got.text(), status);
+    }
+}
+
+// Converse: differently-written values that ARE a lifecycle value after the grammar's own
+// quote and comment rules.
+#[test]
+fn cell_quoted_or_commented_lifecycle_values_are_recognised() {
+    for raw in ["\"done\"", "'done'", "done # closed early", "  done  "] {
+        let story = fm(&[("status", raw)], "");
+        assert!(
+            matches!(
+                cell(Some(&story), None, &[]),
+                StatusCell::Lifecycle(Lifecycle::Done)
+            ),
+            "{raw:?}"
+        );
+    }
+}
+
+// ── spikes (R3.4, R3.5) ─────────────────────────────────────────────────────
+
+#[test]
+fn cell_spike_shows_its_verdict() {
+    let tasks = spike_tasks("- status: wont-do");
+    let got = cell(None, Some(&tasks), &[]);
+    assert!(
+        matches!(got, StatusCell::Verdict { .. }),
+        "a verdict, not a lifecycle or Unknown"
+    );
+    assert_eq!(got.text(), "wont-do");
+    assert_eq!(
+        text(None, Some(&spike_tasks("- status: open")), &[]),
+        "open"
+    );
+}
+
+#[test]
+fn cell_spike_promote_resolved_and_missing() {
+    let tasks = spike_tasks("- status: promote\n- promoted-to: 12");
+    assert_eq!(text(None, Some(&tasks), &["12"]), "promote to 12");
+    assert_eq!(text(None, Some(&tasks), &["13"]), "promote to 12 (missing)");
+    assert_eq!(text(None, Some(&tasks), &[]), "promote to 12 (missing)");
+}
+
+#[test]
+fn cell_spike_promote_without_a_target_is_bare() {
+    assert_eq!(
+        text(None, Some(&spike_tasks("- status: promote")), &["12"]),
+        "promote"
+    );
+    assert_eq!(
+        text(
+            None,
+            Some(&spike_tasks("- status: promote\n- promoted-to: NNN")),
+            &["12"]
+        ),
+        "promote"
+    );
+}
+
+#[test]
+fn cell_spike_target_on_a_non_promote_verdict_is_not_shown() {
+    let tasks = spike_tasks("- status: wont-do\n- promoted-to: 12");
+    assert_eq!(text(None, Some(&tasks), &["12"]), "wont-do");
+}
+
+// Hostile half: a spike with no verdict shows the em dash even though its frontmatter has a
+// lifecycle status — falling back would print a status a spike does not use.
+#[test]
+fn cell_spike_without_a_verdict_is_the_em_dash_not_its_frontmatter_status() {
+    let tasks = fm(&[("scale", "spike"), ("status", "done")], "## Overview\n");
+    assert_eq!(text(None, Some(&tasks), &[]), EM_DASH);
+    let story = fm(&[("scale", "spike"), ("status", "done")], "");
+    assert_eq!(
+        text(Some(&story), None, &[]),
+        EM_DASH,
+        "spike with no tasks.md"
+    );
+}
+
+// Hostile half for "which file says spike": story.md's scale wins when present.
+#[test]
+fn cell_story_md_scale_wins_over_tasks_md_scale() {
+    let story = fm(&[("scale", "standard"), ("status", "done")], "");
+    let tasks = spike_tasks("- status: promote\n- promoted-to: 12");
+    assert_eq!(text(Some(&story), Some(&tasks), &["12"]), "done");
+}
+
+// Converse: story.md without `scale` lets tasks.md make it a spike.
+#[test]
+fn cell_tasks_md_scale_applies_when_story_md_has_none() {
+    let story = fm(&[("status", "done")], "");
+    let tasks = spike_tasks("- status: promote\n- promoted-to: 12");
+    assert_eq!(text(Some(&story), Some(&tasks), &["12"]), "promote to 12");
+}
+
+#[test]
+fn cell_spike_verdict_is_read_from_tasks_md_only() {
+    let story = fm(&[("scale", "spike")], "## Verdict\n- status: wont-do\n");
+    let tasks = spike_tasks("- status: open");
+    assert_eq!(text(Some(&story), Some(&tasks), &[]), "open");
+    assert_eq!(
+        text(Some(&story), None, &[]),
+        EM_DASH,
+        "a verdict in story.md is never read"
+    );
+}
+
+// ── superseded (R3.6) ───────────────────────────────────────────────────────
+
+#[test]
+fn cell_superseded_by_resolved_and_missing() {
+    let story = fm(&[("status", "superseded"), ("superseded-by", "7")], "");
+    assert_eq!(text(Some(&story), None, &["7"]), "superseded by 7");
+    assert_eq!(
+        text(Some(&story), None, &["8"]),
+        "superseded by 7 (missing)"
+    );
+}
+
+#[test]
+fn cell_superseded_without_a_pointer_is_plain() {
+    let story = fm(&[("status", "superseded")], "");
+    assert_eq!(text(Some(&story), None, &["7"]), "superseded");
+}
+
+// Hostile half: `superseded-by` is read from story.md ONLY (Assumption A7).
+#[test]
+fn cell_superseded_by_in_tasks_md_is_ignored() {
+    let story = fm(&[("status", "superseded")], "");
+    let tasks = fm(&[("status", "superseded"), ("superseded-by", "7")], "");
+    assert_eq!(text(Some(&story), Some(&tasks), &["7"]), "superseded");
+    assert_eq!(text(None, Some(&tasks), &["7"]), "superseded");
+}
+
+#[test]
+fn cell_superseded_by_only_matters_when_superseded() {
+    let story = fm(&[("status", "done"), ("superseded-by", "7")], "");
+    assert_eq!(text(Some(&story), None, &["7"]), "done");
+}
+
+#[test]
+fn cell_non_numeric_pointer_is_always_missing() {
+    let story = fm(&[("status", "superseded"), ("superseded-by", "foo")], "");
+    let always = |_: &str| true;
+    assert_eq!(
+        status_cell(Some(&story), None, &always).text(),
+        "superseded by foo (missing)",
+        "a pointer with no leading digits names no story, whatever `known` says"
+    );
+}
+
+#[test]
+fn cell_pointer_text_is_kept_as_written() {
+    let story = fm(&[("status", "superseded"), ("superseded-by", "007")], "");
+    assert_eq!(text(Some(&story), None, &["7"]), "superseded by 007");
+    let story = fm(&[("status", "superseded"), ("superseded-by", "12abc")], "");
+    assert_eq!(text(Some(&story), None, &["12"]), "superseded by 12abc");
+}
+
+// ── `known` receives the stripped leading digits (identity of numbers) ──────
+
+// Hostile half (wrong collapse): 120 is not 12, and 1 is not 12.
+#[test]
+fn cell_resolution_does_not_collapse_different_numbers() {
+    let tasks = spike_tasks("- status: promote\n- promoted-to: 120");
+    assert_eq!(
+        text(None, Some(&tasks), &["12"]),
+        "promote to 120 (missing)"
+    );
+    let story = fm(&[("status", "superseded"), ("superseded-by", "1")], "");
+    assert_eq!(
+        text(Some(&story), None, &["12", "10"]),
+        "superseded by 1 (missing)"
+    );
+}
+
+// Hostile half (wrong split): 012 and 12 are the same story number.
+#[test]
+fn cell_resolution_does_not_split_one_number_in_two() {
+    let tasks = spike_tasks("- status: promote\n- promoted-to: 012");
+    assert_eq!(text(None, Some(&tasks), &["12"]), "promote to 012");
+    let story = fm(&[("status", "superseded"), ("superseded-by", "0007")], "");
+    assert_eq!(text(Some(&story), None, &["7"]), "superseded by 0007");
+}
+
+#[test]
+fn cell_known_is_asked_with_stripped_digits_only() {
+    let asked = RefCell::new(Vec::<String>::new());
+    let known = |number: &str| {
+        asked.borrow_mut().push(number.to_string());
+        false
+    };
+    let tasks = spike_tasks("- status: promote\n- promoted-to: 0042-slug");
+    assert_eq!(
+        status_cell(None, Some(&tasks), &known).text(),
+        "promote to 0042-slug (missing)"
+    );
+    let story = fm(&[("status", "superseded"), ("superseded-by", "000")], "");
+    status_cell(Some(&story), None, &known);
+    let asked = asked.into_inner();
+    assert!(!asked.is_empty(), "the resolver is consulted");
+    for number in &asked {
+        assert!(
+            number.chars().all(|c| c.is_ascii_digit())
+                && (number == "0" || !number.starts_with('0')),
+            "known() received {number:?}; it must get strip_zeros of the leading digits"
+        );
+    }
+    assert!(asked.contains(&"42".to_string()));
+    assert!(asked.contains(&"0".to_string()));
+}
+
+// ── progress (R4.3, R4.4) ───────────────────────────────────────────────────
+
+#[test]
+fn progress_text_absent_is_the_em_dash() {
+    assert_eq!(progress_text(None), EM_DASH);
+}
+
+// Hostile half: an empty tasks.md is a measurement (0/0), not an absent one.
+#[test]
+fn progress_text_empty_census_is_zero_over_zero_not_the_dash() {
+    assert_eq!(progress_text(Some(Census::default())), "0/0");
+}
+
+#[test]
+fn progress_text_formats_closed_total_and_deferred() {
+    let census = |total, closed, deferred| Census {
+        total,
+        closed,
+        deferred,
+    };
+    assert_eq!(progress_text(Some(census(3, 2, 0))), "2/3");
+    assert_eq!(progress_text(Some(census(3, 2, 1))), "2/3 (+1 deferred)");
+    assert_eq!(progress_text(Some(census(5, 1, 2))), "1/5 (+2 deferred)");
+    assert_eq!(progress_text(Some(census(61, 61, 0))), "61/61");
+}
diff --git a/crates/epic_panel/src/format_census_tests.rs b/crates/epic_panel/src/format_census_tests.rs
new file mode 100644
index 0000000..0363f59
--- /dev/null
+++ b/crates/epic_panel/src/format_census_tests.rs
@@ -0,0 +1,149 @@
+// Story 042, task 2.2 — the checkbox census (R4.1, R4.2).
+//
+// Mounted from `src/format.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "format_census_tests.rs"]
+//     mod census_tests;
+//
+// so the Validation filter `cargo test -p epic_panel format::census` selects this file.
+//
+// Oracle: epic-index.sh `census` (lines 385-416):
+//   box_re      ^[[:space:]]*- \[([ x~])\]
+//   deferred_re (^|[^[:alnum:]_-])deferred:
+//   terminal_re (^|[^[:alnum:]_-])(waived|n-a|superseded-by):
+// `[x]` closed; `[~]` + deferred: -> deferred (wins over a terminal qualifier); `[~]` + a
+// terminal qualifier alone -> closed; any other `[~]` counts in the total only. No fence
+// handling (Assumption A7).
+
+use super::*;
+
+fn c(total: u32, closed: u32, deferred: u32) -> Census {
+    Census {
+        total,
+        closed,
+        deferred,
+    }
+}
+
+#[test]
+fn census_counts_the_three_box_states() {
+    let text = "- [ ] open\n- [x] closed\n- [~] owed (deferred: later)\n";
+    assert_eq!(census(text), c(3, 1, 1));
+}
+
+#[test]
+fn census_of_empty_text_is_zero() {
+    assert_eq!(census(""), c(0, 0, 0));
+    assert_eq!(census("# Plan\n\nNo boxes here.\n"), c(0, 0, 0));
+}
+
+// Hostile half: lines that resemble a box and are not one.
+#[test]
+fn census_refuses_lines_that_only_resemble_a_box() {
+    for line in [
+        "- [X] uppercase is not a box",
+        "-  [x] two spaces",
+        "-[x] no space",
+        "* [x] star bullet",
+        "+ [x] plus bullet",
+        "1. [x] numbered",
+        "- [xx] two marks",
+        "- [] empty",
+        "- [-] dash",
+        "text - [x] not at the start",
+        "    - ToDo: close as `[~] (deferred: …)` naming the missing piece.",
+    ] {
+        assert_eq!(census(line), c(0, 0, 0), "{line:?}");
+    }
+}
+
+// Converse: differently-indented boxes are still boxes.
+#[test]
+fn census_counts_indented_boxes() {
+    let text = "  - [x] two spaces\n\t- [x] a tab\n    \t  - [ ] mixed\n- [x] none";
+    assert_eq!(census(text), c(4, 3, 0));
+}
+
+#[test]
+fn census_counts_boxes_inside_fenced_code_blocks() {
+    let text = "```markdown\n- [x] in a fence\n- [ ] also\n```\n- [x] outside\n";
+    assert_eq!(
+        census(text),
+        c(3, 2, 0),
+        "A7: the plugin does not skip fences"
+    );
+}
+
+#[test]
+fn census_counts_a_last_line_without_newline_and_crlf_lines() {
+    assert_eq!(census("- [x] a\r\n- [ ] b\r\n- [x] c"), c(3, 2, 0));
+}
+
+#[test]
+fn census_each_terminal_qualifier_closes_a_tilde_box() {
+    for line in [
+        "- [~] 1.1 - x (waived: not needed)",
+        "- [~] 1.1 - x (n-a: no UI)",
+        "- [~] 1.1 - x (superseded-by: 044)",
+        "- [~] waived: at the start of the text",
+    ] {
+        assert_eq!(census(line), c(1, 1, 0), "{line:?}");
+    }
+}
+
+#[test]
+fn census_deferred_wins_over_a_terminal_qualifier() {
+    assert_eq!(census("- [~] x (waived: a; deferred: b)"), c(1, 0, 1));
+    assert_eq!(census("- [~] x (deferred: b; n-a: a)"), c(1, 0, 1));
+}
+
+#[test]
+fn census_unqualified_tilde_counts_in_the_total_only() {
+    assert_eq!(census("- [~] no qualifier at all"), c(1, 0, 0));
+    assert_eq!(census("- [~] deferred without a colon"), c(1, 0, 0));
+}
+
+// Hostile half for qualifier identity: words that END in a qualifier are not the qualifier.
+#[test]
+fn census_qualifiers_need_a_word_boundary_before_them() {
+    for line in [
+        "- [~] x (man-a: not n-a)",
+        "- [~] x (undeferred: no)",
+        "- [~] x (pre-deferred: no)",
+        "- [~] x (re_deferred: no)",
+        "- [~] x (unwaived: no)",
+        "- [~] x (not-superseded-by: no)",
+        "- [~] x (x-n-a: no)",
+        "- [~] x (N-A: case matters)",
+        "- [~] x (Deferred: case matters)",
+    ] {
+        assert_eq!(
+            census(line),
+            c(1, 0, 0),
+            "{line:?} counts in the total only"
+        );
+    }
+}
+
+// Converse: punctuation, spaces and tabs ARE boundaries.
+#[test]
+fn census_qualifiers_after_punctuation_or_space_count() {
+    assert_eq!(census("- [~] x (deferred: a)"), c(1, 0, 1));
+    assert_eq!(census("- [~] x\tdeferred: a"), c(1, 0, 1));
+    assert_eq!(census("- [~] x,n-a: a"), c(1, 1, 0));
+    assert_eq!(census("- [~] x.waived: a"), c(1, 1, 0));
+}
+
+#[test]
+fn census_qualifiers_only_matter_on_a_tilde_box() {
+    assert_eq!(census("- [x] done (deferred: still closed)"), c(1, 1, 0));
+    assert_eq!(census("- [ ] open (waived: still open)"), c(1, 0, 0));
+}
+
+#[test]
+fn census_of_a_real_corpus_deferred_line() {
+    // .epico/archive/024-tasks-panel-opens-thread-in-its-project-window/tasks.md:108, shortened.
+    let text = "  - [~] 5.2 - Replay the report in a running Zeo (deferred: the Xvfb harness has no window manager)\n- [x] 5.1 - done\n";
+    assert_eq!(census(text), c(2, 1, 1));
+}
diff --git a/crates/epic_panel/src/format_dir_tests.rs b/crates/epic_panel/src/format_dir_tests.rs
new file mode 100644
index 0000000..3f58e75
--- /dev/null
+++ b/crates/epic_panel/src/format_dir_tests.rs
@@ -0,0 +1,82 @@
+// Story 042, task 2.1 — story directory names and the numeric key (R2.2).
+//
+// Mounted from `src/format.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "format_dir_tests.rs"]
+//     mod dir_tests;
+//
+// so the Validation filter `cargo test -p epic_panel format::dir` selects this file.
+//
+// Oracle: epic-index.sh `scan_area` (`^([0-9]+)-(.+)$` under LC_ALL=C, so `[0-9]` is ASCII
+// only) and `strip_zeros` (pure string work, never an integer: a 20-digit name must not wrap).
+
+use super::*;
+
+#[test]
+fn split_dir_name_splits_number_and_slug() {
+    assert_eq!(
+        split_dir_name("042-epic-panel-read-only"),
+        (Some("042"), "epic-panel-read-only")
+    );
+    assert_eq!(split_dir_name("7-x"), (Some("7"), "x"));
+    assert_eq!(split_dir_name("0099-c"), (Some("0099"), "c"));
+    // The number is the leading digit run; everything after the FIRST hyphen is the slug.
+    assert_eq!(split_dir_name("1-2-3"), (Some("1"), "2-3"));
+    assert_eq!(split_dir_name("12-café"), (Some("12"), "café"));
+}
+
+// Hostile half: names that look numbered but are not `NNN-slug`.
+#[test]
+fn split_dir_name_refuses_names_that_only_look_numbered() {
+    for name in [
+        "042",    // no slug
+        "042-",   // empty slug
+        "-042-x", // no leading digit
+        "abc-1",  // letters first
+        "v2-x", "4a-x",   // digits then a letter before the hyphen
+        "١٢-x",   // Arabic-Indic digits are not [0-9] under LC_ALL=C
+        "１２-x", // fullwidth digits neither
+        "",
+    ] {
+        assert_eq!(split_dir_name(name), (None, ""), "{name:?}");
+    }
+}
+
+#[test]
+fn split_dir_name_keeps_the_number_as_written() {
+    // The panel shows "007", like EPICO.md's `${dir%%-*}`; stripping is for comparison only.
+    assert_eq!(split_dir_name("007-bond").0, Some("007"));
+}
+
+#[test]
+fn strip_zeros_matches_the_plugin() {
+    assert_eq!(strip_zeros("007"), "7");
+    assert_eq!(strip_zeros("000"), "0");
+    assert_eq!(strip_zeros("0"), "0");
+    assert_eq!(strip_zeros("10"), "10");
+    assert_eq!(strip_zeros("100"), "100");
+    assert_eq!(strip_zeros("0099"), "99");
+}
+
+// Hostile half for "never parse into an integer": inputs that overflow u64/u128 or read as
+// octal in bash arithmetic must come back as digit strings, unchanged in value.
+#[test]
+fn strip_zeros_never_wraps_or_reads_octal() {
+    assert_eq!(strip_zeros("00000000000000000000123"), "123");
+    assert_eq!(
+        strip_zeros("99999999999999999999999999999999999999999"),
+        "99999999999999999999999999999999999999999"
+    );
+    assert_eq!(strip_zeros("0008"), "8");
+    assert_eq!(strip_zeros("0010"), "10");
+}
+
+// Converse: the strip must not split one number into two, nor collapse two into one.
+#[test]
+fn strip_zeros_keeps_distinct_numbers_distinct() {
+    assert_ne!(strip_zeros("12"), strip_zeros("120"));
+    assert_ne!(strip_zeros("1"), strip_zeros("10"));
+    assert_eq!(strip_zeros("012"), strip_zeros("12"));
+    assert_eq!(strip_zeros("0012"), strip_zeros("012"));
+}
diff --git a/crates/epic_panel/src/format_front_tests.rs b/crates/epic_panel/src/format_front_tests.rs
new file mode 100644
index 0000000..4bc6e88
--- /dev/null
+++ b/crates/epic_panel/src/format_front_tests.rs
@@ -0,0 +1,293 @@
+// Story 042, task 2.1 — the frontmatter reader and the title (R3.2, R3.3, R6.4, R2.9).
+//
+// Mounted from `src/format.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "format_front_tests.rs"]
+//     mod front_tests;
+//
+// so every test path reads `format::front_tests::…` and the Validation filter
+// `cargo test -p epic_panel format::front` selects exactly this file.
+//
+// Oracle: epic plugin 0.13.1 `scripts/epico-index.sh`, `front_value` (lines 316-357). Every
+// expectation below is what that function prints for the same bytes; where the plugin's
+// behaviour looks odd, the test pins the oddity on purpose (design.md, Assumption A7).
+
+use super::*;
+
+fn value<'a>(text: &'a str, key: &str) -> Option<String> {
+    front_value(text, key).map(|value| value.into_owned())
+}
+
+fn fm(body: &str) -> String {
+    format!("---\n{body}\n---\n# Story - Title\n")
+}
+
+// ── the key: column 0, then a colon ─────────────────────────────────────────
+
+// Hostile half first: near-identical keys that are NOT `status` must never be read as it.
+#[test]
+fn front_value_refuses_keys_that_only_start_like_the_key() {
+    for line in [
+        "statusx: done",
+        "status_x: done",
+        "status-x: done",
+        "review_status: done",
+        "  status: done",
+        "\tstatus: done",
+        "- status: done",
+        "status : done",
+        "Status: done",
+    ] {
+        assert_eq!(
+            value(&fm(line), "status"),
+            None,
+            "`{line}` must not be read as the `status` key"
+        );
+    }
+}
+
+// Converse: differently-shaped lines that ARE the key must be read.
+#[test]
+fn front_value_reads_the_key_however_its_value_is_spaced() {
+    for (line, expected) in [
+        ("status:done", "done"),
+        ("status: done", "done"),
+        ("status:    done   ", "done"),
+        ("status:\tdone\t", "done"),
+    ] {
+        assert_eq!(
+            value(&fm(line), "status").as_deref(),
+            Some(expected),
+            "`{line}`"
+        );
+    }
+}
+
+#[test]
+fn front_value_reads_a_hyphenated_key_and_not_its_longer_neighbour() {
+    assert_eq!(
+        value(&fm("superseded-by: 7"), "superseded-by").as_deref(),
+        Some("7")
+    );
+    assert_eq!(value(&fm("superseded-byx: 7"), "superseded-by"), None);
+    assert_eq!(
+        value(&fm("superseded-by: 7"), "superseded"),
+        None,
+        "`superseded` is not a prefix match of `superseded-by:` (the colon is part of the key)"
+    );
+}
+
+#[test]
+fn front_value_first_match_wins() {
+    assert_eq!(
+        value(&fm("status: draft\nstatus: done"), "status").as_deref(),
+        Some("draft")
+    );
+}
+
+// The plugin marks the key found on its first match even when the value is empty, and then
+// rejects the empty value: a later non-empty line does NOT rescue it.
+#[test]
+fn front_value_an_empty_first_match_hides_a_later_value() {
+    assert_eq!(value(&fm("status:\nstatus: done"), "status"), None);
+    assert_eq!(value(&fm("status:   \nstatus: done"), "status"), None);
+}
+
+#[test]
+fn front_value_empty_value_is_absent() {
+    assert_eq!(value(&fm("status:"), "status"), None);
+    assert_eq!(value(&fm("status:    "), "status"), None);
+    assert_eq!(value(&fm("status: \"\""), "status"), None);
+    assert_eq!(value(&fm("status: ''"), "status"), None);
+}
+
+#[test]
+fn front_value_ignores_keys_it_was_not_asked_for() {
+    let text =
+        fm("history:\n  - 2026-10-10: a: b\t|c\nweird line without colon\nstatus: done\nextra: x");
+    assert_eq!(value(&text, "status").as_deref(), Some("done"));
+    assert_eq!(value(&text, "scale"), None);
+}
+
+// ── quotes and comments ─────────────────────────────────────────────────────
+
+#[test]
+fn front_value_strips_one_pair_of_matching_quotes_and_keeps_the_inside_verbatim() {
+    for (line, expected) in [
+        ("status: \"done\"", "done"),
+        ("status: 'done'", "done"),
+        ("status: \"a # b\"", "a # b"),
+        ("status: ' padded '", " padded "),
+        ("status: \"\"done\"\"", "\"done\""),
+    ] {
+        assert_eq!(
+            value(&fm(line), "status").as_deref(),
+            Some(expected),
+            "`{line}`"
+        );
+    }
+}
+
+#[test]
+fn front_value_leaves_unmatched_quotes_alone() {
+    for (line, expected) in [
+        ("status: \"done'", "\"done'"),
+        ("status: \"done", "\"done"),
+        ("status: \"", "\""),
+        ("status: done\"", "done\""),
+    ] {
+        assert_eq!(
+            value(&fm(line), "status").as_deref(),
+            Some(expected),
+            "`{line}`"
+        );
+    }
+}
+
+// Hostile half: only a SPACE then `#` starts a comment, so `#` glued to the value stays.
+#[test]
+fn front_value_keeps_a_hash_that_does_not_start_a_comment() {
+    assert_eq!(
+        value(&fm("status: done#closed"), "status").as_deref(),
+        Some("done#closed")
+    );
+    assert_eq!(
+        value(&fm("superseded-by: 12#x"), "superseded-by").as_deref(),
+        Some("12#x")
+    );
+}
+
+#[test]
+fn front_value_cuts_an_unquoted_value_at_a_space_hash_comment() {
+    for (line, expected) in [
+        ("status: done # closed early", "done"),
+        ("status: done   # closed early", "done"),
+        ("status: in-progress # a # b", "in-progress"),
+    ] {
+        assert_eq!(
+            value(&fm(line), "status").as_deref(),
+            Some(expected),
+            "`{line}`"
+        );
+    }
+}
+
+#[test]
+fn front_value_a_value_that_starts_with_a_hash_is_empty() {
+    assert_eq!(value(&fm("status: # nothing yet"), "status"), None);
+    assert_eq!(value(&fm("status: #done"), "status"), None);
+}
+
+// ── the block: opens on line 1, must close ──────────────────────────────────
+
+#[test]
+fn front_value_requires_the_first_line_to_be_exactly_three_dashes() {
+    for text in [
+        "\n---\nstatus: done\n---\n",
+        "# Title\n---\nstatus: done\n---\n",
+        " ---\nstatus: done\n---\n",
+        "--- \nstatus: done\n---\n",
+        "----\nstatus: done\n---\n",
+        "status: done\n",
+        "",
+    ] {
+        assert_eq!(value(text, "status"), None, "{text:?}");
+    }
+}
+
+#[test]
+fn front_value_unterminated_block_makes_every_key_absent() {
+    assert_eq!(value("---\nstatus: done\nscale: spike\n", "status"), None);
+    assert_eq!(value("---\nstatus: done\nscale: spike", "scale"), None);
+    assert_eq!(value("---\n", "status"), None);
+    // `--- ` (trailing space) and `----` do not close the block either.
+    assert_eq!(value("---\nstatus: done\n--- \nbody\n", "status"), None);
+    assert_eq!(value("---\nstatus: done\n----\nbody\n", "status"), None);
+}
+
+#[test]
+fn front_value_never_reads_below_the_closing_line() {
+    assert_eq!(
+        value("---\ntype: feature\n---\nstatus: done\n", "status"),
+        None
+    );
+}
+
+#[test]
+fn front_value_closing_line_without_trailing_newline_still_closes() {
+    assert_eq!(
+        value("---\nstatus: done\n---", "status").as_deref(),
+        Some("done")
+    );
+}
+
+#[test]
+fn front_value_tolerates_crlf() {
+    let text = "---\r\nstatus: done\r\nscale: \"spike\"\r\n---\r\n# Story - X\r\n";
+    assert_eq!(value(text, "status").as_deref(), Some("done"));
+    assert_eq!(value(text, "scale").as_deref(), Some("spike"));
+}
+
+#[test]
+fn front_value_reads_a_real_corpus_header() {
+    // .epico/archive/006-measure-the-resumed-load-path/tasks.md, first 9 lines.
+    let text = "---\nstory: measure-the-resumed-load-path\ntype: feature\nscale: spike\nversion: 1\ncreated: 2026-09-05\nstatus: archived\n---\n\n# Implementation Plan - Measure the resumed load path\n";
+    assert_eq!(value(text, "scale").as_deref(), Some("spike"));
+    assert_eq!(value(text, "status").as_deref(), Some("archived"));
+    assert_eq!(value(text, "type").as_deref(), Some("feature"));
+    assert_eq!(value(text, "engineering"), None);
+}
+
+#[test]
+fn front_value_borrows_when_it_does_not_need_to_allocate() {
+    let text = fm("status: done");
+    assert!(matches!(
+        front_value(&text, "status"),
+        Some(Cow::Borrowed("done"))
+    ));
+}
+
+// ── title (R2.9) ────────────────────────────────────────────────────────────
+
+#[test]
+fn title_strips_the_plugin_prefixes() {
+    assert_eq!(title("# Story - A panel\n"), Some("A panel"));
+    assert_eq!(title("# Implementation Plan - A panel\n"), Some("A panel"));
+    assert_eq!(title("# Design - A panel\n"), Some("A panel"));
+    assert_eq!(title("# A panel\n"), Some("A panel"));
+}
+
+// Hostile half: words that merely contain a prefix are not stripped.
+#[test]
+fn title_keeps_text_that_only_resembles_a_prefix() {
+    assert_eq!(
+        title("# Storytelling - tips\n"),
+        Some("Storytelling - tips")
+    );
+    assert_eq!(title("# A Story - told\n"), Some("A Story - told"));
+    assert_eq!(title("# Story-less\n"), Some("Story-less"));
+    assert_eq!(title("# Designer - notes\n"), Some("Designer - notes"));
+}
+
+#[test]
+fn title_skips_a_hash_line_inside_the_frontmatter() {
+    let text = "---\n# a yaml comment\nstatus: done\n---\n\n# Story - The real title\n";
+    assert_eq!(title(text), Some("The real title"));
+}
+
+#[test]
+fn title_takes_the_first_level_one_heading_only() {
+    assert_eq!(
+        title("## Overview\n#NoSpace\n# Story - First\n# Second\n"),
+        Some("First")
+    );
+    assert_eq!(title("## Only deeper\n### headings\n"), None);
+    assert_eq!(title(""), None);
+}
+
+#[test]
+fn title_of_a_real_corpus_file() {
+    let text = "---\nstory: measure-the-resumed-load-path\nscale: spike\nstatus: archived\n---\n\n# Implementation Plan - Measure the resumed load path\n\n## Overview\n";
+    assert_eq!(title(text), Some("Measure the resumed load path"));
+}
diff --git a/crates/epic_panel/src/format_verdict_tests.rs b/crates/epic_panel/src/format_verdict_tests.rs
new file mode 100644
index 0000000..622001d
--- /dev/null
+++ b/crates/epic_panel/src/format_verdict_tests.rs
@@ -0,0 +1,208 @@
+// Story 042, task 2.2 — the spike `## Verdict` section (R3.4).
+//
+// Mounted from `src/format.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "format_verdict_tests.rs"]
+//     mod verdict_tests;
+//
+// so the Validation filter `cargo test -p epic_panel format::verdict` selects this file.
+//
+// Oracle: epic-index.sh `parse_verdict` (lines 443-468):
+//   heading_re  ^#{2,}[[:space:]]                         (any ##+ heading ends a section)
+//   verdict_re  ^#{2,}[[:space:]]+[Vv]erdict([[:space:]].*)?$
+//   status_re   ^[[:space:]]*(-[[:space:]]+)?status:[[:space:]]*([^[:space:]|]+)
+//   promoted_re ^[[:space:]]*(-[[:space:]]+)?promoted-to:[[:space:]]*([0-9][^[:space:]#]*)
+// Only the first MATCH of each key inside a verdict section is kept. A line that does not
+// match (an empty `status:`, a `promoted-to: NNN`) does not consume the slot.
+
+use super::*;
+
+fn v(status: Option<&str>, promoted_to: Option<&str>) -> Verdict {
+    Verdict {
+        status: status.map(str::to_string),
+        promoted_to: promoted_to.map(str::to_string),
+    }
+}
+
+#[test]
+fn verdict_reads_status_and_target() {
+    let text =
+        "# Plan\n\n## Verdict\n- status: promote\n- conclusion: worth it\n- promoted-to: 044\n";
+    assert_eq!(parse_verdict(text), v(Some("promote"), Some("044")));
+}
+
+#[test]
+fn verdict_absent_section_is_empty() {
+    assert_eq!(parse_verdict(""), v(None, None));
+    assert_eq!(
+        parse_verdict("## Overview\n- status: open\n"),
+        v(None, None)
+    );
+}
+
+// Hostile half first: a `status:` outside the section — above all the frontmatter's
+// lifecycle status — must never be read as the verdict.
+#[test]
+fn verdict_never_reads_a_status_outside_the_section() {
+    let text = "---\nscale: spike\nstatus: done\n---\n\n# Plan\n- status: open\n\n## Verdict\n- conclusion: none yet\n";
+    assert_eq!(parse_verdict(text), v(None, None));
+}
+
+#[test]
+fn verdict_heading_variants_that_open_the_section() {
+    for heading in [
+        "## Verdict",
+        "## verdict",
+        "### Verdict",
+        "#### verdict",
+        "## Verdict (2026-10-01)",
+        "##  Verdict",
+        "##\tVerdict",
+        "## Verdict ",
+    ] {
+        let text = format!("{heading}\n- status: wont-do\n");
+        assert_eq!(
+            parse_verdict(&text),
+            v(Some("wont-do"), None),
+            "{heading:?}"
+        );
+    }
+}
+
+// Hostile half: headings that look like a verdict heading and are not.
+#[test]
+fn verdict_heading_lookalikes_do_not_open_the_section() {
+    for heading in [
+        "## Verdict:",
+        "## Verdicts",
+        "## VERDICT",
+        "## The Verdict",
+        "# Verdict",
+        "##Verdict",
+        "Verdict",
+        "**Verdict**",
+    ] {
+        let text = format!("{heading}\n- status: wont-do\n");
+        assert_eq!(parse_verdict(&text), v(None, None), "{heading:?}");
+    }
+}
+
+#[test]
+fn verdict_section_ends_at_the_next_level_two_or_deeper_heading() {
+    let text = "## Verdict\n- conclusion: x\n## Notes\n- status: promote\n";
+    assert_eq!(parse_verdict(text), v(None, None));
+    let text = "## Verdict\n- conclusion: x\n### Sub\n- status: promote\n";
+    assert_eq!(parse_verdict(text), v(None, None));
+}
+
+#[test]
+fn verdict_a_level_one_heading_does_not_end_the_section() {
+    let text = "## Verdict\n# Aside\n- status: open\n";
+    assert_eq!(parse_verdict(text), v(Some("open"), None));
+}
+
+#[test]
+fn verdict_a_later_verdict_section_fills_what_the_first_lacked() {
+    let text = "## Verdict\n- conclusion: x\n## Notes\n- status: no\n## Verdict\n- status: promote\n- promoted-to: 12\n";
+    assert_eq!(parse_verdict(text), v(Some("promote"), Some("12")));
+}
+
+#[test]
+fn verdict_status_is_cut_at_whitespace_or_pipe() {
+    // The template line itself: the first alternative is what is read.
+    assert_eq!(
+        parse_verdict("## Verdict\n- status: open | promote | wont-do\n"),
+        v(Some("open"), None)
+    );
+    assert_eq!(
+        parse_verdict("## Verdict\n- status: promote|x\n"),
+        v(Some("promote"), None)
+    );
+    assert_eq!(
+        parse_verdict("## Verdict\n- status: wont-do (decided)\n"),
+        v(Some("wont-do"), None)
+    );
+}
+
+#[test]
+fn verdict_status_line_shapes() {
+    for line in [
+        "status: open",
+        "- status: open",
+        "  - status:open",
+        "\t-\tstatus:   open",
+    ] {
+        let text = format!("## Verdict\n{line}\n");
+        assert_eq!(parse_verdict(&text), v(Some("open"), None), "{line:?}");
+    }
+    for line in [
+        "- substatus: open",
+        "- **status**: open",
+        "* status: open",
+        "-status: open",
+    ] {
+        let text = format!("## Verdict\n{line}\n");
+        assert_eq!(parse_verdict(&text), v(None, None), "{line:?}");
+    }
+}
+
+#[test]
+fn verdict_first_status_wins() {
+    let text = "## Verdict\n- status: open\n- status: promote\n";
+    assert_eq!(parse_verdict(text), v(Some("open"), None));
+}
+
+// Contrast with front_value: an empty `status:` does not match status_re, so it does not take
+// the slot, and the next status line is read.
+#[test]
+fn verdict_an_empty_status_line_does_not_take_the_slot() {
+    let text = "## Verdict\n- status:\n- status: promote\n";
+    assert_eq!(parse_verdict(text), v(Some("promote"), None));
+}
+
+// Hostile half: the template placeholder is not a recorded target, and does not take the slot.
+#[test]
+fn verdict_promoted_to_must_start_with_a_digit() {
+    assert_eq!(
+        parse_verdict("## Verdict\n- status: promote\n- promoted-to: NNN\n"),
+        v(Some("promote"), None)
+    );
+    assert_eq!(
+        parse_verdict(
+            "## Verdict\n- status: promote\n- promoted-to: NNN   # required\n- promoted-to: 7\n"
+        ),
+        v(Some("promote"), Some("7"))
+    );
+    assert_eq!(
+        parse_verdict("## Verdict\n- status: promote\n- promoted-to: #12\n"),
+        v(Some("promote"), None)
+    );
+}
+
+#[test]
+fn verdict_promoted_to_is_cut_at_whitespace_or_hash() {
+    assert_eq!(
+        parse_verdict("## Verdict\n- promoted-to: 012#note\n- status: promote\n"),
+        v(Some("promote"), Some("012"))
+    );
+    assert_eq!(
+        parse_verdict("## Verdict\n- promoted-to: 12-foo bar\n"),
+        v(None, Some("12-foo"))
+    );
+    assert_eq!(
+        parse_verdict("## Verdict\n- promoted-to: 12|x\n"),
+        v(None, Some("12|x")),
+        "unlike status, a pipe does not end promoted-to"
+    );
+}
+
+#[test]
+fn verdict_of_real_corpus_sections() {
+    // .epico/archive/006-measure-the-resumed-load-path/tasks.md:162-164
+    let text = "## Verdict\n- status: open\n- conclusion: not yet measured\n";
+    assert_eq!(parse_verdict(text), v(Some("open"), None));
+    // .epico/archive/030-deleted-task-record-survives-restart/tasks.md:62-63, conclusion shortened.
+    let text = "## Verdict\n- status: wont-do\n- conclusion: The record does not come back in practice. status: promote | open\n";
+    assert_eq!(parse_verdict(text), v(Some("wont-do"), None));
+}
diff --git a/crates/epic_panel/src/never_writes_tests.rs b/crates/epic_panel/src/never_writes_tests.rs
new file mode 100644
index 0000000..8e00df6
--- /dev/null
+++ b/crates/epic_panel/src/never_writes_tests.rs
@@ -0,0 +1,463 @@
+// Story 042, task 3.2 — the panel never writes and never starts a process (R6.5, R6.6).
+//
+// Mounted from the crate root `src/epic_panel.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "never_writes_tests.rs"]
+//     pub(crate) mod never_writes_tests;
+//
+// so the Validation filter `cargo test -p epic_panel never_writes` selects it. `build_corpus`
+// and `tree_state` are `pub(crate)` because the panel-session no-write test (task 5.4,
+// `panel_live_tests.rs`) reuses them.
+//
+// Two guards:
+//   * behavioural — a corpus holding every variant of design.md §Testing Strategy, built in a
+//     temp dir at test time (symlinks and a FIFO included), scanned twice on RealFs; every
+//     path's kind, size, mtime, content and link target must be identical afterwards;
+//   * static — the crate's non-test sources name no mutating `Fs` method, no `std::fs`, no
+//     process API. Its matcher is itself tested in both directions, so a guard that matches
+//     nothing cannot pass silently.
+//
+// Dev-dependencies: fs, gpui (test-support), tempfile; `regex` is a regular dependency.
+
+#![allow(dead_code)]
+
+use std::{
+    collections::BTreeMap,
+    fs as stdfs,
+    io::Read as _,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::SystemTime,
+};
+
+use fs::Fs;
+use gpui::TestAppContext;
+use regex::Regex;
+
+use crate::scan::scan_epic_dir;
+
+// ── the corpus ──────────────────────────────────────────────────────────────
+
+fn write(root: &Path, relative: &str, content: &[u8]) {
+    let file = root.join(relative);
+    let parent = file.parent().expect("a relative file has a parent");
+    stdfs::create_dir_all(parent).unwrap_or_else(|e| panic!("create {}: {e}", parent.display()));
+    stdfs::write(&file, content).unwrap_or_else(|e| panic!("write {}: {e}", file.display()));
+}
+
+fn mkdir(root: &Path, relative: &str) {
+    let dir = root.join(relative);
+    stdfs::create_dir_all(&dir).unwrap_or_else(|e| panic!("create {}: {e}", dir.display()));
+}
+
+fn symlink(target: &Path, link: &Path) {
+    std::os::unix::fs::symlink(target, link)
+        .unwrap_or_else(|e| panic!("symlink {} -> {}: {e}", link.display(), target.display()));
+}
+
+/// Builds `<root>/project/.epico` with one story directory per variant, plus an `outside/`
+/// directory the symlinks point into. Returns the `.epico` path.
+pub(crate) fn build_corpus(root: &Path) -> PathBuf {
+    let epic = root.join("project/.epico");
+    let fm = |pairs: &[(&str, &str)], body: &str| {
+        let mut text = String::from("---\n");
+        for (key, value) in pairs {
+            text.push_str(&format!("{key}: {value}\n"));
+        }
+        text.push_str("---\n\n");
+        text.push_str(body);
+        text.into_bytes()
+    };
+    write(
+        &epic,
+        "EPICO.md",
+        b"# Epic\n\n<!-- epico:index:start -->\n<!-- epico:index:end -->\n",
+    );
+    write(
+        &epic,
+        "stories/001-normal/story.md",
+        &fm(
+            &[("status", "in-progress"), ("type", "feature")],
+            "# Story - Normal\n",
+        ),
+    );
+    write(
+        &epic,
+        "stories/001-normal/tasks.md",
+        b"- [x] a\n- [ ] b\n- [~] c (deferred: d)\n",
+    );
+    write(
+        &epic,
+        "stories/002-spike/tasks.md",
+        &fm(
+            &[("scale", "spike")],
+            "## Verdict\n- status: promote\n- promoted-to: 3\n",
+        ),
+    );
+    write(
+        &epic,
+        "stories/004-no-status/story.md",
+        &fm(&[("type", "bug")], "# Story - No status\n"),
+    );
+    write(
+        &epic,
+        "stories/005-unknown/story.md",
+        &fm(&[("status", "blocked")], ""),
+    );
+    write(
+        &epic,
+        "stories/006-superseded/story.md",
+        &fm(&[("status", "superseded"), ("superseded-by", "3")], ""),
+    );
+    write(
+        &epic,
+        "stories/007-dangling/story.md",
+        &fm(&[("status", "superseded"), ("superseded-by", "999")], ""),
+    );
+    write(&epic, "stories/notes/tasks.md", b"- [ ] unnumbered\n");
+    mkdir(&epic, "stories/008-empty/.draft");
+    write(&epic, "stories/009-binary/tasks.md", b"- [x] a\n\xff\xfe\n");
+    let mut large = b"- [x] a\n".to_vec();
+    large.resize(1024 * 1024 + 1, b' ');
+    write(&epic, "stories/010-large/tasks.md", &large);
+    write(
+        &epic,
+        "archive/003-archived/story.md",
+        &fm(&[("status", "archived")], "# Story - Archived\n"),
+    );
+    write(&epic, "archive/manifest.yaml", b"entries: []\n");
+
+    write(
+        root,
+        "outside/real-story/story.md",
+        &fm(&[("status", "validated")], ""),
+    );
+    write(root, "outside/secret.md", b"- [x] secret\n");
+    symlink(
+        &root.join("outside/real-story"),
+        &epic.join("stories/011-linked-dir"),
+    );
+    write(
+        &epic,
+        "stories/012-linked-file/story.md",
+        &fm(&[("status", "done")], ""),
+    );
+    symlink(
+        &root.join("outside/secret.md"),
+        &epic.join("stories/012-linked-file/tasks.md"),
+    );
+    epic
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) enum Kind {
+    File { len: u64, content: Vec<u8> },
+    Dir,
+    Symlink { target: PathBuf },
+    Other,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) struct EntryState {
+    kind: Kind,
+    mtime: SystemTime,
+}
+
+/// Every path under `root`, never following a symlink and never opening a FIFO.
+pub(crate) fn tree_state(root: &Path) -> BTreeMap<PathBuf, EntryState> {
+    fn walk(root: &Path, path: &Path, out: &mut BTreeMap<PathBuf, EntryState>) {
+        let meta = stdfs::symlink_metadata(path)
+            .unwrap_or_else(|e| panic!("symlink_metadata {}: {e}", path.display()));
+        let mtime = meta
+            .modified()
+            .unwrap_or_else(|e| panic!("mtime {}: {e}", path.display()));
+        let file_type = meta.file_type();
+        let kind = if file_type.is_symlink() {
+            Kind::Symlink {
+                target: stdfs::read_link(path)
+                    .unwrap_or_else(|e| panic!("read_link {}: {e}", path.display())),
+            }
+        } else if file_type.is_dir() {
+            let entries = stdfs::read_dir(path)
+                .unwrap_or_else(|e| panic!("read_dir {}: {e}", path.display()));
+            for entry in entries {
+                let entry = entry.unwrap_or_else(|e| panic!("entry of {}: {e}", path.display()));
+                walk(root, &entry.path(), out);
+            }
+            Kind::Dir
+        } else if file_type.is_file() {
+            let mut content = Vec::new();
+            stdfs::File::open(path)
+                .and_then(|mut file| file.read_to_end(&mut content))
+                .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
+            Kind::File {
+                len: meta.len(),
+                content,
+            }
+        } else {
+            Kind::Other
+        };
+        let relative = path.strip_prefix(root).unwrap_or(path).to_path_buf();
+        out.insert(relative, EntryState { kind, mtime });
+    }
+    let mut out = BTreeMap::new();
+    walk(root, root, &mut out);
+    out
+}
+
+fn assert_same_tree(before: &BTreeMap<PathBuf, EntryState>, after: &BTreeMap<PathBuf, EntryState>) {
+    let before_names: Vec<_> = before.keys().collect();
+    let after_names: Vec<_> = after.keys().collect();
+    assert_eq!(
+        after_names, before_names,
+        "R6.5: no path was created, removed or renamed"
+    );
+    for (path, state) in before {
+        assert_eq!(
+            after.get(path),
+            Some(state),
+            "R6.5: {} changed",
+            path.display()
+        );
+    }
+}
+
+#[gpui::test]
+async fn never_writes_a_scanned_corpus(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    let temp = tempfile::tempdir().expect("temp dir");
+    let epic = build_corpus(temp.path());
+
+    #[cfg(target_os = "linux")]
+    let fifo = {
+        let fifo = epic.join("stories/013-fifo/tasks.md");
+        mkdir(&epic, "stories/013-fifo");
+        let made = std::process::Command::new("mkfifo")
+            .arg(&fifo)
+            .status()
+            .expect("mkfifo runs");
+        assert!(made.success(), "mkfifo {}", fifo.display());
+        fifo
+    };
+    #[cfg(target_os = "linux")]
+    let (done, unblocker) = {
+        use std::os::unix::fs::OpenOptionsExt as _;
+        use std::sync::atomic::{AtomicBool, Ordering};
+        let done = Arc::new(AtomicBool::new(false));
+        let flag = done.clone();
+        let fifo = fifo.clone();
+        // A reader that opens the FIFO gets EOF instead of hanging the test.
+        let handle = std::thread::spawn(move || {
+            while !flag.load(Ordering::SeqCst) {
+                if let Ok(writer) = stdfs::OpenOptions::new()
+                    .write(true)
+                    .custom_flags(0o4000)
+                    .open(&fifo)
+                {
+                    drop(writer);
+                }
+                std::thread::sleep(std::time::Duration::from_millis(50));
+            }
+        });
+        (done, handle)
+    };
+
+    let before = tree_state(temp.path());
+    let fs: Arc<dyn Fs> = fs::RealFs::new(None, cx.executor());
+    for _ in 0..2 {
+        let snapshot = scan_epic_dir(fs.clone(), epic.clone())
+            .await
+            .expect("the corpus scans");
+        assert!(
+            snapshot.active.len() + snapshot.archived.len() >= 13,
+            "the scan saw the corpus (else this test proves nothing)"
+        );
+    }
+    let after = tree_state(temp.path());
+
+    #[cfg(target_os = "linux")]
+    {
+        done.store(true, std::sync::atomic::Ordering::SeqCst);
+        unblocker.join().expect("unblocker thread");
+    }
+    assert_same_tree(&before, &after);
+}
+
+// Hostile half for the behavioural guard: it must notice a write when one happens.
+#[test]
+fn never_writes_tree_state_notices_every_kind_of_change() {
+    let temp = tempfile::tempdir().expect("temp dir");
+    build_corpus(temp.path());
+    let base = tree_state(temp.path());
+
+    let epic = temp.path().join("project/.epico");
+    write(&epic, "stories/001-normal/tasks.md", b"- [x] a\n- [x] b\n");
+    assert_ne!(tree_state(temp.path()), base, "a content change is noticed");
+
+    let temp = tempfile::tempdir().expect("temp dir");
+    build_corpus(temp.path());
+    let base = tree_state(temp.path());
+    write(&temp.path().join("project/.epico"), ".epic-index.tmp", b"");
+    assert_ne!(tree_state(temp.path()), base, "a new file is noticed");
+
+    let temp = tempfile::tempdir().expect("temp dir");
+    build_corpus(temp.path());
+    let base = tree_state(temp.path());
+    let epic = temp.path().join("project/.epico");
+    stdfs::rename(
+        epic.join("stories/004-no-status"),
+        epic.join("archive/004-no-status"),
+    )
+    .expect("rename");
+    assert_ne!(tree_state(temp.path()), base, "a move is noticed");
+}
+
+// ── the static guard ────────────────────────────────────────────────────────
+
+fn forbidden_patterns() -> Vec<(Regex, &'static str)> {
+    [
+        (r"\bstd::fs\b", "std::fs"),
+        (r"\bstd::\{[^}]*\bfs\b", "std::fs (grouped import)"),
+        (r"\bstd::process\b", "std::process"),
+        (r"\bstd::\{[^}]*\bprocess\b", "std::process (grouped import)"),
+        (r"\bCommand::new\b", "Command::new"),
+        (r"\b(smol|tokio|async_std)::(fs|process)\b", "async fs/process"),
+        (r"\bnew_(smol|std)_command\b", "util::command"),
+        (
+            r"\.\s*(create_dir|create_symlink|create_file|create_file_with|extract_tar_file|copy_file|rename|remove_dir|remove_file|trash|atomic_write|save|write|git_init|git_clone|git_config)\s*\(",
+            "a mutating Fs method",
+        ),
+    ]
+    .into_iter()
+    .map(|(pattern, name)| (Regex::new(pattern).expect("a valid guard pattern"), name))
+    .collect()
+}
+
+/// The forbidden names a source text uses, ignoring `//` comment lines.
+fn violations(source: &str) -> Vec<String> {
+    let patterns = forbidden_patterns();
+    let mut found = Vec::new();
+    for (number, line) in source.lines().enumerate() {
+        if line.trim_start().starts_with("//") {
+            continue;
+        }
+        for (pattern, name) in &patterns {
+            if pattern.is_match(line) {
+                found.push(format!("line {}: {name}: {}", number + 1, line.trim()));
+            }
+        }
+    }
+    found
+}
+
+fn is_test_source(path: &Path) -> bool {
+    let name = path
+        .file_name()
+        .and_then(|name| name.to_str())
+        .unwrap_or_default();
+    name.ends_with("_tests.rs") || name.ends_with("test_helpers.rs")
+}
+
+fn non_test_sources() -> Vec<PathBuf> {
+    fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
+        let entries =
+            stdfs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()));
+        for entry in entries {
+            let path = entry
+                .unwrap_or_else(|e| panic!("entry of {}: {e}", dir.display()))
+                .path();
+            if path.is_dir() {
+                walk(&path, out);
+            } else if path.extension().is_some_and(|ext| ext == "rs") && !is_test_source(&path) {
+                out.push(path);
+            }
+        }
+    }
+    let mut out = Vec::new();
+    walk(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut out);
+    out.sort();
+    out
+}
+
+#[test]
+fn never_writes_static_guard_over_the_crate_sources() {
+    let sources = non_test_sources();
+    let names: Vec<String> = sources
+        .iter()
+        .filter_map(|path| {
+            path.file_name()
+                .and_then(|name| name.to_str())
+                .map(str::to_string)
+        })
+        .collect();
+    for required in ["format.rs", "scan.rs"] {
+        assert!(
+            names.contains(&required.to_string()),
+            "the guard must read {required}; it read {names:?}"
+        );
+    }
+    let mut report = Vec::new();
+    for path in &sources {
+        let source =
+            stdfs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
+        for violation in violations(&source) {
+            report.push(format!("{}: {violation}", path.display()));
+        }
+    }
+    assert!(
+        report.is_empty(),
+        "R6.5/R6.6: non-test code names a writer or a process:\n{}",
+        report.join("\n")
+    );
+}
+
+// Hostile half: the matcher fires on every forbidden shape...
+#[test]
+fn never_writes_static_guard_fires_on_forbidden_code() {
+    for sample in [
+        "use std::fs;",
+        "use std::{fs, path::Path};",
+        "use std::{path::Path, process::Stdio};",
+        "let out = std::process::Command::new(\"sh\");",
+        "let child = Command::new(\"bash\");",
+        "smol::fs::write(path, b\"x\").await?;",
+        "util::command::new_smol_command(\"git\");",
+        "fs.atomic_write(path, text).await?;",
+        "fs.save(&path, &rope, LineEnding::Unix).await?;",
+        "fs.write(&path, b\"x\").await?;",
+        "fs.rename(&a, &b, Default::default()).await?;",
+        "fs.create_dir(&dir).await?;",
+        "fs.remove_file(&path, Default::default()).await?;",
+        "fs.trash(&path, Default::default()).await?;",
+        "fs . copy_file (&a, &b, Default::default()).await?;",
+    ] {
+        assert!(
+            !violations(sample).is_empty(),
+            "the guard must flag {sample:?}"
+        );
+    }
+}
+
+// ...and stays quiet on the reads the crate is allowed, and on lookalikes.
+#[test]
+fn never_writes_static_guard_allows_reads_and_lookalikes() {
+    for sample in [
+        "let meta = fs.metadata(&path).await?;",
+        "let mut entries = fs.read_dir(&dir).await?;",
+        "let text = fs.load(&path).await?;",
+        "if fs.is_dir(&epic).await {",
+        "let (events, watcher) = fs.watch(&epic, WATCH_LATENCY).await;",
+        "kvp.write_kvp(key, value).await",
+        "// never uses std::fs or Command::new",
+        "    /// Calls no mutating Fs method such as fs.write(..)",
+        "let renamed = row.renamed_from.clone();",
+        "use std::{path::PathBuf, sync::Arc};",
+    ] {
+        assert!(
+            violations(sample).is_empty(),
+            "the guard must not flag {sample:?}: {:?}",
+            violations(sample)
+        );
+    }
+}
diff --git a/crates/epic_panel/src/panel.rs b/crates/epic_panel/src/panel.rs
new file mode 100644
index 0000000..4892a4c
--- /dev/null
+++ b/crates/epic_panel/src/panel.rs
@@ -0,0 +1,1628 @@
+//! `EpicPanel`: the dock panel that lists the stories under each local worktree's `.epic/`.
+//!
+//! The panel only reads. It looks for `.epic/` at the root of every local worktree (A6), keeps
+//! one `WorktreeEpic` per worktree that has one, and shows its dock button only while at least
+//! one exists (R1.2). The worktree scanner never loads the inside of a gitignored directory, so
+//! worktree events are used only to notice the root entry `.epic` appearing or disappearing;
+//! the contents are followed by `EpicWatch`.
+//!
+//! The dock the panel sits in is saved in the key-value store under the panel's key, as the
+//! Agent Tasks panel does: the workspace saves each panel's size, but a panel's dock only
+//! through that panel's settings, and this panel has none.
+
+use std::{
+    ops::Range,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+use anyhow::Result;
+use collections::HashMap;
+use db::kvp::KeyValueStore;
+use fs::Fs;
+use futures::{StreamExt as _, channel::mpsc};
+use gpui::{
+    Action, AnyElement, App, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle,
+    Focusable, IntoElement, Pixels, Render, ScrollStrategy, SharedString, Subscription, Task,
+    UniformListScrollHandle, WeakEntity, Window, actions, px, uniform_list,
+};
+use project::{Project, WorktreeId};
+use settings::SettingsStore;
+use ui::{Banner, ListItem, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, prelude::*};
+use util::ResultExt as _;
+use workspace::{
+    OpenOptions, OpenVisible, Workspace,
+    dock::{DockPosition, Panel, PanelEvent},
+    workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError},
+};
+
+use crate::format::{StatusCell, progress_text};
+use crate::scan::{
+    Area, DIRECTORY, EPIC_DIR_NAMES, EpicSnapshot, MAX_FILE_BYTES, RowProblem, StoryRow,
+    index_file_name, scan_epic_dir,
+};
+use crate::watch::EpicWatch;
+
+actions!(
+    epic_panel,
+    [
+        /// Toggles focus on the Epico Stories panel.
+        ToggleFocus,
+        /// Reads every worktree's `.epico/` (or legacy `.epic/`) again.
+        Refresh
+    ]
+);
+
+const NO_EPIC_DIR: &str = "No .epico/ directory in this project";
+const NO_STORIES: &str = "No stories yet";
+/// The absent number, status or progress: U+2014, as the plugin writes it.
+const ABSENT: &str = "\u{2014}";
+/// The right gutter every list row keeps, so the always-visible scrollbar never covers the
+/// progress or the badges. It is the `ui` scrollbar's track: the `ScrollbarStyle::Regular`
+/// thumb (6px) plus the 4px it is inset by on each side (`SCROLLBAR_PADDING`, private to
+/// `ui`). `Pixels` has no const arithmetic, so the sum is spelled out here;
+/// `panel_polish_tests` recomputes it from `ScrollbarStyle::Regular` and fails on drift.
+const ROW_END_PADDING: Pixels = px(14.);
+const UNKNOWN_STATUS: &str = "Status not known to this Zeo; the epic plugin may be newer";
+
+/// Registers the panel's `ToggleFocus` on every workspace created from now on. No default
+/// keybinding (A3): the palette action and the dock button are enough.
+pub fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+        workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
+            workspace.toggle_panel_focus::<EpicPanel>(window, cx);
+        });
+    })
+    .detach();
+}
+
+/// One line of the panel, in display order. `worktree` indexes the panel's worktrees, `row`
+/// the snapshot's rows of `area`.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum DisplayEntry {
+    /// Shown only when more than one worktree has `.epic/` (R2.5).
+    WorktreeHeader { worktree: usize },
+    /// "Open EPIC.md" (R2.8).
+    OpenIndex { worktree: usize },
+    Story {
+        worktree: usize,
+        area: Area,
+        row: usize,
+    },
+    /// "Archived (N)", collapsed on creation (R2.3, R2.4).
+    ArchivedHeader {
+        worktree: usize,
+        count: usize,
+        expanded: bool,
+    },
+    /// "N more not shown" (R2.12).
+    MoreNotShown { worktree: usize, count: usize },
+    /// A scan error or a snapshot note, at the top of that worktree's section.
+    WorktreeError { worktree: usize, message: String },
+    /// R1.4, R2.11.
+    Empty { message: &'static str },
+}
+
+impl DisplayEntry {
+    /// The worktree whose section holds this line; `None` for an `Empty` line, which does not
+    /// say (see `EpicPanel::empty_line_worktree`).
+    fn worktree(&self) -> Option<usize> {
+        match self {
+            DisplayEntry::WorktreeHeader { worktree }
+            | DisplayEntry::OpenIndex { worktree }
+            | DisplayEntry::Story { worktree, .. }
+            | DisplayEntry::ArchivedHeader { worktree, .. }
+            | DisplayEntry::MoreNotShown { worktree, .. }
+            | DisplayEntry::WorktreeError { worktree, .. } => Some(*worktree),
+            DisplayEntry::Empty { .. } => None,
+        }
+    }
+}
+
+/// What a selected entry is, independent of where it sits: the list is rebuilt on every
+/// scan, so the selection is found again by this key rather than kept as an index.
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum EntryKey {
+    WorktreeHeader(WorktreeId),
+    OpenIndex(WorktreeId),
+    /// The area too: `stories/X` and `archive/X` can exist side by side.
+    Story(WorktreeId, Area, String),
+    ArchivedHeader(WorktreeId),
+    MoreNotShown(WorktreeId),
+    WorktreeError(WorktreeId, String),
+    /// `None` for the project-wide "No .epic/ directory" line.
+    Empty(Option<WorktreeId>, &'static str),
+}
+
+struct Selection {
+    index: usize,
+    key: EntryKey,
+}
+
+/// What the panel holds for one local worktree whose root has `.epic/`.
+struct WorktreeEpic {
+    worktree_id: WorktreeId,
+    name: String,
+    epic_dir: PathBuf,
+    snapshot: Option<EpicSnapshot>,
+    error: Option<String>,
+    archived_expanded: bool,
+    /// The inode of the `.epic` directory the watch was started on. A `.epic` removed and
+    /// recreated gets a new one, and the old watch no longer covers it on Linux.
+    epic_inode: u64,
+    /// Dropping the last handle ends the watch. Shared so `sync_dirs`, which makes watch
+    /// syscalls, runs on the background executor.
+    watch: Arc<EpicWatch>,
+    /// Forwards the watch's debounced changes and its failure to the panel; dropped with it.
+    _watch_events: Task<()>,
+    /// The latest `sync_dirs`; a newer one replaces the whole wanted set, so dropping this one
+    /// loses nothing.
+    _sync_dirs: Task<()>,
+    /// Why live updates stopped, shown in the banner (R5.6).
+    watch_failure: Option<String>,
+    /// The first scan logs at `info`, every later one at `debug` (R8.1).
+    logged_first_scan: bool,
+    /// The problem kind last logged per `(dir_name, file)`, so each is logged once per change
+    /// of its reason (R8.2).
+    logged_problems: HashMap<(bool, String, String), &'static str>,
+}
+
+/// What the watch's background callbacks send to the panel.
+enum WatchMessage {
+    Changed,
+    Failed(String),
+}
+
+impl WorktreeEpic {
+    /// R8.1: `stories` and `archived` count every directory found, before the row cap.
+    fn log_scan(&mut self, snapshot: &EpicSnapshot) {
+        let problems = snapshot.notes.len()
+            + snapshot
+                .active
+                .iter()
+                .chain(&snapshot.archived)
+                .filter(|row| row.problem.is_some())
+                .count();
+        let level = if self.logged_first_scan {
+            log::Level::Debug
+        } else {
+            log::Level::Info
+        };
+        self.logged_first_scan = true;
+        log::log!(
+            level,
+            "epic_panel event=scan worktree={} stories={} archived={} problems={} duration_ms={}",
+            log_value(&self.name),
+            snapshot.active_total,
+            snapshot.archived_total,
+            problems,
+            snapshot.duration.as_millis()
+        );
+    }
+
+    /// R8.2: one `warn` per problem that is new or whose kind changed since the last scan. A
+    /// problem that goes away is forgotten, so it is logged again if it returns.
+    fn log_new_problems(&mut self, snapshot: &EpicSnapshot) {
+        let mut current = HashMap::default();
+        // Keyed by area too: `stories/X` and `archive/X` are different directories.
+        let rows = snapshot
+            .active
+            .iter()
+            .map(|row| (false, row))
+            .chain(snapshot.archived.iter().map(|row| (true, row)));
+        for (archived, row) in rows {
+            let Some(problem) = &row.problem else {
+                continue;
+            };
+            let (file, kind) = problem_kind(problem);
+            let key = (archived, row.dir_name.clone(), file.to_string());
+            if self.logged_problems.get(&key) != Some(&kind) {
+                log::warn!(
+                    "epic_panel event=problem worktree={} story={} file={} reason={}",
+                    log_value(&self.name),
+                    log_value(&row.dir_name),
+                    log_value(file),
+                    kind
+                );
+            }
+            current.insert(key, kind);
+        }
+        self.logged_problems = current;
+    }
+
+    /// Hands the watch the areas that exist plus every story directory the snapshot holds, so
+    /// an `archive/` or a story directory created after the watch started is followed too. A
+    /// symlinked or unreadable directory is left out: it is never read, so never watched.
+    fn sync_watched_dirs(&self, fs: Arc<dyn Fs>, snapshot: &EpicSnapshot, cx: &App) -> Task<()> {
+        let watch = self.watch.clone();
+        let epic_dir = self.epic_dir.clone();
+        let story_dirs: Vec<PathBuf> = snapshot
+            .active
+            .iter()
+            .chain(&snapshot.archived)
+            .filter(|row| !is_directory_problem(row.problem.as_ref()))
+            .map(|row| epic_dir.join(row.area.dir_name()).join(&row.dir_name))
+            .collect();
+        cx.background_spawn(async move {
+            let mut dirs = Vec::with_capacity(story_dirs.len() + 2);
+            for area in [Area::Active, Area::Archived] {
+                let path = epic_dir.join(area.dir_name());
+                if let Ok(Some(metadata)) = fs.metadata(&path).await
+                    && metadata.is_dir
+                    && !metadata.is_symlink
+                {
+                    dirs.push(path);
+                }
+            }
+            dirs.extend(story_dirs);
+            watch.sync_dirs(&dirs);
+        })
+    }
+}
+
+pub struct EpicPanel {
+    workspace: WeakEntity<Workspace>,
+    project: Entity<Project>,
+    fs: Arc<dyn Fs>,
+    focus_handle: FocusHandle,
+    position: DockPosition,
+    /// In the project's worktree order.
+    worktrees: Vec<WorktreeEpic>,
+    /// The latest detection-and-scan per worktree. Replacing one drops, and so cancels, the
+    /// previous, so a slow read can never overwrite a newer snapshot.
+    refreshes: HashMap<WorktreeId, Task<Result<()>>>,
+    /// Cached so `icon()`, which runs on every dock render, does no I/O (R1.2).
+    has_any_epic: bool,
+    entries: Vec<DisplayEntry>,
+    selection: Option<Selection>,
+    /// Set when a rebuild moved the selection to another index; the next render scrolls it
+    /// into view while the panel has focus.
+    scroll_to_selection: bool,
+    scroll_handle: UniformListScrollHandle,
+    _save_position: Task<()>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl EpicPanel {
+    /// Builds the panel for `workspace` in its saved dock (Right when nothing was saved, A8)
+    /// and starts looking for `.epic/` in every worktree the project already has.
+    pub fn load(
+        workspace: WeakEntity<Workspace>,
+        cx: AsyncWindowContext,
+    ) -> Task<Result<Entity<Self>>> {
+        cx.spawn(async move |cx| {
+            let kvp = cx.update(|_, cx| KeyValueStore::global(cx))?;
+            let saved = cx
+                .background_spawn(async move { kvp.read_kvp(<Self as Panel>::panel_key()) })
+                .await;
+            let position = saved_position(saved);
+            workspace.update_in(cx, |workspace, _window, cx| {
+                let project = workspace.project().clone();
+                let workspace = cx.weak_entity();
+                cx.new(|cx| Self::new(workspace, project, position, cx))
+            })
+        })
+    }
+
+    fn new(
+        workspace: WeakEntity<Workspace>,
+        project: Entity<Project>,
+        position: DockPosition,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let fs = project.read(cx).fs().clone();
+        let subscription = cx.subscribe(&project, Self::handle_project_event);
+        let mut this = Self {
+            workspace,
+            project,
+            fs,
+            focus_handle: cx.focus_handle(),
+            position,
+            worktrees: Vec::new(),
+            refreshes: HashMap::default(),
+            has_any_epic: false,
+            entries: Vec::new(),
+            selection: None,
+            scroll_to_selection: false,
+            scroll_handle: UniformListScrollHandle::new(),
+            _save_position: Task::ready(()),
+            _subscriptions: vec![subscription],
+        };
+        this.refresh_all(cx);
+        this.rebuild_entries();
+        this
+    }
+
+    fn handle_project_event(
+        &mut self,
+        _project: Entity<Project>,
+        event: &project::Event,
+        cx: &mut Context<Self>,
+    ) {
+        match event {
+            project::Event::WorktreeAdded(worktree_id)
+            | project::Event::WorktreeRemoved(worktree_id) => {
+                self.refresh_worktree(*worktree_id, cx);
+            }
+            project::Event::WorktreeUpdatedEntries(worktree_id, changes)
+                if changes
+                    .iter()
+                    .any(|(path, _, _)| EPIC_DIR_NAMES.contains(&path.as_unix_str())) =>
+            {
+                self.refresh_worktree(*worktree_id, cx);
+            }
+            project::Event::WorktreeOrderChanged => {
+                self.sort_worktrees(cx);
+                self.rebuild_entries();
+                cx.notify();
+            }
+            _ => {}
+        }
+    }
+
+    /// Checks whether the worktree's root holds `.epic/`, starts or stops its `WorktreeEpic`
+    /// accordingly, and reads it when present. Only local, visible worktrees are looked at: a
+    /// hidden one is a single file opened from outside the project, not a project root.
+    fn refresh_worktree(&mut self, worktree_id: WorktreeId, cx: &mut Context<Self>) {
+        let worktree = self
+            .project
+            .read(cx)
+            .worktree_for_id(worktree_id, cx)
+            .filter(|worktree| {
+                let worktree = worktree.read(cx);
+                worktree.is_local() && worktree.is_visible()
+            });
+        let Some(worktree) = worktree else {
+            self.refreshes.remove(&worktree_id);
+            self.stop_worktree(worktree_id, cx);
+            return;
+        };
+        let (name, root) = {
+            let worktree = worktree.read(cx);
+            (worktree.root_name_str().to_string(), worktree.abs_path())
+        };
+        let fs = self.fs.clone();
+        let task = cx.spawn(async move |this, cx| {
+            let found = cx
+                .background_spawn({
+                    let fs = fs.clone();
+                    let name = name.clone();
+                    async move {
+                        for dir_name in EPIC_DIR_NAMES {
+                            let epic_dir = root.join(dir_name);
+                            if let Some(inode) = epic_dir_inode(fs.as_ref(), &epic_dir, &name).await
+                            {
+                                return Some((epic_dir, inode));
+                            }
+                        }
+                        None
+                    }
+                })
+                .await;
+            let Some((epic_dir, inode)) = found else {
+                return this.update(cx, |panel, cx| panel.stop_worktree(worktree_id, cx));
+            };
+            this.update(cx, |panel, cx| {
+                panel.start_worktree(worktree_id, name, epic_dir.clone(), inode, cx)
+            })?;
+            let result = cx.background_spawn(scan_epic_dir(fs, epic_dir)).await;
+            this.update(cx, |panel, cx| panel.apply_scan(worktree_id, result, cx))
+        });
+        self.refreshes.insert(worktree_id, task);
+    }
+
+    /// Creates the worktree's `WorktreeEpic` and its watch, unless one already watches this
+    /// same `.epic` directory. A `.epic` recreated under the same path (a new inode) gets a
+    /// new watch, since the old one stopped covering it.
+    fn start_worktree(
+        &mut self,
+        worktree_id: WorktreeId,
+        name: String,
+        epic_dir: PathBuf,
+        epic_inode: u64,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(index) = self
+            .worktrees
+            .iter()
+            .position(|epic| epic.worktree_id == worktree_id)
+        {
+            if self.worktrees[index].epic_inode == epic_inode {
+                self.worktrees[index].name = name;
+                return;
+            }
+            self.worktrees.remove(index);
+        }
+        // The watch's callbacks run on a background thread, and may run once after the watch
+        // is dropped; they only send, and the receiving task holds a weak handle to the panel.
+        // A closed channel means the panel is gone: nothing to tell.
+        let (messages_tx, mut messages_rx) = mpsc::unbounded::<WatchMessage>();
+        let failures_tx = messages_tx.clone();
+        let watch = EpicWatch::start(
+            self.fs.clone(),
+            epic_dir.clone(),
+            move || {
+                messages_tx.unbounded_send(WatchMessage::Changed).ok();
+            },
+            move |reason| {
+                failures_tx
+                    .unbounded_send(WatchMessage::Failed(reason))
+                    .ok();
+            },
+            cx,
+        );
+        let watch_events = cx.spawn(async move |this, cx| {
+            while let Some(message) = messages_rx.next().await {
+                let handled = this.update(cx, |panel, cx| match message {
+                    WatchMessage::Changed => panel.refresh_worktree(worktree_id, cx),
+                    WatchMessage::Failed(reason) => panel.watch_failed(worktree_id, reason, cx),
+                });
+                if handled.is_err() {
+                    break;
+                }
+            }
+        });
+        self.worktrees.push(WorktreeEpic {
+            worktree_id,
+            name,
+            epic_dir,
+            snapshot: None,
+            error: None,
+            archived_expanded: false,
+            epic_inode,
+            watch: Arc::new(watch),
+            _watch_events: watch_events,
+            _sync_dirs: Task::ready(()),
+            watch_failure: None,
+            logged_first_scan: false,
+            logged_problems: HashMap::default(),
+        });
+        self.sort_worktrees(cx);
+        self.epic_presence_changed(cx);
+    }
+
+    fn stop_worktree(&mut self, worktree_id: WorktreeId, cx: &mut Context<Self>) {
+        let before = self.worktrees.len();
+        self.worktrees
+            .retain(|epic| epic.worktree_id != worktree_id);
+        if self.worktrees.len() != before {
+            self.epic_presence_changed(cx);
+        }
+    }
+
+    /// Lands a scan: the snapshot replaces the previous one, and the watch is told about every
+    /// directory that now exists, so an area or a story directory created later is followed.
+    /// A failed scan drops the previous snapshot and shows the error; the watch keeps running,
+    /// so a later fix shows up on its own.
+    fn apply_scan(
+        &mut self,
+        worktree_id: WorktreeId,
+        result: Result<EpicSnapshot>,
+        cx: &mut Context<Self>,
+    ) {
+        let fs = self.fs.clone();
+        let Some(epic) = self
+            .worktrees
+            .iter_mut()
+            .find(|epic| epic.worktree_id == worktree_id)
+        else {
+            return;
+        };
+        match result {
+            Ok(snapshot) => {
+                epic.log_scan(&snapshot);
+                epic.log_new_problems(&snapshot);
+                epic._sync_dirs = epic.sync_watched_dirs(fs, &snapshot, cx);
+                epic.snapshot = Some(snapshot);
+                epic.error = None;
+            }
+            Err(error) => {
+                let reason = scan_error_text(&error);
+                log::warn!(
+                    "epic_panel event=scan_failed worktree={} reason={}",
+                    log_value(&epic.name),
+                    log_value(&reason)
+                );
+                epic.snapshot = None;
+                epic.error = Some(reason);
+            }
+        }
+        self.rebuild_entries();
+        cx.notify();
+    }
+
+    /// The watch reports at most one failure; it is kept for the banner and logged once (R5.6,
+    /// R8.3). The list and Refresh keep working.
+    fn watch_failed(&mut self, worktree_id: WorktreeId, reason: String, cx: &mut Context<Self>) {
+        let Some(epic) = self
+            .worktrees
+            .iter_mut()
+            .find(|epic| epic.worktree_id == worktree_id)
+        else {
+            return;
+        };
+        if epic.watch_failure.is_some() {
+            return;
+        }
+        let reason = without_absolute_path(reason, "the watch failed");
+        log::warn!(
+            "epic_panel event=watch_failed worktree={} reason={}",
+            log_value(&epic.name),
+            log_value(&reason)
+        );
+        epic.watch_failure = Some(reason);
+        cx.notify();
+    }
+
+    /// "Live updates are off: <reason>. Use Refresh." when the project has one worktree with
+    /// `.epic/`. With several, one such line per failing worktree, prefixed with its name, so
+    /// the banner says which one stopped even when only one did (R5.6).
+    pub fn banner_text(&self) -> Option<String> {
+        let several = self.worktrees.len() > 1;
+        let lines: Vec<String> = self
+            .worktrees
+            .iter()
+            .filter_map(|epic| {
+                let line = format!(
+                    "Live updates are off: {}. Use Refresh.",
+                    epic.watch_failure.as_deref()?
+                );
+                Some(if several {
+                    format!("{}: {line}", epic.name)
+                } else {
+                    line
+                })
+            })
+            .collect();
+        (!lines.is_empty()).then(|| lines.join("\n"))
+    }
+
+    /// Updates the cached dock-button state and redraws; the dock observes the panel, so its
+    /// button bar re-reads `icon()` (R1.2, R1.3, R5.4).
+    fn epic_presence_changed(&mut self, cx: &mut Context<Self>) {
+        self.has_any_epic = !self.worktrees.is_empty();
+        self.rebuild_entries();
+        cx.notify();
+    }
+
+    fn sort_worktrees(&mut self, cx: &App) {
+        let order: HashMap<WorktreeId, usize> = self
+            .project
+            .read(cx)
+            .worktrees(cx)
+            .enumerate()
+            .map(|(index, worktree)| (worktree.read(cx).id(), index))
+            .collect();
+        self.worktrees
+            .sort_by_key(|epic| order.get(&epic.worktree_id).copied().unwrap_or(usize::MAX));
+    }
+
+    fn refresh(&mut self, _: &Refresh, _window: &mut Window, cx: &mut Context<Self>) {
+        self.refresh_all(cx);
+    }
+
+    /// Looks for `.epic/` again in every worktree the project has.
+    fn refresh_all(&mut self, cx: &mut Context<Self>) {
+        let worktree_ids: Vec<WorktreeId> = self
+            .project
+            .read(cx)
+            .worktrees(cx)
+            .map(|worktree| worktree.read(cx).id())
+            .collect();
+        for worktree_id in worktree_ids {
+            self.refresh_worktree(worktree_id, cx);
+        }
+    }
+
+    /// Flattens the worktrees' snapshots into the lines the panel shows, then finds the
+    /// selection again in the new list.
+    fn rebuild_entries(&mut self) {
+        let mut entries = Vec::new();
+        if self.worktrees.is_empty() {
+            entries.push(DisplayEntry::Empty {
+                message: NO_EPIC_DIR,
+            });
+        }
+        let several = self.worktrees.len() > 1;
+        for (worktree, epic) in self.worktrees.iter().enumerate() {
+            if several {
+                entries.push(DisplayEntry::WorktreeHeader { worktree });
+            }
+            if let Some(message) = &epic.error {
+                entries.push(DisplayEntry::WorktreeError {
+                    worktree,
+                    message: message.clone(),
+                });
+            }
+            let Some(snapshot) = &epic.snapshot else {
+                continue;
+            };
+            for note in &snapshot.notes {
+                entries.push(DisplayEntry::WorktreeError {
+                    worktree,
+                    message: note.clone(),
+                });
+            }
+            if snapshot.has_index {
+                entries.push(DisplayEntry::OpenIndex { worktree });
+            }
+            // The totals are counted before the row cap, so a capped tree is never "empty".
+            if snapshot.active_total == 0 && snapshot.archived_total == 0 {
+                entries.push(DisplayEntry::Empty {
+                    message: NO_STORIES,
+                });
+            }
+            entries.extend((0..snapshot.active.len()).map(|row| DisplayEntry::Story {
+                worktree,
+                area: Area::Active,
+                row,
+            }));
+            if snapshot.archived_total > 0 {
+                // R2.3: N is every archived story directory, including those past the cap.
+                entries.push(DisplayEntry::ArchivedHeader {
+                    worktree,
+                    count: snapshot.archived_total,
+                    expanded: epic.archived_expanded,
+                });
+                if epic.archived_expanded {
+                    entries.extend((0..snapshot.archived.len()).map(|row| DisplayEntry::Story {
+                        worktree,
+                        area: Area::Archived,
+                        row,
+                    }));
+                }
+            }
+            if snapshot.not_shown > 0 {
+                entries.push(DisplayEntry::MoreNotShown {
+                    worktree,
+                    count: snapshot.not_shown,
+                });
+            }
+        }
+        self.entries = entries;
+        self.restore_selection();
+    }
+
+    /// Finds the selected entry in the rebuilt list by its key. A story found only in the other
+    /// area (it was archived, or restored) keeps the selection there; one that moved into a
+    /// collapsed Archived group selects that group's header; an entry that is gone hands the
+    /// selection to whatever now sits at its place, so the keyboard keeps its position.
+    fn restore_selection(&mut self) {
+        let Some(selection) = self.selection.take() else {
+            return;
+        };
+        let previous = selection.index;
+        self.selection = if let Some(index) = self.position_of(|key| *key == selection.key) {
+            Some(Selection {
+                index,
+                key: selection.key,
+            })
+        } else if let Some(index) = self.story_in_other_area(&selection.key) {
+            self.selection_at(index)
+        } else if let Some(index) = self.collapsed_archive_holding(&selection.key) {
+            Some(Selection {
+                index,
+                key: selection.key,
+            })
+        } else {
+            self.selection_at(previous.min(self.entries.len().saturating_sub(1)))
+        };
+        if self
+            .selection
+            .as_ref()
+            .is_some_and(|selection| selection.index != previous)
+        {
+            self.scroll_to_selection = true;
+        }
+    }
+
+    fn position_of(&self, matches: impl Fn(&EntryKey) -> bool) -> Option<usize> {
+        (0..self.entries.len())
+            .position(|index| self.entry_key(index).is_some_and(|key| matches(&key)))
+    }
+
+    /// The index of the story `key` names, shown in either area.
+    fn story_in_other_area(&self, key: &EntryKey) -> Option<usize> {
+        let EntryKey::Story(worktree_id, _, dir_name) = key else {
+            return None;
+        };
+        self.position_of(|candidate| {
+            matches!(candidate, EntryKey::Story(id, _, name) if id == worktree_id && name == dir_name)
+        })
+    }
+
+    /// The index of the collapsed Archived header whose group holds the story `key` names.
+    fn collapsed_archive_holding(&self, key: &EntryKey) -> Option<usize> {
+        let EntryKey::Story(worktree_id, _, dir_name) = key else {
+            return None;
+        };
+        let (worktree, epic) = self
+            .worktrees
+            .iter()
+            .enumerate()
+            .find(|(_, epic)| epic.worktree_id == *worktree_id)?;
+        let archived = epic
+            .snapshot
+            .as_ref()?
+            .archived
+            .iter()
+            .any(|row| row.dir_name == *dir_name);
+        if !archived || epic.archived_expanded {
+            return None;
+        }
+        self.entries.iter().position(|entry| {
+            matches!(entry, DisplayEntry::ArchivedHeader { worktree: header, .. } if *header == worktree)
+        })
+    }
+
+    fn entry_key(&self, index: usize) -> Option<EntryKey> {
+        let worktree_id =
+            |worktree: usize| self.worktrees.get(worktree).map(|epic| epic.worktree_id);
+        Some(match self.entries.get(index)? {
+            DisplayEntry::WorktreeHeader { worktree } => {
+                EntryKey::WorktreeHeader(worktree_id(*worktree)?)
+            }
+            DisplayEntry::OpenIndex { worktree } => EntryKey::OpenIndex(worktree_id(*worktree)?),
+            DisplayEntry::Story {
+                worktree,
+                area,
+                row,
+            } => EntryKey::Story(
+                worktree_id(*worktree)?,
+                *area,
+                self.resolve_row(*worktree, *area, *row)?.dir_name.clone(),
+            ),
+            DisplayEntry::ArchivedHeader { worktree, .. } => {
+                EntryKey::ArchivedHeader(worktree_id(*worktree)?)
+            }
+            DisplayEntry::MoreNotShown { worktree, .. } => {
+                EntryKey::MoreNotShown(worktree_id(*worktree)?)
+            }
+            DisplayEntry::WorktreeError { worktree, message } => {
+                EntryKey::WorktreeError(worktree_id(*worktree)?, message.clone())
+            }
+            DisplayEntry::Empty { message } => {
+                EntryKey::Empty(self.empty_line_worktree(index), message)
+            }
+        })
+    }
+
+    /// The worktree an `Empty` line at `index` belongs to. `DisplayEntry::Empty` does not carry
+    /// one, but a worktree's lines all follow its header, error and index lines, so it is the
+    /// worktree of the closest earlier line that names one, or the first worktree when no line
+    /// precedes it. With no `.epic/` anywhere there is no worktree: `None`.
+    fn empty_line_worktree(&self, index: usize) -> Option<WorktreeId> {
+        let worktree = self
+            .entries
+            .get(..index)?
+            .iter()
+            .rev()
+            .find_map(DisplayEntry::worktree)
+            .unwrap_or(0);
+        self.worktrees.get(worktree).map(|epic| epic.worktree_id)
+    }
+
+    fn selection_at(&self, index: usize) -> Option<Selection> {
+        let key = self.entry_key(index)?;
+        Some(Selection { index, key })
+    }
+
+    fn select_index(&mut self, index: usize, cx: &mut Context<Self>) {
+        self.selection = self.selection_at(index);
+        self.scroll_handle
+            .scroll_to_item(index, ScrollStrategy::Nearest);
+        cx.notify();
+    }
+
+    /// R2.10: Down, wrapping from the last line to the first. Every line takes the selection,
+    /// rows and headers alike.
+    fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+        let count = self.entries.len();
+        if count == 0 {
+            return;
+        }
+        let index = match &self.selection {
+            Some(selection) => (selection.index + 1) % count,
+            None => 0,
+        };
+        self.select_index(index, cx);
+    }
+
+    /// R2.10: Up, wrapping from the first line to the last.
+    fn select_previous(
+        &mut self,
+        _: &menu::SelectPrevious,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let count = self.entries.len();
+        if count == 0 {
+            return;
+        }
+        let index = match &self.selection {
+            Some(selection) if selection.index > 0 && selection.index < count => {
+                selection.index - 1
+            }
+            _ => count - 1,
+        };
+        self.select_index(index, cx);
+    }
+
+    /// R2.10: Enter activates the selected line.
+    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(index) = self.selection.as_ref().map(|selection| selection.index) {
+            self.activate(index, window, cx);
+        }
+    }
+
+    /// An Archived header toggles its group (R2.4), a story row opens its `open_target` (R2.6)
+    /// and "Open EPICO.md" opens the index (R2.8). A row with no target, which includes every
+    /// problem row and a symlinked directory, opens nothing (R2.7, R6.3); so does any other line.
+    /// "Open EPICO.md", or "Open EPIC.md" for a worktree still on the legacy layout.
+    fn index_label(&self, worktree: usize) -> String {
+        let index_file = self
+            .worktrees
+            .get(worktree)
+            .and_then(|epic| epic.snapshot.as_ref())
+            .map_or("EPICO.md", |snapshot| index_file_name(&snapshot.epic_dir));
+        format!("Open {index_file}")
+    }
+
+    fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
+        match self.entries.get(index) {
+            Some(DisplayEntry::ArchivedHeader { worktree, .. }) => {
+                self.toggle_archived(*worktree, cx);
+            }
+            Some(DisplayEntry::Story {
+                worktree,
+                area,
+                row,
+            }) => {
+                let worktree = *worktree;
+                let target = self
+                    .resolve_row(worktree, *area, *row)
+                    .and_then(|row| row.open_target.clone());
+                if let Some(target) = target {
+                    self.open_file(worktree, target, window, cx);
+                }
+            }
+            Some(DisplayEntry::OpenIndex { worktree }) => {
+                let worktree = *worktree;
+                let index_file = self
+                    .worktrees
+                    .get(worktree)
+                    .and_then(|epic| epic.snapshot.as_ref())
+                    .map(|snapshot| snapshot.epic_dir.join(index_file_name(&snapshot.epic_dir)));
+                if let Some(index_file) = index_file {
+                    self.open_file(worktree, index_file, window, cx);
+                }
+            }
+            _ => {}
+        }
+    }
+
+    /// Opens `abs_path` in an editor tab without adding a worktree for it. A failure is shown
+    /// to the user and logged, both with the worktree-relative path only (R8.4).
+    fn open_file(
+        &self,
+        worktree: usize,
+        abs_path: PathBuf,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(epic) = self.worktrees.get(worktree) else {
+            return;
+        };
+        let Some(root) = epic
+            .snapshot
+            .as_ref()
+            .and_then(|snapshot| snapshot.epic_dir.parent())
+            .map(Path::to_path_buf)
+        else {
+            return;
+        };
+        let worktree_name = epic.name.clone();
+        let relative = relative_path(&root, &abs_path);
+        let workspace = self.workspace.clone();
+        let fs = self.fs.clone();
+        cx.spawn_in(window, async move |_panel, cx| {
+            let opened: Result<()> = async {
+                cx.background_spawn({
+                    let abs_path = abs_path.clone();
+                    async move { check_open_target(fs.as_ref(), &abs_path).await }
+                })
+                .await?;
+                let options = OpenOptions {
+                    visible: Some(OpenVisible::None),
+                    ..Default::default()
+                };
+                let opening = workspace.update_in(cx, |workspace, window, cx| {
+                    workspace.open_abs_path(abs_path, options, window, cx)
+                })?;
+                opening.await.map(drop)
+            }
+            .await;
+            let Err(error) = opened else {
+                return;
+            };
+            // The outer contexts of `open_abs_path` carry the absolute path; the root cause
+            // can too, so the worktree's root is replaced by its name.
+            let root_text = root.to_string_lossy();
+            let reason = error
+                .root_cause()
+                .to_string()
+                .replace(format!("{:?}", root_text).trim_matches('"'), &worktree_name)
+                .replace(root_text.as_ref(), &worktree_name);
+            // Whatever form the path took, an absolute path never reaches the user or the log.
+            let reason = without_absolute_path(reason, "the file could not be opened");
+            log::warn!(
+                "epic_panel event=open_failed worktree={} path={} reason={}",
+                log_value(&worktree_name),
+                log_value(&relative),
+                log_value(&reason)
+            );
+            let message = format!("Could not open {worktree_name}/{relative}: {reason}");
+            workspace
+                .update(cx, |workspace, cx| {
+                    workspace.show_error(OpenFailed(message.into()), cx)
+                })
+                .log_err();
+        })
+        .detach();
+    }
+
+    /// A click: the panel takes the focus and the line the selection, then it acts as Enter
+    /// would. The focus is taken here because the Archived chevron is a button, which stops the
+    /// mouse-down that would otherwise focus the panel, and Up/Down would then go elsewhere.
+    fn select_and_activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
+        window.focus(&self.focus_handle, cx);
+        self.select_index(index, cx);
+        self.activate(index, window, cx);
+    }
+
+    fn toggle_archived(&mut self, worktree: usize, cx: &mut Context<Self>) {
+        let Some(epic) = self.worktrees.get_mut(worktree) else {
+            return;
+        };
+        epic.archived_expanded = !epic.archived_expanded;
+        self.rebuild_entries();
+        cx.notify();
+    }
+
+    fn resolve_row(&self, worktree: usize, area: Area, row: usize) -> Option<&StoryRow> {
+        let snapshot = self.worktrees.get(worktree)?.snapshot.as_ref()?;
+        match area {
+            Area::Active => snapshot.active.get(row),
+            Area::Archived => snapshot.archived.get(row),
+        }
+    }
+
+    fn render_toolbar(&self, cx: &Context<Self>) -> impl IntoElement {
+        let focus_handle = self.focus_handle.clone();
+        h_flex()
+            .w_full()
+            .p_1()
+            .justify_end()
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+            .child(
+                IconButton::new("epic-panel-refresh", IconName::RotateCw)
+                    .icon_size(IconSize::Small)
+                    .icon_color(Color::Muted)
+                    .tooltip(Tooltip::text("Refresh"))
+                    // Dispatched on the panel's own node, so the click reaches the handler
+                    // whether or not the panel holds the focus (R5.5).
+                    .on_click(move |_, window, cx| {
+                        focus_handle.dispatch_action(&Refresh, window, cx)
+                    }),
+            )
+    }
+
+    fn render_entries(&self, range: Range<usize>, cx: &mut Context<Self>) -> Vec<AnyElement> {
+        range
+            .filter_map(|index| Some(self.render_entry(index, self.entries.get(index)?, cx)))
+            .collect()
+    }
+
+    fn render_entry(
+        &self,
+        index: usize,
+        entry: &DisplayEntry,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let selected = self
+            .selection
+            .as_ref()
+            .is_some_and(|selection| selection.index == index);
+        let item = ListItem::new(("epic-entry", index))
+            .toggle_state(selected)
+            .on_click(cx.listener(move |panel, _, window, cx| {
+                panel.select_and_activate(index, window, cx)
+            }));
+        let row = match entry {
+            DisplayEntry::WorktreeHeader { worktree } => item.child(
+                Label::new(
+                    self.worktrees
+                        .get(*worktree)
+                        .map(|epic| epic.name.clone())
+                        .unwrap_or_default(),
+                )
+                .size(LabelSize::Small)
+                .color(Color::Muted)
+                .truncate(),
+            ),
+            DisplayEntry::OpenIndex { worktree } => item
+                .start_slot(
+                    Icon::new(IconName::FileDoc)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
+                .child(
+                    Label::new(self.index_label(*worktree))
+                        .size(LabelSize::Small)
+                        .truncate(),
+                ),
+            DisplayEntry::Story {
+                worktree,
+                area,
+                row,
+            } => match self.resolve_row(*worktree, *area, *row) {
+                Some(row) => item
+                    .tooltip(Tooltip::text(row_tooltip(row)))
+                    .child(render_story(index, row)),
+                None => item,
+            },
+            DisplayEntry::ArchivedHeader {
+                count, expanded, ..
+            } => item
+                .toggle(Some(*expanded))
+                // The disclosure chevron is a button that stops the click from reaching the
+                // item, so it needs the same handler.
+                .on_toggle(cx.listener(move |panel, _, window, cx| {
+                    panel.select_and_activate(index, window, cx)
+                }))
+                .child(
+                    Label::new(format!("Archived ({count})"))
+                        .size(LabelSize::Small)
+                        .truncate(),
+                ),
+            DisplayEntry::MoreNotShown { count, .. } => item.child(
+                Label::new(format!("{count} more not shown"))
+                    .size(LabelSize::Small)
+                    .color(Color::Muted)
+                    .truncate(),
+            ),
+            DisplayEntry::WorktreeError { message, .. } => item
+                .start_slot(
+                    Icon::new(IconName::Warning)
+                        .size(IconSize::Small)
+                        .color(Color::Error),
+                )
+                .tooltip(Tooltip::text(message.clone()))
+                .child(
+                    Label::new(message.clone())
+                        .size(LabelSize::Small)
+                        .color(Color::Error)
+                        .truncate(),
+                ),
+            DisplayEntry::Empty { message } => item.child(
+                Label::new(*message)
+                    .size(LabelSize::Small)
+                    .color(Color::Muted)
+                    .truncate(),
+            ),
+        };
+        // `ListItem` takes no padding of its own, so the gutter is the wrapper's.
+        div()
+            .w_full()
+            .pr(ROW_END_PADDING)
+            .child(row)
+            .into_any_element()
+    }
+}
+
+/// Test seams named by `panel_test_helpers.rs`.
+#[cfg(any(test, feature = "test-support"))]
+impl EpicPanel {
+    pub fn entries(&self) -> &[DisplayEntry] {
+        &self.entries
+    }
+
+    pub fn story_row(&self, worktree: usize, area: Area, row: usize) -> Option<&StoryRow> {
+        self.resolve_row(worktree, area, row)
+    }
+
+    pub fn worktree_name(&self, worktree: usize) -> Option<&str> {
+        self.worktrees.get(worktree).map(|epic| epic.name.as_str())
+    }
+
+    pub fn selected_index(&self) -> Option<usize> {
+        self.selection.as_ref().map(|selection| selection.index)
+    }
+}
+
+/// The inode of `epic_dir` when it is a directory and not a symlink to one: a symlinked
+/// `.epic` is treated as absent, so the panel never reads through it (R6.3). A metadata error
+/// counts as absent, and is logged with the worktree name and the root cause only.
+async fn epic_dir_inode(fs: &dyn Fs, epic_dir: &Path, worktree_name: &str) -> Option<u64> {
+    match fs.metadata(epic_dir).await {
+        Ok(Some(metadata)) if metadata.is_dir && !metadata.is_symlink => Some(metadata.inode),
+        Ok(_) => None,
+        Err(error) => {
+            log::warn!(
+                "epic_panel event=detect_failed worktree={} reason={}",
+                log_value(worktree_name),
+                log_value(&without_absolute_path(
+                    error.root_cause().to_string(),
+                    "metadata failed"
+                ))
+            );
+            None
+        }
+    }
+}
+
+/// The file can still be opened as the scan found it: a regular file, not a symlink. The check
+/// is needed because `open_abs_path` does not fail for a missing file, it opens an empty
+/// unsaved buffer at that path, and because the file may have changed since the scan (R6.3).
+async fn check_open_target(fs: &dyn Fs, abs_path: &Path) -> Result<()> {
+    match fs.metadata(abs_path).await? {
+        None => Err(anyhow::anyhow!("file not found")),
+        Some(metadata) if metadata.is_symlink => Err(anyhow::anyhow!("symlink not followed")),
+        Some(metadata) if metadata.is_dir || metadata.is_fifo => {
+            Err(anyhow::anyhow!("not a regular file"))
+        }
+        Some(_) => Ok(()),
+    }
+}
+
+/// `abs_path` relative to the worktree's `root`, with `/` separators; the file name alone when
+/// it is not under `root`, so an absolute path never reaches a message or a log.
+fn relative_path(root: &Path, abs_path: &Path) -> String {
+    match abs_path.strip_prefix(root) {
+        Ok(relative) => relative
+            .components()
+            .map(|component| component.as_os_str().to_string_lossy())
+            .collect::<Vec<_>>()
+            .join("/"),
+        Err(_) => abs_path
+            .file_name()
+            .map(|name| name.to_string_lossy().into_owned())
+            .unwrap_or_default(),
+    }
+}
+
+/// The notification for a file the panel could not open. A type of its own gives it its own
+/// notification id, so it neither replaces nor is replaced by another part's error.
+struct OpenFailed(SharedString);
+
+impl WorkspaceError for OpenFailed {
+    fn primary_message(&self) -> SharedString {
+        self.0.clone()
+    }
+
+    fn primary_action(&self) -> ErrorAction {
+        ErrorAction::dismiss()
+    }
+
+    fn severity(&self) -> ErrorSeverity {
+        ErrorSeverity::Error
+    }
+}
+
+/// A log value: quoted when it holds whitespace, `=`, `"` or a control character. Inside the
+/// quotes, `"`, `\`, control characters and every whitespace other than the ASCII space are
+/// written as `\u{..}`, so a quoted value never holds a raw `"` or `\`: a name from a cloned
+/// repository can never end a line (U+2028 does, in some readers), close the quotes early or
+/// forge another key (Q14).
+fn log_value(value: &str) -> String {
+    if !value.contains(['=', '"'])
+        && !value
+            .chars()
+            .any(|character| character.is_whitespace() || character.is_control())
+    {
+        return value.to_string();
+    }
+    let mut quoted = String::with_capacity(value.len() + 2);
+    quoted.push('"');
+    for character in value.chars() {
+        match character {
+            ' ' => quoted.push(' '),
+            character
+                if matches!(character, '"' | '\\')
+                    || character.is_control()
+                    || character.is_whitespace() =>
+            {
+                quoted.push_str(&character.escape_unicode().to_string())
+            }
+            character => quoted.push(character),
+        }
+    }
+    quoted.push('"');
+    quoted
+}
+
+/// The file a problem names and its kind, as the `event=problem` line writes it (R8.2).
+fn problem_kind(problem: &RowProblem) -> (&str, &'static str) {
+    match problem {
+        RowProblem::Unreadable(file, _) => (file, "unreadable"),
+        RowProblem::TooLarge(file, _) => (file, "too_large"),
+        RowProblem::Symlink(file) => (file, "symlink"),
+    }
+}
+
+/// The problem is on the story directory itself rather than on one of its files.
+fn is_directory_problem(problem: Option<&RowProblem>) -> bool {
+    match problem {
+        Some(RowProblem::Unreadable(file, _) | RowProblem::Symlink(file)) => file == DIRECTORY,
+        Some(RowProblem::TooLarge(..)) | None => false,
+    }
+}
+
+/// The scan's own context, which is worktree-relative ("reading <worktree>/.epic"), followed by
+/// the root cause when that names no absolute path. The layers in between are never shown: they
+/// can carry one (R8.4).
+fn scan_error_text(error: &anyhow::Error) -> String {
+    let outer = error.to_string();
+    let root_cause = error.root_cause().to_string();
+    if error.chain().count() > 1 && !holds_absolute_path(&root_cause) {
+        format!("{outer}: {root_cause}")
+    } else {
+        outer
+    }
+}
+
+/// `text`, or `fallback` when `text` names an absolute path (R8.4).
+fn without_absolute_path(text: String, fallback: &str) -> String {
+    if holds_absolute_path(&text) {
+        fallback.to_string()
+    } else {
+        text
+    }
+}
+
+/// Whether any word of `text`, stripped of the quotes and punctuation an error message puts
+/// around a path, is an absolute path.
+fn holds_absolute_path(text: &str) -> bool {
+    text.split(|character: char| {
+        character.is_whitespace() || matches!(character, '"' | '\'' | '`' | '(' | ')' | ',' | ';')
+    })
+    .any(|word| Path::new(word.trim_end_matches([':', '.'])).is_absolute())
+}
+
+/// A story line: the number (or `—`), the slug (or the directory name when unnumbered), the
+/// status, the progress, then the badge of a problem or of a row with nothing to open (R2.1).
+fn render_story(index: usize, row: &StoryRow) -> impl IntoElement {
+    let (number, name) = match &row.number {
+        Some(number) => (number.as_str(), row.slug.as_str()),
+        None => (ABSENT, row.dir_name.as_str()),
+    };
+    h_flex()
+        .w_full()
+        .min_w_0()
+        .gap_2()
+        .child(
+            Label::new(number.to_string())
+                .size(LabelSize::Small)
+                .color(Color::Muted)
+                .single_line(),
+        )
+        .child(
+            div().min_w_0().flex_1().child(
+                Label::new(name.to_string())
+                    .size(LabelSize::Small)
+                    .truncate(),
+            ),
+        )
+        .child(
+            h_flex()
+                .flex_none()
+                .gap_1()
+                .when_some(status_warning(&row.status), |this, warning| {
+                    this.child(
+                        div()
+                            .id(("epic-status-warning", index))
+                            .tooltip(Tooltip::text(warning))
+                            .child(
+                                Icon::new(IconName::Warning)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Warning),
+                            ),
+                    )
+                })
+                .child(
+                    Label::new(row.status.text())
+                        .size(LabelSize::Small)
+                        .color(Color::Muted)
+                        .single_line(),
+                )
+                .child(
+                    Label::new(progress_text(row.census))
+                        .size(LabelSize::Small)
+                        .color(Color::Muted)
+                        .single_line(),
+                )
+                .when_some(row_badge(row), |this, badge| {
+                    this.child(
+                        Label::new(badge)
+                            .size(LabelSize::XSmall)
+                            .color(Color::Warning)
+                            .single_line(),
+                    )
+                }),
+        )
+}
+
+/// The problem a row shows instead of its contents (R2.7, R6.1-R6.3). A problem wins over
+/// "no story files": a row whose only file is unreadable has nothing to open either.
+fn row_badge(row: &StoryRow) -> Option<&'static str> {
+    match &row.problem {
+        Some(RowProblem::Unreadable(..)) => Some("unreadable"),
+        Some(RowProblem::TooLarge(..)) => Some("too large"),
+        Some(RowProblem::Symlink(..)) => Some("symlink not followed"),
+        None => row.open_target.is_none().then_some("no story files"),
+    }
+}
+
+/// The title (the directory name when there is none), the `type`/`scale`/`engineering` values
+/// that are present, and a problem's file name and reason (R2.9, R6.1). Never a path: the
+/// scan keeps only names relative to the story directory.
+fn row_tooltip(row: &StoryRow) -> String {
+    let mut lines = vec![row.title.clone().unwrap_or_else(|| row.dir_name.clone())];
+    let values: Vec<String> = [
+        ("type", &row.kind),
+        ("scale", &row.scale),
+        ("engineering", &row.engineering),
+    ]
+    .into_iter()
+    .filter_map(|(key, value)| value.as_ref().map(|value| format!("{key}: {value}")))
+    .collect();
+    if !values.is_empty() {
+        lines.push(values.join(" \u{b7} "));
+    }
+    match &row.problem {
+        Some(RowProblem::Unreadable(file, reason)) => {
+            lines.push(format!("{file}: unreadable ({reason})"));
+        }
+        Some(RowProblem::TooLarge(file, bytes)) => lines.push(format!(
+            "{file}: too large ({bytes} bytes, over the {MAX_FILE_BYTES}-byte limit)"
+        )),
+        Some(RowProblem::Symlink(file)) => lines.push(format!("{file}: symlink not followed")),
+        None if row.open_target.is_none() => {
+            lines.push("No story.md or tasks.md in this directory".to_string());
+        }
+        None => {}
+    }
+    lines.join("\n")
+}
+
+/// The R3.8 warning, for a status outside the lifecycle only.
+fn status_warning(status: &StatusCell) -> Option<&'static str> {
+    matches!(status, StatusCell::Unknown(_)).then_some(UNKNOWN_STATUS)
+}
+
+fn saved_position(saved: Result<Option<String>>) -> DockPosition {
+    match saved.log_err().flatten() {
+        None => DockPosition::Right,
+        Some(text) => parse_dock_position(&text).unwrap_or_else(|| {
+            log::warn!("epic_panel event=dock_position_unknown fallback=right");
+            DockPosition::Right
+        }),
+    }
+}
+
+fn dock_position_text(position: DockPosition) -> &'static str {
+    match position {
+        DockPosition::Left => "left",
+        DockPosition::Right => "right",
+        DockPosition::Bottom => "bottom",
+    }
+}
+
+fn parse_dock_position(text: &str) -> Option<DockPosition> {
+    match text {
+        "left" => Some(DockPosition::Left),
+        "right" => Some(DockPosition::Right),
+        "bottom" => Some(DockPosition::Bottom),
+        _ => None,
+    }
+}
+
+impl EventEmitter<PanelEvent> for EpicPanel {}
+
+impl Focusable for EpicPanel {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Render for EpicPanel {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        // Only while the panel has focus: a rescan must not scroll a list nobody is navigating.
+        if std::mem::take(&mut self.scroll_to_selection)
+            && self.focus_handle.contains_focused(window, cx)
+            && let Some(selection) = &self.selection
+        {
+            self.scroll_handle
+                .scroll_to_item(selection.index, ScrollStrategy::Nearest);
+        }
+        // With no `.epic/` anywhere the message stands alone, centred, as in the Agent Tasks
+        // panel; "No stories yet" is a line of its worktree's section.
+        let lone_message = match self.entries.as_slice() {
+            [DisplayEntry::Empty { message }] => Some(*message),
+            _ => None,
+        };
+        let entry_count = self.entries.len();
+        v_flex()
+            .key_context("EpicPanel")
+            .track_focus(&self.focus_handle)
+            .on_action(cx.listener(Self::refresh))
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::confirm))
+            .size_full()
+            .bg(cx.theme().colors().panel_background)
+            .child(self.render_toolbar(cx))
+            .when_some(self.banner_text(), |panel, text| {
+                panel.child(
+                    div().p_1().child(
+                        Banner::new().severity(Severity::Warning).child(
+                            v_flex().min_w_0().children(
+                                text.lines().map(|line| {
+                                    Label::new(line.to_string()).size(LabelSize::Small)
+                                }),
+                            ),
+                        ),
+                    ),
+                )
+            })
+            .map(|panel| match lone_message {
+                Some(message) => panel.child(
+                    v_flex()
+                        .flex_1()
+                        .items_center()
+                        .justify_center()
+                        .p_4()
+                        .child(
+                            Label::new(message)
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                        ),
+                ),
+                None => panel.child(
+                    v_flex()
+                        .flex_1()
+                        .min_h_0()
+                        .overflow_hidden()
+                        .child(
+                            uniform_list(
+                                "epic-panel-list",
+                                entry_count,
+                                cx.processor(|panel, range: Range<usize>, _window, cx| {
+                                    panel.render_entries(range, cx)
+                                }),
+                            )
+                            .flex_1()
+                            .size_full()
+                            .track_scroll(&self.scroll_handle),
+                        )
+                        // The default scrollbar hides itself a second after the pointer enters,
+                        // and this panel has no settings to change that.
+                        .custom_scrollbars(
+                            Scrollbars::always_visible(ScrollAxes::Vertical)
+                                .tracked_scroll_handle(&self.scroll_handle),
+                            window,
+                            cx,
+                        ),
+                ),
+            })
+    }
+}
+
+impl Panel for EpicPanel {
+    fn persistent_name() -> &'static str {
+        "EpicPanel"
+    }
+
+    fn panel_key() -> &'static str {
+        "EpicPanel"
+    }
+
+    fn position(&self, _window: &Window, _cx: &App) -> DockPosition {
+        self.position
+    }
+
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        matches!(
+            position,
+            DockPosition::Left | DockPosition::Right | DockPosition::Bottom
+        )
+    }
+
+    fn set_position(
+        &mut self,
+        position: DockPosition,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.position = position;
+        let kvp = KeyValueStore::global(cx);
+        let value = dock_position_text(position).to_string();
+        self._save_position = cx.background_spawn(async move {
+            kvp.write_kvp(<Self as Panel>::panel_key().to_string(), value)
+                .await
+                .log_err();
+        });
+        // A dock moves its panels only when it re-reads their positions, which it does on a
+        // settings change. This panel has no settings, so it asks for the same re-read without
+        // changing any.
+        cx.update_global::<SettingsStore, _>(|_, _| {});
+        cx.notify();
+    }
+
+    fn default_size(&self, _window: &Window, _cx: &App) -> Pixels {
+        px(300.)
+    }
+
+    fn icon(&self, _window: &Window, _cx: &App) -> Option<IconName> {
+        self.has_any_epic.then_some(IconName::ListTree)
+    }
+
+    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
+        Some("Epico Stories")
+    }
+
+    fn toggle_action(&self) -> Box<dyn Action> {
+        Box::new(ToggleFocus)
+    }
+
+    fn activation_priority(&self) -> u32 {
+        9
+    }
+}
+
+#[cfg(test)]
+#[path = "panel_test_helpers.rs"]
+#[rustfmt::skip]
+mod panel_test_helpers;
+
+#[cfg(test)]
+#[path = "panel_shell_tests.rs"]
+#[rustfmt::skip]
+mod panel_shell_tests;
+
+#[cfg(test)]
+#[path = "panel_rows_tests.rs"]
+#[rustfmt::skip]
+mod panel_rows_tests;
+
+#[cfg(test)]
+#[path = "panel_open_tests.rs"]
+#[rustfmt::skip]
+mod panel_open_tests;
+
+// Frozen like the others; its real-file-system session test builds its corpus with blocking
+// standard-library calls, which the workspace's disallowed-methods list rejects.
+#[cfg(test)]
+#[path = "panel_live_tests.rs"]
+#[rustfmt::skip]
+#[allow(clippy::disallowed_methods)]
+mod panel_live_tests;
+
+// Task 6.1: every field the renderer reads is set on every scan path; log values, the scan
+// error line and the several-worktree banner.
+#[cfg(test)]
+#[path = "panel_contract_tests.rs"]
+mod panel_contract_tests;
+
+// Task 10.1: log values the frozen Q14 parser reads, and the rows' scrollbar gutter.
+#[cfg(test)]
+#[path = "panel_polish_tests.rs"]
+#[rustfmt::skip]
+mod panel_polish_tests;
+
+// Task 11.1: a symlink whose target cannot be read puts no absolute path in any log line.
+#[cfg(test)]
+#[path = "panel_fs_log_tests.rs"]
+#[rustfmt::skip]
+mod panel_fs_log_tests;
diff --git a/crates/epic_panel/src/panel_contract_tests.rs b/crates/epic_panel/src/panel_contract_tests.rs
new file mode 100644
index 0000000..64f5026
--- /dev/null
+++ b/crates/epic_panel/src/panel_contract_tests.rs
@@ -0,0 +1,869 @@
+// Story 042, task 6.1 — the panel renders only what the scan provides.
+//
+// Four groups, all test-after:
+//   1. the field contract: for every scan path (normal, absent story.md, absent both, problem
+//      file, symlinked directory, too large, unreadable file, unreadable directory, area notes),
+//      every field `render_story`, `row_badge` and `row_tooltip` read is set the way they
+//      expect, and nothing they show holds an absolute path;
+//   2. `log_value`: one token per value, no raw line break, accepted by the key=value parser of
+//      `panel_live_tests` (and, for an escaped `"`, by an escape-aware one);
+//   3. a scan error in the panel (`.epico` unlistable) and its recovery;
+//   4. the banner with several `.epico` worktrees.
+//
+// Mounted from `src/panel.rs` as a child of `panel`, so it reaches the private renderer
+// helpers. Groups 1 and 3 run on RealFs in a temp dir; group 4 on FakeFs.
+
+use std::path::{Path, PathBuf};
+
+use gpui::TestAppContext;
+use serde_json::{Value, json};
+
+use super::panel_test_helpers::*;
+use super::{
+    DisplayEntry, holds_absolute_path, log_value, row_badge, row_tooltip, scan_error_text,
+    without_absolute_path,
+};
+use crate::format::{StatusCell, progress_text};
+use crate::scan::{Area, EpicSnapshot, MAX_FILE_BYTES, RowProblem, StoryRow};
+
+const EM_DASH: &str = "\u{2014}";
+
+// ── group 1: the field contract ─────────────────────────────────────────────
+
+/// What every row must satisfy for the renderer, whatever path the scan took. `epic` is the
+/// scanned `.epico`; `temp` the temp dir holding it, whose path must never be shown.
+fn assert_renderable(row: &StoryRow, epic: &Path, temp: &Path) {
+    let context = format!("row {}", row.dir_name);
+    // The line: the number and slug, or the em dash and the directory name (R2.1).
+    match &row.number {
+        Some(number) => {
+            assert!(!number.is_empty(), "{context}: empty number");
+            assert!(!row.slug.is_empty(), "{context}: a number without a slug");
+        }
+        None => assert!(
+            !row.dir_name.is_empty(),
+            "{context}: nothing to name the row by"
+        ),
+    }
+    assert!(
+        !row.status.text().is_empty(),
+        "{context}: empty status cell"
+    );
+    assert!(
+        !progress_text(row.census).is_empty(),
+        "{context}: empty progress cell"
+    );
+
+    // The badge agrees with the problem and the target (R2.7, R6.1-R6.3).
+    let expected_badge = match &row.problem {
+        Some(RowProblem::Unreadable(..)) => Some("unreadable"),
+        Some(RowProblem::TooLarge(..)) => Some("too large"),
+        Some(RowProblem::Symlink(..)) => Some("symlink not followed"),
+        None if row.open_target.is_none() => Some("no story files"),
+        None => None,
+    };
+    assert_eq!(row_badge(row), expected_badge, "{context}: badge");
+
+    // A problem names a file relative to the story directory, with a reason or a size.
+    if let Some(problem) = &row.problem {
+        let file = match problem {
+            RowProblem::Unreadable(file, reason) => {
+                assert!(!reason.is_empty(), "{context}: unreadable without a reason");
+                file
+            }
+            RowProblem::TooLarge(file, bytes) => {
+                assert!(
+                    *bytes > MAX_FILE_BYTES,
+                    "{context}: too large at {bytes} bytes"
+                );
+                file
+            }
+            RowProblem::Symlink(file) => file,
+        };
+        assert!(
+            ["story.md", "tasks.md", "directory"].contains(&file.as_str()),
+            "{context}: problem file {file:?}"
+        );
+        if file == "directory" {
+            // Nothing behind the directory was read, so nothing can be shown from it.
+            assert_eq!(row.status, StatusCell::Absent, "{context}: status");
+            assert_eq!(row.census, None, "{context}: census");
+            assert_eq!(row.title, None, "{context}: title");
+            assert_eq!(
+                (&row.kind, &row.scale, &row.engineering),
+                (&None, &None, &None),
+                "{context}: frontmatter values"
+            );
+            assert_eq!(row.open_target, None, "{context}: open target");
+        }
+    }
+
+    // The open target is a problem-free regular file of this very story directory.
+    if let Some(target) = &row.open_target {
+        let story_dir = epic.join(row.area.dir_name()).join(&row.dir_name);
+        let name = target
+            .file_name()
+            .and_then(|name| name.to_str())
+            .unwrap_or_default();
+        assert!(
+            target.parent() == Some(story_dir.as_path())
+                && (name == "story.md" || name == "tasks.md"),
+            "{context}: open target {}",
+            target.display()
+        );
+        let problem_file = row.problem.as_ref().map(|problem| match problem {
+            RowProblem::Unreadable(file, _)
+            | RowProblem::TooLarge(file, _)
+            | RowProblem::Symlink(file) => file.as_str(),
+        });
+        assert_ne!(
+            problem_file,
+            Some(name),
+            "{context}: opens its problem file"
+        );
+        let metadata = std::fs::symlink_metadata(target)
+            .unwrap_or_else(|error| panic!("{context}: stat {}: {error}", target.display()));
+        assert!(
+            metadata.is_file() && !metadata.file_type().is_symlink(),
+            "{context}: target is not a regular file"
+        );
+    }
+
+    // The tooltip: the title or the directory name first, then what the row holds (R2.9).
+    let tooltip = row_tooltip(row);
+    let first = tooltip.lines().next().unwrap_or_default();
+    assert_eq!(
+        first,
+        row.title.as_deref().unwrap_or(&row.dir_name),
+        "{context}: tooltip's first line"
+    );
+    for (key, value) in [
+        ("type", &row.kind),
+        ("scale", &row.scale),
+        ("engineering", &row.engineering),
+    ] {
+        if let Some(value) = value {
+            assert!(
+                tooltip.contains(&format!("{key}: {value}")),
+                "{context}: tooltip lacks {key}: {tooltip}"
+            );
+        }
+    }
+    match &row.problem {
+        Some(RowProblem::Unreadable(file, reason)) => assert!(
+            tooltip.contains(&format!("{file}: unreadable ({reason})")),
+            "{context}: {tooltip}"
+        ),
+        Some(RowProblem::TooLarge(file, bytes)) => assert!(
+            tooltip.contains(&format!("{file}: too large ({bytes} bytes")),
+            "{context}: {tooltip}"
+        ),
+        Some(RowProblem::Symlink(file)) => assert!(
+            tooltip.contains(&format!("{file}: symlink not followed")),
+            "{context}: {tooltip}"
+        ),
+        None if row.open_target.is_none() => assert!(
+            tooltip.contains("No story.md or tasks.md"),
+            "{context}: {tooltip}"
+        ),
+        None => {}
+    }
+    assert_no_absolute_path(&tooltip, temp, &context);
+}
+
+fn assert_no_absolute_path(text: &str, temp: &Path, context: &str) {
+    assert!(
+        !holds_absolute_path(text) && !text.contains(&temp.display().to_string()),
+        "{context}: an absolute path is shown: {text}"
+    );
+}
+
+fn all_rows(snapshot: &EpicSnapshot) -> impl Iterator<Item = &StoryRow> {
+    snapshot.active.iter().chain(&snapshot.archived)
+}
+
+fn row<'a>(snapshot: &'a EpicSnapshot, dir_name: &str) -> &'a StoryRow {
+    all_rows(snapshot)
+        .find(|row| row.dir_name == dir_name)
+        .unwrap_or_else(|| panic!("no row {dir_name}: {:?}", names(snapshot)))
+}
+
+fn names(snapshot: &EpicSnapshot) -> Vec<&str> {
+    all_rows(snapshot)
+        .map(|row| row.dir_name.as_str())
+        .collect()
+}
+
+/// The row as one line plus its badge and its target's file name, the way a reader sees it.
+fn shown(row: &StoryRow) -> String {
+    let target = row
+        .open_target
+        .as_ref()
+        .and_then(|target| target.file_name())
+        .map(|name| name.to_string_lossy().into_owned())
+        .unwrap_or_else(|| EM_DASH.to_string());
+    format!(
+        "{} [{}] -> {target}",
+        story_line(row),
+        row_badge(row).unwrap_or("")
+    )
+}
+
+#[cfg(unix)]
+mod real_fs {
+    use std::{
+        fs as stdfs,
+        os::unix::fs::{PermissionsExt as _, symlink},
+        sync::Arc,
+        time::Duration,
+    };
+
+    use fs::Fs;
+
+    use super::*;
+    use crate::scan::scan_epic_dir;
+
+    pub(super) fn write(root: &Path, relative: &str, content: &[u8]) {
+        let file = root.join(relative);
+        let parent = file.parent().expect("a relative file has a parent");
+        stdfs::create_dir_all(parent)
+            .unwrap_or_else(|error| panic!("create {}: {error}", parent.display()));
+        stdfs::write(&file, content)
+            .unwrap_or_else(|error| panic!("write {}: {error}", file.display()));
+    }
+
+    pub(super) fn mkdir(root: &Path, relative: &str) {
+        let dir = root.join(relative);
+        stdfs::create_dir_all(&dir)
+            .unwrap_or_else(|error| panic!("create {}: {error}", dir.display()));
+    }
+
+    pub(super) fn chmod(path: &Path, mode: u32) {
+        stdfs::set_permissions(path, stdfs::Permissions::from_mode(mode))
+            .unwrap_or_else(|error| panic!("chmod {mode:o} {}: {error}", path.display()));
+    }
+
+    /// Puts the modes back when dropped, so the temp dir can be removed even after a failure.
+    pub(super) struct RestoreModes(pub Vec<(PathBuf, u32)>);
+
+    impl Drop for RestoreModes {
+        fn drop(&mut self) {
+            for (path, mode) in &self.0 {
+                if let Err(error) =
+                    stdfs::set_permissions(path, stdfs::Permissions::from_mode(*mode))
+                {
+                    eprintln!(
+                        "could not restore mode {mode:o} on {}: {error}",
+                        path.display()
+                    );
+                }
+            }
+        }
+    }
+
+    async fn scan(cx: &mut TestAppContext, epic: &Path) -> EpicSnapshot {
+        let fs: Arc<dyn Fs> = fs::RealFs::new(None, cx.executor());
+        scan_epic_dir(fs, epic.to_path_buf())
+            .await
+            .unwrap_or_else(|error| panic!("scan {}: {error:#}", epic.display()))
+    }
+
+    // Normal, absent story.md, absent both, symlinked file, non-UTF-8 file, too large,
+    // symlinked directory, unnumbered — the paths that need no permission change.
+    #[gpui::test]
+    async fn contract_every_scan_path_fills_what_the_renderer_reads(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let temp = tempfile::tempdir().expect("temp dir");
+        let epic = temp.path().join("project/.epico");
+        let story = |pairs: &[(&str, &str)], body: &str| fm(pairs, body).into_bytes();
+        write(&epic, "EPICO.md", b"# index\n");
+        write(
+            &epic,
+            "stories/001-normal/story.md",
+            &story(
+                &[
+                    ("status", "in-progress"),
+                    ("type", "feature"),
+                    ("scale", "standard"),
+                    ("engineering", "lean"),
+                ],
+                "# Story - Normal\n",
+            ),
+        );
+        write(&epic, "stories/001-normal/tasks.md", b"- [x] a\n- [ ] b\n");
+        write(
+            &epic,
+            "stories/002-tasks-only/tasks.md",
+            &story(
+                &[("status", "draft")],
+                "# Implementation Plan - Tasks only\n- [ ] a\n",
+            ),
+        );
+        mkdir(&epic, "stories/003-empty");
+        write(&epic, "stories/004-story-link/tasks.md", b"- [ ] a\n");
+        symlink(
+            epic.join("stories/001-normal/story.md"),
+            epic.join("stories/004-story-link/story.md"),
+        )
+        .expect("symlink story.md");
+        write(
+            &epic,
+            "stories/005-binary/story.md",
+            &story(&[("status", "draft")], "# Binary\n"),
+        );
+        write(&epic, "stories/005-binary/tasks.md", b"- [x] a\n\xff\xfe\n");
+        write(&epic, "stories/006-large/tasks.md", b"- [x] a\n");
+        let large = epic.join("stories/006-large/story.md");
+        stdfs::File::create(&large)
+            .and_then(|file| file.set_len(MAX_FILE_BYTES + 1))
+            .unwrap_or_else(|error| panic!("size {}: {error}", large.display()));
+        write(
+            temp.path(),
+            "outside/story.md",
+            &story(&[("status", "done"), ("type", "secret")], "# Outside\n"),
+        );
+        mkdir(&epic, "stories");
+        symlink(temp.path().join("outside"), epic.join("stories/008-linked"))
+            .expect("symlink a story directory");
+        write(
+            &epic,
+            "stories/unnumbered/story.md",
+            &story(&[("status", "done")], ""),
+        );
+
+        let snapshot = scan(cx, &epic).await;
+        for row in all_rows(&snapshot) {
+            assert_renderable(row, &epic, temp.path());
+        }
+        let table: Vec<String> = all_rows(&snapshot).map(shown).collect();
+        assert_eq!(
+            table,
+            [
+                "001 normal | in-progress | 1/2 [] -> story.md",
+                "002 tasks-only | draft | 0/1 [] -> tasks.md",
+                "003 empty | \u{2014} | \u{2014} [no story files] -> \u{2014}",
+                // The link's target is 001's story.md: had it been followed, this would read
+                // in-progress.
+                "004 story-link | \u{2014} | 0/1 [symlink not followed] -> tasks.md",
+                "005 binary | draft | \u{2014} [unreadable] -> story.md",
+                "006 large | \u{2014} | 1/1 [too large] -> tasks.md",
+                "008 linked | \u{2014} | \u{2014} [symlink not followed] -> \u{2014}",
+                "\u{2014} unnumbered | done | \u{2014} [] -> story.md",
+            ],
+        );
+        let normal = row(&snapshot, "001-normal");
+        assert_eq!(
+            row_tooltip(normal),
+            "Normal\ntype: feature \u{b7} scale: standard \u{b7} engineering: lean"
+        );
+        assert_eq!(
+            row_tooltip(row(&snapshot, "002-tasks-only")),
+            "Tasks only",
+            "R2.9: the title from tasks.md when there is no story.md"
+        );
+        let binary = row_tooltip(row(&snapshot, "005-binary"));
+        assert!(
+            binary.starts_with("Binary\ntasks.md: unreadable (") && binary.contains("UTF-8"),
+            "{binary}"
+        );
+        assert_eq!(
+            row_tooltip(row(&snapshot, "008-linked")),
+            "008-linked\ndirectory: symlink not followed",
+            "nothing behind the link reaches the tooltip"
+        );
+        assert!(snapshot.has_index);
+        assert!(snapshot.notes.is_empty(), "{:?}", snapshot.notes);
+        assert_eq!(
+            (
+                snapshot.active_total,
+                snapshot.archived_total,
+                snapshot.not_shown
+            ),
+            (8, 0, 0)
+        );
+    }
+
+    // An unreadable file and an unreadable story directory (its area lists, its entries do not
+    // stat). Skipped when this user reads mode-000 files.
+    #[gpui::test]
+    async fn contract_unreadable_file_and_directory_rows(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let temp = tempfile::tempdir().expect("temp dir");
+        let epic = temp.path().join("project/.epico");
+        write(
+            &epic,
+            "stories/007-locked/story.md",
+            fm(&[("status", "done")], "# Locked\n").as_bytes(),
+        );
+        write(
+            &epic,
+            "stories/007-locked/tasks.md",
+            fm(
+                &[("status", "draft"), ("scale", "fast")],
+                "# From tasks\n- [ ] a\n",
+            )
+            .as_bytes(),
+        );
+        write(
+            &epic,
+            "archive/009-hidden/story.md",
+            fm(&[("status", "archived")], "# Hidden\n").as_bytes(),
+        );
+        let locked = epic.join("stories/007-locked/story.md");
+        let archive = epic.join("archive");
+        let _restore = RestoreModes(vec![(locked.clone(), 0o644), (archive.clone(), 0o755)]);
+        chmod(&locked, 0o000);
+        // Readable, not searchable: the area lists, its entries cannot be stat'ed.
+        chmod(&archive, 0o444);
+        if stdfs::read(&locked).is_ok() {
+            eprintln!("SKIPPED unreadable rows: this user reads mode-000 files");
+            return;
+        }
+
+        let snapshot = scan(cx, &epic).await;
+        for row in all_rows(&snapshot) {
+            assert_renderable(row, &epic, temp.path());
+        }
+        let table: Vec<String> = all_rows(&snapshot).map(shown).collect();
+        assert_eq!(
+            table,
+            [
+                // R6.1 and design.md: a row keeps every field it could read, so status, title
+                // and target come from the readable tasks.md.
+                "007 locked | draft | 0/1 [unreadable] -> tasks.md",
+                "009 hidden | \u{2014} | \u{2014} [unreadable] -> \u{2014}",
+            ]
+        );
+        let locked_row = row(&snapshot, "007-locked");
+        assert!(
+            matches!(&locked_row.problem, Some(RowProblem::Unreadable(file, _)) if file == "story.md"),
+            "{:?}",
+            locked_row.problem
+        );
+        assert_eq!(locked_row.scale.as_deref(), Some("fast"));
+        let hidden = row(&snapshot, "009-hidden");
+        assert_eq!(hidden.area, Area::Archived);
+        assert!(
+            matches!(&hidden.problem, Some(RowProblem::Unreadable(file, _)) if file == "directory"),
+            "{:?}",
+            hidden.problem
+        );
+        assert_eq!(snapshot.archived_total, 1);
+    }
+
+    // Area-level problems become notes, which the panel shows as `WorktreeError` lines: a
+    // symlinked area and an area that cannot be listed.
+    #[gpui::test]
+    async fn contract_area_notes_are_worktree_relative(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let temp = tempfile::tempdir().expect("temp dir");
+        let epic = temp.path().join("project/.epico");
+        write(
+            temp.path(),
+            "outside/001-x/story.md",
+            fm(&[("status", "done")], "").as_bytes(),
+        );
+        mkdir(&epic, "archive/002-y");
+        symlink(temp.path().join("outside"), epic.join("stories")).expect("symlink stories/");
+        let archive = epic.join("archive");
+        let _restore = RestoreModes(vec![(archive.clone(), 0o755)]);
+        chmod(&archive, 0o000);
+        let privileged = stdfs::read_dir(&archive).is_ok();
+
+        let snapshot = scan(cx, &epic).await;
+        assert_eq!(
+            snapshot.notes.first().map(String::as_str),
+            Some("stories/ is a symlink and is not followed")
+        );
+        if privileged {
+            eprintln!("SKIPPED unlistable area: this user lists mode-000 directories");
+        } else {
+            assert_eq!(snapshot.notes.len(), 2, "{:?}", snapshot.notes);
+            assert!(
+                snapshot.notes[1].starts_with("could not list archive/: "),
+                "{:?}",
+                snapshot.notes
+            );
+        }
+        for note in &snapshot.notes {
+            assert_no_absolute_path(note, temp.path(), "note");
+        }
+        assert!(names(&snapshot).is_empty(), "{:?}", names(&snapshot));
+        assert_eq!(
+            (snapshot.active_total, snapshot.archived_total),
+            (0, if privileged { 1 } else { 0 })
+        );
+    }
+
+    // ── group 3: a scan error in the panel, and its recovery ────────────────
+
+    /// Real I/O: wait in real time, letting virtual timers (the debounce) run too.
+    fn wait_for(rig: &mut Rig, what: &str, done: impl Fn(&[String]) -> bool) {
+        for _ in 0..60 {
+            if done(&lines(rig)) {
+                return;
+            }
+            std::thread::sleep(Duration::from_millis(50));
+            settle(&mut rig.cx);
+        }
+        panic!("{what} never happened; lines: {:?}", lines(rig));
+    }
+
+    #[gpui::test]
+    async fn contract_unlistable_epic_shows_an_error_line_then_recovers(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        capture_logs();
+        init_test(cx);
+        let temp = tempfile::tempdir().expect("temp dir");
+        let root = temp.path().join("project");
+        write(&root, ".gitignore", b".epico/\n");
+        write(
+            &root,
+            ".epico/stories/001-a/story.md",
+            fm(&[("status", "in-progress")], "# A\n").as_bytes(),
+        );
+        let epic = root.join(".epico");
+
+        let fs: Arc<dyn Fs> = fs::RealFs::new(None, cx.executor());
+        cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
+        let project = project::Project::test(fs.clone(), [root.as_path()], cx).await;
+        let window = cx.add_window(|window, cx| {
+            workspace::MultiWorkspace::test_new(project.clone(), window, cx)
+        });
+        let workspace = window
+            .read_with(cx, |multi, _| multi.workspace().clone())
+            .expect("workspace");
+        let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
+        let panel = load(&workspace, &mut vcx).await;
+        workspace.update_in(&mut vcx, |workspace, window, cx| {
+            workspace.add_panel(panel.clone(), window, cx)
+        });
+        let mut rig = Rig {
+            fs: fs::FakeFs::new(vcx.executor()),
+            project,
+            workspace,
+            panel,
+            cx: vcx,
+        };
+        let row_line = "001 a | in-progress | \u{2014}";
+        wait_for(&mut rig, "the first scan", |lines| lines == [row_line]);
+
+        let _restore = RestoreModes(vec![(epic.clone(), 0o755)]);
+        chmod(&epic, 0o000);
+        if stdfs::read_dir(&epic).is_ok() {
+            eprintln!("SKIPPED scan error: this user lists mode-000 directories");
+            return;
+        }
+        focus_panel(&mut rig);
+        press(&mut rig, super::super::Refresh);
+        wait_for(&mut rig, "the scan error line", |lines| {
+            lines
+                .iter()
+                .any(|line| line.starts_with("reading project/.epico"))
+        });
+        let shown = lines(&mut rig);
+        assert_eq!(
+            shown.len(),
+            1,
+            "the previous snapshot is dropped: {shown:?}"
+        );
+        assert_no_absolute_path(&shown[0], temp.path(), "scan error line");
+        let is_error_line = rig.panel.read_with(&rig.cx, |panel, _| {
+            matches!(panel.entries(), [DisplayEntry::WorktreeError { .. }])
+        });
+        assert!(is_error_line, "the line is a WorktreeError entry");
+        let failures = events("scan_failed");
+        assert!(!failures.is_empty(), "a scan_failed warn is logged");
+        for (level, line) in &failures {
+            assert_eq!(*level, log::Level::Warn, "{line}");
+            assert_eq!(field(line, "worktree"), Some("project"), "{line}");
+            assert_no_absolute_path(line, temp.path(), "scan_failed line");
+        }
+
+        chmod(&epic, 0o755);
+        press(&mut rig, super::super::Refresh);
+        wait_for(&mut rig, "the recovery", |lines| lines == [row_line]);
+        let errors = rig.panel.read_with(&rig.cx, |panel, _| {
+            panel
+                .entries()
+                .iter()
+                .filter(|entry| matches!(entry, DisplayEntry::WorktreeError { .. }))
+                .count()
+        });
+        assert_eq!(errors, 0, "the error is cleared");
+    }
+}
+
+// ── group 2: log_value ──────────────────────────────────────────────────────
+
+/// `panel_live_tests::parses_as_key_values`, copied verbatim: it is private to that frozen
+/// module, so it cannot be called from here.
+fn frozen_parser(mut rest: &str) -> bool {
+    loop {
+        rest = rest.trim_start();
+        if rest.is_empty() {
+            return true;
+        }
+        let Some(eq) = rest.find('=') else {
+            return false;
+        };
+        let key = &rest[..eq];
+        if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
+            return false;
+        }
+        rest = &rest[eq + 1..];
+        if let Some(quoted) = rest.strip_prefix('"') {
+            let Some(end) = quoted.find('"') else {
+                return false;
+            };
+            rest = &quoted[end + 1..];
+        } else {
+            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
+            rest = &rest[end..];
+        }
+    }
+}
+
+/// A logfmt reader that honours the escapes `log_value` writes (`\"`, `\\`, `\u{..}`) and
+/// returns the decoded pairs; `None` when the line is not key=value.
+fn decode_key_values(mut rest: &str) -> Option<Vec<(String, String)>> {
+    let mut pairs = Vec::new();
+    loop {
+        rest = rest.trim_start_matches(' ');
+        if rest.is_empty() {
+            return Some(pairs);
+        }
+        let eq = rest.find('=')?;
+        let key = &rest[..eq];
+        if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
+            return None;
+        }
+        rest = &rest[eq + 1..];
+        let mut value = String::new();
+        if let Some(quoted) = rest.strip_prefix('"') {
+            let mut chars = quoted.char_indices();
+            let end = loop {
+                let (index, character) = chars.next()?;
+                match character {
+                    '"' => break index,
+                    '\\' => match chars.next()?.1 {
+                        '"' => value.push('"'),
+                        '\\' => value.push('\\'),
+                        'u' => {
+                            let (open, '{') = chars.next()? else {
+                                return None;
+                            };
+                            let close = quoted[open..].find('}')? + open;
+                            let code = u32::from_str_radix(&quoted[open + 1..close], 16).ok()?;
+                            value.push(char::from_u32(code)?);
+                            while chars.next()?.0 < close {}
+                        }
+                        _ => return None,
+                    },
+                    character => value.push(character),
+                }
+            };
+            rest = &quoted[end + 1..];
+        } else {
+            let end = rest.find(' ').unwrap_or(rest.len());
+            value.push_str(&rest[..end]);
+            rest = &rest[end..];
+        }
+        pairs.push((key.to_string(), value));
+    }
+}
+
+/// Unicode line terminators: none may appear raw in a log line.
+const LINE_BREAKS: [char; 7] = [
+    '\n', '\r', '\u{b}', '\u{c}', '\u{85}', '\u{2028}', '\u{2029}',
+];
+
+#[test]
+fn contract_log_value_writes_one_token_per_value() {
+    let cases: &[(&str, &str)] = &[
+        ("project", "project"),
+        ("001-a", "001-a"),
+        ("tasks.md", "tasks.md"),
+        // design.md: only a space, `=` or `"` needs quotes; a lone backslash stays bare.
+        ("a\\b", "a\\b"),
+        ("my project", "\"my project\""),
+        ("a=b", "\"a=b\""),
+        ("a\"b", "\"a\\u{22}b\""),
+        ("a \\b", "\"a \\u{5c}b\""),
+        ("a\u{a0}b", "\"a\\u{a0}b\""),
+        ("a\u{3000}b", "\"a\\u{3000}b\""),
+        ("a\u{2028}b", "\"a\\u{2028}b\""),
+        ("a\nb", "\"a\\u{a}b\""),
+        ("a\tb", "\"a\\u{9}b\""),
+        ("a\r\nevent=forged", "\"a\\u{d}\\u{a}event=forged\""),
+    ];
+    for (raw, expected) in cases {
+        let written = log_value(raw);
+        assert_eq!(&written, expected, "log_value({raw:?})");
+        assert!(
+            !written.contains(LINE_BREAKS),
+            "a raw line break in {written:?}"
+        );
+        let line = format!("event=problem story={written} file=tasks.md");
+        let decoded = decode_key_values(&line).unwrap_or_else(|| panic!("not key=value: {line:?}"));
+        assert_eq!(
+            decoded,
+            [
+                ("event".to_string(), "problem".to_string()),
+                ("story".to_string(), raw.to_string()),
+                ("file".to_string(), "tasks.md".to_string()),
+            ],
+            "one token that decodes back to {raw:?}: {line:?}"
+        );
+        // A quoted value holds no raw `"` (task 10.1), so the frozen parser reads every line.
+        assert!(frozen_parser(&line), "Q14 parser rejects {line:?}");
+    }
+}
+
+#[test]
+fn contract_absolute_path_helpers() {
+    assert!(holds_absolute_path(
+        "failed to open directory \"/work/p/.epico\""
+    ));
+    assert!(holds_absolute_path("at /tmp/x: denied"));
+    assert!(holds_absolute_path("(/a/b)"));
+    assert!(!holds_absolute_path(
+        "could not watch .epico/stories: file already exists"
+    ));
+    assert!(!holds_absolute_path(
+        "reading project/.epico: Permission denied (os error 13)"
+    ));
+    assert_eq!(
+        without_absolute_path("open /etc/x".to_string(), "fallback"),
+        "fallback"
+    );
+    assert_eq!(
+        without_absolute_path("open .epico/x".to_string(), "fallback"),
+        "open .epico/x"
+    );
+
+    let denied = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
+        .context("failed to open directory \"/work/project/.epico\"")
+        .context("reading project/.epico");
+    let text = scan_error_text(&denied);
+    assert!(
+        text.starts_with("reading project/.epico: ") && !holds_absolute_path(&text),
+        "{text}"
+    );
+    let leaky =
+        anyhow::anyhow!("no entry at /work/project/.epico").context("reading project/.epico");
+    assert_eq!(scan_error_text(&leaky), "reading project/.epico");
+    assert_eq!(
+        scan_error_text(&anyhow::anyhow!("reading project/.epico")),
+        "reading project/.epico"
+    );
+}
+
+// ── group 4: the banner with several `.epico` worktrees ──────────────────────
+
+fn small_tree() -> Value {
+    json!({
+        ".git": {},
+        // The worktree scanner must not take the one-shot watch hook: it skips ignored dirs.
+        ".gitignore": ".epico/\n",
+        ".epico": { "stories": { "001-a": {
+            "story.md": fm(&[("status", "draft")], ""),
+            "tasks.md": "- [ ] one\n",
+        } } },
+    })
+}
+
+#[cfg(target_os = "linux")]
+#[gpui::test]
+async fn contract_banner_names_the_failing_worktree_when_several_have_epic(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+    let fs = fs::FakeFs::new(cx.executor());
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+    fs.insert_tree("/work/one", small_tree()).await;
+    fs.insert_tree("/work/two", small_tree()).await;
+    // The same refused backend watch `panel_live_watch_failure_banner_and_refresh` provokes.
+    fs.create_file_before_next_watch_add(
+        "/work/one/.epico/stories",
+        "/work/one/.epico/stories/001-a/tasks.md",
+    );
+    let project = project::Project::test(
+        fs.clone(),
+        [Path::new("/work/one"), Path::new("/work/two")],
+        cx,
+    )
+    .await;
+    let window = cx
+        .add_window(|window, cx| workspace::MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |multi, _| multi.workspace().clone())
+        .expect("workspace");
+    let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
+    let panel = load(&workspace, &mut vcx).await;
+    workspace.update_in(&mut vcx, |workspace, window, cx| {
+        workspace.add_panel(panel.clone(), window, cx)
+    });
+    settle(&mut vcx);
+    let mut rig = Rig {
+        fs,
+        project,
+        workspace,
+        panel,
+        cx: vcx,
+    };
+    assert_eq!(
+        lines(&mut rig),
+        [
+            "worktree one",
+            "001 a | draft | 0/1",
+            "worktree two",
+            "001 a | draft | 0/1"
+        ],
+        "both worktrees are listed"
+    );
+
+    let one = banner(&mut rig).expect("R5.6: a banner is shown");
+    assert_eq!(one.lines().count(), 1, "{one}");
+    assert!(
+        one.starts_with("one: Live updates are off: ") && one.ends_with(". Use Refresh."),
+        "the line names its worktree: {one}"
+    );
+    assert!(!one.contains("/work/"), "{one}");
+
+    // A new watch for `two`: the worktree is removed and added back.
+    rig.fs.create_file_before_next_watch_add(
+        "/work/two/.epico/stories",
+        "/work/two/.epico/stories/001-a/tasks.md",
+    );
+    remove_worktree(&mut rig, "/work/two").await;
+    assert_eq!(
+        banner(&mut rig).as_deref(),
+        Some(one.strip_prefix("one: ").unwrap_or(&one)),
+        "one worktree left: the line carries no prefix"
+    );
+    add_worktree(&mut rig, "/work/two").await;
+    let both = banner(&mut rig).expect("both fail");
+    let both: Vec<&str> = both.lines().collect();
+    assert_eq!(both.len(), 2, "{both:?}");
+    assert!(
+        both[0].starts_with("one: Live updates are off: "),
+        "{both:?}"
+    );
+    assert!(
+        both[1].starts_with("two: Live updates are off: ") && both[1].ends_with(". Use Refresh."),
+        "{both:?}"
+    );
+}
+
+#[gpui::test]
+async fn contract_no_banner_while_every_watch_works(cx: &mut TestAppContext) {
+    let mut rig = rig(
+        cx,
+        &[("/work/one", small_tree()), ("/work/two", small_tree())],
+        &["/work/one", "/work/two"],
+    )
+    .await;
+    assert_eq!(lines(&mut rig).len(), 4, "{:?}", lines(&mut rig));
+    assert_eq!(banner(&mut rig), None);
+}
diff --git a/crates/epic_panel/src/panel_fs_log_tests.rs b/crates/epic_panel/src/panel_fs_log_tests.rs
new file mode 100644
index 0000000..5bce402
--- /dev/null
+++ b/crates/epic_panel/src/panel_fs_log_tests.rs
@@ -0,0 +1,278 @@
+// Story 042, task 11.1 — a hostile symlink in `.epico/` puts no absolute path in any log line,
+// from any crate (R8.5, R8.4; Q5, Q14). Module path: `panel::panel_fs_log_tests`.
+//
+// Mounted from `src/panel.rs`, a child of `panel` like its siblings:
+//
+//     #[cfg(test)]
+//     #[path = "panel_fs_log_tests.rs"]
+//     #[rustfmt::skip]
+//     mod panel_fs_log_tests;
+//
+// The corpus is built with blocking standard-library calls on a real temp directory, which the
+// workspace's disallowed-methods list rejects; the allow is an inner attribute below, so the
+// mount needs nothing more than the three lines above.
+//
+// HOW EVERY LOG TARGET IS SEEN. The process has one logger, `panel_test_helpers`'
+// `CaptureLogger`, installed once by `capture_logs()`. It records every record from every
+// target (the `fs` crate's included) on the thread that logs it, but its only reader of raw
+// lines, `panel_lines()`, keeps the lines containing "epic_panel". A second logger cannot be
+// installed: there is one per process and the sibling tests race to own it. So this test makes
+// the filter irrelevant to what it forbids instead: the temp directory's own name starts with
+// `epic_panel-`, hence every line carrying its absolute path — whichever crate wrote it —
+// contains "epic_panel" and is read. The forbidden set is observed in full.
+//
+// LIVENESS. A capture that never sees the `fs` crate would pass this test silently. A story-area
+// entry that is itself a symlink loop is named `099-epic_panel-loop`, so the `fs` warn for it
+// contains "epic_panel" whether it names the path or only the file name; the test requires that
+// warn to be seen.
+
+#![cfg(unix)]
+#![allow(clippy::disallowed_methods)]
+
+use std::{
+    fs as stdfs,
+    os::unix::fs::PermissionsExt as _,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+};
+
+use gpui::TestAppContext;
+
+use super::panel_test_helpers::*;
+use super::row_badge;
+use crate::scan::RowProblem;
+
+const LOOP_STORY: &str = "001-loop";
+const LOCKED_STORY: &str = "002-locked";
+/// A story-area entry that is a symlink to itself: `fs` warns about it, and its name makes the
+/// warn visible to `panel_lines()` (see LIVENESS above).
+const LOOP_ENTRY: &str = "099-epic_panel-loop";
+/// The derived value the fix logs is a file name. A name may hold a newline, which written
+/// raw would forge a second log line; this one names a field the panel's parser reads.
+const FORGING_ENTRY: &str = "098-epic_panel-forge\nevent=scan worktree=x";
+
+fn write(path: &Path, content: &str) {
+    let parent = path.parent().expect("a file has a parent");
+    stdfs::create_dir_all(parent).unwrap_or_else(|e| panic!("create {}: {e}", parent.display()));
+    stdfs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
+}
+
+fn symlink(target: &Path, link: &Path) {
+    std::os::unix::fs::symlink(target, link)
+        .unwrap_or_else(|e| panic!("symlink {} -> {}: {e}", link.display(), target.display()));
+}
+
+/// Gives a locked directory its permissions back, so the temp directory can be removed even
+/// when an assertion fails first.
+struct Unlock(PathBuf);
+
+impl Drop for Unlock {
+    fn drop(&mut self) {
+        let _ = stdfs::set_permissions(&self.0, stdfs::Permissions::from_mode(0o755));
+    }
+}
+
+struct Corpus {
+    project: PathBuf,
+    /// The absolute paths of every hostile link and of the locked target.
+    hostile: Vec<PathBuf>,
+    /// True when the locked target really errors (false when running as root).
+    locked_errors: bool,
+    _unlock: Unlock,
+}
+
+/// `<root>/project/.epico/stories/` with:
+///   - `001-loop`: a `story.md`, and a `tasks.md` that is a symlink to itself (a loop);
+///   - `002-locked`: a `story.md`, and a `tasks.md` that is a symlink, by absolute path, to a
+///     file inside a directory with mode 000 (the user may not read the target);
+///   - `099-epic_panel-loop` and the forging name: entries that are symlinks to themselves.
+/// Every link target is absolute, so the link text itself carries the temp path.
+fn build(root: &Path) -> Corpus {
+    let project = root.join("project");
+    write(&project.join(".gitignore"), ".epico/\n");
+    let stories = project.join(".epico/stories");
+    let story = |status: &str| fm(&[("status", status)], "# Story - Hostile link\n");
+
+    write(
+        &stories.join(LOOP_STORY).join("story.md"),
+        &story("in-progress"),
+    );
+    let loop_tasks = stories.join(LOOP_STORY).join("tasks.md");
+    symlink(&loop_tasks, &loop_tasks);
+
+    write(
+        &stories.join(LOCKED_STORY).join("story.md"),
+        &story("draft"),
+    );
+    let locked_dir = root.join("locked");
+    let locked_target = locked_dir.join("tasks.md");
+    write(&locked_target, "- [ ] hidden\n");
+    symlink(&locked_target, &stories.join(LOCKED_STORY).join("tasks.md"));
+    stdfs::set_permissions(&locked_dir, stdfs::Permissions::from_mode(0o000))
+        .unwrap_or_else(|e| panic!("chmod 000 {}: {e}", locked_dir.display()));
+    let unlock = Unlock(locked_dir);
+    let locked_errors = stdfs::metadata(&locked_target).is_err();
+
+    let mut hostile = vec![
+        loop_tasks,
+        stories.join(LOCKED_STORY).join("tasks.md"),
+        locked_target,
+    ];
+    for entry in [LOOP_ENTRY, FORGING_ENTRY] {
+        let link = stories.join(entry);
+        symlink(&link, &link);
+        hostile.push(link);
+    }
+
+    Corpus {
+        project,
+        hostile,
+        locked_errors,
+        _unlock: unlock,
+    }
+}
+
+fn find_line(rig: &mut Rig, dir_name: &str) -> bool {
+    find_row(rig, dir_name).is_some()
+}
+
+// R8.5, R8.4 — opening the panel on a story whose `tasks.md` is a symlink loop, and on one whose
+// target the user may not read, logs no line from any crate that carries an absolute path; the
+// stories still show their row problem.
+#[gpui::test]
+async fn panel_fs_log_hostile_symlinks_log_no_absolute_path(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    capture_logs();
+    init_test(cx);
+
+    let temp = tempfile::Builder::new()
+        .prefix("epic_panel-fs-log-")
+        .tempdir()
+        .expect("temp dir");
+    let corpus = build(temp.path());
+    let mut forbidden = vec![temp.path().to_string_lossy().into_owned()];
+    if let Ok(canonical) = stdfs::canonicalize(temp.path()) {
+        forbidden.push(canonical.to_string_lossy().into_owned());
+    }
+    for path in &forbidden {
+        assert!(
+            path.contains("epic_panel"),
+            "the capture sees this path only through its name: {path}"
+        );
+    }
+
+    let fs: Arc<dyn fs::Fs> = fs::RealFs::new(None, cx.executor());
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+    let project = project::Project::test(fs.clone(), [corpus.project.as_path()], cx).await;
+    let window = cx
+        .add_window(|window, cx| workspace::MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |multi, _| multi.workspace().clone())
+        .expect("workspace");
+    let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
+    let panel = load(&workspace, &mut vcx).await;
+    workspace.update_in(&mut vcx, |workspace, window, cx| {
+        workspace.add_panel(panel.clone(), window, cx)
+    });
+    vcx.run_until_parked();
+    let fake = fs::FakeFs::new(vcx.executor());
+    let mut rig = Rig {
+        fs: fake,
+        project,
+        workspace,
+        panel,
+        cx: vcx,
+    };
+
+    // Real I/O: give the scan real time rather than virtual time.
+    for _ in 0..100 {
+        if find_line(&mut rig, LOOP_STORY) && find_line(&mut rig, LOCKED_STORY) {
+            break;
+        }
+        std::thread::sleep(Duration::from_millis(50));
+        rig.cx.run_until_parked();
+    }
+    // Open the panel and read again, so later reads log too.
+    focus_panel(&mut rig);
+    press(&mut rig, super::Refresh);
+    for _ in 0..10 {
+        std::thread::sleep(Duration::from_millis(50));
+        rig.cx.run_until_parked();
+    }
+
+    // The stories still show their row problem.
+    for dir_name in [LOOP_STORY, LOCKED_STORY] {
+        let row = find_row(&mut rig, dir_name)
+            .unwrap_or_else(|| panic!("{dir_name} is listed: {:?}", lines(&mut rig)));
+        assert_eq!(
+            row.problem,
+            Some(RowProblem::Symlink("tasks.md".to_string())),
+            "{dir_name}: {row:?}"
+        );
+        assert_eq!(row_badge(&row), Some("symlink not followed"), "{dir_name}");
+    }
+
+    let captured = panel_lines();
+
+    // Liveness: the `fs` crate's warn for the looping entry was captured, so the scan below
+    // looked at the `fs` crate's lines and not only the panel's.
+    assert!(
+        captured
+            .iter()
+            .any(|(level, line)| *level == log::Level::Warn
+                && line.contains(LOOP_ENTRY)
+                && !line.starts_with("epic_panel ")),
+        "the capture saw the fs crate's warn for the looping entry {LOOP_ENTRY:?}: {captured:#?}"
+    );
+
+    // R8.5, R8.4 — no line, from any target, at debug or above, carries the temp directory's
+    // absolute path. Trace is left out on purpose: the `fs` watcher and the git ancestor lookup
+    // trace every directory they watch or consider, by absolute path, in any project and with
+    // or without a symlink; that is upstream behaviour the symlink does not trigger, and trace
+    // is never on in a shipped build.
+    for (level, line) in &captured {
+        if *level == log::Level::Trace {
+            continue;
+        }
+        for path in &forbidden {
+            assert!(
+                !line.contains(path.as_str()),
+                "R8.5 a {level} line carries the absolute path {path:?}: {line}"
+            );
+        }
+    }
+
+    // R8.5 — at every level, trace included, no line names a hostile link or the locked target
+    // by absolute path (as written, or under the canonical temp directory).
+    let canonical_root = stdfs::canonicalize(temp.path()).ok();
+    for link in &corpus.hostile {
+        let mut spellings = vec![link.to_string_lossy().into_owned()];
+        if let (Some(root), Ok(rest)) = (&canonical_root, link.strip_prefix(temp.path())) {
+            spellings.push(root.join(rest).to_string_lossy().into_owned());
+        }
+        for (level, line) in &captured {
+            for spelling in &spellings {
+                assert!(
+                    !line.contains(spelling.as_str()),
+                    "R8.5 a {level} line names the hostile link {spelling:?}: {line}"
+                );
+            }
+        }
+    }
+
+    // The derived file name is written escaped: a name holding a newline forges no line.
+    for (level, line) in &captured {
+        assert!(
+            !line.contains('\n'),
+            "a {level} line holds a raw newline (forged line): {line:?}"
+        );
+    }
+
+    if !corpus.locked_errors {
+        eprintln!(
+            "panel_fs_log: the locked target was readable (running as root?); only the loop exercised the error path"
+        );
+    }
+    drop(rig);
+}
diff --git a/crates/epic_panel/src/panel_live_tests.rs b/crates/epic_panel/src/panel_live_tests.rs
new file mode 100644
index 0000000..c37aa42
--- /dev/null
+++ b/crates/epic_panel/src/panel_live_tests.rs
@@ -0,0 +1,474 @@
+// Story 042, task 5.4 — live updates, Refresh, the watch-failure banner and the logs (R5.1-R5.6,
+// R8.1-R8.4; Q14; R6.5 over a full panel session). Module path: `panel::panel_live_tests`.
+// See panel_test_helpers.rs for the assumed contract.
+
+use std::{path::Path, sync::Arc, time::Duration};
+
+use gpui::TestAppContext;
+use serde_json::{Value, json};
+use ui::IconName;
+
+use super::panel_test_helpers::*;
+
+const SECRET: &str = "SECRET-CONTENT-MARKER-42";
+
+fn corpus() -> Value {
+    json!({
+        ".git": {},
+        ".gitignore": ".epico/\n",
+        ".epico": {
+            "stories": {
+                "001-a": {
+                    "story.md": fm(&[("status", "in-progress")], &format!("# Story - A\n{SECRET}\n")),
+                    "tasks.md": format!("- [ ] one\n- [ ] two\n{SECRET}\n"),
+                },
+                "002-b": { "story.md": fm(&[("status", "draft")], "") },
+            },
+            "archive": { "003-c": { "story.md": fm(&[("status", "archived")], "") } },
+        },
+    })
+}
+
+fn line_of(rig: &mut Rig, prefix: &str) -> Option<String> {
+    lines(rig).into_iter().find(|line| line.starts_with(prefix))
+}
+
+#[gpui::test]
+async fn panel_live_tasks_md_change_shows_within_two_seconds(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    assert_eq!(
+        line_of(&mut rig, "001 a").as_deref(),
+        Some("001 a | in-progress | 0/2")
+    );
+    rig.fs
+        .insert_file(
+            "/work/project/.epico/stories/001-a/tasks.md",
+            b"- [x] one\n- [ ] two\n".to_vec(),
+        )
+        .await;
+    settle(&mut rig.cx);
+    assert_eq!(
+        line_of(&mut rig, "001 a").as_deref(),
+        Some("001 a | in-progress | 1/2"),
+        "R5.1"
+    );
+
+    rig.fs
+        .insert_file(
+            "/work/project/.epico/stories/001-a/story.md",
+            fm(&[("status", "done")], "").into_bytes(),
+        )
+        .await;
+    settle(&mut rig.cx);
+    assert_eq!(
+        line_of(&mut rig, "001 a").as_deref(),
+        Some("001 a | done | 1/2"),
+        "R5.1 status"
+    );
+}
+
+#[gpui::test]
+async fn panel_live_directories_added_moved_removed(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    rig.fs
+        .insert_tree(
+            "/work/project/.epico/stories/004-new",
+            json!({ "story.md": fm(&[("status", "draft")], "") }),
+        )
+        .await;
+    settle(&mut rig.cx);
+    assert!(
+        line_of(&mut rig, "004 new").is_some(),
+        "R5.2 added: {:?}",
+        lines(&mut rig)
+    );
+
+    rig.fs
+        .rename(
+            Path::new("/work/project/.epico/stories/002-b"),
+            Path::new("/work/project/.epico/archive/002-b"),
+            Default::default(),
+        )
+        .await
+        .expect("archive 002-b");
+    settle(&mut rig.cx);
+    assert!(
+        line_of(&mut rig, "002 b").is_none(),
+        "moved out of the active rows"
+    );
+    assert!(
+        line_of(&mut rig, "Archived (2)").is_some(),
+        "R5.2 moved: {:?}",
+        lines(&mut rig)
+    );
+
+    rig.fs
+        .remove_dir(
+            Path::new("/work/project/.epico/stories/004-new"),
+            fs::RemoveOptions {
+                recursive: true,
+                ignore_if_not_exists: false,
+            },
+        )
+        .await
+        .expect("remove 004-new");
+    settle(&mut rig.cx);
+    assert!(line_of(&mut rig, "004 new").is_none(), "R5.2 removed");
+}
+
+// R5.3 — a stream of changes costs at most one read per 500 ms (each read logs one
+// `event=scan` line, R8.1), and the last change still lands.
+#[gpui::test]
+async fn panel_live_rereads_at_most_once_per_half_second(cx: &mut TestAppContext) {
+    capture_logs();
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    let scans_before = events("scan").len();
+    for round in 0..20u32 {
+        let ticked = if round == 19 { "x" } else { " " };
+        rig.fs
+            .insert_file(
+                "/work/project/.epico/stories/001-a/tasks.md",
+                format!("- [{ticked}] one\n- [ ] two {round}\n").into_bytes(),
+            )
+            .await;
+        rig.cx.executor().advance_clock(Duration::from_millis(50));
+        rig.cx.run_until_parked();
+    }
+    settle(&mut rig.cx);
+    let scans = events("scan").len() - scans_before;
+    // 20 changes over ~1 s, then the deadline: at most one read per 500 ms window.
+    assert!(
+        (1..=4).contains(&scans),
+        "R5.3: {scans} reads for 20 changes in 1 s"
+    );
+    assert_eq!(
+        line_of(&mut rig, "001 a").as_deref(),
+        Some("001 a | in-progress | 1/2"),
+        "the last change lands"
+    );
+}
+
+// R5.5 — Refresh re-reads even when no event arrives; its hostile half: without Refresh (and
+// with events paused) nothing changes.
+#[gpui::test]
+async fn panel_live_refresh_rereads_without_events(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    rig.fs.pause_events();
+    rig.fs
+        .insert_file(
+            "/work/project/.epico/stories/001-a/tasks.md",
+            b"- [x] one\n- [x] two\n".to_vec(),
+        )
+        .await;
+    settle(&mut rig.cx);
+    assert_eq!(
+        line_of(&mut rig, "001 a").as_deref(),
+        Some("001 a | in-progress | 0/2"),
+        "no event, no change"
+    );
+
+    focus_panel(&mut rig);
+    press(&mut rig, super::Refresh);
+    settle(&mut rig.cx);
+    assert_eq!(
+        line_of(&mut rig, "001 a").as_deref(),
+        Some("001 a | in-progress | 2/2"),
+        "R5.5"
+    );
+    rig.fs.unpause_events_and_flush();
+}
+
+// R5.4 — `.epico/` removed from every worktree: the empty state and no dock button.
+#[gpui::test]
+async fn panel_live_epic_removed_shows_the_empty_state(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    assert_eq!(icon(&mut rig), Some(IconName::ListTree));
+    rig.fs
+        .remove_dir(
+            Path::new("/work/project/.epico"),
+            fs::RemoveOptions {
+                recursive: true,
+                ignore_if_not_exists: false,
+            },
+        )
+        .await
+        .expect("remove .epico");
+    settle(&mut rig.cx);
+    assert_eq!(lines(&mut rig), ["No .epico/ directory in this project"]);
+    assert_eq!(icon(&mut rig), None);
+}
+
+// R5.6, R8.3 — a failed watch shows the banner, logs once, and the list and Refresh keep
+// working. The failure is provoked the way watch_tests.rs does it (a refused backend watch of
+// stories/), so it is Linux-only.
+#[cfg(target_os = "linux")]
+#[gpui::test]
+async fn panel_live_watch_failure_banner_and_refresh(cx: &mut TestAppContext) {
+    capture_logs();
+    init_test(cx);
+    let fs = fs::FakeFs::new(cx.executor());
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+    fs.insert_tree("/work/project", corpus()).await;
+    fs.create_file_before_next_watch_add(
+        "/work/project/.epico/stories",
+        "/work/project/.epico/stories/001-a/tasks.md",
+    );
+    let project = project::Project::test(fs.clone(), [Path::new("/work/project")], cx).await;
+    let window = cx
+        .add_window(|window, cx| workspace::MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |multi, _| multi.workspace().clone())
+        .expect("workspace");
+    let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
+    let panel = load(&workspace, &mut vcx).await;
+    workspace.update_in(&mut vcx, |workspace, window, cx| {
+        workspace.add_panel(panel.clone(), window, cx)
+    });
+    settle(&mut vcx);
+    let mut rig = Rig {
+        fs,
+        project,
+        workspace,
+        panel,
+        cx: vcx,
+    };
+
+    let shown = banner(&mut rig).expect("R5.6: a banner is shown");
+    assert!(shown.starts_with("Live updates are off: "), "{shown}");
+    assert!(shown.ends_with(". Use Refresh."), "{shown}");
+    assert!(
+        line_of(&mut rig, "001 a").is_some(),
+        "the list is still shown"
+    );
+
+    let failures = events("watch_failed");
+    assert_eq!(failures.len(), 1, "R8.3 one warn line: {failures:?}");
+    assert_eq!(failures[0].0, log::Level::Warn);
+    assert_eq!(field(&failures[0].1, "worktree"), Some("project"));
+    assert!(field(&failures[0].1, "reason").is_some());
+    assert!(
+        !failures[0].1.contains("/work/"),
+        "R8.4 no absolute path: {}",
+        failures[0].1
+    );
+
+    rig.fs.pause_events();
+    rig.fs
+        .insert_file(
+            "/work/project/.epico/stories/001-a/tasks.md",
+            b"- [x] one\n- [x] two\n".to_vec(),
+        )
+        .await;
+    focus_panel(&mut rig);
+    press(&mut rig, super::Refresh);
+    settle(&mut rig.cx);
+    assert_eq!(
+        line_of(&mut rig, "001 a").as_deref(),
+        Some("001 a | in-progress | 2/2"),
+        "Refresh still works"
+    );
+    rig.fs.unpause_events_and_flush();
+}
+
+// R8.1, R8.2, R8.4, Q14 — the log lines.
+#[gpui::test]
+async fn panel_live_logs_are_structured_and_carry_no_content_or_absolute_path(
+    cx: &mut TestAppContext,
+) {
+    capture_logs();
+    let mut tree = corpus();
+    tree[".epico"]["stories"]["005-binary"] = json!({ "story.md": fm(&[("status", "draft")], "") });
+    let mut rig = rig(cx, &[("/work/project", tree)], &["/work/project"]).await;
+    rig.fs
+        .insert_file(
+            "/work/project/.epico/stories/005-binary/tasks.md",
+            b"\xff\xfe".to_vec(),
+        )
+        .await;
+    settle(&mut rig.cx);
+
+    let scans = events("scan");
+    assert!(!scans.is_empty(), "R8.1 a scan line is logged");
+    assert_eq!(
+        scans[0].0,
+        log::Level::Info,
+        "R8.1 the first read logs at info"
+    );
+    let first = &scans[0].1;
+    assert_eq!(field(first, "worktree"), Some("project"));
+    assert_eq!(field(first, "stories"), Some("3"), "{first}");
+    assert_eq!(field(first, "archived"), Some("1"), "{first}");
+    assert!(
+        field(first, "problems").is_some_and(|n| n.parse::<u32>().is_ok()),
+        "{first}"
+    );
+    assert!(
+        field(first, "duration_ms").is_some_and(|n| n.parse::<u64>().is_ok()),
+        "{first}"
+    );
+    for (level, line) in &scans[1..] {
+        assert_eq!(
+            *level,
+            log::Level::Debug,
+            "R8.1 later reads log at debug: {line}"
+        );
+    }
+
+    // R8.2 names no field keys: a warn line naming the story directory and the file.
+    let warns_for_binary = || {
+        panel_lines()
+            .into_iter()
+            .filter(|(level, line)| {
+                *level == log::Level::Warn
+                    && line.contains("005-binary")
+                    && line.contains("tasks.md")
+            })
+            .count()
+    };
+    assert_eq!(
+        warns_for_binary(),
+        1,
+        "R8.2 one warn line for the new problem: {:?}",
+        panel_lines()
+    );
+
+    // The same problem on the next read is not logged again (once per change of reason).
+    focus_panel(&mut rig);
+    press(&mut rig, super::Refresh);
+    settle(&mut rig.cx);
+    assert_eq!(
+        warns_for_binary(),
+        1,
+        "R8.2 not repeated while the reason is unchanged"
+    );
+
+    for (_, line) in panel_lines() {
+        assert!(
+            !line.contains(SECRET),
+            "R8.4 no story content in the log: {line}"
+        );
+        assert!(
+            !line.contains("/work/project"),
+            "R8.4 no absolute path in the log: {line}"
+        );
+        if let Some(start) = line.find("event=") {
+            assert!(
+                parses_as_key_values(&line[start..]),
+                "Q14 key=value fields (quote values with spaces): {line}"
+            );
+        }
+    }
+}
+
+// R6.5 over a full panel session on the real file system: load, expand the archive, open a
+// story, Refresh, let the watcher run — and every path keeps its content, name and mtime.
+#[gpui::test]
+async fn panel_live_session_never_writes_on_real_fs(cx: &mut TestAppContext) {
+    cx.executor().allow_parking();
+    init_test(cx);
+    let temp = tempfile::tempdir().expect("temp dir");
+    let epic = crate::never_writes_tests::build_corpus(temp.path());
+    let project_root = epic.parent().expect(".epico has a parent").to_path_buf();
+    std::fs::write(project_root.join(".gitignore"), ".epico/\n").expect("write .gitignore");
+    let before = crate::never_writes_tests::tree_state(temp.path());
+
+    let fs: Arc<dyn fs::Fs> = fs::RealFs::new(None, cx.executor());
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+    let project = project::Project::test(fs.clone(), [project_root.as_path()], cx).await;
+    let window = cx
+        .add_window(|window, cx| workspace::MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |multi, _| multi.workspace().clone())
+        .expect("workspace");
+    let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
+    let panel = load(&workspace, &mut vcx).await;
+    workspace.update_in(&mut vcx, |workspace, window, cx| {
+        workspace.add_panel(panel.clone(), window, cx)
+    });
+    vcx.run_until_parked();
+    let fake = fs::FakeFs::new(vcx.executor());
+    let mut rig = Rig {
+        fs: fake,
+        project,
+        workspace,
+        panel,
+        cx: vcx,
+    };
+    // Real I/O: give the scan real time rather than virtual time.
+    for _ in 0..40 {
+        if line_of(&mut rig, "001 normal").is_some() {
+            break;
+        }
+        std::thread::sleep(Duration::from_millis(50));
+        rig.cx.run_until_parked();
+    }
+    assert!(
+        line_of(&mut rig, "001 normal").is_some(),
+        "the panel read the corpus: {:?}",
+        lines(&mut rig)
+    );
+    activate_line(&mut rig, "Archived (");
+    activate_line(&mut rig, "001 normal");
+    focus_panel(&mut rig);
+    press(&mut rig, super::Refresh);
+    rig.cx.run_until_parked();
+    drop(rig);
+
+    let after = crate::never_writes_tests::tree_state(temp.path());
+    assert_eq!(
+        after.keys().collect::<Vec<_>>(),
+        before.keys().collect::<Vec<_>>(),
+        "R6.5 names"
+    );
+    for (path, state) in &before {
+        assert_eq!(
+            after.get(path),
+            Some(state),
+            "R6.5: {} changed during a panel session",
+            path.display()
+        );
+    }
+}
+
+/// `k=v k2="v with spaces" …` and nothing else (Q14).
+fn parses_as_key_values(mut rest: &str) -> bool {
+    loop {
+        rest = rest.trim_start();
+        if rest.is_empty() {
+            return true;
+        }
+        let Some(eq) = rest.find('=') else {
+            return false;
+        };
+        let key = &rest[..eq];
+        if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
+            return false;
+        }
+        rest = &rest[eq + 1..];
+        if let Some(quoted) = rest.strip_prefix('"') {
+            let Some(end) = quoted.find('"') else {
+                return false;
+            };
+            rest = &quoted[end + 1..];
+        } else {
+            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
+            rest = &rest[end..];
+        }
+    }
+}
+
+#[test]
+fn panel_live_key_value_parser_both_directions() {
+    assert!(parses_as_key_values(
+        "event=scan worktree=project stories=2 duration_ms=4"
+    ));
+    assert!(parses_as_key_values(
+        "event=watch_failed worktree=p reason=\"too many open files\""
+    ));
+    assert!(!parses_as_key_values(
+        "event=problem reason=stream did not contain valid UTF-8"
+    ));
+    assert!(!parses_as_key_values(
+        "event=scan worktree=p reason=\"unterminated"
+    ));
+}
diff --git a/crates/epic_panel/src/panel_open_tests.rs b/crates/epic_panel/src/panel_open_tests.rs
new file mode 100644
index 0000000..b1b347b
--- /dev/null
+++ b/crates/epic_panel/src/panel_open_tests.rs
@@ -0,0 +1,130 @@
+// Story 042, task 5.3 — activating rows opens files (R2.6, R2.7, R2.8, R6.3).
+// Module path: `panel::panel_open_tests`. See panel_test_helpers.rs for the assumed contract.
+
+use std::path::PathBuf;
+
+use gpui::TestAppContext;
+use serde_json::{Value, json};
+
+use super::panel_test_helpers::*;
+
+fn corpus() -> Value {
+    json!({
+        ".gitignore": ".epico/\n",
+        "src": { "main.rs": "fn main() {}\n" },
+        ".epico": {
+            "EPICO.md": "# Epic\n",
+            "stories": {
+                "001-both": {
+                    "story.md": fm(&[("status", "draft")], "# Story - Both\n"),
+                    "tasks.md": fm(&[("status", "draft")], "- [ ] a\n"),
+                },
+                "002-tasks-only": { "tasks.md": fm(&[("status", "done")], "- [x] a\n") },
+                "003-nothing": { "notes.txt": "x" },
+            },
+        },
+    })
+}
+
+// Hostile half first: with both files present it is story.md, never tasks.md.
+#[gpui::test]
+async fn panel_open_prefers_story_md(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    activate_line(&mut rig, "001 both");
+    assert_eq!(
+        active_path(&mut rig).as_deref(),
+        Some(".epico/stories/001-both/story.md"),
+        "R2.6"
+    );
+}
+
+#[gpui::test]
+async fn panel_open_falls_back_to_tasks_md(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    activate_line(&mut rig, "002 tasks-only");
+    assert_eq!(
+        active_path(&mut rig).as_deref(),
+        Some(".epico/stories/002-tasks-only/tasks.md"),
+        "R2.6"
+    );
+}
+
+#[gpui::test]
+async fn panel_open_a_row_without_story_files_opens_nothing(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    let before = item_count(&mut rig);
+    activate_line(&mut rig, "003 nothing");
+    assert_eq!(item_count(&mut rig), before, "R2.7: nothing is opened");
+}
+
+#[gpui::test]
+async fn panel_open_epic_md_control(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    activate_line(&mut rig, "Open EPICO.md");
+    assert_eq!(
+        active_path(&mut rig).as_deref(),
+        Some(".epico/EPICO.md"),
+        "R2.8"
+    );
+}
+
+// R6.3 — a symlinked story directory opens nothing, so no file is read through the link.
+#[gpui::test]
+async fn panel_open_a_symlinked_story_directory_opens_nothing(cx: &mut TestAppContext) {
+    let mut rig = rig(
+        cx,
+        &[
+            ("/work/project", corpus()),
+            (
+                "/outside/real",
+                json!({ "story.md": fm(&[("status", "validated")], "# Story - Outside\n") }),
+            ),
+        ],
+        &["/work/project"],
+    )
+    .await;
+    rig.fs
+        .insert_symlink(
+            "/work/project/.epico/stories/004-linked",
+            PathBuf::from("/outside/real"),
+        )
+        .await;
+    settle(&mut rig.cx);
+    let before = item_count(&mut rig);
+    activate_line(&mut rig, "004 linked");
+    assert_eq!(item_count(&mut rig), before);
+    assert_ne!(
+        active_path(&mut rig).as_deref(),
+        Some(".epico/stories/004-linked/story.md")
+    );
+}
+
+// Error path: the file vanished between the scan and the activation (events paused, so no
+// rescan intervenes). The workspace shows an error instead of failing silently.
+#[gpui::test]
+async fn panel_open_failure_is_shown_to_the_user(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    let notifications_before = rig
+        .workspace
+        .read_with(&rig.cx, |workspace, _| workspace.notification_ids().len());
+    rig.fs.pause_events();
+    rig.fs
+        .remove_file(
+            std::path::Path::new("/work/project/.epico/stories/002-tasks-only/tasks.md"),
+            fs::RemoveOptions {
+                recursive: false,
+                ignore_if_not_exists: false,
+            },
+        )
+        .await
+        .expect("remove tasks.md");
+    activate_line(&mut rig, "002 tasks-only");
+    let notifications_after = rig
+        .workspace
+        .read_with(&rig.cx, |workspace, _| workspace.notification_ids().len());
+    assert!(
+        notifications_after > notifications_before,
+        "an open failure becomes a visible error (workspace.show_error)"
+    );
+    rig.fs.unpause_events_and_flush();
+}
diff --git a/crates/epic_panel/src/panel_polish_tests.rs b/crates/epic_panel/src/panel_polish_tests.rs
new file mode 100644
index 0000000..86c354f
--- /dev/null
+++ b/crates/epic_panel/src/panel_polish_tests.rs
@@ -0,0 +1,256 @@
+// Story 042, task 10.1 — log values the frozen Q14 parser reads, and a right gutter for the
+// list's scrollbar (R8.3, R8.4, R2.1; Q5, Q14, Q16). Module path: `panel::panel_polish_tests`.
+//
+// Mounted from `src/panel.rs` as a child of `panel`, so it reaches the private helpers:
+//
+//     #[cfg(test)]
+//     #[path = "panel_polish_tests.rs"]
+//     #[rustfmt::skip]
+//     mod panel_polish_tests;
+//
+// SEAMS THE IMPLEMENTATION MUST EXPOSE (in `panel.rs`, private is enough):
+//   - `fn log_value(value: &str) -> String` — exists today.
+//   - `const ROW_END_PADDING: Pixels` — the right gutter every list row keeps, applied by
+//     `EpicPanel::render_entry` (the builder every row kind goes through) by naming it.
+//
+// Value form, from design.md §As Built (Log values) and task 10.1's Objective: a value holding
+// `=`, `"`, whitespace or a control character is written in double quotes; inside the quotes
+// `"` is `\u{22}`, `\` is `\u{5c}`, and every `\` begins a `\u{..}` escape. Every other value
+// is bare.
+
+use gpui::Pixels;
+use ui::ScrollbarStyle;
+
+use super::{ROW_END_PADDING, log_value};
+
+// ── the frozen Q14 parser ───────────────────────────────────────────────────
+
+/// `panel_live_tests::parses_as_key_values`, copied verbatim: it is private to that frozen
+/// module, so it cannot be called from here.
+fn frozen_parser(mut rest: &str) -> bool {
+    loop {
+        rest = rest.trim_start();
+        if rest.is_empty() {
+            return true;
+        }
+        let Some(eq) = rest.find('=') else {
+            return false;
+        };
+        let key = &rest[..eq];
+        if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
+            return false;
+        }
+        rest = &rest[eq + 1..];
+        if let Some(quoted) = rest.strip_prefix('"') {
+            let Some(end) = quoted.find('"') else {
+                return false;
+            };
+            rest = &quoted[end + 1..];
+        } else {
+            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
+            rest = &rest[end..];
+        }
+    }
+}
+
+/// The SAME rule as `frozen_parser`, step for step, but returning the `(key, token)` pairs it
+/// reads instead of a bool — so a test can see how many keys a line holds and where each
+/// value ends. A token keeps its quotes.
+fn frozen_tokens(mut rest: &str) -> Option<Vec<(&str, &str)>> {
+    let mut pairs = Vec::new();
+    loop {
+        rest = rest.trim_start();
+        if rest.is_empty() {
+            return Some(pairs);
+        }
+        let eq = rest.find('=')?;
+        let key = &rest[..eq];
+        if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
+            return None;
+        }
+        rest = &rest[eq + 1..];
+        let token;
+        if let Some(quoted) = rest.strip_prefix('"') {
+            let end = quoted.find('"')?;
+            token = &rest[..end + 2];
+            rest = &quoted[end + 1..];
+        } else {
+            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
+            token = &rest[..end];
+            rest = &rest[end..];
+        }
+        pairs.push((key, token));
+    }
+}
+
+/// Reads a token back to the value: a bare token is the value as it is; a quoted token's
+/// inside may hold only `\u{hex}` escapes, so `\"` and `\\` are rejected (`None`).
+fn decode_token(token: &str) -> Option<String> {
+    let Some(inner) = token
+        .strip_prefix('"')
+        .and_then(|rest| rest.strip_suffix('"'))
+    else {
+        return Some(token.to_string());
+    };
+    let mut value = String::new();
+    let mut rest = inner;
+    while let Some(slash) = rest.find('\\') {
+        value.push_str(&rest[..slash]);
+        let escape = rest[slash + 1..].strip_prefix("u{")?;
+        let close = escape.find('}')?;
+        let code = u32::from_str_radix(&escape[..close], 16).ok()?;
+        value.push(char::from_u32(code)?);
+        rest = &escape[close + 1..];
+    }
+    value.push_str(rest);
+    Some(value)
+}
+
+/// One value's checks on a line shaped like the panel's `event=problem` line.
+fn assert_one_token(raw: &str) {
+    let written = log_value(raw);
+    let line = format!("event=problem story={written} file=tasks.md");
+    assert!(
+        frozen_parser(&line),
+        "Q14: the frozen parser rejects {line:?}"
+    );
+    let tokens = frozen_tokens(&line).unwrap_or_else(|| panic!("not key=value: {line:?}"));
+    let keys: Vec<&str> = tokens.iter().map(|(key, _)| *key).collect();
+    assert_eq!(
+        keys,
+        ["event", "story", "file"],
+        "{raw:?} must stay one value, neither forging a key nor splitting: {line:?}"
+    );
+    assert_eq!(
+        tokens[1].1, written,
+        "the whole of {written:?} is the story value"
+    );
+    assert_eq!(
+        decode_token(&written).as_deref(),
+        Some(raw),
+        "{written:?} must read back to {raw:?} with only \\u{{..}} escapes"
+    );
+}
+
+// ── log values ──────────────────────────────────────────────────────────────
+
+/// Values that hold `"`, `\`, `=` and a space — the hostile ones first: each is built to close
+/// the quotes early and forge a key under the old `\"` escape.
+const HOSTILE: &[&str] = &[
+    // all four at once
+    r#"say "hi" \ k=v"#,
+    // closes the quotes and forges `event=`
+    r#"x" event=forged reason="y"#,
+    // a backslash right before the closing quote (`\"` would swallow it)
+    r#"trailing \"#,
+    // the old escape spelled out literally
+    r#"a \" b = c"#,
+    r#"C:\Program Files\"x"=1"#,
+];
+
+#[test]
+fn polish_quoted_value_has_no_raw_quote_or_backslash() {
+    for raw in HOSTILE {
+        let written = log_value(raw);
+        let inner = written
+            .strip_prefix('"')
+            .and_then(|rest| rest.strip_suffix('"'))
+            .unwrap_or_else(|| panic!("{raw:?} needs quotes, got {written:?}"));
+        assert!(
+            !inner.contains('"'),
+            "a raw `\"` inside the quotes of {written:?} (for {raw:?})"
+        );
+        for (at, _) in inner.match_indices('\\') {
+            assert!(
+                inner[at + 1..].starts_with("u{"),
+                "a raw `\\` at byte {at} inside {written:?}: every backslash must begin a \\u{{..}} escape"
+            );
+        }
+        assert!(
+            !raw.contains('"') || inner.contains("\\u{22}"),
+            "`\"` is written as \\u{{22}} in {written:?}"
+        );
+        assert!(
+            !raw.contains('\\') || inner.contains("\\u{5c}"),
+            "`\\` is written as \\u{{5c}} in {written:?}"
+        );
+    }
+}
+
+#[test]
+fn polish_frozen_parser_reads_every_line_as_three_keys() {
+    for raw in HOSTILE {
+        assert_one_token(raw);
+    }
+}
+
+/// The escape is a spelling the fix invents, so a THIRD value can collide with it: a value
+/// whose own text is `\u{22}` must not render the same as a value holding `"`.
+#[test]
+fn polish_escape_spelling_cannot_collide_with_a_literal_value() {
+    let pairs: &[(&str, &str)] = &[
+        (r#"a "b"#, r#"a \u{22}b"#),
+        (r#"a \b"#, r#"a \u{5c}b"#),
+        (r#"a"b"#, r#"a\u{22}b"#),
+        (r#"a\b c"#, r#"a\u{5c}b c"#),
+    ];
+    for (with_char, with_escape_text) in pairs {
+        let left = log_value(with_char);
+        let right = log_value(with_escape_text);
+        assert_ne!(
+            left, right,
+            "{with_char:?} and {with_escape_text:?} must stay distinguishable"
+        );
+        assert_one_token(with_char);
+        assert_one_token(with_escape_text);
+    }
+}
+
+#[test]
+fn polish_plain_value_stays_bare() {
+    for raw in ["project", "001-epic-panel", "tasks.md", "0", "watch_failed"] {
+        assert_eq!(log_value(raw), raw, "a plain value is written bare");
+        assert_one_token(raw);
+    }
+}
+
+// ── the scrollbar gutter ────────────────────────────────────────────────────
+
+/// The track the panel's `Scrollbars::always_visible` draws over the list's right edge: the
+/// default `ScrollbarStyle::Regular` thumb plus the `SCROLLBAR_PADDING` (4px, private to `ui`)
+/// it is inset by on each side. A gutter narrower than this leaves text under the thumb.
+fn scrollbar_track() -> f32 {
+    ScrollbarStyle::Regular.to_pixels().as_f32() + 2.0 * 4.0
+}
+
+#[test]
+fn polish_row_gutter_is_the_scrollbar_width() {
+    let gutter: Pixels = ROW_END_PADDING;
+    let track = scrollbar_track();
+    assert!(
+        gutter.as_f32() >= track,
+        "every row keeps a right gutter at least as wide as the scrollbar track ({track}px), got {gutter:?}"
+    );
+    // A gutter far wider than the track only takes width from the labels, scrollbar or not.
+    assert!(
+        gutter.as_f32() <= 2.0 * track,
+        "the gutter ({gutter:?}) is at most twice the track ({track}px)"
+    );
+}
+
+/// `render_entry` is the builder every row kind goes through (headers, stories, `Archived (N)`,
+/// "N more not shown", errors), so the gutter is applied there, by name.
+#[test]
+fn polish_row_builder_applies_the_gutter() {
+    let source = include_str!("panel.rs");
+    let start = source
+        .find("fn render_entry(")
+        .expect("panel.rs has `fn render_entry(`");
+    let body = &source[start..];
+    // The method ends at the first closing brace at its own indentation.
+    let end = body.find("\n    }\n").map_or(body.len(), |end| end + 6);
+    assert!(
+        body[..end].contains("ROW_END_PADDING"),
+        "render_entry must apply ROW_END_PADDING to every row"
+    );
+}
diff --git a/crates/epic_panel/src/panel_rows_tests.rs b/crates/epic_panel/src/panel_rows_tests.rs
new file mode 100644
index 0000000..878c5a4
--- /dev/null
+++ b/crates/epic_panel/src/panel_rows_tests.rs
@@ -0,0 +1,397 @@
+// Story 042, task 5.2 — rows, groups, empty states, badges, tooltips and the keyboard (R1.4,
+// R2.1, R2.3, R2.4, R2.5, R2.7, R2.8, R2.9, R2.10, R2.11, R2.12, R3.8, R6.1-R6.3; Q16).
+// Module path: `panel::panel_rows_tests`. See panel_test_helpers.rs for the assumed contract.
+
+use std::path::PathBuf;
+
+use gpui::TestAppContext;
+use serde_json::{Value, json};
+
+use super::panel_test_helpers::*;
+use super::{row_badge, row_tooltip, status_warning};
+
+const DASH: &str = "\u{2014}";
+
+fn corpus() -> Value {
+    json!({
+        ".gitignore": ".epico/\n",
+        ".epico": {
+            "EPICO.md": "# Epic\n",
+            "stories": {
+                "010-b": {
+                    "story.md": fm(&[("status", "done"), ("type", "feature"), ("scale", "standard"), ("engineering", "project")], "# Story - Bee story\n"),
+                    "tasks.md": "- [x] a\n- [ ] b\n",
+                },
+                "9-a": { "story.md": fm(&[("status", "draft")], "# Story - Ay\n") },
+                "0100-e": {},
+                "99-f": {},
+                "notes": {},
+                "README.md": "not a story",
+            },
+            "archive": {
+                "003-x": { "story.md": fm(&[("status", "archived")], "") },
+                "004-y": { "tasks.md": "- [x] y\n" },
+            },
+        },
+    })
+}
+
+#[gpui::test]
+async fn panel_rows_show_number_slug_status_progress_in_order(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    assert_eq!(
+        lines(&mut rig),
+        [
+            "Open EPICO.md".to_string(),
+            "9 a | draft | \u{2014}".to_string(),
+            "010 b | done | 1/2".to_string(),
+            format!("99 f | {DASH} | {DASH}"),
+            format!("0100 e | {DASH} | {DASH}"),
+            format!("{DASH} notes | {DASH} | {DASH}"),
+            "Archived (2)".to_string(),
+        ],
+        "R2.1/R2.2 order by value, unnumbered last; R2.3 archive collapsed on open; one worktree, no header"
+    );
+}
+
+#[gpui::test]
+async fn panel_rows_archived_group_toggles_on_activation(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    activate_line(&mut rig, "Archived (2)");
+    let expanded = lines(&mut rig);
+    let header = expanded
+        .iter()
+        .position(|line| line == "Archived (2) [expanded]")
+        .expect("expanded header");
+    assert_eq!(
+        &expanded[header + 1..],
+        [
+            "003 x | archived | \u{2014}".to_string(),
+            "004 y | \u{2014} | 1/1".to_string()
+        ],
+        "R2.4 expanded shows the archived rows, in order"
+    );
+    activate_line(&mut rig, "Archived (2)");
+    assert_eq!(
+        lines(&mut rig).last().map(String::as_str),
+        Some("Archived (2)"),
+        "R2.4 collapses again"
+    );
+}
+
+// Hostile half for R2.5: one worktree with `.epico/` gets no header, even beside a second
+// worktree without one.
+#[gpui::test]
+async fn panel_rows_no_worktree_header_when_only_one_worktree_has_epic(cx: &mut TestAppContext) {
+    let mut rig = rig(
+        cx,
+        &[
+            ("/work/alpha", corpus()),
+            ("/work/plain", json!({ "src": {} })),
+        ],
+        &["/work/alpha", "/work/plain"],
+    )
+    .await;
+    assert!(
+        !lines(&mut rig)
+            .iter()
+            .any(|line| line.starts_with("worktree ")),
+        "{:?}",
+        lines(&mut rig)
+    );
+}
+
+#[gpui::test]
+async fn panel_rows_worktree_headers_when_several_worktrees_have_epic(cx: &mut TestAppContext) {
+    let beta = json!({ ".epico": { "stories": { "001-only": { "story.md": fm(&[("status", "draft")], "") } } } });
+    let mut rig = rig(
+        cx,
+        &[("/work/alpha", corpus()), ("/work/beta", beta)],
+        &["/work/alpha", "/work/beta"],
+    )
+    .await;
+    let shown = lines(&mut rig);
+    assert!(
+        shown.iter().any(|line| line == "worktree alpha"),
+        "R2.5: {shown:?}"
+    );
+    let beta = shown
+        .iter()
+        .position(|line| line == "worktree beta")
+        .expect("R2.5 beta header");
+    let beta_section: Vec<&String> = shown[beta + 1..]
+        .iter()
+        .take_while(|line| !line.starts_with("worktree "))
+        .collect();
+    assert!(
+        beta_section
+            .iter()
+            .any(|line| *line == "001 only | draft | \u{2014}"),
+        "beta's rows sit under beta's header: {shown:?}"
+    );
+    assert!(
+        !beta_section.iter().any(|line| line.starts_with("010 b")),
+        "alpha's rows do not leak under beta: {shown:?}"
+    );
+}
+
+// R2.11, and its hostile half: an archive-only `.epico/` HAS stories.
+#[gpui::test]
+async fn panel_rows_no_stories_yet(cx: &mut TestAppContext) {
+    let tree = json!({ ".epico": { "EPICO.md": "# Epic\n", "stories": { "README.md": "x" } } });
+    let mut rig = rig(cx, &[("/work/project", tree)], &["/work/project"]).await;
+    let shown = lines(&mut rig);
+    assert!(
+        shown.iter().any(|line| line == "No stories yet"),
+        "{shown:?}"
+    );
+    assert!(
+        !shown.iter().any(|line| line.starts_with("Archived")),
+        "{shown:?}"
+    );
+}
+
+#[gpui::test]
+async fn panel_rows_archive_only_is_not_empty(cx: &mut TestAppContext) {
+    let tree = json!({ ".epico": { "archive": { "003-x": {} } } });
+    let mut rig = rig(cx, &[("/work/project", tree)], &["/work/project"]).await;
+    let shown = lines(&mut rig);
+    assert!(
+        !shown.iter().any(|line| line == "No stories yet"),
+        "{shown:?}"
+    );
+    assert!(shown.iter().any(|line| line == "Archived (1)"), "{shown:?}");
+}
+
+// R2.8 hostile half: no EPICO.md, no control.
+#[gpui::test]
+async fn panel_rows_open_index_only_when_epic_md_exists(cx: &mut TestAppContext) {
+    let tree = json!({ ".epico": { "stories": { "001-a": {} } } });
+    let mut rig = rig(cx, &[("/work/project", tree)], &["/work/project"]).await;
+    assert!(!lines(&mut rig).iter().any(|line| line == "Open EPICO.md"));
+}
+
+// R2.12 — the 2000-row cap and its final line.
+#[gpui::test]
+async fn panel_rows_more_not_shown(cx: &mut TestAppContext) {
+    let mut stories = serde_json::Map::new();
+    for number in 1..=2001 {
+        stories.insert(format!("{number:04}-s"), json!({}));
+    }
+    let tree = json!({ ".epico": { "stories": Value::Object(stories) } });
+    let mut rig = rig(cx, &[("/work/project", tree)], &["/work/project"]).await;
+    let shown = lines(&mut rig);
+    assert_eq!(shown.last().map(String::as_str), Some("1 more not shown"));
+    assert_eq!(
+        shown
+            .iter()
+            .filter(|line| line.ends_with("| \u{2014} | \u{2014}"))
+            .count(),
+        2000
+    );
+}
+
+// R2.7, R6.1-R6.3 — what a problem row says, beside an ordinary one.
+#[gpui::test]
+async fn panel_rows_badges_for_problem_rows(cx: &mut TestAppContext) {
+    let tree = json!({ ".epico": { "stories": {
+        "001-fine": { "story.md": fm(&[("status", "draft")], "") },
+        "002-binary": { "story.md": fm(&[("status", "draft")], "") },
+        "003-large": {},
+        "004-linked": { "story.md": fm(&[("status", "draft")], "") },
+        "005-nothing": { "notes.txt": "x" },
+    } } });
+    let mut rig = rig(
+        cx,
+        &[
+            ("/work/project", tree),
+            ("/outside", json!({ "secret.md": "- [x] s\n" })),
+        ],
+        &["/work/project"],
+    )
+    .await;
+    let epic = PathBuf::from("/work/project/.epico/stories");
+    rig.fs
+        .insert_file(epic.join("002-binary/tasks.md"), b"\xff\xfe".to_vec())
+        .await;
+    let mut large = b"- [x] a\n".to_vec();
+    large.resize(1024 * 1024 + 1, b' ');
+    rig.fs
+        .insert_file(epic.join("003-large/tasks.md"), large)
+        .await;
+    rig.fs
+        .insert_symlink(
+            epic.join("004-linked/tasks.md"),
+            PathBuf::from("/outside/secret.md"),
+        )
+        .await;
+    focus_panel(&mut rig);
+    press(&mut rig, super::Refresh);
+    settle(&mut rig.cx);
+
+    let badge = |rig: &mut Rig, dir: &str| find_row(rig, dir).as_ref().and_then(row_badge);
+    assert_eq!(badge(&mut rig, "001-fine"), None);
+    assert_eq!(badge(&mut rig, "002-binary"), Some("unreadable"));
+    assert_eq!(badge(&mut rig, "003-large"), Some("too large"));
+    assert_eq!(badge(&mut rig, "004-linked"), Some("symlink not followed"));
+    assert_eq!(badge(&mut rig, "005-nothing"), Some("no story files"));
+
+    let tooltip = row_tooltip(&find_row(&mut rig, "002-binary").expect("row"));
+    assert!(
+        tooltip.contains("tasks.md"),
+        "R6.1 the tooltip names the file: {tooltip}"
+    );
+    assert!(
+        tooltip.to_lowercase().contains("utf-8"),
+        "R6.1 and the reason: {tooltip}"
+    );
+    assert!(
+        !tooltip.contains("/work/project"),
+        "no absolute path in the tooltip: {tooltip}"
+    );
+}
+
+#[gpui::test]
+async fn panel_rows_tooltip_carries_title_and_frontmatter(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    let tooltip = row_tooltip(&find_row(&mut rig, "010-b").expect("row"));
+    for needle in ["Bee story", "feature", "standard", "project"] {
+        assert!(tooltip.contains(needle), "R2.9 {needle:?} in {tooltip:?}");
+    }
+    let bare = row_tooltip(&find_row(&mut rig, "0100-e").expect("row"));
+    assert!(
+        !bare.contains("feature"),
+        "absent values are not invented: {bare:?}"
+    );
+}
+
+// R3.8 — the warning is for unknown statuses only.
+#[gpui::test]
+async fn panel_rows_unknown_status_warning(cx: &mut TestAppContext) {
+    let tree = json!({ ".epico": { "stories": {
+        "001-odd": { "story.md": fm(&[("status", "blocked")], "") },
+        "002-known": { "story.md": fm(&[("status", "in-progress")], "") },
+        "003-near": { "story.md": fm(&[("status", "Done")], "") },
+    } } });
+    let mut rig = rig(cx, &[("/work/project", tree)], &["/work/project"]).await;
+    let warning =
+        |rig: &mut Rig, dir: &str| status_warning(&find_row(rig, dir).expect("row").status);
+    let expected = Some("Status not known to this Zeo; the epic plugin may be newer");
+    assert_eq!(warning(&mut rig, "001-odd"), expected);
+    assert_eq!(
+        warning(&mut rig, "003-near"),
+        expected,
+        "a near miss is still unknown"
+    );
+    assert_eq!(warning(&mut rig, "002-known"), None);
+    assert!(
+        lines(&mut rig)
+            .iter()
+            .any(|line| line == "001 odd | blocked | \u{2014}"),
+        "shown as written"
+    );
+}
+
+// R2.10, Q16 — Up/Down (menu::SelectPrevious/SelectNext) move the selection over rows and
+// headers alike.
+#[gpui::test]
+async fn panel_rows_keyboard_moves_the_selection(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    select_line(&mut rig, "9 a");
+    press(&mut rig, menu::SelectNext);
+    assert!(selected_line(&mut rig).is_some_and(|line| line.starts_with("010 b")));
+    press(&mut rig, menu::SelectPrevious);
+    assert!(selected_line(&mut rig).is_some_and(|line| line.starts_with("9 a")));
+    select_line(&mut rig, "Open EPICO.md");
+    select_line(&mut rig, "Archived (2)");
+}
+
+// Behaviour rule: the selection survives a rescan by directory name, and falls on the Archived
+// header when its story moves into a collapsed group.
+#[gpui::test]
+async fn panel_rows_selection_survives_a_rescan(cx: &mut TestAppContext) {
+    let mut rig = rig(cx, &[("/work/project", corpus())], &["/work/project"]).await;
+    select_line(&mut rig, "010 b");
+    rig.fs
+        .insert_file(
+            "/work/project/.epico/stories/010-b/tasks.md",
+            b"- [x] a\n- [x] b\n".to_vec(),
+        )
+        .await;
+    settle(&mut rig.cx);
+    assert_eq!(
+        selected_line(&mut rig).as_deref(),
+        Some("010 b | done | 2/2")
+    );
+
+    rig.fs
+        .rename(
+            std::path::Path::new("/work/project/.epico/stories/010-b"),
+            std::path::Path::new("/work/project/.epico/archive/010-b"),
+            Default::default(),
+        )
+        .await
+        .expect("archive 010-b");
+    settle(&mut rig.cx);
+    assert_eq!(
+        selected_line(&mut rig).as_deref(),
+        Some("Archived (3)"),
+        "collapsed: the header"
+    );
+
+    activate_line(&mut rig, "Archived (3)");
+    select_line(&mut rig, "010 b");
+    rig.fs
+        .rename(
+            std::path::Path::new("/work/project/.epico/archive/010-b"),
+            std::path::Path::new("/work/project/.epico/stories/010-b"),
+            Default::default(),
+        )
+        .await
+        .expect("unarchive 010-b");
+    settle(&mut rig.cx);
+    assert!(
+        selected_line(&mut rig).is_some_and(|line| line.starts_with("010 b")),
+        "the selection follows the row: {:?}",
+        lines(&mut rig)
+    );
+}
+
+#[gpui::test]
+async fn panel_rows_legacy_epic_layout_is_still_read(cx: &mut TestAppContext) {
+    // A project the renamed plugin has not migrated yet keeps `.epic/EPIC.md`.
+    let legacy = json!({
+        ".epic": {
+            "EPIC.md": "# Epic\n",
+            "stories": { "001-old": { "story.md": fm(&[("status", "done")], "") } },
+        },
+    });
+    let mut rig = rig(cx, &[("/work/project", legacy)], &["/work/project"]).await;
+    assert_eq!(
+        lines(&mut rig),
+        [
+            "Open EPIC.md".to_string(),
+            format!("001 old | done | {DASH}")
+        ]
+    );
+}
+
+#[gpui::test]
+async fn panel_rows_epico_wins_when_both_layouts_exist(cx: &mut TestAppContext) {
+    // With both present the plugin moves nothing and writes `.epico/`, so that is the one read.
+    let both = json!({
+        ".epic": { "EPIC.md": "# Epic\n", "stories": { "001-stale": {} } },
+        ".epico": {
+            "EPICO.md": "# Epico\n",
+            "stories": { "002-current": { "story.md": fm(&[("status", "draft")], "") } },
+        },
+    });
+    let mut rig = rig(cx, &[("/work/project", both)], &["/work/project"]).await;
+    assert_eq!(
+        lines(&mut rig),
+        [
+            "Open EPICO.md".to_string(),
+            format!("002 current | draft | {DASH}")
+        ]
+    );
+}
diff --git a/crates/epic_panel/src/panel_shell_tests.rs b/crates/epic_panel/src/panel_shell_tests.rs
new file mode 100644
index 0000000..da89344
--- /dev/null
+++ b/crates/epic_panel/src/panel_shell_tests.rs
@@ -0,0 +1,279 @@
+// Story 042, task 5.1 — the panel shell, its registration and the dock button (R1.1, R1.2,
+// R1.3, R1.5, R5.4; Assumptions A3, A6, A8). Module path: `panel::panel_shell_tests`.
+// See panel_test_helpers.rs for the mount lines and the assumed contract.
+
+use gpui::{Focusable as _, TestAppContext, px};
+use serde_json::json;
+use ui::IconName;
+use workspace::dock::{DockPosition, Panel};
+
+use super::EpicPanel;
+use super::panel_test_helpers::*;
+
+fn project_with_epic() -> serde_json::Value {
+    json!({
+        ".gitignore": ".epico/\n",
+        "src": { "main.rs": "" },
+        ".epico": { "stories": { "001-a": { "story.md": "---\nstatus: draft\n---\n" } } },
+    })
+}
+
+fn project_without_epic() -> serde_json::Value {
+    json!({ ".gitignore": ".epico/\n", "src": { "main.rs": "" } })
+}
+
+#[gpui::test]
+async fn panel_shell_identity_dock_and_toggle_action(cx: &mut TestAppContext) {
+    let mut rig = rig(
+        cx,
+        &[("/work/project", project_with_epic())],
+        &["/work/project"],
+    )
+    .await;
+    assert_eq!(<EpicPanel as Panel>::persistent_name(), "EpicPanel");
+    assert_eq!(<EpicPanel as Panel>::panel_key(), "EpicPanel");
+    rig.panel.update_in(&mut rig.cx, |panel, window, cx| {
+        assert_eq!(
+            panel.position(window, cx),
+            DockPosition::Right,
+            "A8: right dock when nothing was saved"
+        );
+        assert_eq!(panel.default_size(window, cx), px(300.), "A8: 300 px");
+        for position in [
+            DockPosition::Left,
+            DockPosition::Right,
+            DockPosition::Bottom,
+        ] {
+            assert!(
+                panel.position_is_valid(position),
+                "{position:?} is a valid dock (R1.5)"
+            );
+        }
+        assert_eq!(panel.icon_tooltip(window, cx), Some("Epico Stories"));
+        assert!(panel.toggle_action().partial_eq(&super::ToggleFocus));
+    });
+}
+
+// R1.1 — the palette action `epic panel: toggle focus` opens and focuses the panel.
+#[gpui::test]
+async fn panel_shell_toggle_focus_action_opens_and_focuses_the_panel(cx: &mut TestAppContext) {
+    let mut rig = rig(
+        cx,
+        &[("/work/project", project_with_epic())],
+        &["/work/project"],
+    )
+    .await;
+    assert_eq!(
+        gpui::Action::name(&super::ToggleFocus),
+        "epic_panel::ToggleFocus",
+        "the palette shows it as `epic panel: toggle focus`"
+    );
+    press(&mut rig, super::ToggleFocus);
+    let (open, focused) = rig
+        .workspace
+        .update_in(&mut rig.cx, |workspace, window, cx| {
+            let dock = workspace.right_dock().read(cx);
+            let open = dock.is_open()
+                && dock
+                    .visible_panel()
+                    .is_some_and(|panel| panel.panel_key() == "EpicPanel");
+            let focused = workspace
+                .panel::<EpicPanel>(cx)
+                .is_some_and(|panel| panel.focus_handle(cx).contains_focused(window, cx));
+            (open, focused)
+        });
+    assert!(open, "the right dock shows the Epic panel");
+    assert!(focused, "the panel has focus");
+}
+
+// R1.2 — hostile halves first: trees that look like "a project with .epico" and are not.
+async fn assert_no_dock_button(cx: &mut TestAppContext, case: &str, tree: serde_json::Value) {
+    let mut rig = rig(cx, &[("/work/project", tree)], &["/work/project"]).await;
+    assert_eq!(icon(&mut rig), None, "{case}: no dock button");
+    assert_eq!(
+        lines(&mut rig),
+        ["No .epico/ directory in this project"],
+        "{case}: R1.4 empty state"
+    );
+}
+
+#[gpui::test]
+async fn panel_shell_dock_button_hidden_without_epic(cx: &mut TestAppContext) {
+    assert_no_dock_button(cx, "no .epico at all", project_without_epic()).await;
+}
+
+#[gpui::test]
+async fn panel_shell_dock_button_hidden_for_a_file_named_epic(cx: &mut TestAppContext) {
+    assert_no_dock_button(
+        cx,
+        "a FILE named .epico",
+        json!({ ".epico": "not a directory", "src": {} }),
+    )
+    .await;
+}
+
+#[gpui::test]
+async fn panel_shell_dock_button_hidden_for_an_epic_below_the_root(cx: &mut TestAppContext) {
+    assert_no_dock_button(
+        cx,
+        "an .epico below the root (A6)",
+        json!({ "sub": { ".epico": { "stories": { "001-a": {} } } } }),
+    )
+    .await;
+}
+
+// Converse: a root `.epico/` directory shows the button — gitignored or not.
+#[gpui::test]
+async fn panel_shell_dock_button_shown_with_a_root_epic_directory(cx: &mut TestAppContext) {
+    let mut rig = rig(
+        cx,
+        &[("/work/project", project_with_epic())],
+        &["/work/project"],
+    )
+    .await;
+    assert_eq!(icon(&mut rig), Some(IconName::ListTree), "A8: ListTree");
+}
+
+#[gpui::test]
+async fn panel_shell_dock_button_appears_within_two_seconds_for_a_gitignored_epic(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = rig(
+        cx,
+        &[(
+            "/work/project",
+            json!({ ".git": {}, ".gitignore": ".epico/\n", "src": {} }),
+        )],
+        &["/work/project"],
+    )
+    .await;
+    assert_eq!(icon(&mut rig), None);
+
+    rig.fs
+        .insert_tree(
+            "/work/project/.epico",
+            json!({ "stories": { "001-a": {} } }),
+        )
+        .await;
+    settle(&mut rig.cx);
+    assert_eq!(
+        icon(&mut rig),
+        Some(IconName::ListTree),
+        "R1.3: within 2 s, though .epico/ is gitignored"
+    );
+}
+
+#[gpui::test]
+async fn panel_shell_dock_button_hides_when_epic_is_removed(cx: &mut TestAppContext) {
+    let mut rig = rig(
+        cx,
+        &[("/work/project", project_with_epic())],
+        &["/work/project"],
+    )
+    .await;
+    assert_eq!(icon(&mut rig), Some(IconName::ListTree));
+    rig.fs
+        .remove_dir(
+            std::path::Path::new("/work/project/.epico"),
+            fs::RemoveOptions {
+                recursive: true,
+                ignore_if_not_exists: false,
+            },
+        )
+        .await
+        .expect("remove .epico");
+    settle(&mut rig.cx);
+    assert_eq!(icon(&mut rig), None, "R5.4");
+}
+
+// R1.2 across worktrees: any local worktree with `.epico/` shows the button, and removing that
+// worktree hides it again.
+#[gpui::test]
+async fn panel_shell_dock_button_follows_worktrees(cx: &mut TestAppContext) {
+    let mut rig = rig(
+        cx,
+        &[
+            ("/work/with", project_with_epic()),
+            ("/work/without", project_without_epic()),
+        ],
+        &["/work/without"],
+    )
+    .await;
+    assert_eq!(icon(&mut rig), None);
+    add_worktree(&mut rig, "/work/with").await;
+    assert_eq!(
+        icon(&mut rig),
+        Some(IconName::ListTree),
+        "a worktree with .epico/ was added"
+    );
+    remove_worktree(&mut rig, "/work/with").await;
+    assert_eq!(
+        icon(&mut rig),
+        None,
+        "the only worktree with .epico/ was removed"
+    );
+}
+
+async fn reload(rig: &mut Rig) -> gpui::Entity<EpicPanel> {
+    load(&rig.workspace.clone(), &mut rig.cx).await
+}
+
+fn move_to(rig: &mut Rig, position: DockPosition) {
+    rig.panel.update_in(&mut rig.cx, |panel, window, cx| {
+        panel.set_position(position, window, cx)
+    });
+    rig.cx.run_until_parked();
+}
+
+// R1.5 — the dock survives a restart; Right when nothing was saved.
+#[gpui::test]
+async fn panel_shell_dock_position_survives_a_reload(cx: &mut TestAppContext) {
+    let mut rig = rig(
+        cx,
+        &[("/work/project", project_with_epic())],
+        &["/work/project"],
+    )
+    .await;
+    for position in [
+        DockPosition::Left,
+        DockPosition::Bottom,
+        DockPosition::Right,
+    ] {
+        move_to(&mut rig, position);
+        let moved = rig.workspace.read_with(&rig.cx, |workspace, cx| {
+            let dock = match position {
+                DockPosition::Left => workspace.left_dock(),
+                DockPosition::Right => workspace.right_dock(),
+                DockPosition::Bottom => workspace.bottom_dock(),
+            };
+            dock.read(cx).panel::<EpicPanel>().is_some()
+        });
+        assert!(moved, "the dock moves the panel to {position:?}");
+        let reloaded = reload(&mut rig).await;
+        let saved = reloaded.update_in(&mut rig.cx, |panel, window, cx| panel.position(window, cx));
+        assert_eq!(
+            saved, position,
+            "a fresh load docks where the panel was left"
+        );
+    }
+}
+
+#[gpui::test]
+async fn panel_shell_unknown_saved_position_falls_back_to_right(cx: &mut TestAppContext) {
+    let mut rig = rig(
+        cx,
+        &[("/work/project", project_with_epic())],
+        &["/work/project"],
+    )
+    .await;
+    let kvp = rig.cx.update(|_, cx| db::kvp::KeyValueStore::global(cx));
+    kvp.write_kvp(
+        <EpicPanel as Panel>::panel_key().to_string(),
+        "upside-down".to_string(),
+    )
+    .await
+    .expect("seed the key-value store");
+    let reloaded = reload(&mut rig).await;
+    let position = reloaded.update_in(&mut rig.cx, |panel, window, cx| panel.position(window, cx));
+    assert_eq!(position, DockPosition::Right);
+}
diff --git a/crates/epic_panel/src/panel_test_helpers.rs b/crates/epic_panel/src/panel_test_helpers.rs
new file mode 100644
index 0000000..aa77f50
--- /dev/null
+++ b/crates/epic_panel/src/panel_test_helpers.rs
@@ -0,0 +1,389 @@
+// Story 042 — shared rig for the Epic panel tests (tasks 5.1-5.4).
+//
+// Mounted from `src/panel.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "panel_test_helpers.rs"]
+//     mod panel_test_helpers;
+//     #[cfg(test)]
+//     #[path = "panel_shell_tests.rs"]
+//     mod panel_shell_tests;
+//     #[cfg(test)]
+//     #[path = "panel_rows_tests.rs"]
+//     mod panel_rows_tests;
+//     #[cfg(test)]
+//     #[path = "panel_open_tests.rs"]
+//     mod panel_open_tests;
+//     #[cfg(test)]
+//     #[path = "panel_live_tests.rs"]
+//     mod panel_live_tests;
+//
+// so each test path carries its Validation filter: `panel_shell`, `panel_rows`, `panel_open`,
+// `panel_live`. Being children of `panel`, these modules see its private items.
+//
+// Dev-dependencies (all workspace crates): db, editor, fs, gpui, project, settings, workspace
+// — each with `features = ["test-support"]` where it has one — plus menu, serde_json,
+// tempfile, ui, log.
+//
+// CONTRACT ASSUMED BEYOND design.md's SIGNATURES. design.md names `EpicPanel`, `DisplayEntry`,
+// `ToggleFocus`, `Refresh`, `init`, `load` and the `Panel` impl, but not how a test reads what
+// the panel shows. These are the seams the tests use; when the implementation names them
+// differently, adjust HERE, not in each test:
+//   - `EpicPanel::entries(&self) -> &[DisplayEntry]` — the flattened list `uniform_list`
+//     renders, in display order (design.md §Performance: rebuilt on snapshot/expanded change).
+//   - `EpicPanel::story_row(&self, worktree: usize, area: Area, row: usize) -> Option<&StoryRow>`
+//     — resolves a `DisplayEntry::Story`.
+//   - `EpicPanel::worktree_name(&self, worktree: usize) -> Option<&str>` — the name a
+//     `WorktreeHeader` shows (R2.5).
+//   - `EpicPanel::selected_index(&self) -> Option<usize>` — index into `entries()`.
+//   - `EpicPanel::banner_text(&self) -> Option<String>` — the banner shown, if any (R5.6).
+//   - free functions in panel.rs used by `render` (so the test checks what is drawn):
+//       `row_badge(&StoryRow) -> Option<&'static str>` — "unreadable" | "too large" |
+//         "symlink not followed" | "no story files" (R2.7, R6.1-R6.3);
+//       `row_tooltip(&StoryRow) -> String` — title, type/scale/engineering, problem detail (R2.9);
+//       `status_warning(&StatusCell) -> Option<&'static str>` — the R3.8 tooltip, Unknown only.
+//   - `StoryRow: Clone` and `Area: Copy` (both plain data).
+//   - the panel reads the file system through the project's / the global `Fs`; the rig sets
+//     the test's FakeFs as both.
+
+#![allow(dead_code)]
+
+use std::{path::Path, sync::Arc, time::Duration};
+
+use fs::FakeFs;
+// Brings `Fs` methods such as `remove_dir` into scope for the tests that glob-import this rig.
+pub(crate) use fs::Fs as _;
+use gpui::{Action, Entity, TestAppContext, VisualTestContext};
+use project::Project;
+use serde_json::Value;
+use ui::IconName;
+use workspace::{AppState, MultiWorkspace, Workspace, dock::Panel};
+
+use super::{DisplayEntry, EpicPanel};
+use crate::format::progress_text;
+use crate::scan::{Area, StoryRow};
+
+/// R1.3, R5.1, R5.2: what the panel shows must be there within this long.
+pub(crate) const DEADLINE: Duration = Duration::from_secs(2);
+
+pub(crate) fn init_test(cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        // A database of this test's own: the shared fallback lets parallel tests read each
+        // other's saved dock position.
+        cx.set_global(db::AppDatabase::test_new());
+        let app_state = AppState::test(cx);
+        editor::init(cx);
+        workspace::init(app_state, cx);
+        crate::init(cx);
+    });
+}
+
+pub(crate) struct Rig {
+    pub fs: Arc<FakeFs>,
+    pub project: Entity<Project>,
+    pub workspace: Entity<Workspace>,
+    pub panel: Entity<EpicPanel>,
+    pub cx: VisualTestContext,
+}
+
+/// A FakeFs holding `trees`, a project with one local worktree per entry of `roots`, a
+/// workspace window, and the Epic panel loaded through `EpicPanel::load` and added to the dock
+/// — the order Zeo starts in.
+pub(crate) async fn rig(cx: &mut TestAppContext, trees: &[(&str, Value)], roots: &[&str]) -> Rig {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+    for (path, tree) in trees {
+        fs.insert_tree(path, tree.clone()).await;
+    }
+    let project = Project::test(fs.clone(), roots.iter().map(Path::new), cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |multi, _| multi.workspace().clone())
+        .expect("the window holds a workspace");
+    let mut vcx = VisualTestContext::from_window(window.into(), cx);
+    vcx.simulate_resize(gpui::size(gpui::px(1400.), gpui::px(900.)));
+    vcx.run_until_parked();
+    let panel = load(&workspace, &mut vcx).await;
+    workspace.update_in(&mut vcx, |workspace, window, cx| {
+        workspace.add_panel(panel.clone(), window, cx)
+    });
+    settle(&mut vcx);
+    Rig {
+        fs,
+        project,
+        workspace,
+        panel,
+        cx: vcx,
+    }
+}
+
+pub(crate) async fn load(
+    workspace: &Entity<Workspace>,
+    cx: &mut VisualTestContext,
+) -> Entity<EpicPanel> {
+    let weak = workspace.downgrade();
+    let loading = cx.update(|window, cx| EpicPanel::load(weak, window.to_async(cx)));
+    cx.run_until_parked();
+    loading.await.expect("the Epic panel loads")
+}
+
+/// Lets the debounce, the scans and the worktree scanner run out the R1.3/R5.1 deadline.
+pub(crate) fn settle(cx: &mut VisualTestContext) {
+    cx.run_until_parked();
+    cx.executor().advance_clock(DEADLINE);
+    cx.run_until_parked();
+}
+
+pub(crate) fn icon(rig: &mut Rig) -> Option<IconName> {
+    rig.panel
+        .update_in(&mut rig.cx, |panel, window, cx| panel.icon(window, cx))
+}
+
+/// One readable line per visible entry, in display order. A story reads
+/// `"<number or —> <slug or dir name> | <status> | <progress>"`.
+pub(crate) fn lines(rig: &mut Rig) -> Vec<String> {
+    rig.panel.read_with(&rig.cx, |panel, _| {
+        panel
+            .entries()
+            .iter()
+            .map(|entry| describe(panel, entry))
+            .collect()
+    })
+}
+
+fn describe(panel: &EpicPanel, entry: &DisplayEntry) -> String {
+    match entry {
+        DisplayEntry::WorktreeHeader { worktree } => {
+            format!("worktree {}", panel.worktree_name(*worktree).unwrap_or("?"))
+        }
+        DisplayEntry::OpenIndex { worktree } => panel.index_label(*worktree),
+        DisplayEntry::Story {
+            worktree,
+            area,
+            row,
+        } => match panel.story_row(*worktree, *area, *row) {
+            Some(row) => story_line(row),
+            None => format!("dangling story entry {worktree}/{row}"),
+        },
+        DisplayEntry::ArchivedHeader {
+            count, expanded, ..
+        } => {
+            format!(
+                "Archived ({count}){}",
+                if *expanded { " [expanded]" } else { "" }
+            )
+        }
+        DisplayEntry::MoreNotShown { count, .. } => format!("{count} more not shown"),
+        DisplayEntry::Empty { message } => message.to_string(),
+        DisplayEntry::WorktreeError { message, .. } => message.clone(),
+    }
+}
+
+pub(crate) fn story_line(row: &StoryRow) -> String {
+    let number = row.number.clone().unwrap_or_else(|| "\u{2014}".to_string());
+    let name = if row.number.is_some() {
+        row.slug.clone()
+    } else {
+        row.dir_name.clone()
+    };
+    format!(
+        "{number} {name} | {} | {}",
+        row.status.text(),
+        progress_text(row.census)
+    )
+}
+
+/// The StoryRow a visible entry shows, found by directory name.
+pub(crate) fn find_row(rig: &mut Rig, dir_name: &str) -> Option<StoryRow> {
+    rig.panel.read_with(&rig.cx, |panel, _| {
+        panel.entries().iter().find_map(|entry| match entry {
+            DisplayEntry::Story {
+                worktree,
+                area,
+                row,
+            } => panel
+                .story_row(*worktree, *area, *row)
+                .filter(|row| row.dir_name == dir_name)
+                .cloned(),
+            _ => None,
+        })
+    })
+}
+
+pub(crate) fn selected_line(rig: &mut Rig) -> Option<String> {
+    let index = rig
+        .panel
+        .read_with(&rig.cx, |panel, _| panel.selected_index())?;
+    lines(rig).get(index).cloned()
+}
+
+pub(crate) fn focus_panel(rig: &mut Rig) {
+    rig.workspace
+        .update_in(&mut rig.cx, |workspace, window, cx| {
+            workspace.focus_panel::<EpicPanel>(window, cx);
+        });
+    rig.cx.run_until_parked();
+}
+
+pub(crate) fn press(rig: &mut Rig, action: impl Action) {
+    rig.cx.dispatch_action(action);
+    rig.cx.run_until_parked();
+}
+
+/// Moves the selection with `menu::SelectNext` (R2.10) until the line starting with `prefix`
+/// is selected. Panics when a full lap does not reach it.
+pub(crate) fn select_line(rig: &mut Rig, prefix: &str) {
+    focus_panel(rig);
+    let laps = lines(rig).len() * 2 + 2;
+    for _ in 0..laps {
+        if selected_line(rig).is_some_and(|line| line.starts_with(prefix)) {
+            return;
+        }
+        press(rig, menu::SelectNext);
+    }
+    panic!(
+        "no line starting with {prefix:?} could be selected; lines: {:?}",
+        lines(rig)
+    );
+}
+
+pub(crate) fn activate_line(rig: &mut Rig, prefix: &str) {
+    select_line(rig, prefix);
+    press(rig, menu::Confirm);
+    settle(&mut rig.cx);
+}
+
+/// The worktree-relative path of the active editor tab, if any.
+pub(crate) fn active_path(rig: &mut Rig) -> Option<String> {
+    rig.workspace.read_with(&rig.cx, |workspace, cx| {
+        workspace
+            .active_item(cx)
+            .and_then(|item| item.project_path(cx))
+            .map(|path| path.path.as_unix_str().to_string())
+    })
+}
+
+pub(crate) fn item_count(rig: &mut Rig) -> usize {
+    rig.workspace
+        .read_with(&rig.cx, |workspace, cx| workspace.items(cx).count())
+}
+
+pub(crate) fn banner(rig: &mut Rig) -> Option<String> {
+    rig.panel.read_with(&rig.cx, |panel, _| panel.banner_text())
+}
+
+pub(crate) fn fm(pairs: &[(&str, &str)], body: &str) -> String {
+    let mut text = String::from("---\n");
+    for (key, value) in pairs {
+        text.push_str(&format!("{key}: {value}\n"));
+    }
+    text.push_str("---\n\n");
+    text.push_str(body);
+    text
+}
+
+pub(crate) async fn remove_worktree(rig: &mut Rig, root: &str) {
+    let id = rig.project.read_with(&rig.cx, |project, cx| {
+        project
+            .worktrees(cx)
+            .find(|worktree| worktree.read(cx).abs_path().as_ref() == Path::new(root))
+            .map(|worktree| worktree.read(cx).id())
+            .expect("the worktree exists")
+    });
+    rig.project
+        .update(&mut rig.cx, |project, cx| project.remove_worktree(id, cx));
+    settle(&mut rig.cx);
+}
+
+pub(crate) async fn add_worktree(rig: &mut Rig, root: &str) {
+    let task = rig.project.update(&mut rig.cx, |project, cx| {
+        project.find_or_create_worktree(root, true, cx)
+    });
+    task.await.expect("the worktree is added");
+    settle(&mut rig.cx);
+}
+
+pub(crate) fn area_is_archived(area: Area) -> bool {
+    matches!(area, Area::Archived)
+}
+
+// ── log capture (R8) — the 0035 pattern ─────────────────────────────────────
+
+thread_local! {
+    static CAPTURED: std::cell::RefCell<Vec<(log::Level, String)>> = const { std::cell::RefCell::new(Vec::new()) };
+}
+
+struct CaptureLogger;
+
+impl log::Log for CaptureLogger {
+    fn enabled(&self, _metadata: &log::Metadata) -> bool {
+        true
+    }
+
+    fn log(&self, record: &log::Record) {
+        let mut line = record.args().to_string();
+        struct Collect<'a>(&'a mut String);
+        impl<'kvs> log::kv::VisitSource<'kvs> for Collect<'_> {
+            fn visit_pair(
+                &mut self,
+                key: log::kv::Key<'kvs>,
+                value: log::kv::Value<'kvs>,
+            ) -> Result<(), log::kv::Error> {
+                self.0.push_str(&format!(" {key}={value}"));
+                Ok(())
+            }
+        }
+        record
+            .key_values()
+            .visit(&mut Collect(&mut line))
+            .unwrap_or_else(|error| line.push_str(&format!(" kv_error={error}")));
+        CAPTURED.with(|captured| captured.borrow_mut().push((record.level(), line)));
+    }
+
+    fn flush(&self) {}
+}
+
+static INSTALL: std::sync::Once = std::sync::Once::new();
+
+/// Starts capturing log lines on this thread. Panics when another logger owns the process,
+/// rather than letting a log assertion pass or fail for the wrong reason.
+pub(crate) fn capture_logs() {
+    INSTALL.call_once(|| {
+        log::set_boxed_logger(Box::new(CaptureLogger))
+            .expect("another logger is installed; the log capture cannot run");
+        log::set_max_level(log::LevelFilter::Trace);
+    });
+    CAPTURED.with(|captured| captured.borrow_mut().clear());
+}
+
+/// Captured lines naming `event=<event>`, with their level.
+pub(crate) fn events(event: &str) -> Vec<(log::Level, String)> {
+    let needle = format!("event={event}");
+    CAPTURED.with(|captured| {
+        captured
+            .borrow()
+            .iter()
+            .filter(|(_, line)| line.split_whitespace().any(|token| token == needle))
+            .cloned()
+            .collect()
+    })
+}
+
+/// Every captured line that mentions the panel.
+pub(crate) fn panel_lines() -> Vec<(log::Level, String)> {
+    CAPTURED.with(|captured| {
+        captured
+            .borrow()
+            .iter()
+            .filter(|(_, line)| line.contains("epic_panel"))
+            .cloned()
+            .collect()
+    })
+}
+
+/// `key=value` tokens of a structured line (Q14).
+pub(crate) fn field<'a>(line: &'a str, key: &str) -> Option<&'a str> {
+    line.split_whitespace()
+        .find_map(|token| token.strip_prefix(key)?.strip_prefix('='))
+}
diff --git a/crates/epic_panel/src/parity_tests.rs b/crates/epic_panel/src/parity_tests.rs
new file mode 100644
index 0000000..f3909fe
--- /dev/null
+++ b/crates/epic_panel/src/parity_tests.rs
@@ -0,0 +1,468 @@
+// Story 042, task 3.3 — parity with epic plugin 0.13.1's `epic-index.sh` (R4.5, Q13), carried to
+// the plugin renamed Epico: `epico-index.sh` in 0.25.1 is the same grammar under the new names
+// (`.epico/`, `EPICO.md`, `epico:index` markers), so the contract now runs against it.
+//
+// Mounted from the crate root `src/epic_panel.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "parity_tests.rs"]
+//     mod parity_tests;
+//
+// so the Validation filter `cargo test -p epic_panel parity` selects it.
+//
+// The contract test runs only with both variables set:
+//   EPIC_PARITY_DIR  a `.epico` directory (the corpus; never written)
+//   EPIC_INDEX_SH    the plugin's `scripts/epico-index.sh`
+// Without them it prints `SKIPPED: set EPIC_PARITY_DIR and EPIC_INDEX_SH` and passes. With
+// them it copies the corpus to a temp dir (symlinks recreated, never followed), runs
+// `bash <EPIC_INDEX_SH> --epic-dir <copy>` — on the copy only, because the script writes
+// EPICO.md — parses the generated table and compares number, slug, status text, progress text
+// and location for every row with `scan_epic_dir` over the same copy. It fails on zero rows
+// and lists every mismatch in both directions.
+//
+// The parser and the comparator are tested on their own (always, no variables needed), each
+// with the input that would make it pass wrongly.
+//
+// Dev-dependencies: fs, gpui (test-support), tempfile.
+
+use std::{
+    collections::BTreeMap,
+    env, fs as stdfs,
+    path::{Path, PathBuf},
+    process::Command,
+    sync::Arc,
+};
+
+use fs::Fs;
+use gpui::TestAppContext;
+
+use crate::format::progress_text;
+use crate::scan::{Area, EpicSnapshot, scan_epic_dir};
+
+const START: &str = "<!-- epico:index:start -->";
+const END: &str = "<!-- epico:index:end -->";
+const REMOVED: &str = "archived (directory removed)";
+const EM_DASH: &str = "\u{2014}";
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct Row {
+    number: String,
+    slug: String,
+    status: String,
+    progress: String,
+    location: String,
+}
+
+impl Row {
+    fn key(&self) -> (String, String, String) {
+        (
+            self.location.clone(),
+            self.number.clone(),
+            self.slug.clone(),
+        )
+    }
+}
+
+/// Splits a table line on `|` not preceded by a backslash escape.
+fn split_unescaped(line: &str) -> Vec<String> {
+    let mut cells = vec![String::new()];
+    let mut chars = line.chars();
+    while let Some(c) = chars.next() {
+        match c {
+            '\\' => {
+                let cell = cells.last_mut().expect("at least one cell");
+                cell.push('\\');
+                if let Some(next) = chars.next() {
+                    cell.push(next);
+                }
+            }
+            '|' => cells.push(String::new()),
+            _ => cells.last_mut().expect("at least one cell").push(c),
+        }
+    }
+    cells
+}
+
+/// The inverse of epic-index.sh `md_cell` for `\\`, `\|`, `\[`, `\]`.
+fn unescape(cell: &str) -> String {
+    let mut out = String::new();
+    let mut chars = cell.chars();
+    while let Some(c) = chars.next() {
+        if c == '\\' {
+            if let Some(next) = chars.next() {
+                out.push(next);
+            }
+        } else {
+            out.push(c);
+        }
+    }
+    out
+}
+
+/// `[slug](link)` -> slug; a cell without a link (manifest-only rows) is the slug itself.
+fn slug_of(raw: &str) -> String {
+    let raw = raw.trim();
+    if let Some(rest) = raw.strip_prefix('[') {
+        let mut escaped = false;
+        let bytes: Vec<char> = rest.chars().collect();
+        for (index, c) in bytes.iter().enumerate() {
+            if escaped {
+                escaped = false;
+                continue;
+            }
+            match c {
+                '\\' => escaped = true,
+                ']' if bytes.get(index + 1) == Some(&'(') => {
+                    return unescape(&bytes[..index].iter().collect::<String>());
+                }
+                _ => {}
+            }
+        }
+    }
+    unescape(raw)
+}
+
+fn parse_index(text: &str) -> Result<Vec<Row>, String> {
+    let start = text.find(START).ok_or("no start marker")?;
+    let end = text.find(END).ok_or("no end marker")?;
+    if end < start {
+        return Err("end marker before start marker".into());
+    }
+    let mut rows = Vec::new();
+    for line in text[start..end].lines() {
+        let line = line.trim_end_matches('\r');
+        if !line.starts_with('|') || line.starts_with("| # |") || line.starts_with("| --- |") {
+            continue;
+        }
+        let cells = split_unescaped(line);
+        // "| a | b | c | d | e |" -> ["", a, b, c, d, e, ""]
+        if cells.len() != 7 {
+            return Err(format!(
+                "a row with {} columns: {line}",
+                cells.len().saturating_sub(2)
+            ));
+        }
+        rows.push(Row {
+            number: unescape(cells[1].trim()),
+            slug: slug_of(&cells[2]),
+            status: unescape(cells[3].trim()),
+            progress: unescape(cells[4].trim()),
+            location: unescape(cells[5].trim()),
+        });
+    }
+    Ok(rows)
+}
+
+fn panel_rows(snapshot: &EpicSnapshot) -> Vec<Row> {
+    snapshot
+        .active
+        .iter()
+        .chain(snapshot.archived.iter())
+        .map(|row| Row {
+            number: row.number.clone().unwrap_or_else(|| EM_DASH.to_string()),
+            slug: if row.number.is_some() {
+                row.slug.clone()
+            } else {
+                row.dir_name.clone()
+            },
+            status: row.status.text(),
+            progress: progress_text(row.census),
+            location: match row.area {
+                Area::Active => "active".to_string(),
+                Area::Archived => "archived".to_string(),
+            },
+        })
+        .collect()
+}
+
+/// Every difference between the two readers, in both directions.
+fn compare(index: &[Row], panel: &[Row]) -> Vec<String> {
+    let mut report = Vec::new();
+    let mut by_key: BTreeMap<_, Vec<&Row>> = BTreeMap::new();
+    for row in panel {
+        by_key.entry(row.key()).or_default().push(row);
+    }
+    for expected in index {
+        match by_key.get_mut(&expected.key()).and_then(|rows| rows.pop()) {
+            None => report.push(format!("only in EPICO.md: {expected:?}")),
+            Some(actual) => {
+                if actual.status != expected.status {
+                    report.push(format!(
+                        "{} {}: status {:?} (EPICO.md) vs {:?} (panel)",
+                        expected.number, expected.slug, expected.status, actual.status
+                    ));
+                }
+                if actual.progress != expected.progress {
+                    report.push(format!(
+                        "{} {}: progress {:?} (EPICO.md) vs {:?} (panel)",
+                        expected.number, expected.slug, expected.progress, actual.progress
+                    ));
+                }
+            }
+        }
+    }
+    for rows in by_key.values() {
+        for row in rows {
+            report.push(format!("only in the panel: {row:?}"));
+        }
+    }
+    report
+}
+
+fn copy_tree(source: &Path, target: &Path) {
+    let meta = stdfs::symlink_metadata(source)
+        .unwrap_or_else(|e| panic!("stat {}: {e}", source.display()));
+    let file_type = meta.file_type();
+    if file_type.is_symlink() {
+        let link = stdfs::read_link(source)
+            .unwrap_or_else(|e| panic!("read_link {}: {e}", source.display()));
+        std::os::unix::fs::symlink(&link, target)
+            .unwrap_or_else(|e| panic!("symlink {} -> {}: {e}", target.display(), link.display()));
+    } else if file_type.is_dir() {
+        stdfs::create_dir_all(target)
+            .unwrap_or_else(|e| panic!("create {}: {e}", target.display()));
+        let entries = stdfs::read_dir(source)
+            .unwrap_or_else(|e| panic!("read_dir {}: {e}", source.display()));
+        for entry in entries {
+            let entry = entry.unwrap_or_else(|e| panic!("entry of {}: {e}", source.display()));
+            copy_tree(&entry.path(), &target.join(entry.file_name()));
+        }
+    } else if file_type.is_file() {
+        stdfs::copy(source, target)
+            .unwrap_or_else(|e| panic!("copy {} -> {}: {e}", source.display(), target.display()));
+    } else {
+        eprintln!(
+            "parity: not copied (neither file, directory nor symlink): {}",
+            source.display()
+        );
+    }
+}
+
+fn file_state(path: &Path) -> Option<(Vec<u8>, std::time::SystemTime)> {
+    let meta = stdfs::symlink_metadata(path).ok()?;
+    let content = stdfs::read(path).ok()?;
+    Some((content, meta.modified().ok()?))
+}
+
+// ── the contract test ───────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn parity_with_epic_index_sh(cx: &mut TestAppContext) {
+    let (Some(corpus), Some(script)) =
+        (env::var_os("EPIC_PARITY_DIR"), env::var_os("EPIC_INDEX_SH"))
+    else {
+        println!("SKIPPED: set EPIC_PARITY_DIR and EPIC_INDEX_SH");
+        return;
+    };
+    cx.executor().allow_parking();
+    let corpus = PathBuf::from(corpus);
+    let script = PathBuf::from(script);
+    assert!(
+        corpus.is_dir(),
+        "EPIC_PARITY_DIR is not a directory: {}",
+        corpus.display()
+    );
+    assert!(
+        script.is_file(),
+        "EPIC_INDEX_SH is not a file: {}",
+        script.display()
+    );
+    let corpus_index_before = file_state(&corpus.join("EPICO.md"));
+
+    let temp = tempfile::tempdir().expect("temp dir");
+    let copy = temp.path().join(".epico");
+    copy_tree(&corpus, &copy);
+
+    let output = Command::new("bash")
+        .arg(&script)
+        .arg("--epic-dir")
+        .arg(&copy)
+        .output()
+        .unwrap_or_else(|e| panic!("running bash {}: {e}", script.display()));
+    assert!(
+        output.status.success(),
+        "epico-index.sh failed ({}):\n{}",
+        output.status,
+        String::from_utf8_lossy(&output.stderr)
+    );
+
+    let index_text = stdfs::read_to_string(copy.join("EPICO.md")).unwrap_or_else(|e| {
+        panic!(
+            "read the generated {}: {e}",
+            copy.join("EPICO.md").display()
+        )
+    });
+    let index: Vec<Row> = parse_index(&index_text)
+        .unwrap_or_else(|e| panic!("parse the generated index: {e}"))
+        .into_iter()
+        .filter(|row| row.location != REMOVED) // Assumption A5
+        .collect();
+    assert!(
+        !index.is_empty(),
+        "the generated index has zero story rows: parity would prove nothing"
+    );
+
+    let fs: Arc<dyn Fs> = fs::RealFs::new(None, cx.executor());
+    let snapshot = scan_epic_dir(fs, copy.clone())
+        .await
+        .expect("scan the copy");
+    assert_eq!(
+        snapshot.not_shown, 0,
+        "the corpus exceeds MAX_ROWS; parity would be partial"
+    );
+    let panel = panel_rows(&snapshot);
+
+    let report = compare(&index, &panel);
+    println!(
+        "parity: {} EPICO.md rows, {} panel rows, {} mismatches",
+        index.len(),
+        panel.len(),
+        report.len()
+    );
+    assert!(
+        report.is_empty(),
+        "the panel and epico-index.sh disagree:\n{}",
+        report.join("\n")
+    );
+
+    assert_eq!(
+        file_state(&corpus.join("EPICO.md")),
+        corpus_index_before,
+        "the corpus's own EPICO.md must be untouched: the script runs on the copy only"
+    );
+}
+
+// ── the parser, both directions ─────────────────────────────────────────────
+
+const SAMPLE: &str = "# Epic\n\nHand-written | text with a pipe.\n\n<!-- epico:index:start -->\n<!-- Generated by scripts/epico-index.sh — edits inside this block are overwritten. -->\n\n| # | Story | Status | Progress | Location |\n| --- | --- | --- | --- | --- |\n| 001 | [close-the-parity-backlog](archive/001-close-the-parity-backlog/story.md) | archived | 61/61 | archived |\n| 007 | [a\\|b \\[x\\]](stories/007-a%7Cb%20[x]/) | odd \\| status | 1/2 (+1 deferred) | active |\n| — | [notes](stories/notes/) | — | — | active |\n| 012 | gone-story | done | 3/3 | archived (directory removed) |\n<!-- epico:index:end -->\n\nAfter | the block.\n";
+
+#[test]
+fn parity_parser_reads_rows_inside_the_markers_only() {
+    let rows = parse_index(SAMPLE).expect("parses");
+    assert_eq!(
+        rows.len(),
+        4,
+        "text outside the markers is not a row: {rows:?}"
+    );
+    assert_eq!(
+        rows[0],
+        Row {
+            number: "001".into(),
+            slug: "close-the-parity-backlog".into(),
+            status: "archived".into(),
+            progress: "61/61".into(),
+            location: "archived".into(),
+        }
+    );
+    assert_eq!(rows[2].number, EM_DASH);
+    assert_eq!(rows[2].slug, "notes");
+    assert_eq!(rows[3].location, REMOVED);
+    assert_eq!(rows[3].slug, "gone-story", "a linkless manifest row");
+}
+
+// Hostile half: an escaped pipe is content, not a column break; escaped brackets are content,
+// not the end of the link text.
+#[test]
+fn parity_parser_keeps_escaped_pipes_and_brackets_as_content() {
+    let rows = parse_index(SAMPLE).expect("parses");
+    assert_eq!(rows[1].slug, "a|b [x]");
+    assert_eq!(rows[1].status, "odd | status");
+    assert_eq!(rows[1].progress, "1/2 (+1 deferred)");
+}
+
+// Converse: an unescaped pipe IS a column break, so a row with one too many is refused rather
+// than silently shifted.
+#[test]
+fn parity_parser_refuses_a_row_with_an_extra_column() {
+    let text = format!("{START}\n| 001 | [a](x) | done | extra | 1/1 | active |\n{END}\n");
+    assert!(parse_index(&text).is_err());
+}
+
+#[test]
+fn parity_parser_requires_both_markers() {
+    assert!(parse_index("| 001 | [a](x) | done | 1/1 | active |\n").is_err());
+    assert!(parse_index(&format!("{END}\n{START}\n")).is_err());
+}
+
+// ── the comparator, both directions ─────────────────────────────────────────
+
+fn r(number: &str, slug: &str, status: &str, progress: &str, location: &str) -> Row {
+    Row {
+        number: number.into(),
+        slug: slug.into(),
+        status: status.into(),
+        progress: progress.into(),
+        location: location.into(),
+    }
+}
+
+#[test]
+fn parity_comparator_accepts_identical_rows() {
+    let rows = vec![
+        r("001", "a", "done", "1/1", "active"),
+        r("002", "b", EM_DASH, EM_DASH, "archived"),
+    ];
+    assert!(compare(&rows, &rows).is_empty());
+}
+
+// Hostile half: every kind of disagreement is reported.
+#[test]
+fn parity_comparator_reports_every_disagreement() {
+    let index = vec![
+        r("001", "a", "done", "1/1", "active"),
+        r("002", "b", "draft", "0/3", "active"),
+        r("003", "c", "archived", "2/2", "archived"),
+    ];
+    let panel = vec![
+        r("001", "a", "done", "1/2", "active"),     // progress differs
+        r("002", "b", "draft ", "0/3", "active"),   // status differs by a trailing space
+        r("003", "c", "archived", "2/2", "active"), // location differs -> both sides orphaned
+        r("004", "d", "draft", "0/0", "active"),    // only in the panel
+    ];
+    let report = compare(&index, &panel);
+    assert!(
+        report.iter().any(|line| line.contains("progress")),
+        "{report:?}"
+    );
+    assert!(
+        report.iter().any(|line| line.contains("status")),
+        "{report:?}"
+    );
+    assert!(
+        report
+            .iter()
+            .any(|line| line.starts_with("only in EPICO.md") && line.contains("\"c\"")),
+        "{report:?}"
+    );
+    assert!(
+        report
+            .iter()
+            .filter(|line| line.starts_with("only in the panel"))
+            .count()
+            == 2,
+        "{report:?}"
+    );
+}
+
+// Hostile half for identity: two rows sharing a number in different areas are two rows, and
+// the same row twice on one side is not absorbed by one on the other.
+#[test]
+fn parity_comparator_neither_collapses_nor_splits_rows() {
+    let index = vec![
+        r("012", "a", "done", "1/1", "active"),
+        r("012", "b", "done", "1/1", "archived"),
+    ];
+    assert!(compare(&index, &index.clone()).is_empty());
+    let panel = vec![r("012", "a", "done", "1/1", "active")];
+    assert_eq!(
+        compare(&index, &panel).len(),
+        1,
+        "the archived 012 is missing from the panel"
+    );
+    let doubled = vec![index[0].clone(), index[0].clone(), index[1].clone()];
+    assert_eq!(
+        compare(&index, &doubled).len(),
+        1,
+        "one extra panel row is reported"
+    );
+}
diff --git a/crates/epic_panel/src/scan.rs b/crates/epic_panel/src/scan.rs
new file mode 100644
index 0000000..0912fe8
--- /dev/null
+++ b/crates/epic_panel/src/scan.rs
@@ -0,0 +1,425 @@
+//! Reading one worktree's `.epic/` directory into an [`EpicSnapshot`].
+//!
+//! Two passes, like the plugin's `epic-index.sh`: list every story directory of `stories/` and
+//! `archive/` first, so a `superseded by` or `promote to` pointer resolves against both areas,
+//! then read each row. Nothing here writes, and nothing behind a symlink is read (R6.3, R6.6).
+
+use std::{
+    cmp::Ordering,
+    collections::HashSet,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::{Duration, Instant},
+};
+
+use anyhow::Context as _;
+use fs::{Fs, Metadata};
+use futures::StreamExt as _;
+
+use crate::format::{
+    Census, StatusCell, census, split_dir_name, status_cell, story_then_tasks, strip_zeros, title,
+};
+
+/// Files larger than this are not read (R6.2).
+pub const MAX_FILE_BYTES: u64 = 1024 * 1024;
+/// Rows kept per worktree, active and archived together (R2.12).
+pub const MAX_ROWS: usize = 2000;
+
+const STORY_FILE: &str = "story.md";
+const TASKS_FILE: &str = "tasks.md";
+/// The directories the panel looks for at a worktree's root, current first. The plugin, renamed
+/// Epico, moved `.epic/EPIC.md` to `.epico/EPICO.md` and migrates a project the next time it runs
+/// there; until then the old layout is still read. With both present it moves nothing, and the
+/// panel reads `.epico/`, the one it writes.
+pub(crate) const EPIC_DIR_NAMES: [&str; 2] = [".epico", ".epic"];
+const LEGACY_EPIC_DIR_NAME: &str = ".epic";
+
+/// The index the plugin writes inside `epic_dir`: `EPIC.md` in the old layout, `EPICO.md` now.
+pub(crate) fn index_file_name(epic_dir: &Path) -> &'static str {
+    if epic_dir
+        .file_name()
+        .is_some_and(|name| name == LEGACY_EPIC_DIR_NAME)
+    {
+        "EPIC.md"
+    } else {
+        "EPICO.md"
+    }
+}
+/// The name a problem on the story directory itself carries in place of a file name.
+pub(crate) const DIRECTORY: &str = "directory";
+
+/// Which of `.epic/`'s two areas a story directory lives in.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Area {
+    Active,
+    Archived,
+}
+
+impl Area {
+    pub(crate) fn dir_name(self) -> &'static str {
+        match self {
+            Area::Active => "stories",
+            Area::Archived => "archive",
+        }
+    }
+}
+
+/// Why a row shows less than its files hold. The `String` is a file name relative to the story
+/// directory (or `"directory"`), never an absolute path.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RowProblem {
+    /// The file or directory could not be read; the reason is the error's root cause.
+    Unreadable(String, String),
+    /// The file is larger than [`MAX_FILE_BYTES`]; the number is its length in bytes.
+    TooLarge(String, u64),
+    /// The file or directory is a symlink, and is not followed.
+    Symlink(String),
+}
+
+/// One story directory, as the panel shows it. A row with a problem keeps every field it could
+/// read.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct StoryRow {
+    pub dir_name: String,
+    /// The leading digits as written ("042"); `None` when the name is not `NNN-slug`.
+    pub number: Option<String>,
+    /// `""` when the name is not `NNN-slug`.
+    pub slug: String,
+    pub area: Area,
+    pub status: StatusCell,
+    /// `None` when `tasks.md` is absent or was not read.
+    pub census: Option<Census>,
+    pub title: Option<String>,
+    /// The frontmatter `type`.
+    pub kind: Option<String>,
+    pub scale: Option<String>,
+    pub engineering: Option<String>,
+    /// The first of `story.md`, `tasks.md` that exists and carries no problem.
+    pub open_target: Option<PathBuf>,
+    pub problem: Option<RowProblem>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct EpicSnapshot {
+    pub epic_dir: PathBuf,
+    /// The index ([`index_file_name`]) is a regular file, not a symlink (R2.8).
+    pub has_index: bool,
+    /// Area-level read problems, worktree-relative, shown as the worktree's error line.
+    pub notes: Vec<String>,
+    pub active: Vec<StoryRow>,
+    pub archived: Vec<StoryRow>,
+    /// Rows past [`MAX_ROWS`], in display order.
+    pub not_shown: usize,
+    /// Story directories found in `stories/`, counted before the [`MAX_ROWS`] cap.
+    pub active_total: usize,
+    /// Story directories found in `archive/`, counted before the [`MAX_ROWS`] cap.
+    pub archived_total: usize,
+    pub duration: Duration,
+}
+
+/// Reads `epic_dir` into a snapshot. Fails only when `epic_dir` itself cannot be listed; every
+/// other read problem lands in [`EpicSnapshot::notes`] or on a row.
+pub async fn scan_epic_dir(fs: Arc<dyn Fs>, epic_dir: PathBuf) -> anyhow::Result<EpicSnapshot> {
+    let started = Instant::now();
+    let fs = fs.as_ref();
+    // Only whether `.epic/` can be listed matters here; the areas are listed by name below.
+    let listing = fs
+        .read_dir(&epic_dir)
+        .await
+        .with_context(|| format!("reading {}", worktree_relative(&epic_dir)))?;
+    drop(listing);
+
+    let mut notes = Vec::new();
+    let mut active = list_area(fs, &epic_dir, Area::Active, &mut notes).await;
+    let mut archived = list_area(fs, &epic_dir, Area::Archived, &mut notes).await;
+
+    let known: HashSet<String> = active
+        .iter()
+        .chain(archived.iter())
+        .filter_map(|entry| number_value(&entry.dir_name))
+        .map(str::to_string)
+        .collect();
+
+    active.sort_by(|left, right| display_order(&left.dir_name, &right.dir_name));
+    archived.sort_by(|left, right| display_order(&left.dir_name, &right.dir_name));
+    // Capped before any file is read, so a hostile tree cannot make the scan read more than
+    // MAX_ROWS directories' files.
+    let active_total = active.len();
+    let archived_total = archived.len();
+    let kept_active = active.len().min(MAX_ROWS);
+    let kept_archived = archived.len().min(MAX_ROWS - kept_active);
+    let not_shown = active.len() - kept_active + archived.len() - kept_archived;
+    active.truncate(kept_active);
+    archived.truncate(kept_archived);
+
+    let is_known = |number: &str| known.contains(number);
+    let active = read_rows(fs, active, &is_known).await;
+    let archived = read_rows(fs, archived, &is_known).await;
+
+    let has_index = matches!(
+        fs.metadata(&epic_dir.join(index_file_name(&epic_dir))).await,
+        Ok(Some(metadata)) if !metadata.is_symlink && is_regular(&metadata)
+    );
+
+    Ok(EpicSnapshot {
+        epic_dir,
+        has_index,
+        notes,
+        active,
+        archived,
+        not_shown,
+        active_total,
+        archived_total,
+        duration: started.elapsed(),
+    })
+}
+
+/// `<worktree>/.epic`: the directory's parent name and its own, never the absolute path.
+fn worktree_relative(epic_dir: &Path) -> String {
+    let own = epic_dir
+        .file_name()
+        .map(|name| name.to_string_lossy())
+        .unwrap_or_default();
+    match epic_dir.parent().and_then(Path::file_name) {
+        Some(worktree) => format!("{}/{own}", worktree.to_string_lossy()),
+        None => own.into_owned(),
+    }
+}
+
+/// A story directory found by pass 1, before any of its files is read.
+struct StoryEntry {
+    area: Area,
+    path: PathBuf,
+    dir_name: String,
+    kind: EntryKind,
+}
+
+enum EntryKind {
+    Directory,
+    Symlink,
+    /// `metadata` failed; the root cause.
+    Unreadable(String),
+}
+
+/// Pass 1 over one area: its story directories, with no file read. An absent area is empty; one
+/// that cannot be listed, or an entry that errors, becomes a note and the rest is still read.
+async fn list_area(
+    fs: &dyn Fs,
+    epic_dir: &Path,
+    area: Area,
+    notes: &mut Vec<String>,
+) -> Vec<StoryEntry> {
+    let area_dir = epic_dir.join(area.dir_name());
+    let shown = area.dir_name();
+    match fs.metadata(&area_dir).await {
+        Ok(None) => return Vec::new(),
+        Ok(Some(metadata)) if metadata.is_symlink => {
+            // The same rule as a symlinked story directory (R6.3): the target is never read.
+            notes.push(format!("{shown}/ is a symlink and is not followed"));
+            return Vec::new();
+        }
+        Ok(Some(metadata)) if !metadata.is_dir => return Vec::new(),
+        Ok(Some(_)) => {}
+        Err(error) => {
+            notes.push(format!("could not list {shown}/: {}", root_cause(&error)));
+            return Vec::new();
+        }
+    }
+    let mut listing = match fs.read_dir(&area_dir).await {
+        Ok(listing) => listing,
+        Err(error) => {
+            notes.push(format!("could not list {shown}/: {}", root_cause(&error)));
+            return Vec::new();
+        }
+    };
+
+    let mut entries = Vec::new();
+    while let Some(item) = listing.next().await {
+        let path = match item {
+            Ok(path) => path,
+            Err(error) => {
+                notes.push(format!(
+                    "could not list an entry of {shown}/: {}",
+                    root_cause(&error)
+                ));
+                continue;
+            }
+        };
+        let Some(dir_name) = path
+            .file_name()
+            .map(|name| name.to_string_lossy().into_owned())
+        else {
+            continue;
+        };
+        // epic-index.sh lists `"$area"/*/`, a glob that skips hidden entries.
+        if dir_name.starts_with('.') {
+            continue;
+        }
+        // `metadata` follows a symlink for `is_dir`, so a symlinked directory is still a story
+        // directory (as `[[ -d ]]` sees it), only never read.
+        let kind = match fs.metadata(&path).await {
+            Ok(Some(metadata)) if !metadata.is_dir => continue,
+            Ok(Some(metadata)) if metadata.is_symlink => EntryKind::Symlink,
+            Ok(Some(_)) => EntryKind::Directory,
+            Ok(None) => continue,
+            Err(error) => EntryKind::Unreadable(root_cause(&error)),
+        };
+        entries.push(StoryEntry {
+            area,
+            path,
+            dir_name,
+            kind,
+        });
+    }
+    entries
+}
+
+/// R2.2: numbered rows first, by the number's value (compared as stripped digits, by length
+/// then bytewise, so no name wraps an integer), then unnumbered rows; ties by name, bytewise.
+fn display_order(left: &str, right: &str) -> Ordering {
+    match (number_value(left), number_value(right)) {
+        (Some(left_value), Some(right_value)) => left_value
+            .len()
+            .cmp(&right_value.len())
+            .then_with(|| left_value.cmp(right_value)),
+        (Some(_), None) => Ordering::Less,
+        (None, Some(_)) => Ordering::Greater,
+        (None, None) => Ordering::Equal,
+    }
+    .then_with(|| left.cmp(right))
+}
+
+/// The stripped digits of an `NNN-slug` name; `None` when unnumbered.
+fn number_value(dir_name: &str) -> Option<&str> {
+    split_dir_name(dir_name).0.map(strip_zeros)
+}
+
+/// What reading one of a story's files produced.
+enum FileRead {
+    Absent,
+    Text(String),
+    Problem(RowProblem),
+}
+
+impl FileRead {
+    fn text(&self) -> Option<&str> {
+        match self {
+            FileRead::Text(text) => Some(text),
+            FileRead::Absent | FileRead::Problem(_) => None,
+        }
+    }
+
+    fn into_problem(self) -> Option<RowProblem> {
+        match self {
+            FileRead::Problem(problem) => Some(problem),
+            FileRead::Absent | FileRead::Text(_) => None,
+        }
+    }
+}
+
+/// Pass 2, in the entries' order.
+async fn read_rows(
+    fs: &dyn Fs,
+    entries: Vec<StoryEntry>,
+    known: &(dyn Fn(&str) -> bool + Sync),
+) -> Vec<StoryRow> {
+    let mut rows = Vec::with_capacity(entries.len());
+    for entry in entries {
+        rows.push(read_row(fs, entry, known).await);
+    }
+    rows
+}
+
+/// Pass 2 for one story directory.
+async fn read_row(
+    fs: &dyn Fs,
+    entry: StoryEntry,
+    known: &(dyn Fn(&str) -> bool + Sync),
+) -> StoryRow {
+    let (story, tasks) = match entry.kind {
+        EntryKind::Directory => (
+            read_file(fs, &entry.path, STORY_FILE).await,
+            read_file(fs, &entry.path, TASKS_FILE).await,
+        ),
+        EntryKind::Symlink => (
+            FileRead::Problem(RowProblem::Symlink(DIRECTORY.to_string())),
+            FileRead::Absent,
+        ),
+        EntryKind::Unreadable(reason) => (
+            FileRead::Problem(RowProblem::Unreadable(DIRECTORY.to_string(), reason)),
+            FileRead::Absent,
+        ),
+    };
+    let story_md = story.text();
+    let tasks_md = tasks.text();
+
+    let open_target = if story_md.is_some() {
+        Some(entry.path.join(STORY_FILE))
+    } else if tasks_md.is_some() {
+        Some(entry.path.join(TASKS_FILE))
+    } else {
+        None
+    };
+    // R2.9: the title comes from tasks.md only when there is no story.md to read it from.
+    let title = match story_md {
+        Some(text) => title(text),
+        None => tasks_md.and_then(title),
+    };
+    let value =
+        |key: &str| story_then_tasks(story_md, tasks_md, key).map(|value| value.into_owned());
+    let (number, slug) = split_dir_name(&entry.dir_name);
+
+    StoryRow {
+        number: number.map(str::to_string),
+        slug: slug.to_string(),
+        area: entry.area,
+        status: status_cell(story_md, tasks_md, known),
+        census: tasks_md.map(census),
+        title: title.map(str::to_string),
+        kind: value("type"),
+        scale: value("scale"),
+        engineering: value("engineering"),
+        open_target,
+        problem: story.into_problem().or_else(|| tasks.into_problem()),
+        dir_name: entry.dir_name,
+    }
+}
+
+/// `metadata` before any read: a symlink is not followed, a FIFO is never opened (it would block
+/// the read), and an oversized file is never loaded whole.
+async fn read_file(fs: &dyn Fs, story_dir: &Path, file: &str) -> FileRead {
+    let path = story_dir.join(file);
+    let problem = match fs.metadata(&path).await {
+        Ok(None) => return FileRead::Absent,
+        Ok(Some(metadata)) if metadata.is_symlink => RowProblem::Symlink(file.to_string()),
+        Ok(Some(metadata)) if !is_regular(&metadata) => {
+            RowProblem::Unreadable(file.to_string(), "not a regular file".to_string())
+        }
+        Ok(Some(metadata)) if metadata.len > MAX_FILE_BYTES => {
+            RowProblem::TooLarge(file.to_string(), metadata.len)
+        }
+        Ok(Some(_)) => match fs.load(&path).await {
+            Ok(text) => return FileRead::Text(text),
+            Err(error) => RowProblem::Unreadable(file.to_string(), root_cause(&error)),
+        },
+        Err(error) => RowProblem::Unreadable(file.to_string(), root_cause(&error)),
+    };
+    FileRead::Problem(problem)
+}
+
+fn is_regular(metadata: &Metadata) -> bool {
+    !metadata.is_dir && !metadata.is_fifo
+}
+
+/// The innermost error's message: the cause without the wrappers that name absolute paths.
+fn root_cause(error: &anyhow::Error) -> String {
+    error.root_cause().to_string()
+}
+
+// The test file is pre-authored and frozen byte for byte, so rustfmt must not rewrap it, and
+// clippy's ban on blocking process spawns cannot be met inside it (it runs `mkfifo`).
+#[cfg(test)]
+#[path = "scan_tests.rs"]
+#[rustfmt::skip]
+#[allow(clippy::disallowed_methods)]
+mod scan_tests;
diff --git a/crates/epic_panel/src/scan_tests.rs b/crates/epic_panel/src/scan_tests.rs
new file mode 100644
index 0000000..7ed9aac
--- /dev/null
+++ b/crates/epic_panel/src/scan_tests.rs
@@ -0,0 +1,825 @@
+// Story 042, task 3.1 — reading one `.epico/` directory into a snapshot (R2.1, R2.2, R2.3, R2.7,
+// R2.8, R2.11, R2.12, R3.5, R3.6, R6.1-R6.4, R6.6).
+//
+// Mounted from `src/scan.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "scan_tests.rs"]
+//     mod scan_tests;
+//
+// so the Validation filter `cargo test -p epic_panel scan` selects it.
+//
+// Dev-dependencies: fs, gpui (both with "test-support"), serde_json, tempfile.
+//
+// Contract used (design.md §Components `scan`, §Data Models): `scan_epic_dir(Arc<dyn Fs>,
+// PathBuf) -> anyhow::Result<EpicSnapshot>`; `EpicSnapshot { epic_dir, has_index, active,
+// archived, not_shown, duration }`; `StoryRow { dir_name, number, slug, area, status, census,
+// title, kind, scale, engineering, open_target, problem }`; `RowProblem::{Unreadable(file,
+// reason), TooLarge(file, bytes), Symlink(file)}` where `file`, `reason` and `bytes` only need
+// `Display`; `Area::{Active, Archived}`; `MAX_FILE_BYTES`, `MAX_ROWS`.
+
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+use fs::{FakeFs, Fs};
+use gpui::TestAppContext;
+use serde_json::{Value, json};
+
+use super::*;
+use crate::format::{StatusCell, progress_text};
+
+const ROOT: &str = "/work/project";
+const EPIC: &str = "/work/project/.epico";
+const EM_DASH: &str = "\u{2014}";
+
+fn fm(pairs: &[(&str, &str)], body: &str) -> String {
+    let mut text = String::from("---\n");
+    for (key, value) in pairs {
+        text.push_str(&format!("{key}: {value}\n"));
+    }
+    text.push_str("---\n\n");
+    text.push_str(body);
+    text
+}
+
+async fn scan(fs: &Arc<FakeFs>) -> EpicSnapshot {
+    let fs: Arc<dyn Fs> = fs.clone();
+    scan_epic_dir(fs, PathBuf::from(EPIC))
+        .await
+        .expect("a listable .epico directory scans without error")
+}
+
+fn all_rows(snapshot: &EpicSnapshot) -> impl Iterator<Item = &StoryRow> {
+    snapshot.active.iter().chain(snapshot.archived.iter())
+}
+
+fn row<'a>(snapshot: &'a EpicSnapshot, dir_name: &str) -> &'a StoryRow {
+    all_rows(snapshot)
+        .find(|row| row.dir_name == dir_name)
+        .unwrap_or_else(|| panic!("no row for {dir_name}"))
+}
+
+fn names(rows: &[StoryRow]) -> Vec<&str> {
+    rows.iter().map(|row| row.dir_name.as_str()).collect()
+}
+
+fn problem(row: &StoryRow) -> Option<String> {
+    match &row.problem {
+        None => None,
+        Some(RowProblem::Unreadable(file, reason)) => Some(format!("unreadable {file}: {reason}")),
+        Some(RowProblem::TooLarge(file, bytes)) => Some(format!("too large {file} {bytes}")),
+        Some(RowProblem::Symlink(file)) => Some(format!("symlink {file}")),
+    }
+}
+
+fn status(row: &StoryRow) -> String {
+    row.status.text()
+}
+
+fn progress(row: &StoryRow) -> String {
+    progress_text(row.census)
+}
+
+fn path(relative: &str) -> PathBuf {
+    Path::new(EPIC).join(relative)
+}
+
+#[test]
+fn scan_limits_are_the_story_limits() {
+    assert_eq!(MAX_FILE_BYTES, 1024 * 1024, "R6.2: 1 MiB");
+    assert_eq!(MAX_ROWS, 2000, "R2.12");
+}
+
+// ── the ordinary rows ───────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn scan_lists_active_and_archived_rows_with_every_field(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({
+            ".epico": {
+                "EPICO.md": "# Epic\n",
+                "stories": {
+                    "001-first-story": {
+                        "story.md": fm(
+                            &[("type", "feature"), ("scale", "standard"), ("engineering", "project"), ("status", "in-progress")],
+                            "# Story - The first story\n",
+                        ),
+                        "tasks.md": fm(&[("status", "draft")], "# Implementation Plan - The first story\n- [x] 1\n- [ ] 2\n- [~] 3 (deferred: later)\n"),
+                        "design.md": "# Design - ignored\n",
+                    },
+                },
+                "archive": {
+                    "002-old-story": {
+                        "story.md": fm(&[("status", "archived")], "# Story - Old\n"),
+                        "tasks.md": "- [x] a\n",
+                    },
+                    "manifest.yaml": "entries: []\n",
+                },
+            },
+        }),
+    )
+    .await;
+
+    let snapshot = scan(&fs).await;
+    assert_eq!(snapshot.epic_dir, PathBuf::from(EPIC));
+    assert!(snapshot.has_index, "EPICO.md is there (R2.8)");
+    assert_eq!(names(&snapshot.active), ["001-first-story"]);
+    assert_eq!(names(&snapshot.archived), ["002-old-story"]);
+    assert_eq!(snapshot.not_shown, 0);
+
+    let first = row(&snapshot, "001-first-story");
+    assert_eq!(first.number.as_deref(), Some("001"));
+    assert_eq!(first.slug, "first-story");
+    assert!(matches!(first.area, Area::Active));
+    assert_eq!(status(first), "in-progress");
+    assert_eq!(progress(first), "1/3 (+1 deferred)");
+    assert_eq!(first.title.as_deref(), Some("The first story"));
+    assert_eq!(first.kind.as_deref(), Some("feature"));
+    assert_eq!(first.scale.as_deref(), Some("standard"));
+    assert_eq!(first.engineering.as_deref(), Some("project"));
+    assert_eq!(
+        first.open_target,
+        Some(path("stories/001-first-story/story.md"))
+    );
+    assert_eq!(problem(first), None);
+
+    let old = row(&snapshot, "002-old-story");
+    assert!(matches!(old.area, Area::Archived));
+    assert_eq!(status(old), "archived");
+    assert_eq!(progress(old), "1/1");
+}
+
+#[gpui::test]
+async fn scan_falls_back_to_tasks_md_for_status_title_and_target(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": {
+            "003-no-status": {
+                "story.md": fm(&[("type", "bug")], "# Story - Has no status\n"),
+                "tasks.md": fm(&[("status", "in-progress"), ("scale", "fast")], "- [ ] a\n"),
+            },
+            "004-tasks-only": {
+                "tasks.md": fm(&[("status", "done"), ("scale", "fast")], "# Implementation Plan - Tasks only\n- [x] a\n"),
+            },
+        }}}),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    assert!(!snapshot.has_index, "no EPICO.md");
+
+    let no_status = row(&snapshot, "003-no-status");
+    assert_eq!(
+        status(no_status),
+        "in-progress",
+        "R3.1 fallback to tasks.md"
+    );
+    assert_eq!(no_status.title.as_deref(), Some("Has no status"));
+    assert_eq!(
+        no_status.open_target,
+        Some(path("stories/003-no-status/story.md"))
+    );
+
+    let tasks_only = row(&snapshot, "004-tasks-only");
+    assert_eq!(status(tasks_only), "done");
+    assert_eq!(
+        tasks_only.title.as_deref(),
+        Some("Tasks only"),
+        "R2.9 title from tasks.md"
+    );
+    assert_eq!(tasks_only.scale.as_deref(), Some("fast"));
+    assert_eq!(
+        tasks_only.open_target,
+        Some(path("stories/004-tasks-only/tasks.md")),
+        "R2.6"
+    );
+}
+
+#[gpui::test]
+async fn scan_keeps_an_unknown_status_verbatim(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": { "005-odd": { "story.md": fm(&[("status", "blocked-upstream")], "") } } } }),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    let odd = row(&snapshot, "005-odd");
+    assert!(matches!(&odd.status, StatusCell::Unknown(value) if value == "blocked-upstream"));
+    assert_eq!(progress(odd), EM_DASH, "R4.4 no tasks.md");
+}
+
+// R2.7, R6.4 — a directory with neither file is listed, opens nothing, and is not a problem.
+#[gpui::test]
+async fn scan_directory_without_story_files(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": { "037-just-drafts": {
+            ".draft": { "meta.yaml": "phase: 1\n" },
+            "notes.txt": "status: done\n",
+        } } } }),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    let drafts = row(&snapshot, "037-just-drafts");
+    assert_eq!(drafts.open_target, None);
+    assert_eq!(problem(drafts), None, "absence is not a problem");
+    assert_eq!(status(drafts), EM_DASH);
+    assert_eq!(drafts.census, None);
+    assert_eq!(drafts.title, None);
+}
+
+// R6.4 — files beside the story directories are not rows.
+#[gpui::test]
+async fn scan_ignores_files_in_the_areas(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": {
+            "stories": { "README.md": "x", "010-real": {} , "011-a-file.md": "x" },
+            "archive": { "manifest.yaml": "x" },
+            "sources": { "012-not-an-area": {} },
+        } }),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    assert_eq!(names(&snapshot.active), ["010-real"]);
+    assert!(snapshot.archived.is_empty());
+}
+
+// ── order (R2.2) ────────────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn scan_orders_by_number_value_then_unnumbered_by_name(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    let mut stories = serde_json::Map::new();
+    for name in [
+        "zeta",
+        "010-b",
+        "Beta",
+        "9-a",
+        "0100-e",
+        "99-f",
+        "99999999999999999999999-big",
+        "7-y",
+        "alpha",
+        "007-x",
+        "00000000000000000000001-tiny",
+        "042",
+    ] {
+        stories.insert(name.to_string(), json!({}));
+    }
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": Value::Object(stories) } }),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    assert_eq!(
+        names(&snapshot.active),
+        [
+            "00000000000000000000001-tiny",
+            // equal value 7: the directory name breaks the tie, bytewise
+            "007-x",
+            "7-y",
+            // lexical order would put "010-b" before "9-a"
+            "9-a",
+            "010-b",
+            // lexical order would put "0100-e" before "99-f"
+            "99-f",
+            "0100-e",
+            // a 23-digit number sorts by value, never wrapped into an integer
+            "99999999999999999999999-big",
+            // unnumbered (no `NNN-slug`): after every numbered row, bytewise by name
+            "042",
+            "Beta",
+            "alpha",
+            "zeta",
+        ]
+    );
+    let unnumbered = row(&snapshot, "zeta");
+    assert_eq!(unnumbered.number, None);
+    assert_eq!(unnumbered.slug, "");
+    let bare = row(&snapshot, "042");
+    assert_eq!(bare.number, None, "`042` alone is not NNN-slug");
+}
+
+#[gpui::test]
+async fn scan_keeps_rows_sharing_a_number_in_both_areas(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": { "012-a": {} }, "archive": { "012-b": {}, "11-c": {} } } }),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    assert_eq!(names(&snapshot.active), ["012-a"]);
+    assert_eq!(names(&snapshot.archived), ["11-c", "012-b"]);
+}
+
+// ── pointer resolution across stories/ and archive/ (R3.5, R3.6) ────────────
+
+#[gpui::test]
+async fn scan_resolves_pointers_against_both_areas(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": {
+            "stories": {
+                // a tasks-only spike promoted to a story that now lives in the archive
+                "020-spike": { "tasks.md": fm(&[("scale", "spike")], "## Verdict\n- status: promote\n- promoted-to: 3\n") },
+                // superseded by an archived story, written with leading zeros
+                "021-old": { "story.md": fm(&[("status", "superseded"), ("superseded-by", "0008")], "") },
+                // superseded by a number only an unnumbered directory and a file carry
+                "022-dangling": { "story.md": fm(&[("status", "superseded"), ("superseded-by", "4")], "") },
+                "4": {},
+                "004-a-file.md": "not a directory",
+                // promoted to a number that is a prefix of an existing one
+                "023-prefix": { "tasks.md": fm(&[("scale", "spike")], "## Verdict\n- status: promote\n- promoted-to: 12\n") },
+                "120-long": {},
+            },
+            "archive": {
+                "003-promoted": {},
+                "008-successor": {},
+            },
+        } }),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    // Hostile half (wrong split): a number known only from archive/ is still known.
+    assert_eq!(status(row(&snapshot, "020-spike")), "promote to 3");
+    assert_eq!(status(row(&snapshot, "021-old")), "superseded by 0008");
+    // Hostile half (wrong collapse): an unnumbered "4" and a file are not story number 4,
+    // and 120 is not 12.
+    assert_eq!(
+        status(row(&snapshot, "022-dangling")),
+        "superseded by 4 (missing)"
+    );
+    assert_eq!(
+        status(row(&snapshot, "023-prefix")),
+        "promote to 12 (missing)"
+    );
+}
+
+// ── tolerance (R6.1-R6.3) ───────────────────────────────────────────────────
+
+#[gpui::test]
+async fn scan_lists_a_symlinked_story_directory_without_reading_it(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/outside/real-story",
+        json!({
+            "story.md": fm(&[("status", "validated"), ("type", "secret")], "# Story - Outside title\n"),
+            "tasks.md": "- [x] a\n- [x] b\n",
+        }),
+    )
+    .await;
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": { "010-plain": {} } } }),
+    )
+    .await;
+    fs.insert_symlink(
+        path("stories/011-link"),
+        PathBuf::from("/outside/real-story"),
+    )
+    .await;
+
+    let snapshot = scan(&fs).await;
+    assert_eq!(
+        names(&snapshot.active),
+        ["010-plain", "011-link"],
+        "the link is still a row"
+    );
+    let link = row(&snapshot, "011-link");
+    assert!(
+        matches!(link.problem, Some(RowProblem::Symlink(_))),
+        "{:?}",
+        problem(link)
+    );
+    assert_eq!(status(link), EM_DASH, "nothing behind the link is read");
+    assert_eq!(link.census, None);
+    assert_eq!(link.title, None);
+    assert_eq!(link.kind, None);
+    assert_eq!(
+        link.open_target, None,
+        "activating it must not open a file through the link"
+    );
+}
+
+#[gpui::test]
+async fn scan_does_not_follow_a_symlinked_tasks_md(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/outside",
+        json!({ "secret.md": fm(&[("status", "validated"), ("scale", "spike")], "- [x] a\n- [x] b\n- [x] c\n") }),
+    )
+    .await;
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": { "013-linked-tasks": {
+            "story.md": fm(&[("status", "done")], "# Story - Linked tasks\n"),
+        } } } }),
+    )
+    .await;
+    fs.insert_symlink(
+        path("stories/013-linked-tasks/tasks.md"),
+        PathBuf::from("/outside/secret.md"),
+    )
+    .await;
+
+    let snapshot = scan(&fs).await;
+    let linked = row(&snapshot, "013-linked-tasks");
+    assert_eq!(problem(linked), Some("symlink tasks.md".to_string()));
+    assert_eq!(
+        status(linked),
+        "done",
+        "story.md is still read (a problem row keeps what it could read)"
+    );
+    assert_eq!(linked.census, None, "the target's boxes are never counted");
+    assert_eq!(progress(linked), EM_DASH);
+    assert_eq!(
+        linked.scale, None,
+        "the target's `scale: spike` is never read"
+    );
+    assert_eq!(
+        linked.open_target,
+        Some(path("stories/013-linked-tasks/story.md"))
+    );
+}
+
+#[gpui::test]
+async fn scan_does_not_follow_a_symlinked_story_md(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/outside",
+        json!({ "secret.md": fm(&[("status", "validated")], "# Story - Secret\n") }),
+    )
+    .await;
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": { "014-linked-story": {
+            "tasks.md": fm(&[("status", "draft")], "- [ ] a\n"),
+        } } } }),
+    )
+    .await;
+    fs.insert_symlink(
+        path("stories/014-linked-story/story.md"),
+        PathBuf::from("/outside/secret.md"),
+    )
+    .await;
+
+    let snapshot = scan(&fs).await;
+    let linked = row(&snapshot, "014-linked-story");
+    assert_eq!(problem(linked), Some("symlink story.md".to_string()));
+    assert_eq!(
+        status(linked),
+        "draft",
+        "status falls back to tasks.md, never the target"
+    );
+    assert_ne!(linked.title.as_deref(), Some("Secret"));
+    assert_eq!(progress(linked), "0/1");
+    assert_ne!(
+        linked.open_target,
+        Some(path("stories/014-linked-story/story.md")),
+        "activating the row must not open the symlink"
+    );
+}
+
+// Hostile pair for the 1 MiB limit: exactly the limit is read, one byte more is not.
+#[gpui::test]
+async fn scan_size_limit_is_inclusive_of_one_mebibyte(cx: &mut TestAppContext) {
+    fn tasks_of_len(len: usize) -> Vec<u8> {
+        let mut text = String::from("- [x] a\n- [ ] b\n");
+        text.push_str(&" ".repeat(len - text.len()));
+        assert_eq!(text.len(), len);
+        text.into_bytes()
+    }
+    let limit = MAX_FILE_BYTES as usize;
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": {
+            "030-at-limit": { "story.md": fm(&[("status", "done")], "") },
+            "031-over-limit": { "story.md": fm(&[("status", "done")], "") },
+        } } }),
+    )
+    .await;
+    fs.insert_file(path("stories/030-at-limit/tasks.md"), tasks_of_len(limit))
+        .await;
+    fs.insert_file(
+        path("stories/031-over-limit/tasks.md"),
+        tasks_of_len(limit + 1),
+    )
+    .await;
+
+    let snapshot = scan(&fs).await;
+    let at = row(&snapshot, "030-at-limit");
+    assert_eq!(problem(at), None);
+    assert_eq!(progress(at), "1/2");
+
+    let over = row(&snapshot, "031-over-limit");
+    assert_eq!(
+        problem(over),
+        Some(format!("too large tasks.md {}", limit + 1))
+    );
+    assert_eq!(over.census, None);
+    assert_eq!(status(over), "done");
+}
+
+#[gpui::test]
+async fn scan_marks_a_non_utf8_file_unreadable_and_keeps_the_row(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": {
+            "040-binary": { "story.md": fm(&[("status", "done")], "") },
+            "041-fine": { "story.md": fm(&[("status", "draft")], "") },
+        } } }),
+    )
+    .await;
+    fs.insert_file(
+        path("stories/040-binary/tasks.md"),
+        b"- [x] a\n\xff\xfe\n".to_vec(),
+    )
+    .await;
+
+    let snapshot = scan(&fs).await;
+    let binary = row(&snapshot, "040-binary");
+    let shown = problem(binary).expect("a problem is recorded");
+    assert!(shown.starts_with("unreadable tasks.md: "), "{shown}");
+    assert!(
+        shown.to_lowercase().contains("utf-8"),
+        "the reason names the cause: {shown}"
+    );
+    assert_eq!(status(binary), "done");
+    assert_eq!(binary.census, None);
+    assert_eq!(
+        status(row(&snapshot, "041-fine")),
+        "draft",
+        "R6.1 every other row as usual"
+    );
+}
+
+#[gpui::test]
+async fn scan_marks_a_directory_named_like_a_story_file_unreadable(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": { "050-dir-file": {
+            "story.md": fm(&[("status", "done")], ""),
+            "tasks.md": { "inner": "x" },
+        } } } }),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    let odd = row(&snapshot, "050-dir-file");
+    let shown = problem(odd).expect("a problem is recorded");
+    assert!(shown.starts_with("unreadable tasks.md: "), "{shown}");
+    assert!(shown.contains("not a regular file"), "{shown}");
+    assert_eq!(status(odd), "done");
+}
+
+// ── areas and the directory itself (R2.3, R2.11, error context) ─────────────
+
+#[gpui::test]
+async fn scan_missing_areas_are_empty_not_errors(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "archive": { "003-only-archived": {} } } }),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    assert!(snapshot.active.is_empty(), "no stories/ is an empty area");
+    assert_eq!(names(&snapshot.archived), ["003-only-archived"]);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(ROOT, json!({ ".epico": {} })).await;
+    let snapshot = scan(&fs).await;
+    assert!(snapshot.active.is_empty() && snapshot.archived.is_empty());
+    assert!(!snapshot.has_index);
+}
+
+#[gpui::test]
+async fn scan_of_an_unlistable_epic_dir_is_an_error_with_relative_context(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(ROOT, json!({ "src": {} })).await;
+    let fs_dyn: Arc<dyn Fs> = fs.clone();
+    let error = scan_epic_dir(fs_dyn, PathBuf::from(EPIC))
+        .await
+        .err()
+        .expect("a missing .epico cannot be listed");
+    let outer = error.to_string();
+    assert!(
+        outer.contains("project/.epico"),
+        "context names <worktree>/.epico: {outer}"
+    );
+    assert!(
+        !outer.contains("/work/"),
+        "the context carries no absolute path: {outer}"
+    );
+}
+
+// ── the row cap (R2.12) ─────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn scan_caps_rows_in_display_order(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    let mut stories = serde_json::Map::new();
+    for number in 1..=1999 {
+        stories.insert(format!("{number:04}-s"), json!({}));
+    }
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": {
+            "stories": Value::Object(stories),
+            "archive": { "2000-kept": {}, "2001-dropped": {} },
+        } }),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    assert_eq!(
+        snapshot.active.len(),
+        1999,
+        "active rows come first and are all kept"
+    );
+    assert_eq!(names(&snapshot.archived), ["2000-kept"]);
+    assert_eq!(snapshot.not_shown, 1);
+    assert_eq!(snapshot.active.len() + snapshot.archived.len(), MAX_ROWS);
+}
+
+#[gpui::test]
+async fn scan_exactly_max_rows_hides_nothing(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    let mut stories = serde_json::Map::new();
+    for number in 1..=MAX_ROWS {
+        stories.insert(format!("{number:04}-s"), json!({}));
+    }
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": Value::Object(stories) } }),
+    )
+    .await;
+    let snapshot = scan(&fs).await;
+    assert_eq!(snapshot.active.len(), MAX_ROWS);
+    assert_eq!(snapshot.not_shown, 0);
+}
+
+// ── R6.5 on the fake file system: the tree is the same after a scan ─────────
+
+#[gpui::test]
+async fn scan_leaves_the_fake_tree_unchanged(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        ROOT,
+        json!({ ".epico": { "stories": { "001-a": { "story.md": fm(&[("status", "done")], ""), "tasks.md": "- [ ] x\n" } } } }),
+    )
+    .await;
+    let before = fs.paths(true);
+    let before_files: Vec<_> = fs
+        .files()
+        .into_iter()
+        .map(|file| (file.clone(), fs.read_file_sync(&file).ok()))
+        .collect();
+    scan(&fs).await;
+    scan(&fs).await;
+    assert_eq!(fs.paths(true), before);
+    let after_files: Vec<_> = fs
+        .files()
+        .into_iter()
+        .map(|file| (file.clone(), fs.read_file_sync(&file).ok()))
+        .collect();
+    assert_eq!(after_files, before_files);
+}
+
+// ── RealFs-only cases: FIFO, permission, the real UTF-8 error ───────────────
+
+#[cfg(target_os = "linux")]
+mod real_fs {
+    use std::{
+        fs as stdfs,
+        os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
+        sync::atomic::{AtomicBool, Ordering},
+        time::Duration,
+    };
+
+    use super::*;
+
+    const O_NONBLOCK: i32 = 0o4000;
+
+    fn write(root: &Path, relative: &str, content: &[u8]) {
+        let file = root.join(relative);
+        let parent = file.parent().expect("a relative file has a parent");
+        stdfs::create_dir_all(parent)
+            .unwrap_or_else(|e| panic!("create {}: {e}", parent.display()));
+        stdfs::write(&file, content).unwrap_or_else(|e| panic!("write {}: {e}", file.display()));
+    }
+
+    /// If anything opens `fifo` for reading, give it EOF instead of letting the test hang.
+    /// A correct scan never opens it, and then this only ever sees ENXIO.
+    fn unblock_readers_of(fifo: PathBuf, done: Arc<AtomicBool>) -> std::thread::JoinHandle<()> {
+        std::thread::spawn(move || {
+            while !done.load(Ordering::SeqCst) {
+                if let Ok(writer) = stdfs::OpenOptions::new()
+                    .write(true)
+                    .custom_flags(O_NONBLOCK)
+                    .open(&fifo)
+                {
+                    drop(writer);
+                }
+                std::thread::sleep(Duration::from_millis(50));
+            }
+        })
+    }
+
+    #[gpui::test]
+    async fn scan_refuses_a_fifo_without_blocking(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let temp = tempfile::tempdir().expect("temp dir");
+        let epic = temp.path().join("project/.epico");
+        write(
+            &epic,
+            "stories/060-fifo/story.md",
+            fm(&[("status", "done")], "").as_bytes(),
+        );
+        let fifo = epic.join("stories/060-fifo/tasks.md");
+        let made = std::process::Command::new("mkfifo")
+            .arg(&fifo)
+            .status()
+            .expect("mkfifo runs");
+        assert!(made.success(), "mkfifo {}", fifo.display());
+        let done = Arc::new(AtomicBool::new(false));
+        let unblocker = unblock_readers_of(fifo.clone(), done.clone());
+
+        let fs: Arc<dyn Fs> = fs::RealFs::new(None, cx.executor());
+        let snapshot = scan_epic_dir(fs, epic.clone()).await.expect("scan");
+        done.store(true, Ordering::SeqCst);
+        unblocker.join().expect("unblocker thread");
+
+        let row = row(&snapshot, "060-fifo");
+        let shown = problem(row).expect("the FIFO is a problem, refused before any read");
+        assert!(shown.starts_with("unreadable tasks.md: "), "{shown}");
+        assert!(shown.contains("not a regular file"), "{shown}");
+        assert_eq!(status(row), "done");
+    }
+
+    #[gpui::test]
+    async fn scan_reports_the_root_cause_of_a_load_error(cx: &mut TestAppContext) {
+        cx.executor().allow_parking();
+        let temp = tempfile::tempdir().expect("temp dir");
+        let epic = temp.path().join("project/.epico");
+        write(&epic, "stories/061-binary/tasks.md", b"- [x] a\n\xff\xfe\n");
+        write(
+            &epic,
+            "stories/062-locked/story.md",
+            fm(&[("status", "done")], "").as_bytes(),
+        );
+        write(
+            &epic,
+            "stories/062-locked/tasks.md",
+            fm(&[("status", "draft")], "- [ ] a\n").as_bytes(),
+        );
+        let locked = epic.join("stories/062-locked/story.md");
+        stdfs::set_permissions(&locked, stdfs::Permissions::from_mode(0o000))
+            .unwrap_or_else(|e| panic!("chmod {}: {e}", locked.display()));
+        let can_still_read = stdfs::read(&locked).is_ok();
+
+        let fs: Arc<dyn Fs> = fs::RealFs::new(None, cx.executor());
+        let snapshot = scan_epic_dir(fs, epic.clone()).await.expect("scan");
+        stdfs::set_permissions(&locked, stdfs::Permissions::from_mode(0o644))
+            .unwrap_or_else(|e| panic!("chmod back {}: {e}", locked.display()));
+
+        let temp_text = temp.path().display().to_string();
+        let binary = problem(row(&snapshot, "061-binary")).expect("non-UTF-8 is a problem");
+        assert!(binary.starts_with("unreadable tasks.md: "), "{binary}");
+        assert!(
+            binary.contains("UTF-8"),
+            "the root cause, not a wrapper: {binary}"
+        );
+        assert!(
+            !binary.contains(&temp_text),
+            "no absolute path in the reason: {binary}"
+        );
+
+        if can_still_read {
+            eprintln!("SKIPPED permission case: this user reads mode-000 files");
+            return;
+        }
+        let row = row(&snapshot, "062-locked");
+        let shown = problem(row).expect("a mode-000 story.md is a problem");
+        assert!(shown.starts_with("unreadable story.md: "), "{shown}");
+        assert!(shown.contains("ermission denied"), "{shown}");
+        assert!(!shown.contains(&temp_text), "{shown}");
+        assert_eq!(
+            status(row),
+            "draft",
+            "status falls back to the readable tasks.md"
+        );
+    }
+}
diff --git a/crates/epic_panel/src/watch.rs b/crates/epic_panel/src/watch.rs
new file mode 100644
index 0000000..09b8ec8
--- /dev/null
+++ b/crates/epic_panel/src/watch.rs
@@ -0,0 +1,201 @@
+use std::{
+    collections::HashSet,
+    path::{MAIN_SEPARATOR_STR, Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+};
+
+use fs::{Fs, PathEvent, PathEventKind, Watcher};
+use futures::{FutureExt as _, StreamExt as _};
+use gpui::{App, Task};
+use parking_lot::Mutex;
+
+/// At most one `on_change` per this window while events keep arriving (R5.3).
+pub const DEBOUNCE: Duration = Duration::from_millis(500);
+/// The native watcher's own batching; it sits inside `DEBOUNCE`.
+pub const WATCH_LATENCY: Duration = Duration::from_millis(100);
+
+const AREAS: [&str; 2] = ["stories", "archive"];
+
+type OnFailure = Box<dyn Fn(String) + Send>;
+/// The callback taken out of the state, with the reason to call it with.
+type Failure = (OnFailure, String);
+
+/// Follows one `.epic/` directory and reports debounced changes. Dropping it ends the watch:
+/// the task owns the event stream, and `state` owns the watcher.
+pub struct EpicWatch {
+    _task: Task<()>,
+    state: Arc<Mutex<WatchState>>,
+}
+
+struct WatchState {
+    /// `None` until `fs.watch` returns inside the background task.
+    watcher: Option<Arc<dyn Watcher>>,
+    added: HashSet<PathBuf>,
+    wanted: Vec<PathBuf>,
+    /// Taken on the first failure, so `on_failure` runs at most once per `EpicWatch`.
+    on_failure: Option<OnFailure>,
+    worktree_root: PathBuf,
+}
+
+impl EpicWatch {
+    /// Spawns on the background executor: watches `epic_dir`, adds `stories/` and `archive/`
+    /// when they exist (Linux inotify is not recursive), then every directory `sync_dirs` asked
+    /// for. Calls `on_change` at most once per `DEBOUNCE`; calls `on_failure` once, when a
+    /// directory cannot be added or the event stream ends.
+    pub fn start(
+        fs: Arc<dyn Fs>,
+        epic_dir: PathBuf,
+        on_change: impl Fn() + Send + 'static,
+        on_failure: impl Fn(String) + Send + 'static,
+        cx: &App,
+    ) -> Self {
+        let worktree_root = epic_dir.parent().map(Path::to_path_buf).unwrap_or_default();
+        let state = Arc::new(Mutex::new(WatchState {
+            watcher: None,
+            added: HashSet::default(),
+            wanted: Vec::new(),
+            on_failure: Some(Box::new(on_failure)),
+            worktree_root,
+        }));
+        let executor = cx.background_executor().clone();
+        let task = cx.background_executor().spawn({
+            let state = state.clone();
+            async move {
+                let (events, watcher) = fs.watch(&epic_dir, WATCH_LATENCY).await;
+                // Fused: the drain below may see the end, and the loop then polls once more.
+                let mut events = events.fuse();
+                let mut areas = Vec::new();
+                for area in AREAS {
+                    let path = epic_dir.join(area);
+                    // A missing area is not a failure: it arrives later through `sync_dirs`.
+                    // A symlinked area is never watched: it would point outside `.epic`.
+                    if matches!(
+                        fs.metadata(&path).await,
+                        Ok(Some(metadata)) if metadata.is_dir && !metadata.is_symlink
+                    ) {
+                        areas.push(path);
+                    }
+                }
+                let failure = {
+                    let mut state = state.lock();
+                    state.watcher = Some(watcher);
+                    let wanted = state.wanted.clone();
+                    state.add_missing(areas.iter().chain(wanted.iter()))
+                };
+                report(failure);
+
+                while let Some(batch) = events.next().await {
+                    state.lock().forget_stale(&batch);
+                    executor.timer(DEBOUNCE).await;
+                    // Whatever arrived during the window is covered by the one rescan below.
+                    while let Some(Some(batch)) = events.next().now_or_never() {
+                        state.lock().forget_stale(&batch);
+                    }
+                    on_change();
+                }
+                let failure = state.lock().take_failure("the watch ended".to_string());
+                report(failure);
+            }
+        });
+        Self { _task: task, state }
+    }
+
+    /// `dirs` replaces the wanted set: the areas that exist now plus every story directory.
+    /// Whatever is not yet added is added at once when the watcher exists, or by the task as
+    /// soon as it does. A directory that left the set is forgotten, so it is added again if it
+    /// returns (the watcher re-arms a stale registration).
+    pub fn sync_dirs(&self, dirs: &[PathBuf]) {
+        let failure = {
+            let mut state = self.state.lock();
+            state.wanted = dirs.to_vec();
+            let wanted: HashSet<&PathBuf> = dirs.iter().collect();
+            state.added.retain(|path| wanted.contains(path));
+            state.add_missing(dirs.iter())
+        };
+        report(failure);
+    }
+}
+
+/// Runs outside the state lock, so the callback may call back into `EpicWatch`.
+fn report(failure: Option<Failure>) {
+    if let Some((on_failure, reason)) = failure {
+        on_failure(reason);
+    }
+}
+
+impl WatchState {
+    /// A directory that was removed or created may have lost its inotify registration (the
+    /// kernel drops it on removal), so it is forgotten here and the next `sync_dirs` after the
+    /// rescan adds it again. A rescan event means anything may be stale.
+    fn forget_stale(&mut self, batch: &[PathEvent]) {
+        for event in batch {
+            match event.kind {
+                Some(PathEventKind::Rescan) => self.added.clear(),
+                Some(PathEventKind::Removed | PathEventKind::Created) => {
+                    self.added.remove(&event.path);
+                }
+                _ => {}
+            }
+        }
+    }
+
+    /// Adds every path not yet added. The first `add` error takes `on_failure` and is returned
+    /// with its reason; later ones are not reported again, and adding carries on.
+    fn add_missing<'a>(&mut self, paths: impl Iterator<Item = &'a PathBuf>) -> Option<Failure> {
+        let watcher = self.watcher.clone()?;
+        let mut failure = None;
+        for path in paths {
+            if !self.added.insert(path.clone()) {
+                continue;
+            }
+            if let Err(error) = watcher.add(path) {
+                let reason = format!(
+                    "could not watch {}: {}",
+                    self.relative(path),
+                    self.redact(&error.to_string())
+                );
+                if failure.is_none() {
+                    failure = self.take_failure(reason);
+                }
+            }
+        }
+        failure
+    }
+
+    fn take_failure(&mut self, reason: String) -> Option<Failure> {
+        self.on_failure
+            .take()
+            .map(|on_failure| (on_failure, reason))
+    }
+
+    fn relative(&self, path: &Path) -> String {
+        match path.strip_prefix(&self.worktree_root) {
+            Ok(relative) => relative.display().to_string(),
+            Err(_) => path
+                .file_name()
+                .map(|name| name.to_string_lossy().into_owned())
+                .unwrap_or_default(),
+        }
+    }
+
+    /// Error texts from the watcher backend can name absolute paths; reasons are shown and
+    /// logged worktree-relative (R8.4).
+    fn redact(&self, text: &str) -> String {
+        let root = self.worktree_root.display().to_string();
+        if root.is_empty() {
+            return text.to_string();
+        }
+        let prefix = if root.ends_with(MAIN_SEPARATOR_STR) {
+            root
+        } else {
+            format!("{root}{MAIN_SEPARATOR_STR}")
+        };
+        text.replace(&prefix, "")
+    }
+}
+
+#[cfg(test)]
+#[path = "watch_tests.rs"]
+#[rustfmt::skip]
+mod watch_tests;
diff --git a/crates/epic_panel/src/watch_tests.rs b/crates/epic_panel/src/watch_tests.rs
new file mode 100644
index 0000000..075bd5c
--- /dev/null
+++ b/crates/epic_panel/src/watch_tests.rs
@@ -0,0 +1,375 @@
+// Story 042, task 4.1 — `EpicWatch`: debounced change notifications under `.epico/` (R5.1,
+// R5.2, R5.3, R5.6).
+//
+// Mounted from `src/watch.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "watch_tests.rs"]
+//     mod watch_tests;
+//
+// so the Validation filter `cargo test -p epic_panel watch` selects it.
+//
+// Contract (design.md §Components `watch`): `DEBOUNCE` (500 ms), `WATCH_LATENCY`,
+// `EpicWatch::start(fs, epic_dir, on_change, on_failure, cx: &App) -> EpicWatch`,
+// `EpicWatch::sync_dirs(&self, &[PathBuf])`; dropping the value ends the watch.
+//
+// Time is the test executor's virtual clock, advanced in 50 ms steps; a call observed in a
+// step happened within that step, so gaps are checked with one step of tolerance.
+//
+// Notes on FakeFs: its delivery is effectively recursive (any registration on an ancestor
+// receives the event), so the non-recursive inotify GOTCHA is checked through
+// `FakeFs::watch_calls()` — the paths the native backend was asked to watch — on Linux, where
+// the native watcher is non-recursive and every `Watcher::add` reaches the backend. A
+// `Watcher::add` failure is provoked with `FakeFs::create_file_before_next_watch_add` aimed at
+// a path that already exists: the fake backend then refuses the watch ("file already exists").
+//
+// Dev-dependencies: fs, gpui (test-support), serde_json.
+
+use std::{
+    path::{Path, PathBuf},
+    sync::{
+        Arc, Mutex,
+        atomic::{AtomicUsize, Ordering},
+    },
+    time::{Duration, Instant},
+};
+
+use fs::{FakeFs, Fs};
+use gpui::TestAppContext;
+use serde_json::json;
+
+use super::*;
+
+const EPIC: &str = "/work/project/.epico";
+const STEP: Duration = Duration::from_millis(50);
+
+fn epic(relative: &str) -> PathBuf {
+    Path::new(EPIC).join(relative)
+}
+
+#[derive(Clone, Default)]
+struct Probe {
+    changes: Arc<AtomicUsize>,
+    failures: Arc<Mutex<Vec<String>>>,
+}
+
+impl Probe {
+    fn changes(&self) -> usize {
+        self.changes.load(Ordering::SeqCst)
+    }
+
+    fn failures(&self) -> Vec<String> {
+        self.failures.lock().expect("failures lock").clone()
+    }
+}
+
+async fn corpus(cx: &mut TestAppContext) -> Arc<FakeFs> {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/work/project",
+        json!({ ".epico": {
+            "stories": {
+                "001-a": { "story.md": "---\nstatus: draft\n---\n", "tasks.md": "- [ ] a\n" },
+                "002-b": { "tasks.md": "- [ ] b\n" },
+            },
+            "archive": { "003-c": { "tasks.md": "- [x] c\n" } },
+        } }),
+    )
+    .await;
+    fs
+}
+
+fn start(fs: &Arc<FakeFs>, cx: &mut TestAppContext) -> (EpicWatch, Probe) {
+    let probe = Probe::default();
+    let changes = probe.changes.clone();
+    let failures = probe.failures.clone();
+    let fs: Arc<dyn Fs> = fs.clone();
+    let watch = cx.update(|cx| {
+        EpicWatch::start(
+            fs,
+            PathBuf::from(EPIC),
+            move || {
+                changes.fetch_add(1, Ordering::SeqCst);
+            },
+            move |reason: String| {
+                failures.lock().expect("failures lock").push(reason);
+            },
+            cx,
+        )
+    });
+    cx.run_until_parked();
+    (watch, probe)
+}
+
+/// Advances the virtual clock by `total` in steps; returns the step time of each call seen.
+fn advance(cx: &mut TestAppContext, probe: &Probe, total: Duration) -> Vec<Instant> {
+    let mut seen = probe.changes();
+    let mut times = Vec::new();
+    let mut elapsed = Duration::ZERO;
+    while elapsed < total {
+        cx.executor().advance_clock(STEP);
+        cx.run_until_parked();
+        elapsed += STEP;
+        let now = cx.executor().now();
+        let count = probe.changes();
+        for _ in seen..count {
+            times.push(now);
+        }
+        seen = count;
+    }
+    times
+}
+
+fn assert_spaced_by_debounce(times: &[Instant]) {
+    for pair in times.windows(2) {
+        let gap = pair[1] - pair[0];
+        assert!(
+            gap + STEP >= DEBOUNCE,
+            "R5.3: two rescans {gap:?} apart; at most one per {DEBOUNCE:?} is allowed ({times:?})"
+        );
+    }
+}
+
+#[test]
+fn watch_constants() {
+    assert_eq!(DEBOUNCE, Duration::from_millis(500), "R5.3");
+    assert!(
+        WATCH_LATENCY < DEBOUNCE,
+        "the native latency sits inside the debounce window"
+    );
+}
+
+// Hostile half: with no event there is no rescan.
+#[gpui::test]
+async fn watch_is_quiet_without_events(cx: &mut TestAppContext) {
+    let fs = corpus(cx).await;
+    let (_watch, probe) = start(&fs, cx);
+    advance(cx, &probe, Duration::from_secs(3));
+    assert_eq!(probe.changes(), 0);
+    assert!(probe.failures().is_empty(), "{:?}", probe.failures());
+}
+
+#[gpui::test]
+async fn watch_reports_a_tasks_md_change_below_story_level_within_two_seconds(
+    cx: &mut TestAppContext,
+) {
+    let fs = corpus(cx).await;
+    let (watch, probe) = start(&fs, cx);
+    watch.sync_dirs(&[
+        epic("stories/001-a"),
+        epic("stories/002-b"),
+        epic("archive/003-c"),
+    ]);
+    cx.run_until_parked();
+
+    fs.insert_file(epic("stories/001-a/tasks.md"), b"- [x] a\n".to_vec())
+        .await;
+    let times = advance(cx, &probe, Duration::from_secs(2));
+    assert!(
+        !times.is_empty(),
+        "R5.1: a tasks.md change two levels down is reported within 2 s"
+    );
+    drop(watch);
+}
+
+#[gpui::test]
+async fn watch_debounces_a_burst_into_spaced_rescans(cx: &mut TestAppContext) {
+    let fs = corpus(cx).await;
+    let (_watch, probe) = start(&fs, cx);
+    for round in 0..10u8 {
+        fs.insert_file(
+            epic("stories/001-a/tasks.md"),
+            vec![b'-', b' ', b'[', b' ', b']', b' ', b'0' + round, b'\n'],
+        )
+        .await;
+    }
+    let times = advance(cx, &probe, Duration::from_secs(3));
+    assert!(!times.is_empty(), "the burst is reported");
+    assert!(
+        times.len() <= 2,
+        "ten writes at one instant cost at most two rescans: {times:?}"
+    );
+    assert_spaced_by_debounce(&times);
+}
+
+#[gpui::test]
+async fn watch_sustained_changes_are_rate_limited_and_the_last_one_is_reported(
+    cx: &mut TestAppContext,
+) {
+    let fs = corpus(cx).await;
+    let (_watch, probe) = start(&fs, cx);
+    let mut times = Vec::new();
+    let mut last_event = cx.executor().now();
+    for round in 0..20u32 {
+        fs.insert_file(
+            epic("stories/002-b/tasks.md"),
+            format!("- [ ] {round}\n").into_bytes(),
+        )
+        .await;
+        last_event = cx.executor().now();
+        times.extend(advance(cx, &probe, Duration::from_millis(100)));
+    }
+    times.extend(advance(cx, &probe, Duration::from_secs(2)));
+
+    assert!(
+        times.len() < 20,
+        "one rescan per event is not a debounce: {} rescans",
+        times.len()
+    );
+    assert_spaced_by_debounce(&times);
+    let last_call = *times.last().expect("at least one rescan");
+    assert!(
+        last_call >= last_event,
+        "R5.1: a rescan follows the last change"
+    );
+    assert!(
+        last_call - last_event <= Duration::from_secs(2),
+        "R5.1: within 2 s of the last change"
+    );
+}
+
+#[gpui::test]
+async fn watch_reports_directories_created_moved_and_removed(cx: &mut TestAppContext) {
+    let fs = corpus(cx).await;
+    let (_watch, probe) = start(&fs, cx);
+
+    fs.insert_tree(
+        epic("stories/004-new"),
+        json!({ "tasks.md": "- [ ] new\n" }),
+    )
+    .await;
+    assert!(
+        !advance(cx, &probe, Duration::from_secs(2)).is_empty(),
+        "R5.2 created"
+    );
+
+    fs.rename(
+        &epic("stories/002-b"),
+        &epic("archive/002-b"),
+        Default::default(),
+    )
+    .await
+    .expect("move a story to the archive");
+    assert!(
+        !advance(cx, &probe, Duration::from_secs(2)).is_empty(),
+        "R5.2 moved"
+    );
+
+    fs.remove_dir(
+        &epic("stories/004-new"),
+        fs::RemoveOptions {
+            recursive: true,
+            ignore_if_not_exists: false,
+        },
+    )
+    .await
+    .expect("remove a story directory");
+    assert!(
+        !advance(cx, &probe, Duration::from_secs(2)).is_empty(),
+        "R5.2 removed"
+    );
+}
+
+// Hostile half for "the watch lives as long as the value": once dropped, nothing is reported.
+#[gpui::test]
+async fn watch_ends_when_dropped(cx: &mut TestAppContext) {
+    let fs = corpus(cx).await;
+    let (watch, probe) = start(&fs, cx);
+    drop(watch);
+    cx.run_until_parked();
+    fs.insert_file(epic("stories/001-a/tasks.md"), b"- [x] a\n".to_vec())
+        .await;
+    advance(cx, &probe, Duration::from_secs(2));
+    assert_eq!(probe.changes(), 0, "a dropped EpicWatch reports nothing");
+}
+
+// The Linux GOTCHA: inotify is not recursive, so the areas and every story directory need a
+// watch of their own.
+#[cfg(target_os = "linux")]
+#[gpui::test]
+async fn watch_adds_the_areas_and_each_synced_story_directory(cx: &mut TestAppContext) {
+    let fs = corpus(cx).await;
+    let (watch, probe) = start(&fs, cx);
+    let calls = fs.watch_calls();
+    for path in [epic(""), epic("stories"), epic("archive")] {
+        assert!(
+            calls.contains(&path),
+            "{} watched: {calls:?}",
+            path.display()
+        );
+    }
+
+    fs.insert_tree(
+        epic("stories/005-later"),
+        json!({ "tasks.md": "- [ ] x\n" }),
+    )
+    .await;
+    advance(cx, &probe, Duration::from_secs(1));
+    watch.sync_dirs(&[
+        epic("stories/001-a"),
+        epic("stories/005-later"),
+        epic("archive/003-c"),
+    ]);
+    cx.run_until_parked();
+    let calls = fs.watch_calls();
+    for path in [
+        epic("stories/001-a"),
+        epic("stories/005-later"),
+        epic("archive/003-c"),
+    ] {
+        assert!(
+            calls.contains(&path),
+            "{} watched after sync_dirs: {calls:?}",
+            path.display()
+        );
+    }
+    assert!(probe.failures().is_empty(), "{:?}", probe.failures());
+}
+
+// Hostile half for failure reporting: a missing area is not a failure (it may appear later).
+#[gpui::test]
+async fn watch_a_missing_area_is_not_a_failure(cx: &mut TestAppContext) {
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/work/project",
+        json!({ ".epico": { "stories": { "001-a": {} } } }),
+    )
+    .await;
+    let (_watch, probe) = start(&fs, cx);
+    advance(cx, &probe, Duration::from_secs(1));
+    assert!(
+        probe.failures().is_empty(),
+        "no archive/ yet: {:?}",
+        probe.failures()
+    );
+}
+
+#[cfg(target_os = "linux")]
+#[gpui::test]
+async fn watch_failure_is_reported_once_and_changes_still_flow(cx: &mut TestAppContext) {
+    let fs = corpus(cx).await;
+    // The next watch of stories/ will try to create a file that already exists, and fail.
+    fs.create_file_before_next_watch_add(epic("stories"), epic("stories/001-a/tasks.md"));
+    let (watch, probe) = start(&fs, cx);
+    watch.sync_dirs(&[epic("stories/001-a")]);
+    advance(cx, &probe, Duration::from_secs(1));
+
+    let failures = probe.failures();
+    assert_eq!(
+        failures.len(),
+        1,
+        "R5.6: on_failure is called once: {failures:?}"
+    );
+    assert!(
+        !failures[0].trim().is_empty(),
+        "the failure carries a reason"
+    );
+
+    fs.insert_file(epic("archive/003-c/tasks.md"), b"- [ ] reopened\n".to_vec())
+        .await;
+    assert!(
+        !advance(cx, &probe, Duration::from_secs(2)).is_empty(),
+        "the watch keeps going after a failed add"
+    );
+    assert_eq!(probe.failures().len(), 1, "still reported only once");
+}
diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs
index e6ef06b..853dc56 100644
--- a/crates/fs/src/fs.rs
+++ b/crates/fs/src/fs.rs
@@ -1116,8 +1116,11 @@ impl Fs for RealFs {
                 Err(err) => {
                     if err.kind() != io::ErrorKind::NotFound {
                         // TODO: Also FilesystemLoop when that's stable
+                        // The file name only: the path may sit in an untrusted repository,
+                        // and an absolute path does not belong in a log line.
                         log::warn!(
-                            "Failed to read symlink target metadata for path {path:?}: {err}"
+                            "Failed to read symlink target metadata for {:?}: {err}",
+                            path.file_name().unwrap_or_default()
                         );
                     }
                     // For a broken or recursive symlink, return the symlink metadata. (Or
diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml
index 5b4c596..6b1750f 100644
--- a/crates/zed/Cargo.toml
+++ b/crates/zed/Cargo.toml
@@ -111,6 +111,7 @@ devtools_bridge.workspace = true
 diagnostics.workspace = true
 editor.workspace = true
 encoding_selector.workspace = true
+epic_panel.workspace = true
 env_logger = { workspace = true, optional = true }
 extension.workspace = true
 extension_host.workspace = true
diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs
index 868414d..1598bc1 100644
--- a/crates/zed/src/main.rs
+++ b/crates/zed/src/main.rs
@@ -726,6 +726,7 @@ fn main() {
             cx,
         );
         agent_tasks_panel::init(cx);
+        epic_panel::init(cx);
         zed::watch_user_agents_md(app_state.fs.clone(), cx);
 
         repl::init(app_state.fs.clone(), cx);
diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs
index 6c45d54..5d90d59 100644
--- a/crates/zed/src/zed.rs
+++ b/crates/zed/src/zed.rs
@@ -785,6 +785,7 @@ fn initialize_panels(window: &mut Window, cx: &mut Context<Workspace>) -> Task<a
             collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
         let agent_tasks_panel =
             agent_tasks_panel::AgentTasksPanel::load(workspace_handle.clone(), cx.clone());
+        let epic_panel = epic_panel::EpicPanel::load(workspace_handle.clone(), cx.clone());
         let debug_panel = DebugPanel::load(workspace_handle.clone(), cx);
 
         async fn add_panel_when_ready(
@@ -810,6 +811,7 @@ fn initialize_panels(window: &mut Window, cx: &mut Context<Workspace>) -> Task<a
             add_panel_when_ready(channels_panel, workspace_handle.clone(), cx.clone()),
             add_panel_when_ready(debug_panel, workspace_handle.clone(), cx.clone()),
             add_panel_when_ready(agent_tasks_panel, workspace_handle.clone(), cx.clone()),
+            add_panel_when_ready(epic_panel, workspace_handle.clone(), cx.clone()),
             initialize_agent_panel(workspace_handle.clone(), cx.clone()).map(|r| r.log_err()),
         );
 
@@ -6366,6 +6368,7 @@ mod tests {
                 "edit_prediction",
                 "editor",
                 "encoding_selector",
+                "epic_panel",
                 "feedback",
                 "file_finder",
                 "git",
