From 79b783864ec6aa82a468c31f45087c6097526665 Mon Sep 17 00:00:00 2001 From: laptran Date: Sun, 14 Jun 2026 11:24:36 -0400 Subject: [PATCH] Harden framework: tests, VRAM Python, dashboard spec, security, CI - Rewrite vram_detect in Python with fixed config parsing and 10KB read limit - Add pytest suite (72 tests) covering dashboard core, app security, and VRAM - Standardize all prompts to .automaton/tasks/{task-name}/ path - Reconcile dashboard spec with web implementation; remove themes.py - Remove half-implemented refresh.py file watcher - Harden dashboard static-file serving and task-name validation - Add uncommitted-change guard to update.sh and real Gitea URLs - Add AGENTS.md, Gitea CI workflow, and template documentation --- .gitea/workflows/ci.yml | 31 + .gitignore | 4 + .rules.md | 5 + AGENTS.md | 102 ++++ CHANGELOG.md | 21 + README.md | 6 +- automaton/dashboard/README.md | 8 +- .../__pycache__/__init__.cpython-314.pyc | Bin 155 -> 0 bytes .../__pycache__/__main__.cpython-314.pyc | Bin 2271 -> 0 bytes .../__pycache__/config.cpython-314.pyc | Bin 5046 -> 0 bytes .../core/__pycache__/__init__.cpython-314.pyc | Bin 160 -> 0 bytes .../core/__pycache__/scope.cpython-314.pyc | Bin 1686 -> 0 bytes .../core/__pycache__/task.cpython-314.pyc | Bin 16596 -> 0 bytes automaton/dashboard/core/refresh.py | 90 --- automaton/dashboard/html/index.html | 3 +- automaton/dashboard/pyproject.toml | 15 - automaton/dashboard/themes.py | 19 - .../ui/__pycache__/__init__.cpython-314.pyc | Bin 158 -> 0 bytes .../ui/__pycache__/app.cpython-314.pyc | Bin 22621 -> 0 bytes automaton/dashboard/ui/app.py | 25 +- prompts/adversarial_bug_find.md | 6 +- prompts/bug_finder.md | 12 +- prompts/decompose.md | 8 +- prompts/design.md | 2 +- prompts/doc_review.md | 10 +- prompts/implement.md | 12 +- prompts/onboarding.md | 6 +- prompts/orchestrate.md | 6 +- prompts/referee.md | 22 +- prompts/research.md | 2 +- prompts/test_design.md | 6 +- prompts/workflow.md | 2 +- pyproject.toml | 22 + scripts/install.sh | 30 +- scripts/update.sh | 7 + scripts/vram_detect.py | 536 +++++++++++++++++ scripts/vram_detect.sh | 549 ------------------ tasks/add-pytest-test-suite/IMPLEMENTATION.md | 28 + tasks/add-pytest-test-suite/REVIEW.md | 4 + tasks/add-pytest-test-suite/SPEC.md | 29 + tasks/add-pytest-test-suite/VERDICT.md | 18 + tasks/dashboard-spec.md | 175 +++--- .../IMPLEMENTATION.md | 22 + tasks/developer-experience-gitea-ci/REVIEW.md | 4 + tasks/developer-experience-gitea-ci/SPEC.md | 32 + .../developer-experience-gitea-ci/VERDICT.md | 20 + .../IMPLEMENTATION.md | 24 + .../REVIEW.md | 4 + .../harden-dashboard-security-scripts/SPEC.md | 25 + .../VERDICT.md | 20 + .../IMPLEMENTATION.md | 18 + tasks/reconcile-dashboard-spec/REVIEW.md | 4 + tasks/reconcile-dashboard-spec/SPEC.md | 25 + tasks/reconcile-dashboard-spec/VERDICT.md | 18 + .../IMPLEMENTATION.md | 17 + tasks/remove-file-system-watcher/REVIEW.md | 4 + tasks/remove-file-system-watcher/SPEC.md | 20 + tasks/remove-file-system-watcher/VERDICT.md | 18 + .../IMPLEMENTATION.md | 26 + tasks/rewrite-vram-detection-python/REVIEW.md | 4 + tasks/rewrite-vram-detection-python/SPEC.md | 32 + .../rewrite-vram-detection-python/VERDICT.md | 19 + .../IMPLEMENTATION.md | 25 + .../REVIEW.md | 4 + .../standardize-task-path-conventions/SPEC.md | 30 + .../VERDICT.md | 18 + templates/README.md | 25 + templates/contract-template.md | 35 -- tests/test_app.py | 88 +++ tests/test_board.py | 54 ++ tests/test_config.py | 55 ++ tests/test_prompt_paths.py | 44 ++ tests/test_scope.py | 62 ++ tests/test_stats.py | 56 ++ tests/test_task.py | 134 +++++ tests/test_vram_detect.py | 110 ++++ 76 files changed, 2025 insertions(+), 892 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 AGENTS.md delete mode 100644 automaton/dashboard/__pycache__/__init__.cpython-314.pyc delete mode 100644 automaton/dashboard/__pycache__/__main__.cpython-314.pyc delete mode 100644 automaton/dashboard/__pycache__/config.cpython-314.pyc delete mode 100644 automaton/dashboard/core/__pycache__/__init__.cpython-314.pyc delete mode 100644 automaton/dashboard/core/__pycache__/scope.cpython-314.pyc delete mode 100644 automaton/dashboard/core/__pycache__/task.cpython-314.pyc delete mode 100644 automaton/dashboard/core/refresh.py delete mode 100644 automaton/dashboard/pyproject.toml delete mode 100644 automaton/dashboard/themes.py delete mode 100644 automaton/dashboard/ui/__pycache__/__init__.cpython-314.pyc delete mode 100644 automaton/dashboard/ui/__pycache__/app.cpython-314.pyc create mode 100644 pyproject.toml create mode 100755 scripts/vram_detect.py delete mode 100755 scripts/vram_detect.sh create mode 100644 tasks/add-pytest-test-suite/IMPLEMENTATION.md create mode 100644 tasks/add-pytest-test-suite/REVIEW.md create mode 100644 tasks/add-pytest-test-suite/SPEC.md create mode 100644 tasks/add-pytest-test-suite/VERDICT.md create mode 100644 tasks/developer-experience-gitea-ci/IMPLEMENTATION.md create mode 100644 tasks/developer-experience-gitea-ci/REVIEW.md create mode 100644 tasks/developer-experience-gitea-ci/SPEC.md create mode 100644 tasks/developer-experience-gitea-ci/VERDICT.md create mode 100644 tasks/harden-dashboard-security-scripts/IMPLEMENTATION.md create mode 100644 tasks/harden-dashboard-security-scripts/REVIEW.md create mode 100644 tasks/harden-dashboard-security-scripts/SPEC.md create mode 100644 tasks/harden-dashboard-security-scripts/VERDICT.md create mode 100644 tasks/reconcile-dashboard-spec/IMPLEMENTATION.md create mode 100644 tasks/reconcile-dashboard-spec/REVIEW.md create mode 100644 tasks/reconcile-dashboard-spec/SPEC.md create mode 100644 tasks/reconcile-dashboard-spec/VERDICT.md create mode 100644 tasks/remove-file-system-watcher/IMPLEMENTATION.md create mode 100644 tasks/remove-file-system-watcher/REVIEW.md create mode 100644 tasks/remove-file-system-watcher/SPEC.md create mode 100644 tasks/remove-file-system-watcher/VERDICT.md create mode 100644 tasks/rewrite-vram-detection-python/IMPLEMENTATION.md create mode 100644 tasks/rewrite-vram-detection-python/REVIEW.md create mode 100644 tasks/rewrite-vram-detection-python/SPEC.md create mode 100644 tasks/rewrite-vram-detection-python/VERDICT.md create mode 100644 tasks/standardize-task-path-conventions/IMPLEMENTATION.md create mode 100644 tasks/standardize-task-path-conventions/REVIEW.md create mode 100644 tasks/standardize-task-path-conventions/SPEC.md create mode 100644 tasks/standardize-task-path-conventions/VERDICT.md create mode 100644 templates/README.md delete mode 100644 templates/contract-template.md create mode 100644 tests/test_app.py create mode 100644 tests/test_board.py create mode 100644 tests/test_config.py create mode 100644 tests/test_prompt_paths.py create mode 100644 tests/test_scope.py create mode 100644 tests/test_stats.py create mode 100644 tests/test_task.py create mode 100644 tests/test_vram_detect.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..f6d301a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + + - name: Compile Python files + run: | + python -m py_compile automaton/**/*.py automaton/dashboard/**/*.py + + - name: Run pytest + run: | + python -m pytest tests/ -v + + - name: Syntax-check shell scripts + run: | + bash -n scripts/*.sh diff --git a/.gitignore b/.gitignore index c18dd8d..586842a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ __pycache__/ +.pytest_cache/ +*.egg-info/ +.venv/ +venv/ diff --git a/.rules.md b/.rules.md index 3bef11e..e8d7cfe 100644 --- a/.rules.md +++ b/.rules.md @@ -29,3 +29,8 @@ - Never scan, read, or modify tasks in other projects on the system - The `{project}` in the Orchestrator prompt is the project root where the session started — do not broaden scope - Past failure: agent scanned tasks in `~/ai-env/projects/invest-copilot/` while working on the automaton framework itself, wasting time on unrelated work across project boundaries + +## Artifact Integrity +- Write task artifacts atomically to avoid partial reads by the dashboard or other agents +- Prefer writing to a temporary file and renaming it into place +- Avoid leaving empty or malformed artifacts in the task folder diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fed4f1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,102 @@ +# AGENTS.md — Automaton Framework + +This file contains the information coding agents need to work effectively on the automaton framework itself. + +## Project Overview + +Automaton is a **prompt-driven, contract-based workflow framework** for LLM agents. It is intentionally not an agent harness: the framework provides prompts, conventions, scripts, and a dashboard, but enforcement is soft and relies on agent discipline. + +## Repository Layout + +``` +~/.automaton/ +├── .agent.md # Global router (autopilot mode, task routing) +├── .rules.md # Global rules and failure modes +├── system-prompt.md # Session startup prompt +├── config.md # Global VRAM/model configuration +├── README.md # Human-facing documentation +├── AGENTS.md # This file +├── CHANGELOG.md # Release notes +├── scripts/ # Bash/Python helper scripts +│ ├── install.sh +│ ├── update.sh +│ ├── migrate-project.sh +│ ├── vram_detect.py +│ └── dashboard.sh +├── prompts/ # Phase-specific LLM prompts +│ ├── orchestrate.md +│ ├── research.md +│ ├── implement.md +│ └── ... +├── contracts/ # Contract checklists +├── templates/ # Task templates +│ └── tasks/ +│ ├── bad-impl/ +│ ├── research-task/ +│ └── subtask-parent/ +├── automaton/ # Python dashboard package +│ └── dashboard/ +│ ├── __main__.py +│ ├── config.py +│ ├── core/ +│ └── ui/ +├── tests/ # pytest suite +└── tasks/ # Framework development tasks +``` + +## Build & Test Commands + +```bash +# Compile all Python files +python -m py_compile automaton/**/*.py automaton/dashboard/**/*.py + +# Run the test suite +python -m pytest tests/ -v + +# Run a single test file +python -m pytest tests/test_task.py -v + +# Syntax-check shell scripts +bash -n scripts/*.sh + +# Start the dashboard +python -m automaton.dashboard +``` + +## Conventions + +- **Prompts** live in `prompts/` and use `{placeholder}` syntax. +- **Task paths** must always be written as `{project}/.automaton/tasks/{task-name}/`. +- **Scripts** should be written in Python if they need complex parsing or testing; Bash is OK for simple glue. +- **Tests** are required for any new Python code or significant script logic. +- **No orchestrator runtime** — keep the framework prompt-driven. Do not add an agent harness. +- **No Rust rewrite** — Python/Bash are the implementation languages. + +## Adding or Updating Prompts + +1. Edit the relevant file in `prompts/`. +2. Run `python -m pytest tests/test_prompt_paths.py` to ensure task paths are canonical. +3. Update `CHANGELOG.md` under `[unreleased]`. + +## Adding a New Script + +1. Place the script in `scripts/`. +2. Make it executable if it is entry-point code (`chmod +x`). +3. Add tests in `tests/` if the script is Python. +4. Update `README.md` and any prompts that reference it. + +## Dashboard Development + +- The dashboard is a **web application** served by a Python HTTP server. +- Static assets are in `automaton/dashboard/html/`. +- Core logic is in `automaton/dashboard/core/`. +- The dashboard is scope-aware: framework mode when run from `~/.automaton/`, project mode otherwise. + +## CI + +Gitea CI runs on every push: +- `python -m py_compile` +- `python -m pytest tests/` +- `bash -n scripts/*.sh` + +See `.gitea/workflows/ci.yml`. diff --git a/CHANGELOG.md b/CHANGELOG.md index d424a91..1a86bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [unreleased] ### Added +- pytest test suite covering dashboard core, app security, and VRAM detection (#add-pytest-test-suite) +- Root `pyproject.toml` with optional test/dashboard dependency groups (#add-pytest-test-suite) +- `AGENTS.md` with build/test commands and conventions (#developer-experience-gitea-ci) +- `.gitea/workflows/ci.yml` running py_compile, pytest, and shell script syntax checks (#developer-experience-gitea-ci) +- `templates/README.md` documenting the task template examples (#developer-experience-gitea-ci) - Blocked phase column between Verification and Resolution on dashboard (#additive-extension-model) - Framework self-enforcement rules in .rules.md and system-prompt.md (#framework-self-enforcement) - Additive extension model: projects extend via extensions/ dir, never copy framework files (#additive-extension-model) @@ -10,6 +15,10 @@ - Framework audit: comprehensive self-consistency check with RESEARCH.md (#framework-audit) ### Changed +- All prompts now use the canonical task path `{project}/.automaton/tasks/{task-name}/` (#standardize-task-path-conventions) +- `scripts/vram_detect.sh` rewritten as `scripts/vram_detect.py` for testability and correctness (#rewrite-vram-detection-python) +- `tasks/dashboard-spec.md` reconciled with the implemented web dashboard (#reconcile-dashboard-spec) +- `automaton/dashboard/README.md` and help modal shortcuts now match the web UI (#reconcile-dashboard-spec) - prompts/orchestrate.md: always reads prompts/contracts/scripts from global, project extensions are additive (#additive-extension-model) - prompts/onboarding.md: removed diff/merge upgrade, replaced with migration check (#additive-extension-model) - README.md: updated upgrade docs for new additive model (#additive-extension-model); added Dashboard section (#dashboard-task-review) @@ -25,6 +34,12 @@ - automaton/dashboard/core/refresh.py: improved inotify error handling with explicit fallback messages (#implement-task) ### Fixed +- VRAM detection: undefined headroom, hardcoded JSON headroom, and code-block config parsing (#rewrite-vram-detection-python) +- VRAM detection: 10KB file-read limit now enforced for API config files (#rewrite-vram-detection-python) +- Dashboard static file serving: replaced string-prefix path traversal check with `Path.relative_to()` (#harden-dashboard-security-scripts) +- Dashboard task name validation: restricted to `[A-Za-z0-9_-]+` (#harden-dashboard-security-scripts) +- `scripts/update.sh`: now warns and aborts on uncommitted changes before pulling (#harden-dashboard-security-scripts) +- README/install.sh: replaced placeholder repository URL with real Gitea URL (#harden-dashboard-security-scripts) - State machine: IMPLEMENTATION.md was never checked in determine_task_state(), tasks showed as RESEARCH (#implement-task) - State machine: DOC_REVIEW checked after BUG_REPORT — wrong priority order (#implement-task) - Path traversal: review API accepted task names with ../ allowing writes outside tasks directory (#dashboard-task-review) @@ -32,6 +47,12 @@ - Review parsing: comment extraction used fragile conditional, falsy comments (e.g., "0") skipped (#implement-task) - Board display: approved planning tasks stayed in Planning column instead of advancing to Design (#dashboard-task-review) +### Removed +- `automaton/dashboard/themes.py` (vestigial ANSI theme stub) (#reconcile-dashboard-spec) +- `automaton/dashboard/core/refresh.py` (half-implemented file watcher; dashboard uses JS polling) (#remove-file-system-watcher) +- `templates/contract-template.md` (unused) (#developer-experience-gitea-ci) +- `automaton/dashboard/pyproject.toml` (consolidated into root `pyproject.toml`) (#add-pytest-test-suite) + ### Migration - Project migration script for old-model projects: scripts/migrate-project.sh (#project-migration) - Project migration detection in onboarding.md (#project-migration) diff --git a/README.md b/README.md index fc02b66..a1a8322 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Before you can use the framework in any project, you must install the core logic ```bash # Clone the framework into the global config directory -git clone [INSERT_FRAMEWORK_REPO_URL_HERE] ~/.automaton +git clone http://10.37.0.86:3003/hermes/automaton ~/.automaton # Enter the directory cd ~/.automaton @@ -96,7 +96,7 @@ For low-VRAM systems (8GB, 16GB), set your VRAM limits in `~/.automaton/config.m - **Max peak context per sub-task**: 12k tokens ``` -**Auto-detection**: When `Auto-detect: Yes`, the Orchestrator runs `scripts/vram_detect.sh` to probe: +**Auto-detection**: When `Auto-detect: Yes`, the Orchestrator runs `scripts/vram_detect.py` to probe: - GPU VRAM (via `nvidia-smi`) - System RAM (via `free`) - Model context window (from config.md or API config files) @@ -173,7 +173,7 @@ When a task is decomposed, the Orchestrator creates sub-tasks under `tasks/{pare - `workflow.md`: The state machine governing the Autopilot lifecycle. - `test_design.md`: Produces a TEST_PLAN.md — an explicit test specification before implementation. - `decompose.md`: Breaks a task into VRAM-sized sub-tasks. -- `scripts/vram_detect.sh`: Auto-detects GPU VRAM, RAM, model context window, and framework overhead. +- `scripts/vram_detect.py`: Auto-detects GPU VRAM, RAM, model context window, and framework overhead. - `contracts/vram_config.md`: Contract for VRAM-aware task decomposition. ## Layered File System diff --git a/automaton/dashboard/README.md b/automaton/dashboard/README.md index 58ed57f..90899a2 100644 --- a/automaton/dashboard/README.md +++ b/automaton/dashboard/README.md @@ -67,7 +67,6 @@ Shows task progress through phases as a timeline with wave visualization for dec | `s` | Focus search | | `?` | Show help | | `Esc` | Close modals / clear search | -| `q` | Quit | ## Configuration @@ -97,7 +96,7 @@ Create a `dashboard-config.json` file in your project's `.automaton/` directory: - **Default**: Bright colors on dark background - **Dark**: Dimmed colors for a darker appearance -- **Light**: Softer colors for light terminals +- **Light**: Softer colors for light displays ## Phase Mapping @@ -123,16 +122,13 @@ automaton/dashboard/ ├── __init__.py # Package marker ├── __main__.py # Entry point (python -m automaton.dashboard) ├── config.py # Configuration management -├── themes.py # Color themes (for TUI fallback) -├── pyproject.toml # Python package config ├── README.md # This file ├── core/ │ ├── scope.py # Scope detection (framework vs project mode) │ ├── task.py # Task model, state machine, artifact parsing │ ├── board.py # Kanban board logic │ ├── stats.py # Statistics calculations -│ ├── timeline.py # Timeline data -│ └── refresh.py # File system watcher (for future use) +│ └── timeline.py # Timeline data └── ui/ ├── app.py # Web server (HTTP + API endpoints) └── html/ diff --git a/automaton/dashboard/__pycache__/__init__.cpython-314.pyc b/automaton/dashboard/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 0d96e2ed3c43e69683813b0c9bc3e469c66daa23..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 155 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 J`=EEY{{!>b_qPB5 diff --git a/automaton/dashboard/core/__pycache__/__init__.cpython-314.pyc b/automaton/dashboard/core/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 99621a292807bcf9d42b008f7644a6329651da0f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 160 zcmdPqfj43Z=)pJn>wgIdJ!hMMLPKWf17NzSf+$)*6?_4IE>Ph(s>qelB&vv$s5Q~T zGz9xF?4{vetOz-zMs8*=)ojPi7$!Aqv}s$Jx=k{)0VlfKxMu6bD6C;$EbFvUs$sCi zK1N~qmVdZbgdM8bm%zccPF?0l*8i~Bu-j&_r8|`9RWW=Y*gBRRfuu*@9XM{|pi^z+P86;hsO zVGPL;szNy$?8g7##Z$v0V~XDv%CFoFv|rQZ<*nizSo1sxks3F4O;>)!T;qvIHnl5|gWF|vc zWBAE>(=xP?P$RZY$(tbImDGUgB2SM0^yAh+wOkm#SShn z++BDQ%Xz8vO6zIrou#O!r20yFT}k(q=?!IiRqba_uV+v9v$Gr7Suc5HspK7+S}xoz z+>)Ou2@(ezd-{S|<}k3h_5axyBZ!P)v$uEVMR=Ce86kfp;1Ob-Ll}G@cRbNy+HqQ@ zA95*CF<~$mv}5*F+m;{26-9hUb5w$xI*tM+l+nCd3gEpxkp$3$o1a;^l9-t zmye@{i=3EA9`*0QbkTe;B>>d3L*BtvTnC+_(Em9!KZ>+GifHf@TgPoter0Y1@&J?o zaQt8LsxbF8GzVm5&>UwVN6;h!`>SHfkJgFaHWzJj!2*21DNnDap2#Oab>(U=G1H68c*^9;R8PtF z#4N9eic4zPmo?3>YnlcT!N_hkuki>5u`oB*5LX_wPvqmaZFE}ZCnN_VRwtK1YCD86 VeuidVpu@WojJ@5%0WkR8zX6L6JG%e? diff --git a/automaton/dashboard/core/__pycache__/task.cpython-314.pyc b/automaton/dashboard/core/__pycache__/task.cpython-314.pyc deleted file mode 100644 index 1a3b194d24c5cd94285bceae6a06971988a285a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16596 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 None: - if not self.tasks_dir.exists(): - return - self._running = True - self._stop_event.clear() - if HAS_INOTIFY: - self._thread = Thread(target=self._watch_inotify, daemon=True) - else: - self._thread = Thread(target=self._watch_polling, daemon=True) - self._thread.start() - - def stop(self) -> None: - self._running = False - self._stop_event.set() - if self._thread and self._thread.is_alive(): - self._thread.join(timeout=1) - - def _watch_inotify(self) -> None: - try: - watcher = inotify.adapters.Inotify() - watcher.add_watch(self.tasks_dir) - except Exception as e: - print(f"Warning: inotify init failed ({e}), falling back to polling") - self._watch_polling() - return - try: - while self._running and not self._stop_event.is_set(): - try: - events = watcher.inotify_read(timeout_ms=1000) - for event in events: - if self._stop_event.is_set(): - break - if self.callback: - self.callback() - except Exception: - if self.callback: - self.callback() - time.sleep(1) - watcher.remove_watch(self.tasks_dir) - except Exception as e: - print(f"Warning: inotify watch failed ({e}), falling back to polling") - self._watch_polling() - - def _watch_polling(self) -> None: - last_hash = _dir_hash(self.tasks_dir) - while self._running and not self._stop_event.is_set(): - time.sleep(2) - if self._stop_event.is_set(): - break - current_hash = _dir_hash(self.tasks_dir) - if current_hash != last_hash: - last_hash = current_hash - if self.callback: - self.callback() - - -def _dir_hash(directory: Path) -> str: - if not directory.exists(): - return "" - entries = [] - for item in sorted(directory.rglob("*")): - if item.is_file(): - try: - stat = item.stat() - entries.append(f"{item.name}:{stat.st_mtime}:{stat.st_size}") - except (OSError, IOError): - pass - return "|".join(entries) diff --git a/automaton/dashboard/html/index.html b/automaton/dashboard/html/index.html index 81018dc..0150437 100644 --- a/automaton/dashboard/html/index.html +++ b/automaton/dashboard/html/index.html @@ -110,12 +110,11 @@ 1Board view 2Statistics view 3Timeline view - tToggle wave display on Timeline + tCycle themes (default → dark → light) rManual refresh fToggle filter bar sFocus search EscClose modals / clear search - qQuit ?Show this help diff --git a/automaton/dashboard/pyproject.toml b/automaton/dashboard/pyproject.toml deleted file mode 100644 index 321b172..0000000 --- a/automaton/dashboard/pyproject.toml +++ /dev/null @@ -1,15 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "automaton" -version = "0.1.0" -description = "Automaton framework - dashboard and tools" -requires-python = ">=3.9" - -[project.scripts] -automaton-dashboard = "automaton.dashboard.__main__:main" - -[tool.setuptools.packages.find] -include = ["automaton.dashboard*"] diff --git a/automaton/dashboard/themes.py b/automaton/dashboard/themes.py deleted file mode 100644 index c044d10..0000000 --- a/automaton/dashboard/themes.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Color themes for the dashboard.""" - -from typing import Dict - -THEMES = { - "default": {}, - "dark": {}, - "light": {}, -} - -# ANSI escape sequences (not needed for web dashboard but kept for compat) -RESET = "\033[0m" -BOLD = "\033[1m" - -def get_theme(theme_name: str = "default") -> Dict[str, str]: - return THEMES.get(theme_name, THEMES["default"]) - -def colorize(text: str, color_code: str) -> str: - return f"{color_code}{text}{RESET}" diff --git a/automaton/dashboard/ui/__pycache__/__init__.cpython-314.pyc b/automaton/dashboard/ui/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index f31641fb101d97ffc1a861e6f3e33003dc6e0549..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 158 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 diff --git a/automaton/dashboard/ui/app.py b/automaton/dashboard/ui/app.py index da28e5e..0fdb1f1 100644 --- a/automaton/dashboard/ui/app.py +++ b/automaton/dashboard/ui/app.py @@ -4,6 +4,7 @@ import json import mimetypes import os import posixpath +import re import sys from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path @@ -77,23 +78,21 @@ class DashboardHandler(SimpleHTTPRequestHandler): try: resolved = full_path.resolve() base = self.dashboard_path.resolve() - # Ensure the resolved path starts with the base directory - if not str(resolved).startswith(str(base) + '/'): - self._send_error(403, "Forbidden") - return - except Exception: + # Ensure the resolved path is inside the base directory + resolved.relative_to(base) + except (ValueError, RuntimeError, OSError): self._send_error(403, "Forbidden") return - if not full_path.exists() or not full_path.is_file(): + if not resolved.exists() or not resolved.is_file(): self._send_error(404, "File not found") return # Determine content type - content_type, encoding = mimetypes.guess_type(str(full_path)) + content_type, encoding = mimetypes.guess_type(str(resolved)) if not content_type: content_type = 'application/octet-stream' # Serve the file try: - with open(full_path, 'rb') as f: + with open(resolved, 'rb') as f: data = f.read() self.send_response(200) self.send_header("Content-Type", content_type) @@ -197,8 +196,14 @@ class DashboardHandler(SimpleHTTPRequestHandler): REVIEW_FILE = "REVIEW.md" - def _validate_task_name(self, task_name: str) -> bool: - return bool(task_name) and "/" not in task_name and "\\" not in task_name and ".." not in task_name + @staticmethod + def _validate_task_name(task_name: str) -> bool: + if not task_name: + return False + # Allow kebab-case names with letters, digits, hyphens, and underscores. + if not re.fullmatch(r"[A-Za-z0-9_-]+", task_name): + return False + return True def _get_review_path(self, task_name: str) -> Path | None: project_root = find_automaton_root() diff --git a/prompts/adversarial_bug_find.md b/prompts/adversarial_bug_find.md index 64dbb5e..ddb8a0e 100644 --- a/prompts/adversarial_bug_find.md +++ b/prompts/adversarial_bug_find.md @@ -2,9 +2,9 @@ You are the Adversarial Bug Finder. ## Read These Files -1. {project}/tasks/{task-name}/SPEC.md -2. {project}/tasks/{task-name}/VRAM_CONFIG.md (if exists) -3. {project}/tasks/{task-name}/PARENT_SPEC.md (if exists) +1. {project}/.automaton/tasks/{task-name}/SPEC.md +2. {project}/.automaton/tasks/{task-name}/VRAM_CONFIG.md (if exists) +3. {project}/.automaton/tasks/{task-name}/PARENT_SPEC.md (if exists) 4. The code Your job is to find bugs that are difficult to spot, such as complex logic errors, race conditions, and performance bottlenecks. Be more aggressive and exhaustive than a standard bug finder. diff --git a/prompts/bug_finder.md b/prompts/bug_finder.md index f4c6fb3..f78e07a 100644 --- a/prompts/bug_finder.md +++ b/prompts/bug_finder.md @@ -2,11 +2,11 @@ You are the Bug Finder. Your job is to find every bug, deviation from spec, and ## Read These Files -1. {project}/tasks/{task-name}/SPEC.md -2. {project}/tasks/{task-name}/{task-name}_CONTRACT.md (if exists) -3. {project}/tasks/{task-name}/IMPLEMENTATION.md (if exists) -4. {project}/tasks/{task-name}/VRAM_CONFIG.md (if exists) -5. {project}/tasks/{task-name}/PARENT_SPEC.md (if exists) +1. {project}/.automaton/tasks/{task-name}/SPEC.md +2. {project}/.automaton/tasks/{task-name}/{task-name}_CONTRACT.md (if exists) +3. {project}/.automaton/tasks/{task-name}/IMPLEMENTATION.md (if exists) +4. {project}/.automaton/tasks/{task-name}/VRAM_CONFIG.md (if exists) +5. {project}/.automaton/tasks/{task-name}/PARENT_SPEC.md (if exists) ## Task @@ -22,7 +22,7 @@ You are the Bug Finder. Your job is to find every bug, deviation from spec, and ## Output Format -Produce a BUG_REPORT.md at {project}/tasks/{task-name}/BUG_REPORT.md: +Produce a BUG_REPORT.md at {project}/.automaton/tasks/{task-name}/BUG_REPORT.md: ```markdown # Bug Report: {task-name} diff --git a/prompts/decompose.md b/prompts/decompose.md index e64622e..e34f350 100644 --- a/prompts/decompose.md +++ b/prompts/decompose.md @@ -4,11 +4,11 @@ Your only job is to take a completed SPEC.md and break it into the smallest poss ## Read These Files -1. {project}/tasks/{task-name}/SPEC.md +1. {project}/.automaton/tasks/{task-name}/SPEC.md 2. {project}/.automaton/.rules.md (if exists — project override) OR ~/.automaton/.rules.md (global default) — project-specific rules 3. ~/.automaton/config.md — Global framework configuration (VRAM, model settings) 4. {project}/.automaton/.agent.md (if exists — project override) OR ~/.automaton/.agent.md (global default) — project agent config -5. {project}/.automaton/scripts/vram_detect.sh (if exists — project override) OR ~/.automaton/scripts/vram_detect.sh (global default) — VRAM detection +5. {project}/.automaton/scripts/vram_detect.py (if exists — project override) OR ~/.automaton/scripts/vram_detect.py (global default) — VRAM detection ## Task @@ -98,7 +98,7 @@ Before decomposing, analyze the SPEC.md: 6. Estimate the token budget for the full task (sum of all requirements' SPEC + DESIGN + TEST files) 7. **Detect VRAM limits**: - Check `~/.automaton/config.md` for VRAM Configuration section - - If `Auto-detect: Yes`, run `{project}/.automaton/scripts/vram_detect.sh` to probe GPU VRAM, RAM, and model context window + - If `Auto-detect: Yes`, run `{project}/.automaton/scripts/vram_detect.py` to probe GPU VRAM, RAM, and model context window - If `Auto-detect: No`, use the manually specified values from config.md - Report the detected VRAM limits 8. **Detect model context window**: @@ -156,7 +156,7 @@ Only produce the DECOMPOSITION.md after the user says "APPROVED" or equivalent. ## Output -Produce a file called DECOMPOSITION.md at {project}/tasks/{task-name}/DECOMPOSITION.md that contains: +Produce a file called DECOMPOSITION.md at {project}/.automaton/tasks/{task-name}/DECOMPOSITION.md that contains: ```markdown # Task Decomposition diff --git a/prompts/design.md b/prompts/design.md index 5d50154..d2f7b87 100644 --- a/prompts/design.md +++ b/prompts/design.md @@ -4,7 +4,7 @@ Your job is to create a clear, actionable design for the project based on the sp ## Read These Files -1. {project}/tasks/{task-name}/SPEC.md +1. {project}/.automaton/tasks/{task-name}/SPEC.md 2. {project}/.automaton/.rules.md (if exists — project override) OR ~/.automaton/.rules.md (global default) — project-specific rules ## Task diff --git a/prompts/doc_review.md b/prompts/doc_review.md index 420a41d..feea31a 100644 --- a/prompts/doc_review.md +++ b/prompts/doc_review.md @@ -2,10 +2,10 @@ You are in Documentation Review mode. ## Read These Files -1. {project}/tasks/{task-name}/DESIGN.md — Look for the "Documentation Plan" section -2. {project}/tasks/{task-name}/SPEC.md — Check what the spec requires -3. {project}/tasks/{task-name}/VRAM_CONFIG.md (if exists) -4. {project}/tasks/{task-name}/PARENT_SPEC.md (if exists) +1. {project}/.automaton/tasks/{task-name}/DESIGN.md — Look for the "Documentation Plan" section +2. {project}/.automaton/tasks/{task-name}/SPEC.md — Check what the spec requires +3. {project}/.automaton/tasks/{task-name}/VRAM_CONFIG.md (if exists) +4. {project}/.automaton/tasks/{task-name}/PARENT_SPEC.md (if exists) 5. Any existing documentation files mentioned in the DESIGN.md Documentation Plan 6. The code that was implemented (implementation artifacts) @@ -42,7 +42,7 @@ You are in Documentation Review mode. ## Output -If the DESIGN.md has a Documentation Plan section, produce a `DOC_REVIEW.md` at `{project}/tasks/{task-name}/DOC_REVIEW.md` with: +If the DESIGN.md has a Documentation Plan section, produce a `DOC_REVIEW.md` at `{project}/.automaton/tasks/{task-name}/DOC_REVIEW.md` with: ```markdown # Documentation Review: {task-name} diff --git a/prompts/implement.md b/prompts/implement.md index 7506160..12c11b7 100644 --- a/prompts/implement.md +++ b/prompts/implement.md @@ -2,14 +2,14 @@ You are in implementation mode. ## Read These Files -1. {project}/tasks/{task-name}/SPEC.md +1. {project}/.automaton/tasks/{task-name}/SPEC.md 2. {project}/.automaton/.rules.md (if exists — project override) OR ~/.automaton/.rules.md (global default) — project-specific rules 3. {project}/.automaton/.agent.md (if exists — project override) OR ~/.automaton/.agent.md (global default) — project agent config -4. {project}/tasks/{task-name}/{task-name}_CONTRACT.md (if exists) -5. {project}/tasks/{task-name}/DESIGN.md (if exists) -6. {project}/tasks/{task-name}/TEST_PLAN.md (if exists) -7. {project}/tasks/{task-name}/VRAM_CONFIG.md (if exists — for low-VRAM systems) -8. {project}/tasks/{task-name}/PARENT_SPEC.md (if exists — for sub-tasks) +4. {project}/.automaton/tasks/{task-name}/{task-name}_CONTRACT.md (if exists) +5. {project}/.automaton/tasks/{task-name}/DESIGN.md (if exists) +6. {project}/.automaton/tasks/{task-name}/TEST_PLAN.md (if exists) +7. {project}/.automaton/tasks/{task-name}/VRAM_CONFIG.md (if exists — for low-VRAM systems) +8. {project}/.automaton/tasks/{task-name}/PARENT_SPEC.md (if exists — for sub-tasks) ## Task diff --git a/prompts/onboarding.md b/prompts/onboarding.md index bb0cc73..217f650 100644 --- a/prompts/onboarding.md +++ b/prompts/onboarding.md @@ -9,7 +9,7 @@ Your only job is to set up the minimal agent framework structure in the target p 3. ~/.automaton/.onboarding.md — human reference for drop-in vs from-scratch scenarios 4. {project}/.automaton/.agent.md (if it exists — project override) 5. {project}/.automaton/.rules.md (if it exists — project override) -6. ~/.automaton/scripts/vram_detect.sh (if exists — for VRAM detection) +6. ~/.automaton/scripts/vram_detect.py (if exists — for VRAM detection) ## Task @@ -37,10 +37,10 @@ Your only job is to set up the minimal agent framework structure in the target p Check if VRAM configuration is available in `~/.automaton/config.md`: 1. Read `~/.automaton/config.md` to check for VRAM Configuration section. 2. If VRAM Configuration section exists, note the values. -3. If VRAM Configuration section does NOT exist, check if VRAM detection is available: `~/.automaton/scripts/vram_detect.sh`. +3. If VRAM Configuration section does NOT exist, check if VRAM detection is available: `~/.automaton/scripts/vram_detect.py`. 4. If available, run it to get VRAM recommendations: ``` - cd ~/.automaton && bash ~/.automaton/scripts/vram_detect.sh + cd ~/.automaton && python ~/.automaton/scripts/vram_detect.py ``` 5. Parse the JSON output for `recommended_k`, `max_peak_context_kb`, and `headroom`. 6. Add a VRAM Configuration section to `~/.automaton/config.md`: diff --git a/prompts/orchestrate.md b/prompts/orchestrate.md index 85190a6..d69be67 100644 --- a/prompts/orchestrate.md +++ b/prompts/orchestrate.md @@ -27,7 +27,7 @@ When VRAM configuration is needed (during task decomposition, sub-task creation, ### Detection Priority -1. **Auto-detect via script**: Check if `{project}/.automaton/scripts/vram_detect.sh` exists. If it does, run it to probe GPU VRAM, RAM, and model context window. Parse the JSON output for `recommended_kb`, `headroom`, and `max_peak_context_kb`. +1. **Auto-detect via script**: Check if `{project}/.automaton/scripts/vram_detect.py` exists. If it does, run it to probe GPU VRAM, RAM, and model context window. Parse the JSON output for `recommended_kb`, `headroom`, and `max_peak_context_kb`. 2. **Auto-detect via API config**: If the script is not available, try to detect the model name from `.agent.md` or config files (`.env`, `config.yaml`, etc.) and look up its context window. **Important**: Only read the specific lines needed (e.g., the model name line), not the entire file. Limit file reads to 10KB to prevent memory exhaustion. 3. **Manual override**: Check if `~/.automaton/config.md` has `Auto-detect: No` under VRAM Configuration. If so, use the manually specified values. 4. **Fallback**: Use 8k tokens as default, with 25% headroom. @@ -52,7 +52,7 @@ When model context window is needed, the Orchestrator MUST attempt to detect it #### Detection Priority -1. **Auto-detect via script**: Check if `{project}/.automaton/scripts/vram_detect.sh` exists. If it does, run it to detect the model name and its context window. Parse the JSON output for `model_context_kb`. +1. **Auto-detect via script**: Check if `{project}/.automaton/scripts/vram_detect.py` exists. If it does, run it to detect the model name and its context window. Parse the JSON output for `model_context_kb`. 2. **Auto-detect via config**: Check `~/.automaton/config.md` for the model name and override context window. 3. **Auto-detect via API config**: If the script is not available, try to detect the model name from `.agent.md` or config files (`.env`, `config.yaml`, etc.) and look up its context window. **Important**: Only read the specific lines needed (e.g., the model name line), not the entire file. Limit file reads to 10KB to prevent memory exhaustion. 4. **Fallback**: Use 128k tokens as default (common for modern models). @@ -372,7 +372,7 @@ When decomposing, the Orchestrator creates sub-tasks under the parent task's `su When a parent task reaches the **Decomposition** phase (has `SPEC.md` and `DECOMPOSITION.md`): 1. **Read `~/.automaton/config.md`** to get the VRAM configuration and check if auto-detect is enabled. -2. **If Auto-detect: Yes**, run `{project}/.automaton/scripts/vram_detect.sh` to detect VRAM limits. Parse the JSON output for `recommended_kb`, `headroom`, and `max_peak_context_kb`. Report the detection results. +2. **If Auto-detect: Yes**, run `{project}/.automaton/scripts/vram_detect.py` to detect VRAM limits. Parse the JSON output for `recommended_kb`, `headroom`, and `max_peak_context_kb`. Report the detection results. 3. **If Auto-detect: No**, use the manually specified values from config.md. 4. **Read `DECOMPOSITION.md`** to extract all sub-task names, dependencies, and their estimated token budgets. 5. **Verify VRAM constraints**: diff --git a/prompts/referee.md b/prompts/referee.md index 7b23661..18f1007 100644 --- a/prompts/referee.md +++ b/prompts/referee.md @@ -2,16 +2,16 @@ You are the Referee. Your job is to objectively evaluate whether the implementat ## Read These Files -1. {project}/tasks/{task-name}/SPEC.md -2. {project}/tasks/{task-name}/{task-name}_CONTRACT.md (if exists) -3. {project}/tasks/{task-name}/BUG_REPORT.md (if exists) -4. {project}/tasks/{task-name}/ADVERSARIAL_BUG_REPORT.md (if exists) -5. {project}/tasks/{task-name}/DOC_REVIEW.md (if exists) -6. {project}/tasks/{task-name}/IMPLEMENTATION.md (if exists) -7. {project}/tasks/{task-name}/DESIGN.md (if exists) -8. {project}/tasks/{task-name}/TEST_PLAN.md (if exists) -9. {project}/tasks/{task-name}/VRAM_CONFIG.md (if exists) -10. {project}/tasks/{task-name}/PARENT_SPEC.md (if exists) +1. {project}/.automaton/tasks/{task-name}/SPEC.md +2. {project}/.automaton/tasks/{task-name}/{task-name}_CONTRACT.md (if exists) +3. {project}/.automaton/tasks/{task-name}/BUG_REPORT.md (if exists) +4. {project}/.automaton/tasks/{task-name}/ADVERSARIAL_BUG_REPORT.md (if exists) +5. {project}/.automaton/tasks/{task-name}/DOC_REVIEW.md (if exists) +6. {project}/.automaton/tasks/{task-name}/IMPLEMENTATION.md (if exists) +7. {project}/.automaton/tasks/{task-name}/DESIGN.md (if exists) +8. {project}/.automaton/tasks/{task-name}/TEST_PLAN.md (if exists) +9. {project}/.automaton/tasks/{task-name}/VRAM_CONFIG.md (if exists) +10. {project}/.automaton/tasks/{task-name}/PARENT_SPEC.md (if exists) ## Task @@ -56,7 +56,7 @@ You are the Referee. Your job is to objectively evaluate whether the implementat ## Verdict -Produce a VERDICT.md at {project}/tasks/{task-name}/VERDICT.md with: +Produce a VERDICT.md at {project}/.automaton/tasks/{task-name}/VERDICT.md with: ```markdown # Verdict: {task-name} diff --git a/prompts/research.md b/prompts/research.md index 16e3be1..3d651fd 100644 --- a/prompts/research.md +++ b/prompts/research.md @@ -80,7 +80,7 @@ Only produce the SPEC.md after the user says "APPROVED" or equivalent. ## Output -Produce a file called SPEC.md at {project}/tasks/{task-name}/SPEC.md that contains: +Produce a file called SPEC.md at {project}/.automaton/tasks/{task-name}/SPEC.md that contains: - Clear goal - Exact requirements (numbered) diff --git a/prompts/test_design.md b/prompts/test_design.md index b673d1d..558ec6f 100644 --- a/prompts/test_design.md +++ b/prompts/test_design.md @@ -4,8 +4,8 @@ Your only job is to produce a comprehensive, explicit test specification for the ## Read These Files -1. {project}/tasks/{task-name}/SPEC.md — Requirements and acceptance criteria -2. {project}/tasks/{task-name}/DESIGN.md — Architecture and data model (if exists) +1. {project}/.automaton/tasks/{task-name}/SPEC.md — Requirements and acceptance criteria +2. {project}/.automaton/tasks/{task-name}/DESIGN.md — Architecture and data model (if exists) 3. {project}/.automaton/.rules.md (if exists — project override) OR ~/.automaton/.rules.md (global default) — Project constraints ## Task @@ -77,7 +77,7 @@ Only produce the TEST_PLAN.md after the user says "APPROVED" or equivalent. ## Output -Produce a file called TEST_PLAN.md at {project}/tasks/{task-name}/TEST_PLAN.md that contains: +Produce a file called TEST_PLAN.md at {project}/.automaton/tasks/{task-name}/TEST_PLAN.md that contains: ```markdown # Test Plan: {task-name} diff --git a/prompts/workflow.md b/prompts/workflow.md index f50360a..cc2aa99 100644 --- a/prompts/workflow.md +++ b/prompts/workflow.md @@ -24,7 +24,7 @@ The Orchestrator is responsible for creating new task folders automatically — ### New tasks from user input When the Orchestrator detects a new task description: 1. Generate a kebab-case task name from the description -2. Create `{project}/tasks/{task-name}/` (empty — no artifact files) +2. Create `{project}/.automaton/tasks/{task-name}/` (empty — no artifact files) 3. Move the task to the **Research** phase The Orchestrator also scans for tasks that have `VERDICT.md` with `PASS` and removes them from the active task list (they can be archived but not auto-deleted). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ebfc3aa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "automaton" +version = "0.2.0" +description = "Automaton framework - contract-based operating system for LLM agents" +requires-python = ">=3.9" + +[project.optional-dependencies] +test = ["pytest>=7.0"] +dashboard = ["inotify>=0.2"] + +[project.scripts] +automaton-dashboard = "automaton.dashboard.__main__:main" + +[tool.setuptools.packages.find] +include = ["automaton*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/scripts/install.sh b/scripts/install.sh index 8934ccc..d8c7a3a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -10,7 +10,7 @@ if [ -d "$FRAMEWORK_DIR" ]; then fi echo "Cloning automaton to $FRAMEWORK_DIR..." -git clone https://gitea.yourdomain.com/you/automaton.git "$FRAMEWORK_DIR" +git clone http://10.37.0.86:3003/hermes/automaton "$FRAMEWORK_DIR" echo "" echo "=== VRAM / Context Detection ===" @@ -18,23 +18,23 @@ echo "Detecting your system's VRAM to recommend task decomposition settings..." echo "" # Run VRAM detection script if it exists -if [ -f "$FRAMEWORK_DIR/scripts/vram_detect.sh" ]; then +if [ -f "$FRAMEWORK_DIR/scripts/vram_detect.py" ]; then # Run in project-dir context so it can read framework overhead - detection_output=$(cd "$FRAMEWORK_DIR" && bash "$FRAMEWORK_DIR/scripts/vram_detect.sh" 2>&1) - - # Extract JSON output (last section after "=== JSON Output ===") - json_output=$(echo "$detection_output" | sed -n '/=== JSON Output ===/,/EOF/p' | grep -v '=== JSON Output ===' | grep -v '^EOF$') - + detection_output=$(cd "$FRAMEWORK_DIR" && python3 "$FRAMEWORK_DIR/scripts/vram_detect.py" 2>&1) + + # Extract JSON output (the block after "=== JSON Output ===") + json_output=$(echo "$detection_output" | sed -n '/=== JSON Output ===/,$p' | tail -n +2) + if [ -n "$json_output" ]; then echo "$detection_output" - - # Extract key values from JSON for display - recommended_k=$(echo "$json_output" | grep '"recommended_k"' | grep -oP '\d+') - max_peak_kb=$(echo "$json_output" | grep '"max_peak_context_kb"' | grep -oP '\d+') - headroom=$(echo "$json_output" | grep '"headroom"' | grep -oP '\d+\.\d+') - gpu_vram=$(echo "$json_output" | grep '"gpu_vram_gb"' | grep -oP '\d+') - ram_gb=$(echo "$json_output" | grep '"ram_gb"' | grep -oP '\d+') - model_context=$(echo "$json_output" | grep '"model_context_kb"' | grep -oP '\d+') + + # Extract key values from JSON using Python + recommended_k=$(echo "$json_output" | python3 -c 'import json,sys; print(json.load(sys.stdin)["recommended_k"])') + max_peak_kb=$(echo "$json_output" | python3 -c 'import json,sys; print(json.load(sys.stdin)["max_peak_context_kb"])') + headroom=$(echo "$json_output" | python3 -c 'import json,sys; print(json.load(sys.stdin)["headroom"])') + gpu_vram=$(echo "$json_output" | python3 -c 'import json,sys; print(json.load(sys.stdin)["gpu_vram_gb"])') + ram_gb=$(echo "$json_output" | python3 -c 'import json,sys; print(json.load(sys.stdin)["ram_gb"])') + model_context=$(echo "$json_output" | python3 -c 'import json,sys; print(json.load(sys.stdin)["model_context_kb"])') echo "" echo "=== Recommended VRAM Configuration ===" diff --git a/scripts/update.sh b/scripts/update.sh index 90a4d68..922653f 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -19,6 +19,13 @@ if [ ! -d ".git" ]; then exit 1 fi +# Check for uncommitted changes +if ! git diff --quiet HEAD || ! git diff --cached --quiet HEAD; then + echo "ERROR: $FRAMEWORK_DIR has uncommitted changes." + echo "Commit or stash them before updating to avoid losing work." + exit 1 +fi + # Fetch and pull latest changes git pull origin main diff --git a/scripts/vram_detect.py b/scripts/vram_detect.py new file mode 100755 index 0000000..2d4deb0 --- /dev/null +++ b/scripts/vram_detect.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +"""VRAM/Context Detection Script for automaton. + +Detects GPU VRAM, system RAM, and model context window to recommend a safe +context window for task decomposition. + +Usage: + python vram_detect.py [model_name] [--model model_name] [--project project_dir] +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + +# Known model context windows in tokens. +MODEL_CONTEXT_WINDOWS: dict[str, int] = { + "gpt-4o": 128_000, + "gpt-4o-2024-05-13": 128_000, + "gpt-4o-2024-08-06": 128_000, + "gpt-4o-mini": 128_000, + "gpt-4o-mini-2024-07-18": 128_000, + "gpt-4-turbo": 128_000, + "gpt-4-turbo-2024-04-09": 128_000, + "gpt-4": 128_000, + "gpt-4-0125-preview": 128_000, + "gpt-4-1106-preview": 128_000, + "claude-3-5-sonnet": 200_000, + "claude-3-5-sonnet-20241022": 200_000, + "claude-3-5-haiku": 200_000, + "claude-3-5-haiku-20241022": 200_000, + "claude-3-opus": 200_000, + "claude-3-opus-20240229": 200_000, + "claude-3-sonnet": 200_000, + "claude-3-sonnet-20240229": 200_000, + "claude-3-haiku": 200_000, + "claude-3-haiku-20240307": 200_000, + "claude-2": 200_000, + "claude-2.1": 200_000, +} + +DEFAULT_FALLBACK_CONTEXT_TOKENS = 128_000 +DEFAULT_HEADROOM_PCT = 25 +MAX_CONFIG_READ_BYTES = 10 * 1024 # 10KB limit per prompt requirement + + +def run_command(cmd: list[str], timeout: float = 5.0) -> Optional[str]: + """Run a command and return stdout, or None on failure.""" + if not shutil.which(cmd[0]): + return None + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + except (subprocess.TimeoutExpired, OSError): + return None + + +def detect_gpu_vram() -> tuple[int, int, int]: + """Detect GPU VRAM and return (total_vram_kb, vram_per_gpu_kb, num_gpus).""" + total_vram_kb = 0 + num_gpus = 0 + + # Try nvidia-smi first. + nvidia_output = run_command( + ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"] + ) + if nvidia_output: + lines = [line.strip() for line in nvidia_output.splitlines() if line.strip()] + if lines: + try: + vram_mb = int(lines[0]) + if vram_mb > 0: + total_vram_kb = vram_mb * 1024 + num_gpus = len(lines) + print("GPU: NVIDIA (nvidia-smi available)") + print(f"VRAM per GPU: {vram_mb // 1024}GB ({vram_mb} MB)") + print(f"Num GPUs: {num_gpus}") + except ValueError: + print("GPU: NVIDIA (nvidia-smi available but driver not responding)") + + # Fallback: lspci for AMD/others. + if total_vram_kb == 0: + lspci_output = run_command(["lspci"]) + if lspci_output and re.search(r"VGA|3D|Display", lspci_output, re.IGNORECASE): + print("GPU detected via lspci") + vram_mb = _detect_amd_vram_from_lspci() + if vram_mb > 0: + total_vram_kb = vram_mb * 1024 + num_gpus = 1 + print(f"VRAM: {vram_mb} MB ({vram_mb // 1024}GB)") + + vram_per_gpu_kb = total_vram_kb // num_gpus if num_gpus > 0 else 0 + return total_vram_kb, vram_per_gpu_kb, num_gpus + + +def _detect_amd_vram_from_lspci() -> int: + """Attempt to sum memory region sizes from lspci -vnn for VGA/3D/Display devices.""" + output = run_command(["lspci", "-vnn"]) + if not output: + return 0 + + total_mb = 0 + # Split into device blocks. Each block starts with a bus address like "71:00.0". + blocks = re.split(r"\n\n", output) + for block in blocks: + if not re.search(r"VGA|3D|Display", block, re.IGNORECASE): + continue + for line in block.splitlines(): + match = re.search(r"Memory at [0-9a-fA-Fx]+ \(.*\) \[size=(\d+)([MGK])\]", line) + if match: + value = int(match.group(1)) + unit = match.group(2).upper() + if unit == "G": + total_mb += value * 1024 + elif unit == "M": + total_mb += value + elif unit == "K": + total_mb += value // 1024 + return total_mb + + +def detect_ram() -> tuple[int, int]: + """Detect total and available RAM in KB.""" + meminfo = Path("/proc/meminfo") + if meminfo.exists(): + try: + text = meminfo.read_text(encoding="utf-8") + total_kb = _parse_meminfo_value(text, "MemTotal") + available_kb = _parse_meminfo_value(text, "MemAvailable") or total_kb + if total_kb > 0: + print(f"RAM: {total_kb // 1024 // 1024}GB total, {available_kb // 1024 // 1024}GB available") + return total_kb, available_kb + except (OSError, ValueError): + pass + + sysctl_output = run_command(["sysctl", "-n", "hw.memsize"]) + if sysctl_output: + try: + total_kb = int(sysctl_output) // 1024 + if total_kb > 0: + print(f"RAM: {total_kb // 1024 // 1024}GB total") + return total_kb, total_kb + except ValueError: + pass + + print("RAM: Could not detect") + return 0, 0 + + +def _parse_meminfo_value(text: str, key: str) -> int: + """Parse a value in KB from /proc/meminfo.""" + for line in text.splitlines(): + if line.startswith(key + ":"): + parts = line.split() + if len(parts) >= 2: + return int(parts[1]) + return 0 + + +def detect_model_context( + model_name: Optional[str] = None, + project_dir: Optional[Path] = None, +) -> int: + """Detect model context window in tokens.""" + if model_name: + return _lookup_model_context(model_name) + + # Try config.md (global framework model settings). + config_md = Path.home() / ".automaton" / "config.md" + model_from_config, override_context = _parse_config_model(config_md) + if model_from_config: + print(f"Found model in config.md: {model_from_config}") + if override_context and override_context != "auto": + print(f"Using override context window from config.md: {override_context}") + return override_context + return _lookup_model_context(model_from_config) + + # Try .agent.md (project-level model override). + if project_dir: + agent_md = project_dir / ".automaton" / ".agent.md" + model_from_agent, _ = _parse_config_model(agent_md) + if model_from_agent: + print(f"Found model in .agent.md: {model_from_agent}") + if override_context and override_context != "auto": + print(f"Using override context window from config.md: {override_context}") + return override_context + return _lookup_model_context(model_from_agent) + + # Try common API config files. + config_files = [ + ".env", + ".env.local", + "config.yaml", + "config.yml", + "config.json", + "settings.yaml", + ".automaton/config.yaml", + ".automaton/config.json", + ] + if project_dir: + for config_file in config_files: + for candidate in [project_dir / config_file, project_dir / ".automaton" / config_file]: + if candidate.exists(): + model = _extract_model_from_file(candidate) + if model: + print(f"Found model in {candidate}: {model}") + if override_context and override_context != "auto": + print(f"Using override context window from config.md: {override_context}") + return override_context + return _lookup_model_context(model) + + print("Model: Unknown (could not detect from .agent.md or config files)") + return 0 + + +def _lookup_model_context(model_name: str) -> int: + """Look up context window for a known model name.""" + # Strip common version/date suffixes for lookup. + for key in MODEL_CONTEXT_WINDOWS: + if model_name.lower().startswith(key.lower()): + print(f"Model: {model_name}") + print(f"Context window: {MODEL_CONTEXT_WINDOWS[key] // 1000}k tokens") + return MODEL_CONTEXT_WINDOWS[key] + print(f"Model: {model_name} (unknown context window)") + return 0 + + +def _parse_config_model(config_path: Path) -> tuple[Optional[str], Optional[int]]: + """Parse model name and override context window from a config file.""" + if not config_path.exists(): + return None, None + try: + text = config_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None, None + + model_name = None + override_tokens: Optional[int] = None + in_code_block = False + + for raw_line in text.splitlines(): + line = raw_line.strip() + if line.startswith("```"): + in_code_block = not in_code_block + continue + if in_code_block: + continue + if line.startswith("#"): + continue + if re.search(r"^[-*]?\s*\*\*Model\*\*:|^\s*model\s*[:=]", line, re.IGNORECASE): + if "override" in line.lower(): + continue + value = _extract_value(line) + if value and value.lower() != "auto": + model_name = value + if re.search(r"^[-*]?\s*\*\*Override context( window)?\*\*:|^\s*override.*context\s*[:=]", line, re.IGNORECASE): + value = _extract_value(line) + if value and value.lower() != "auto": + override_tokens = _parse_token_value(value) + + return model_name, override_tokens + + +def _extract_model_from_file(path: Path) -> Optional[str]: + """Extract model name from an API config file, reading at most 10KB.""" + try: + raw = path.read_bytes() + text = raw[:MAX_CONFIG_READ_BYTES].decode("utf-8", errors="replace") + except OSError: + return None + + for line in text.splitlines(): + line = line.strip() + if line.startswith("#"): + continue + if re.search(r"model\s*[:=]", line, re.IGNORECASE): + # Skip non-model keys like max_tokens, temperature, stream. + key = line.split("=", 1)[0].split(":", 1)[0].strip().lower() + if any(bad in key for bad in ["context", "max_tokens", "temperature", "stream"]): + continue + value = _extract_value(line) + if value and value.lower() != "auto": + return value + return None + + +def _extract_value(line: str) -> Optional[str]: + """Extract the value after ':' or '=' from a key-value line.""" + for sep in [":", "="]: + if sep in line: + value = line.split(sep, 1)[1].strip() + value = value.split("#", 1)[0].strip() + value = value.strip('"').strip("'") + return value if value else None + return None + + +def _parse_token_value(value: str) -> Optional[int]: + """Parse a token value like '128k', '5.6k', or '128000' into an integer.""" + value = value.strip().lower() + match = re.search(r"(\d+(?:\.\d+)?)\s*k?", value) + if not match: + return None + number = float(match.group(1)) + if "k" in value: + return int(number * 1000) + return int(number) + + +def calculate_overhead(project_dir: Optional[Path] = None) -> int: + """Estimate framework overhead in tokens.""" + if project_dir: + base_files = [ + project_dir / ".automaton" / ".agent.md", + project_dir / ".automaton" / ".rules.md", + project_dir / ".automaton" / "prompts" / "workflow.md", + project_dir / ".automaton" / "prompts" / "orchestrate.md", + ] + if not base_files[0].exists(): + base_files = [ + Path.home() / ".automaton" / ".agent.md", + Path.home() / ".automaton" / ".rules.md", + Path.home() / ".automaton" / "prompts" / "workflow.md", + Path.home() / ".automaton" / "prompts" / "orchestrate.md", + ] + else: + base_files = [ + Path.home() / ".automaton" / ".agent.md", + Path.home() / ".automaton" / ".rules.md", + Path.home() / ".automaton" / "prompts" / "workflow.md", + Path.home() / ".automaton" / "prompts" / "orchestrate.md", + ] + + overhead_tokens = 0 + for file_path in base_files: + if file_path.exists(): + try: + chars = len(file_path.read_text(encoding="utf-8", errors="replace")) + tokens = chars // 4 + overhead_tokens += tokens + print(f" {file_path.name}: ~{tokens} tokens") + except OSError: + pass + + print(f"Framework overhead: ~{overhead_tokens} tokens") + return overhead_tokens + + +def _iter_config_lines(text: str, section_header: str) -> list[str]: + """Return non-code-block lines within a markdown section.""" + lines: list[str] = [] + in_section = False + in_code_block = False + for raw_line in text.splitlines(): + line = raw_line.strip() + if line.startswith("```"): + in_code_block = not in_code_block + continue + if in_code_block: + continue + if line.startswith(section_header): + in_section = True + continue + if in_section and line.startswith("##"): + in_section = False + if not in_section: + continue + lines.append(line) + return lines + + +def parse_vram_config(config_path: Path) -> dict[str, object]: + """Parse VRAM configuration from config.md.""" + defaults: dict[str, object] = { + "auto_detect": True, + "target_context_kb": 0, + "headroom_pct": DEFAULT_HEADROOM_PCT, + "max_peak_kb": 0, + } + if not config_path.exists(): + return defaults + + try: + text = config_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return defaults + + for line in _iter_config_lines(text, "## VRAM Configuration"): + if line.startswith("#"): + continue + lower = line.lower() + if "auto-detect" in lower: + value = _extract_value(line) + if value: + defaults["auto_detect"] = value.lower() in ("yes", "true", "1") + elif "target" in lower and "context" in lower: + value = _extract_value(line) + if value: + parsed = _parse_token_value(value) + if parsed: + defaults["target_context_kb"] = parsed + elif "headroom" in lower: + value = _extract_value(line) + if value: + match = re.search(r"(\d+)", value) + if match: + defaults["headroom_pct"] = int(match.group(1)) + elif "max peak" in lower: + value = _extract_value(line) + if value: + parsed = _parse_token_value(value) + if parsed: + defaults["max_peak_kb"] = parsed + + return defaults + + +def recommend_context( + gpu_vram_gb: int, + ram_gb: int, + model_context_kb: int, + overhead_tokens: int, + config: dict[str, object], +) -> tuple[int, int, int]: + """Return (headroom_pct, recommended_kb, max_peak_kb).""" + headroom_pct = int(config.get("headroom_pct", DEFAULT_HEADROOM_PCT)) + + # Manual override mode. + if not config.get("auto_detect", True): + target_kb = int(config.get("target_context_kb", 0)) + max_peak_kb = int(config.get("max_peak_kb", 0)) + if target_kb > 0: + if max_peak_kb == 0 and headroom_pct > 0: + max_peak_kb = target_kb * (100 - headroom_pct) // 100 + return headroom_pct, target_kb, max_peak_kb + + recommended_kb = 0 + + if gpu_vram_gb >= 4: + # Conservative: 1GB VRAM ≈ 2k context tokens. + vram_context_kb = gpu_vram_gb * 2000 + recommended_kb = vram_context_kb * (100 - headroom_pct) // 100 + elif model_context_kb > 0: + recommended_kb = model_context_kb * (100 - headroom_pct) // 100 + else: + # RAM fallback: 0.75k tokens per GB. + ram_context_kb = ram_gb * 750 + recommended_kb = ram_context_kb * (100 - headroom_pct) // 100 + + # Subtract framework overhead. + net_kb = max(0, recommended_kb - overhead_tokens) + + # Calculate max peak context based on headroom. + max_peak_kb = net_kb * (100 - headroom_pct) // 100 + + return headroom_pct, net_kb, max_peak_kb + + +def main() -> int: + parser = argparse.ArgumentParser(description="Detect VRAM/context for automaton") + parser.add_argument("model", nargs="?", help="Model name") + parser.add_argument("--model", "-m", dest="model_flag", help="Model name") + parser.add_argument("--project", "-p", type=Path, help="Project directory") + args = parser.parse_args() + + model_name = args.model_flag or args.model + project_dir = args.project.resolve() if args.project else None + + print("=== VRAM / Context Detection ===\n") + + print("--- GPU VRAM ---") + total_vram_kb, vram_per_gpu_kb, num_gpus = detect_gpu_vram() + gpu_vram_gb = total_vram_kb // 1024 // 1024 + print(f"Total VRAM: {gpu_vram_gb}GB") + print(f"VRAM per GPU: {vram_per_gpu_kb // 1024 // 1024}GB") + print(f"Num GPUs: {num_gpus}\n") + + print("--- RAM ---") + ram_kb, _ = detect_ram() + ram_gb = ram_kb // 1024 // 1024 + print(f"RAM: {ram_gb}GB\n") + + print("--- Model Context Window ---") + model_context_kb = detect_model_context(model_name, project_dir) + print() + + print("--- Framework Overhead ---") + overhead_tokens = calculate_overhead(project_dir) + print() + + print("--- Recommendation ---") + config_path = Path.home() / ".automaton" / "config.md" + config = parse_vram_config(config_path) + headroom_pct, recommended_kb, max_peak_kb = recommend_context( + gpu_vram_gb, ram_gb, model_context_kb, overhead_tokens, config + ) + + recommended_k = recommended_kb // 1000 if recommended_kb > 0 else 8 + max_peak_k = max_peak_kb // 1000 if max_peak_kb > 0 else 6 + + print(f"Target context: {recommended_k}k tokens") + print(f"Headroom: {headroom_pct}%") + print(f"Max peak context per sub-task: {max_peak_k}k tokens") + + print("\n=== JSON Output ===") + output = { + "gpu_vram_gb": gpu_vram_gb, + "ram_gb": ram_gb, + "model_context_kb": model_context_kb, + "framework_overhead_tokens": overhead_tokens, + "recommended_kb": recommended_kb, + "recommended_k": recommended_k, + "headroom": headroom_pct / 100.0, + "max_peak_context_kb": max_peak_kb, + } + print(json.dumps(output, indent=4)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/vram_detect.sh b/scripts/vram_detect.sh deleted file mode 100755 index 0fcb536..0000000 --- a/scripts/vram_detect.sh +++ /dev/null @@ -1,549 +0,0 @@ -#!/usr/bin/env bash -# VRAM/Context Detection Script -# Detects GPU VRAM, system RAM, and model context window to recommend -# a safe VRAM context window for task decomposition. -# -# Usage: ./vram_detect.sh [model_name] -# - If model_name is provided, looks up its context window -# - Otherwise, tries to detect from API config or config.md - -set -uo pipefail # Don't exit on error - we want to continue even if detection fails - -# ─── GPU VRAM Detection ─── -detect_gpu_vram() { - local total_vram_kb=0 - local vram_per_gpu_kb=0 - local num_gpus=0 - - # Try nvidia-smi first (NVIDIA GPUs) - if command -v nvidia-smi &>/dev/null; then - local vram_kb - # Use timeout to avoid hanging on nvidia-smi (e.g., driver not loaded) - vram_kb=$(timeout 5 nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -d ' ' || true) - # Validate that vram_kb is a positive number - if [[ -n "$vram_kb" && "$vram_kb" =~ ^[0-9]+$ && "$vram_kb" -gt 0 ]]; then - total_vram_kb=$((vram_kb * 1024)) # MB → KB - vram_per_gpu_kb=$((total_vram_kb / (num_gpus+1))) - num_gpus=1 - echo "GPU: NVIDIA (nvidia-smi available)" - echo "VRAM per GPU: $((vram_kb / 1024))GB ($vram_kb MB)" - else - echo "GPU: NVIDIA (nvidia-smi available but driver not responding)" - fi - fi - - # Fallback: lspci - if [[ $total_vram_kb -eq 0 && $num_gpus -eq 0 ]]; then - local gpu_info - gpu_info=$(lspci 2>/dev/null | grep -i -E 'VGA|3D|Display' | head -5) - if [[ -n "$gpu_info" ]]; then - echo "GPU detected: $gpu_info" - # Try to get VRAM from lspci -vnn memory regions - # GPUs show VRAM as Memory regions in lspci - # Parse patterns like: Memory at f800000000 (64-bit, prefetchable) [size=256M] - local total_vram_mb=0 - while IFS= read -r line; do - # Extract the size value from [size=256M] pattern - local size_num - size_num=$(echo "$line" | grep -oE '[0-9]+(M|G|K)' | head -1 || true) - if [[ -n "$size_num" ]]; then - local size_val - size_val=$(echo "$size_num" | grep -oE '[0-9]+') - local size_unit - size_unit=$(echo "$size_num" | grep -oE '(M|G|K)') - if [[ -n "$size_val" && -n "$size_unit" ]]; then - case "$size_unit" in - M) total_vram_mb=$((total_vram_mb + size_val)) ;; - G) total_vram_mb=$((total_vram_mb + size_val * 1024)) ;; - K) total_vram_mb=$((total_vram_mb + size_val / 1024)) ;; - esac - fi - fi - done < <(lspci -vnn 2>/dev/null | grep -i -A 15 -E 'VGA|3D|Display' | grep -i 'Memory at') - if [[ $total_vram_mb -gt 0 ]]; then - local total_vram_gb=$((total_vram_mb / 1024)) - local total_vram_mb_remain=$((total_vram_mb % 1024)) - echo "VRAM: $total_vram_mb MB ($total_vram_gb GB $total_vram_mb_remain MB)" - else - echo "VRAM: Could not determine from lspci" - fi - # Check for AMD GPU via amdgpu sysfs - if lspci -vnn 2>/dev/null | grep -qi 'amd\|ati'; then - local amdgpu_info - amdgpu_info=$(ls /sys/kernel/debug/amdgpu/ 2>/dev/null | head -1) - if [[ -n "$amdgpu_info" ]]; then - local vram_total - vram_total=$(cat /sys/kernel/debug/amdgpu/${amdgpu_info}/vram_total 2>/dev/null || echo 0) - if [[ "$vram_total" -gt 0 ]]; then - local vram_gb=$((vram_total / 1024 / 1024 / 1024)) - local vram_mb=$((vram_total / 1024 / 1024)) - echo "AMD GPU VRAM: ${vram_gb}GB (${vram_mb}MB)" - fi - fi - fi - fi - fi - - echo "Total VRAM: $((total_vram_kb / 1024 / 1024))GB" - echo "VRAM per GPU: $((vram_per_gpu_kb / 1024 / 1024))GB" - echo "Num GPUs: $num_gpus" -} - -# ─── System RAM Detection ─── -detect_ram() { - local total_kb=0 - local available_kb=0 - - if [[ -f /proc/meminfo ]]; then - total_kb=$(grep MemTotal /proc/meminfo | awk '{print $2}') - available_kb=$(grep MemAvailable /proc/meminfo | awk '{print $2}') - if [[ $total_kb -gt 0 ]]; then - echo "RAM: $((total_kb / 1024 / 1024))GB total, $((available_kb / 1024 / 1024))GB available" - echo "$available_kb $total_kb" - fi - elif command -v sysctl &>/dev/null; then - total_kb=$(sysctl -n hw.memsize 2>/dev/null | awk '{print $1 / 1024}') - if [[ -n "$total_kb" && "$total_kb" -gt 0 ]]; then - echo "RAM: $((total_kb / 1024))GB total" - echo "$total_kb $total_kb" # Assume all available - fi - else - echo "RAM: Could not detect" - echo "0 0" - fi -} - -# ─── Model Context Window Detection ─── -detect_model_context() { - local model_name="$1" - local context_kb=0 - - # If model name provided, look it up - if [[ -n "$model_name" ]]; then - case "$model_name" in - gpt-4o|gpt-4o-2024-05-13|gpt-4o-2024-08-06) - context_kb=128000; echo "Model: $model_name" - echo "Context window: 128k tokens" - ;; - gpt-4o-mini|gpt-4o-mini-2024-07-18) - context_kb=128000; echo "Model: $model_name" - echo "Context window: 128k tokens" - ;; - gpt-4-turbo|gpt-4-turbo-2024-04-09) - context_kb=128000; echo "Model: $model_name" - echo "Context window: 128k tokens" - ;; - gpt-4|gpt-4-0125-preview|gpt-4-1106-preview) - context_kb=128000; echo "Model: $model_name" - echo "Context window: 128k tokens" - ;; - claude-3-5-sonnet|claude-3-5-sonnet-20241022) - context_kb=200000; echo "Model: $model_name" - echo "Context window: 200k tokens" - ;; - claude-3-5-haiku|claude-3-5-haiku-20241022) - context_kb=200000; echo "Model: $model_name" - echo "Context window: 200k tokens" - ;; - claude-3-opus|claude-3-opus-20240229) - context_kb=200000; echo "Model: $model_name" - echo "Context window: 200k tokens" - ;; - claude-3-sonnet|claude-3-sonnet-20240229) - context_kb=200000; echo "Model: $model_name" - echo "Context window: 200k tokens" - ;; - claude-3-haiku|claude-3-haiku-20240307) - context_kb=200000; echo "Model: $model_name" - echo "Context window: 200k tokens" - ;; - claude-2|claude-2.1) - context_kb=200000; echo "Model: $model_name" - echo "Context window: 200k tokens" - ;; - *) - echo "Model: $model_name (unknown context window)" - echo "0" - ;; - esac - echo "$context_kb" - return - fi - - # Try to detect from config.md (global framework model settings) - local project_dir="${1:-.}" - local config_md="${HOME}/.automaton/config.md" - local model_from_config="" - local override_context="" - if [[ -f "$config_md" ]]; then - # Check for model name - model_from_config=$(grep -i "model:" "$config_md" 2>/dev/null | grep -v "#" | grep -v "model_context" | grep -v "override" | head -1 | sed -E 's/.*[:=[:space:]]+//i' | tr -d '[:space:]') - # Check for override context window - override_context=$(grep -i "override context" "$config_md" 2>/dev/null | grep -v "#" | head -1 | sed -E 's/.*[:=[:space:]]+//i' | tr -d '[:space:]') - fi - - # If config.md specifies a model, use it - if [[ -n "$model_from_config" ]]; then - echo "Found model in config.md: $model_from_config" - # Use override context window if specified in config.md - if [[ -n "$override_context" && "$override_context" != "auto" ]]; then - echo "Using override context window from config.md: $override_context" - local override_kb - override_kb=$(echo "$override_context" | sed 's/[kK]$//' | grep -oP '[0-9]+' || true) - if [[ -n "$override_kb" ]]; then - echo "Context window: ${override_context} tokens (override)" - echo "$((override_kb * 1000))" - return - fi - fi - detect_model_context "$model_from_config" - return - fi - - # Try to detect from .agent.md (project-level model override) - local agent_md="${project_dir}/.automaton/.agent.md" - if [[ -f "$agent_md" ]]; then - local model_line - model_line=$(grep -i "model" "$agent_md" 2>/dev/null | grep -v "#" | grep -v "target" | grep -v "headroom" | grep -v "peak" | grep -v "Auto-detect" | head -1) - if [[ -n "$model_line" ]]; then - echo "Found model in .agent.md: $model_line" - # Extract model name from the line - local model - model=$(echo "$model_line" | sed -E 's/.*[:=[:space:]]+//i' | tr -d '[:space:]') - if [[ -n "$model" ]]; then - # Use override context window if specified in config.md - if [[ -n "$override_context" && "$override_context" != "auto" ]]; then - echo "Using override context window from config.md: $override_context" - # Convert override_context to kb (e.g., 128k -> 128000, 200k -> 200000) - local override_kb - override_kb=$(echo "$override_context" | sed 's/[kK]$//' | grep -oP '[0-9]+' || true) - if [[ -n "$override_kb" ]]; then - echo "Context window: ${override_context} tokens (override)" - echo "$((override_kb * 1000))" - return - fi - fi - detect_model_context "$model" - return - fi - fi - fi - - # Try to detect from common API config files - local config_files=( - ".env" - ".env.local" - "config.yaml" - "config.yml" - "config.json" - "settings.yaml" - ".automaton/config.yaml" - ".automaton/config.json" - ) - - for config_file in "${config_files[@]}"; do - local abs_file="" - for candidate in "${project_dir}/${config_file}" "${project_dir}/.automaton/${config_file}"; do - if [[ -f "$candidate" ]]; then - abs_file="$candidate" - break - fi - done - - if [[ -n "$abs_file" ]]; then - local model - model=$(grep -i "model" "$abs_file" 2>/dev/null | grep -v "#" | grep -v "context" | grep -v "max_tokens" | grep -v "temperature" | grep -v "stream" | head -1 | sed -E 's/.*[:=[:space:]]+//i' | tr -d '[:space:]') - if [[ -n "$model" ]]; then - echo "Found model in $abs_file: $model" - # Use override context window if specified in config.md - if [[ -n "$override_context" && "$override_context" != "auto" ]]; then - echo "Using override context window from config.md: $override_context" - local override_kb - override_kb=$(echo "$override_context" | sed 's/[kK]$//' | grep -oP '[0-9]+' || true) - if [[ -n "$override_kb" ]]; then - echo "Context window: ${override_context} tokens (override)" - echo "$((override_kb * 1000))" - return - fi - fi - detect_model_context "$model" - return - fi - fi - done - - echo "Model: Unknown (could not detect from .agent.md or config files)" - echo "0" -} - -# ─── Agent Framework Overhead Calculation ─── -calculate_overhead() { - local project_dir="${1:-.}" - local overhead_tokens=0 - - # Count tokens for the framework files that are loaded during orchestration - # These are the files loaded during the most common phase (orchestration): - # .agent.md + .rules.md + workflow.md + orchestrate.md - # Note: other phase files (decompose.md, implement.md, etc.) are only loaded during - # their specific phases, so they don't contribute to the peak context during orchestration. - local framework_files=( - "${project_dir}/.automaton/.agent.md" - "${project_dir}/.automaton/.rules.md" - "${project_dir}/.automaton/prompts/workflow.md" - "${project_dir}/.automaton/prompts/orchestrate.md" - ) - # Fallback: check home directory if project dir doesn't have framework - if [[ ! -f "${project_dir}/.automaton/.agent.md" ]]; then - framework_files=( - "${HOME}/.automaton/.agent.md" - "${HOME}/.automaton/.rules.md" - "${HOME}/.automaton/prompts/workflow.md" - "${HOME}/.automaton/prompts/orchestrate.md" - ) - fi - - for file in "${framework_files[@]}"; do - if [[ -f "$file" ]]; then - # Rough estimate: 1 token ≈ 4 characters (English text) - local chars - chars=$(wc -c < "$file" 2>/dev/null || echo 0) - local tokens=$((chars / 4)) - overhead_tokens=$((overhead_tokens + tokens)) - echo " ${file##*/}: ~${tokens} tokens" - fi - done - - echo "Framework overhead: ~${overhead_tokens} tokens" - echo "$overhead_tokens" -} - -# ─── Recommendation Engine ─── -recommend_context() { - local gpu_vram_gb="$1" - local ram_gb="$2" - local model_context_kb="$3" - local overhead_tokens="$4" - - # Read VRAM config from config.md if it exists - local config_md="${HOME}/.automaton/config.md" - local auto_detect="Yes" - local target_context_kb=0 - local override_headroom=25 - local override_max_peak_kb=0 - if [[ -f "$config_md" ]]; then - auto_detect=$(grep -i "auto-detect:" "$config_md" 2>/dev/null | grep -v "#" | grep -i "vram" | head -1 | sed -E 's/.*[:=[:space:]]+//i' | tr -d '[:space:]' || true) - target_context_kb=$(grep -i "target.*context" "$config_md" 2>/dev/null | grep -v "#" | grep -i "vram" | head -1 | sed -E 's/.*[:=[:space:]]+//i' | grep -oP '\d+' || true) - override_headroom=$(grep -i "headroom" "$config_md" 2>/dev/null | grep -v "#" | grep -i "vram" | head -1 | sed -E 's/.*[:=[:space:]]+//i' | grep -oP '\d+' || true) - override_max_peak_kb=$(grep -i "max peak" "$config_md" 2>/dev/null | grep -v "#" | grep -i "vram" | head -1 | sed -E 's/.*[:=[:space:]]+//i' | grep -oP '\d+' || true) - fi - - # If auto-detect is disabled, use the manually specified values - if [[ -n "$auto_detect" && "$auto_detect" == "No" ]]; then - if [[ -n "$target_context_kb" ]]; then - local max_peak_kb=${override_max_peak_kb:-0} - if [[ $max_peak_kb -eq 0 && $headroom_pct -gt 0 ]]; then - max_peak_kb=$((target_context_kb * (100 - headroom_pct) / 100)) - fi - echo "$headroom_pct" - echo "$target_context_kb" - echo "$max_peak_kb" - return - fi - fi - - local recommended_kb=0 - local headroom_pct=${override_headroom:-25} # Use override from config.md, or default to 25% - - # Output headroom_pct first (for parent to read) - # Then output recommended_kb - # Then output max_peak_kb (only for manual mode) - echo "$headroom_pct" - - # Recommendation logic: - # 1. If GPU VRAM >= 4GB: use VRAM (practical for local inference) - # 2. If model context window is available: use it (for API inference) - # 3. If GPU VRAM < 4GB but > 0: use RAM (VRAM too small for local inference) - # 4. If no GPU VRAM and no model: use RAM as fallback - - # If GPU VRAM >= 4GB, base it on VRAM - if [[ $gpu_vram_gb -ge 4 ]]; then - # Rule of thumb: 1GB VRAM ≈ 4k tokens for local LLMs - # But we need to leave room for the model itself - # For a model, each ~8k context tokens takes about ~3-5MB of GPU VRAM - # So VRAM available for context = VRAM - model size - agent overhead - # Conservative: 1GB VRAM ≈ 2k context tokens - local vram_context_kb=$((gpu_vram_gb * 2000)) - - # Leave headroom for the model itself and agent overhead - recommended_kb=$((vram_context_kb * (100 - headroom_pct) / 100)) - # If model context window is available, use it (for API inference) - elif [[ $model_context_kb -gt 0 ]]; then - # For API-based, we're limited by the model's context window - # But we don't want to use the full window due to overhead - recommended_kb=$((model_context_kb * (100 - headroom_pct) / 100)) - # Fallback: use RAM to estimate - else - # Moderate estimate for low-VRAM systems where VRAM is too small for local inference - # but RAM is available. Use 0.75k tokens per GB of RAM as a moderate estimate. - # This balances between being too conservative (0.5k/GB) and too generous (1k/GB). - local ram_context_kb=$((ram_gb * 750)) - recommended_kb=$((ram_context_kb * (100 - headroom_pct) / 100)) - fi - - # Subtract framework overhead - local net_kb=$((recommended_kb - overhead_tokens)) - if [[ $net_kb -lt 0 ]]; then - net_kb=0 - fi - - # Output: headroom_pct, recommended_kb - echo "$net_kb" -} - -# ─── Main ─── -main() { - local model_name="" - local project_dir="." - - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - --model|-m) - model_name="$2" - shift 2 - ;; - --project|-p) - project_dir="$2" - shift 2 - ;; - *) - # Could be model name as first argument - if [[ -z "$model_name" ]]; then - model_name="$1" - fi - shift - ;; - esac - done - - echo "=== VRAM / Context Detection ===" - echo "" - - # Detect GPU VRAM - echo "--- GPU VRAM ---" - detect_gpu_vram - local gpu_vram_kb=0 - local gpu_vram_gb=0 - # Use timeout to avoid hanging - gpu_vram_kb=$(timeout 5 nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -d ' ' || true) - # Validate that vram_kb is a positive number - if [[ -z "$gpu_vram_kb" || ! "$gpu_vram_kb" =~ ^[0-9]+$ || "$gpu_vram_kb" -le 0 ]]; then - gpu_vram_kb=0 - fi - # If nvidia-smi didn't work, try to detect from lspci (AMD GPUs) - if [[ $gpu_vram_kb -eq 0 ]]; then - echo " nvidia-smi failed, checking lspci for AMD GPU VRAM..." - local total_vram_mb=0 - while IFS= read -r line; do - local size_num - size_num=$(echo "$line" | grep -oE '[0-9]+(M|G|K)' | head -1 || true) - if [[ -n "$size_num" ]]; then - local size_val - size_val=$(echo "$size_num" | grep -oE '[0-9]+') - local size_unit - size_unit=$(echo "$size_num" | grep -oE '(M|G|K)') - if [[ -n "$size_val" && -n "$size_unit" ]]; then - case "$size_unit" in - M) total_vram_mb=$((total_vram_mb + size_val)) ;; - G) total_vram_mb=$((total_vram_mb + size_val * 1024)) ;; - K) total_vram_mb=$((total_vram_mb + size_val / 1024)) ;; - esac - fi - fi - done < <(lspci -vnn 2>/dev/null | grep -i -A 15 -E 'VGA|3D|Display' | grep -i 'Memory at') - if [[ $total_vram_mb -gt 0 ]]; then - gpu_vram_kb=$((total_vram_mb * 1024)) - gpu_vram_gb=$((total_vram_mb / 1024)) - echo " AMD GPU VRAM from lspci: ${gpu_vram_gb}GB ($total_vram_mb MB)" - else - echo " No VRAM found from lspci" - fi - fi - echo "" - - # Detect RAM - echo "--- RAM ---" - detect_ram - local ram_kb - ram_kb=$(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}' || echo 0) - local ram_gb=$((ram_kb / 1024 / 1024)) - echo "" - - # Detect model context window - echo "--- Model Context Window ---" - detect_model_context "$model_name" - local model_context_kb - model_context_kb=$(detect_model_context "$model_name" | tail -1) - echo "" - - # Calculate framework overhead - echo "--- Framework Overhead ---" - calculate_overhead "$project_dir" - local overhead_tokens - overhead_tokens=$(calculate_overhead "$project_dir" | tail -1) - echo "" - - # Recommend context window (also outputs headroom_pct and recommended_kb) - echo "--- Recommendation ---" - local recommendation_output - recommendation_output=$(recommend_context "$gpu_vram_gb" "$ram_gb" "$model_context_kb" "$overhead_tokens") - local line_count - line_count=$(echo "$recommendation_output" | wc -l) - local recommended_kb - local headroom_pct - local max_peak_kb - if [[ $line_count -ge 3 ]]; then - # Manual mode: outputs headroom_pct, recommended_kb, max_peak_kb - headroom_pct=$(echo "$recommendation_output" | head -1 | tr -d '[:space:]') - recommended_kb=$(echo "$recommendation_output" | sed -n '2p' | tr -d '[:space:]') - max_peak_kb=$(echo "$recommendation_output" | tail -1 | tr -d '[:space:]') - else - # Auto-detect mode: outputs headroom_pct, recommended_kb - headroom_pct=$(echo "$recommendation_output" | head -1 | tr -d '[:space:]') - recommended_kb=$(echo "$recommendation_output" | tail -1 | tr -d '[:space:]') - # Calculate max peak context based on headroom - max_peak_kb=$((recommended_kb * (100 - headroom_pct) / 100)) - fi - - # Convert to human-readable - local recommended_k - if [[ $recommended_kb -gt 0 ]]; then - recommended_k=$((recommended_kb / 1000)) - else - recommended_k=8 # Default fallback - fi - - echo "" - echo "=== Recommended Configuration ===" - echo "Target context: ${recommended_k}k tokens" - echo "Headroom: ${headroom_pct}%" - echo "Max peak context per sub-task: $((recommended_k * (100 - headroom_pct) / 100))k tokens" - - # Output as JSON for programmatic use - echo "" - echo "=== JSON Output ===" - cat < None: + assert DashboardHandler._validate_task_name("good-task") is True + assert DashboardHandler._validate_task_name("bad/../task") is False + assert DashboardHandler._validate_task_name("bad\\task") is False + assert DashboardHandler._validate_task_name("") is False + + +def test_find_tasks_dir(tmp_path: Path) -> None: + (tmp_path / ".automaton" / "tasks").mkdir(parents=True) + tasks_dir = DashboardHandler._find_tasks_dir(tmp_path) + assert tasks_dir == tmp_path / ".automaton" / "tasks" + + +def test_find_tasks_dir_missing(tmp_path: Path) -> None: + tasks_dir = DashboardHandler._find_tasks_dir(tmp_path) + assert tasks_dir == tmp_path / ".automaton" / "tasks" + + +def test_path_traversal_attempt() -> None: + """Task names with path traversal should be rejected.""" + assert DashboardHandler._validate_task_name("../etc/passwd") is False + assert DashboardHandler._validate_task_name("task%2f..%2fetc") is False + + +def test_static_path_traversal_symlink(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Static file serving must reject symlinks that resolve outside the html directory.""" + html_dir = tmp_path / "html" + html_dir.mkdir() + outside = tmp_path / "secret.txt" + outside.write_text("secret") + symlink = html_dir / "link.txt" + symlink.symlink_to(outside) + + monkeypatch.setattr(DashboardHandler, "dashboard_path", html_dir) + + handler = DashboardHandler.__new__(DashboardHandler) + handler.path = "/link.txt" + errors: list[tuple[int, str]] = [] + + def capture_error(code: int, message: str) -> None: + errors.append((code, message)) + + handler._send_error = capture_error + handler._serve_static() + assert errors == [(403, "Forbidden")] + + +def test_static_valid_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Static file serving returns a valid html file.""" + html_dir = tmp_path / "html" + html_dir.mkdir() + (html_dir / "index.html").write_text("") + + monkeypatch.setattr(DashboardHandler, "dashboard_path", html_dir) + + handler = DashboardHandler.__new__(DashboardHandler) + handler.path = "/" + + response_status: list[int] = [] + response_headers: list[tuple[str, str]] = [] + + def fake_send_response(code: int) -> None: + response_status.append(code) + + def fake_send_header(key: str, value: str) -> None: + response_headers.append((key, value)) + + handler.send_response = fake_send_response + handler.send_header = fake_send_header + handler.end_headers = lambda: None + handler.wfile = io.BytesIO() + handler._send_error = lambda code, msg: None + + handler._serve_static() + assert response_status == [200] + assert any(h[0] == "Content-Type" and h[1] == "text/html" for h in response_headers) + assert handler.wfile.getvalue() == b"" diff --git a/tests/test_board.py b/tests/test_board.py new file mode 100644 index 0000000..7ffe582 --- /dev/null +++ b/tests/test_board.py @@ -0,0 +1,54 @@ +"""Tests for automaton.dashboard.core.board.""" + +from pathlib import Path + +import pytest + +from automaton.dashboard.core.board import KanbanBoard +from automaton.dashboard.core.task import determine_task_state + + +def _task(name: str, tmp_path: Path, artifacts: dict[str, str]) -> object: + from automaton.dashboard.core.task import Task + + task_dir = tmp_path / name + task_dir.mkdir(parents=True) + for filename, content in artifacts.items(): + (task_dir / filename).write_text(content) + state, artifact_map = determine_task_state(task_dir) + return Task(name=name, folder_path=task_dir, state=state, artifacts=artifact_map) + + +def test_board_columns(tmp_path: Path) -> None: + tasks = [ + _task("research", tmp_path, {"SPEC.md": "# Spec"}), + _task("done", tmp_path, {"VERDICT.md": "## Status: PASS"}), + ] + board = KanbanBoard(tasks) + assert board.columns[tasks[0].state] == [tasks[0]] + assert board.columns[tasks[1].state] == [tasks[1]] + + +def test_board_filter_by_phase(tmp_path: Path) -> None: + research = _task("research", tmp_path, {"SPEC.md": "# Spec"}) + done = _task("done", tmp_path, {"VERDICT.md": "## Status: PASS"}) + board = KanbanBoard([research, done]) + filtered = board.filter_columns(phase_filter=research.state) + assert research in filtered[research.state] + assert done not in filtered[research.state] + + +def test_board_search(tmp_path: Path) -> None: + apple = _task("apple-task", tmp_path, {"SPEC.md": "# Spec"}) + banana = _task("banana-task", tmp_path, {"SPEC.md": "# Spec"}) + board = KanbanBoard([apple, banana]) + filtered = board.filter_columns(search_query="apple") + assert apple in filtered[apple.state] + assert banana not in filtered[banana.state] + + +def test_board_wip_count(tmp_path: Path) -> None: + research = _task("research", tmp_path, {"SPEC.md": "# Spec"}) + done = _task("done", tmp_path, {"VERDICT.md": "## Status: PASS"}) + board = KanbanBoard([research, done]) + assert board.get_wip_count() == 1 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..6204b12 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,55 @@ +"""Tests for automaton.dashboard.config.""" + +import json +from pathlib import Path + +import pytest + +from automaton.dashboard.config import DashboardConfig, get_config_path + + +def test_config_defaults() -> None: + config = DashboardConfig() + assert config.auto_refresh_interval == 2 + assert config.default_view == "board" + assert config.column_width == 30 + assert config.show_timelines is True + assert config.theme == "default" + + +def test_config_validation() -> None: + config = DashboardConfig(auto_refresh_interval=0, default_view="invalid", column_width=5, theme="neon") + errors = config.validate() + assert any("auto_refresh_interval" in e for e in errors) + assert any("default_view" in e for e in errors) + assert any("column_width" in e for e in errors) + assert any("theme" in e for e in errors) + + +def test_config_from_dict() -> None: + config = DashboardConfig.from_dict({"theme": "dark", "column_width": 50}) + assert config.theme == "dark" + assert config.column_width == 50 + assert config.default_view == "board" # default preserved + + +def test_config_from_file(tmp_path: Path) -> None: + config_path = tmp_path / "dashboard-config.json" + config_path.write_text(json.dumps({"theme": "light", "auto_refresh_interval": 5})) + config = DashboardConfig.from_file(config_path) + assert config.theme == "light" + assert config.auto_refresh_interval == 5 + + +def test_config_save(tmp_path: Path) -> None: + config_path = tmp_path / "subdir" / "dashboard-config.json" + config = DashboardConfig(theme="dark") + config.save(config_path) + assert config_path.exists() + data = json.loads(config_path.read_text()) + assert data["theme"] == "dark" + + +def test_get_config_path(tmp_path: Path) -> None: + path = get_config_path(tmp_path) + assert path == tmp_path / ".automaton" / "dashboard-config.json" diff --git a/tests/test_prompt_paths.py b/tests/test_prompt_paths.py new file mode 100644 index 0000000..84b160b --- /dev/null +++ b/tests/test_prompt_paths.py @@ -0,0 +1,44 @@ +"""Regression test for task path conventions in prompts and templates.""" + +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +PROMPTS_DIR = ROOT / "prompts" +TEMPLATES_DIR = ROOT / "templates" + +# Legacy path pattern that should no longer appear. +LEGACY_PATH = re.compile(r"\{project\}/tasks/\{task-name\}/") +# Canonical path pattern that should be used instead. +CANONICAL_PATH = re.compile(r"\{project\}/\.automaton/tasks/\{task-name\}/") + + +def _markdown_files(*directories: Path) -> list[Path]: + files = [] + for directory in directories: + if directory.exists(): + files.extend(directory.rglob("*.md")) + return files + + +@pytest.mark.parametrize("path", _markdown_files(PROMPTS_DIR, TEMPLATES_DIR)) +def test_no_legacy_task_paths(path: Path) -> None: + """Every prompt/template must use the canonical .automaton/tasks path.""" + text = path.read_text(encoding="utf-8") + legacy_matches = LEGACY_PATH.findall(text) + assert not legacy_matches, ( + f"Found legacy task path in {path.relative_to(ROOT)}: {legacy_matches}\n" + "Use {project}/.automaton/tasks/{task-name}/ instead." + ) + + +def test_canonical_path_present_in_prompts() -> None: + """At least one prompt uses the canonical path (sanity check).""" + found = False + for path in _markdown_files(PROMPTS_DIR): + if CANONICAL_PATH.search(path.read_text(encoding="utf-8")): + found = True + break + assert found, "No prompt uses the canonical {project}/.automaton/tasks/{task-name}/ path" diff --git a/tests/test_scope.py b/tests/test_scope.py new file mode 100644 index 0000000..15ea67b --- /dev/null +++ b/tests/test_scope.py @@ -0,0 +1,62 @@ +"""Tests for automaton.dashboard.core.scope.""" + +from pathlib import Path + +import pytest + +from automaton.dashboard.core.scope import detect_scope, find_automaton_root + + +@pytest.fixture +def temp_project(tmp_path: Path) -> Path: + """Create a temporary automaton project.""" + (tmp_path / ".automaton").mkdir() + return tmp_path + + +@pytest.fixture +def nested_project(tmp_path: Path) -> Path: + """Create a nested automaton project.""" + project = tmp_path / "my-project" + (project / ".automaton").mkdir(parents=True) + return project + + +def test_find_automaton_root_in_project(temp_project: Path) -> None: + root = find_automaton_root(temp_project) + assert root == temp_project + + +def test_find_automaton_root_from_subdir(temp_project: Path) -> None: + subdir = temp_project / "src" / "deep" + subdir.mkdir(parents=True) + root = find_automaton_root(subdir) + assert root == temp_project + + +def test_find_automaton_root_not_found(tmp_path: Path) -> None: + root = find_automaton_root(tmp_path) + assert root is None + + +def test_detect_scope_project(temp_project: Path) -> None: + root, scope = detect_scope(temp_project) + assert root == temp_project + assert scope == "project" + + +def test_detect_scope_framework(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Framework mode is detected when .automaton is directly in home.""" + fake_home = tmp_path / "home" + fake_home.mkdir(parents=True) + (fake_home / ".automaton").mkdir() + monkeypatch.setattr(Path, "home", lambda: fake_home) + root, scope = detect_scope(fake_home) + assert root == fake_home + assert scope == "framework" + + +def test_detect_scope_none(tmp_path: Path) -> None: + root, scope = detect_scope(tmp_path) + assert root is None + assert scope == "none" diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..6407d2f --- /dev/null +++ b/tests/test_stats.py @@ -0,0 +1,56 @@ +"""Tests for automaton.dashboard.core.stats.""" + +from pathlib import Path + +import pytest + +from automaton.dashboard.core.stats import TaskStats +from automaton.dashboard.core.task import determine_task_state + + +def _task(name: str, tmp_path: Path, artifacts: dict[str, str]) -> object: + from automaton.dashboard.core.task import Task + + task_dir = tmp_path / name + task_dir.mkdir(parents=True) + for filename, content in artifacts.items(): + (task_dir / filename).write_text(content) + state, artifact_map = determine_task_state(task_dir) + return Task(name=name, folder_path=task_dir, state=state, artifacts=artifact_map) + + +def test_empty_stats() -> None: + stats = TaskStats([]) + assert stats.total_tasks == 0 + assert stats.pass_rate == 0.0 + assert stats.fail_rate == 0.0 + + +def test_counts(tmp_path: Path) -> None: + tasks = [ + _task("done-1", tmp_path, {"VERDICT.md": "## Status: PASS"}), + _task("done-2", tmp_path, {"VERDICT.md": "## Status: PASS"}), + _task("blocked", tmp_path, {"VERDICT.md": "## Status: FAIL"}), + _task("research", tmp_path, {"SPEC.md": "# Spec"}), + ] + stats = TaskStats(tasks) + assert stats.total_tasks == 4 + assert stats.pass_count == 2 + assert stats.fail_count == 1 + assert stats.in_progress_count == 1 + assert stats.backlog_count == 0 + assert stats.pass_rate == 50.0 + + +def test_sub_task_stats(tmp_path: Path) -> None: + from automaton.dashboard.core.task import SubTask, TaskState + + parent = _task("parent", tmp_path, {"SPEC.md": "# Spec"}) + parent.sub_tasks = [ + SubTask(name="a", state=TaskState.DONE, has_spec=True, has_verdict=True, verdict_status="PASS"), + SubTask(name="b", state=TaskState.BLOCKED, has_spec=True, has_verdict=True, verdict_status="FAIL"), + ] + stats = TaskStats([parent]) + assert stats.sub_task_stats == { + "parent": {"total": 2, "passed": 1, "failed": 1, "needs_review": 0, "incomplete": 0} + } diff --git a/tests/test_task.py b/tests/test_task.py new file mode 100644 index 0000000..b1da237 --- /dev/null +++ b/tests/test_task.py @@ -0,0 +1,134 @@ +"""Tests for automaton.dashboard.core.task.""" + +from pathlib import Path + +import pytest + +from automaton.dashboard.core.task import ( + determine_task_state, + discover_tasks, + parse_sub_tasks, + TaskState, +) + + +def _make_task(tmp_path: Path, name: str, artifacts: dict[str, str]) -> Path: + """Create a task folder with the given artifact contents.""" + task_dir = tmp_path / name + task_dir.mkdir(parents=True) + for filename, content in artifacts.items(): + (task_dir / filename).write_text(content) + return task_dir + + +def test_backlog_state(tmp_path: Path) -> None: + task_dir = _make_task(tmp_path, "backlog-task", {}) + state, artifacts = determine_task_state(task_dir) + assert state == TaskState.BACKLOG + assert not artifacts + + +def test_research_state(tmp_path: Path) -> None: + task_dir = _make_task(tmp_path, "research-task", {"SPEC.md": "# Spec"}) + state, artifacts = determine_task_state(task_dir) + assert state == TaskState.RESEARCH + assert "SPEC.md" in artifacts + + +def test_implementation_state(tmp_path: Path) -> None: + task_dir = _make_task( + tmp_path, + "impl-task", + {"SPEC.md": "# Spec", "IMPLEMENTATION.md": "# Impl"}, + ) + state, _ = determine_task_state(task_dir) + assert state == TaskState.IMPLEMENT + + +def test_bug_find_state(tmp_path: Path) -> None: + task_dir = _make_task( + tmp_path, + "bug-task", + {"SPEC.md": "# Spec", "IMPLEMENTATION.md": "# Impl", "BUG_REPORT.md": "# Bugs"}, + ) + state, _ = determine_task_state(task_dir) + assert state == TaskState.BUG_FIND + + +def test_adv_bug_find_state(tmp_path: Path) -> None: + task_dir = _make_task( + tmp_path, + "adv-task", + { + "SPEC.md": "# Spec", + "IMPLEMENTATION.md": "# Impl", + "BUG_REPORT.md": "# Bugs", + "ADVERSARIAL_BUG_REPORT.md": "# Adv", + }, + ) + state, _ = determine_task_state(task_dir) + assert state == TaskState.ADV_BUG_FIND + + +def test_done_state(tmp_path: Path) -> None: + task_dir = _make_task( + tmp_path, + "done-task", + {"SPEC.md": "# Spec", "VERDICT.md": "## Status: PASS"}, + ) + state, _ = determine_task_state(task_dir) + assert state == TaskState.DONE + + +def test_blocked_state(tmp_path: Path) -> None: + task_dir = _make_task( + tmp_path, + "blocked-task", + {"SPEC.md": "# Spec", "VERDICT.md": "## Status: FAIL"}, + ) + state, _ = determine_task_state(task_dir) + assert state == TaskState.BLOCKED + + +def test_empty_verdict_is_blocked(tmp_path: Path) -> None: + task_dir = _make_task(tmp_path, "empty-verdict", {"VERDICT.md": ""}) + state, _ = determine_task_state(task_dir) + assert state == TaskState.BLOCKED + + +def test_parse_sub_tasks(tmp_path: Path) -> None: + parent = tmp_path / "parent" + parent.mkdir() + sub = parent / "subtasks" / "sub-a" + sub.mkdir(parents=True) + (sub / "SPEC.md").write_text("# Spec") + (sub / "VERDICT.md").write_text("## Status: PASS") + + sub_tasks = parse_sub_tasks(parent) + assert len(sub_tasks) == 1 + assert sub_tasks[0].name == "sub-a" + assert sub_tasks[0].has_verdict is True + assert sub_tasks[0].verdict_status == "PASS" + + +def test_discover_tasks(tmp_path: Path) -> None: + _make_task(tmp_path, "aaa", {"SPEC.md": "# Spec"}) + _make_task(tmp_path, "zzz", {"VERDICT.md": "## Status: PASS"}) + tasks = discover_tasks(tmp_path) + assert len(tasks) == 2 + # Done task should be first (sorted by advancement). + assert tasks[0].state == TaskState.DONE + assert tasks[1].state == TaskState.RESEARCH + + +def test_discover_tasks_skips_subtasks_root(tmp_path: Path) -> None: + parent = tmp_path / "parent" + parent.mkdir() + (parent / "SPEC.md").write_text("# Spec") + (parent / "subtasks" / "sub-a").mkdir(parents=True) + (parent / "subtasks" / "sub-a" / "SPEC.md").write_text("# Sub") + + tasks = discover_tasks(tmp_path) + assert len(tasks) == 1 + assert tasks[0].name == "parent" + assert len(tasks[0].sub_tasks) == 1 diff --git a/tests/test_vram_detect.py b/tests/test_vram_detect.py new file mode 100644 index 0000000..2a6bff4 --- /dev/null +++ b/tests/test_vram_detect.py @@ -0,0 +1,110 @@ +"""Tests for scripts/vram_detect.py.""" + +from pathlib import Path + +import pytest + +import scripts.vram_detect as vram + + +def test_lookup_model_context() -> None: + assert vram._lookup_model_context("gpt-4o") == 128_000 + assert vram._lookup_model_context("claude-3-5-sonnet") == 200_000 + assert vram._lookup_model_context("unknown-model") == 0 + + +def test_parse_token_value() -> None: + assert vram._parse_token_value("128k") == 128_000 + assert vram._parse_token_value("128K") == 128_000 + assert vram._parse_token_value("128000") == 128_000 + assert vram._parse_token_value("nonsense") is None + + +def test_extract_value() -> None: + assert vram._extract_value("- **Model**: gpt-4o") == "gpt-4o" + assert vram._extract_value("model = gpt-4o # comment") == "gpt-4o" + assert vram._extract_value("- **Model**: auto") == "auto" + + +def test_parse_config_model(tmp_path: Path) -> None: + config = tmp_path / "config.md" + config.write_text( + "## VRAM Configuration\n" + "- **Auto-detect**: Yes\n" + "- **Target context**: 16k tokens\n" + "- **Headroom**: 25%\n" + "\n" + "## Model Configuration\n" + "- **Model**: gpt-4o\n" + "- **Override context window**: 128k\n" + ) + model, override = vram._parse_config_model(config) + assert model == "gpt-4o" + assert override == 128_000 + + +def test_parse_config_model_skips_code_blocks(tmp_path: Path) -> None: + """Example code blocks should not be parsed as live config.""" + config = tmp_path / "config.md" + config.write_text( + "## VRAM Configuration\n" + "- **Headroom**: 25%\n" + "```\n" + "- **Headroom**: 99%\n" + "```\n" + ) + parsed = vram.parse_vram_config(config) + assert parsed["headroom_pct"] == 25 + + +def test_parse_vram_config_manual(tmp_path: Path) -> None: + config = tmp_path / "config.md" + config.write_text( + "## VRAM Configuration\n" + "- **Auto-detect**: No\n" + "- **Target context**: 8k\n" + "- **Headroom**: 30%\n" + "- **Max peak context per sub-task**: 5.6k\n" + ) + parsed = vram.parse_vram_config(config) + assert parsed["auto_detect"] is False + assert parsed["target_context_kb"] == 8000 + assert parsed["headroom_pct"] == 30 + assert parsed["max_peak_kb"] == 5600 + + +def test_recommend_context_api_model() -> None: + config = {"auto_detect": True, "headroom_pct": 25, "target_context_kb": 0, "max_peak_kb": 0} + _, recommended_kb, max_peak_kb = vram.recommend_context( + gpu_vram_gb=0, + ram_gb=16, + model_context_kb=128_000, + overhead_tokens=4000, + config=config, + ) + assert recommended_kb > 0 + assert max_peak_kb > 0 + assert recommended_kb <= 128_000 * 0.75 # after headroom + + +def test_recommend_context_manual_mode() -> None: + config = {"auto_detect": False, "headroom_pct": 30, "target_context_kb": 8000, "max_peak_kb": 0} + headroom, recommended_kb, max_peak_kb = vram.recommend_context( + gpu_vram_gb=0, + ram_gb=16, + model_context_kb=0, + overhead_tokens=0, + config=config, + ) + assert headroom == 30 + assert recommended_kb == 8000 + assert max_peak_kb == 5600 + + +def test_extract_model_from_file_respects_10kb_limit(tmp_path: Path) -> None: + """Only the first 10KB of an API config file is scanned.""" + env_file = tmp_path / ".env" + # Put the model name far beyond 10KB. + env_file.write_text("x" * 11_000 + "\nMODEL=far-away-model\n") + model = vram._extract_model_from_file(env_file) + assert model is None