diff --git a/.cursor/rules/octobot-cloud.mdc b/.cursor/rules/octobot-cloud.mdc
index 553fbd52c1..ed1c5f3e2d 100644
--- a/.cursor/rules/octobot-cloud.mdc
+++ b/.cursor/rules/octobot-cloud.mdc
@@ -9,7 +9,8 @@ alwaysApply: true
- Before Python, `OctoBot`, or pytest: `source .cursor/env.sh`
- **Pytest failure:** follow [When tests fail](.cursor/skills/octobot-cloud/reference-pytest.md#when-tests-fail) in `reference-pytest.md`, starting with [step 1 — visible logs](.cursor/skills/octobot-cloud/reference-pytest.md#1-re-run-with-visible-logs); do not patch symptoms (mocks, seeds, timeouts) until the failure boundary is identified.
- **Tentacles:** edit only `packages/tentacles/`; after changes run `bash .cursor/reinstall-tentacles.sh`. Never commit repo-root `tentacles/`.
-- **Git:** checkout `dev` (or user base) → feature branch → commit → open PR to **`dev`** unless the user specifies another target.
+- **Git:** checkout `dev` (or user base) → feature branch → commit → open PR to **`dev`** unless the user specifies another target. Do not commit agent plan files (`PLAN-*.md`, `*.plan.md`, `.cursor/plans/`); use Plan mode or chat only — delete scratch plans before staging (`path.deny_agent_plans`). Do **not** edit agent docs (`**/AGENTS.md`, `.cursor/skills/**`, `.cursor/rules/**`, `.cursor/README.md`, `CONTRIBUTING-agent.md`, `tools/**/README.md`, `tools/**/ARCHITECTURE.md`) unless the task owns them; if diffs are out of scope, `git checkout origin/ -- ` before commit (`agent_docs.no_regression_vs_merge_base`).
+- **Node UI tests:** Vitest (`npm test`) + Python/API tests; do not add `node_web_interface/e2e/` or Playwright specs (`path.deny_node_web_playwright_e2e`). Optional live UI QA via skill **agent-seed**, not committed e2e.
- Before handoff: `python -m tools.extended_linter --base origin/dev` (use `origin/` matching the PR target).
- No `pip install` / `npm install` to fix imports. No edits under `user/` or secret/env files.
- Cross-package or tentacles work: read root `AGENTS.md` and colocated `AGENTS.md` for every area you touch (`octobot/`, `packages//`, `packages/tentacles/`).
diff --git a/.cursor/skills/cloud-roadmap/SKILL.md b/.cursor/skills/cloud-roadmap/SKILL.md
index 62f77893fe..619b5a155c 100644
--- a/.cursor/skills/cloud-roadmap/SKILL.md
+++ b/.cursor/skills/cloud-roadmap/SKILL.md
@@ -25,7 +25,7 @@ Also when the user invokes **cloud-roadmap** or asks for a **cloud-safe multi-st
2. Skim root `AGENTS.md` and every colocated `AGENTS.md` for packages in scope.
3. If not in Plan mode but the user wants a roadmap-style plan, use the same rules in the reply or switch to Plan + `CreatePlan`.
-**Output:** submit via **CreatePlan** only. Do **not** write `.cursor/roadmaps/*.md` unless the user asks to persist a file.
+**Output:** submit via **CreatePlan** only. Do **not** write `.cursor/roadmaps/*.md` unless the user asks to persist a file. Never commit plan artifacts in the repo (`PLAN-*.md`, `*.plan.md`, `.cursor/plans/`) — `path.deny_agent_plans`.
## Clarify first; never guess
@@ -97,7 +97,7 @@ Pytest matrix: [reference-pytest.md](../octobot-cloud/reference-pytest.md) (skil
| Unit (Python) | Test path + enumerated behaviors | Package `tests/` or `tools/tests/…` per `AGENTS.md` **Tests** |
| Unit (Node UI) | TS under `packages/tentacles/Services/Interfaces/node_web_interface/` | `src/**/__tests__`; cwd that directory; `npm test` (vitest). Profile **`ui-node-web`** if build env needed. |
| Functional / integration | Cross-package or I/O flows | `tools/tests`, integration dirs, tentacles-dependent pytest |
-| UI functional | Browser / seeded grid | **agent-seed** after vitest on logic |
+| UI functional | Browser / seeded grid | Default **N/A — Vitest + API tests**; **agent-seed** only when the user explicitly wants manual `/app` QA — **not** new Playwright `e2e/` files in git (`path.deny_node_web_playwright_e2e`) |
**Milestone catalog**
diff --git a/.cursor/skills/octobot-cloud/SKILL.md b/.cursor/skills/octobot-cloud/SKILL.md
index 4410467a38..9f52fb72aa 100644
--- a/.cursor/skills/octobot-cloud/SKILL.md
+++ b/.cursor/skills/octobot-cloud/SKILL.md
@@ -15,8 +15,10 @@ Multi-step **roadmap** plans (title or description contains `roadmap`): author w
1. `source .cursor/env.sh` before Python tooling.
2. Checkout **`dev`** (or user base) → create feature branch → implement → commit → PR to **`dev`** (match `--base` on extended_linter to PR target).
3. After `packages/tentacles/` edits: `bash .cursor/reinstall-tentacles.sh`.
-4. Before handoff: `python -m tools.extended_linter --base origin/dev` (or `origin/`).
-5. Run targeted pytest per [reference-pytest.md](reference-pytest.md) (CI matrix). **If any test fails,** start with [step 1 — visible logs](reference-pytest.md#1-re-run-with-visible-logs), then follow [When tests fail](reference-pytest.md#when-tests-fail) **before** changing production code or the harness.
+4. Before commit: remove any scratch plan files (`PLAN-*.md`, `*.plan.md`, `.cursor/plans/`) from the tree; do not stage them (`path.deny_agent_plans`). Do not change agent docs (see `octobot-cloud.mdc` path list) unless the task owns them — otherwise restore from `origin/` (`agent_docs.no_regression_vs_merge_base`).
+5. After `git rebase` or fast-forward onto `origin/dev`, re-checkout unrelated agent-doc paths from `origin/dev` if the working tree still has stale copies. Squash commits must reflect the **current** branch tip, not an old cloud workspace snapshot.
+6. Before handoff: `python -m tools.extended_linter --base origin/dev` (or `origin/`).
+7. Run targeted pytest per [reference-pytest.md](reference-pytest.md) (CI matrix). **If any test fails,** start with [step 1 — visible logs](reference-pytest.md#1-re-run-with-visible-logs), then follow [When tests fail](reference-pytest.md#when-tests-fail) **before** changing production code or the harness.
## Install and env
@@ -59,6 +61,11 @@ See **[reference-pytest.md](reference-pytest.md)** for cwd and PYTHONPATH per CI
3. Find the **failure boundary** (last good layer vs first bad layer); do not widen mocks, seeds, or timeouts until that boundary is clear.
4. Apply the full protocol in [When tests fail](reference-pytest.md#when-tests-fail) before editing production code or the harness.
+## Node UI tests (agents)
+
+- **In PRs:** Vitest in `packages/tentacles/Services/Interfaces/node_web_interface/` (`npm test`) plus Python/API tests (`node_api_interface`, `packages/node`, tentacles pytest as needed). **Do not** add Playwright e2e under `e2e/` or `test:e2e` scripts (`path.deny_node_web_playwright_e2e`).
+- CI runs Vitest for tentacles `package.json` projects; e2e is not in the matrix.
+
## Node UI manual QA (agent seed)
**Not required** for normal pytest / `ci-tentacles` package work — only when the task uses the live Node web UI.
diff --git a/.gitignore b/.gitignore
index fc7c350850..d1920dbe92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -197,3 +197,8 @@ installer/
# ai
analysis/
+
+# Agent session plans (never commit; see path.deny_agent_plans in extended_linter)
+**/PLAN-*.md
+**/*.plan.md
+.cursor/plans/
diff --git a/AGENTS.md b/AGENTS.md
index c5002cc9da..1f7690e166 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,7 +16,7 @@ source .cursor/env.sh
python -m tools.extended_linter --base origin/dev
```
-Use `origin/` matching your PR target. Policy: `tools/extended_linter/config/policy.yaml` — see `tools/extended_linter/ARCHITECTURE.md`.
+Use `origin/` matching your PR target. Policy: `tools/extended_linter/config/policy.yaml` — see `tools/extended_linter/ARCHITECTURE.md`. No agent plan files or Node UI Playwright e2e in the diff (`path.deny_agent_plans`, `path.deny_node_web_playwright_e2e`). No accidental agent-doc regressions (`agent_docs.no_regression_vs_merge_base`; see `octobot-cloud.mdc`).
## Architecture for agents
diff --git a/CONTRIBUTING-agent.md b/CONTRIBUTING-agent.md
index 056f4b538b..8fddb3e516 100644
--- a/CONTRIBUTING-agent.md
+++ b/CONTRIBUTING-agent.md
@@ -9,7 +9,9 @@ See [`.cursor/README.md`](.cursor/README.md). Install profile **`ci-tentacles`**
1. Branch from `dev` (or user-specified base).
2. For cross-package work, read root [`AGENTS.md`](AGENTS.md) and each colocated `AGENTS.md` for packages you touch.
3. Run `python -m tools.extended_linter --base origin/` before handoff.
-4. CI: **OctoBot-CI** job **`extended_linter`** (`pytest tools/tests` with wheel + tentacles; PR policy with `--skip-tentacles-reinstall`, no `cloud-install`). Package **`tests`** matrix unchanged.
+4. Do not commit agent session plans (`path.deny_agent_plans`: `PLAN-*.md`, `*.plan.md`, `.cursor/plans/`). Node UI: Vitest + Python/API tests only — no Playwright e2e under `node_web_interface/e2e/` (`path.deny_node_web_playwright_e2e`).
+5. **Agent docs:** edit `AGENTS.md`, `.cursor/skills/**`, `.cursor/rules/**`, `.cursor/README.md`, `CONTRIBUTING-agent.md`, and `tools/**/README.md` / `tools/**/ARCHITECTURE.md` only when the PR owns them. `extended_linter` blocks shrink/backdated **Last reviewed** (where present) vs merge-base (`agent_docs.no_regression_vs_merge_base`). Large trims need explicit reviewer intent.
+6. CI: **OctoBot-CI** job **`extended_linter`** (`pytest tools/tests` with wheel + tentacles; PR policy with `--skip-tentacles-reinstall`, no `cloud-install`). Package **`tests`** matrix unchanged.
## Adding a policy rule
@@ -20,9 +22,9 @@ See [tools/extended_linter/README.md#adding-a-rule](tools/extended_linter/README
3. Add tests under `tools/tests/extended_linter/layers/`.
4. Summarize in `octobot-cloud` skill only for human context; YAML is the contract.
-## AGENTS.md hygiene (guidance, not CI in v1)
+## AGENTS.md hygiene
-When you change package boundaries (owns, public API, test cwd), update the matching colocated `AGENTS.md` (`octobot/`, `packages//`, or `packages/tentacles/`) and bump **Last reviewed**. CI does not enforce this yet.
+When you change package boundaries (owns, public API, test cwd), update the matching colocated `AGENTS.md` (`octobot/`, `packages//`, or `packages/tentacles/`) and bump **Last reviewed**. `extended_linter` enforces no shrink/backdate on agent-doc paths vs merge-base (`agent_docs.no_regression_vs_merge_base`); it does not require updates when boundaries change.
### AGENTS.md sections (new packages)
diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/README.md b/packages/tentacles/Services/Interfaces/node_web_interface/README.md
index dcad1a6d95..5377fd2c52 100644
--- a/packages/tentacles/Services/Interfaces/node_web_interface/README.md
+++ b/packages/tentacles/Services/Interfaces/node_web_interface/README.md
@@ -14,9 +14,11 @@ npm ci # install from the committed package-lock.json
npm run generate-client # regenerate the typed API client from openapi.json
npm run dev # Vite dev server
npm run build # tsc + vite build -> dist/
-npm test
+npm test # Vitest — this is the CI test gate for this package
```
+Do not add Playwright e2e under `e2e/` in this repo (agent policy: Vitest + Python/API tests; live UI QA via [`tools/agent_seed/README.md`](../../../../../tools/agent_seed/README.md)).
+
`openapi.json` is generated by the `node_api_interface` tentacle (`python build_openapi.py`) and is
git-ignored. Generate it once before `npm run generate-client` if it is missing.
diff --git a/tools/extended_linter/ARCHITECTURE.md b/tools/extended_linter/ARCHITECTURE.md
index 58a8856f1c..dae328c9c4 100644
--- a/tools/extended_linter/ARCHITECTURE.md
+++ b/tools/extended_linter/ARCHITECTURE.md
@@ -9,6 +9,7 @@ flowchart TB
hook[hooks.tentacles]
git[layers.git_scope]
path[layers.path_policy]
+ agentDocs[layers.agent_docs_policy]
diff[layers.diff_policy]
report[reporting.format]
config[config.policy.yaml]
@@ -17,6 +18,7 @@ flowchart TB
engine --> git
engine --> hook
engine --> path
+ engine --> agentDocs
engine --> diff
cli --> report
```
@@ -25,8 +27,9 @@ flowchart TB
2. **Git layer** — verify `--base` ref; list changed paths + unified diff
3. **Tentacles hook** — if `packages/tentacles/**` in diff, run `reinstall-tentacles.sh` (not a policy violation). Requires cloud install / `env.sh`. **CI** passes `--skip-tentacles-reinstall`; agents run the hook by default.
4. **Path layer** — `layers.path_policy.run(changed_paths, policy)`
-5. **Diff layer** — `layers.diff_policy.run(diff_text, policy)`
-6. **Report** — `reporting.format` in CLI
+5. **Agent docs layer** — `layers.agent_docs_policy.run(...)` for policy `path_globs` (AGENTS.md, `.cursor/skills/**/SKILL.md`, `.cursor/rules/**`, tools README/ARCHITECTURE, etc.): shrink / Last reviewed vs merge-base; uses `git show` like `git_scope`
+6. **Diff layer** — `layers.diff_policy.run(diff_text, policy)`
+7. **Report** — `reporting.format` in CLI
## RunContext
@@ -38,7 +41,7 @@ flowchart TB
|--------|------------|
| `cli.py` | `config.loader`, `engine.runner`, `reporting.format` |
| `engine.runner` | `config`, `layers`, `hooks`, `domain` |
-| `layers/*` | `domain` only (git layer may use `subprocess`) |
+| `layers/*` | `domain` only (`git_scope` and `agent_docs_policy` may use `subprocess` for `git`; `agent_docs_policy` may import `path_policy` for globs) |
| `hooks/*` | `domain.paths`; `subprocess` for scripts |
| `reporting/*` | `domain.models` |
| `config.loader` | PyYAML only |
@@ -54,6 +57,7 @@ flowchart TB
| Change | Location |
|--------|----------|
| New path deny / glob | `config/policy.yaml` + optional `kind` in `layers/path_policy.py` |
+| Agent docs regression vs merge-base | `config/policy.yaml` `agent_docs_rules` + `layers/agent_docs_policy.py` |
| New diff regex | `config/policy.yaml` `patterns` or `kind` in `layers/diff_policy.py` |
| New pre-check side effect | `hooks/` + call from `engine/runner.py` only |
| CLI flags | `cli.py` |
diff --git a/tools/extended_linter/config/policy.yaml b/tools/extended_linter/config/policy.yaml
index 2aa1ffc922..bfd9de3784 100644
--- a/tools/extended_linter/config/policy.yaml
+++ b/tools/extended_linter/config/policy.yaml
@@ -39,6 +39,29 @@ path_rules:
- "**/secrets.json"
- "**/*service-account*.json"
hint: Use secrets manager / local only
+ - rule_id: path.deny_agent_plans
+ globs:
+ - "**/PLAN-*.md"
+ - "**/*.plan.md"
+ - ".cursor/plans/**"
+ hint: Agent/session plans are not product code; keep in Cursor Plan UI or chat; never commit
+ - rule_id: path.deny_node_web_playwright_e2e
+ globs:
+ - "packages/tentacles/Services/Interfaces/node_web_interface/e2e/**"
+ hint: Node UI — use Vitest (npm test) + Python/API tests; no Playwright e2e in repo; manual UI QA via agent-seed
+
+agent_docs_rules:
+ - rule_id: agent_docs.no_regression_vs_merge_base
+ shrink_line_threshold: 5
+ path_globs:
+ - "**/AGENTS.md"
+ - ".cursor/skills/**/SKILL.md"
+ - ".cursor/README.md"
+ - ".cursor/rules/**"
+ - "CONTRIBUTING-agent.md"
+ - "tools/**/README.md"
+ - "tools/**/ARCHITECTURE.md"
+ hint: Do not shrink or backdate agent docs unless this PR owns them; restore from origin/ or merge-base
diff_rules:
- rule_id: diff.no_pip_install
diff --git a/tools/extended_linter/engine/runner.py b/tools/extended_linter/engine/runner.py
index d3483ccce0..bce8c5da57 100644
--- a/tools/extended_linter/engine/runner.py
+++ b/tools/extended_linter/engine/runner.py
@@ -5,6 +5,7 @@
import tools.extended_linter.domain.models as domain_models
import tools.extended_linter.engine.context as engine_context
import tools.extended_linter.hooks.tentacles as hooks_tentacles
+import tools.extended_linter.layers.agent_docs_policy as layer_agent_docs
import tools.extended_linter.layers.diff_policy as layer_diff
import tools.extended_linter.layers.git_scope as layer_git
import tools.extended_linter.layers.path_policy as layer_path
@@ -39,6 +40,14 @@ def run(config: RunnerConfig) -> list[domain_models.Violation]:
):
hooks_tentacles.run_reinstall(context.repo_root)
context.violations.extend(layer_path.run(context.changed_paths, context.policy))
+ context.violations.extend(
+ layer_agent_docs.run(
+ context.repo_root,
+ context.merge_base_sha,
+ context.changed_paths,
+ context.policy,
+ )
+ )
context.diff_text = layer_git.unified_diff(context.repo_root, context.merge_base_sha)
context.violations.extend(layer_diff.run(context.diff_text, context.policy))
return context.violations
diff --git a/tools/extended_linter/layers/agent_docs_policy.py b/tools/extended_linter/layers/agent_docs_policy.py
new file mode 100644
index 0000000000..a62e905e9a
--- /dev/null
+++ b/tools/extended_linter/layers/agent_docs_policy.py
@@ -0,0 +1,154 @@
+import datetime
+import pathlib
+import re
+import subprocess
+import typing
+
+import tools.extended_linter.domain.models as domain_models
+import tools.extended_linter.domain.paths as domain_paths
+import tools.extended_linter.layers.path_policy as layer_path_policy
+
+_LAST_REVIEWED_SECTION = re.compile(
+ r"##\s+Last reviewed\s*\n(?:.*\n)*?-\s*(\d{4}-\d{2}-\d{2})",
+ re.IGNORECASE,
+)
+
+_DEFAULT_PATH_GLOBS = [
+ "**/AGENTS.md",
+ ".cursor/skills/**/SKILL.md",
+ ".cursor/README.md",
+ ".cursor/rules/**",
+ "CONTRIBUTING-agent.md",
+ "tools/**/README.md",
+ "tools/**/ARCHITECTURE.md",
+]
+
+
+def parse_last_reviewed_date(content: str) -> datetime.date | None:
+ match = _LAST_REVIEWED_SECTION.search(content)
+ if not match:
+ return None
+ try:
+ return datetime.date.fromisoformat(match.group(1))
+ except ValueError:
+ return None
+
+
+def should_flag_shrink(
+ base_line_count: int,
+ head_line_count: int,
+ shrink_line_threshold: int,
+) -> bool:
+ return head_line_count < base_line_count - shrink_line_threshold
+
+
+def matches_agent_doc_path(path: str, path_globs: list[str]) -> bool:
+ normalized = domain_paths.normalize_path(path)
+ for pattern in path_globs:
+ if layer_path_policy._matches_glob(normalized, pattern):
+ return True
+ return False
+
+
+def regression_reasons(
+ base_content: str,
+ head_content: str,
+ shrink_line_threshold: int,
+ doc_path: str = "agent doc",
+) -> list[str]:
+ reasons: list[str] = []
+ base_lines = base_content.splitlines()
+ head_lines = head_content.splitlines()
+ if should_flag_shrink(len(base_lines), len(head_lines), shrink_line_threshold):
+ reasons.append(
+ f"Agent doc `{doc_path}` shrank by {len(base_lines) - len(head_lines)} lines "
+ f"(threshold {shrink_line_threshold})"
+ )
+ base_reviewed = parse_last_reviewed_date(base_content)
+ head_reviewed = parse_last_reviewed_date(head_content)
+ if base_reviewed is not None and head_reviewed is not None:
+ if head_reviewed < base_reviewed:
+ reasons.append(
+ f"Last reviewed regressed ({head_reviewed.isoformat()} "
+ f"< {base_reviewed.isoformat()})"
+ )
+ return reasons
+
+
+def _git_show_blob(
+ repo_root: pathlib.Path,
+ object_ref: str,
+ path: str,
+) -> str | None:
+ result = subprocess.run(
+ ["git", "show", f"{object_ref}:{path}"],
+ cwd=repo_root,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if result.returncode != 0:
+ return None
+ return result.stdout
+
+
+def _rule_config(
+ policy: dict[str, typing.Any],
+) -> tuple[str, str, int, list[str]]:
+ rules = policy.get("agent_docs_rules") or []
+ if not rules:
+ return (
+ "agent_docs.no_regression_vs_merge_base",
+ "Do not shrink or backdate agent docs",
+ 5,
+ list(_DEFAULT_PATH_GLOBS),
+ )
+ rule = rules[0]
+ globs = rule.get("path_globs") or list(_DEFAULT_PATH_GLOBS)
+ return (
+ rule.get("rule_id", "agent_docs.no_regression_vs_merge_base"),
+ rule.get(
+ "hint",
+ "Do not shrink or backdate agent docs unless this PR owns them; "
+ "restore from origin/ or merge-base",
+ ),
+ int(rule.get("shrink_line_threshold", 5)),
+ list(globs),
+ )
+
+
+def run(
+ repo_root: pathlib.Path,
+ merge_base_sha: str,
+ changed_paths: list[str],
+ policy: dict[str, typing.Any],
+) -> list[domain_models.Violation]:
+ rule_id, hint, shrink_threshold, path_globs = _rule_config(policy)
+ violations: list[domain_models.Violation] = []
+ for path in changed_paths:
+ if not matches_agent_doc_path(path, path_globs):
+ continue
+ normalized = domain_paths.normalize_path(path)
+ base_content = _git_show_blob(repo_root, merge_base_sha, normalized)
+ if base_content is None:
+ continue
+ head_path = repo_root / normalized
+ if not head_path.is_file():
+ continue
+ head_content = head_path.read_text(encoding="utf-8")
+ for reason in regression_reasons(
+ base_content,
+ head_content,
+ shrink_threshold,
+ doc_path=normalized,
+ ):
+ violations.append(
+ domain_models.Violation(
+ rule_id=rule_id,
+ file=normalized,
+ line=None,
+ hint=hint,
+ detail=reason,
+ )
+ )
+ return violations
diff --git a/tools/tests/extended_linter/layers/test_agent_docs_policy.py b/tools/tests/extended_linter/layers/test_agent_docs_policy.py
new file mode 100644
index 0000000000..eb8c7138e2
--- /dev/null
+++ b/tools/tests/extended_linter/layers/test_agent_docs_policy.py
@@ -0,0 +1,104 @@
+import datetime
+import unittest
+
+import tools.extended_linter.config.loader as config_loader
+import tools.extended_linter.layers.agent_docs_policy as layer_agent_docs
+
+_BASE_AGENTS = """# Agents: example
+
+## Last reviewed
+
+- 2026-09-18
+"""
+
+_POLICY_GLOBS = [
+ "**/AGENTS.md",
+ ".cursor/skills/**/SKILL.md",
+ ".cursor/README.md",
+ ".cursor/rules/**",
+ "CONTRIBUTING-agent.md",
+ "tools/**/README.md",
+ "tools/**/ARCHITECTURE.md",
+]
+
+
+class TestParseLastReviewedDate(unittest.TestCase):
+ def test_parses_date_under_section(self) -> None:
+ self.assertEqual(
+ layer_agent_docs.parse_last_reviewed_date(_BASE_AGENTS),
+ datetime.date(2026, 9, 18),
+ )
+
+ def test_missing_section_returns_none(self) -> None:
+ self.assertIsNone(layer_agent_docs.parse_last_reviewed_date("# Agents\n"))
+
+
+class TestShouldFlagShrink(unittest.TestCase):
+ def test_large_shrink_flags(self) -> None:
+ self.assertTrue(layer_agent_docs.should_flag_shrink(30, 15, 5))
+
+ def test_small_shrink_passes(self) -> None:
+ self.assertFalse(layer_agent_docs.should_flag_shrink(30, 28, 5))
+
+
+class TestMatchesAgentDocPath(unittest.TestCase):
+ def test_skill_path_matches(self) -> None:
+ self.assertTrue(
+ layer_agent_docs.matches_agent_doc_path(
+ ".cursor/skills/octobot-cloud/SKILL.md",
+ _POLICY_GLOBS,
+ )
+ )
+
+ def test_package_readme_does_not_match(self) -> None:
+ self.assertFalse(
+ layer_agent_docs.matches_agent_doc_path(
+ "packages/foo/README.md",
+ _POLICY_GLOBS,
+ )
+ )
+
+ def test_mdc_rule_matches(self) -> None:
+ self.assertTrue(
+ layer_agent_docs.matches_agent_doc_path(
+ ".cursor/rules/octobot-cloud.mdc",
+ _POLICY_GLOBS,
+ )
+ )
+
+
+class TestRegressionReasons(unittest.TestCase):
+ def test_backdated_last_reviewed(self) -> None:
+ head = _BASE_AGENTS.replace("2026-09-18", "2026-09-16")
+ reasons = layer_agent_docs.regression_reasons(
+ _BASE_AGENTS,
+ head,
+ 5,
+ doc_path="packages/protocol/AGENTS.md",
+ )
+ self.assertTrue(any("Last reviewed regressed" in reason for reason in reasons))
+
+ def test_forward_last_reviewed_with_growth_passes(self) -> None:
+ head = _BASE_AGENTS + "\n## Extra\n\nMore boundary docs.\n"
+ reasons = layer_agent_docs.regression_reasons(_BASE_AGENTS, head, 5)
+ self.assertEqual(reasons, [])
+
+ def test_shrink_message_includes_path(self) -> None:
+ head = "line\n" * 5
+ base = "line\n" * 20
+ reasons = layer_agent_docs.regression_reasons(
+ base,
+ head,
+ 5,
+ doc_path=".cursor/skills/foo/SKILL.md",
+ )
+ self.assertTrue(
+ any("Agent doc `.cursor/skills/foo/SKILL.md` shrank" in r for r in reasons)
+ )
+
+
+class TestCatalogAgentDocsRule(unittest.TestCase):
+ def test_agent_docs_rule_in_policy(self) -> None:
+ policy = config_loader.load_policy()
+ rule_ids = {rule["rule_id"] for rule in policy.get("agent_docs_rules") or []}
+ self.assertIn("agent_docs.no_regression_vs_merge_base", rule_ids)
diff --git a/tools/tests/extended_linter/layers/test_policy_rules_catalog.py b/tools/tests/extended_linter/layers/test_policy_rules_catalog.py
index f4780fa086..9dfe996334 100644
--- a/tools/tests/extended_linter/layers/test_policy_rules_catalog.py
+++ b/tools/tests/extended_linter/layers/test_policy_rules_catalog.py
@@ -4,6 +4,10 @@
import tools.extended_linter.layers.diff_policy as layer_diff
import tools.extended_linter.layers.path_policy as layer_path
+CATALOG_AGENT_DOCS_RULES = [
+ "agent_docs.no_regression_vs_merge_base",
+]
+
CATALOG_PATH_RULES = [
"path.deny_repo_tentacles",
"path.deny_user",
@@ -11,6 +15,8 @@
"path.deny_cursor_local",
"path.deny_private_keys",
"path.deny_known_secret_filenames",
+ "path.deny_agent_plans",
+ "path.deny_node_web_playwright_e2e",
]
CATALOG_DIFF_RULES = [
@@ -44,6 +50,32 @@ def test_secrets_json_path(self) -> None:
violations = layer_path.run(["config/credentials.json"], policy)
self.assertEqual(violations[0].rule_id, "path.deny_known_secret_filenames")
+ def test_agent_plan_paths(self) -> None:
+ policy = config_loader.load_policy()
+ plan_scratch = (
+ "packages/tentacles/Services/Interfaces/node_web_interface/"
+ "PLAN-login-error-display.md"
+ )
+ violations = layer_path.run([plan_scratch, ".cursor/plans/foo.plan.md"], policy)
+ rule_ids = {violation.rule_id for violation in violations}
+ self.assertEqual(rule_ids, {"path.deny_agent_plans"})
+
+ def test_node_web_playwright_e2e_path(self) -> None:
+ policy = config_loader.load_policy()
+ e2e_spec = (
+ "packages/tentacles/Services/Interfaces/node_web_interface/"
+ "e2e/login-auth-errors.spec.ts"
+ )
+ violations = layer_path.run([e2e_spec], policy)
+ self.assertEqual(violations[0].rule_id, "path.deny_node_web_playwright_e2e")
+
+
+class TestCatalogAgentDocsRules(unittest.TestCase):
+ def test_each_agent_docs_rule_id_present(self) -> None:
+ policy = config_loader.load_policy()
+ rule_ids = {rule["rule_id"] for rule in policy.get("agent_docs_rules") or []}
+ self.assertEqual(rule_ids, set(CATALOG_AGENT_DOCS_RULES))
+
class TestCatalogDiffRules(unittest.TestCase):
def test_each_diff_rule_id_present(self) -> None: