Files
Nicholas Tindleandunknown f3e05a73e1 docs(platform): move engineering notes out of the published docs folder (#15163)
### Why / What / How

**Why:** `docs/platform/` is published at agpt.co/docs, and a page goes
live once `SUMMARY.md` lists it. Engineering notes were kept in the same
folder, so one line in `SUMMARY.md` was all that separated them from the
public site. Two of them, the activation metrics contract and the
PostHog tracking plan, had already been added to the navigation under
"Using the Platform". They are not live only because the site is built
from the `gitbook` branch, which is behind `dev`. Agents also had no
stated place to put a note that isn't a page for the site, so
`docs/platform/` was the default.

**What:** Engineering notes get their own folder, `docs/engineering/`,
which no GitBook space reads. The eleven files in `docs/platform/` that
are not pages for the site move there, the two analytics pages leave the
navigation, and the agent guides say where a document goes. A test
requires every file in `docs/platform/` to be listed in its
`SUMMARY.md`.

**How:** The files are moved with `git mv`, so history follows them.
References to the old paths are updated: five code comments, the backend
agent guide, and one link on a published page. The test lives in
`.github/workflows/scripts/`, where the PR status checker already runs
every `test_*.py` on each pull request and merge group, so it needs no
new workflow.

### Changes 🏗️

- **Moved to `docs/engineering/`** (content unchanged apart from two
relative links in `hosted-mcp-catalog.md`):
- `activation-metrics.md`, `tracking-plan.md` (both were in
`SUMMARY.md`)
- `feature-flags.md`, `org-feature-map.md`,
`workspace-media-architecture.md`, `codex-subscription-preview.md`
  - `releases/clay-rock-v1.md`, `releases/clay-rock-v2.1.md`
- `contributing/hosted-mcp-catalog.md`,
`contributing/oauth-integration-flow.md`, `contributing/tests.md` (now
at the top of `docs/engineering/`)
- **`docs/platform/SUMMARY.md`**: removed "Activation Metrics &
Experiments" and "PostHog Tracking Plan".
- **`docs/platform/integrations-and-credentials.md`**: the "Maintaining
MCP Integrations" link now uses the note's GitHub URL on `dev`, because
the relative path does not exist on the docs site. It resolves as soon
as this merges.
- **Path updates**: `posthog_client.py`, `posthog_events.py`,
`product_analytics.py`, `posthog-base-properties.ts`,
`posthog-events.ts` (comments only) and
`autogpt_platform/backend/AGENTS.md`.
- **`docs/engineering/README.md`** (new): what the folder is for, that
it is not on the docs site, and that it is still public.
- **`AGENTS.md`, `docs/AGENTS.md`**: a short "where a document goes"
rule. Pages for people outside the team (using, self-hosting or
contributing) go in the published folders; the team's working documents
go in `docs/engineering/`; when unsure, `docs/engineering/`.
- **`.github/workflows/scripts/test_docs_layout.py`** (new): fails when
a Markdown file under `docs/platform/` is not listed in
`docs/platform/SUMMARY.md`, and when any published folder's `SUMMARY.md`
lists a page outside its own folder or one that does not exist. The
failure message says to move the file to `docs/engineering/` unless it
is a page meant for the site.

**What this does not do:**

- The test checks that a file is listed, not who it is written for. A
note that is added to `SUMMARY.md` still passes, so that remains a
review call.
- AutoPilot's documentation search indexes every Markdown file under
`docs/`, so it still returns these notes, as it did when they were in
`docs/platform/`. For two of them (`tests.md`,
`oauth-integration-flow.md`) the link it cites used to redirect to a
live page and now returns 404; the other nine already did. Keeping
unpublished files out of that index is a backend change for a follow-up.
- `master` still lists the two analytics pages until this change is
released. Nothing publishes from `master` today, but a docs sync from
`master` before that release would put them on the site.

No published page changes on the live site from this PR: the site is
built from the `gitbook` branch.

### Agents and large language models used

Claude Code (model: unknown)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `python -m unittest discover -s .github/workflows/scripts -p
"test_*.py"` passes (21 tests, 8 new)
- [x] The new test fails when an unlisted file is added to
`docs/platform/`
- [x] The new test fails when `docs/platform/SUMMARY.md` lists a file in
`docs/engineering/`
- [x] Every relative link in the moved files resolves, the same as
before the move
  - [x] No file in the repository still references an old path
- [x] `docs/platform/` has no Markdown file left that `SUMMARY.md` does
not list

<details>
  <summary>Example test plan</summary>
  
  - [ ] Create from scratch and execute an agent with at least 3 blocks
- [ ] Import an agent from file upload, and confirm it executes
correctly
  - [ ] Upload agent to marketplace
- [ ] Import an agent from marketplace and confirm it executes correctly
  - [ ] Edit an agent from monitor, and confirm it executes correctly
</details>

#### For configuration changes:

- [ ] `.env.default` is updated or already compatible with my changes
- [ ] `docker-compose.yml` is updated or already compatible with my
changes
- [ ] I have included a list of my configuration changes in the PR
description (under **Changes**)

<details>
  <summary>Examples of configuration changes</summary>

  - Changing ports
  - Adding new services that need to communicate with each other
  - Secrets or environment variable changes
  - New or infrastructure changes such as databases
</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Documentation layout, link, and comment updates plus a CI docs guard;
no application runtime, auth, or data-path changes.
> 
> **Overview**
> Engineering and internal docs are split from the GitBook-published
tree by introducing **`docs/engineering/`** and moving team-only notes
(tracking plan, activation metrics, runbooks, contributing internals,
etc.) out of **`docs/platform/`**. **`docs/platform/SUMMARY.md`** drops
the two analytics pages that were one nav line away from going public,
and agent guides (**`AGENTS.md`**, **`docs/AGENTS.md`**) now spell out
published vs engineering placement.
> 
> References are retargeted: PostHog-related code comments and
**`autogpt_platform/backend/AGENTS.md`** point at
**`docs/engineering/`**, and the published integrations page links to
the MCP catalog note via a **GitHub `dev` URL** because that path is not
on agpt.co/docs.
> 
> **`.github/workflows/scripts/test_docs_layout.py`** enforces that
every `.md` under **`docs/platform/`** appears in
**`docs/platform/SUMMARY.md`** and that published **`SUMMARY.md`** files
only list real pages in their own folder—hooked into existing PR
**`test_*.py`** discovery in **`.github/workflows/scripts/`**.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fb8e992a6a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: unknown (Claude Code) <[email protected]>
2026-10-03 14:15:19 +00:00

155 lines
5.7 KiB
Python

"""docs/platform is part of the public docs site, so it holds only published pages.
GitBook publishes a page once its folder's SUMMARY.md lists it. A file that sits
in a published folder without being listed is one line away from going public,
which is how internal notes end up in the site navigation. Notes that are not
for the site live in docs/engineering instead.
"""
import posixpath
import re
import tempfile
import unittest
from pathlib import Path
from urllib.parse import unquote
DOCS = Path(__file__).resolve().parents[3] / "docs"
PUBLISHED = ("home", "platform", "integrations")
# A Markdown link target that is a .md file, written bare or as
# <a path with spaces>. The target has to end at ".md": "page.md.old" is not one.
SUMMARY_LINK = re.compile(r"\]\((?:<([^>#]+\.md)(?:#[^>]*)?>|([^)#\s]+\.md)(?=[)#\s]))")
HAS_SCHEME = re.compile(r"^[a-z][a-z0-9+.-]*:", re.IGNORECASE)
HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
def listed_pages(folder: Path) -> set:
"""Paths of the local pages a folder's SUMMARY.md lists, relative to it."""
summary = (folder / "SUMMARY.md").read_text(encoding="utf-8")
# A commented-out entry is not in the navigation, so it lists nothing.
summary = HTML_COMMENT.sub("", summary)
links = (bracketed or bare for bracketed, bare in SUMMARY_LINK.findall(summary))
return {
posixpath.normpath(unquote(link))
for link in links
if not HAS_SCHEME.match(link)
}
def unlisted_pages(folder: Path) -> list:
"""Markdown files in a folder that its SUMMARY.md does not list.
Files under .gitbook are GitBook's own (reusable content it includes into
pages), not pages, and are never listed.
"""
listed = listed_pages(folder)
return sorted(
page.relative_to(folder).as_posix()
for page in folder.rglob("*.md")
if page != folder / "SUMMARY.md"
and ".gitbook" not in page.relative_to(folder).parts
and page.relative_to(folder).as_posix() not in listed
)
class DocsLayoutTests(unittest.TestCase):
def test_every_platform_page_is_in_the_summary(self):
self.assertEqual(
unlisted_pages(DOCS / "platform"),
[],
"These files are in docs/platform but not in docs/platform/SUMMARY.md. "
"docs/platform is the public docs site, so move them to "
"docs/engineering. Only list a file in SUMMARY.md, which publishes "
"it, if it is a page meant for the site. See docs/AGENTS.md.",
)
def test_summaries_list_only_pages_in_their_own_folder(self):
for name in PUBLISHED:
folder = DOCS / name
for link in sorted(listed_pages(folder)):
page = (folder / link).resolve()
with self.subTest(summary=f"docs/{name}/SUMMARY.md", link=link):
self.assertTrue(
folder.resolve() in page.parents,
"A SUMMARY.md publishes what it lists, so it may only "
f"list pages inside docs/{name}.",
)
self.assertTrue(page.is_file(), "The listed page does not exist.")
class SummaryParsingTests(unittest.TestCase):
def folder(self, summary: str, *files: str) -> Path:
"""A temporary folder with the given SUMMARY.md and empty files."""
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
folder = Path(directory.name)
(folder / "SUMMARY.md").write_text(summary, encoding="utf-8")
for name in files:
(folder / name).parent.mkdir(parents=True, exist_ok=True)
(folder / name).write_text("", encoding="utf-8")
return folder
def test_reads_bare_bracketed_and_encoded_links(self):
folder = self.folder(
"* [A](a.md)\n"
" * [B](./sub/b.md#section)\n"
"* [C](<with space.md>)\n"
"* [D](with%20percent.md)\n"
'* [E](titled.md "A title")\n'
"* [F](<bracketed.md#section>)\n"
)
self.assertEqual(
listed_pages(folder),
{
"a.md",
"sub/b.md",
"with space.md",
"with percent.md",
"titled.md",
"bracketed.md",
},
)
def test_ignores_links_to_other_sites(self):
folder = self.folder("* [Elsewhere](https://example.com/page.md)\n")
self.assertEqual(listed_pages(folder), set())
def test_target_must_end_at_the_md_extension(self):
"""Listing page.md.old must not count as listing page.md."""
folder = self.folder(
"* [Old](page.md.old)\n* [Old](<page.md.old>)\n", "page.md"
)
self.assertEqual(listed_pages(folder), set())
self.assertEqual(unlisted_pages(folder), ["page.md"])
def test_commented_out_entry_lists_nothing(self):
folder = self.folder(
"* [A](a.md)\n<!-- * [B](b.md) -->\n<!--\n* [C](c.md)\n-->\n",
"a.md",
"b.md",
"c.md",
)
self.assertEqual(listed_pages(folder), {"a.md"})
self.assertEqual(unlisted_pages(folder), ["b.md", "c.md"])
def test_gitbook_reusable_content_is_not_a_page(self):
folder = self.folder(
"* [A](a.md)\n", "a.md", ".gitbook/includes/snippet.md", "sub/b.md"
)
self.assertEqual(unlisted_pages(folder), ["sub/b.md"])
def test_only_the_folders_own_summary_is_exempt(self):
folder = self.folder(
"* [A](a.md)\n", "a.md", "b.md", "sub/SUMMARY.md", "sub/c.md"
)
self.assertEqual(unlisted_pages(folder), ["b.md", "sub/SUMMARY.md", "sub/c.md"])
if __name__ == "__main__":
unittest.main()