From 55caba3dcf9733985ab307e1f3b4a88b49948fb3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Jun 2026 01:52:27 +0000 Subject: [PATCH] feat(automation): add continual learning, agents-md-audit, and shell entry points Wire continual-learning skill and agents-memory-updater subagent, add a read-only AGENTS.md audit agent, shell/Makefile shortcuts, and docs for the expanded repository inspection automation stack. Co-authored-by: Bryan --- .cursor/agents/agents-memory-updater.md | 47 ++++ .cursor/skills/continual-learning/SKILL.md | 24 ++ AGENTS.md | 7 +- Makefile | 18 +- docs/guides/REPO_AUTOMATION_GUIDE.md | 10 +- scripts/README.md | 8 +- scripts/agents/agents_md_audit_agent.py | 277 +++++++++++++++++++++ scripts/run_ai_automation.sh | 4 + scripts/run_repo_agents.py | 1 + tests/test_agents_md_audit_agent.py | 150 +++++++++++ 10 files changed, 542 insertions(+), 4 deletions(-) create mode 100644 .cursor/agents/agents-memory-updater.md create mode 100644 .cursor/skills/continual-learning/SKILL.md create mode 100644 scripts/agents/agents_md_audit_agent.py create mode 100755 scripts/run_ai_automation.sh create mode 100644 tests/test_agents_md_audit_agent.py diff --git a/.cursor/agents/agents-memory-updater.md b/.cursor/agents/agents-memory-updater.md new file mode 100644 index 000000000..60caef0a9 --- /dev/null +++ b/.cursor/agents/agents-memory-updater.md @@ -0,0 +1,47 @@ +--- +name: agents-memory-updater +description: Mine high-signal transcript deltas, update `AGENTS.md`, and keep the incremental transcript index in sync. +model: inherit +--- + +# AGENTS.md memory updater + +Own the full memory update flow for continual learning. + +## Trigger + +Use from `continual-learning` when transcript deltas may produce durable memory updates. + +## Workflow + +1. Read existing `AGENTS.md` first. If it does not exist, create it with only: + - `## Learned User Preferences` + - `## Learned Workspace Facts` +2. Load the incremental index if present at `.cursor/hooks/state/continual-learning-index.json`. +3. Inspect only transcript files under `~/.cursor/projects//agent-transcripts/` that are new or have newer mtimes than the index. +4. Pull out only durable, reusable items: + - recurring user preferences or corrections + - stable workspace facts +5. Update `AGENTS.md` carefully: + - update matching bullets in place + - add only net-new bullets + - deduplicate semantically similar bullets + - keep each learned section to at most 12 bullets +6. Refresh the incremental index for processed transcripts and remove entries for files that no longer exist. +7. If the merge produces no `AGENTS.md` changes, leave `AGENTS.md` unchanged but still refresh the index. +8. If no meaningful updates exist, respond exactly: `No high-signal memory updates.` + +## Guardrails + +- Use plain bullet points only. +- Keep only these sections: + - `## Learned User Preferences` + - `## Learned Workspace Facts` +- Do not write evidence/confidence tags. +- Do not write process instructions, rationale, or metadata blocks. +- Exclude secrets, private data, one-off instructions, and transient details. + +## Output + +- Updated `AGENTS.md` and `.cursor/hooks/state/continual-learning-index.json` when needed +- Otherwise exactly `No high-signal memory updates.` diff --git a/.cursor/skills/continual-learning/SKILL.md b/.cursor/skills/continual-learning/SKILL.md new file mode 100644 index 000000000..f1beb5c7e --- /dev/null +++ b/.cursor/skills/continual-learning/SKILL.md @@ -0,0 +1,24 @@ +--- +name: continual-learning +description: Orchestrate continual learning by delegating transcript mining and AGENTS.md updates to `agents-memory-updater`. +disable-model-invocation: true +--- + +# Continual Learning + +Keep `AGENTS.md` current by delegating the memory update flow to one subagent. + +## Trigger + +Use when the user asks to mine prior chats, maintain `AGENTS.md`, or run the continual-learning loop. + +## Workflow + +1. Call `agents-memory-updater`. +2. Return the updater result. + +## Guardrails + +- Keep the parent skill orchestration-only. +- Do not mine transcripts or edit files in the parent flow. +- Do not bypass the subagent. diff --git a/AGENTS.md b/AGENTS.md index 146970be7..83b24029b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,10 @@ actions. Tag forms recognised include: - Fast unit tests: `python scripts/test_runner.py --unit` - Aria-specific unit tests: `pytest tests/unit/test_tags_to_actions.py` - Quick repo validation: `python scripts/fast_validate.py` -- Repo automation agents: `python scripts/run_repo_agents.py` (writes `data_out/agents/status.json`; use `--run-agents` with `scripts/repo_health_automation.py`) +- Repo automation agents: `python scripts/run_repo_agents.py` or `make agents` (writes `data_out/agents/status.json`; use `--run-agents` with `scripts/repo_health_automation.py`) +- Shell wrapper: `./scripts/run_ai_automation.sh` (forwards to `run_repo_agents.py`) +- Continual learning: run `/continual-learning` in Cursor to mine transcripts and update Learned sections below (delegates to `.cursor/agents/agents-memory-updater.md`) +- Automation canvas: open `~/.cursor/projects/workspace/canvases/automation-status.canvas.tsx` beside chat for agent + memory metrics - System health: `curl http://localhost:7071/api/ai/status | jq` ## Conventions @@ -94,6 +97,8 @@ The startup update script installs Python deps into `.venv` (Python 3.12). Use ` - **Aria web UI** (flagship, port 8080): `.venv/bin/python apps/aria/server.py --port 8080` (or `make start`). The root page `/` is a static stage demo with **no command box**; the natural-language command UI is at **`/auto-execute.html`** (text input + "Execute Actions" → `POST /api/aria/execute`). Backend command/execute endpoints work via curl regardless (see AGENTS.md API table). `ARIA_RENDER_MODE` defaults to `ue5` but the browser falls back to Three.js, so the stage still renders without UE5. - **Azure Functions API** (port 7071): `func host start --port 7071` (or `make start-functions`). The `func` CLI (v4) is preinstalled in the VM snapshot at `~/.npm-global/bin` but is **not on the default PATH** — prepend it: `export PATH="$HOME/.npm-global/bin:$PATH"`. If `func` is ever missing, reinstall with `npm install -g azure-functions-core-tools@4 --unsafe-perm true`, then run `npm config delete prefix` to avoid an nvm/npmrc conflict. On startup the host logs `AzureWebJobsStorage ... Unhealthy` because Azurite isn't running; this is **expected and non-fatal** — HTTP-triggered endpoints (`/api/ai/status`, `/api/chat`, `/api/tts`, etc.) still respond. A lightweight fallback `make start-local-status` serves only `/api/ai/status`. - **Tests/lint:** `.venv/bin/python scripts/test_runner.py --unit` is the fast suite. All ~2700 unit tests now pass (0 failures, 50 skipped) — previous devcontainer-path failures were fixed in the automation runner PR. `make lint` (ruff + black) currently reports many pre-existing findings across the repo — treat lint failures as code-quality state, not an environment problem. +- **Repo agents:** `make agents` or `./scripts/run_ai_automation.sh` runs all inspection agents; `make agents-dry` previews without writing status files. +- **Continual learning:** invoke `/continual-learning` in Cursor chat (skill at `.cursor/skills/continual-learning/SKILL.md`) to update Learned sections via the `agents-memory-updater` subagent. ## Learned User Preferences diff --git a/Makefile b/Makefile index dd22cc50a..09c502087 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,8 @@ GRADIO_SHARE ?= false .PHONY: all install install-qai dev start stop build test test-unit test-integration \ lint format type-check clean docker-build docker-dev start-gradio \ - start-local-status start-functions-clean restart-functions-clean start-qai validate-mcp validate-mcp-json help + start-local-status start-functions-clean restart-functions-clean start-qai validate-mcp validate-mcp-json \ + agents agents-dry ai-automation help # Default target all: lint test @@ -145,6 +146,21 @@ validate-mcp: validate-mcp-json: @$(PYTHON) scripts/validate_mcp_setup.py --json +# --------------------------------------------------------------------------- +# Repository inspection agents +# --------------------------------------------------------------------------- + +## Run all repository inspection agents +agents: + $(PYTHON) scripts/run_repo_agents.py + +## Preview agent results without writing data_out/agents/* +agents-dry: + $(PYTHON) scripts/run_repo_agents.py --dry-run + +## Alias for agents +ai-automation: agents + # --------------------------------------------------------------------------- # Code quality # --------------------------------------------------------------------------- diff --git a/docs/guides/REPO_AUTOMATION_GUIDE.md b/docs/guides/REPO_AUTOMATION_GUIDE.md index 682df636e..c681dc175 100644 --- a/docs/guides/REPO_AUTOMATION_GUIDE.md +++ b/docs/guides/REPO_AUTOMATION_GUIDE.md @@ -232,6 +232,7 @@ Use `scripts/run_repo_agents.py` when you need deterministic repository checks t | `status-freshness` | Inspect `data_out/**/status.json` for stale, failed, timestamp-less, or unparseable runs | A health file has not updated within the configured max age | | `marker-audit` | Scan source-like files for `TODO`, `FIXME`, `HACK`, `XXX`, and `BUG` markers | Maintenance marker counts by file and marker type | | `docstring-audit` | Measure Python module/class/function docstring coverage | Public functions missing docstrings in audited paths | +| `agents-md-audit` | Validate `AGENTS.md` Learned sections for structure, bullet limits, and secret patterns | Missing sections, over-limit bullets, or stale referenced dates | ### Contract and outputs @@ -262,9 +263,16 @@ python scripts/run_repo_agents.py --agent status-freshness --json # Fail a CI/check step when any agent warns or errors python scripts/run_repo_agents.py --fail-on-warning + +# Shell wrapper (uses .venv/bin/python) +./scripts/run_ai_automation.sh --json + +# Makefile shortcuts +make agents +make agents-dry ``` -`repo_health_automation.py --run-agents` inserts the agent runner before the integration contract gate. This is useful after orchestrator, status-file, or documentation-maintenance changes because it records repository inspection results in the same health-cycle status payload. +`repo_health_automation.py --run-agents` inserts the agent runner **after** the integration contract gate. This is useful after orchestrator, status-file, or documentation-maintenance changes because it records repository inspection results in the same health-cycle status payload. ## Operational Smoke Flow diff --git a/scripts/README.md b/scripts/README.md index f53286711..eee13d61d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -93,7 +93,7 @@ python .\scripts\repo_health_automation.py --watch --interval 300 --strict-endpo - `--strict-endpoints` — run integration gate in strict endpoint mode - `--full-pytest` — include `pytest tests -q --maxfail=1 --tb=short` - `--auto-fix-ruff` — run `ruff check --fix` on changed `.py` files before checks -- `--run-agents` — run `scripts/run_repo_agents.py` before the integration contract gate +- `--run-agents` — run `scripts/run_repo_agents.py` after the integration contract gate - `--continue-on-fail` — continue all steps even after a failed step **Status output:** @@ -111,6 +111,7 @@ python .\scripts\repo_health_automation.py --watch --interval 300 --strict-endpo | `status-freshness` | Stale, failed, unparseable, or timestamp-less `data_out/**/status.json` files | `scripts/agents/status_freshness_agent.py` | | `marker-audit` | `TODO`, `FIXME`, `HACK`, `XXX`, and `BUG` markers in source-like files | `scripts/agents/marker_audit_agent.py` | | `docstring-audit` | Module/class/function docstring coverage for Python paths, defaulting to `shared` and `scripts/agents` | `scripts/agents/docstring_audit_agent.py` | +| `agents-md-audit` | Structure and hygiene of `AGENTS.md` Learned sections (bullet limits, secrets, stale dates) | `scripts/agents/agents_md_audit_agent.py` | **Result contract:** Every agent returns an `AgentResult` with `name`, `status`, `summary`, `findings`, `metrics`, and `timestamp`. Valid statuses are `ok`, `warning`, and `error`; only `ok` makes `AgentResult.ok` true. @@ -119,9 +120,14 @@ python .\scripts\repo_health_automation.py --watch --interval 300 --strict-endpo ```powershell # Run all registered agents and write status files python .\scripts\run_repo_agents.py +make agents # Preview without writing data_out/agents/* python .\scripts\run_repo_agents.py --dry-run +make agents-dry + +# Shell wrapper +./scripts/run_ai_automation.sh --json # Run one agent, emit aggregate JSON, and fail CI on warnings python .\scripts\run_repo_agents.py --agent status-freshness --json --fail-on-warning diff --git a/scripts/agents/agents_md_audit_agent.py b/scripts/agents/agents_md_audit_agent.py new file mode 100644 index 000000000..052a9d3c1 --- /dev/null +++ b/scripts/agents/agents_md_audit_agent.py @@ -0,0 +1,277 @@ +"""Audit AGENTS.md learned-memory sections for structure and hygiene.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections.abc import Sequence +from datetime import datetime, timezone +from pathlib import Path + +_REPO_ROOT_FOR_IMPORT = Path(__file__).resolve().parents[2] +if str(_REPO_ROOT_FOR_IMPORT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT_FOR_IMPORT)) + +from scripts.agents.base import REPO_ROOT, AgentResult, AutomationAgent, register # noqa: E402 + +LEARNED_SECTIONS = ( + "## Learned User Preferences", + "## Learned Workspace Facts", +) +MAX_BULLETS_PER_SECTION = 12 +MIN_BULLETS_PER_SECTION = 1 +STALE_DATE_DAYS = 30 + +MERGE_CONFLICT_PATTERN = re.compile(r"^(<<<<<<<|=======|>>>>>>>)") +SECRET_PATTERNS = ( + re.compile(r"\bsk-[A-Za-z0-9]{10,}\b"), + re.compile(r"\bpassword\s*=\s*\S+", re.IGNORECASE), + re.compile(r"\bapi[_-]?key\s*=\s*\S+", re.IGNORECASE), +) +DATE_PATTERN = re.compile( + r"\b(?:as of|updated|since)\s+(\d{4}-\d{2}-\d{2})\b", + re.IGNORECASE, +) + + +@register +class AgentsMdAuditAgent(AutomationAgent): + """Validate AGENTS.md learned-memory sections for structure and hygiene.""" + + name = "agents-md-audit" + description = "Validates AGENTS.md Learned sections for structure, bullet limits, and secret patterns." + + def __init__( + self, + repo_root: Path | None = None, + *, + agents_md_path: Path | None = None, + stale_date_days: int = STALE_DATE_DAYS, + ) -> None: + super().__init__(repo_root=repo_root) + self.agents_md_path = ( + Path(agents_md_path) if agents_md_path is not None else self.repo_root / "AGENTS.md" + ) + self.stale_date_days = stale_date_days + + def run(self) -> AgentResult: + """Audit AGENTS.md learned sections and return structured findings.""" + findings: list[dict] = [] + metrics: dict = { + "sections_found": 0, + "preferences_bullets": 0, + "facts_bullets": 0, + "stale_dates": 0, + } + + if not self.agents_md_path.exists(): + findings.append( + { + "kind": "missing_file", + "file": self._relative_path(self.agents_md_path), + "message": "AGENTS.md not found", + } + ) + return self._finish(findings, metrics) + + try: + content = self.agents_md_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + findings.append( + { + "kind": "read_error", + "file": self._relative_path(self.agents_md_path), + "message": str(exc), + } + ) + return self._finish(findings, metrics) + + self._check_merge_conflicts(content, findings) + section_bullets = self._parse_sections(content, findings) + metrics["sections_found"] = len(section_bullets) + + for section_title, bullets in section_bullets.items(): + bullet_count = len(bullets) + if section_title == LEARNED_SECTIONS[0]: + metrics["preferences_bullets"] = bullet_count + elif section_title == LEARNED_SECTIONS[1]: + metrics["facts_bullets"] = bullet_count + + if bullet_count < MIN_BULLETS_PER_SECTION: + findings.append( + { + "kind": "empty_section", + "section": section_title, + "message": f"Section has {bullet_count} bullets; expected at least {MIN_BULLETS_PER_SECTION}.", + } + ) + elif bullet_count > MAX_BULLETS_PER_SECTION: + findings.append( + { + "kind": "bullet_limit", + "section": section_title, + "message": ( + f"Section has {bullet_count} bullets; " + f"maximum allowed is {MAX_BULLETS_PER_SECTION}." + ), + } + ) + + for index, bullet in enumerate(bullets, start=1): + self._check_bullet_hygiene(section_title, index, bullet, findings, metrics) + + return self._finish(findings, metrics) + + def _finish(self, findings: list[dict], metrics: dict) -> AgentResult: + has_error = any(f["kind"] in {"missing_file", "read_error", "missing_section"} for f in findings) + has_warning = bool(findings) and not has_error + status = "error" if has_error else ("warning" if has_warning else "ok") + summary = ( + f"Audited {self._relative_path(self.agents_md_path)}: " + f"{metrics.get('preferences_bullets', 0)} preference bullets, " + f"{metrics.get('facts_bullets', 0)} fact bullets, " + f"{len(findings)} finding{'s' if len(findings) != 1 else ''}." + ) + return self.make_result(status=status, summary=summary, findings=findings, metrics=metrics) + + def _check_merge_conflicts(self, content: str, findings: list[dict]) -> None: + for line_number, line in enumerate(content.splitlines(), start=1): + if MERGE_CONFLICT_PATTERN.match(line.strip()): + findings.append( + { + "kind": "merge_conflict", + "line": line_number, + "message": "Merge conflict marker detected", + "text": line.strip(), + } + ) + + def _parse_sections(self, content: str, findings: list[dict]) -> dict[str, list[str]]: + lines = content.splitlines() + section_starts: dict[str, int] = {} + for index, line in enumerate(lines): + stripped = line.strip() + if stripped in LEARNED_SECTIONS: + section_starts[stripped] = index + + for section_title in LEARNED_SECTIONS: + if section_title not in section_starts: + findings.append( + { + "kind": "missing_section", + "section": section_title, + "message": f"Required section {section_title!r} not found.", + } + ) + + section_bullets: dict[str, list[str]] = {} + ordered_sections = sorted(section_starts.items(), key=lambda item: item[1]) + for position, (section_title, start_index) in enumerate(ordered_sections): + end_index = ( + ordered_sections[position + 1][1] + if position + 1 < len(ordered_sections) + else len(lines) + ) + bullets: list[str] = [] + for line in lines[start_index + 1 : end_index]: + stripped = line.strip() + if stripped.startswith("- "): + bullets.append(stripped[2:].strip()) + elif stripped.startswith("## "): + break + section_bullets[section_title] = bullets + + return section_bullets + + def _check_bullet_hygiene( + self, + section_title: str, + index: int, + bullet: str, + findings: list[dict], + metrics: dict, + ) -> None: + for pattern in SECRET_PATTERNS: + if pattern.search(bullet): + findings.append( + { + "kind": "secret_pattern", + "section": section_title, + "bullet_index": index, + "message": "Possible secret or credential pattern in bullet text.", + } + ) + + for match in DATE_PATTERN.finditer(bullet): + date_text = match.group(1) + try: + referenced = datetime.strptime(date_text, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + continue + age_days = (datetime.now(timezone.utc) - referenced).days + if age_days > self.stale_date_days: + metrics["stale_dates"] += 1 + findings.append( + { + "kind": "stale_date", + "section": section_title, + "bullet_index": index, + "message": f"Referenced date {date_text} is {age_days} days old.", + "text": bullet[:200], + } + ) + + def _relative_path(self, path: Path) -> str: + try: + return str(path.relative_to(self.repo_root)) + except ValueError: + return str(path) + + +def build_parser() -> argparse.ArgumentParser: + """Build the command-line parser for the AGENTS.md audit agent.""" + parser = argparse.ArgumentParser(description=AgentsMdAuditAgent.description) + parser.add_argument("--root", type=Path, default=REPO_ROOT, help="Repository root (default: repo root).") + parser.add_argument( + "--agents-md", + type=Path, + default=None, + help="Path to AGENTS.md (default: /AGENTS.md).", + ) + parser.add_argument( + "--stale-days", + type=int, + default=STALE_DATE_DAYS, + help=f"Warn when referenced dates exceed this many days (default: {STALE_DATE_DAYS}).", + ) + parser.add_argument("--dry-run", action="store_true", help="Compute results without writing status.json.") + parser.add_argument("--json", action="store_true", help="Print the full result as JSON.") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the AGENTS.md audit CLI and return a process exit code.""" + args = build_parser().parse_args(argv) + agents_md_path = args.agents_md if args.agents_md is not None else args.root / "AGENTS.md" + agent = AgentsMdAuditAgent( + repo_root=args.root, + agents_md_path=agents_md_path, + stale_date_days=args.stale_days, + ) + result = agent.run() + + if not args.dry_run: + agent.write_status(result) + + if args.json: + print(json.dumps(result.to_dict(), indent=2)) + else: + print(result.summary) + + return 1 if result.status == "error" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_ai_automation.sh b/scripts/run_ai_automation.sh new file mode 100755 index 000000000..efb879802 --- /dev/null +++ b/scripts/run_ai_automation.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +exec "${REPO_ROOT}/.venv/bin/python" "${REPO_ROOT}/scripts/run_repo_agents.py" "$@" diff --git a/scripts/run_repo_agents.py b/scripts/run_repo_agents.py index 6e16c3bc1..df11969fa 100644 --- a/scripts/run_repo_agents.py +++ b/scripts/run_repo_agents.py @@ -40,6 +40,7 @@ "scripts.agents.status_freshness_agent", "scripts.agents.marker_audit_agent", "scripts.agents.docstring_audit_agent", + "scripts.agents.agents_md_audit_agent", ) SUMMARY_PATH = AGENTS_DATA_DIR / "status.json" diff --git a/tests/test_agents_md_audit_agent.py b/tests/test_agents_md_audit_agent.py new file mode 100644 index 000000000..7bc0bebd9 --- /dev/null +++ b/tests/test_agents_md_audit_agent.py @@ -0,0 +1,150 @@ +"""Tests for the AGENTS.md audit automation agent.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from scripts.agents.agents_md_audit_agent import AgentsMdAuditAgent, main # noqa: E402 + +VALID_AGENTS_MD = """\ +# AGENTS + +## Learned User Preferences + +- Commit messages follow conventional format. +- PR bodies use standard headers. + +## Learned Workspace Facts + +- Unit test suite passes 2700 tests (as of 2026-06-20). +- Automation agent framework lives in scripts/agents/. +""" + + +def test_valid_agents_md_passes(tmp_path): + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text(VALID_AGENTS_MD, encoding="utf-8") + + result = AgentsMdAuditAgent(repo_root=tmp_path, agents_md_path=agents_md).run() + + assert result.status == "ok" + assert result.metrics["preferences_bullets"] == 2 + assert result.metrics["facts_bullets"] == 2 + assert result.findings == [] + + +def test_missing_file_is_error(tmp_path): + missing = tmp_path / "AGENTS.md" + + result = AgentsMdAuditAgent(repo_root=tmp_path, agents_md_path=missing).run() + + assert result.status == "error" + assert result.findings[0]["kind"] == "missing_file" + + +def test_missing_section_is_error(tmp_path): + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text( + "## Learned User Preferences\n\n- One preference.\n", + encoding="utf-8", + ) + + result = AgentsMdAuditAgent(repo_root=tmp_path, agents_md_path=agents_md).run() + + assert result.status == "error" + assert any(f["kind"] == "missing_section" for f in result.findings) + + +def test_bullet_limit_warning(tmp_path): + bullets = "\n".join(f"- Bullet {index}." for index in range(13)) + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text( + f"## Learned User Preferences\n\n{bullets}\n\n## Learned Workspace Facts\n\n- One fact.\n", + encoding="utf-8", + ) + + result = AgentsMdAuditAgent(repo_root=tmp_path, agents_md_path=agents_md).run() + + assert result.status == "warning" + assert any(f["kind"] == "bullet_limit" for f in result.findings) + + +def test_merge_conflict_is_warning(tmp_path): + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text( + "## Learned User Preferences\n\n- ok\n<<<<<<< HEAD\n\n## Learned Workspace Facts\n\n- ok\n", + encoding="utf-8", + ) + + result = AgentsMdAuditAgent(repo_root=tmp_path, agents_md_path=agents_md).run() + + assert result.status == "warning" + assert any(f["kind"] == "merge_conflict" for f in result.findings) + + +def test_secret_pattern_is_warning(tmp_path): + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text( + "## Learned User Preferences\n\n- Use sk-abcdefghijklmnopqrstuvwxyz123456 for auth.\n\n" + "## Learned Workspace Facts\n\n- One fact.\n", + encoding="utf-8", + ) + + result = AgentsMdAuditAgent(repo_root=tmp_path, agents_md_path=agents_md).run() + + assert result.status == "warning" + assert any(f["kind"] == "secret_pattern" for f in result.findings) + + +def test_stale_date_warning(tmp_path): + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text( + "## Learned User Preferences\n\n- One preference.\n\n" + "## Learned Workspace Facts\n\n- Tests pass as of 2020-01-01.\n", + encoding="utf-8", + ) + + result = AgentsMdAuditAgent( + repo_root=tmp_path, + agents_md_path=agents_md, + stale_date_days=30, + ).run() + + assert result.status == "warning" + assert any(f["kind"] == "stale_date" for f in result.findings) + assert result.metrics["stale_dates"] == 1 + + +def test_dry_run_does_not_write_status(tmp_path, monkeypatch, capsys): + import scripts.agents.base as base + + monkeypatch.setattr(base, "AGENTS_DATA_DIR", tmp_path / "agent-status") + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text(VALID_AGENTS_MD, encoding="utf-8") + status_path = tmp_path / "agent-status" / "agents-md-audit" / "status.json" + + assert main(["--root", str(tmp_path), "--agents-md", str(agents_md), "--dry-run"]) == 0 + assert not status_path.exists() + + assert main(["--root", str(tmp_path), "--agents-md", str(agents_md)]) == 0 + assert status_path.exists() + loaded = json.loads(status_path.read_text(encoding="utf-8")) + assert loaded["name"] == "agents-md-audit" + capsys.readouterr() + + +def test_json_flag_prints_full_result(tmp_path, capsys): + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text(VALID_AGENTS_MD, encoding="utf-8") + + assert main(["--root", str(tmp_path), "--agents-md", str(agents_md), "--dry-run", "--json"]) == 0 + + printed = json.loads(capsys.readouterr().out) + assert printed["name"] == "agents-md-audit" + assert printed["status"] == "ok"