diff --git a/.github/workflows/cowork-auto-pr.yml b/.github/workflows/cowork-auto-pr.yml
new file mode 100644
index 0000000..91690e6
--- /dev/null
+++ b/.github/workflows/cowork-auto-pr.yml
@@ -0,0 +1,28 @@
+# Seeded by the repo-improver-rotation Cowork job into cowork/improve-* branches.
+# Opens a PR automatically when such a branch is pushed (sandbox cannot reach
+# the GitHub API directly; this runs server-side with the repo's GITHUB_TOKEN).
+name: cowork-auto-pr
+on:
+ push:
+ branches: ['cowork/improve-**']
+permissions:
+ contents: read
+ pull-requests: write
+jobs:
+ ensure-pr:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Open PR for this branch if none exists
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -eu
+ existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REF_NAME" --state open --json number --jq 'length')
+ if [ "$existing" = "0" ]; then
+ gh pr create --repo "$GITHUB_REPOSITORY" \
+ --head "$GITHUB_REF_NAME" \
+ --title "cowork-bot: automated improvements ($GITHUB_REF_NAME)" \
+ --body "Automated improvement PR from the Cowork repo-improver rotation (one coherent senior-dev improvement per run; see individual commit messages). Subsequent runs push additional commits to this PR rather than opening new ones."
+ else
+ echo "Open PR already exists for $GITHUB_REF_NAME — nothing to do."
+ fi
diff --git a/.hermes/linkcheck.py b/.hermes/linkcheck.py
index 6c1ac0f..5525ea8 100644
--- a/.hermes/linkcheck.py
+++ b/.hermes/linkcheck.py
@@ -37,17 +37,25 @@ def build_actual_pages(all_files: set[str]) -> set[str]:
def resolve_link(link: str, source_dir: str) -> str:
"""Resolve a relative href to an absolute site-relative path."""
- if link.startswith("../"):
- parts = source_dir.split("/") if source_dir else []
- up_count = link.count("../")
- resolved_parts = parts[:-up_count] if len(parts) >= up_count else []
- remaining = link[3 * up_count :]
- resolved = "/".join(resolved_parts + [remaining]) if resolved_parts else remaining
- elif link.startswith("./"):
- resolved = (source_dir + "/" + link[2:]) if source_dir else link[2:]
+ if link.startswith("http://") or link.startswith("https://") or link.startswith("#"):
+ return link
+ if link.startswith("/"):
+ return link.lstrip("/")
+
+ if source_dir:
+ path = source_dir + "/" + link
else:
- resolved = (source_dir + "/" + link) if source_dir else link
- return resolved.replace("//", "/")
+ path = link
+
+ normalized = os.path.normpath(path).replace(os.sep, "/")
+
+ # Cap at site root: strip any leading ../ that goes above root
+ while normalized.startswith("../"):
+ normalized = normalized[3:]
+ if normalized.startswith("/"):
+ normalized = normalized[1:]
+
+ return normalized
def check_links(root_dir: str, verbose: bool = False) -> int:
diff --git a/README.md b/README.md
index de41a78..33390fe 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
[](https://coding-dev-tools.github.io/devforge/)
[](https://github.com/Coding-Dev-Tools/devforge/stargazers)
-Landing page, documentation, pricing, alternatives, and blog for the **DevForge CLI tool suite** — 11 developer CLI tools for API contracts, SQL, infrastructure, config drift, and more. **60 articles and growing.**
+Landing page, documentation, pricing, alternatives, and blog for the **DevForge CLI tool suite** — 11 developer CLI tools for API contracts, SQL, infrastructure, config drift, and more. **70 articles and growing.**
**Live site:** [coding-dev-tools.github.io/devforge/](https://coding-dev-tools.github.io/devforge/)
@@ -14,7 +14,7 @@ Landing page, documentation, pricing, alternatives, and blog for the **DevForge
| [Home](https://coding-dev-tools.github.io/devforge/) | Hero, stats, feature cards, CTA |
| [Pricing](https://coding-dev-tools.github.io/devforge/pricing.html) | Free / Pro / Team / Enterprise tiers |
| [Alternatives](https://coding-dev-tools.github.io/devforge/alternatives.html) | 11 comparison tables vs competitors |
-| [Blog](https://coding-dev-tools.github.io/devforge/blog.html) | 60+ articles and tutorials |
+| [Blog](https://coding-dev-tools.github.io/devforge/blog.html) | 70+ articles and tutorials |
| [Docs](https://coding-dev-tools.github.io/devforge/docs.html) | Tool documentation hub |
| [Quickstart](https://coding-dev-tools.github.io/devforge/quickstart.html) | Get started in 60 seconds |
| [FAQ](https://coding-dev-tools.github.io/devforge/#faq) | Common questions |
diff --git a/sitemap.xml b/sitemap.xml
index c0f8a8e..8bee297 100644
--- a/sitemap.xml
+++ b/sitemap.xml
@@ -33,9 +33,6 @@
https://coding-dev-tools.github.io/devforge/quickstart.html
2026-05-17
-
- https://coding-dev-tools.github.io/revenueholdings.dev/blog/openapi-diff-tools-comparison.html
- 2026-05-15
monthly
0.9
@@ -447,28 +444,58 @@
monthly
0.6
-
- https://coding-dev-tools.github.io/devforge/blog/traction-hits-680-developers.html
- 2026-05-17
- monthly
- 0.6
-
-
- https://coding-dev-tools.github.io/revenueholdings.dev/alternatives.html
- 2026-05-15
+
+ https://coding-dev-tools.github.io/devforge/blog/click-to-mcp-three-distribution-channels.html
+ 2026-06-13
monthly
- 0.9
+ 0.6
+
+
+ https://coding-dev-tools.github.io/devforge/blog/click-to-mcp-three-transport-modes.html
+ 2026-05-28
+ monthly
+ 0.6
+
+ https://coding-dev-tools.github.io/devforge/blog/configdrift-scan-multi-environment-configs-one-command.html
+ 2026-05-28
+ monthly
+ 0.6
+
+
+ https://coding-dev-tools.github.io/devforge/blog/deploydiff-cost-governance-before-you-deploy.html
+ 2026-05-28
+ monthly
+ 0.6
+
+
+ https://coding-dev-tools.github.io/devforge/blog/saas-churn-predictor-launch.html
+ 2026-05-28
+ monthly
+ 0.6
+
+
+ https://coding-dev-tools.github.io/devforge/blog/apicontractguardian-git-branch-openapi-diff-pr-review.html
+ 2026-08-26
+ weekly
+ 0.6
+
- https://coding-dev-tools.github.io/revenueholdings.dev/quickstart.html
- 2026-05-15
- monthly
- 0.9
+ https://coding-dev-tools.github.io/devforge/blog/deadcode-remove-dead-code-nextjs-app-router.html
+ 2026-08-26
+ weekly
+ 0.6
+
+
+ https://coding-dev-tools.github.io/devforge/blog/json2sql-cicd-automated-database-seeding.html
+ 2026-08-26
+ weekly
+ 0.6
- https://coding-dev-tools.github.io/revenueholdings.dev/blog/clean-up-react-dead-code.html
- 2026-05-15
- monthly
+ https://coding-dev-tools.github.io/devforge/blog/schemaforge-convert-prisma-to-drizzle-migration-guide.html
+ 2026-08-26
+ weekly
0.6
diff --git a/tests/test_linkcheck.py b/tests/test_linkcheck.py
index 739c826..932e782 100644
--- a/tests/test_linkcheck.py
+++ b/tests/test_linkcheck.py
@@ -167,6 +167,24 @@ def test_resolve_link_double_slash():
assert result == "blog/page.html"
+def test_resolve_link_absolute_path():
+ """Absolute path /about.html strips leading slash."""
+ result = resolve_link("/about.html", "blog")
+ assert result == "about.html"
+
+
+def test_resolve_link_up_with_intermediate_segments():
+ """../foo/../bar.html correctly cancels the intermediate segment."""
+ result = resolve_link("../foo/../bar.html", "blog/2024")
+ assert result == "blog/bar.html"
+
+
+def test_resolve_link_up_capped_at_root():
+ """../../ from a shallow dir is capped at site root."""
+ result = resolve_link("../../page.html", "blog")
+ assert result == "page.html"
+
+
# ---- CLI argument parsing ----
diff --git a/tests/test_linkcheck_additional.py b/tests/test_linkcheck_additional.py
index c26ac5f..802c09b 100644
--- a/tests/test_linkcheck_additional.py
+++ b/tests/test_linkcheck_additional.py
@@ -11,7 +11,7 @@
_SCRIPT_DIR = Path(__file__).resolve().parent.parent / ".hermes"
sys.path.insert(0, str(_SCRIPT_DIR))
-from linkcheck import build_actual_pages
+from linkcheck import build_actual_pages # noqa: E402
class TestBuildActualPages:
diff --git a/tests/test_linkcheck_edge_cases.py b/tests/test_linkcheck_edge_cases.py
index c2606de..055e8e2 100644
--- a/tests/test_linkcheck_edge_cases.py
+++ b/tests/test_linkcheck_edge_cases.py
@@ -16,7 +16,7 @@
_SCRIPT_DIR = Path(__file__).resolve().parent.parent / ".hermes"
sys.path.insert(0, str(_SCRIPT_DIR))
-from linkcheck import check_links, main
+from linkcheck import check_links, main # noqa: E402
class TestCheckLinksEdgeCases:
diff --git a/tests/test_sitemap.py b/tests/test_sitemap.py
new file mode 100644
index 0000000..892321e
--- /dev/null
+++ b/tests/test_sitemap.py
@@ -0,0 +1,51 @@
+"""Regression tests: sitemap.xml must exactly cover the site's real pages.
+
+Every non-404 HTML file in the repo must have a entry, and every
+ must resolve to a real page (index.html is represented as "/").
+"""
+
+import os
+import re
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+SITEMAP = REPO / "sitemap.xml"
+BASE = "https://coding-dev-tools.github.io/devforge/"
+NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
+
+
+def _actual_pages():
+ pages = set()
+ for root, dirs, files in os.walk(REPO):
+ rel_root = Path(root).relative_to(REPO)
+ dirs[:] = [d for d in dirs if d not in (".git", "drafts", ".hermes", "tests")]
+ for fn in files:
+ if fn.endswith(".html") and fn != "404.html":
+ p = str(rel_root / fn).replace(os.sep, "/")
+ pages.add(BASE if p == "index.html" else BASE + p)
+ return pages
+
+
+def _sitemap_urls():
+ tree = ET.parse(SITEMAP)
+ return {e.text for e in tree.getroot().iter(NS + "loc")}
+
+
+def test_sitemap_parses():
+ ET.parse(SITEMAP)
+
+
+def test_sitemap_covers_every_page():
+ missing = _actual_pages() - _sitemap_urls()
+ assert not missing, f"pages missing from sitemap.xml: {sorted(missing)}"
+
+
+def test_sitemap_has_no_stale_urls():
+ stale = _sitemap_urls() - _actual_pages()
+ assert not stale, f"stale sitemap.xml URLs with no page: {sorted(stale)}"
+
+
+def test_no_duplicate_locs():
+ urls = re.findall(r"(.*?)", SITEMAP.read_text(encoding="utf-8"))
+ assert len(urls) == len(set(urls))