From 0417ca931f0cb212dd3d35213b8f556143834cf5 Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Fri, 18 Sep 2026 19:07:58 +0200 Subject: [PATCH 1/2] [Cloud env] add plan & e2e tests guidance --- .cursor/rules/octobot-cloud.mdc | 3 ++- .cursor/skills/cloud-roadmap/SKILL.md | 4 ++-- .cursor/skills/octobot-cloud/SKILL.md | 10 +++++++-- .gitignore | 5 +++++ AGENTS.md | 2 +- CONTRIBUTING-agent.md | 3 ++- .../Interfaces/node_web_interface/README.md | 4 +++- tools/extended_linter/config/policy.yaml | 10 +++++++++ .../layers/test_policy_rules_catalog.py | 21 +++++++++++++++++++ 9 files changed, 54 insertions(+), 8 deletions(-) diff --git a/.cursor/rules/octobot-cloud.mdc b/.cursor/rules/octobot-cloud.mdc index 553fbd52c1..d97a43dd2a 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`). +- **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..5efc836d32 100644 --- a/.cursor/skills/octobot-cloud/SKILL.md +++ b/.cursor/skills/octobot-cloud/SKILL.md @@ -15,8 +15,9 @@ 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`). +5. Before handoff: `python -m tools.extended_linter --base origin/dev` (or `origin/`). +6. 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 +60,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..21258f7bb1 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`). ## Architecture for agents diff --git a/CONTRIBUTING-agent.md b/CONTRIBUTING-agent.md index 056f4b538b..8f49aaba29 100644 --- a/CONTRIBUTING-agent.md +++ b/CONTRIBUTING-agent.md @@ -9,7 +9,8 @@ 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. 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 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/config/policy.yaml b/tools/extended_linter/config/policy.yaml index 2aa1ffc922..a8eb6e33cd 100644 --- a/tools/extended_linter/config/policy.yaml +++ b/tools/extended_linter/config/policy.yaml @@ -39,6 +39,16 @@ 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 diff_rules: - rule_id: diff.no_pip_install 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..a3da619d74 100644 --- a/tools/tests/extended_linter/layers/test_policy_rules_catalog.py +++ b/tools/tests/extended_linter/layers/test_policy_rules_catalog.py @@ -11,6 +11,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 +46,25 @@ 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 TestCatalogDiffRules(unittest.TestCase): def test_each_diff_rule_id_present(self) -> None: From 3d83d0f193628a6655979cd4d10cd39986316927 Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Fri, 18 Sep 2026 19:50:40 +0200 Subject: [PATCH 2/2] [Cloud env] add guards on agent files edit --- .cursor/rules/octobot-cloud.mdc | 2 +- .cursor/skills/octobot-cloud/SKILL.md | 7 +- AGENTS.md | 2 +- CONTRIBUTING-agent.md | 7 +- tools/extended_linter/ARCHITECTURE.md | 10 +- tools/extended_linter/config/policy.yaml | 13 ++ tools/extended_linter/engine/runner.py | 9 + .../layers/agent_docs_policy.py | 154 ++++++++++++++++++ .../layers/test_agent_docs_policy.py | 104 ++++++++++++ .../layers/test_policy_rules_catalog.py | 11 ++ 10 files changed, 308 insertions(+), 11 deletions(-) create mode 100644 tools/extended_linter/layers/agent_docs_policy.py create mode 100644 tools/tests/extended_linter/layers/test_agent_docs_policy.py diff --git a/.cursor/rules/octobot-cloud.mdc b/.cursor/rules/octobot-cloud.mdc index d97a43dd2a..ed1c5f3e2d 100644 --- a/.cursor/rules/octobot-cloud.mdc +++ b/.cursor/rules/octobot-cloud.mdc @@ -9,7 +9,7 @@ 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. 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`). +- **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. diff --git a/.cursor/skills/octobot-cloud/SKILL.md b/.cursor/skills/octobot-cloud/SKILL.md index 5efc836d32..9f52fb72aa 100644 --- a/.cursor/skills/octobot-cloud/SKILL.md +++ b/.cursor/skills/octobot-cloud/SKILL.md @@ -15,9 +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 commit: remove any scratch plan files (`PLAN-*.md`, `*.plan.md`, `.cursor/plans/`) from the tree; do not stage them (`path.deny_agent_plans`). -5. Before handoff: `python -m tools.extended_linter --base origin/dev` (or `origin/`). -6. 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 diff --git a/AGENTS.md b/AGENTS.md index 21258f7bb1..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`. No agent plan files or Node UI Playwright e2e in the diff (`path.deny_agent_plans`, `path.deny_node_web_playwright_e2e`). +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 8f49aaba29..8fddb3e516 100644 --- a/CONTRIBUTING-agent.md +++ b/CONTRIBUTING-agent.md @@ -10,7 +10,8 @@ See [`.cursor/README.md`](.cursor/README.md). Install profile **`ci-tentacles`** 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. 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. 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. +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 @@ -21,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/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 a8eb6e33cd..bfd9de3784 100644 --- a/tools/extended_linter/config/policy.yaml +++ b/tools/extended_linter/config/policy.yaml @@ -50,6 +50,19 @@ path_rules: - "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 patterns: 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 a3da619d74..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", @@ -66,6 +70,13 @@ def test_node_web_playwright_e2e_path(self) -> None: 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: policy = config_loader.load_policy()