diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 540d7c17..81477629 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -63,6 +63,9 @@ jobs:
mdbook-admonish install .
mdbook build
+ - name: Check generated HTML links
+ run: tests/release/mdbook-links.test.sh
+
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 6518314d..278904a4 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -10,6 +10,7 @@
- [MCP Server](mcp.md)
- [Registry and Directory Listings](mcp-registry.md)
- [CLI Reference](cli.md)
+- [User Stories](user-stories.md)
- [Configure Your LLM](configuration.md)
- [Distro Support](distro-support.md)
@@ -43,7 +44,9 @@
- [Developer Guide](developer-guide.md)
- [Testing Guide](contributing/testing.md)
+- [Ubuntu VM Testing](contributing/ubuntu-vm-testing.md)
- [Contributing](contributing/CONTRIBUTING.md)
- [Ubuntu Story Audit](contributing/ubuntu-story-audit.md)
- [Release Process](release.md)
- [Release Readiness](release-readiness.md)
+- [Testing User Stories](testing/user-stories.md)
diff --git a/docs/cli.md b/docs/cli.md
index e07ccc83..13a83d66 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -5,7 +5,7 @@ natural-language intent into a risk-labelled plan, asks for approval where
needed, and streams execution output in real time.
If you want SysKnife inside Claude Code / Cursor / Codex CLI instead, see
-the [main README](../README.md) and run `npx sysknife-setup`. Both paths
+the [main README](https://github.com/lacs-project/sysknife/blob/main/README.md) and run `npx sysknife-setup`. Both paths
share the daemon, the audit chain, and the typed-action set.
startup and `sysknife audit verify` repeats it beside every verdict, because
> `OK: N rows verified` would otherwise read as "nothing was removed". Setup
> instructions are in
-> [SECURITY.md](../SECURITY.md#audit-anchoring-in-the-default-deployment).
+> [SECURITY.md](https://github.com/lacs-project/sysknife/blob/main/SECURITY.md#audit-anchoring-in-the-default-deployment).
A hash chain alone cannot detect one specific attack: **tail truncation**.
If an attacker with write access to the audit database deletes the most
diff --git a/scripts/check-mdbook-links.sh b/scripts/check-mdbook-links.sh
new file mode 100755
index 00000000..40779d87
--- /dev/null
+++ b/scripts/check-mdbook-links.sh
@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+book_dir="${1:-book}"
+
+python3 - "$book_dir" <<'PY'
+import pathlib
+import re
+import sys
+import urllib.parse
+
+book = pathlib.Path(sys.argv[1])
+if not book.is_dir():
+ raise SystemExit(f"mdbook-links: book directory does not exist: {book}")
+
+href_pattern = re.compile(r'href="([^"]+)"')
+checked = 0
+broken = []
+
+for page in sorted(book.rglob("*.html")):
+ if page.name == "print.html":
+ continue
+ for href in href_pattern.findall(page.read_text(errors="replace")):
+ if href.startswith(("http://", "https://", "#", "mailto:", "//")):
+ continue
+ target = urllib.parse.unquote(href.split("#", 1)[0].split("?", 1)[0])
+ if not target.endswith(".html"):
+ continue
+ checked += 1
+ if not (page.parent / target).resolve().exists():
+ broken.append(f"{page}\t{href}")
+
+if checked == 0:
+ raise SystemExit("mdbook-links: no internal .html links were checked")
+if broken:
+ print("mdbook-links: broken generated links:", file=sys.stderr)
+ print("\n".join(broken), file=sys.stderr)
+ raise SystemExit(1)
+
+print(f"mdbook-links: checked {checked} internal .html links")
+PY
\ No newline at end of file
diff --git a/tests/release/mdbook-links.test.sh b/tests/release/mdbook-links.test.sh
new file mode 100755
index 00000000..0a8d52f7
--- /dev/null
+++ b/tests/release/mdbook-links.test.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+checker="$repo_root/scripts/check-mdbook-links.sh"
+fixture="$(mktemp -d)"
+trap 'rm -rf "$fixture"' EXIT
+
+mkdir -p "$fixture/book"
+cat > "$fixture/book/index.html" <<'EOF'
+guide
+EOF
+cat > "$fixture/book/guide.html" <<'EOF'
+external
+EOF
+
+"$checker" "$fixture/book"
+
+rm "$fixture/book/guide.html"
+if output="$($checker "$fixture/book" 2>&1)"; then
+ printf 'mdbook-links: missing generated page unexpectedly passed\n' >&2
+ exit 1
+fi
+grep -Fq 'index.html' <<< "$output" || {
+ printf 'mdbook-links: missing-page error omitted its source: %s\n' "$output" >&2
+ exit 1
+}
+
+cat > "$fixture/book/index.html" <<'EOF'
+external only
+EOF
+if output="$($checker "$fixture/book" 2>&1)"; then
+ printf 'mdbook-links: zero-link fixture unexpectedly passed\n' >&2
+ exit 1
+fi
+grep -Fq 'no internal .html links were checked' <<< "$output" || {
+ printf 'mdbook-links: zero-link error was not explicit: %s\n' "$output" >&2
+ exit 1
+}
+
+if ! command -v mdbook >/dev/null 2>&1 || ! command -v mdbook-admonish >/dev/null 2>&1; then
+ printf 'mdbook-links: SKIP real mdBook build (mdbook and mdbook-admonish are required)\n'
+ exit 0
+fi
+
+build_dir="$fixture/real-book"
+mdbook-admonish install "$repo_root"
+mdbook build --dest-dir "$build_dir" "$repo_root"
+"$checker" "$build_dir"
\ No newline at end of file