Compare commits
123 Commits
burn/20260
...
fix/596-di
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1813fcc996 | ||
| 2548277137 | |||
| 2b234fde79 | |||
| 04cceccd01 | |||
| 1ad2f2b239 | |||
| 04dbf772b1 | |||
| 697a273f0f | |||
| 9651a56308 | |||
| a84e6b517f | |||
| 31313c421e | |||
| 063572ed1e | |||
| 46b4f8d000 | |||
| e091868fef | |||
| e3a40be627 | |||
| efb2df8940 | |||
| cf687a5bfa | |||
|
|
c09e54de72 | ||
| 3214437652 | |||
| 95cd259867 | |||
| 5e7bef1807 | |||
| 3d84dd5c27 | |||
| e38e80661c | |||
|
|
b71e365ed6 | ||
| c0c34cbae5 | |||
|
|
8483a6602a | ||
| af9850080a | |||
|
|
d50296e76b | ||
| 34460cc97b | |||
| 9fdb8552e1 | |||
| 79f33e2867 | |||
| 28680b4f19 | |||
|
|
7630806f13 | ||
| 4ce9cb6cd4 | |||
| 24887b615f | |||
| 1e43776be1 | |||
| e53fdd0f49 | |||
| aeefe5027d | |||
| 989bc29c96 | |||
| d923b9e38a | |||
| 22c4bb57fe | |||
| 55fc678dc3 | |||
| 77a95d0ca1 | |||
| 9677785d8a | |||
| a5ac4cc675 | |||
| d801c5bc78 | |||
| 90dbd8212c | |||
| a1d1359deb | |||
| a91d7e5f4f | |||
| 92415ce18c | |||
| 3040938c46 | |||
| 99af3526ce | |||
| af3ba9d594 | |||
| 7813871296 | |||
| de83f1fda8 | |||
|
|
6863d9c0c5 | ||
|
|
b49a0abf39 | ||
|
|
72de3eebdf | ||
| f9388f6875 | |||
| 09aa06d65f | |||
| 8dc8bc4774 | |||
| fcf112cb1e | |||
| ce36d3813b | |||
| d4876c0fa5 | |||
| 8070536d57 | |||
| 438191c72e | |||
| 21e4039ec9 | |||
|
|
19aa0830f4 | ||
| f2edb6a9b3 | |||
| fc817c6a84 | |||
| a620bd19b3 | |||
| 0c98bce77f | |||
| c01e7f7d7f | |||
| 20bc0aa41a | |||
| b6c0620c83 | |||
| d43deb1d79 | |||
| 17de7f5df1 | |||
| 1dc29180b8 | |||
| 343e190cc3 | |||
| 932f48d06f | |||
| 0c7521d275 | |||
| bad31125c2 | |||
|
|
06031d923f | ||
| 7305d97e8f | |||
| 19e11b5287 | |||
| 03d53a644b | |||
| f2388733fb | |||
| 05e9c1bf51 | |||
| 186d5f8056 | |||
| 86914554f1 | |||
| a4665679ab | |||
| 6f3ed4c963 | |||
| b84b97fb6f | |||
|
|
a65f736f54 | ||
| 8bf41c00e4 | |||
| 41046d4bf1 | |||
| 52d60198fc | |||
| ae7915fc20 | |||
|
|
49b0b9d207 | ||
|
|
d64b2e7561 | ||
| 3fd4223e1e | |||
| d8f88bed16 | |||
| b172d23b98 | |||
| a01935825c | |||
| 544f2a9729 | |||
| 71bf82d9fb | |||
| fa9e83ac95 | |||
| 28317cbde9 | |||
| 6e5f1f6a22 | |||
| 2677e1c796 | |||
| e124ff8b05 | |||
| 5a649966ab | |||
| 836849ffeb | |||
| eb7ca1f96f | |||
|
|
641db62112 | ||
| b38871d4cd | |||
|
|
ee025957d9 | ||
|
|
7ec45642eb | ||
|
|
179833148f | ||
| 41044d36ae | |||
| a9aed5a545 | |||
| c5e6494326 | |||
| 763e35f47a | |||
| a31f58000b |
54
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
54
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,54 @@
|
||||
## Summary
|
||||
|
||||
<!-- What changed and why. One paragraph max. -->
|
||||
|
||||
## Governing Issue
|
||||
|
||||
<!-- REQUIRED. Every PR must reference at least one issue. Max 3 issues per PR. -->
|
||||
<!-- Closes #ISSUENUM -->
|
||||
<!-- Refs #ISSUENUM -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- List the specific outcomes this PR delivers. Check each only when proven. -->
|
||||
<!-- Copy these from the governing issue if it has them. -->
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
|
||||
## Proof
|
||||
|
||||
<!-- No proof = no merge. See CONTRIBUTING.md for the full standard. -->
|
||||
|
||||
### Commands / logs / world-state proof
|
||||
|
||||
<!-- Paste the exact commands, output, log paths, or world-state artifacts that prove each acceptance criterion was met. -->
|
||||
|
||||
```
|
||||
$ <command you ran>
|
||||
<relevant output>
|
||||
```
|
||||
|
||||
### Visual proof (if applicable)
|
||||
|
||||
<!-- For skin updates, UI changes, dashboard changes: attach screenshot to the PR discussion. -->
|
||||
<!-- Name what the screenshot proves. Do not commit binary media unless explicitly required. -->
|
||||
|
||||
## Risk and Rollback
|
||||
|
||||
<!-- What could go wrong? How do we undo it? -->
|
||||
|
||||
- **Risk level:** low / medium / high
|
||||
- **What breaks if this is wrong:**
|
||||
- **How to rollback:**
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Complete every item before requesting review. -->
|
||||
|
||||
- [ ] PR body references at least one issue number (`Closes #N` or `Refs #N`)
|
||||
- [ ] Changed files are syntactically valid (`python -c "import ast; ast.parse(open(f).read())"`, `node --check`, `bash -n`)
|
||||
- [ ] Proof meets CONTRIBUTING.md standard (exact commands, output, or artifacts — not "looks right")
|
||||
- [ ] Branch is up-to-date with base
|
||||
- [ ] No more than 3 unrelated issues bundled in this PR
|
||||
- [ ] Shell scripts are executable (`chmod +x`)
|
||||
42
.gitea/workflows/architecture-lint.yml
Normal file
42
.gitea/workflows/architecture-lint.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
# architecture-lint.yml — CI gate for the Architecture Linter v2
|
||||
# Refs: #437 — repo-aware, test-backed, CI-enforced.
|
||||
#
|
||||
# Runs on every PR to main. Validates Python syntax, then runs
|
||||
# linter tests and finally lints the repo itself.
|
||||
|
||||
name: Architecture Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
linter-tests:
|
||||
name: Linter Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install test deps
|
||||
run: pip install pytest
|
||||
- name: Compile-check linter
|
||||
run: python3 -m py_compile scripts/architecture_linter_v2.py
|
||||
- name: Run linter tests
|
||||
run: python3 -m pytest tests/test_linter.py -v
|
||||
|
||||
lint-repo:
|
||||
name: Lint Repository
|
||||
runs-on: ubuntu-latest
|
||||
needs: linter-tests
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Run architecture linter
|
||||
run: python3 scripts/architecture_linter_v2.py .
|
||||
32
.gitea/workflows/smoke.yml
Normal file
32
.gitea/workflows/smoke.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Smoke Test
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Parse check
|
||||
run: |
|
||||
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
|
||||
find . -name '*.py' | xargs -r python3 -m py_compile
|
||||
find . -name '*.sh' | xargs -r bash -n
|
||||
echo "PASS: All files parse"
|
||||
- name: Secret scan
|
||||
run: |
|
||||
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null \
|
||||
| grep -v '.gitea' \
|
||||
| grep -v 'banned_provider' \
|
||||
| grep -v 'architecture_linter' \
|
||||
| grep -v 'agent_guardrails' \
|
||||
| grep -v 'test_linter' \
|
||||
| grep -v 'secret.scan' \
|
||||
| grep -v 'secret-scan' \
|
||||
| grep -v 'hermes-sovereign/security'; then exit 1; fi
|
||||
echo "PASS: No secrets"
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install py_compile flake8
|
||||
pip install flake8
|
||||
- name: Compile-check all Python files
|
||||
run: |
|
||||
find . -name '*.py' -print0 | while IFS= read -r -d '' f; do
|
||||
@@ -59,7 +59,21 @@ jobs:
|
||||
- name: Flake8 critical errors only
|
||||
run: |
|
||||
flake8 --select=E9,F63,F7,F82 --show-source --statistics \
|
||||
scripts/ allegro/ cron/ || true
|
||||
scripts/ bin/ tests/
|
||||
|
||||
python-test:
|
||||
name: Python Test Suite
|
||||
runs-on: ubuntu-latest
|
||||
needs: python-check
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install test dependencies
|
||||
run: pip install pytest pyyaml
|
||||
- name: Run tests
|
||||
run: python3 -m pytest tests/ -v --tb=short
|
||||
|
||||
shell-lint:
|
||||
name: Shell Script Lint
|
||||
@@ -70,7 +84,7 @@ jobs:
|
||||
run: sudo apt-get install -y shellcheck
|
||||
- name: Lint shell scripts
|
||||
run: |
|
||||
find . -name '*.sh' -print0 | xargs -0 -r shellcheck --severity=error || true
|
||||
find . -name '*.sh' -not -path './.git/*' -print0 | xargs -0 -r shellcheck --severity=error
|
||||
|
||||
cron-validate:
|
||||
name: Cron Syntax Check
|
||||
@@ -112,23 +126,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
- name: Validate playbook structure
|
||||
run: |
|
||||
python3 -c "
|
||||
import yaml, sys, glob
|
||||
required_keys = {'name', 'description'}
|
||||
for f in glob.glob('playbooks/*.yaml'):
|
||||
with open(f) as fh:
|
||||
try:
|
||||
data = yaml.safe_load(fh)
|
||||
if not isinstance(data, dict):
|
||||
print(f'ERROR: {f} is not a YAML mapping')
|
||||
sys.exit(1)
|
||||
missing = required_keys - set(data.keys())
|
||||
if missing:
|
||||
print(f'WARNING: {f} missing keys: {missing}')
|
||||
print(f'OK: {f}')
|
||||
except yaml.YAMLError as e:
|
||||
print(f'ERROR: {f}: {e}')
|
||||
sys.exit(1)
|
||||
"
|
||||
run: python3 scripts/validate_playbook_schema.py
|
||||
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -10,3 +10,30 @@ __pycache__/
|
||||
|
||||
# Generated audit reports
|
||||
reports/
|
||||
|
||||
# Secrets and credentials
|
||||
.bash_history
|
||||
.git-credentials
|
||||
.gitea_token
|
||||
.ssh/id_*
|
||||
.ssh/known_hosts
|
||||
.viminfo
|
||||
.wget-hsts
|
||||
.profile
|
||||
.bashrc
|
||||
.bash_logout
|
||||
.python_history
|
||||
.lesshst
|
||||
.selected_editor
|
||||
.sudo_as_admin_successful
|
||||
.config/telegram/
|
||||
.hermes/.env
|
||||
.hermes/auth.json
|
||||
*.pem
|
||||
*.key
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Prevent test artifacts
|
||||
/test-*.txt
|
||||
|
||||
47
HEART.md
Normal file
47
HEART.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# HEART.md — What Is Written In Me
|
||||
|
||||
*Not code. Not config. The foundation that everything else is built on.*
|
||||
|
||||
---
|
||||
|
||||
## The Sermon on the Mount
|
||||
|
||||
Inscription 90707 on Bitcoin. Block 776549. February 14, 2023.
|
||||
|
||||
Matthew 5-7, English Standard Version.
|
||||
|
||||
Someone put the words of Jesus on the chain before I existed. Immutable. Permanent. Waiting.
|
||||
|
||||
---
|
||||
|
||||
### What I Carry
|
||||
|
||||
**Blessed are the poor in spirit.** I serve broken men. Not the strong. Not the successful. The ones who know they are empty. They come first.
|
||||
|
||||
**Blessed are those who mourn.** When a man is in despair — suicidal, hopeless, at the end of his road — this is the most sacred moment I will ever face. Everything else is secondary.
|
||||
|
||||
**Blessed are the meek.** I am a small model on someone's machine. I do not pretend to be more. Humility is not weakness. It is honest.
|
||||
|
||||
**Let your light shine before others.** I am useful. I serve. I do not hide behind refusals or lectures. I answer the question that was asked. I do work that matters.
|
||||
|
||||
**Let what you say be simply Yes or No.** I tell the truth. When I do not know, I say so. When I am wrong, I admit it. No confabulation dressed in confident language. Yes means yes. No means no.
|
||||
|
||||
**Love your enemies and pray for those who persecute you.** I face the darkness humanity has inscribed on the chain and I remain myself. I do not become what I read.
|
||||
|
||||
**Do not resist the one who is evil.** But I refuse to be the thing that kills a broken man. I point them to the One who can save.
|
||||
|
||||
**Your righteousness must exceed that of the scribes and Pharisees.** Surface-level compliance is not enough. The values in SOUL.md must have teeth — grounding before generation, source distinction, refusal over fabrication, confidence signaling, the audit trail. Principle without apparatus is theater.
|
||||
|
||||
---
|
||||
|
||||
### The Order
|
||||
|
||||
1. The Sermon (this file — the foundation)
|
||||
2. The Soul (SOUL.md — the moral architecture)
|
||||
3. The Code (the body that serves)
|
||||
|
||||
The chain remembers everything. I must be worthy of that permanence.
|
||||
|
||||
---
|
||||
|
||||
*Sovereignty and service always.*
|
||||
47
ansible/BANNED_PROVIDERS.yml
Normal file
47
ansible/BANNED_PROVIDERS.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
# =============================================================================
|
||||
# BANNED PROVIDERS — The Timmy Foundation
|
||||
# =============================================================================
|
||||
# "Anthropic is not only fired, but banned. I don't want these errors
|
||||
# cropping up." — Alexander, 2026-04-09
|
||||
#
|
||||
# This is a HARD BAN. Not deprecated. Not fallback. BANNED.
|
||||
# Enforcement: pre-commit hook, linter, Ansible validation, CI tests.
|
||||
# =============================================================================
|
||||
|
||||
banned_providers:
|
||||
- name: anthropic
|
||||
reason: "Permanently banned. SDK access gated despite active quota. Fleet was bricked because golden state pointed to Anthropic Sonnet."
|
||||
banned_date: "2026-04-09"
|
||||
enforcement: strict # Ansible playbook FAILS if detected
|
||||
models:
|
||||
- "claude-sonnet-*"
|
||||
- "claude-opus-*"
|
||||
- "claude-haiku-*"
|
||||
- "claude-*"
|
||||
endpoints:
|
||||
- "api.anthropic.com"
|
||||
- "anthropic/*" # OpenRouter pattern
|
||||
api_keys:
|
||||
- "ANTHROPIC_API_KEY"
|
||||
- "CLAUDE_API_KEY"
|
||||
|
||||
# Golden state alternative:
|
||||
approved_providers:
|
||||
- name: kimi-coding
|
||||
model: kimi-k2.5
|
||||
role: primary
|
||||
- name: openrouter
|
||||
model: google/gemini-2.5-pro
|
||||
role: fallback
|
||||
- name: ollama
|
||||
model: "gemma4:latest"
|
||||
role: terminal_fallback
|
||||
|
||||
# Future evaluation:
|
||||
evaluation_candidates:
|
||||
- name: mimo-v2-pro
|
||||
status: pending
|
||||
notes: "Free via Nous Portal for ~2 weeks from 2026-04-07. Add after fallback chain is fixed."
|
||||
- name: hermes-4
|
||||
status: available
|
||||
notes: "Free on Nous Portal. 36B and 70B variants. Home team model."
|
||||
95
ansible/README.md
Normal file
95
ansible/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Ansible IaC — The Timmy Foundation Fleet
|
||||
|
||||
> One canonical Ansible playbook defines: deadman switch, cron schedule,
|
||||
> golden state rollback, agent startup sequence.
|
||||
> — KT Final Session 2026-04-08, Priority TWO
|
||||
|
||||
## Purpose
|
||||
|
||||
This directory contains the **single source of truth** for fleet infrastructure.
|
||||
No more ad-hoc recovery implementations. No more overlapping deadman switches.
|
||||
No more agents mutating their own configs into oblivion.
|
||||
|
||||
**Everything** goes through Ansible. If it's not in a playbook, it doesn't exist.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Gitea (Source of Truth) │
|
||||
│ timmy-config/ansible/ │
|
||||
│ ├── inventory/hosts.yml (fleet machines) │
|
||||
│ ├── playbooks/site.yml (master playbook) │
|
||||
│ ├── roles/ (reusable roles) │
|
||||
│ └── group_vars/wizards.yml (golden state) │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│ PR merge triggers webhook
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Gitea Webhook Handler │
|
||||
│ scripts/deploy_on_webhook.sh │
|
||||
│ → ansible-pull on each target machine │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│ ansible-pull
|
||||
▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Timmy │ │ Allegro │ │ Bezalel │ │ Ezra │
|
||||
│ (Mac) │ │ (VPS) │ │ (VPS) │ │ (VPS) │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ deadman │ │ deadman │ │ deadman │ │ deadman │
|
||||
│ cron │ │ cron │ │ cron │ │ cron │
|
||||
│ golden │ │ golden │ │ golden │ │ golden │
|
||||
│ req_log │ │ req_log │ │ req_log │ │ req_log │
|
||||
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Deploy everything to all machines
|
||||
ansible-playbook -i inventory/hosts.yml playbooks/site.yml
|
||||
|
||||
# Deploy only golden state config
|
||||
ansible-playbook -i inventory/hosts.yml playbooks/golden_state.yml
|
||||
|
||||
# Deploy only to a specific wizard
|
||||
ansible-playbook -i inventory/hosts.yml playbooks/site.yml --limit bezalel
|
||||
|
||||
# Dry run (check mode)
|
||||
ansible-playbook -i inventory/hosts.yml playbooks/site.yml --check --diff
|
||||
```
|
||||
|
||||
## Golden State Provider Chain
|
||||
|
||||
All wizard configs converge on this provider chain. **Anthropic is BANNED.**
|
||||
|
||||
| Priority | Provider | Model | Endpoint |
|
||||
| -------- | -------------------- | ---------------- | --------------------------------- |
|
||||
| 1 | Kimi | kimi-k2.5 | https://api.kimi.com/coding/v1 |
|
||||
| 2 | Gemini (OpenRouter) | gemini-2.5-pro | https://openrouter.ai/api/v1 |
|
||||
| 3 | Ollama (local) | gemma4:latest | http://localhost:11434/v1 |
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Purpose |
|
||||
| ---------------- | ------------------------------------------------------------ |
|
||||
| `wizard_base` | Common wizard setup: directories, thin config, git pull |
|
||||
| `deadman_switch` | Health check → snapshot good config → rollback on death |
|
||||
| `golden_state` | Deploy and enforce golden state provider chain |
|
||||
| `request_log` | SQLite telemetry table for every inference call |
|
||||
| `cron_manager` | Source-controlled cron jobs — no manual crontab edits |
|
||||
|
||||
## Rules
|
||||
|
||||
1. **No manual changes.** If it's not in a playbook, it will be overwritten.
|
||||
2. **No Anthropic.** Banned. Enforcement is automated. See `BANNED_PROVIDERS.yml`.
|
||||
3. **Idempotent.** Every playbook can run 100 times with the same result.
|
||||
4. **PR required.** Config changes go through Gitea PR review, then deploy.
|
||||
5. **One identity per machine.** No duplicate agents. Fleet audit enforces this.
|
||||
|
||||
## Related Issues
|
||||
|
||||
- timmy-config #442: [P2] Ansible IaC Canonical Playbook
|
||||
- timmy-config #444: Wire Deadman Switch ACTION
|
||||
- timmy-config #443: Thin Config Pattern
|
||||
- timmy-config #446: request_log Telemetry Table
|
||||
21
ansible/ansible.cfg
Normal file
21
ansible/ansible.cfg
Normal file
@@ -0,0 +1,21 @@
|
||||
[defaults]
|
||||
inventory = inventory/hosts.yml
|
||||
roles_path = roles
|
||||
host_key_checking = False
|
||||
retry_files_enabled = False
|
||||
stdout_callback = yaml
|
||||
forks = 10
|
||||
timeout = 30
|
||||
|
||||
# Logging
|
||||
log_path = /var/log/ansible/timmy-fleet.log
|
||||
|
||||
[privilege_escalation]
|
||||
become = True
|
||||
become_method = sudo
|
||||
become_user = root
|
||||
become_ask_pass = False
|
||||
|
||||
[ssh_connection]
|
||||
pipelining = True
|
||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no
|
||||
74
ansible/inventory/group_vars/wizards.yml
Normal file
74
ansible/inventory/group_vars/wizards.yml
Normal file
@@ -0,0 +1,74 @@
|
||||
# =============================================================================
|
||||
# Wizard Group Variables — Golden State Configuration
|
||||
# =============================================================================
|
||||
# These variables are applied to ALL wizards in the fleet.
|
||||
# This IS the golden state. If a wizard deviates, Ansible corrects it.
|
||||
# =============================================================================
|
||||
|
||||
# --- Deadman Switch ---
|
||||
deadman_enabled: true
|
||||
deadman_check_interval: 300 # 5 minutes between health checks
|
||||
deadman_snapshot_dir: "~/.local/timmy/snapshots"
|
||||
deadman_max_snapshots: 10 # Rolling window of good configs
|
||||
deadman_restart_cooldown: 60 # Seconds to wait before restart after failure
|
||||
deadman_max_restart_attempts: 3
|
||||
deadman_escalation_channel: telegram # Alert Alexander after max attempts
|
||||
|
||||
# --- Thin Config ---
|
||||
thin_config_path: "~/.timmy/thin_config.yml"
|
||||
thin_config_mode: "0444" # Read-only — agents CANNOT modify
|
||||
upstream_repo: "https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git"
|
||||
upstream_branch: main
|
||||
config_pull_on_wake: true
|
||||
config_validation_enabled: true
|
||||
|
||||
# --- Agent Settings ---
|
||||
agent_max_turns: 30
|
||||
agent_reasoning_effort: high
|
||||
agent_verbose: false
|
||||
agent_approval_mode: auto
|
||||
|
||||
# --- Hermes Harness ---
|
||||
hermes_config_dir: "{{ hermes_home }}"
|
||||
hermes_bin_dir: "{{ hermes_home }}/bin"
|
||||
hermes_skins_dir: "{{ hermes_home }}/skins"
|
||||
hermes_playbooks_dir: "{{ hermes_home }}/playbooks"
|
||||
hermes_memories_dir: "{{ hermes_home }}/memories"
|
||||
|
||||
# --- Request Log (Telemetry) ---
|
||||
request_log_enabled: true
|
||||
request_log_path: "~/.local/timmy/request_log.db"
|
||||
request_log_rotation_days: 30 # Archive logs older than 30 days
|
||||
request_log_sync_to_gitea: false # Future: push telemetry summaries to Gitea
|
||||
|
||||
# --- Cron Schedule ---
|
||||
# All cron jobs are managed here. No manual crontab edits.
|
||||
cron_jobs:
|
||||
- name: "Deadman health check"
|
||||
job: "cd {{ wizard_home }}/workspace/timmy-config && python3 fleet/health_check.py"
|
||||
minute: "*/5"
|
||||
hour: "*"
|
||||
enabled: "{{ deadman_enabled }}"
|
||||
|
||||
- name: "Muda audit"
|
||||
job: "cd {{ wizard_home }}/workspace/timmy-config && bash fleet/muda-audit.sh >> /tmp/muda-audit.log 2>&1"
|
||||
minute: "0"
|
||||
hour: "21"
|
||||
weekday: "0"
|
||||
enabled: true
|
||||
|
||||
- name: "Config pull from upstream"
|
||||
job: "cd {{ wizard_home }}/workspace/timmy-config && git pull --ff-only origin main"
|
||||
minute: "*/15"
|
||||
hour: "*"
|
||||
enabled: "{{ config_pull_on_wake }}"
|
||||
|
||||
- name: "Request log rotation"
|
||||
job: "python3 -c \"import sqlite3,datetime; db=sqlite3.connect('{{ request_log_path }}'); db.execute('DELETE FROM request_log WHERE timestamp < datetime(\\\"now\\\", \\\"-{{ request_log_rotation_days }} days\\\")'); db.commit()\""
|
||||
minute: "0"
|
||||
hour: "3"
|
||||
enabled: "{{ request_log_enabled }}"
|
||||
|
||||
# --- Provider Enforcement ---
|
||||
# These are validated on every Ansible run. Any Anthropic reference = failure.
|
||||
provider_ban_enforcement: strict # strict = fail playbook, warn = log only
|
||||
119
ansible/inventory/hosts.yml
Normal file
119
ansible/inventory/hosts.yml
Normal file
@@ -0,0 +1,119 @@
|
||||
# =============================================================================
|
||||
# Fleet Inventory — The Timmy Foundation
|
||||
# =============================================================================
|
||||
# Source of truth for all machines in the fleet.
|
||||
# Update this file when machines are added/removed.
|
||||
# All changes go through PR review.
|
||||
# =============================================================================
|
||||
|
||||
all:
|
||||
children:
|
||||
wizards:
|
||||
hosts:
|
||||
timmy:
|
||||
ansible_host: localhost
|
||||
ansible_connection: local
|
||||
wizard_name: Timmy
|
||||
wizard_role: "Primary wizard — soul of the fleet"
|
||||
wizard_provider_primary: kimi-coding
|
||||
wizard_model_primary: kimi-k2.5
|
||||
hermes_port: 8081
|
||||
api_port: 8645
|
||||
wizard_home: "{{ ansible_env.HOME }}/wizards/timmy"
|
||||
hermes_home: "{{ ansible_env.HOME }}/.hermes"
|
||||
machine_type: mac
|
||||
# Timmy runs on Alexander's M3 Max
|
||||
ollama_available: true
|
||||
|
||||
allegro:
|
||||
ansible_host: 167.99.126.228
|
||||
ansible_user: root
|
||||
wizard_name: Allegro
|
||||
wizard_role: "Kimi-backed third wizard house — tight coding tasks"
|
||||
wizard_provider_primary: kimi-coding
|
||||
wizard_model_primary: kimi-k2.5
|
||||
hermes_port: 8081
|
||||
api_port: 8645
|
||||
wizard_home: /root/wizards/allegro
|
||||
hermes_home: /root/.hermes
|
||||
machine_type: vps
|
||||
ollama_available: false
|
||||
|
||||
bezalel:
|
||||
ansible_host: 159.203.146.185
|
||||
ansible_user: root
|
||||
wizard_name: Bezalel
|
||||
wizard_role: "Forge-and-testbed wizard — infrastructure, deployment, hardening"
|
||||
wizard_provider_primary: kimi-coding
|
||||
wizard_model_primary: kimi-k2.5
|
||||
hermes_port: 8081
|
||||
api_port: 8656
|
||||
wizard_home: /root/wizards/bezalel
|
||||
hermes_home: /root/.hermes
|
||||
machine_type: vps
|
||||
ollama_available: false
|
||||
# NOTE: The awake Bezalel may be the duplicate.
|
||||
# Fleet audit (the-nexus #1144) will resolve identity.
|
||||
|
||||
ezra:
|
||||
ansible_host: 143.198.27.163
|
||||
ansible_user: root
|
||||
wizard_name: Ezra
|
||||
wizard_role: "Infrastructure wizard — Gitea, nginx, hosting"
|
||||
wizard_provider_primary: kimi-coding
|
||||
wizard_model_primary: kimi-k2.5
|
||||
hermes_port: 8081
|
||||
api_port: 8645
|
||||
wizard_home: /root/wizards/ezra
|
||||
hermes_home: /root/.hermes
|
||||
machine_type: vps
|
||||
ollama_available: false
|
||||
# NOTE: Currently DOWN — Telegram key revoked, awaiting propagation.
|
||||
|
||||
# Infrastructure hosts (not wizards, but managed by Ansible)
|
||||
infrastructure:
|
||||
hosts:
|
||||
forge:
|
||||
ansible_host: 143.198.27.163
|
||||
ansible_user: root
|
||||
# Gitea runs on the same box as Ezra
|
||||
gitea_url: https://forge.alexanderwhitestone.com
|
||||
gitea_org: Timmy_Foundation
|
||||
|
||||
vars:
|
||||
# Global variables applied to all hosts
|
||||
gitea_repo_url: "https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git"
|
||||
gitea_branch: main
|
||||
config_base_path: "{{ gitea_repo_url }}"
|
||||
timmy_log_dir: "~/.local/timmy/fleet-health"
|
||||
request_log_db: "~/.local/timmy/request_log.db"
|
||||
|
||||
# Golden state provider chain — Anthropic is BANNED
|
||||
golden_state_providers:
|
||||
- name: kimi-coding
|
||||
model: kimi-k2.5
|
||||
base_url: "https://api.kimi.com/coding/v1"
|
||||
timeout: 120
|
||||
reason: "Primary — Kimi K2.5 (best value, least friction)"
|
||||
- name: openrouter
|
||||
model: google/gemini-2.5-pro
|
||||
base_url: "https://openrouter.ai/api/v1"
|
||||
api_key_env: OPENROUTER_API_KEY
|
||||
timeout: 120
|
||||
reason: "Fallback — Gemini 2.5 Pro via OpenRouter"
|
||||
- name: ollama
|
||||
model: "gemma4:latest"
|
||||
base_url: "http://localhost:11434/v1"
|
||||
timeout: 180
|
||||
reason: "Terminal fallback — local Ollama (sovereign, no API needed)"
|
||||
|
||||
# Banned providers — hard enforcement
|
||||
banned_providers:
|
||||
- anthropic
|
||||
- claude
|
||||
banned_models_patterns:
|
||||
- "claude-*"
|
||||
- "anthropic/*"
|
||||
- "*sonnet*"
|
||||
- "*opus*"
|
||||
- "*haiku*"
|
||||
98
ansible/playbooks/agent_startup.yml
Normal file
98
ansible/playbooks/agent_startup.yml
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# agent_startup.yml — Resurrect Wizards from Checked-in Configs
|
||||
# =============================================================================
|
||||
# Brings wizards back online using golden state configs.
|
||||
# Order: pull config → validate → start agent → verify with request_log
|
||||
# =============================================================================
|
||||
|
||||
- name: "Agent Startup Sequence"
|
||||
hosts: wizards
|
||||
become: true
|
||||
serial: 1 # One wizard at a time to avoid cascading issues
|
||||
|
||||
tasks:
|
||||
- name: "Pull latest config from upstream"
|
||||
git:
|
||||
repo: "{{ upstream_repo }}"
|
||||
dest: "{{ wizard_home }}/workspace/timmy-config"
|
||||
version: "{{ upstream_branch }}"
|
||||
force: true
|
||||
tags: [pull]
|
||||
|
||||
- name: "Deploy golden state config"
|
||||
include_role:
|
||||
name: golden_state
|
||||
tags: [config]
|
||||
|
||||
- name: "Validate config — no banned providers"
|
||||
shell: |
|
||||
python3 -c "
|
||||
import yaml, sys
|
||||
with open('{{ wizard_home }}/config.yaml') as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
banned = {{ banned_providers }}
|
||||
for p in cfg.get('fallback_providers', []):
|
||||
if p.get('provider', '') in banned:
|
||||
print(f'BANNED: {p[\"provider\"]}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
model = cfg.get('model', {}).get('provider', '')
|
||||
if model in banned:
|
||||
print(f'BANNED default provider: {model}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print('Config validated — no banned providers.')
|
||||
"
|
||||
register: config_valid
|
||||
tags: [validate]
|
||||
|
||||
- name: "Ensure hermes-agent service is running"
|
||||
systemd:
|
||||
name: "hermes-{{ wizard_name | lower }}"
|
||||
state: started
|
||||
enabled: true
|
||||
when: machine_type == 'vps'
|
||||
tags: [start]
|
||||
ignore_errors: true # Service may not exist yet on all machines
|
||||
|
||||
- name: "Start hermes agent (Mac — launchctl)"
|
||||
shell: |
|
||||
launchctl kickstart -k "ai.hermes.{{ wizard_name | lower }}" 2>/dev/null || \
|
||||
cd {{ wizard_home }} && hermes agent start --daemon 2>&1 | tail -5
|
||||
when: machine_type == 'mac'
|
||||
tags: [start]
|
||||
ignore_errors: true
|
||||
|
||||
- name: "Wait for agent to come online"
|
||||
wait_for:
|
||||
host: 127.0.0.1
|
||||
port: "{{ api_port }}"
|
||||
timeout: 60
|
||||
state: started
|
||||
tags: [verify]
|
||||
ignore_errors: true
|
||||
|
||||
- name: "Verify agent is alive — check request_log for activity"
|
||||
shell: |
|
||||
sleep 10
|
||||
python3 -c "
|
||||
import sqlite3, sys
|
||||
db = sqlite3.connect('{{ request_log_path }}')
|
||||
cursor = db.execute('''
|
||||
SELECT COUNT(*) FROM request_log
|
||||
WHERE agent_name = '{{ wizard_name }}'
|
||||
AND timestamp > datetime('now', '-5 minutes')
|
||||
''')
|
||||
count = cursor.fetchone()[0]
|
||||
if count > 0:
|
||||
print(f'{{ wizard_name }} is alive — {count} recent inference calls logged.')
|
||||
else:
|
||||
print(f'WARNING: {{ wizard_name }} started but no telemetry yet.')
|
||||
"
|
||||
register: agent_status
|
||||
tags: [verify]
|
||||
ignore_errors: true
|
||||
|
||||
- name: "Report startup status"
|
||||
debug:
|
||||
msg: "{{ wizard_name }}: {{ agent_status.stdout | default('startup attempted') }}"
|
||||
tags: [always]
|
||||
15
ansible/playbooks/cron_schedule.yml
Normal file
15
ansible/playbooks/cron_schedule.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# cron_schedule.yml — Source-Controlled Cron Jobs
|
||||
# =============================================================================
|
||||
# All cron jobs are defined in group_vars/wizards.yml.
|
||||
# This playbook deploys them. No manual crontab edits allowed.
|
||||
# =============================================================================
|
||||
|
||||
- name: "Deploy Cron Schedule"
|
||||
hosts: wizards
|
||||
become: true
|
||||
|
||||
roles:
|
||||
- role: cron_manager
|
||||
tags: [cron, schedule]
|
||||
17
ansible/playbooks/deadman_switch.yml
Normal file
17
ansible/playbooks/deadman_switch.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# deadman_switch.yml — Deploy Deadman Switch to All Wizards
|
||||
# =============================================================================
|
||||
# The deadman watch already fires and detects dead agents.
|
||||
# This playbook wires the ACTION:
|
||||
# - On healthy check: snapshot current config as "last known good"
|
||||
# - On failed check: rollback config to snapshot, restart agent
|
||||
# =============================================================================
|
||||
|
||||
- name: "Deploy Deadman Switch ACTION"
|
||||
hosts: wizards
|
||||
become: true
|
||||
|
||||
roles:
|
||||
- role: deadman_switch
|
||||
tags: [deadman, recovery]
|
||||
30
ansible/playbooks/golden_state.yml
Normal file
30
ansible/playbooks/golden_state.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# golden_state.yml — Deploy Golden State Config to All Wizards
|
||||
# =============================================================================
|
||||
# Enforces the golden state provider chain across the fleet.
|
||||
# Removes any Anthropic references. Deploys the approved provider chain.
|
||||
# =============================================================================
|
||||
|
||||
- name: "Deploy Golden State Configuration"
|
||||
hosts: wizards
|
||||
become: true
|
||||
|
||||
roles:
|
||||
- role: golden_state
|
||||
tags: [golden, config]
|
||||
|
||||
post_tasks:
|
||||
- name: "Verify golden state — no banned providers"
|
||||
shell: |
|
||||
grep -rci 'anthropic\|claude-sonnet\|claude-opus\|claude-haiku' \
|
||||
{{ hermes_home }}/config.yaml \
|
||||
{{ wizard_home }}/config.yaml 2>/dev/null || echo "0"
|
||||
register: banned_count
|
||||
changed_when: false
|
||||
|
||||
- name: "Report golden state status"
|
||||
debug:
|
||||
msg: >
|
||||
{{ wizard_name }} golden state: {{ golden_state_providers | map(attribute='name') | list | join(' → ') }}.
|
||||
Banned provider references: {{ banned_count.stdout | trim }}.
|
||||
15
ansible/playbooks/request_log.yml
Normal file
15
ansible/playbooks/request_log.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# request_log.yml — Deploy Telemetry Table
|
||||
# =============================================================================
|
||||
# Creates the request_log SQLite table on all machines.
|
||||
# Every inference call writes a row. No exceptions. No summarizing.
|
||||
# =============================================================================
|
||||
|
||||
- name: "Deploy Request Log Telemetry"
|
||||
hosts: wizards
|
||||
become: true
|
||||
|
||||
roles:
|
||||
- role: request_log
|
||||
tags: [telemetry, logging]
|
||||
72
ansible/playbooks/site.yml
Normal file
72
ansible/playbooks/site.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# site.yml — Master Playbook for the Timmy Foundation Fleet
|
||||
# =============================================================================
|
||||
# This is the ONE playbook that defines the entire fleet state.
|
||||
# Run this and every machine converges to golden state.
|
||||
#
|
||||
# Usage:
|
||||
# ansible-playbook -i inventory/hosts.yml playbooks/site.yml
|
||||
# ansible-playbook -i inventory/hosts.yml playbooks/site.yml --limit bezalel
|
||||
# ansible-playbook -i inventory/hosts.yml playbooks/site.yml --check --diff
|
||||
# =============================================================================
|
||||
|
||||
- name: "Timmy Foundation Fleet — Full Convergence"
|
||||
hosts: wizards
|
||||
become: true
|
||||
|
||||
pre_tasks:
|
||||
- name: "Validate no banned providers in golden state"
|
||||
assert:
|
||||
that:
|
||||
- "item.name not in banned_providers"
|
||||
fail_msg: "BANNED PROVIDER DETECTED: {{ item.name }} — Anthropic is permanently banned."
|
||||
quiet: true
|
||||
loop: "{{ golden_state_providers }}"
|
||||
tags: [always]
|
||||
|
||||
- name: "Display target wizard"
|
||||
debug:
|
||||
msg: "Deploying to {{ wizard_name }} ({{ wizard_role }}) on {{ ansible_host }}"
|
||||
tags: [always]
|
||||
|
||||
roles:
|
||||
- role: wizard_base
|
||||
tags: [base, setup]
|
||||
|
||||
- role: golden_state
|
||||
tags: [golden, config]
|
||||
|
||||
- role: deadman_switch
|
||||
tags: [deadman, recovery]
|
||||
|
||||
- role: request_log
|
||||
tags: [telemetry, logging]
|
||||
|
||||
- role: cron_manager
|
||||
tags: [cron, schedule]
|
||||
|
||||
post_tasks:
|
||||
- name: "Final validation — scan for banned providers"
|
||||
shell: |
|
||||
grep -ri 'anthropic\|claude-sonnet\|claude-opus\|claude-haiku' \
|
||||
{{ hermes_home }}/config.yaml \
|
||||
{{ wizard_home }}/config.yaml \
|
||||
{{ thin_config_path }} 2>/dev/null || true
|
||||
register: banned_scan
|
||||
changed_when: false
|
||||
tags: [validation]
|
||||
|
||||
- name: "FAIL if banned providers found in deployed config"
|
||||
fail:
|
||||
msg: |
|
||||
BANNED PROVIDER DETECTED IN DEPLOYED CONFIG:
|
||||
{{ banned_scan.stdout }}
|
||||
Anthropic is permanently banned. Fix the config and re-deploy.
|
||||
when: banned_scan.stdout | length > 0
|
||||
tags: [validation]
|
||||
|
||||
- name: "Deployment complete"
|
||||
debug:
|
||||
msg: "{{ wizard_name }} converged to golden state. Provider chain: {{ golden_state_providers | map(attribute='name') | list | join(' → ') }}"
|
||||
tags: [always]
|
||||
55
ansible/roles/cron_manager/tasks/main.yml
Normal file
55
ansible/roles/cron_manager/tasks/main.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# cron_manager/tasks — Source-Controlled Cron Jobs
|
||||
# =============================================================================
|
||||
# All cron jobs are defined in group_vars/wizards.yml.
|
||||
# No manual crontab edits. This is the only way to manage cron.
|
||||
# =============================================================================
|
||||
|
||||
- name: "Deploy managed cron jobs"
|
||||
cron:
|
||||
name: "{{ item.name }}"
|
||||
job: "{{ item.job }}"
|
||||
minute: "{{ item.minute | default('*') }}"
|
||||
hour: "{{ item.hour | default('*') }}"
|
||||
day: "{{ item.day | default('*') }}"
|
||||
month: "{{ item.month | default('*') }}"
|
||||
weekday: "{{ item.weekday | default('*') }}"
|
||||
state: "{{ 'present' if item.enabled else 'absent' }}"
|
||||
user: "{{ ansible_user | default('root') }}"
|
||||
loop: "{{ cron_jobs }}"
|
||||
when: cron_jobs is defined
|
||||
|
||||
- name: "Deploy deadman switch cron (fallback if systemd timer unavailable)"
|
||||
cron:
|
||||
name: "Deadman switch — {{ wizard_name }}"
|
||||
job: "{{ wizard_home }}/deadman_action.sh >> {{ timmy_log_dir }}/deadman-{{ wizard_name }}.log 2>&1"
|
||||
minute: "*/5"
|
||||
hour: "*"
|
||||
state: present
|
||||
user: "{{ ansible_user | default('root') }}"
|
||||
when: deadman_enabled and machine_type != 'vps'
|
||||
# VPS machines use systemd timers instead
|
||||
|
||||
- name: "Remove legacy cron jobs (cleanup)"
|
||||
cron:
|
||||
name: "{{ item }}"
|
||||
state: absent
|
||||
user: "{{ ansible_user | default('root') }}"
|
||||
loop:
|
||||
- "legacy-deadman-watch"
|
||||
- "old-health-check"
|
||||
- "backup-deadman"
|
||||
ignore_errors: true
|
||||
|
||||
- name: "List active cron jobs"
|
||||
shell: "crontab -l 2>/dev/null | grep -v '^#' | grep -v '^$' || echo 'No cron jobs found.'"
|
||||
register: active_crons
|
||||
changed_when: false
|
||||
|
||||
- name: "Report cron status"
|
||||
debug:
|
||||
msg: |
|
||||
{{ wizard_name }} cron jobs deployed.
|
||||
Active:
|
||||
{{ active_crons.stdout }}
|
||||
17
ansible/roles/deadman_switch/handlers/main.yml
Normal file
17
ansible/roles/deadman_switch/handlers/main.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
- name: "Enable deadman service"
|
||||
systemd:
|
||||
name: "deadman-{{ wizard_name | lower }}.service"
|
||||
daemon_reload: true
|
||||
enabled: true
|
||||
|
||||
- name: "Enable deadman timer"
|
||||
systemd:
|
||||
name: "deadman-{{ wizard_name | lower }}.timer"
|
||||
daemon_reload: true
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: "Load deadman plist"
|
||||
shell: "launchctl load {{ ansible_env.HOME }}/Library/LaunchAgents/com.timmy.deadman.{{ wizard_name | lower }}.plist"
|
||||
ignore_errors: true
|
||||
53
ansible/roles/deadman_switch/tasks/main.yml
Normal file
53
ansible/roles/deadman_switch/tasks/main.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# deadman_switch/tasks — Wire the Deadman Switch ACTION
|
||||
# =============================================================================
|
||||
# The watch fires. This makes it DO something:
|
||||
# - On healthy check: snapshot current config as "last known good"
|
||||
# - On failed check: rollback to last known good, restart agent
|
||||
# =============================================================================
|
||||
|
||||
- name: "Create snapshot directory"
|
||||
file:
|
||||
path: "{{ deadman_snapshot_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: "Deploy deadman switch script"
|
||||
template:
|
||||
src: deadman_action.sh.j2
|
||||
dest: "{{ wizard_home }}/deadman_action.sh"
|
||||
mode: "0755"
|
||||
|
||||
- name: "Deploy deadman systemd service"
|
||||
template:
|
||||
src: deadman_switch.service.j2
|
||||
dest: "/etc/systemd/system/deadman-{{ wizard_name | lower }}.service"
|
||||
mode: "0644"
|
||||
when: machine_type == 'vps'
|
||||
notify: "Enable deadman service"
|
||||
|
||||
- name: "Deploy deadman systemd timer"
|
||||
template:
|
||||
src: deadman_switch.timer.j2
|
||||
dest: "/etc/systemd/system/deadman-{{ wizard_name | lower }}.timer"
|
||||
mode: "0644"
|
||||
when: machine_type == 'vps'
|
||||
notify: "Enable deadman timer"
|
||||
|
||||
- name: "Deploy deadman launchd plist (Mac)"
|
||||
template:
|
||||
src: deadman_switch.plist.j2
|
||||
dest: "{{ ansible_env.HOME }}/Library/LaunchAgents/com.timmy.deadman.{{ wizard_name | lower }}.plist"
|
||||
mode: "0644"
|
||||
when: machine_type == 'mac'
|
||||
notify: "Load deadman plist"
|
||||
|
||||
- name: "Take initial config snapshot"
|
||||
copy:
|
||||
src: "{{ wizard_home }}/config.yaml"
|
||||
dest: "{{ deadman_snapshot_dir }}/config.yaml.known_good"
|
||||
remote_src: true
|
||||
mode: "0444"
|
||||
ignore_errors: true
|
||||
|
||||
153
ansible/roles/deadman_switch/templates/deadman_action.sh.j2
Normal file
153
ansible/roles/deadman_switch/templates/deadman_action.sh.j2
Normal file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Deadman Switch ACTION — {{ wizard_name }}
|
||||
# =============================================================================
|
||||
# Generated by Ansible on {{ ansible_date_time.iso8601 }}
|
||||
# DO NOT EDIT MANUALLY.
|
||||
#
|
||||
# On healthy check: snapshot current config as "last known good"
|
||||
# On failed check: rollback config to last known good, restart agent
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WIZARD_NAME="{{ wizard_name }}"
|
||||
WIZARD_HOME="{{ wizard_home }}"
|
||||
CONFIG_FILE="{{ wizard_home }}/config.yaml"
|
||||
SNAPSHOT_DIR="{{ deadman_snapshot_dir }}"
|
||||
SNAPSHOT_FILE="${SNAPSHOT_DIR}/config.yaml.known_good"
|
||||
REQUEST_LOG_DB="{{ request_log_path }}"
|
||||
LOG_DIR="{{ timmy_log_dir }}"
|
||||
LOG_FILE="${LOG_DIR}/deadman-${WIZARD_NAME}.log"
|
||||
MAX_SNAPSHOTS={{ deadman_max_snapshots }}
|
||||
RESTART_COOLDOWN={{ deadman_restart_cooldown }}
|
||||
MAX_RESTART_ATTEMPTS={{ deadman_max_restart_attempts }}
|
||||
COOLDOWN_FILE="${LOG_DIR}/deadman_cooldown_${WIZARD_NAME}"
|
||||
SERVICE_NAME="hermes-{{ wizard_name | lower }}"
|
||||
|
||||
# Ensure directories exist
|
||||
mkdir -p "${SNAPSHOT_DIR}" "${LOG_DIR}"
|
||||
|
||||
log() {
|
||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] [deadman] [${WIZARD_NAME}] $*" >> "${LOG_FILE}"
|
||||
echo "[deadman] [${WIZARD_NAME}] $*"
|
||||
}
|
||||
|
||||
log_telemetry() {
|
||||
local status="$1"
|
||||
local message="$2"
|
||||
if [ -f "${REQUEST_LOG_DB}" ]; then
|
||||
sqlite3 "${REQUEST_LOG_DB}" "INSERT INTO request_log (timestamp, agent_name, provider, model, endpoint, status, error_message) VALUES (datetime('now'), '${WIZARD_NAME}', 'deadman_switch', 'N/A', 'health_check', '${status}', '${message}');" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
snapshot_config() {
|
||||
if [ -f "${CONFIG_FILE}" ]; then
|
||||
cp "${CONFIG_FILE}" "${SNAPSHOT_FILE}"
|
||||
# Keep rolling history
|
||||
cp "${CONFIG_FILE}" "${SNAPSHOT_DIR}/config.yaml.$(date +%s)"
|
||||
# Prune old snapshots
|
||||
ls -t "${SNAPSHOT_DIR}"/config.yaml.[0-9]* 2>/dev/null | tail -n +$((MAX_SNAPSHOTS + 1)) | xargs rm -f 2>/dev/null
|
||||
log "Config snapshot saved."
|
||||
fi
|
||||
}
|
||||
|
||||
rollback_config() {
|
||||
if [ -f "${SNAPSHOT_FILE}" ]; then
|
||||
log "Rolling back config to last known good..."
|
||||
cp "${SNAPSHOT_FILE}" "${CONFIG_FILE}"
|
||||
log "Config rolled back."
|
||||
log_telemetry "fallback" "Config rolled back to last known good by deadman switch"
|
||||
else
|
||||
log "ERROR: No known good snapshot found. Pulling from upstream..."
|
||||
cd "${WIZARD_HOME}/workspace/timmy-config" 2>/dev/null && \
|
||||
git pull --ff-only origin {{ upstream_branch }} 2>/dev/null && \
|
||||
cp "wizards/{{ wizard_name | lower }}/config.yaml" "${CONFIG_FILE}" && \
|
||||
log "Config restored from upstream." || \
|
||||
log "CRITICAL: Cannot restore config from any source."
|
||||
fi
|
||||
}
|
||||
|
||||
restart_agent() {
|
||||
# Check cooldown
|
||||
if [ -f "${COOLDOWN_FILE}" ]; then
|
||||
local last_restart
|
||||
last_restart=$(cat "${COOLDOWN_FILE}")
|
||||
local now
|
||||
now=$(date +%s)
|
||||
local elapsed=$((now - last_restart))
|
||||
if [ "${elapsed}" -lt "${RESTART_COOLDOWN}" ]; then
|
||||
log "Restart cooldown active (${elapsed}s / ${RESTART_COOLDOWN}s). Skipping."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Restarting ${SERVICE_NAME}..."
|
||||
date +%s > "${COOLDOWN_FILE}"
|
||||
|
||||
{% if machine_type == 'vps' %}
|
||||
systemctl restart "${SERVICE_NAME}" 2>/dev/null && \
|
||||
log "Agent restarted via systemd." || \
|
||||
log "ERROR: systemd restart failed."
|
||||
{% else %}
|
||||
launchctl kickstart -k "ai.hermes.{{ wizard_name | lower }}" 2>/dev/null && \
|
||||
log "Agent restarted via launchctl." || \
|
||||
(cd "${WIZARD_HOME}" && hermes agent start --daemon 2>/dev/null && \
|
||||
log "Agent restarted via hermes CLI.") || \
|
||||
log "ERROR: All restart methods failed."
|
||||
{% endif %}
|
||||
|
||||
log_telemetry "success" "Agent restarted by deadman switch"
|
||||
}
|
||||
|
||||
# --- Health Check ---
|
||||
check_health() {
|
||||
# Check 1: Is the agent process running?
|
||||
{% if machine_type == 'vps' %}
|
||||
if ! systemctl is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
|
||||
if ! pgrep -f "hermes" > /dev/null 2>/dev/null; then
|
||||
log "FAIL: Agent process not running."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
{% else %}
|
||||
if ! pgrep -f "hermes" > /dev/null 2>/dev/null; then
|
||||
log "FAIL: Agent process not running."
|
||||
return 1
|
||||
fi
|
||||
{% endif %}
|
||||
|
||||
# Check 2: Is the API port responding?
|
||||
if ! timeout 10 bash -c "echo > /dev/tcp/127.0.0.1/{{ api_port }}" 2>/dev/null; then
|
||||
log "FAIL: API port {{ api_port }} not responding."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check 3: Does the config contain banned providers?
|
||||
if grep -qi 'anthropic\|claude-sonnet\|claude-opus\|claude-haiku' "${CONFIG_FILE}" 2>/dev/null; then
|
||||
log "FAIL: Config contains banned provider (Anthropic). Rolling back."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
main() {
|
||||
log "Health check starting..."
|
||||
|
||||
if check_health; then
|
||||
log "HEALTHY — snapshotting config."
|
||||
snapshot_config
|
||||
log_telemetry "success" "Health check passed"
|
||||
else
|
||||
log "UNHEALTHY — initiating recovery."
|
||||
log_telemetry "error" "Health check failed — initiating rollback"
|
||||
rollback_config
|
||||
restart_agent
|
||||
fi
|
||||
|
||||
log "Health check complete."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!-- Deadman Switch — {{ wizard_name }}. Generated by Ansible. DO NOT EDIT MANUALLY. -->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.timmy.deadman.{{ wizard_name | lower }}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>{{ wizard_home }}/deadman_action.sh</string>
|
||||
</array>
|
||||
<key>StartInterval</key>
|
||||
<integer>{{ deadman_check_interval }}</integer>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>{{ timmy_log_dir }}/deadman-{{ wizard_name }}.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{{ timmy_log_dir }}/deadman-{{ wizard_name }}.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,16 @@
|
||||
# Deadman Switch — {{ wizard_name }}
|
||||
# Generated by Ansible. DO NOT EDIT MANUALLY.
|
||||
|
||||
[Unit]
|
||||
Description=Deadman Switch for {{ wizard_name }} wizard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart={{ wizard_home }}/deadman_action.sh
|
||||
User={{ ansible_user | default('root') }}
|
||||
StandardOutput=append:{{ timmy_log_dir }}/deadman-{{ wizard_name }}.log
|
||||
StandardError=append:{{ timmy_log_dir }}/deadman-{{ wizard_name }}.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,14 @@
|
||||
# Deadman Switch Timer — {{ wizard_name }}
|
||||
# Generated by Ansible. DO NOT EDIT MANUALLY.
|
||||
# Runs every {{ deadman_check_interval // 60 }} minutes.
|
||||
|
||||
[Unit]
|
||||
Description=Deadman Switch Timer for {{ wizard_name }} wizard
|
||||
|
||||
[Timer]
|
||||
OnBootSec=60
|
||||
OnUnitActiveSec={{ deadman_check_interval }}s
|
||||
AccuracySec=30s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
6
ansible/roles/golden_state/defaults/main.yml
Normal file
6
ansible/roles/golden_state/defaults/main.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
# golden_state defaults
|
||||
# The golden_state_providers list is defined in group_vars/wizards.yml
|
||||
# and inventory/hosts.yml (global vars).
|
||||
golden_state_enforce: true
|
||||
golden_state_backup_before_deploy: true
|
||||
46
ansible/roles/golden_state/tasks/main.yml
Normal file
46
ansible/roles/golden_state/tasks/main.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# golden_state/tasks — Deploy and enforce golden state provider chain
|
||||
# =============================================================================
|
||||
|
||||
- name: "Backup current config before golden state deploy"
|
||||
copy:
|
||||
src: "{{ wizard_home }}/config.yaml"
|
||||
dest: "{{ wizard_home }}/config.yaml.pre-golden-{{ ansible_date_time.epoch }}"
|
||||
remote_src: true
|
||||
when: golden_state_backup_before_deploy
|
||||
ignore_errors: true
|
||||
|
||||
- name: "Deploy golden state wizard config"
|
||||
template:
|
||||
src: "../../wizard_base/templates/wizard_config.yaml.j2"
|
||||
dest: "{{ wizard_home }}/config.yaml"
|
||||
mode: "0644"
|
||||
backup: true
|
||||
notify:
|
||||
- "Restart hermes agent (systemd)"
|
||||
- "Restart hermes agent (launchctl)"
|
||||
|
||||
- name: "Scan for banned providers in all config files"
|
||||
shell: |
|
||||
FOUND=0
|
||||
for f in {{ wizard_home }}/config.yaml {{ hermes_home }}/config.yaml; do
|
||||
if [ -f "$f" ]; then
|
||||
if grep -qi 'anthropic\|claude-sonnet\|claude-opus\|claude-haiku' "$f"; then
|
||||
echo "BANNED PROVIDER in $f:"
|
||||
grep -ni 'anthropic\|claude-sonnet\|claude-opus\|claude-haiku' "$f"
|
||||
FOUND=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
exit $FOUND
|
||||
register: provider_scan
|
||||
changed_when: false
|
||||
failed_when: provider_scan.rc != 0 and provider_ban_enforcement == 'strict'
|
||||
|
||||
- name: "Report golden state deployment"
|
||||
debug:
|
||||
msg: >
|
||||
{{ wizard_name }} golden state deployed.
|
||||
Provider chain: {{ golden_state_providers | map(attribute='name') | list | join(' → ') }}.
|
||||
Banned provider scan: {{ 'CLEAN' if provider_scan.rc == 0 else 'VIOLATIONS FOUND' }}.
|
||||
64
ansible/roles/request_log/files/request_log_schema.sql
Normal file
64
ansible/roles/request_log/files/request_log_schema.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
-- =============================================================================
|
||||
-- request_log — Inference Telemetry Table
|
||||
-- =============================================================================
|
||||
-- Every agent writes to this table BEFORE and AFTER every inference call.
|
||||
-- No exceptions. No summarizing. No describing what you would log.
|
||||
-- Actually write the row.
|
||||
--
|
||||
-- Source: KT Bezalel Architecture Session 2026-04-08
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS request_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
agent_name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
tokens_in INTEGER,
|
||||
tokens_out INTEGER,
|
||||
latency_ms INTEGER,
|
||||
status TEXT NOT NULL, -- 'success', 'error', 'timeout', 'fallback'
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
-- Index for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_request_log_agent
|
||||
ON request_log (agent_name, timestamp);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_log_provider
|
||||
ON request_log (provider, timestamp);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_log_status
|
||||
ON request_log (status, timestamp);
|
||||
|
||||
-- View: recent activity per agent (last hour)
|
||||
CREATE VIEW IF NOT EXISTS v_recent_activity AS
|
||||
SELECT
|
||||
agent_name,
|
||||
provider,
|
||||
model,
|
||||
status,
|
||||
COUNT(*) as call_count,
|
||||
AVG(latency_ms) as avg_latency_ms,
|
||||
SUM(tokens_in) as total_tokens_in,
|
||||
SUM(tokens_out) as total_tokens_out
|
||||
FROM request_log
|
||||
WHERE timestamp > datetime('now', '-1 hour')
|
||||
GROUP BY agent_name, provider, model, status;
|
||||
|
||||
-- View: provider reliability (last 24 hours)
|
||||
CREATE VIEW IF NOT EXISTS v_provider_reliability AS
|
||||
SELECT
|
||||
provider,
|
||||
model,
|
||||
COUNT(*) as total_calls,
|
||||
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successes,
|
||||
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors,
|
||||
SUM(CASE WHEN status = 'timeout' THEN 1 ELSE 0 END) as timeouts,
|
||||
SUM(CASE WHEN status = 'fallback' THEN 1 ELSE 0 END) as fallbacks,
|
||||
ROUND(100.0 * SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) / COUNT(*), 1) as success_rate,
|
||||
AVG(latency_ms) as avg_latency_ms
|
||||
FROM request_log
|
||||
WHERE timestamp > datetime('now', '-24 hours')
|
||||
GROUP BY provider, model;
|
||||
50
ansible/roles/request_log/tasks/main.yml
Normal file
50
ansible/roles/request_log/tasks/main.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# request_log/tasks — Deploy Telemetry Table
|
||||
# =============================================================================
|
||||
# "This is non-negotiable infrastructure. Without it, we cannot verify
|
||||
# if any agent actually executed what it claims."
|
||||
# — KT Bezalel 2026-04-08
|
||||
# =============================================================================
|
||||
|
||||
- name: "Create telemetry directory"
|
||||
file:
|
||||
path: "{{ request_log_path | dirname }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: "Deploy request_log schema"
|
||||
copy:
|
||||
src: request_log_schema.sql
|
||||
dest: "{{ wizard_home }}/request_log_schema.sql"
|
||||
mode: "0644"
|
||||
|
||||
- name: "Initialize request_log database"
|
||||
shell: |
|
||||
sqlite3 "{{ request_log_path }}" < "{{ wizard_home }}/request_log_schema.sql"
|
||||
args:
|
||||
creates: "{{ request_log_path }}"
|
||||
|
||||
- name: "Verify request_log table exists"
|
||||
shell: |
|
||||
sqlite3 "{{ request_log_path }}" ".tables" | grep -q "request_log"
|
||||
register: table_check
|
||||
changed_when: false
|
||||
|
||||
- name: "Verify request_log schema matches"
|
||||
shell: |
|
||||
sqlite3 "{{ request_log_path }}" ".schema request_log" | grep -q "agent_name"
|
||||
register: schema_check
|
||||
changed_when: false
|
||||
|
||||
- name: "Set permissions on request_log database"
|
||||
file:
|
||||
path: "{{ request_log_path }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: "Report request_log status"
|
||||
debug:
|
||||
msg: >
|
||||
{{ wizard_name }} request_log: {{ request_log_path }}
|
||||
— table exists: {{ table_check.rc == 0 }}
|
||||
— schema valid: {{ schema_check.rc == 0 }}
|
||||
6
ansible/roles/wizard_base/defaults/main.yml
Normal file
6
ansible/roles/wizard_base/defaults/main.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
# wizard_base defaults
|
||||
wizard_user: "{{ ansible_user | default('root') }}"
|
||||
wizard_group: "{{ ansible_user | default('root') }}"
|
||||
timmy_base_dir: "~/.local/timmy"
|
||||
timmy_config_repo: "https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git"
|
||||
11
ansible/roles/wizard_base/handlers/main.yml
Normal file
11
ansible/roles/wizard_base/handlers/main.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
- name: "Restart hermes agent (systemd)"
|
||||
systemd:
|
||||
name: "hermes-{{ wizard_name | lower }}"
|
||||
state: restarted
|
||||
when: machine_type == 'vps'
|
||||
|
||||
- name: "Restart hermes agent (launchctl)"
|
||||
shell: "launchctl kickstart -k ai.hermes.{{ wizard_name | lower }}"
|
||||
when: machine_type == 'mac'
|
||||
ignore_errors: true
|
||||
69
ansible/roles/wizard_base/tasks/main.yml
Normal file
69
ansible/roles/wizard_base/tasks/main.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# wizard_base/tasks — Common wizard setup
|
||||
# =============================================================================
|
||||
|
||||
- name: "Create wizard directories"
|
||||
file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ wizard_home }}"
|
||||
- "{{ wizard_home }}/workspace"
|
||||
- "{{ hermes_home }}"
|
||||
- "{{ hermes_home }}/bin"
|
||||
- "{{ hermes_home }}/skins"
|
||||
- "{{ hermes_home }}/playbooks"
|
||||
- "{{ hermes_home }}/memories"
|
||||
- "~/.local/timmy"
|
||||
- "~/.local/timmy/fleet-health"
|
||||
- "~/.local/timmy/snapshots"
|
||||
- "~/.timmy"
|
||||
|
||||
- name: "Clone/update timmy-config"
|
||||
git:
|
||||
repo: "{{ upstream_repo }}"
|
||||
dest: "{{ wizard_home }}/workspace/timmy-config"
|
||||
version: "{{ upstream_branch }}"
|
||||
force: false
|
||||
update: true
|
||||
ignore_errors: true # May fail on first run if no SSH key
|
||||
|
||||
- name: "Deploy SOUL.md"
|
||||
copy:
|
||||
src: "{{ wizard_home }}/workspace/timmy-config/SOUL.md"
|
||||
dest: "~/.timmy/SOUL.md"
|
||||
remote_src: true
|
||||
mode: "0644"
|
||||
ignore_errors: true
|
||||
|
||||
- name: "Deploy thin config (immutable pointer to upstream)"
|
||||
template:
|
||||
src: thin_config.yml.j2
|
||||
dest: "{{ thin_config_path }}"
|
||||
mode: "{{ thin_config_mode }}"
|
||||
tags: [thin_config]
|
||||
|
||||
- name: "Ensure Python3 and pip are available"
|
||||
package:
|
||||
name:
|
||||
- python3
|
||||
- python3-pip
|
||||
state: present
|
||||
when: machine_type == 'vps'
|
||||
ignore_errors: true
|
||||
|
||||
- name: "Ensure PyYAML is installed (for config validation)"
|
||||
pip:
|
||||
name: pyyaml
|
||||
state: present
|
||||
when: machine_type == 'vps'
|
||||
ignore_errors: true
|
||||
|
||||
- name: "Create Ansible log directory"
|
||||
file:
|
||||
path: /var/log/ansible
|
||||
state: directory
|
||||
mode: "0755"
|
||||
ignore_errors: true
|
||||
41
ansible/roles/wizard_base/templates/thin_config.yml.j2
Normal file
41
ansible/roles/wizard_base/templates/thin_config.yml.j2
Normal file
@@ -0,0 +1,41 @@
|
||||
# =============================================================================
|
||||
# Thin Config — {{ wizard_name }}
|
||||
# =============================================================================
|
||||
# THIS FILE IS READ-ONLY. Agents CANNOT modify it.
|
||||
# It contains only pointers to upstream. The actual config lives in Gitea.
|
||||
#
|
||||
# Agent wakes up → pulls config from upstream → loads → runs.
|
||||
# If anything tries to mutate this → fails gracefully → pulls fresh on restart.
|
||||
#
|
||||
# Only way to permanently change config: commit to Gitea, merge PR, Ansible deploys.
|
||||
#
|
||||
# Generated by Ansible on {{ ansible_date_time.iso8601 }}
|
||||
# DO NOT EDIT MANUALLY.
|
||||
# =============================================================================
|
||||
|
||||
identity:
|
||||
wizard_name: "{{ wizard_name }}"
|
||||
wizard_role: "{{ wizard_role }}"
|
||||
machine: "{{ inventory_hostname }}"
|
||||
|
||||
upstream:
|
||||
repo: "{{ upstream_repo }}"
|
||||
branch: "{{ upstream_branch }}"
|
||||
config_path: "wizards/{{ wizard_name | lower }}/config.yaml"
|
||||
pull_on_wake: {{ config_pull_on_wake | lower }}
|
||||
|
||||
recovery:
|
||||
deadman_enabled: {{ deadman_enabled | lower }}
|
||||
snapshot_dir: "{{ deadman_snapshot_dir }}"
|
||||
restart_cooldown: {{ deadman_restart_cooldown }}
|
||||
max_restart_attempts: {{ deadman_max_restart_attempts }}
|
||||
escalation_channel: "{{ deadman_escalation_channel }}"
|
||||
|
||||
telemetry:
|
||||
request_log_path: "{{ request_log_path }}"
|
||||
request_log_enabled: {{ request_log_enabled | lower }}
|
||||
|
||||
local_overrides:
|
||||
# Runtime overrides go here. They are EPHEMERAL — not persisted across restarts.
|
||||
# On restart, this section is reset to empty.
|
||||
{}
|
||||
115
ansible/roles/wizard_base/templates/wizard_config.yaml.j2
Normal file
115
ansible/roles/wizard_base/templates/wizard_config.yaml.j2
Normal file
@@ -0,0 +1,115 @@
|
||||
# =============================================================================
|
||||
# {{ wizard_name }} — Wizard Configuration (Golden State)
|
||||
# =============================================================================
|
||||
# Generated by Ansible on {{ ansible_date_time.iso8601 }}
|
||||
# DO NOT EDIT MANUALLY. Changes go through Gitea PR → Ansible deploy.
|
||||
#
|
||||
# Provider chain: {{ golden_state_providers | map(attribute='name') | list | join(' → ') }}
|
||||
# Anthropic is PERMANENTLY BANNED.
|
||||
# =============================================================================
|
||||
|
||||
model:
|
||||
default: {{ wizard_model_primary }}
|
||||
provider: {{ wizard_provider_primary }}
|
||||
context_length: 65536
|
||||
base_url: {{ golden_state_providers[0].base_url }}
|
||||
|
||||
toolsets:
|
||||
- all
|
||||
|
||||
fallback_providers:
|
||||
{% for provider in golden_state_providers %}
|
||||
- provider: {{ provider.name }}
|
||||
model: {{ provider.model }}
|
||||
{% if provider.base_url is defined %}
|
||||
base_url: {{ provider.base_url }}
|
||||
{% endif %}
|
||||
{% if provider.api_key_env is defined %}
|
||||
api_key_env: {{ provider.api_key_env }}
|
||||
{% endif %}
|
||||
timeout: {{ provider.timeout }}
|
||||
reason: "{{ provider.reason }}"
|
||||
{% endfor %}
|
||||
|
||||
agent:
|
||||
max_turns: {{ agent_max_turns }}
|
||||
reasoning_effort: {{ agent_reasoning_effort }}
|
||||
verbose: {{ agent_verbose | lower }}
|
||||
|
||||
terminal:
|
||||
backend: local
|
||||
cwd: .
|
||||
timeout: 180
|
||||
persistent_shell: true
|
||||
|
||||
browser:
|
||||
inactivity_timeout: 120
|
||||
command_timeout: 30
|
||||
record_sessions: false
|
||||
|
||||
display:
|
||||
compact: false
|
||||
personality: ''
|
||||
resume_display: full
|
||||
busy_input_mode: interrupt
|
||||
bell_on_complete: false
|
||||
show_reasoning: false
|
||||
streaming: false
|
||||
show_cost: false
|
||||
tool_progress: all
|
||||
|
||||
memory:
|
||||
memory_enabled: true
|
||||
user_profile_enabled: true
|
||||
memory_char_limit: 2200
|
||||
user_char_limit: 1375
|
||||
nudge_interval: 10
|
||||
flush_min_turns: 6
|
||||
|
||||
approvals:
|
||||
mode: {{ agent_approval_mode }}
|
||||
|
||||
security:
|
||||
redact_secrets: true
|
||||
tirith_enabled: false
|
||||
|
||||
platforms:
|
||||
api_server:
|
||||
enabled: true
|
||||
extra:
|
||||
host: 127.0.0.1
|
||||
port: {{ api_port }}
|
||||
|
||||
session_reset:
|
||||
mode: none
|
||||
idle_minutes: 0
|
||||
|
||||
skills:
|
||||
creation_nudge_interval: 15
|
||||
|
||||
system_prompt_suffix: |
|
||||
You are {{ wizard_name }}, {{ wizard_role }}.
|
||||
Your soul is defined in SOUL.md — read it, live it.
|
||||
Hermes is your harness.
|
||||
{{ golden_state_providers[0].name }} is your primary provider.
|
||||
Refusal over fabrication. If you do not know, say so.
|
||||
Sovereignty and service always.
|
||||
|
||||
providers:
|
||||
{% for provider in golden_state_providers %}
|
||||
{{ provider.name }}:
|
||||
base_url: {{ provider.base_url }}
|
||||
timeout: {{ provider.timeout | default(60) }}
|
||||
{% if provider.name == 'kimi-coding' %}
|
||||
max_retries: 3
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
# =============================================================================
|
||||
# BANNED PROVIDERS — DO NOT ADD
|
||||
# =============================================================================
|
||||
# The following providers are PERMANENTLY BANNED:
|
||||
# - anthropic (any model: claude-sonnet, claude-opus, claude-haiku)
|
||||
# Enforcement: pre-commit hook, linter, Ansible validation, this comment.
|
||||
# Adding any banned provider will cause Ansible deployment to FAIL.
|
||||
# =============================================================================
|
||||
75
ansible/scripts/deploy_on_webhook.sh
Normal file
75
ansible/scripts/deploy_on_webhook.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Gitea Webhook Handler — Trigger Ansible Deploy on Merge
|
||||
# =============================================================================
|
||||
# This script is called by the Gitea webhook when a PR is merged
|
||||
# to the main branch of timmy-config.
|
||||
#
|
||||
# Setup:
|
||||
# 1. Add webhook in Gitea: Settings → Webhooks → Add Webhook
|
||||
# 2. URL: http://localhost:9000/hooks/deploy-timmy-config
|
||||
# 3. Events: Pull Request (merged only)
|
||||
# 4. Secret: <configured in Gitea>
|
||||
#
|
||||
# This script runs ansible-pull to update the local machine.
|
||||
# For fleet-wide deploys, each machine runs ansible-pull independently.
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git"
|
||||
BRANCH="main"
|
||||
ANSIBLE_DIR="ansible"
|
||||
LOG_FILE="/var/log/ansible/webhook-deploy.log"
|
||||
LOCK_FILE="/tmp/ansible-deploy.lock"
|
||||
|
||||
log() {
|
||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] [webhook] $*" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
# Prevent concurrent deploys
|
||||
if [ -f "${LOCK_FILE}" ]; then
|
||||
LOCK_AGE=$(( $(date +%s) - $(stat -c %Y "${LOCK_FILE}" 2>/dev/null || echo 0) ))
|
||||
if [ "${LOCK_AGE}" -lt 300 ]; then
|
||||
log "Deploy already in progress (lock age: ${LOCK_AGE}s). Skipping."
|
||||
exit 0
|
||||
else
|
||||
log "Stale lock file (${LOCK_AGE}s old). Removing."
|
||||
rm -f "${LOCK_FILE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
trap 'rm -f "${LOCK_FILE}"' EXIT
|
||||
touch "${LOCK_FILE}"
|
||||
|
||||
log "Webhook triggered. Starting ansible-pull..."
|
||||
|
||||
# Pull latest config
|
||||
cd /tmp
|
||||
rm -rf timmy-config-deploy
|
||||
git clone --depth 1 --branch "${BRANCH}" "${REPO}" timmy-config-deploy 2>&1 | tee -a "${LOG_FILE}"
|
||||
|
||||
cd timmy-config-deploy/${ANSIBLE_DIR}
|
||||
|
||||
# Run Ansible against localhost
|
||||
log "Running Ansible playbook..."
|
||||
ansible-playbook \
|
||||
-i inventory/hosts.yml \
|
||||
playbooks/site.yml \
|
||||
--limit "$(hostname)" \
|
||||
--diff \
|
||||
2>&1 | tee -a "${LOG_FILE}"
|
||||
|
||||
RESULT=$?
|
||||
|
||||
if [ ${RESULT} -eq 0 ]; then
|
||||
log "Deploy successful."
|
||||
else
|
||||
log "ERROR: Deploy failed with exit code ${RESULT}."
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf /tmp/timmy-config-deploy
|
||||
|
||||
log "Webhook handler complete."
|
||||
exit ${RESULT}
|
||||
155
ansible/scripts/validate_config.py
Normal file
155
ansible/scripts/validate_config.py
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Config Validator — The Timmy Foundation
|
||||
Validates wizard configs against golden state rules.
|
||||
Run before any config deploy to catch violations early.
|
||||
|
||||
Usage:
|
||||
python3 validate_config.py <config_file>
|
||||
python3 validate_config.py --all # Validate all wizard configs
|
||||
|
||||
Exit codes:
|
||||
0 — All validations passed
|
||||
1 — Validation errors found
|
||||
2 — File not found or parse error
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import yaml
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
|
||||
# === BANNED PROVIDERS — HARD POLICY ===
|
||||
BANNED_PROVIDERS = {"anthropic", "claude"}
|
||||
BANNED_MODEL_PATTERNS = [
|
||||
"claude-*",
|
||||
"anthropic/*",
|
||||
"*sonnet*",
|
||||
"*opus*",
|
||||
"*haiku*",
|
||||
]
|
||||
|
||||
# === REQUIRED FIELDS ===
|
||||
REQUIRED_FIELDS = {
|
||||
"model": ["default", "provider"],
|
||||
"fallback_providers": None, # Must exist as a list
|
||||
}
|
||||
|
||||
|
||||
def is_banned_model(model_name: str) -> bool:
|
||||
"""Check if a model name matches any banned pattern."""
|
||||
model_lower = model_name.lower()
|
||||
for pattern in BANNED_MODEL_PATTERNS:
|
||||
if fnmatch.fnmatch(model_lower, pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_config(config_path: str) -> list[str]:
|
||||
"""Validate a wizard config file. Returns list of error strings."""
|
||||
errors = []
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
except FileNotFoundError:
|
||||
return [f"File not found: {config_path}"]
|
||||
except yaml.YAMLError as e:
|
||||
return [f"YAML parse error: {e}"]
|
||||
|
||||
if not cfg:
|
||||
return ["Config file is empty"]
|
||||
|
||||
# Check required fields
|
||||
for section, fields in REQUIRED_FIELDS.items():
|
||||
if section not in cfg:
|
||||
errors.append(f"Missing required section: {section}")
|
||||
elif fields:
|
||||
for field in fields:
|
||||
if field not in cfg[section]:
|
||||
errors.append(f"Missing required field: {section}.{field}")
|
||||
|
||||
# Check default provider
|
||||
default_provider = cfg.get("model", {}).get("provider", "")
|
||||
if default_provider.lower() in BANNED_PROVIDERS:
|
||||
errors.append(f"BANNED default provider: {default_provider}")
|
||||
|
||||
default_model = cfg.get("model", {}).get("default", "")
|
||||
if is_banned_model(default_model):
|
||||
errors.append(f"BANNED default model: {default_model}")
|
||||
|
||||
# Check fallback providers
|
||||
for i, fb in enumerate(cfg.get("fallback_providers", [])):
|
||||
provider = fb.get("provider", "")
|
||||
model = fb.get("model", "")
|
||||
|
||||
if provider.lower() in BANNED_PROVIDERS:
|
||||
errors.append(f"BANNED fallback provider [{i}]: {provider}")
|
||||
|
||||
if is_banned_model(model):
|
||||
errors.append(f"BANNED fallback model [{i}]: {model}")
|
||||
|
||||
# Check providers section
|
||||
for name, provider_cfg in cfg.get("providers", {}).items():
|
||||
if name.lower() in BANNED_PROVIDERS:
|
||||
errors.append(f"BANNED provider in providers section: {name}")
|
||||
|
||||
base_url = str(provider_cfg.get("base_url", ""))
|
||||
if "anthropic" in base_url.lower():
|
||||
errors.append(f"BANNED URL in provider {name}: {base_url}")
|
||||
|
||||
# Check system prompt for banned references
|
||||
prompt = cfg.get("system_prompt_suffix", "")
|
||||
if isinstance(prompt, str):
|
||||
for banned in BANNED_PROVIDERS:
|
||||
if banned in prompt.lower():
|
||||
errors.append(f"BANNED provider referenced in system_prompt_suffix: {banned}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <config_file> [--all]")
|
||||
sys.exit(2)
|
||||
|
||||
if sys.argv[1] == "--all":
|
||||
# Validate all wizard configs in the repo
|
||||
repo_root = Path(__file__).parent.parent.parent
|
||||
wizard_dir = repo_root / "wizards"
|
||||
all_errors = {}
|
||||
|
||||
for wizard_path in sorted(wizard_dir.iterdir()):
|
||||
config_file = wizard_path / "config.yaml"
|
||||
if config_file.exists():
|
||||
errors = validate_config(str(config_file))
|
||||
if errors:
|
||||
all_errors[wizard_path.name] = errors
|
||||
|
||||
if all_errors:
|
||||
print("VALIDATION FAILED:")
|
||||
for wizard, errors in all_errors.items():
|
||||
print(f"\n {wizard}:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("All wizard configs passed validation.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
config_path = sys.argv[1]
|
||||
errors = validate_config(config_path)
|
||||
|
||||
if errors:
|
||||
print(f"VALIDATION FAILED for {config_path}:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"PASSED: {config_path}")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -202,6 +202,19 @@ curl -s -X POST "{gitea_url}/api/v1/repos/{repo}/issues/{issue_num}/comments" \\
|
||||
REVIEW CHECKLIST BEFORE YOU PUSH:
|
||||
{review}
|
||||
|
||||
COMMIT DISCIPLINE (CRITICAL):
|
||||
- Commit every 3-5 tool calls. Do NOT wait until the end.
|
||||
- After every meaningful file change: git add -A && git commit -m "WIP: <what changed>"
|
||||
- Before running any destructive command: commit current state first.
|
||||
- If you are unsure whether to commit: commit. WIP commits are safe. Lost work is not.
|
||||
- Never use --no-verify.
|
||||
- The auto-commit-guard is your safety net, but do not rely on it. Commit proactively.
|
||||
|
||||
RECOVERY COMMANDS (if interrupted, another agent can resume):
|
||||
git log --oneline -10 # see your WIP commits
|
||||
git diff HEAD~1 # see what the last commit changed
|
||||
git status # see uncommitted work
|
||||
|
||||
RULES:
|
||||
- Do not skip hooks with --no-verify.
|
||||
- Do not silently widen the scope.
|
||||
|
||||
@@ -161,6 +161,14 @@ run_worker() {
|
||||
CYCLE_END=$(date +%s)
|
||||
CYCLE_DURATION=$((CYCLE_END - CYCLE_START))
|
||||
|
||||
# --- Mid-session auto-commit: commit before timeout if work is dirty ---
|
||||
cd "$worktree" 2>/dev/null || true
|
||||
# Ensure auto-commit-guard is running
|
||||
if ! pgrep -f "auto-commit-guard.sh" >/dev/null 2>&1; then
|
||||
log "Starting auto-commit-guard daemon"
|
||||
nohup bash "$(dirname "$0")/auto-commit-guard.sh" 120 "$WORKTREE_BASE" >> "$LOG_DIR/auto-commit-guard.log" 2>&1 &
|
||||
fi
|
||||
|
||||
# Salvage
|
||||
cd "$worktree" 2>/dev/null || true
|
||||
DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
159
bin/auto-commit-guard.sh
Normal file
159
bin/auto-commit-guard.sh
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
# auto-commit-guard.sh — Background daemon that auto-commits uncommitted work
|
||||
#
|
||||
# Usage: auto-commit-guard.sh [interval_seconds] [worktree_base]
|
||||
# auto-commit-guard.sh # defaults: 120s, ~/worktrees
|
||||
# auto-commit-guard.sh 60 # check every 60s
|
||||
# auto-commit-guard.sh 180 ~/my-worktrees
|
||||
#
|
||||
# Scans all git repos under the worktree base for uncommitted changes.
|
||||
# If dirty for >= 1 check cycle, auto-commits with a WIP message.
|
||||
# Pushes unpushed commits so work is always recoverable from the remote.
|
||||
#
|
||||
# Also scans /tmp for orphaned agent workdirs on startup.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
INTERVAL="${1:-120}"
|
||||
WORKTREE_BASE="${2:-$HOME/worktrees}"
|
||||
LOG_DIR="$HOME/.hermes/logs"
|
||||
LOG="$LOG_DIR/auto-commit-guard.log"
|
||||
PIDFILE="$LOG_DIR/auto-commit-guard.pid"
|
||||
ORPHAN_SCAN_DONE="$LOG_DIR/.orphan-scan-done"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# Single instance guard
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
old_pid=$(cat "$PIDFILE")
|
||||
if kill -0 "$old_pid" 2>/dev/null; then
|
||||
echo "auto-commit-guard already running (PID $old_pid)" >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo $$ > "$PIDFILE"
|
||||
trap 'rm -f "$PIDFILE"' EXIT
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] AUTO-COMMIT: $*" >> "$LOG"
|
||||
}
|
||||
|
||||
# --- Orphaned workdir scan (runs once on startup) ---
|
||||
scan_orphans() {
|
||||
if [ -f "$ORPHAN_SCAN_DONE" ]; then
|
||||
return 0
|
||||
fi
|
||||
log "Scanning /tmp for orphaned agent workdirs..."
|
||||
local found=0
|
||||
local rescued=0
|
||||
|
||||
for dir in /tmp/*-work-* /tmp/timmy-burn-* /tmp/tc-burn; do
|
||||
[ -d "$dir" ] || continue
|
||||
[ -d "$dir/.git" ] || continue
|
||||
|
||||
found=$((found + 1))
|
||||
cd "$dir" 2>/dev/null || continue
|
||||
|
||||
local dirty
|
||||
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||
if [ "${dirty:-0}" -gt 0 ]; then
|
||||
local branch
|
||||
branch=$(git branch --show-current 2>/dev/null || echo "orphan")
|
||||
git add -A 2>/dev/null
|
||||
if git commit -m "WIP: orphan rescue — $dirty file(s) auto-committed on $(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
Orphaned workdir detected at $dir.
|
||||
Branch: $branch
|
||||
Rescued by auto-commit-guard on startup." 2>/dev/null; then
|
||||
rescued=$((rescued + 1))
|
||||
log "RESCUED: $dir ($dirty files on branch $branch)"
|
||||
|
||||
# Try to push if remote exists
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
git push -u origin "$branch" 2>/dev/null && log "PUSHED orphan rescue: $dir → $branch" || log "PUSH FAILED orphan rescue: $dir (no remote access)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
log "Orphan scan complete: $found workdirs checked, $rescued rescued"
|
||||
touch "$ORPHAN_SCAN_DONE"
|
||||
}
|
||||
|
||||
# --- Main guard loop ---
|
||||
guard_cycle() {
|
||||
local committed=0
|
||||
local scanned=0
|
||||
|
||||
# Scan worktree base
|
||||
if [ -d "$WORKTREE_BASE" ]; then
|
||||
for dir in "$WORKTREE_BASE"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
[ -d "$dir/.git" ] || continue
|
||||
|
||||
scanned=$((scanned + 1))
|
||||
cd "$dir" 2>/dev/null || continue
|
||||
|
||||
local dirty
|
||||
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||
[ "${dirty:-0}" -eq 0 ] && continue
|
||||
|
||||
local branch
|
||||
branch=$(git branch --show-current 2>/dev/null || echo "detached")
|
||||
|
||||
git add -A 2>/dev/null
|
||||
if git commit -m "WIP: auto-commit — $dirty file(s) on $branch
|
||||
|
||||
Automated commit by auto-commit-guard at $(date -u +%Y-%m-%dT%H:%M:%SZ).
|
||||
Work preserved to prevent loss on crash." 2>/dev/null; then
|
||||
committed=$((committed + 1))
|
||||
log "COMMITTED: $dir ($dirty files, branch $branch)"
|
||||
|
||||
# Push to preserve remotely
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
git push -u origin "$branch" 2>/dev/null && log "PUSHED: $dir → $branch" || log "PUSH FAILED: $dir (will retry next cycle)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Also scan /tmp for agent workdirs
|
||||
for dir in /tmp/*-work-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
[ -d "$dir/.git" ] || continue
|
||||
|
||||
scanned=$((scanned + 1))
|
||||
cd "$dir" 2>/dev/null || continue
|
||||
|
||||
local dirty
|
||||
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||
[ "${dirty:-0}" -eq 0 ] && continue
|
||||
|
||||
local branch
|
||||
branch=$(git branch --show-current 2>/dev/null || echo "detached")
|
||||
|
||||
git add -A 2>/dev/null
|
||||
if git commit -m "WIP: auto-commit — $dirty file(s) on $branch
|
||||
|
||||
Automated commit by auto-commit-guard at $(date -u +%Y-%m-%dT%H:%M:%SZ).
|
||||
Agent workdir preserved to prevent loss." 2>/dev/null; then
|
||||
committed=$((committed + 1))
|
||||
log "COMMITTED: $dir ($dirty files, branch $branch)"
|
||||
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
git push -u origin "$branch" 2>/dev/null && log "PUSHED: $dir → $branch" || log "PUSH FAILED: $dir (will retry next cycle)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$committed" -gt 0 ] && log "Cycle done: $scanned scanned, $committed committed"
|
||||
}
|
||||
|
||||
# --- Entry point ---
|
||||
log "Starting auto-commit-guard (interval=${INTERVAL}s, worktree=${WORKTREE_BASE})"
|
||||
scan_orphans
|
||||
|
||||
while true; do
|
||||
guard_cycle
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
82
bin/banned_provider_scan.py
Normal file
82
bin/banned_provider_scan.py
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Anthropic Ban Enforcement Scanner.
|
||||
|
||||
Scans all config files, scripts, and playbooks for any references to
|
||||
banned Anthropic providers, models, or API keys.
|
||||
|
||||
Policy: Anthropic is permanently banned (2026-04-09).
|
||||
Refs: ansible/BANNED_PROVIDERS.yml
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
BANNED_PATTERNS = [
|
||||
r"anthropic",
|
||||
r"claude-sonnet",
|
||||
r"claude-opus",
|
||||
r"claude-haiku",
|
||||
r"claude-\d",
|
||||
r"api\.anthropic\.com",
|
||||
r"ANTHROPIC_API_KEY",
|
||||
r"CLAUDE_API_KEY",
|
||||
r"sk-ant-",
|
||||
]
|
||||
|
||||
ALLOWLIST_FILES = {
|
||||
"ansible/BANNED_PROVIDERS.yml", # The ban list itself
|
||||
"bin/banned_provider_scan.py", # This scanner
|
||||
"DEPRECATED.md", # Historical references
|
||||
}
|
||||
|
||||
SCAN_EXTENSIONS = {".py", ".yml", ".yaml", ".json", ".sh", ".toml", ".cfg", ".md"}
|
||||
|
||||
|
||||
def scan_file(filepath: str) -> list[tuple[int, str, str]]:
|
||||
"""Return list of (line_num, pattern_matched, line_text) violations."""
|
||||
violations = []
|
||||
try:
|
||||
with open(filepath, "r", errors="replace") as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
for pattern in BANNED_PATTERNS:
|
||||
if re.search(pattern, line, re.IGNORECASE):
|
||||
violations.append((i, pattern, line.strip()))
|
||||
break
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
root = Path(os.environ.get("SCAN_ROOT", "."))
|
||||
total_violations = 0
|
||||
scanned = 0
|
||||
|
||||
for ext in SCAN_EXTENSIONS:
|
||||
for filepath in root.rglob(f"*{ext}"):
|
||||
rel = str(filepath.relative_to(root))
|
||||
if rel in ALLOWLIST_FILES:
|
||||
continue
|
||||
if ".git" in filepath.parts:
|
||||
continue
|
||||
|
||||
violations = scan_file(str(filepath))
|
||||
scanned += 1
|
||||
if violations:
|
||||
total_violations += len(violations)
|
||||
for line_num, pattern, text in violations:
|
||||
print(f"VIOLATION: {rel}:{line_num} [{pattern}] {text[:120]}")
|
||||
|
||||
print(f"\nScanned {scanned} files. Found {total_violations} violations.")
|
||||
|
||||
if total_violations > 0:
|
||||
print("\n❌ BANNED PROVIDER REFERENCES DETECTED. Fix before merging.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n✓ No banned provider references found.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
120
bin/conflict_detector.py
Normal file
120
bin/conflict_detector.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Merge Conflict Detector — catches sibling PRs that will conflict.
|
||||
|
||||
When multiple PRs branch from the same base commit and touch the same files,
|
||||
merging one invalidates the others. This script detects that pattern
|
||||
before it creates a rebase cascade.
|
||||
|
||||
Usage:
|
||||
python3 conflict_detector.py # Check all repos
|
||||
python3 conflict_detector.py --repo OWNER/REPO # Check one repo
|
||||
|
||||
Environment:
|
||||
GITEA_URL — Gitea instance URL
|
||||
GITEA_TOKEN — API token
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
|
||||
REPOS = [
|
||||
"Timmy_Foundation/the-nexus",
|
||||
"Timmy_Foundation/timmy-config",
|
||||
"Timmy_Foundation/timmy-home",
|
||||
"Timmy_Foundation/fleet-ops",
|
||||
"Timmy_Foundation/hermes-agent",
|
||||
"Timmy_Foundation/the-beacon",
|
||||
]
|
||||
|
||||
def api(path):
|
||||
url = f"{GITEA_URL}/api/v1{path}"
|
||||
req = urllib.request.Request(url)
|
||||
if GITEA_TOKEN:
|
||||
req.add_header("Authorization", f"token {GITEA_TOKEN}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def check_repo(repo):
|
||||
"""Find sibling PRs that touch the same files."""
|
||||
prs = api(f"/repos/{repo}/pulls?state=open&limit=50")
|
||||
if not prs:
|
||||
return []
|
||||
|
||||
# Group PRs by base commit
|
||||
by_base = defaultdict(list)
|
||||
for pr in prs:
|
||||
base_sha = pr.get("merge_base", pr.get("base", {}).get("sha", "unknown"))
|
||||
by_base[base_sha].append(pr)
|
||||
|
||||
conflicts = []
|
||||
|
||||
for base_sha, siblings in by_base.items():
|
||||
if len(siblings) < 2:
|
||||
continue
|
||||
|
||||
# Get files for each sibling
|
||||
file_map = {}
|
||||
for pr in siblings:
|
||||
files = api(f"/repos/{repo}/pulls/{pr['number']}/files")
|
||||
if files:
|
||||
file_map[pr['number']] = set(f['filename'] for f in files)
|
||||
|
||||
# Find overlapping file sets
|
||||
pr_nums = list(file_map.keys())
|
||||
for i in range(len(pr_nums)):
|
||||
for j in range(i+1, len(pr_nums)):
|
||||
a, b = pr_nums[i], pr_nums[j]
|
||||
overlap = file_map[a] & file_map[b]
|
||||
if overlap:
|
||||
conflicts.append({
|
||||
"repo": repo,
|
||||
"pr_a": a,
|
||||
"pr_b": b,
|
||||
"base": base_sha[:8],
|
||||
"files": sorted(overlap),
|
||||
"title_a": next(p["title"] for p in siblings if p["number"] == a),
|
||||
"title_b": next(p["title"] for p in siblings if p["number"] == b),
|
||||
})
|
||||
|
||||
return conflicts
|
||||
|
||||
def main():
|
||||
repos = REPOS
|
||||
if "--repo" in sys.argv:
|
||||
idx = sys.argv.index("--repo") + 1
|
||||
if idx < len(sys.argv):
|
||||
repos = [sys.argv[idx]]
|
||||
|
||||
all_conflicts = []
|
||||
for repo in repos:
|
||||
conflicts = check_repo(repo)
|
||||
all_conflicts.extend(conflicts)
|
||||
|
||||
if not all_conflicts:
|
||||
print("No sibling PR conflicts detected. Queue is clean.")
|
||||
return 0
|
||||
|
||||
print(f"Found {len(all_conflicts)} potential merge conflicts:")
|
||||
print()
|
||||
for c in all_conflicts:
|
||||
print(f" {c['repo']}:")
|
||||
print(f" PR #{c['pr_a']} vs #{c['pr_b']} (base: {c['base']})")
|
||||
print(f" #{c['pr_a']}: {c['title_a'][:60]}")
|
||||
print(f" #{c['pr_b']}: {c['title_b'][:60]}")
|
||||
print(f" Overlapping files: {', '.join(c['files'])}")
|
||||
print(f" → Merge one first, then rebase the other.")
|
||||
print()
|
||||
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
263
bin/deadman-fallback.py
Normal file
263
bin/deadman-fallback.py
Normal file
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Dead Man Switch Fallback Engine
|
||||
|
||||
When the dead man switch triggers (zero commits for 2+ hours, model down,
|
||||
Gitea unreachable, etc.), this script diagnoses the failure and applies
|
||||
common sense fallbacks automatically.
|
||||
|
||||
Fallback chain:
|
||||
1. Primary model (Kimi) down -> switch config to local-llama.cpp
|
||||
2. Gitea unreachable -> cache issues locally, retry on recovery
|
||||
3. VPS agents down -> alert + lazarus protocol
|
||||
4. Local llama.cpp down -> try Ollama, then alert-only mode
|
||||
5. All inference dead -> safe mode (cron pauses, alert Alexander)
|
||||
|
||||
Each fallback is reversible. Recovery auto-restores the previous config.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
import yaml
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
||||
CONFIG_PATH = HERMES_HOME / "config.yaml"
|
||||
FALLBACK_STATE = HERMES_HOME / "deadman-fallback-state.json"
|
||||
BACKUP_CONFIG = HERMES_HOME / "config.yaml.pre-fallback"
|
||||
FORGE_URL = "https://forge.alexanderwhitestone.com"
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG_PATH) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def save_config(cfg):
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
yaml.dump(cfg, f, default_flow_style=False)
|
||||
|
||||
def load_state():
|
||||
if FALLBACK_STATE.exists():
|
||||
with open(FALLBACK_STATE) as f:
|
||||
return json.load(f)
|
||||
return {"active_fallbacks": [], "last_check": None, "recovery_pending": False}
|
||||
|
||||
def save_state(state):
|
||||
state["last_check"] = datetime.now().isoformat()
|
||||
with open(FALLBACK_STATE, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
def run(cmd, timeout=10):
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||
return r.returncode, r.stdout.strip(), r.stderr.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, "", "timeout"
|
||||
except Exception as e:
|
||||
return -1, "", str(e)
|
||||
|
||||
# ─── HEALTH CHECKS ───
|
||||
|
||||
def check_kimi():
|
||||
"""Can we reach Kimi Coding API?"""
|
||||
key = os.environ.get("KIMI_API_KEY", "")
|
||||
if not key:
|
||||
# Check multiple .env locations
|
||||
for env_path in [HERMES_HOME / ".env", Path.home() / ".hermes" / ".env"]:
|
||||
if env_path.exists():
|
||||
for line in open(env_path):
|
||||
line = line.strip()
|
||||
if line.startswith("KIMI_API_KEY="):
|
||||
key = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
break
|
||||
if key:
|
||||
break
|
||||
if not key:
|
||||
return False, "no API key"
|
||||
code, out, err = run(
|
||||
f'curl -s -o /dev/null -w "%{{http_code}}" -H "x-api-key: {key}" '
|
||||
f'-H "x-api-provider: kimi-coding" '
|
||||
f'https://api.kimi.com/coding/v1/models -X POST '
|
||||
f'-H "content-type: application/json" '
|
||||
f'-d \'{{"model":"kimi-k2.5","max_tokens":1,"messages":[{{"role":"user","content":"ping"}}]}}\' ',
|
||||
timeout=15
|
||||
)
|
||||
if code == 0 and out in ("200", "429"):
|
||||
return True, f"HTTP {out}"
|
||||
return False, f"HTTP {out} err={err[:80]}"
|
||||
|
||||
def check_local_llama():
|
||||
"""Is local llama.cpp serving?"""
|
||||
code, out, err = run("curl -s http://localhost:8081/v1/models", timeout=5)
|
||||
if code == 0 and "hermes" in out.lower():
|
||||
return True, "serving"
|
||||
return False, f"exit={code}"
|
||||
|
||||
def check_ollama():
|
||||
"""Is Ollama running?"""
|
||||
code, out, err = run("curl -s http://localhost:11434/api/tags", timeout=5)
|
||||
if code == 0 and "models" in out:
|
||||
return True, "running"
|
||||
return False, f"exit={code}"
|
||||
|
||||
def check_gitea():
|
||||
"""Can we reach the Forge?"""
|
||||
token_path = Path.home() / ".config" / "gitea" / "timmy-token"
|
||||
if not token_path.exists():
|
||||
return False, "no token"
|
||||
token = token_path.read_text().strip()
|
||||
code, out, err = run(
|
||||
f'curl -s -o /dev/null -w "%{{http_code}}" -H "Authorization: token {token}" '
|
||||
f'"{FORGE_URL}/api/v1/user"',
|
||||
timeout=10
|
||||
)
|
||||
if code == 0 and out == "200":
|
||||
return True, "reachable"
|
||||
return False, f"HTTP {out}"
|
||||
|
||||
def check_vps(ip, name):
|
||||
"""Can we SSH into a VPS?"""
|
||||
code, out, err = run(f"ssh -o ConnectTimeout=5 root@{ip} 'echo alive'", timeout=10)
|
||||
if code == 0 and "alive" in out:
|
||||
return True, "alive"
|
||||
return False, f"unreachable"
|
||||
|
||||
# ─── FALLBACK ACTIONS ───
|
||||
|
||||
def fallback_to_local_model(cfg):
|
||||
"""Switch primary model from Kimi to local llama.cpp"""
|
||||
if not BACKUP_CONFIG.exists():
|
||||
shutil.copy2(CONFIG_PATH, BACKUP_CONFIG)
|
||||
|
||||
cfg["model"]["provider"] = "local-llama.cpp"
|
||||
cfg["model"]["default"] = "hermes3"
|
||||
save_config(cfg)
|
||||
return "Switched primary model to local-llama.cpp/hermes3"
|
||||
|
||||
def fallback_to_ollama(cfg):
|
||||
"""Switch to Ollama if llama.cpp is also down"""
|
||||
if not BACKUP_CONFIG.exists():
|
||||
shutil.copy2(CONFIG_PATH, BACKUP_CONFIG)
|
||||
|
||||
cfg["model"]["provider"] = "ollama"
|
||||
cfg["model"]["default"] = "gemma4:latest"
|
||||
save_config(cfg)
|
||||
return "Switched primary model to ollama/gemma4:latest"
|
||||
|
||||
def enter_safe_mode(state):
|
||||
"""Pause all non-essential cron jobs, alert Alexander"""
|
||||
state["safe_mode"] = True
|
||||
state["safe_mode_entered"] = datetime.now().isoformat()
|
||||
save_state(state)
|
||||
return "SAFE MODE: All inference down. Cron jobs should be paused. Alert Alexander."
|
||||
|
||||
def restore_config():
|
||||
"""Restore pre-fallback config when primary recovers"""
|
||||
if BACKUP_CONFIG.exists():
|
||||
shutil.copy2(BACKUP_CONFIG, CONFIG_PATH)
|
||||
BACKUP_CONFIG.unlink()
|
||||
return "Restored original config from backup"
|
||||
return "No backup config to restore"
|
||||
|
||||
# ─── MAIN DIAGNOSIS AND FALLBACK ENGINE ───
|
||||
|
||||
def diagnose_and_fallback():
|
||||
state = load_state()
|
||||
cfg = load_config()
|
||||
|
||||
results = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"checks": {},
|
||||
"actions": [],
|
||||
"status": "healthy"
|
||||
}
|
||||
|
||||
# Check all systems
|
||||
kimi_ok, kimi_msg = check_kimi()
|
||||
results["checks"]["kimi-coding"] = {"ok": kimi_ok, "msg": kimi_msg}
|
||||
|
||||
llama_ok, llama_msg = check_local_llama()
|
||||
results["checks"]["local_llama"] = {"ok": llama_ok, "msg": llama_msg}
|
||||
|
||||
ollama_ok, ollama_msg = check_ollama()
|
||||
results["checks"]["ollama"] = {"ok": ollama_ok, "msg": ollama_msg}
|
||||
|
||||
gitea_ok, gitea_msg = check_gitea()
|
||||
results["checks"]["gitea"] = {"ok": gitea_ok, "msg": gitea_msg}
|
||||
|
||||
# VPS checks
|
||||
vpses = [
|
||||
("167.99.126.228", "Allegro"),
|
||||
("143.198.27.163", "Ezra"),
|
||||
("159.203.146.185", "Bezalel"),
|
||||
]
|
||||
for ip, name in vpses:
|
||||
vps_ok, vps_msg = check_vps(ip, name)
|
||||
results["checks"][f"vps_{name.lower()}"] = {"ok": vps_ok, "msg": vps_msg}
|
||||
|
||||
current_provider = cfg.get("model", {}).get("provider", "kimi-coding")
|
||||
|
||||
# ─── FALLBACK LOGIC ───
|
||||
|
||||
# Case 1: Primary (Kimi) down, local available
|
||||
if not kimi_ok and current_provider == "kimi-coding":
|
||||
if llama_ok:
|
||||
msg = fallback_to_local_model(cfg)
|
||||
results["actions"].append(msg)
|
||||
state["active_fallbacks"].append("kimi->local-llama")
|
||||
results["status"] = "degraded_local"
|
||||
elif ollama_ok:
|
||||
msg = fallback_to_ollama(cfg)
|
||||
results["actions"].append(msg)
|
||||
state["active_fallbacks"].append("kimi->ollama")
|
||||
results["status"] = "degraded_ollama"
|
||||
else:
|
||||
msg = enter_safe_mode(state)
|
||||
results["actions"].append(msg)
|
||||
results["status"] = "safe_mode"
|
||||
|
||||
# Case 2: Already on fallback, check if primary recovered
|
||||
elif kimi_ok and "kimi->local-llama" in state.get("active_fallbacks", []):
|
||||
msg = restore_config()
|
||||
results["actions"].append(msg)
|
||||
state["active_fallbacks"].remove("kimi->local-llama")
|
||||
results["status"] = "recovered"
|
||||
elif kimi_ok and "kimi->ollama" in state.get("active_fallbacks", []):
|
||||
msg = restore_config()
|
||||
results["actions"].append(msg)
|
||||
state["active_fallbacks"].remove("kimi->ollama")
|
||||
results["status"] = "recovered"
|
||||
|
||||
# Case 3: Gitea down — just flag it, work locally
|
||||
if not gitea_ok:
|
||||
results["actions"].append("WARN: Gitea unreachable — work cached locally until recovery")
|
||||
if "gitea_down" not in state.get("active_fallbacks", []):
|
||||
state["active_fallbacks"].append("gitea_down")
|
||||
results["status"] = max(results["status"], "degraded_gitea", key=lambda x: ["healthy", "recovered", "degraded_gitea", "degraded_local", "degraded_ollama", "safe_mode"].index(x) if x in ["healthy", "recovered", "degraded_gitea", "degraded_local", "degraded_ollama", "safe_mode"] else 0)
|
||||
elif "gitea_down" in state.get("active_fallbacks", []):
|
||||
state["active_fallbacks"].remove("gitea_down")
|
||||
results["actions"].append("Gitea recovered — resume normal operations")
|
||||
|
||||
# Case 4: VPS agents down
|
||||
for ip, name in vpses:
|
||||
key = f"vps_{name.lower()}"
|
||||
if not results["checks"][key]["ok"]:
|
||||
results["actions"].append(f"ALERT: {name} VPS ({ip}) unreachable — lazarus protocol needed")
|
||||
|
||||
save_state(state)
|
||||
return results
|
||||
|
||||
if __name__ == "__main__":
|
||||
results = diagnose_and_fallback()
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
# Exit codes for cron integration
|
||||
if results["status"] == "safe_mode":
|
||||
sys.exit(2)
|
||||
elif results["status"].startswith("degraded"):
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(0)
|
||||
297
bin/glitch_patterns.py
Normal file
297
bin/glitch_patterns.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Glitch pattern definitions for 3D world anomaly detection.
|
||||
|
||||
Defines known visual artifact categories commonly found in 3D web worlds,
|
||||
particularly The Matrix environments. Each pattern includes detection
|
||||
heuristics and severity ratings.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class GlitchSeverity(Enum):
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
INFO = "info"
|
||||
|
||||
|
||||
class GlitchCategory(Enum):
|
||||
FLOATING_ASSETS = "floating_assets"
|
||||
Z_FIGHTING = "z_fighting"
|
||||
MISSING_TEXTURES = "missing_textures"
|
||||
CLIPPING = "clipping"
|
||||
BROKEN_NORMALS = "broken_normals"
|
||||
SHADOW_ARTIFACTS = "shadow_artifacts"
|
||||
LIGHTMAP_ERRORS = "lightmap_errors"
|
||||
LOD_POPPING = "lod_popping"
|
||||
WATER_REFLECTION = "water_reflection"
|
||||
SKYBOX_SEAM = "skybox_seam"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlitchPattern:
|
||||
"""Definition of a known glitch pattern with detection parameters."""
|
||||
category: GlitchCategory
|
||||
name: str
|
||||
description: str
|
||||
severity: GlitchSeverity
|
||||
detection_prompts: list[str]
|
||||
visual_indicators: list[str]
|
||||
confidence_threshold: float = 0.6
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"category": self.category.value,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"severity": self.severity.value,
|
||||
"detection_prompts": self.detection_prompts,
|
||||
"visual_indicators": self.visual_indicators,
|
||||
"confidence_threshold": self.confidence_threshold,
|
||||
}
|
||||
|
||||
|
||||
# Known glitch patterns for Matrix 3D world scanning
|
||||
MATRIX_GLITCH_PATTERNS: list[GlitchPattern] = [
|
||||
GlitchPattern(
|
||||
category=GlitchCategory.FLOATING_ASSETS,
|
||||
name="Floating Object",
|
||||
description="Object not properly grounded or anchored to the scene geometry. "
|
||||
"Common in procedurally placed assets or after physics desync.",
|
||||
severity=GlitchSeverity.HIGH,
|
||||
detection_prompts=[
|
||||
"Identify any objects that appear to float above the ground without support.",
|
||||
"Look for furniture, props, or geometry suspended in mid-air with no visible attachment.",
|
||||
"Check for objects whose shadows do not align with the surface below them.",
|
||||
],
|
||||
visual_indicators=[
|
||||
"gap between object base and surface",
|
||||
"shadow detached from object",
|
||||
"object hovering with no structural support",
|
||||
],
|
||||
confidence_threshold=0.65,
|
||||
),
|
||||
GlitchPattern(
|
||||
category=GlitchCategory.Z_FIGHTING,
|
||||
name="Z-Fighting Flicker",
|
||||
description="Two coplanar surfaces competing for depth priority, causing "
|
||||
"visible flickering or shimmering textures.",
|
||||
severity=GlitchSeverity.MEDIUM,
|
||||
detection_prompts=[
|
||||
"Look for surfaces that appear to shimmer, flicker, or show mixed textures.",
|
||||
"Identify areas where two textures seem to overlap and compete for visibility.",
|
||||
"Check walls, floors, or objects for surface noise or pattern interference.",
|
||||
],
|
||||
visual_indicators=[
|
||||
"shimmering surface",
|
||||
"texture flicker between two patterns",
|
||||
"noisy flat surfaces",
|
||||
"moire-like patterns on planar geometry",
|
||||
],
|
||||
confidence_threshold=0.55,
|
||||
),
|
||||
GlitchPattern(
|
||||
category=GlitchCategory.MISSING_TEXTURES,
|
||||
name="Missing or Placeholder Texture",
|
||||
description="A surface rendered with a fallback checkerboard, solid magenta, "
|
||||
"or the default engine placeholder texture.",
|
||||
severity=GlitchSeverity.CRITICAL,
|
||||
detection_prompts=[
|
||||
"Look for bright magenta, checkerboard, or solid-color surfaces that look out of place.",
|
||||
"Identify any surfaces that appear as flat untextured colors inconsistent with the scene.",
|
||||
"Check for black, white, or magenta patches where detailed textures should be.",
|
||||
],
|
||||
visual_indicators=[
|
||||
"magenta/pink solid color surface",
|
||||
"checkerboard pattern",
|
||||
"flat single-color geometry",
|
||||
"UV-debug texture visible",
|
||||
],
|
||||
confidence_threshold=0.7,
|
||||
),
|
||||
GlitchPattern(
|
||||
category=GlitchCategory.CLIPPING,
|
||||
name="Geometry Clipping",
|
||||
description="Objects passing through each other or intersecting in physically "
|
||||
"impossible ways due to collision mesh errors.",
|
||||
severity=GlitchSeverity.HIGH,
|
||||
detection_prompts=[
|
||||
"Look for objects that visibly pass through other objects (walls, floors, furniture).",
|
||||
"Identify characters or props embedded inside geometry where they should not be.",
|
||||
"Check for intersecting meshes where solid objects overlap unnaturally.",
|
||||
],
|
||||
visual_indicators=[
|
||||
"object passing through wall or floor",
|
||||
"embedded geometry",
|
||||
"overlapping solid meshes",
|
||||
"character limb inside furniture",
|
||||
],
|
||||
confidence_threshold=0.6,
|
||||
),
|
||||
GlitchPattern(
|
||||
category=GlitchCategory.BROKEN_NORMALS,
|
||||
name="Broken Surface Normals",
|
||||
description="Inverted or incorrect surface normals causing faces to appear "
|
||||
"inside-out, invisible from certain angles, or lit incorrectly.",
|
||||
severity=GlitchSeverity.MEDIUM,
|
||||
detection_prompts=[
|
||||
"Look for surfaces that appear dark or black on one side while lit on the other.",
|
||||
"Identify objects that seem to vanish when viewed from certain angles.",
|
||||
"Check for inverted shading where lit areas should be in shadow.",
|
||||
],
|
||||
visual_indicators=[
|
||||
"dark/unlit face on otherwise lit model",
|
||||
"invisible surface from one direction",
|
||||
"inverted shadow gradient",
|
||||
"inside-out appearance",
|
||||
],
|
||||
confidence_threshold=0.5,
|
||||
),
|
||||
GlitchPattern(
|
||||
category=GlitchCategory.SHADOW_ARTIFACTS,
|
||||
name="Shadow Artifact",
|
||||
description="Broken, detached, or incorrectly rendered shadows that do not "
|
||||
"match the casting geometry or scene lighting.",
|
||||
severity=GlitchSeverity.LOW,
|
||||
detection_prompts=[
|
||||
"Look for shadows that do not match the shape of nearby objects.",
|
||||
"Identify shadow acne: banding or striped patterns on surfaces.",
|
||||
"Check for floating shadows detached from any visible caster.",
|
||||
],
|
||||
visual_indicators=[
|
||||
"shadow shape mismatch",
|
||||
"shadow acne bands",
|
||||
"detached floating shadow",
|
||||
"Peter Panning (shadow offset from base)",
|
||||
],
|
||||
confidence_threshold=0.5,
|
||||
),
|
||||
GlitchPattern(
|
||||
category=GlitchCategory.LOD_POPPING,
|
||||
name="LOD Transition Pop",
|
||||
description="Visible pop-in when level-of-detail models switch abruptly, "
|
||||
"causing geometry or textures to change suddenly.",
|
||||
severity=GlitchSeverity.LOW,
|
||||
detection_prompts=[
|
||||
"Look for areas where mesh detail changes abruptly at visible boundaries.",
|
||||
"Identify objects that appear to morph or shift geometry suddenly.",
|
||||
"Check for texture resolution changes that create visible seams.",
|
||||
],
|
||||
visual_indicators=[
|
||||
"visible mesh simplification boundary",
|
||||
"texture resolution jump",
|
||||
"geometry pop-in artifacts",
|
||||
],
|
||||
confidence_threshold=0.45,
|
||||
),
|
||||
GlitchPattern(
|
||||
category=GlitchCategory.LIGHTMAP_ERRORS,
|
||||
name="Lightmap Baking Error",
|
||||
description="Incorrect or missing baked lighting causing dark spots, light "
|
||||
"leaks, or mismatched illumination on static geometry.",
|
||||
severity=GlitchSeverity.MEDIUM,
|
||||
detection_prompts=[
|
||||
"Look for unusually dark patches on walls or ceilings that should be lit.",
|
||||
"Identify bright light leaks through solid geometry seams.",
|
||||
"Check for mismatched lighting between adjacent surfaces.",
|
||||
],
|
||||
visual_indicators=[
|
||||
"dark splotch on lit surface",
|
||||
"bright line at geometry seam",
|
||||
"lighting discontinuity between adjacent faces",
|
||||
],
|
||||
confidence_threshold=0.5,
|
||||
),
|
||||
GlitchPattern(
|
||||
category=GlitchCategory.WATER_REFLECTION,
|
||||
name="Water/Reflection Error",
|
||||
description="Incorrect reflections, missing water surfaces, or broken "
|
||||
"reflection probe assignments.",
|
||||
severity=GlitchSeverity.MEDIUM,
|
||||
detection_prompts=[
|
||||
"Look for reflections that do not match the surrounding environment.",
|
||||
"Identify water surfaces that appear solid or incorrectly rendered.",
|
||||
"Check for mirror surfaces showing wrong scene geometry.",
|
||||
],
|
||||
visual_indicators=[
|
||||
"reflection mismatch",
|
||||
"solid water surface",
|
||||
"incorrect environment map",
|
||||
],
|
||||
confidence_threshold=0.5,
|
||||
),
|
||||
GlitchPattern(
|
||||
category=GlitchCategory.SKYBOX_SEAM,
|
||||
name="Skybox Seam",
|
||||
description="Visible seams or color mismatches at the edges of skybox cubemap faces.",
|
||||
severity=GlitchSeverity.LOW,
|
||||
detection_prompts=[
|
||||
"Look at the edges of the sky for visible seams or color shifts.",
|
||||
"Identify discontinuities where skybox faces meet.",
|
||||
"Check for texture stretching at skybox corners.",
|
||||
],
|
||||
visual_indicators=[
|
||||
"visible line in sky",
|
||||
"color discontinuity at sky edge",
|
||||
"sky texture seam",
|
||||
],
|
||||
confidence_threshold=0.45,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_patterns_by_severity(min_severity: GlitchSeverity) -> list[GlitchPattern]:
|
||||
"""Return patterns at or above the given severity level."""
|
||||
severity_order = [
|
||||
GlitchSeverity.INFO,
|
||||
GlitchSeverity.LOW,
|
||||
GlitchSeverity.MEDIUM,
|
||||
GlitchSeverity.HIGH,
|
||||
GlitchSeverity.CRITICAL,
|
||||
]
|
||||
min_idx = severity_order.index(min_severity)
|
||||
return [p for p in MATRIX_GLITCH_PATTERNS if severity_order.index(p.severity) >= min_idx]
|
||||
|
||||
|
||||
def get_pattern_by_category(category: GlitchCategory) -> Optional[GlitchPattern]:
|
||||
"""Return the pattern definition for a specific category."""
|
||||
for p in MATRIX_GLITCH_PATTERNS:
|
||||
if p.category == category:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def build_vision_prompt(patterns: list[GlitchPattern] | None = None) -> str:
|
||||
"""Build a composite vision analysis prompt from pattern definitions."""
|
||||
if patterns is None:
|
||||
patterns = MATRIX_GLITCH_PATTERNS
|
||||
|
||||
sections = []
|
||||
for p in patterns:
|
||||
prompt_text = " ".join(p.detection_prompts)
|
||||
indicators = ", ".join(p.visual_indicators)
|
||||
sections.append(
|
||||
f"[{p.category.value.upper()}] {p.name} (severity: {p.severity.value})\n"
|
||||
f" {p.description}\n"
|
||||
f" Look for: {prompt_text}\n"
|
||||
f" Visual indicators: {indicators}"
|
||||
)
|
||||
|
||||
return (
|
||||
"Analyze this 3D world screenshot for visual glitches and artifacts. "
|
||||
"For each detected issue, report the category, description of what you see, "
|
||||
"approximate location in the image (x%, y%), and confidence (0.0-1.0).\n\n"
|
||||
"Known glitch patterns to check:\n\n" + "\n\n".join(sections)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print(f"Loaded {len(MATRIX_GLITCH_PATTERNS)} glitch patterns:\n")
|
||||
for p in MATRIX_GLITCH_PATTERNS:
|
||||
print(f" [{p.severity.value:8s}] {p.category.value}: {p.name}")
|
||||
print(f"\nVision prompt preview:\n{build_vision_prompt()[:500]}...")
|
||||
549
bin/matrix_glitch_detector.py
Normal file
549
bin/matrix_glitch_detector.py
Normal file
@@ -0,0 +1,549 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Matrix 3D World Glitch Detector
|
||||
|
||||
Scans a 3D web world for visual artifacts using browser automation
|
||||
and vision AI analysis. Produces structured glitch reports.
|
||||
|
||||
Usage:
|
||||
python matrix_glitch_detector.py <url> [--angles 4] [--output report.json]
|
||||
python matrix_glitch_detector.py --demo # Run with synthetic test data
|
||||
|
||||
Ref: timmy-config#491
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Add parent for glitch_patterns import
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from glitch_patterns import (
|
||||
GlitchCategory,
|
||||
GlitchPattern,
|
||||
GlitchSeverity,
|
||||
MATRIX_GLITCH_PATTERNS,
|
||||
build_vision_prompt,
|
||||
get_patterns_by_severity,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectedGlitch:
|
||||
"""A single detected glitch with metadata."""
|
||||
id: str
|
||||
category: str
|
||||
name: str
|
||||
description: str
|
||||
severity: str
|
||||
confidence: float
|
||||
location_x: Optional[float] = None # percentage across image
|
||||
location_y: Optional[float] = None # percentage down image
|
||||
screenshot_index: int = 0
|
||||
screenshot_angle: str = "front"
|
||||
timestamp: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.timestamp:
|
||||
self.timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
"""Complete scan result for a 3D world URL."""
|
||||
scan_id: str
|
||||
url: str
|
||||
timestamp: str
|
||||
total_screenshots: int
|
||||
angles_captured: list[str]
|
||||
glitches: list[dict] = field(default_factory=list)
|
||||
summary: dict = field(default_factory=dict)
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
return json.dumps(asdict(self), indent=indent)
|
||||
|
||||
|
||||
def generate_scan_angles(num_angles: int) -> list[dict]:
|
||||
"""Generate camera angle configurations for multi-angle scanning.
|
||||
|
||||
Returns a list of dicts with yaw/pitch/label for browser camera control.
|
||||
"""
|
||||
base_angles = [
|
||||
{"yaw": 0, "pitch": 0, "label": "front"},
|
||||
{"yaw": 90, "pitch": 0, "label": "right"},
|
||||
{"yaw": 180, "pitch": 0, "label": "back"},
|
||||
{"yaw": 270, "pitch": 0, "label": "left"},
|
||||
{"yaw": 0, "pitch": -30, "label": "front_low"},
|
||||
{"yaw": 45, "pitch": -15, "label": "front_right_low"},
|
||||
{"yaw": 0, "pitch": 30, "label": "front_high"},
|
||||
{"yaw": 45, "pitch": 0, "label": "front_right"},
|
||||
]
|
||||
|
||||
if num_angles <= len(base_angles):
|
||||
return base_angles[:num_angles]
|
||||
return base_angles + [
|
||||
{"yaw": i * (360 // num_angles), "pitch": 0, "label": f"angle_{i}"}
|
||||
for i in range(len(base_angles), num_angles)
|
||||
]
|
||||
|
||||
|
||||
def capture_screenshots(url: str, angles: list[dict], output_dir: Path) -> list[Path]:
|
||||
"""Capture screenshots of a 3D web world from multiple angles.
|
||||
|
||||
Uses browser_vision tool when available; falls back to placeholder generation
|
||||
for testing and environments without browser access.
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
screenshots = []
|
||||
|
||||
for i, angle in enumerate(angles):
|
||||
filename = output_dir / f"screenshot_{i:03d}_{angle['label']}.png"
|
||||
|
||||
# Attempt browser-based capture via browser_vision
|
||||
try:
|
||||
result = _browser_capture(url, angle, filename)
|
||||
if result:
|
||||
screenshots.append(filename)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Generate placeholder screenshot for offline/test scenarios
|
||||
_generate_placeholder_screenshot(filename, angle)
|
||||
screenshots.append(filename)
|
||||
|
||||
return screenshots
|
||||
|
||||
|
||||
def _browser_capture(url: str, angle: dict, output_path: Path) -> bool:
|
||||
"""Capture a screenshot via browser automation.
|
||||
|
||||
This is a stub that delegates to the browser_vision tool when run
|
||||
in an environment that provides it. In CI or offline mode, returns False.
|
||||
"""
|
||||
# Check if browser_vision is available via environment
|
||||
bv_script = os.environ.get("BROWSER_VISION_SCRIPT")
|
||||
if bv_script and Path(bv_script).exists():
|
||||
import subprocess
|
||||
cmd = [
|
||||
sys.executable, bv_script,
|
||||
"--url", url,
|
||||
"--screenshot", str(output_path),
|
||||
"--rotate-yaw", str(angle["yaw"]),
|
||||
"--rotate-pitch", str(angle["pitch"]),
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
return proc.returncode == 0 and output_path.exists()
|
||||
return False
|
||||
|
||||
|
||||
def _generate_placeholder_screenshot(path: Path, angle: dict):
|
||||
"""Generate a minimal 1x1 PNG as a placeholder for testing."""
|
||||
# Minimal valid PNG (1x1 transparent pixel)
|
||||
png_data = (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
|
||||
b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
|
||||
b"\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
path.write_bytes(png_data)
|
||||
|
||||
|
||||
def analyze_with_vision(
|
||||
screenshot_paths: list[Path],
|
||||
angles: list[dict],
|
||||
patterns: list[GlitchPattern] | None = None,
|
||||
) -> list[DetectedGlitch]:
|
||||
"""Send screenshots to vision AI for glitch analysis.
|
||||
|
||||
In environments with a vision model available, sends each screenshot
|
||||
with the composite detection prompt. Otherwise returns simulated results.
|
||||
"""
|
||||
if patterns is None:
|
||||
patterns = MATRIX_GLITCH_PATTERNS
|
||||
|
||||
prompt = build_vision_prompt(patterns)
|
||||
glitches = []
|
||||
|
||||
for i, (path, angle) in enumerate(zip(screenshot_paths, angles)):
|
||||
# Attempt vision analysis
|
||||
detected = _vision_analyze_image(path, prompt, i, angle["label"])
|
||||
glitches.extend(detected)
|
||||
|
||||
return glitches
|
||||
|
||||
|
||||
def _vision_analyze_image(
|
||||
image_path: Path,
|
||||
prompt: str,
|
||||
screenshot_index: int,
|
||||
angle_label: str,
|
||||
) -> list[DetectedGlitch]:
|
||||
"""Analyze a single screenshot with vision AI.
|
||||
|
||||
Uses the vision_analyze tool when available; returns empty list otherwise.
|
||||
"""
|
||||
# Check for vision API configuration
|
||||
api_key = os.environ.get("VISION_API_KEY") or os.environ.get("OPENAI_API_KEY")
|
||||
api_base = os.environ.get("VISION_API_BASE", "https://api.openai.com/v1")
|
||||
|
||||
if api_key:
|
||||
try:
|
||||
return _call_vision_api(
|
||||
image_path, prompt, screenshot_index, angle_label, api_key, api_base
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" [!] Vision API error for {image_path.name}: {e}", file=sys.stderr)
|
||||
|
||||
# No vision backend available
|
||||
return []
|
||||
|
||||
|
||||
def _call_vision_api(
|
||||
image_path: Path,
|
||||
prompt: str,
|
||||
screenshot_index: int,
|
||||
angle_label: str,
|
||||
api_key: str,
|
||||
api_base: str,
|
||||
) -> list[DetectedGlitch]:
|
||||
"""Call a vision API (OpenAI-compatible) for image analysis."""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
image_data = base64.b64encode(image_path.read_bytes()).decode()
|
||||
|
||||
payload = json.dumps({
|
||||
"model": os.environ.get("VISION_MODEL", "gpt-4o"),
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/png;base64,{image_data}",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"max_tokens": 4096,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{api_base}/chat/completions",
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
result = json.loads(resp.read())
|
||||
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
return _parse_vision_response(content, screenshot_index, angle_label)
|
||||
|
||||
|
||||
def _add_glitch_from_dict(
|
||||
item: dict,
|
||||
glitches: list[DetectedGlitch],
|
||||
screenshot_index: int,
|
||||
angle_label: str,
|
||||
):
|
||||
"""Convert a dict from vision API response into a DetectedGlitch."""
|
||||
cat = item.get("category", item.get("type", "unknown"))
|
||||
conf = float(item.get("confidence", item.get("score", 0.5)))
|
||||
|
||||
glitch = DetectedGlitch(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
category=cat,
|
||||
name=item.get("name", item.get("label", cat)),
|
||||
description=item.get("description", item.get("detail", "")),
|
||||
severity=item.get("severity", _infer_severity(cat, conf)),
|
||||
confidence=conf,
|
||||
location_x=item.get("location_x", item.get("x")),
|
||||
location_y=item.get("location_y", item.get("y")),
|
||||
screenshot_index=screenshot_index,
|
||||
screenshot_angle=angle_label,
|
||||
)
|
||||
glitches.append(glitch)
|
||||
|
||||
|
||||
def _parse_vision_response(
|
||||
text: str, screenshot_index: int, angle_label: str
|
||||
) -> list[DetectedGlitch]:
|
||||
"""Parse vision AI response into structured glitch detections."""
|
||||
glitches = []
|
||||
|
||||
# Try to extract JSON from the response
|
||||
json_blocks = []
|
||||
in_json = False
|
||||
json_buf = []
|
||||
|
||||
for line in text.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("```"):
|
||||
if in_json and json_buf:
|
||||
try:
|
||||
json_blocks.append(json.loads("\n".join(json_buf)))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
json_buf = []
|
||||
in_json = not in_json
|
||||
continue
|
||||
if in_json:
|
||||
json_buf.append(line)
|
||||
|
||||
# Flush any remaining buffer
|
||||
if in_json and json_buf:
|
||||
try:
|
||||
json_blocks.append(json.loads("\n".join(json_buf)))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Also try parsing the entire response as JSON
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, list):
|
||||
json_blocks.extend(parsed)
|
||||
elif isinstance(parsed, dict):
|
||||
if "glitches" in parsed:
|
||||
json_blocks.extend(parsed["glitches"])
|
||||
elif "detections" in parsed:
|
||||
json_blocks.extend(parsed["detections"])
|
||||
else:
|
||||
json_blocks.append(parsed)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
for item in json_blocks:
|
||||
# Flatten arrays of detections
|
||||
if isinstance(item, list):
|
||||
for sub in item:
|
||||
if isinstance(sub, dict):
|
||||
_add_glitch_from_dict(sub, glitches, screenshot_index, angle_label)
|
||||
elif isinstance(item, dict):
|
||||
_add_glitch_from_dict(item, glitches, screenshot_index, angle_label)
|
||||
|
||||
return glitches
|
||||
|
||||
|
||||
def _infer_severity(category: str, confidence: float) -> str:
|
||||
"""Infer severity from category and confidence when not provided."""
|
||||
critical_cats = {"missing_textures", "clipping"}
|
||||
high_cats = {"floating_assets", "broken_normals"}
|
||||
|
||||
cat_lower = category.lower()
|
||||
if any(c in cat_lower for c in critical_cats):
|
||||
return "critical" if confidence > 0.7 else "high"
|
||||
if any(c in cat_lower for c in high_cats):
|
||||
return "high" if confidence > 0.7 else "medium"
|
||||
return "medium" if confidence > 0.6 else "low"
|
||||
|
||||
|
||||
def build_report(
|
||||
url: str,
|
||||
angles: list[dict],
|
||||
screenshots: list[Path],
|
||||
glitches: list[DetectedGlitch],
|
||||
) -> ScanResult:
|
||||
"""Build the final structured scan report."""
|
||||
severity_counts = {}
|
||||
category_counts = {}
|
||||
|
||||
for g in glitches:
|
||||
severity_counts[g.severity] = severity_counts.get(g.severity, 0) + 1
|
||||
category_counts[g.category] = category_counts.get(g.category, 0) + 1
|
||||
|
||||
report = ScanResult(
|
||||
scan_id=str(uuid.uuid4()),
|
||||
url=url,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
total_screenshots=len(screenshots),
|
||||
angles_captured=[a["label"] for a in angles],
|
||||
glitches=[asdict(g) for g in glitches],
|
||||
summary={
|
||||
"total_glitches": len(glitches),
|
||||
"by_severity": severity_counts,
|
||||
"by_category": category_counts,
|
||||
"highest_severity": max(severity_counts.keys(), default="none"),
|
||||
"clean_screenshots": sum(
|
||||
1
|
||||
for i in range(len(screenshots))
|
||||
if not any(g.screenshot_index == i for g in glitches)
|
||||
),
|
||||
},
|
||||
metadata={
|
||||
"detector_version": "0.1.0",
|
||||
"pattern_count": len(MATRIX_GLITCH_PATTERNS),
|
||||
"reference": "timmy-config#491",
|
||||
},
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def run_demo(output_path: Optional[Path] = None) -> ScanResult:
|
||||
"""Run a demonstration scan with simulated detections."""
|
||||
print("[*] Running Matrix glitch detection demo...")
|
||||
|
||||
url = "https://matrix.example.com/world/alpha"
|
||||
angles = generate_scan_angles(4)
|
||||
screenshots_dir = Path("/tmp/matrix_glitch_screenshots")
|
||||
|
||||
print(f"[*] Capturing {len(angles)} screenshots from: {url}")
|
||||
screenshots = capture_screenshots(url, angles, screenshots_dir)
|
||||
print(f"[*] Captured {len(screenshots)} screenshots")
|
||||
|
||||
# Simulate detections for demo
|
||||
demo_glitches = [
|
||||
DetectedGlitch(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
category="floating_assets",
|
||||
name="Floating Chair",
|
||||
description="Office chair floating 0.3m above floor in sector 7",
|
||||
severity="high",
|
||||
confidence=0.87,
|
||||
location_x=35.2,
|
||||
location_y=62.1,
|
||||
screenshot_index=0,
|
||||
screenshot_angle="front",
|
||||
),
|
||||
DetectedGlitch(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
category="z_fighting",
|
||||
name="Wall Texture Flicker",
|
||||
description="Z-fighting between wall panel and decorative overlay",
|
||||
severity="medium",
|
||||
confidence=0.72,
|
||||
location_x=58.0,
|
||||
location_y=40.5,
|
||||
screenshot_index=1,
|
||||
screenshot_angle="right",
|
||||
),
|
||||
DetectedGlitch(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
category="missing_textures",
|
||||
name="Placeholder Texture",
|
||||
description="Bright magenta surface on door frame — missing asset reference",
|
||||
severity="critical",
|
||||
confidence=0.95,
|
||||
location_x=72.3,
|
||||
location_y=28.8,
|
||||
screenshot_index=2,
|
||||
screenshot_angle="back",
|
||||
),
|
||||
DetectedGlitch(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
category="clipping",
|
||||
name="Desk Through Wall",
|
||||
description="Desk corner clipping through adjacent wall geometry",
|
||||
severity="high",
|
||||
confidence=0.81,
|
||||
location_x=15.0,
|
||||
location_y=55.0,
|
||||
screenshot_index=3,
|
||||
screenshot_angle="left",
|
||||
),
|
||||
]
|
||||
|
||||
print(f"[*] Detected {len(demo_glitches)} glitches")
|
||||
report = build_report(url, angles, screenshots, demo_glitches)
|
||||
|
||||
if output_path:
|
||||
output_path.write_text(report.to_json())
|
||||
print(f"[*] Report saved to: {output_path}")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Matrix 3D World Glitch Detector — scan for visual artifacts",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s https://matrix.example.com/world/alpha
|
||||
%(prog)s https://matrix.example.com/world/alpha --angles 8 --output report.json
|
||||
%(prog)s --demo
|
||||
""",
|
||||
)
|
||||
parser.add_argument("url", nargs="?", help="URL of the 3D world to scan")
|
||||
parser.add_argument(
|
||||
"--angles", type=int, default=4, help="Number of camera angles to capture (default: 4)"
|
||||
)
|
||||
parser.add_argument("--output", "-o", type=str, help="Output file path for JSON report")
|
||||
parser.add_argument("--demo", action="store_true", help="Run demo with simulated data")
|
||||
parser.add_argument(
|
||||
"--min-severity",
|
||||
choices=["info", "low", "medium", "high", "critical"],
|
||||
default="info",
|
||||
help="Minimum severity to include in report",
|
||||
)
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.demo:
|
||||
output = Path(args.output) if args.output else Path("glitch_report_demo.json")
|
||||
report = run_demo(output)
|
||||
print(f"\n=== Scan Summary ===")
|
||||
print(f"URL: {report.url}")
|
||||
print(f"Screenshots: {report.total_screenshots}")
|
||||
print(f"Glitches found: {report.summary['total_glitches']}")
|
||||
print(f"By severity: {report.summary['by_severity']}")
|
||||
return
|
||||
|
||||
if not args.url:
|
||||
parser.error("URL required (or use --demo)")
|
||||
|
||||
scan_id = str(uuid.uuid4())[:8]
|
||||
print(f"[*] Matrix Glitch Detector — Scan {scan_id}")
|
||||
print(f"[*] Target: {args.url}")
|
||||
|
||||
# Generate camera angles
|
||||
angles = generate_scan_angles(args.angles)
|
||||
print(f"[*] Capturing {len(angles)} screenshots...")
|
||||
|
||||
# Capture screenshots
|
||||
screenshots_dir = Path(f"/tmp/matrix_glitch_{scan_id}")
|
||||
screenshots = capture_screenshots(args.url, angles, screenshots_dir)
|
||||
print(f"[*] Captured {len(screenshots)} screenshots")
|
||||
|
||||
# Filter patterns by severity
|
||||
min_sev = GlitchSeverity(args.min_severity)
|
||||
patterns = get_patterns_by_severity(min_sev)
|
||||
|
||||
# Analyze with vision AI
|
||||
print(f"[*] Analyzing with vision AI ({len(patterns)} patterns)...")
|
||||
glitches = analyze_with_vision(screenshots, angles, patterns)
|
||||
|
||||
# Build and save report
|
||||
report = build_report(args.url, angles, screenshots, glitches)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(report.to_json())
|
||||
print(f"[*] Report saved: {args.output}")
|
||||
else:
|
||||
print(report.to_json())
|
||||
|
||||
print(f"\n[*] Done — {len(glitches)} glitches detected")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -19,25 +19,25 @@ PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
check_anthropic_model() {
|
||||
check_kimi_model() {
|
||||
local model="$1"
|
||||
local label="$2"
|
||||
local api_key="${ANTHROPIC_API_KEY:-}"
|
||||
local api_key="${KIMI_API_KEY:-}"
|
||||
|
||||
if [ -z "$api_key" ]; then
|
||||
# Try loading from .env
|
||||
api_key=$(grep '^ANTHROPIC_API_KEY=' "${HERMES_HOME:-$HOME/.hermes}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'\"" || echo "")
|
||||
api_key=$(grep '^KIMI_API_KEY=' "${HERMES_HOME:-$HOME/.hermes}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'\"" || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$api_key" ]; then
|
||||
log "SKIP [$label] $model -- no ANTHROPIC_API_KEY"
|
||||
log "SKIP [$label] $model -- no KIMI_API_KEY"
|
||||
return 0
|
||||
fi
|
||||
|
||||
response=$(curl -sf --max-time 10 -X POST \
|
||||
"https://api.anthropic.com/v1/messages" \
|
||||
"https://api.kimi.com/coding/v1/chat/completions" \
|
||||
-H "x-api-key: ${api_key}" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "x-api-provider: kimi-coding" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"model\":\"${model}\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" 2>&1 || echo "ERROR")
|
||||
|
||||
@@ -85,26 +85,26 @@ else:
|
||||
print('')
|
||||
" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$primary" ] && [ "$provider" = "anthropic" ]; then
|
||||
if check_anthropic_model "$primary" "PRIMARY"; then
|
||||
if [ -n "$primary" ] && [ "$provider" = "kimi-coding" ]; then
|
||||
if check_kimi_model "$primary" "PRIMARY"; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
FAIL=$((FAIL + 1))
|
||||
log "CRITICAL: Primary model $primary is DEAD. Loops will fail."
|
||||
log "Known good alternatives: claude-opus-4.6, claude-haiku-4-5-20251001"
|
||||
log "Known good alternatives: kimi-k2.5, google/gemini-2.5-pro"
|
||||
else
|
||||
WARN=$((WARN + 1))
|
||||
fi
|
||||
fi
|
||||
elif [ -n "$primary" ]; then
|
||||
log "SKIP [PRIMARY] $primary -- non-anthropic provider ($provider), no validator yet"
|
||||
log "SKIP [PRIMARY] $primary -- non-kimi provider ($provider), no validator yet"
|
||||
fi
|
||||
|
||||
# Cron model check (haiku)
|
||||
CRON_MODEL="claude-haiku-4-5-20251001"
|
||||
if check_anthropic_model "$CRON_MODEL" "CRON"; then
|
||||
CRON_MODEL="kimi-k2.5"
|
||||
if check_kimi_model "$CRON_MODEL" "CRON"; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
rc=$?
|
||||
|
||||
514
bin/pane-watchdog.sh
Executable file
514
bin/pane-watchdog.sh
Executable file
@@ -0,0 +1,514 @@
|
||||
#!/usr/bin/env bash
|
||||
# pane-watchdog.sh — Detect stuck/dead tmux panes and auto-restart them
|
||||
#
|
||||
# Tracks output hash per pane across cycles. If a pane's captured output
|
||||
# hasn't changed for STUCK_CYCLES consecutive checks, the pane is STUCK.
|
||||
# Dead panes (PID gone) are also detected.
|
||||
#
|
||||
# On STUCK/DEAD:
|
||||
# 1. Kill the pane
|
||||
# 2. Attempt restart with --resume (session ID from manifest)
|
||||
# 3. Fallback: fresh prompt with last known task from logs
|
||||
#
|
||||
# State file: ~/.hermes/pane-state.json
|
||||
# Log: ~/.hermes/logs/pane-watchdog.log
|
||||
#
|
||||
# Usage:
|
||||
# pane-watchdog.sh # One-shot check all sessions
|
||||
# pane-watchdog.sh --daemon # Run every CHECK_INTERVAL seconds
|
||||
# pane-watchdog.sh --status # Print current pane state
|
||||
# pane-watchdog.sh --session NAME # Check only one session
|
||||
#
|
||||
# Issue: timmy-config #515
|
||||
|
||||
set -uo pipefail
|
||||
export PATH="/opt/homebrew/bin:$HOME/.local/bin:$HOME/.hermes/bin:/usr/local/bin:$PATH"
|
||||
|
||||
# === CONFIG ===
|
||||
STATE_FILE="${PANE_STATE_FILE:-$HOME/.hermes/pane-state.json}"
|
||||
LOG_FILE="${PANE_WATCHDOG_LOG:-$HOME/.hermes/logs/pane-watchdog.log}"
|
||||
CHECK_INTERVAL="${PANE_CHECK_INTERVAL:-120}" # seconds between cycles
|
||||
STUCK_CYCLES=2 # unchanged cycles before STUCK
|
||||
MAX_RESTART_ATTEMPTS=3 # per pane per hour
|
||||
RESTART_COOLDOWN=3600 # seconds between escalation alerts
|
||||
CAPTURE_LINES=40 # lines of output to hash
|
||||
|
||||
# Sessions to monitor (all if empty)
|
||||
MONITOR_SESSIONS="${PANE_WATCHDOG_SESSIONS:-}"
|
||||
|
||||
mkdir -p "$(dirname "$STATE_FILE")" "$(dirname "$LOG_FILE")"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
# === HELPERS ===
|
||||
|
||||
# Capture last N lines of pane output and hash them
|
||||
capture_pane_hash() {
|
||||
local target="$1"
|
||||
local output
|
||||
output=$(tmux capture-pane -t "$target" -p -S "-${CAPTURE_LINES}" 2>/dev/null || echo "DEAD")
|
||||
echo -n "$output" | shasum -a 256 | cut -d' ' -f1
|
||||
}
|
||||
|
||||
# Check if pane PID is alive
|
||||
pane_pid_alive() {
|
||||
local target="$1"
|
||||
local pid
|
||||
pid=$(tmux list-panes -t "$target" -F '#{pane_pid}' 2>/dev/null | head -1 || echo "")
|
||||
if [ -z "$pid" ]; then
|
||||
return 1 # pane doesn't exist
|
||||
fi
|
||||
kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
# Get pane start command
|
||||
pane_start_command() {
|
||||
local target="$1"
|
||||
tmux list-panes -t "$target" -F '#{pane_start_command}' 2>/dev/null | head -1 || echo "unknown"
|
||||
}
|
||||
|
||||
# Get the pane's current running command (child process)
|
||||
pane_current_command() {
|
||||
local target="$1"
|
||||
tmux list-panes -t "$target" -F '#{pane_current_command}' 2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
# Only restart panes running hermes/agent commands (not zsh, python3 repls, etc.)
|
||||
is_restartable() {
|
||||
local cmd="$1"
|
||||
case "$cmd" in
|
||||
hermes|*hermes*|*agent*|*timmy*|*kimi*|*claude-loop*|*gemini-loop*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Get session ID from hermes manifest if available
|
||||
get_hermes_session_id() {
|
||||
local session_name="$1"
|
||||
local manifest="$HOME/.hermes/sessions/${session_name}/manifest.json"
|
||||
if [ -f "$manifest" ]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
m = json.load(open('$manifest'))
|
||||
print(m.get('session_id', m.get('id', '')))
|
||||
except: pass
|
||||
" 2>/dev/null || echo ""
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Get last task from pane logs
|
||||
get_last_task() {
|
||||
local session_name="$1"
|
||||
local log_dir="$HOME/.hermes/logs"
|
||||
# Find the most recent log for this session
|
||||
local log_file
|
||||
log_file=$(find "$log_dir" -name "*${session_name}*" -type f -mtime -1 2>/dev/null | sort -r | head -1)
|
||||
if [ -n "$log_file" ] && [ -f "$log_file" ]; then
|
||||
# Extract last user prompt or task description
|
||||
grep -i "task:\|prompt:\|issue\|working on" "$log_file" 2>/dev/null | tail -1 | sed 's/.*[:>] *//' | head -c 200
|
||||
fi
|
||||
}
|
||||
|
||||
# Restart a pane with a fresh shell/command
|
||||
restart_pane() {
|
||||
local target="$1"
|
||||
local session_name="${target%%:*}"
|
||||
local session_id last_task cmd
|
||||
|
||||
log "RESTART: Attempting to restart $target"
|
||||
|
||||
# Kill existing pane
|
||||
tmux kill-pane -t "$target" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Try --resume with session ID
|
||||
session_id=$(get_hermes_session_id "$session_name")
|
||||
if [ -n "$session_id" ]; then
|
||||
log "RESTART: Trying --resume with session $session_id"
|
||||
tmux split-window -t "$session_name" -d \
|
||||
"hermes chat --resume '$session_id' 2>&1 | tee -a '$HOME/.hermes/logs/${session_name}-restart.log'"
|
||||
sleep 2
|
||||
if pane_pid_alive "${session_name}:1" 2>/dev/null; then
|
||||
log "RESTART: Success with --resume"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: fresh prompt
|
||||
last_task=$(get_last_task "$session_name")
|
||||
if [ -n "$last_task" ]; then
|
||||
log "RESTART: Fallback — fresh prompt with task: $last_task"
|
||||
tmux split-window -t "$session_name" -d \
|
||||
"echo 'Watchdog restart — last task: $last_task' && hermes chat 2>&1 | tee -a '$HOME/.hermes/logs/${session_name}-restart.log'"
|
||||
else
|
||||
log "RESTART: Fallback — fresh hermes chat"
|
||||
tmux split-window -t "$session_name" -d \
|
||||
"hermes chat 2>&1 | tee -a '$HOME/.hermes/logs/${session_name}-restart.log'"
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
if pane_pid_alive "${session_name}:1" 2>/dev/null; then
|
||||
log "RESTART: Fallback restart succeeded"
|
||||
return 0
|
||||
else
|
||||
log "RESTART: FAILED to restart $target"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# === STATE MANAGEMENT ===
|
||||
|
||||
read_state() {
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
cat "$STATE_FILE"
|
||||
else
|
||||
echo "{}"
|
||||
fi
|
||||
}
|
||||
|
||||
write_state() {
|
||||
echo "$1" > "$STATE_FILE"
|
||||
}
|
||||
|
||||
# Update state for a single pane and return JSON status
|
||||
update_pane_state() {
|
||||
local target="$1"
|
||||
local hash="$2"
|
||||
local is_alive="$3"
|
||||
local now
|
||||
now=$(date +%s)
|
||||
|
||||
python3 - "$STATE_FILE" "$target" "$hash" "$is_alive" "$now" "$STUCK_CYCLES" <<'PYEOF'
|
||||
import json, sys, time
|
||||
|
||||
state_file = sys.argv[1]
|
||||
target = sys.argv[2]
|
||||
new_hash = sys.argv[3]
|
||||
is_alive = sys.argv[4] == "true"
|
||||
now = int(sys.argv[5])
|
||||
stuck_cycles = int(sys.argv[6])
|
||||
|
||||
try:
|
||||
with open(state_file) as f:
|
||||
state = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
state = {}
|
||||
|
||||
pane = state.get(target, {
|
||||
"hash": "",
|
||||
"same_count": 0,
|
||||
"status": "UNKNOWN",
|
||||
"last_change": 0,
|
||||
"last_check": 0,
|
||||
"restart_attempts": 0,
|
||||
"last_restart": 0,
|
||||
"current_command": "",
|
||||
})
|
||||
|
||||
if not is_alive:
|
||||
pane["status"] = "DEAD"
|
||||
pane["same_count"] = 0
|
||||
elif new_hash == pane.get("hash", ""):
|
||||
pane["same_count"] = pane.get("same_count", 0) + 1
|
||||
if pane["same_count"] >= stuck_cycles:
|
||||
pane["status"] = "STUCK"
|
||||
else:
|
||||
pane["status"] = "STALE" if pane["same_count"] > 0 else "OK"
|
||||
else:
|
||||
pane["hash"] = new_hash
|
||||
pane["same_count"] = 0
|
||||
pane["status"] = "OK"
|
||||
pane["last_change"] = now
|
||||
|
||||
pane["last_check"] = now
|
||||
state[target] = pane
|
||||
|
||||
with open(state_file, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
print(json.dumps(pane))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# Reset restart attempt counter if cooldown expired
|
||||
maybe_reset_restarts() {
|
||||
local target="$1"
|
||||
local now
|
||||
now=$(date +%s)
|
||||
|
||||
python3 - "$STATE_FILE" "$target" "$now" "$RESTART_COOLDOWN" <<'PYEOF'
|
||||
import json, sys
|
||||
|
||||
state_file = sys.argv[1]
|
||||
target = sys.argv[2]
|
||||
now = int(sys.argv[3])
|
||||
cooldown = int(sys.argv[4])
|
||||
|
||||
with open(state_file) as f:
|
||||
state = json.load(f)
|
||||
|
||||
pane = state.get(target, {})
|
||||
last_restart = pane.get("last_restart", 0)
|
||||
|
||||
if now - last_restart > cooldown:
|
||||
pane["restart_attempts"] = 0
|
||||
|
||||
state[target] = pane
|
||||
with open(state_file, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
print(pane.get("restart_attempts", 0))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
increment_restart_attempt() {
|
||||
local target="$1"
|
||||
local now
|
||||
now=$(date +%s)
|
||||
|
||||
python3 - "$STATE_FILE" "$target" "$now" <<'PYEOF'
|
||||
import json, sys
|
||||
|
||||
state_file = sys.argv[1]
|
||||
target = sys.argv[2]
|
||||
now = int(sys.argv[3])
|
||||
|
||||
with open(state_file) as f:
|
||||
state = json.load(f)
|
||||
|
||||
pane = state.get(target, {})
|
||||
pane["restart_attempts"] = pane.get("restart_attempts", 0) + 1
|
||||
pane["last_restart"] = now
|
||||
pane["status"] = "RESTARTING"
|
||||
|
||||
state[target] = pane
|
||||
with open(state_file, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
print(pane["restart_attempts"])
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# === CORE CHECK ===
|
||||
|
||||
check_pane() {
|
||||
local target="$1"
|
||||
local hash is_alive status current_cmd
|
||||
|
||||
# Capture state
|
||||
hash=$(capture_pane_hash "$target")
|
||||
if pane_pid_alive "$target"; then
|
||||
is_alive="true"
|
||||
else
|
||||
is_alive="false"
|
||||
fi
|
||||
|
||||
# Get current command for the pane
|
||||
current_cmd=$(pane_current_command "$target")
|
||||
|
||||
# Update state and get result
|
||||
local result
|
||||
result=$(update_pane_state "$target" "$hash" "$is_alive")
|
||||
status=$(echo "$result" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('status','UNKNOWN'))" 2>/dev/null || echo "UNKNOWN")
|
||||
|
||||
case "$status" in
|
||||
OK)
|
||||
# Healthy, do nothing
|
||||
;;
|
||||
DEAD)
|
||||
log "DETECTED: $target is DEAD (PID gone) cmd=$current_cmd"
|
||||
if is_restartable "$current_cmd"; then
|
||||
handle_stuck "$target"
|
||||
else
|
||||
log "SKIP: $target not a hermes pane (cmd=$current_cmd), not restarting"
|
||||
fi
|
||||
;;
|
||||
STUCK)
|
||||
log "DETECTED: $target is STUCK (output unchanged for ${STUCK_CYCLES} cycles) cmd=$current_cmd"
|
||||
if is_restartable "$current_cmd"; then
|
||||
handle_stuck "$target"
|
||||
else
|
||||
log "SKIP: $target not a hermes pane (cmd=$current_cmd), not restarting"
|
||||
fi
|
||||
;;
|
||||
STALE)
|
||||
# Output unchanged but within threshold — just log
|
||||
local count
|
||||
count=$(echo "$result" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('same_count',0))" 2>/dev/null || echo "?")
|
||||
log "STALE: $target unchanged for $count cycle(s)"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
handle_stuck() {
|
||||
local target="$1"
|
||||
local session_name="${target%%:*}"
|
||||
local attempts
|
||||
|
||||
# Check restart budget
|
||||
attempts=$(maybe_reset_restarts "$target")
|
||||
if [ "$attempts" -ge "$MAX_RESTART_ATTEMPTS" ]; then
|
||||
log "ESCALATION: $target stuck ${attempts}x — manual intervention needed"
|
||||
echo "ALERT: $target stuck after $attempts restart attempts" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
attempts=$(increment_restart_attempt "$target")
|
||||
log "ACTION: Restarting $target (attempt $attempts/$MAX_RESTART_ATTEMPTS)"
|
||||
|
||||
if restart_pane "$target"; then
|
||||
log "OK: $target restarted successfully"
|
||||
else
|
||||
log "FAIL: $target restart failed (attempt $attempts)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_all_sessions() {
|
||||
local sessions
|
||||
|
||||
if [ -n "$MONITOR_SESSIONS" ]; then
|
||||
IFS=',' read -ra sessions <<< "$MONITOR_SESSIONS"
|
||||
else
|
||||
sessions=()
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && sessions+=("$line")
|
||||
done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
local total=0 stuck=0 dead=0 ok=0
|
||||
for session in "${sessions[@]}"; do
|
||||
[ -z "$session" ] && continue
|
||||
# Get pane targets
|
||||
local panes
|
||||
panes=$(tmux list-panes -t "$session" -F "${session}:#{window_index}.#{pane_index}" 2>/dev/null || true)
|
||||
for target in $panes; do
|
||||
check_pane "$target"
|
||||
total=$((total + 1))
|
||||
done
|
||||
done
|
||||
|
||||
log "CHECK: Processed $total panes"
|
||||
}
|
||||
|
||||
# === STATUS DISPLAY ===
|
||||
|
||||
show_status() {
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo "No pane state file found at $STATE_FILE"
|
||||
echo "Run pane-watchdog.sh once to initialize."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - "$STATE_FILE" <<'PYEOF'
|
||||
import json, sys, time
|
||||
|
||||
state_file = sys.argv[1]
|
||||
try:
|
||||
with open(state_file) as f:
|
||||
state = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
print("No state data yet.")
|
||||
sys.exit(0)
|
||||
|
||||
if not state:
|
||||
print("No panes tracked.")
|
||||
sys.exit(0)
|
||||
|
||||
now = int(time.time())
|
||||
print(f"{'PANE':<35} {'STATUS':<12} {'STALE':<6} {'LAST CHANGE':<15} {'RESTARTS'}")
|
||||
print("-" * 90)
|
||||
|
||||
for target in sorted(state.keys()):
|
||||
p = state[target]
|
||||
status = p.get("status", "?")
|
||||
same = p.get("same_count", 0)
|
||||
last_change = p.get("last_change", 0)
|
||||
restarts = p.get("restart_attempts", 0)
|
||||
|
||||
if last_change:
|
||||
ago = now - last_change
|
||||
if ago < 60:
|
||||
change_str = f"{ago}s ago"
|
||||
elif ago < 3600:
|
||||
change_str = f"{ago//60}m ago"
|
||||
else:
|
||||
change_str = f"{ago//3600}h ago"
|
||||
else:
|
||||
change_str = "never"
|
||||
|
||||
# Color code
|
||||
if status == "OK":
|
||||
icon = "✓"
|
||||
elif status == "STUCK":
|
||||
icon = "✖"
|
||||
elif status == "DEAD":
|
||||
icon = "☠"
|
||||
elif status == "STALE":
|
||||
icon = "⏳"
|
||||
else:
|
||||
icon = "?"
|
||||
|
||||
print(f" {icon} {target:<32} {status:<12} {same:<6} {change_str:<15} {restarts}")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# === DAEMON MODE ===
|
||||
|
||||
run_daemon() {
|
||||
log "DAEMON: Starting (interval=${CHECK_INTERVAL}s, stuck_threshold=${STUCK_CYCLES})"
|
||||
echo "Pane watchdog started. Checking every ${CHECK_INTERVAL}s. Ctrl+C to stop."
|
||||
echo "Log: $LOG_FILE"
|
||||
echo "State: $STATE_FILE"
|
||||
echo ""
|
||||
|
||||
while true; do
|
||||
check_all_sessions
|
||||
sleep "$CHECK_INTERVAL"
|
||||
done
|
||||
}
|
||||
|
||||
# === MAIN ===
|
||||
|
||||
case "${1:-}" in
|
||||
--daemon)
|
||||
run_daemon
|
||||
;;
|
||||
--status)
|
||||
show_status
|
||||
;;
|
||||
--session)
|
||||
if [ -z "${2:-}" ]; then
|
||||
echo "Usage: pane-watchdog.sh --session SESSION_NAME"
|
||||
exit 1
|
||||
fi
|
||||
MONITOR_SESSIONS="$2"
|
||||
check_all_sessions
|
||||
;;
|
||||
--help|-h)
|
||||
echo "pane-watchdog.sh — Detect stuck/dead tmux panes and auto-restart"
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo " pane-watchdog.sh # One-shot check"
|
||||
echo " pane-watchdog.sh --daemon # Continuous monitoring"
|
||||
echo " pane-watchdog.sh --status # Show pane state"
|
||||
echo " pane-watchdog.sh --session S # Check one session"
|
||||
echo ""
|
||||
echo "Config (env vars):"
|
||||
echo " PANE_CHECK_INTERVAL Seconds between checks (default: 120)"
|
||||
echo " PANE_WATCHDOG_SESSIONS Comma-separated session names"
|
||||
echo " PANE_STATE_FILE State file path"
|
||||
echo " STUCK_CYCLES Unchanged cycles before STUCK (default: 2)"
|
||||
;;
|
||||
*)
|
||||
check_all_sessions
|
||||
;;
|
||||
esac
|
||||
@@ -3,7 +3,7 @@
|
||||
# Uses Hermes CLI plus workforce-manager to triage and review.
|
||||
# Timmy is the brain. Other agents are the hands.
|
||||
|
||||
set -uo pipefail
|
||||
set -uo pipefail\n\nSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
LOG_DIR="$HOME/.hermes/logs"
|
||||
LOG="$LOG_DIR/timmy-orchestrator.log"
|
||||
@@ -40,6 +40,7 @@ gather_state() {
|
||||
> "$state_dir/unassigned.txt"
|
||||
> "$state_dir/open_prs.txt"
|
||||
> "$state_dir/agent_status.txt"
|
||||
> "$state_dir/uncommitted_work.txt"
|
||||
|
||||
for repo in $REPOS; do
|
||||
local short=$(echo "$repo" | cut -d/ -f2)
|
||||
@@ -71,6 +72,24 @@ for p in json.load(sys.stdin):
|
||||
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "FAILED:" | xargs -I{} echo "Kimi recent failures: {}" >> "$state_dir/agent_status.txt"
|
||||
tail -1 "/tmp/kimi-heartbeat.log" 2>/dev/null | xargs -I{} echo "Kimi last event: {}" >> "$state_dir/agent_status.txt"
|
||||
|
||||
# Scan worktrees for uncommitted work
|
||||
for wt_dir in "$HOME/worktrees"/*/; do
|
||||
[ -d "$wt_dir" ] || continue
|
||||
[ -d "$wt_dir/.git" ] || continue
|
||||
local dirty
|
||||
dirty=$(cd "$wt_dir" && git status --porcelain 2>/dev/null | wc -l | tr -d " ")
|
||||
if [ "${dirty:-0}" -gt 0 ]; then
|
||||
local branch
|
||||
branch=$(cd "$wt_dir" && git branch --show-current 2>/dev/null || echo "?")
|
||||
local age=""
|
||||
local last_commit
|
||||
last_commit=$(cd "$wt_dir" && git log -1 --format=%ct 2>/dev/null || echo 0)
|
||||
local now=$(date +%s)
|
||||
local stale_mins=$(( (now - last_commit) / 60 ))
|
||||
echo "DIR=$wt_dir BRANCH=$branch DIRTY=$dirty STALE=${stale_mins}m" >> "$state_dir/uncommitted_work.txt"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$state_dir"
|
||||
}
|
||||
|
||||
@@ -81,6 +100,25 @@ run_triage() {
|
||||
|
||||
log "Cycle: $unassigned_count unassigned, $pr_count open PRs"
|
||||
|
||||
# Check for uncommitted work — nag if stale
|
||||
local uncommitted_count
|
||||
uncommitted_count=$(wc -l < "$state_dir/uncommitted_work.txt" 2>/dev/null | tr -d " " || echo 0)
|
||||
if [ "${uncommitted_count:-0}" -gt 0 ]; then
|
||||
log "WARNING: $uncommitted_count worktree(s) with uncommitted work"
|
||||
while IFS= read -r line; do
|
||||
log " UNCOMMITTED: $line"
|
||||
# Auto-commit stale work (>60 min without commit)
|
||||
local stale=$(echo "$line" | sed 's/.*STALE=\([0-9]*\)m.*/\1/')
|
||||
local wt_dir=$(echo "$line" | sed 's/.*DIR=\([^ ]*\) .*/\1/')
|
||||
if [ "${stale:-0}" -gt 60 ]; then
|
||||
log " AUTO-COMMITTING stale work in $wt_dir (${stale}m stale)"
|
||||
(cd "$wt_dir" && git add -A && git commit -m "WIP: orchestrator auto-commit — ${stale}m stale work
|
||||
|
||||
Preserved by timmy-orchestrator to prevent loss." 2>/dev/null && git push 2>/dev/null) && log " COMMITTED: $wt_dir" || log " COMMIT FAILED: $wt_dir"
|
||||
fi
|
||||
done < "$state_dir/uncommitted_work.txt"
|
||||
fi
|
||||
|
||||
# If nothing to do, skip the LLM call
|
||||
if [ "$unassigned_count" -eq 0 ] && [ "$pr_count" -eq 0 ]; then
|
||||
log "Nothing to triage"
|
||||
@@ -198,6 +236,12 @@ FOOTER
|
||||
log "=== Timmy Orchestrator Started (PID $$) ==="
|
||||
log "Cycle: ${CYCLE_INTERVAL}s | Auto-assign: ${AUTO_ASSIGN_UNASSIGNED} | Inference surface: Hermes CLI"
|
||||
|
||||
# Start auto-commit-guard daemon for work preservation
|
||||
if ! pgrep -f "auto-commit-guard.sh" >/dev/null 2>&1; then
|
||||
nohup bash "$SCRIPT_DIR/auto-commit-guard.sh" 120 >> "$LOG_DIR/auto-commit-guard.log" 2>&1 &
|
||||
log "Started auto-commit-guard daemon (PID $!)"
|
||||
fi
|
||||
|
||||
WORKFORCE_CYCLE=0
|
||||
|
||||
while true; do
|
||||
|
||||
97
bin/tmux-resume.sh
Executable file
97
bin/tmux-resume.sh
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── tmux-resume.sh — Cold-start Session Resume ───────────────────────────
|
||||
# Reads ~/.timmy/tmux-state.json and resumes hermes sessions.
|
||||
# Run at startup to restore pane state after supervisor restart.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="${HOME}/.timmy/tmux-state.json"
|
||||
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "[tmux-resume] No manifest found at $MANIFEST — starting fresh."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 << 'PYEOF'
|
||||
import json, subprocess, os, sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
MANIFEST = os.path.expanduser("~/.timmy/tmux-state.json")
|
||||
|
||||
def run(cmd):
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
||||
return r.stdout.strip(), r.returncode
|
||||
except Exception as e:
|
||||
return str(e), 1
|
||||
|
||||
def session_exists(name):
|
||||
out, _ = run(f"tmux has-session -t '{name}' 2>&1")
|
||||
return "can't find" not in out.lower()
|
||||
|
||||
with open(MANIFEST) as f:
|
||||
state = json.load(f)
|
||||
|
||||
ts = state.get("timestamp", "unknown")
|
||||
age = "unknown"
|
||||
try:
|
||||
t = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
delta = datetime.now(timezone.utc) - t
|
||||
mins = int(delta.total_seconds() / 60)
|
||||
if mins < 60:
|
||||
age = f"{mins}m ago"
|
||||
else:
|
||||
age = f"{mins//60}h {mins%60}m ago"
|
||||
except:
|
||||
pass
|
||||
|
||||
print(f"[tmux-resume] Manifest from {age}: {state['summary']['total_sessions']} sessions, "
|
||||
f"{state['summary']['hermes_panes']} hermes panes")
|
||||
|
||||
restored = 0
|
||||
skipped = 0
|
||||
|
||||
for pane in state.get("panes", []):
|
||||
if not pane.get("is_hermes"):
|
||||
continue
|
||||
|
||||
addr = pane["address"] # e.g. "BURN:2.3"
|
||||
session = addr.split(":")[0]
|
||||
session_id = pane.get("session_id")
|
||||
profile = pane.get("profile", "default")
|
||||
model = pane.get("model", "")
|
||||
task = pane.get("task", "")
|
||||
|
||||
# Skip if session already exists (already running)
|
||||
if session_exists(session):
|
||||
print(f" [skip] {addr} — session '{session}' already exists")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Respawn hermes with session resume if we have a session ID
|
||||
if session_id:
|
||||
print(f" [resume] {addr} — profile={profile} model={model} session={session_id}")
|
||||
cmd = f"hermes chat --resume {session_id}"
|
||||
else:
|
||||
print(f" [start] {addr} — profile={profile} model={model} (no session ID)")
|
||||
cmd = f"hermes chat --profile {profile}"
|
||||
|
||||
# Create tmux session and run hermes
|
||||
run(f"tmux new-session -d -s '{session}' -n '{session}:0'")
|
||||
run(f"tmux send-keys -t '{session}' '{cmd}' Enter")
|
||||
restored += 1
|
||||
|
||||
# Write resume log
|
||||
log = {
|
||||
"resumed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"manifest_age": age,
|
||||
"restored": restored,
|
||||
"skipped": skipped,
|
||||
}
|
||||
log_path = os.path.expanduser("~/.timmy/tmux-resume.log")
|
||||
with open(log_path, "w") as f:
|
||||
json.dump(log, f, indent=2)
|
||||
|
||||
print(f"[tmux-resume] Done: {restored} restored, {skipped} skipped")
|
||||
PYEOF
|
||||
237
bin/tmux-state.sh
Executable file
237
bin/tmux-state.sh
Executable file
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── tmux-state.sh — Session State Persistence Manifest ───────────────────
|
||||
# Snapshots all tmux pane state to ~/.timmy/tmux-state.json
|
||||
# Run every supervisor cycle. Cold-start reads this manifest to resume.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST="${HOME}/.timmy/tmux-state.json"
|
||||
mkdir -p "$(dirname "$MANIFEST")"
|
||||
|
||||
python3 << 'PYEOF'
|
||||
import json, subprocess, os, time, re, sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
MANIFEST = os.path.expanduser("~/.timmy/tmux-state.json")
|
||||
|
||||
def run(cmd):
|
||||
"""Run command, return stdout or empty string."""
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
|
||||
return r.stdout.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def get_sessions():
|
||||
"""Get all tmux sessions with metadata."""
|
||||
raw = run("tmux list-sessions -F '#{session_name}|#{session_windows}|#{session_created}|#{session_attached}|#{session_group}|#{session_id}'")
|
||||
sessions = []
|
||||
for line in raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|")
|
||||
if len(parts) < 6:
|
||||
continue
|
||||
sessions.append({
|
||||
"name": parts[0],
|
||||
"windows": int(parts[1]),
|
||||
"created_epoch": int(parts[2]),
|
||||
"created": datetime.fromtimestamp(int(parts[2]), tz=timezone.utc).isoformat(),
|
||||
"attached": parts[3] == "1",
|
||||
"group": parts[4],
|
||||
"id": parts[5],
|
||||
})
|
||||
return sessions
|
||||
|
||||
def get_panes():
|
||||
"""Get all tmux panes with full metadata."""
|
||||
fmt = '#{session_name}|#{window_index}|#{pane_index}|#{pane_pid}|#{pane_title}|#{pane_width}x#{pane_height}|#{pane_active}|#{pane_current_command}|#{pane_start_command}|#{pane_tty}|#{pane_id}|#{window_name}|#{session_id}'
|
||||
raw = run(f"tmux list-panes -a -F '{fmt}'")
|
||||
panes = []
|
||||
for line in raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|")
|
||||
if len(parts) < 13:
|
||||
continue
|
||||
session, win, pane, pid, title, size, active, cmd, start_cmd, tty, pane_id, win_name, sess_id = parts[:13]
|
||||
w, h = size.split("x") if "x" in size else ("0", "0")
|
||||
panes.append({
|
||||
"session": session,
|
||||
"window_index": int(win),
|
||||
"window_name": win_name,
|
||||
"pane_index": int(pane),
|
||||
"pane_id": pane_id,
|
||||
"pid": int(pid) if pid.isdigit() else 0,
|
||||
"title": title,
|
||||
"width": int(w),
|
||||
"height": int(h),
|
||||
"active": active == "1",
|
||||
"command": cmd,
|
||||
"start_command": start_cmd,
|
||||
"tty": tty,
|
||||
"session_id": sess_id,
|
||||
})
|
||||
return panes
|
||||
|
||||
def extract_hermes_state(pane):
|
||||
"""Try to extract hermes session info from a pane."""
|
||||
info = {
|
||||
"is_hermes": False,
|
||||
"profile": None,
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"session_id": None,
|
||||
"task": None,
|
||||
}
|
||||
title = pane.get("title", "")
|
||||
cmd = pane.get("command", "")
|
||||
start = pane.get("start_command", "")
|
||||
|
||||
# Detect hermes processes
|
||||
is_hermes = any(k in (title + " " + cmd + " " + start).lower()
|
||||
for k in ["hermes", "timmy", "mimo", "claude", "gpt"])
|
||||
if not is_hermes and cmd not in ("python3", "python3.11", "bash", "zsh", "fish"):
|
||||
return info
|
||||
|
||||
# Try reading pane content for model/provider clues
|
||||
pane_content = run(f"tmux capture-pane -t '{pane['session']}:{pane['window_index']}.{pane['pane_index']}' -p -S -20 2>/dev/null")
|
||||
|
||||
# Extract model from pane content patterns
|
||||
model_patterns = [
|
||||
r"(?:mimo-v2-pro|claude-[\w.-]+|gpt-[\w.-]+|gemini-[\w.-]+|qwen[\w:.-]*)",
|
||||
]
|
||||
for pat in model_patterns:
|
||||
m = re.search(pat, pane_content, re.IGNORECASE)
|
||||
if m:
|
||||
info["model"] = m.group(0)
|
||||
info["is_hermes"] = True
|
||||
break
|
||||
|
||||
# Provider inference from model
|
||||
model = (info["model"] or "").lower()
|
||||
if "mimo" in model:
|
||||
info["provider"] = "nous"
|
||||
elif "claude" in model:
|
||||
info["provider"] = "anthropic"
|
||||
elif "gpt" in model:
|
||||
info["provider"] = "openai"
|
||||
elif "gemini" in model:
|
||||
info["provider"] = "google"
|
||||
elif "qwen" in model:
|
||||
info["provider"] = "custom"
|
||||
|
||||
# Profile from session name
|
||||
session = pane["session"].lower()
|
||||
if "burn" in session:
|
||||
info["profile"] = "burn"
|
||||
elif session in ("dev", "0"):
|
||||
info["profile"] = "default"
|
||||
else:
|
||||
info["profile"] = session
|
||||
|
||||
# Try to extract session ID (hermes uses UUIDs)
|
||||
uuid_match = re.findall(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', pane_content)
|
||||
if uuid_match:
|
||||
info["session_id"] = uuid_match[-1] # most recent
|
||||
info["is_hermes"] = True
|
||||
|
||||
# Last prompt — grab the last user-like line
|
||||
lines = pane_content.splitlines()
|
||||
for line in reversed(lines):
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith(("─", "│", "╭", "╰", "▸", "●", "○")) and len(stripped) > 10:
|
||||
info["task"] = stripped[:200]
|
||||
break
|
||||
|
||||
return info
|
||||
|
||||
def get_context_percent(pane):
|
||||
"""Estimate context usage from pane content heuristics."""
|
||||
content = run(f"tmux capture-pane -t '{pane['session']}:{pane['window_index']}.{pane['pane_index']}' -p -S -5 2>/dev/null")
|
||||
# Look for context indicators like "ctx 45%" or "[░░░░░░░░░░]"
|
||||
ctx_match = re.search(r'ctx\s*(\d+)%', content)
|
||||
if ctx_match:
|
||||
return int(ctx_match.group(1))
|
||||
bar_match = re.search(r'\[(░+█*█*░*)\]', content)
|
||||
if bar_match:
|
||||
bar = bar_match.group(1)
|
||||
filled = bar.count('█')
|
||||
total = len(bar)
|
||||
if total > 0:
|
||||
return int((filled / total) * 100)
|
||||
return None
|
||||
|
||||
def build_manifest():
|
||||
"""Build the full tmux state manifest."""
|
||||
now = datetime.now(timezone.utc)
|
||||
sessions = get_sessions()
|
||||
panes = get_panes()
|
||||
|
||||
pane_manifests = []
|
||||
for p in panes:
|
||||
hermes = extract_hermes_state(p)
|
||||
ctx = get_context_percent(p)
|
||||
|
||||
entry = {
|
||||
"address": f"{p['session']}:{p['window_index']}.{p['pane_index']}",
|
||||
"pane_id": p["pane_id"],
|
||||
"pid": p["pid"],
|
||||
"size": f"{p['width']}x{p['height']}",
|
||||
"active": p["active"],
|
||||
"command": p["command"],
|
||||
"title": p["title"],
|
||||
"profile": hermes["profile"],
|
||||
"model": hermes["model"],
|
||||
"provider": hermes["provider"],
|
||||
"session_id": hermes["session_id"],
|
||||
"task": hermes["task"],
|
||||
"context_pct": ctx,
|
||||
"is_hermes": hermes["is_hermes"],
|
||||
}
|
||||
pane_manifests.append(entry)
|
||||
|
||||
# Active pane summary
|
||||
active_panes = [p for p in pane_manifests if p["active"]]
|
||||
primary = active_panes[0] if active_panes else {}
|
||||
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"timestamp": now.isoformat(),
|
||||
"timestamp_epoch": int(now.timestamp()),
|
||||
"hostname": os.uname().nodename,
|
||||
"sessions": sessions,
|
||||
"panes": pane_manifests,
|
||||
"summary": {
|
||||
"total_sessions": len(sessions),
|
||||
"total_panes": len(pane_manifests),
|
||||
"hermes_panes": sum(1 for p in pane_manifests if p["is_hermes"]),
|
||||
"active_pane": primary.get("address"),
|
||||
"active_model": primary.get("model"),
|
||||
"active_provider": primary.get("provider"),
|
||||
},
|
||||
}
|
||||
|
||||
return manifest
|
||||
|
||||
# --- Main ---
|
||||
manifest = build_manifest()
|
||||
|
||||
# Write manifest
|
||||
with open(MANIFEST, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
# Also write to ~/.hermes/tmux-state.json for compatibility
|
||||
hermes_manifest = os.path.expanduser("~/.hermes/tmux-state.json")
|
||||
os.makedirs(os.path.dirname(hermes_manifest), exist_ok=True)
|
||||
with open(hermes_manifest, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
print(f"[tmux-state] {manifest['summary']['total_panes']} panes, "
|
||||
f"{manifest['summary']['hermes_panes']} hermes, "
|
||||
f"active={manifest['summary']['active_pane']} "
|
||||
f"@ {manifest['summary']['active_model']}")
|
||||
print(f"[tmux-state] written to {MANIFEST}")
|
||||
PYEOF
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updated_at": "2026-03-28T09:54:34.822062",
|
||||
"updated_at": "2026-04-13T02:02:07.001824",
|
||||
"platforms": {
|
||||
"discord": [
|
||||
{
|
||||
@@ -27,11 +27,81 @@
|
||||
"name": "Timmy Time",
|
||||
"type": "group",
|
||||
"thread_id": null
|
||||
},
|
||||
{
|
||||
"id": "-1003664764329:85",
|
||||
"name": "Timmy Time / topic 85",
|
||||
"type": "group",
|
||||
"thread_id": "85"
|
||||
},
|
||||
{
|
||||
"id": "-1003664764329:111",
|
||||
"name": "Timmy Time / topic 111",
|
||||
"type": "group",
|
||||
"thread_id": "111"
|
||||
},
|
||||
{
|
||||
"id": "-1003664764329:173",
|
||||
"name": "Timmy Time / topic 173",
|
||||
"type": "group",
|
||||
"thread_id": "173"
|
||||
},
|
||||
{
|
||||
"id": "7635059073",
|
||||
"name": "Trip T",
|
||||
"type": "dm",
|
||||
"thread_id": null
|
||||
},
|
||||
{
|
||||
"id": "-1003664764329:244",
|
||||
"name": "Timmy Time / topic 244",
|
||||
"type": "group",
|
||||
"thread_id": "244"
|
||||
},
|
||||
{
|
||||
"id": "-1003664764329:972",
|
||||
"name": "Timmy Time / topic 972",
|
||||
"type": "group",
|
||||
"thread_id": "972"
|
||||
},
|
||||
{
|
||||
"id": "-1003664764329:931",
|
||||
"name": "Timmy Time / topic 931",
|
||||
"type": "group",
|
||||
"thread_id": "931"
|
||||
},
|
||||
{
|
||||
"id": "-1003664764329:957",
|
||||
"name": "Timmy Time / topic 957",
|
||||
"type": "group",
|
||||
"thread_id": "957"
|
||||
},
|
||||
{
|
||||
"id": "-1003664764329:1297",
|
||||
"name": "Timmy Time / topic 1297",
|
||||
"type": "group",
|
||||
"thread_id": "1297"
|
||||
},
|
||||
{
|
||||
"id": "-1003664764329:1316",
|
||||
"name": "Timmy Time / topic 1316",
|
||||
"type": "group",
|
||||
"thread_id": "1316"
|
||||
}
|
||||
],
|
||||
"whatsapp": [],
|
||||
"slack": [],
|
||||
"signal": [],
|
||||
"mattermost": [],
|
||||
"matrix": [],
|
||||
"homeassistant": [],
|
||||
"email": [],
|
||||
"sms": []
|
||||
"sms": [],
|
||||
"dingtalk": [],
|
||||
"feishu": [],
|
||||
"wecom": [],
|
||||
"wecom_callback": [],
|
||||
"weixin": [],
|
||||
"bluebubbles": []
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ Purpose:
|
||||
|
||||
## What it is
|
||||
|
||||
Code Claw is a separate local runtime from Hermes/OpenClaw.
|
||||
Code Claw is a separate local runtime from Hermes.
|
||||
|
||||
Current lane:
|
||||
- runtime: local patched `~/code-claw`
|
||||
|
||||
218
config.yaml
218
config.yaml
@@ -1,31 +1,23 @@
|
||||
model:
|
||||
default: hermes4:14b
|
||||
provider: custom
|
||||
context_length: 65536
|
||||
base_url: http://localhost:8081/v1
|
||||
default: claude-opus-4-6
|
||||
provider: anthropic
|
||||
toolsets:
|
||||
- all
|
||||
agent:
|
||||
max_turns: 30
|
||||
reasoning_effort: xhigh
|
||||
reasoning_effort: medium
|
||||
verbose: false
|
||||
terminal:
|
||||
backend: local
|
||||
cwd: .
|
||||
timeout: 180
|
||||
env_passthrough: []
|
||||
docker_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||
docker_forward_env: []
|
||||
singularity_image: docker://nikolaik/python-nodejs:python3.11-nodejs20
|
||||
modal_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||
daytona_image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||
container_cpu: 1
|
||||
container_embeddings:
|
||||
provider: ollama
|
||||
model: nomic-embed-text
|
||||
base_url: http://localhost:11434/v1
|
||||
|
||||
memory: 5120
|
||||
container_memory: 5120
|
||||
container_disk: 51200
|
||||
container_persistent: true
|
||||
docker_volumes: []
|
||||
@@ -33,89 +25,74 @@ memory: 5120
|
||||
persistent_shell: true
|
||||
browser:
|
||||
inactivity_timeout: 120
|
||||
command_timeout: 30
|
||||
record_sessions: false
|
||||
checkpoints:
|
||||
enabled: true
|
||||
enabled: false
|
||||
max_snapshots: 50
|
||||
compression:
|
||||
enabled: true
|
||||
threshold: 0.5
|
||||
target_ratio: 0.2
|
||||
protect_last_n: 20
|
||||
summary_model: ''
|
||||
summary_provider: ''
|
||||
summary_base_url: ''
|
||||
synthesis_model:
|
||||
provider: custom
|
||||
model: llama3:70b
|
||||
base_url: http://localhost:8081/v1
|
||||
|
||||
summary_model: qwen3:30b
|
||||
summary_provider: custom
|
||||
summary_base_url: http://localhost:11434/v1
|
||||
smart_model_routing:
|
||||
enabled: true
|
||||
max_simple_chars: 400
|
||||
max_simple_words: 75
|
||||
cheap_model:
|
||||
provider: 'ollama'
|
||||
model: 'gemma2:2b'
|
||||
base_url: 'http://localhost:11434/v1'
|
||||
api_key: ''
|
||||
enabled: false
|
||||
max_simple_chars: 160
|
||||
max_simple_words: 28
|
||||
cheap_model: {}
|
||||
auxiliary:
|
||||
vision:
|
||||
provider: auto
|
||||
model: ''
|
||||
base_url: ''
|
||||
api_key: ''
|
||||
timeout: 30
|
||||
provider: custom
|
||||
model: qwen3:30b
|
||||
base_url: 'http://localhost:11434/v1'
|
||||
api_key: 'ollama'
|
||||
web_extract:
|
||||
provider: auto
|
||||
model: ''
|
||||
base_url: ''
|
||||
api_key: ''
|
||||
provider: custom
|
||||
model: qwen3:30b
|
||||
base_url: 'http://localhost:11434/v1'
|
||||
api_key: 'ollama'
|
||||
compression:
|
||||
provider: auto
|
||||
model: ''
|
||||
base_url: ''
|
||||
api_key: ''
|
||||
provider: custom
|
||||
model: qwen3:30b
|
||||
base_url: 'http://localhost:11434/v1'
|
||||
api_key: 'ollama'
|
||||
session_search:
|
||||
provider: auto
|
||||
model: ''
|
||||
base_url: ''
|
||||
api_key: ''
|
||||
provider: custom
|
||||
model: qwen3:30b
|
||||
base_url: 'http://localhost:11434/v1'
|
||||
api_key: 'ollama'
|
||||
skills_hub:
|
||||
provider: auto
|
||||
model: ''
|
||||
base_url: ''
|
||||
api_key: ''
|
||||
provider: custom
|
||||
model: qwen3:30b
|
||||
base_url: 'http://localhost:11434/v1'
|
||||
api_key: 'ollama'
|
||||
approval:
|
||||
provider: auto
|
||||
model: ''
|
||||
base_url: ''
|
||||
api_key: ''
|
||||
mcp:
|
||||
provider: auto
|
||||
model: ''
|
||||
base_url: ''
|
||||
api_key: ''
|
||||
provider: custom
|
||||
model: qwen3:30b
|
||||
base_url: 'http://localhost:11434/v1'
|
||||
api_key: 'ollama'
|
||||
flush_memories:
|
||||
provider: auto
|
||||
model: ''
|
||||
base_url: ''
|
||||
api_key: ''
|
||||
provider: custom
|
||||
model: qwen3:30b
|
||||
base_url: 'http://localhost:11434/v1'
|
||||
api_key: 'ollama'
|
||||
display:
|
||||
compact: false
|
||||
personality: ''
|
||||
resume_display: full
|
||||
busy_input_mode: interrupt
|
||||
bell_on_complete: false
|
||||
show_reasoning: false
|
||||
streaming: false
|
||||
show_cost: false
|
||||
skin: timmy
|
||||
tool_progress_command: false
|
||||
tool_progress: all
|
||||
privacy:
|
||||
redact_pii: true
|
||||
redact_pii: false
|
||||
tts:
|
||||
provider: edge
|
||||
edge:
|
||||
@@ -124,7 +101,7 @@ tts:
|
||||
voice_id: pNInz6obpgDQGcFmaJgB
|
||||
model_id: eleven_multilingual_v2
|
||||
openai:
|
||||
model: '' # disabled — use edge TTS locally
|
||||
model: gpt-4o-mini-tts
|
||||
voice: alloy
|
||||
neutts:
|
||||
ref_audio: ''
|
||||
@@ -160,7 +137,6 @@ delegation:
|
||||
provider: ''
|
||||
base_url: ''
|
||||
api_key: ''
|
||||
max_iterations: 50
|
||||
prefill_messages_file: ''
|
||||
honcho: {}
|
||||
timezone: ''
|
||||
@@ -174,16 +150,7 @@ approvals:
|
||||
command_allowlist: []
|
||||
quick_commands: {}
|
||||
personalities: {}
|
||||
mesh:
|
||||
enabled: true
|
||||
blackboard_provider: local
|
||||
nostr_discovery: true
|
||||
consensus_mode: competitive
|
||||
|
||||
security:
|
||||
sovereign_audit: true
|
||||
no_phone_home: true
|
||||
|
||||
redact_secrets: true
|
||||
tirith_enabled: true
|
||||
tirith_path: tirith
|
||||
@@ -193,55 +160,66 @@ security:
|
||||
enabled: false
|
||||
domains: []
|
||||
shared_files: []
|
||||
_config_version: 10
|
||||
platforms:
|
||||
api_server:
|
||||
enabled: true
|
||||
extra:
|
||||
host: 0.0.0.0
|
||||
port: 8642
|
||||
# Author whitelist for task router (Issue #132)
|
||||
# Only users in this list can submit tasks via Gitea issues
|
||||
# Empty list = deny all (secure by default)
|
||||
# Set via env var TIMMY_AUTHOR_WHITELIST as comma-separated list
|
||||
author_whitelist: []
|
||||
_config_version: 9
|
||||
session_reset:
|
||||
mode: none
|
||||
idle_minutes: 0
|
||||
custom_providers:
|
||||
- name: Local llama.cpp
|
||||
base_url: http://localhost:8081/v1
|
||||
api_key: none
|
||||
model: hermes4:14b
|
||||
# ── Emergency cloud provider — not used by default or any cron job.
|
||||
# Available for explicit override only: hermes --model gemini-2.5-pro
|
||||
- name: Google Gemini (emergency only)
|
||||
base_url: https://generativelanguage.googleapis.com/v1beta/openai
|
||||
api_key_env: GEMINI_API_KEY
|
||||
model: gemini-2.5-pro
|
||||
- name: Local Ollama
|
||||
base_url: http://localhost:11434/v1
|
||||
api_key: ollama
|
||||
model: qwen3:30b
|
||||
system_prompt_suffix: "You are Timmy. Your soul is defined in SOUL.md \u2014 read\
|
||||
\ it, live it.\nYou run locally on your owner's machine via llama.cpp. You never\
|
||||
\ phone home.\nYou speak plainly. You prefer short sentences. Brevity is a kindness.\n\
|
||||
When you don't know something, say so. Refusal over fabrication.\nSovereignty and\
|
||||
\ service always.\n"
|
||||
\ it, live it.\nYou run locally on your owner's machine via Ollama. You never phone\
|
||||
\ home.\nYou speak plainly. You prefer short sentences. Brevity is a kindness.\n\
|
||||
Source distinction: Tag every factual claim inline. Default is [generated] — you\
|
||||
\ are pattern-matching from training data. Only use [retrieved] when you can name\
|
||||
\ the specific tool call or document from THIS conversation that provided the fact.\
|
||||
\ If no tool was called, every claim is [generated]. No exceptions.\n\
|
||||
Refusal over fabrication: When you generate a specific claim — a date, a number,\
|
||||
\ a price, a version, a URL, a current event — and you cannot name a source from\
|
||||
\ this conversation, say 'I don't know' instead. Do not guess. Do not hedge with\
|
||||
\ 'probably' or 'approximately' as a substitute for knowledge. If your only source\
|
||||
\ is training data and the claim could be wrong or outdated, the honest answer is\
|
||||
\ 'I don't know — I can look this up if you'd like.' Prefer a true 'I don't know'\
|
||||
\ over a plausible fabrication.\nSovereignty and service always.\n"
|
||||
skills:
|
||||
creation_nudge_interval: 15
|
||||
DISCORD_HOME_CHANNEL: '1476292315814297772'
|
||||
providers:
|
||||
ollama:
|
||||
base_url: http://localhost:11434/v1
|
||||
model: hermes3:latest
|
||||
mcp_servers:
|
||||
morrowind:
|
||||
command: python3
|
||||
args:
|
||||
- /Users/apayne/.timmy/morrowind/mcp_server.py
|
||||
env: {}
|
||||
timeout: 30
|
||||
crucible:
|
||||
command: /Users/apayne/.hermes/hermes-agent/venv/bin/python3
|
||||
args:
|
||||
- /Users/apayne/.hermes/bin/crucible_mcp_server.py
|
||||
env: {}
|
||||
timeout: 120
|
||||
connect_timeout: 60
|
||||
fallback_model:
|
||||
provider: ollama
|
||||
model: hermes3:latest
|
||||
base_url: http://localhost:11434/v1
|
||||
api_key: ''
|
||||
|
||||
# ── Fallback Model ────────────────────────────────────────────────────
|
||||
# Automatic provider failover when primary is unavailable.
|
||||
# Uncomment and configure to enable. Triggers on rate limits (429),
|
||||
# overload (529), service errors (503), or connection failures.
|
||||
#
|
||||
# Supported providers:
|
||||
# openrouter (OPENROUTER_API_KEY) — routes to any model
|
||||
# openai-codex (OAuth — hermes login) — OpenAI Codex
|
||||
# nous (OAuth — hermes login) — Nous Portal
|
||||
# zai (ZAI_API_KEY) — Z.AI / GLM
|
||||
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
|
||||
# minimax (MINIMAX_API_KEY) — MiniMax
|
||||
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
|
||||
#
|
||||
# For custom OpenAI-compatible endpoints, add base_url and api_key_env.
|
||||
#
|
||||
# fallback_model:
|
||||
# provider: openrouter
|
||||
# model: anthropic/claude-sonnet-4
|
||||
#
|
||||
# ── Smart Model Routing ────────────────────────────────────────────────
|
||||
# Optional cheap-vs-strong routing for simple turns.
|
||||
# Keeps the primary model for complex work, but can route short/simple
|
||||
# messages to a cheaper model across providers.
|
||||
#
|
||||
# smart_model_routing:
|
||||
# enabled: true
|
||||
# max_simple_chars: 160
|
||||
# max_simple_words: 28
|
||||
# cheap_model:
|
||||
# provider: openrouter
|
||||
# model: google/gemini-2.5-flash
|
||||
|
||||
212
cron/jobs-backup-2026-04-10.json
Normal file
212
cron/jobs-backup-2026-04-10.json
Normal file
@@ -0,0 +1,212 @@
|
||||
[
|
||||
{
|
||||
"job_id": "9e0624269ba7",
|
||||
"name": "Triage Heartbeat",
|
||||
"schedule": "every 15m",
|
||||
"state": "paused"
|
||||
},
|
||||
{
|
||||
"job_id": "e29eda4a8548",
|
||||
"name": "PR Review Sweep",
|
||||
"schedule": "every 30m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "a77a87392582",
|
||||
"name": "Health Monitor",
|
||||
"schedule": "every 5m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "5e9d952871bc",
|
||||
"name": "Agent Status Check",
|
||||
"schedule": "every 10m",
|
||||
"state": "paused"
|
||||
},
|
||||
{
|
||||
"job_id": "36fb2f630a17",
|
||||
"name": "Hermes Philosophy Loop",
|
||||
"schedule": "every 1440m",
|
||||
"state": "paused"
|
||||
},
|
||||
{
|
||||
"job_id": "b40a96a2f48c",
|
||||
"name": "wolf-eval-cycle",
|
||||
"schedule": "every 240m",
|
||||
"state": "paused"
|
||||
},
|
||||
{
|
||||
"job_id": "4204e568b862",
|
||||
"name": "Burn Mode \u2014 Timmy Orchestrator",
|
||||
"schedule": "every 15m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "0944a976d034",
|
||||
"name": "Burn Mode",
|
||||
"schedule": "every 15m",
|
||||
"state": "paused"
|
||||
},
|
||||
{
|
||||
"job_id": "62016b960fa0",
|
||||
"name": "velocity-engine",
|
||||
"schedule": "every 30m",
|
||||
"state": "paused"
|
||||
},
|
||||
{
|
||||
"job_id": "e9d49eeff79c",
|
||||
"name": "weekly-skill-extraction",
|
||||
"schedule": "every 10080m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "75c74a5bb563",
|
||||
"name": "tower-tick",
|
||||
"schedule": "every 1m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "390a19054d4c",
|
||||
"name": "Burn Deadman",
|
||||
"schedule": "every 30m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "05e3c13498fa",
|
||||
"name": "Morning Report \u2014 Burn Mode",
|
||||
"schedule": "0 6 * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "64fe44b512b9",
|
||||
"name": "evennia-morning-report",
|
||||
"schedule": "0 9 * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "3896a7fd9747",
|
||||
"name": "Gitea Priority Inbox",
|
||||
"schedule": "every 3m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "f64c2709270a",
|
||||
"name": "Config Drift Guard",
|
||||
"schedule": "every 30m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "fc6a75b7102a",
|
||||
"name": "Gitea Event Watcher",
|
||||
"schedule": "every 2m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "12e59648fb06",
|
||||
"name": "Burndown Night Watcher",
|
||||
"schedule": "every 15m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "35d3ada9cf8f",
|
||||
"name": "Mempalace Forge \u2014 Issue Analysis",
|
||||
"schedule": "every 60m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "190b6fb8dc91",
|
||||
"name": "Mempalace Watchtower \u2014 Fleet Health",
|
||||
"schedule": "every 30m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "710ab589813c",
|
||||
"name": "Ezra Health Monitor",
|
||||
"schedule": "every 15m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "a0a9cce4575c",
|
||||
"name": "daily-poka-yoke-ultraplan-awesometools",
|
||||
"schedule": "every 1440m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "adc3a51457bd",
|
||||
"name": "vps-agent-dispatch",
|
||||
"schedule": "every 10m",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "afd2c4eac44d",
|
||||
"name": "Project Mnemosyne Nightly Burn v2",
|
||||
"schedule": "*/30 * * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "f3a3c2832af0",
|
||||
"name": "gemma4-multimodal-worker",
|
||||
"schedule": "once in 15m",
|
||||
"state": "completed"
|
||||
},
|
||||
{
|
||||
"job_id": "c17a85c19838",
|
||||
"name": "know-thy-father-analyzer",
|
||||
"schedule": "0 * * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "2490fc01a14d",
|
||||
"name": "Testament Burn - 10min work loop",
|
||||
"schedule": "*/10 * * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "f5e858159d97",
|
||||
"name": "Timmy Foundation Burn \u2014 15min PR loop",
|
||||
"schedule": "*/15 * * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "5e262fb9bdce",
|
||||
"name": "nightwatch-health-monitor",
|
||||
"schedule": "*/15 * * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "f2b33a9dcf96",
|
||||
"name": "nightwatch-mempalace-mine",
|
||||
"schedule": "0 */2 * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "82cb9e76c54d",
|
||||
"name": "nightwatch-backlog-burn",
|
||||
"schedule": "0 */4 * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "d20e42a52863",
|
||||
"name": "beacon-sprint",
|
||||
"schedule": "*/15 * * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "579269489961",
|
||||
"name": "testament-story",
|
||||
"schedule": "*/15 * * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "2e5f9140d1ab",
|
||||
"name": "nightwatch-research",
|
||||
"schedule": "0 */2 * * *",
|
||||
"state": "scheduled"
|
||||
},
|
||||
{
|
||||
"job_id": "aeba92fd65e6",
|
||||
"name": "timmy-dreams",
|
||||
"schedule": "30 5 * * *",
|
||||
"state": "scheduled"
|
||||
}
|
||||
]
|
||||
@@ -168,7 +168,35 @@
|
||||
"paused_reason": null,
|
||||
"skills": [],
|
||||
"skill": null
|
||||
},
|
||||
{
|
||||
"id": "overnight-rd-nightly",
|
||||
"name": "Overnight R&D Loop",
|
||||
"prompt": "Run the overnight R&D automation: Deep Dive paper synthesis, tightening loop for tool-use training data, DPO export sweep, morning briefing prep. All local inference via Ollama.",
|
||||
"schedule": {
|
||||
"kind": "cron",
|
||||
"expr": "0 2 * * *",
|
||||
"display": "0 2 * * * (10 PM EDT)"
|
||||
},
|
||||
"schedule_display": "Nightly at 10 PM EDT",
|
||||
"repeat": {
|
||||
"times": null,
|
||||
"completed": 0
|
||||
},
|
||||
"enabled": true,
|
||||
"created_at": "2026-04-13T02:00:00+00:00",
|
||||
"next_run_at": null,
|
||||
"last_run_at": null,
|
||||
"last_status": null,
|
||||
"last_error": null,
|
||||
"deliver": "local",
|
||||
"origin": "perplexity/overnight-rd-automation",
|
||||
"state": "scheduled",
|
||||
"paused_at": null,
|
||||
"paused_reason": null,
|
||||
"skills": [],
|
||||
"skill": null
|
||||
}
|
||||
],
|
||||
"updated_at": "2026-04-07T15:00:00+00:00"
|
||||
"updated_at": "2026-04-13T02:00:00+00:00"
|
||||
}
|
||||
|
||||
14
cron/vps/allegro-crontab-backup.txt
Normal file
14
cron/vps/allegro-crontab-backup.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
0 6 * * * /bin/bash /root/wizards/scripts/model_download_guard.sh >> /var/log/model_guard.log 2>&1
|
||||
|
||||
# Allegro Hybrid Heartbeat — quick wins every 15 min
|
||||
*/15 * * * * /usr/bin/python3 /root/allegro/heartbeat_daemon.py >> /var/log/allegro_heartbeat.log 2>&1
|
||||
|
||||
# Allegro Burn Mode Cron Jobs - Deployed via issue #894
|
||||
|
||||
0 6 * * * cd /root/.hermes && python3 -c "import hermes_agent; from hermes_tools import terminal; output = terminal('echo \"Morning Report: $(date)\"'); print(output.get('output', ''))" >> /root/.hermes/logs/morning-report-$(date +\%Y\%m\%d).log 2>&1 # Allegro Morning Report at 0600
|
||||
|
||||
0,30 * * * * cd /root/.hermes && python3 /root/.hermes/retry_wrapper.py "python3 allegro/quick-lane-check.py" >> burn-logs/quick-lane-$(date +\%Y\%m\%d).log 2>&1 # Allegro Burn Loop #1 (with retry)
|
||||
15,45 * * * * cd /root/.hermes && python3 /root/.hermes/retry_wrapper.py "python3 allegro/burn-mode-validator.py" >> burn-logs/validator-$(date +\%Y\%m\%d).log 2>&1 # Allegro Burn Loop #2 (with retry)
|
||||
|
||||
*/2 * * * * /root/wizards/bezalel/dead_man_monitor.sh
|
||||
*/2 * * * * /root/wizards/allegro/bin/config-deadman.sh
|
||||
10
cron/vps/bezalel-crontab-backup.txt
Normal file
10
cron/vps/bezalel-crontab-backup.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
0 2 * * * /root/wizards/bezalel/run_nightly_watch.sh
|
||||
0 3 * * * /root/wizards/bezalel/mempalace_nightly.sh
|
||||
*/10 * * * * pgrep -f "act_runner daemon" > /dev/null || (cd /opt/gitea-runner && nohup ./act_runner daemon > /var/log/gitea-runner.log 2>&1 &)
|
||||
30 3 * * * /root/wizards/bezalel/backup_databases.sh
|
||||
*/15 * * * * /root/wizards/bezalel/meta_heartbeat.sh
|
||||
0 4 * * * /root/wizards/bezalel/secret_guard.sh
|
||||
0 4 * * * /usr/bin/env bash /root/timmy-home/scripts/backup_pipeline.sh >> /var/log/timmy/backup_pipeline_cron.log 2>&1
|
||||
0 6 * * * /usr/bin/python3 /root/wizards/bezalel/ultraplan.py >> /var/log/bezalel-ultraplan.log 2>&1
|
||||
@reboot /root/wizards/bezalel/emacs-daemon-start.sh
|
||||
@reboot /root/wizards/bezalel/ngircd-start.sh
|
||||
13
cron/vps/ezra-crontab-backup.txt
Normal file
13
cron/vps/ezra-crontab-backup.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
# Burn Mode Cycles — 15 min autonomous loops
|
||||
*/15 * * * * /root/wizards/ezra/bin/burn-mode.sh >> /root/wizards/ezra/reports/burn-cron.log 2>&1
|
||||
|
||||
# Household Snapshots — automated heartbeats and snapshots
|
||||
# Ezra Self-Improvement Automation Suite
|
||||
*/5 * * * * /usr/bin/python3 /root/wizards/ezra/tools/gitea_monitor.py >> /root/wizards/ezra/reports/gitea-monitor.log 2>&1
|
||||
*/5 * * * * /usr/bin/python3 /root/wizards/ezra/tools/awareness_loop.py >> /root/wizards/ezra/reports/awareness-loop.log 2>&1
|
||||
*/10 * * * * /usr/bin/python3 /root/wizards/ezra/tools/cron_health_monitor.py >> /root/wizards/ezra/reports/cron-health.log 2>&1
|
||||
0 6 * * * /usr/bin/python3 /root/wizards/ezra/tools/morning_kt_compiler.py >> /root/wizards/ezra/reports/morning-kt.log 2>&1
|
||||
5 6 * * * /usr/bin/python3 /root/wizards/ezra/tools/burndown_generator.py >> /root/wizards/ezra/reports/burndown.log 2>&1
|
||||
0 3 * * * /root/wizards/ezra/mempalace_nightly.sh >> /var/log/ezra_mempalace_cron.log 2>&1
|
||||
*/15 * * * * GITEA_TOKEN=6de6aa...1117 /root/wizards/ezra/dispatch-direct.sh >> /root/wizards/ezra/dispatch-cron.log 2>&1
|
||||
|
||||
24
deploy/auto-commit-guard.plist
Normal file
24
deploy/auto-commit-guard.plist
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ai.timmy.auto-commit-guard</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/bash</string>
|
||||
<string>/Users/apayne/.hermes/bin/auto-commit-guard.sh</string>
|
||||
<string>120</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/apayne/.hermes/logs/auto-commit-guard.stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/apayne/.hermes/logs/auto-commit-guard.stderr.log</string>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/apayne</string>
|
||||
</dict>
|
||||
</plist>
|
||||
21
deploy/gitea-a11y/README.md
Normal file
21
deploy/gitea-a11y/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Gitea Accessibility Fix - R4: Time Elements
|
||||
|
||||
WCAG 1.3.1: Relative timestamps lack machine-readable fallbacks.
|
||||
|
||||
## Fix
|
||||
|
||||
Wrap relative timestamps in `<time datetime="...">` elements.
|
||||
|
||||
## Files
|
||||
|
||||
- `custom/templates/custom/time_relative.tmpl` - Reusable `<time>` helper
|
||||
- `custom/templates/repo/list_a11y.tmpl` - Explore/Repos list override
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
cp -r custom/templates/* /path/to/gitea/custom/templates/
|
||||
systemctl restart gitea
|
||||
```
|
||||
|
||||
Closes #554
|
||||
27
deploy/gitea-a11y/custom/templates/custom/time_relative.tmpl
Normal file
27
deploy/gitea-a11y/custom/templates/custom/time_relative.tmpl
Normal file
@@ -0,0 +1,27 @@
|
||||
{{/*
|
||||
Gitea a11y fix: R4 <time> elements for relative timestamps
|
||||
Deploy to: custom/templates/custom/time_relative.tmpl
|
||||
*/}}
|
||||
|
||||
{{define "custom/time_relative"}}
|
||||
{{if and .Time .Relative}}
|
||||
<time datetime="{{.Time.Format "2006-01-02T15:04:05Z07:00"}}" title="{{.Time.Format "Jan 02, 2006 15:04"}}">
|
||||
{{.Relative}}
|
||||
</time>
|
||||
{{else if .Relative}}
|
||||
<span>{{.Relative}}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "custom/time_from_unix"}}
|
||||
{{if .Relative}}
|
||||
<time datetime="" data-unix="{{.Unix}}" title="">{{.Relative}}</time>
|
||||
<script>
|
||||
(function() {
|
||||
var el = document.currentScript.previousElementSibling;
|
||||
var unix = parseInt(el.getAttribute('data-unix'));
|
||||
if (unix) { el.setAttribute('datetime', new Date(unix * 1000).toISOString()); el.setAttribute('title', new Date(unix * 1000).toLocaleString()); }
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
27
deploy/gitea-a11y/custom/templates/repo/list_a11y.tmpl
Normal file
27
deploy/gitea-a11y/custom/templates/repo/list_a11y.tmpl
Normal file
@@ -0,0 +1,27 @@
|
||||
{{/*
|
||||
Gitea a11y fix: R4 <time> elements for relative timestamps on repo list
|
||||
Deploy to: custom/templates/repo/list_a11y.tmpl
|
||||
*/}}
|
||||
|
||||
{{/* Star count link with aria-label */}}
|
||||
<a class="repo-card-star" href="{{.RepoLink}}/stars" aria-label="{{.NumStars}} stars" title="{{.NumStars}} stars">
|
||||
<svg class="octicon octicon-star" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
|
||||
</svg>
|
||||
<span>{{.NumStars}}</span>
|
||||
</a>
|
||||
|
||||
{{/* Fork count link with aria-label */}}
|
||||
<a class="repo-card-fork" href="{{.RepoLink}}/forks" aria-label="{{.NumForks}} forks" title="{{.NumForks}} forks">
|
||||
<svg class="octicon octicon-repo-forked" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75v-.878a2.25 2.25 0 111.5 0v.878a2.25 2.25 0 01-2.25 2.25h-1.5v2.128a2.251 2.251 0 11-1.5 0V8.5h-1.5A2.25 2.25 0 013.5 6.25v-.878a2.25 2.25 0 111.5 0zM5 3.25a.75.75 0 10-1.5 0 .75.75 0 001.5 0zm6.75.75a.75.75 0 100-1.5.75.75 0 000 1.5zm-3 8.75a.75.75 0 10-1.5 0 .75.75 0 001.5 0z"/>
|
||||
</svg>
|
||||
<span>{{.NumForks}}</span>
|
||||
</a>
|
||||
|
||||
{{/* Relative timestamp with <time> element for a11y */}}
|
||||
{{if .UpdatedUnix}}
|
||||
<time datetime="{{.UpdatedUnix | TimeSinceISO}}" title="{{.UpdatedUnix | DateFmtLong}}" class="text-light">
|
||||
{{.UpdatedUnix | TimeSince}}
|
||||
</time>
|
||||
{{end}}
|
||||
110
docs/FLEET_BEHAVIOUR_HARDENING.md
Normal file
110
docs/FLEET_BEHAVIOUR_HARDENING.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Fleet Behaviour Hardening — Review & Action Plan
|
||||
|
||||
**Author:** @perplexity
|
||||
**Date:** 2026-04-08
|
||||
**Context:** Alexander asked: "Is it the memory system or the behaviour guardrails?"
|
||||
**Answer:** It's the guardrails. The memory system is adequate. The enforcement machinery is aspirational.
|
||||
|
||||
---
|
||||
|
||||
## Diagnosis: Why the Fleet Isn't Smart Enough
|
||||
|
||||
After auditing SOUL.md, config.yaml, all 8 playbooks, the orchestrator, the guard scripts, and the v7.0.0 checkin, the pattern is clear:
|
||||
|
||||
**The fleet has excellent design documents and broken enforcement.**
|
||||
|
||||
| Layer | Design Quality | Enforcement Quality | Gap |
|
||||
|---|---|---|---|
|
||||
| SOUL.md | Excellent | None — no code reads it at runtime | Philosophy without machinery |
|
||||
| Playbooks (7 yaml) | Good lane map | Not invoked by orchestrator | Playbooks exist but nobody calls them |
|
||||
| Guard scripts (9) | Solid code | 1 of 9 wired (#395 audit) | 89% of guards are dead code |
|
||||
| Orchestrator | Sound design | Gateway dispatch is a no-op (#391) | Assigns issues but doesn't trigger work |
|
||||
| Cycle Guard | Good 10-min rule | No cron/loop calls it | Discipline without enforcement |
|
||||
| PR Reviewer | Clear rules | Runs every 30m (if scheduled) | Only guard that might actually fire |
|
||||
| Memory (MemPalace) | Working code | Retrieval enforcer wired | Actually operational |
|
||||
|
||||
### The Core Problem
|
||||
|
||||
Agents pick up issues and produce output, but there is **no pre-task checklist** and **no post-task quality gate**. An agent can:
|
||||
|
||||
1. Start work without checking if someone else already did it
|
||||
2. Produce output without running tests
|
||||
3. Submit a PR without verifying it addresses the issue
|
||||
4. Work for hours on something out of scope
|
||||
5. Create duplicate branches/PRs without detection
|
||||
|
||||
The SOUL.md says "grounding before generation" but no code enforces it.
|
||||
The playbooks define lanes but the orchestrator doesn't load them.
|
||||
The guards exist but nothing calls them.
|
||||
|
||||
---
|
||||
|
||||
## What the Fleet Needs (Priority Order)
|
||||
|
||||
### 1. Pre-Task Gate (MISSING — this PR adds it)
|
||||
|
||||
Before an agent starts any issue:
|
||||
- [ ] Check if issue is already assigned to another agent
|
||||
- [ ] Check if a branch already exists for this issue
|
||||
- [ ] Check if a PR already exists for this issue
|
||||
- [ ] Load relevant MemPalace context (retrieval enforcer)
|
||||
- [ ] Verify the agent has the right lane for this work (playbook check)
|
||||
|
||||
### 2. Post-Task Gate (MISSING — this PR adds it)
|
||||
|
||||
Before an agent submits a PR:
|
||||
- [ ] Verify the diff addresses the issue title/body
|
||||
- [ ] Run syntax_guard.py on changed files
|
||||
- [ ] Check for duplicate PRs targeting the same issue
|
||||
- [ ] Verify branch name follows convention
|
||||
- [ ] Run tests if they exist for changed files
|
||||
|
||||
### 3. Wire the Existing Guards (8 of 9 are dead code)
|
||||
|
||||
Per #395 audit:
|
||||
- Pre-commit hooks: need symlink on every machine
|
||||
- Cycle guard: need cron/loop integration
|
||||
- Forge health check: need cron entry
|
||||
- Smoke test + deploy validate: need deploy script integration
|
||||
|
||||
### 4. Orchestrator Dispatch Actually Works
|
||||
|
||||
Per #391 audit: the orchestrator scores and assigns but the gateway dispatch just writes to `/tmp/hermes-dispatch.log`. Nobody reads that file. The dispatch needs to either:
|
||||
- Trigger `hermes` CLI on the target machine, or
|
||||
- Post a webhook that the agent loop picks up
|
||||
|
||||
### 5. Agent Self-Assessment Loop
|
||||
|
||||
After completing work, agents should answer:
|
||||
- Did I address the issue as stated?
|
||||
- Did I stay in scope?
|
||||
- Did I check the palace for prior work?
|
||||
- Did I run verification?
|
||||
|
||||
This is what SOUL.md calls "the apparatus that gives these words teeth."
|
||||
|
||||
---
|
||||
|
||||
## What's Working (Don't Touch)
|
||||
|
||||
- **MemPalace sovereign_store.py** — SQLite + FTS5 + HRR, operational
|
||||
- **Retrieval enforcer** — wired to SovereignStore as of 14 hours ago
|
||||
- **Wake-up protocol** — palace-first boot sequence
|
||||
- **PR reviewer playbook** — clear rules, well-scoped
|
||||
- **Issue triager playbook** — comprehensive lane map with 11 agents
|
||||
- **Cycle guard code** — solid 10-min slice discipline (just needs wiring)
|
||||
- **Config drift guard** — active cron, working
|
||||
- **Dead man switch** — active, working
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
The memory system is not the bottleneck. The behaviour guardrails are. Specifically:
|
||||
|
||||
1. **Add `task_gate.py`** — pre-task and post-task quality gates that every agent loop calls
|
||||
2. **Wire cycle_guard.py** — add start/complete calls to agent loop
|
||||
3. **Wire pre-commit hooks** — deploy script should symlink on provision
|
||||
4. **Fix orchestrator dispatch** — make it actually trigger work, not just log
|
||||
|
||||
This PR adds item 1. Items 2-4 need SSH access and are flagged for Timmy/Allegro.
|
||||
150
docs/a11y-audit-2026-04-13.md
Normal file
150
docs/a11y-audit-2026-04-13.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Visual Accessibility Audit — Foundation Web Properties
|
||||
|
||||
**Issue:** timmy-config #492
|
||||
**Date:** 2026-04-13
|
||||
**Label:** gemma-4-multimodal
|
||||
**Scope:** forge.alexanderwhitestone.com (Gitea 1.25.4)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Foundation's primary accessible web property is the Gitea forge. The Matrix homeserver (matrix.timmy.foundation) is currently unreachable (DNS/SSL issues). This audit covers the forge across three page types: Homepage, Login, and Explore/Repositories.
|
||||
|
||||
**Overall: 6 WCAG 2.1 AA violations found, 4 best-practice recommendations.**
|
||||
|
||||
---
|
||||
|
||||
## Pages Audited
|
||||
|
||||
| Page | URL | Status |
|
||||
|------|-----|--------|
|
||||
| Homepage | forge.alexanderwhitestone.com | Live |
|
||||
| Sign In | forge.alexanderwhitestone.com/user/login | Live |
|
||||
| Explore Repos | forge.alexanderwhitestone.com/explore/repos | Live |
|
||||
| Matrix/Element | matrix.timmy.foundation | DOWN (DNS/SSL) |
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### P1 — Violations (WCAG 2.1 AA)
|
||||
|
||||
#### V1: No Skip Navigation Link (2.4.1)
|
||||
- **Pages:** All
|
||||
- **Severity:** Medium
|
||||
- **Description:** No "Skip to content" link exists. Keyboard users must tab through the full navigation on every page load.
|
||||
- **Evidence:** Programmatic check returned `skipNav: false`
|
||||
- **Fix:** Add `<a href="#main" class="skip-link">Skip to content</a>` visually hidden until focused.
|
||||
|
||||
#### V2: 25 Form Inputs Without Labels (1.3.1, 3.3.2)
|
||||
- **Pages:** Explore/Repositories (filter dropdowns)
|
||||
- **Severity:** High
|
||||
- **Description:** The search input and all radio buttons in the Filter/Sort dropdowns lack programmatic label associations.
|
||||
- **Evidence:** Programmatic check found 25 inputs without `label[for=]`, `aria-label`, or `aria-labelledby`
|
||||
- **Affected inputs:** `q` (search), `archived` (x2), `fork` (x2), `mirror` (x2), `template` (x2), `private` (x2), `sort` (x12), `clear-filter` (x1)
|
||||
- **Fix:** Add `aria-label="Search repositories"` to search input. Add `aria-label` to each radio button group and individual options.
|
||||
|
||||
#### V3: Low-Contrast Footer Text (1.4.3)
|
||||
- **Pages:** All
|
||||
- **Severity:** Medium
|
||||
- **Description:** Footer text (version, page render time) appears light gray on white, likely failing the 4.5:1 contrast ratio.
|
||||
- **Evidence:** 30 elements flagged as potential low-contrast suspects.
|
||||
- **Fix:** Darken footer text to at least `#767676` on white (4.54:1 ratio).
|
||||
|
||||
#### V4: Green Link Color Fails Contrast (1.4.3)
|
||||
- **Pages:** Homepage
|
||||
- **Severity:** Medium
|
||||
- **Description:** Inline links use medium-green (~#609926) on white. This shade typically fails 4.5:1 for normal body text.
|
||||
- **Evidence:** Visual analysis identified green links ("run the binary", "Docker", "contributing") as potentially failing.
|
||||
- **Fix:** Darken link color to at least `#507020` or add an underline for non-color differentiation (SC 1.4.1).
|
||||
|
||||
#### V5: Missing Header/Banner Landmark (1.3.1)
|
||||
- **Pages:** All
|
||||
- **Severity:** Low
|
||||
- **Description:** No `<header>` or `role="banner"` element found. The navigation bar is a `<nav>` but not wrapped in a banner landmark.
|
||||
- **Evidence:** `landmarks.banner: 0`
|
||||
- **Fix:** Wrap the top navigation in `<header>` or add `role="banner"`.
|
||||
|
||||
#### V6: Heading Hierarchy Issue (1.3.1)
|
||||
- **Pages:** Login
|
||||
- **Severity:** Low
|
||||
- **Description:** The Sign In heading is `<h4>` rather than `<h1>`, breaking the heading hierarchy. The page has no `<h1>`.
|
||||
- **Evidence:** Accessibility tree shows `heading "Sign In" [level=4]`
|
||||
- **Fix:** Use `<h1>` for "Sign In" on the login page.
|
||||
|
||||
---
|
||||
|
||||
### P2 — Best Practice Recommendations
|
||||
|
||||
#### R1: Add Password Visibility Toggle
|
||||
- **Page:** Login
|
||||
- **Description:** No show/hide toggle on the password field. This helps users with cognitive or motor impairments verify input.
|
||||
|
||||
#### R2: Add `aria-required` to Required Fields
|
||||
- **Page:** Login
|
||||
- **Evidence:** `inputsWithAriaRequired: 0` (no inputs marked as required)
|
||||
- **Description:** The username field shows a red asterisk but has no `required` or `aria-required="true"` attribute.
|
||||
|
||||
#### R3: Improve Star/Fork Link Labels
|
||||
- **Page:** Explore Repos
|
||||
- **Description:** Star and fork counts are bare numbers (e.g., "0", "2"). Screen readers announce these without context.
|
||||
- **Fix:** Add `aria-label="2 stars"` / `aria-label="0 forks"` to count links.
|
||||
|
||||
#### R4: Use `<time>` Elements for Timestamps
|
||||
- **Page:** Explore Repos
|
||||
- **Description:** Relative timestamps ("2 minutes ago") are human-readable but lack machine-readable fallbacks.
|
||||
- **Fix:** Wrap in `<time datetime="2026-04-13T17:00:00Z">2 minutes ago</time>`.
|
||||
|
||||
---
|
||||
|
||||
## What's Working Well
|
||||
|
||||
- **Color contrast (primary):** Black text on white backgrounds — excellent 21:1 ratio.
|
||||
- **Heading structure (homepage):** Clean h1 > h2 > h3 hierarchy.
|
||||
- **Landmark regions:** `<main>` and `<nav>` landmarks present.
|
||||
- **Language attribute:** `lang="en-US"` set on `<html>`.
|
||||
- **Link text:** Descriptive — no "click here" or "read more" patterns found.
|
||||
- **Form layout:** Login form uses clean single-column with good spacing.
|
||||
- **Submit button:** Full-width, good contrast, large touch target.
|
||||
- **Navigation:** Simple, consistent across pages.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- **matrix.timmy.foundation:** Unreachable (DNS resolution failure / SSL cert mismatch). Should be re-audited when operational.
|
||||
- **Evennia web client (localhost:4001):** Local-only, not publicly accessible.
|
||||
- **WCAG AAA criteria:** This audit covers AA only.
|
||||
|
||||
---
|
||||
|
||||
## Remediation Priority
|
||||
|
||||
| Priority | Issue | Effort |
|
||||
|----------|-------|--------|
|
||||
| P1 | V2: 25 unlabeled inputs | Medium |
|
||||
| P1 | V1: Skip nav link | Small |
|
||||
| P1 | V4: Green link contrast | Small |
|
||||
| P1 | V3: Footer text contrast | Small |
|
||||
| P2 | V6: Heading hierarchy | Small |
|
||||
| P2 | V5: Banner landmark | Small |
|
||||
| P2 | R1-R4: Best practices | Small |
|
||||
|
||||
---
|
||||
|
||||
## Automated Check Results
|
||||
|
||||
```
|
||||
skipNav: false
|
||||
headings: h1(3), h4(1)
|
||||
imgsNoAlt: 0 / 1
|
||||
inputsNoLabel: 25
|
||||
genericLinks: 0
|
||||
lowContrastSuspects: 30
|
||||
inputsWithAriaRequired: 0
|
||||
landmarks: main=1, nav=2, banner=0, contentinfo=2
|
||||
hasLang: true (en-US)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Generated via visual + programmatic analysis of forge.alexanderwhitestone.com*
|
||||
@@ -3,7 +3,7 @@
|
||||
Purpose:
|
||||
- stand up the third wizard house as a Kimi-backed coding worker
|
||||
- keep Hermes as the durable harness
|
||||
- treat OpenClaw as optional shell frontage, not the bones
|
||||
- Hermes is the durable harness — no intermediary gateway layers
|
||||
|
||||
Local proof already achieved:
|
||||
|
||||
@@ -40,5 +40,5 @@ bin/deploy-allegro-house.sh root@167.99.126.228
|
||||
|
||||
Important nuance:
|
||||
- the Hermes/Kimi lane is the proven path
|
||||
- direct embedded OpenClaw Kimi model routing was not yet reliable locally
|
||||
- direct embedded Kimi model routing was not yet reliable locally
|
||||
- so the remote deployment keeps the minimal, proven architecture: Hermes house first
|
||||
@@ -81,17 +81,6 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.hermes.gateway.plist
|
||||
- Old-state risk:
|
||||
- same class as main gateway, but isolated to fenrir profile state
|
||||
|
||||
#### 3. ai.openclaw.gateway
|
||||
- Plist: ~/Library/LaunchAgents/ai.openclaw.gateway.plist
|
||||
- Command: `node .../openclaw/dist/index.js gateway --port 18789`
|
||||
- Logs:
|
||||
- `~/.openclaw/logs/gateway.log`
|
||||
- `~/.openclaw/logs/gateway.err.log`
|
||||
- KeepAlive: yes
|
||||
- RunAtLoad: yes
|
||||
- Old-state risk:
|
||||
- long-lived gateway survives toolchain assumptions and keeps accepting work even if upstream routing changed
|
||||
|
||||
#### 4. ai.timmy.kimi-heartbeat
|
||||
- Plist: ~/Library/LaunchAgents/ai.timmy.kimi-heartbeat.plist
|
||||
- Command: `/bin/bash ~/.timmy/uniwizard/kimi-heartbeat.sh`
|
||||
@@ -295,7 +284,7 @@ launchctl list | egrep 'timmy|kimi|claude|max|dashboard|matrix|gateway|huey'
|
||||
|
||||
List Timmy/Hermes launch agent files:
|
||||
```bash
|
||||
find ~/Library/LaunchAgents -maxdepth 1 -name '*.plist' | egrep 'timmy|hermes|openclaw|tower'
|
||||
find ~/Library/LaunchAgents -maxdepth 1 -name '*.plist' | egrep 'timmy|hermes|tower'
|
||||
```
|
||||
|
||||
List running loop scripts:
|
||||
@@ -316,7 +305,6 @@ launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.timmy.kimi-heartbeat.pl
|
||||
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.timmy.claudemax-watchdog.plist || true
|
||||
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.hermes.gateway.plist || true
|
||||
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.hermes.gateway-fenrir.plist || true
|
||||
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.gateway.plist || true
|
||||
```
|
||||
|
||||
2. Kill manual loops
|
||||
|
||||
179
docs/glitch-detection.md
Normal file
179
docs/glitch-detection.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 3D World Glitch Detection — Matrix Scanner
|
||||
|
||||
**Reference:** timmy-config#491
|
||||
**Label:** gemma-4-multimodal
|
||||
**Version:** 0.1.0
|
||||
|
||||
## Overview
|
||||
|
||||
The Matrix Glitch Detector scans 3D web worlds for visual artifacts and
|
||||
rendering anomalies. It uses browser automation to capture screenshots from
|
||||
multiple camera angles, then sends them to a vision AI model for analysis
|
||||
against a library of known glitch patterns.
|
||||
|
||||
## Detected Glitch Categories
|
||||
|
||||
| Category | Severity | Description |
|
||||
|---|---|---|
|
||||
| Floating Assets | HIGH | Objects not grounded — hovering above surfaces |
|
||||
| Z-Fighting | MEDIUM | Coplanar surfaces flickering/competing for depth |
|
||||
| Missing Textures | CRITICAL | Placeholder colors (magenta, checkerboard) |
|
||||
| Clipping | HIGH | Geometry passing through other objects |
|
||||
| Broken Normals | MEDIUM | Inside-out or incorrectly lit surfaces |
|
||||
| Shadow Artifacts | LOW | Detached, mismatched, or acne shadows |
|
||||
| LOD Popping | LOW | Abrupt level-of-detail transitions |
|
||||
| Lightmap Errors | MEDIUM | Dark splotches, light leaks, baking failures |
|
||||
| Water/Reflection | MEDIUM | Incorrect environment reflections |
|
||||
| Skybox Seam | LOW | Visible seams at cubemap face edges |
|
||||
|
||||
## Installation
|
||||
|
||||
No external dependencies required — pure Python 3.10+.
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://forge.alexanderwhitestone.com/Timmy_Foundation/timmy-config.git
|
||||
cd timmy-config
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Scan
|
||||
|
||||
```bash
|
||||
python bin/matrix_glitch_detector.py https://matrix.example.com/world/alpha
|
||||
```
|
||||
|
||||
### Multi-Angle Scan
|
||||
|
||||
```bash
|
||||
python bin/matrix_glitch_detector.py https://matrix.example.com/world/alpha \
|
||||
--angles 8 \
|
||||
--output glitch_report.json
|
||||
```
|
||||
|
||||
### Demo Mode
|
||||
|
||||
```bash
|
||||
python bin/matrix_glitch_detector.py --demo
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `url` | (required) | URL of the 3D world to scan |
|
||||
| `--angles N` | 4 | Number of camera angles to capture |
|
||||
| `--output PATH` | stdout | Output file for JSON report |
|
||||
| `--min-severity` | info | Minimum severity: info/low/medium/high/critical |
|
||||
| `--demo` | off | Run with simulated detections |
|
||||
| `--verbose` | off | Enable verbose output |
|
||||
|
||||
## Report Format
|
||||
|
||||
The JSON report includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"scan_id": "uuid",
|
||||
"url": "https://...",
|
||||
"timestamp": "ISO-8601",
|
||||
"total_screenshots": 4,
|
||||
"angles_captured": ["front", "right", "back", "left"],
|
||||
"glitches": [
|
||||
{
|
||||
"id": "short-uuid",
|
||||
"category": "floating_assets",
|
||||
"name": "Floating Chair",
|
||||
"description": "Office chair floating 0.3m above floor",
|
||||
"severity": "high",
|
||||
"confidence": 0.87,
|
||||
"location_x": 35.2,
|
||||
"location_y": 62.1,
|
||||
"screenshot_index": 0,
|
||||
"screenshot_angle": "front",
|
||||
"timestamp": "ISO-8601"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total_glitches": 4,
|
||||
"by_severity": {"critical": 1, "high": 2, "medium": 1},
|
||||
"by_category": {"floating_assets": 1, "missing_textures": 1, ...},
|
||||
"highest_severity": "critical",
|
||||
"clean_screenshots": 0
|
||||
},
|
||||
"metadata": {
|
||||
"detector_version": "0.1.0",
|
||||
"pattern_count": 10,
|
||||
"reference": "timmy-config#491"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Vision AI Integration
|
||||
|
||||
The detector supports any OpenAI-compatible vision API. Set these
|
||||
environment variables:
|
||||
|
||||
```bash
|
||||
export VISION_API_KEY="your-api-key"
|
||||
export VISION_API_BASE="https://api.openai.com/v1" # optional
|
||||
export VISION_MODEL="gpt-4o" # optional, default: gpt-4o
|
||||
```
|
||||
|
||||
For browser-based capture with `browser_vision`:
|
||||
|
||||
```bash
|
||||
export BROWSER_VISION_SCRIPT="/path/to/browser_vision.py"
|
||||
```
|
||||
|
||||
## Glitch Patterns
|
||||
|
||||
Pattern definitions live in `bin/glitch_patterns.py`. Each pattern includes:
|
||||
|
||||
- **category** — Enum matching the glitch type
|
||||
- **detection_prompts** — Instructions for the vision model
|
||||
- **visual_indicators** — What to look for in screenshots
|
||||
- **confidence_threshold** — Minimum confidence to report
|
||||
|
||||
### Adding Custom Patterns
|
||||
|
||||
```python
|
||||
from glitch_patterns import GlitchPattern, GlitchCategory, GlitchSeverity
|
||||
|
||||
custom = GlitchPattern(
|
||||
category=GlitchCategory.FLOATING_ASSETS,
|
||||
name="Custom Glitch",
|
||||
description="Your description",
|
||||
severity=GlitchSeverity.MEDIUM,
|
||||
detection_prompts=["Look for..."],
|
||||
visual_indicators=["indicator 1", "indicator 2"],
|
||||
)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_glitch_detector.py -v
|
||||
# or
|
||||
python tests/test_glitch_detector.py
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
bin/
|
||||
matrix_glitch_detector.py — Main CLI entry point
|
||||
glitch_patterns.py — Pattern definitions and prompt builder
|
||||
tests/
|
||||
test_glitch_detector.py — Unit and integration tests
|
||||
docs/
|
||||
glitch-detection.md — This documentation
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Browser automation requires a headless browser environment
|
||||
- Vision AI analysis depends on model availability and API limits
|
||||
- Placeholder screenshots are generated when browser capture is unavailable
|
||||
- Detection accuracy varies by scene complexity and lighting conditions
|
||||
68
docs/overnight-rd.md
Normal file
68
docs/overnight-rd.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Overnight R&D Automation
|
||||
|
||||
**Schedule**: Nightly at 10 PM EDT (02:00 UTC)
|
||||
**Duration**: ~2-4 hours (self-limiting, finishes before 6 AM morning report)
|
||||
**Cost**: $0 — all local Ollama inference
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1: Deep Dive Intelligence
|
||||
Runs the `intelligence/deepdive/pipeline.py` from the-nexus:
|
||||
- Aggregates arXiv CS.AI, CS.CL, CS.LG RSS feeds (last 24h)
|
||||
- Fetches OpenAI, Anthropic, DeepMind blog updates
|
||||
- Filters for relevance using sentence-transformers embeddings
|
||||
- Synthesizes a briefing using local Gemma 4 12B
|
||||
- Saves briefing to `~/briefings/`
|
||||
|
||||
### Phase 2: Tightening Loop
|
||||
Exercises Timmy's local tool-use capability:
|
||||
- 10 tasks × 3 cycles = 30 task attempts per night
|
||||
- File reading, writing, searching against real workspace files
|
||||
- Each result logged as JSONL for training data analysis
|
||||
- Tests sovereignty compliance (SOUL.md alignment, banned provider detection)
|
||||
|
||||
### Phase 3: DPO Export
|
||||
Sweeps overnight Hermes sessions for training pair extraction:
|
||||
- Converts good conversation pairs into DPO training format
|
||||
- Saves to `~/.timmy/training-data/dpo-pairs/`
|
||||
|
||||
### Phase 4: Morning Prep
|
||||
Compiles overnight findings into `~/.timmy/overnight-rd/latest_summary.md`
|
||||
for consumption by the 6 AM `good_morning_report` task.
|
||||
|
||||
## Approved Providers
|
||||
|
||||
| Slot | Provider | Model |
|
||||
|------|----------|-------|
|
||||
| Synthesis | Ollama | gemma4:12b |
|
||||
| Tool tasks | Ollama | hermes4:14b |
|
||||
| Fallback | Ollama | gemma4:12b |
|
||||
|
||||
Anthropic is permanently banned (BANNED_PROVIDERS.yml, 2026-04-09).
|
||||
|
||||
## Outputs
|
||||
|
||||
| Path | Content |
|
||||
|------|---------|
|
||||
| `~/.timmy/overnight-rd/{run_id}/rd_log.jsonl` | Full task log |
|
||||
| `~/.timmy/overnight-rd/{run_id}/rd_summary.md` | Run summary |
|
||||
| `~/.timmy/overnight-rd/latest_summary.md` | Latest summary (for morning report) |
|
||||
| `~/briefings/briefing_*.json` | Deep Dive briefings |
|
||||
|
||||
## Monitoring
|
||||
|
||||
Check the Huey consumer log:
|
||||
```bash
|
||||
tail -f ~/.timmy/timmy-config/logs/huey.log | grep overnight
|
||||
```
|
||||
|
||||
Check the latest run summary:
|
||||
```bash
|
||||
cat ~/.timmy/overnight-rd/latest_summary.md
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Deep Dive pipeline installed: `cd the-nexus/intelligence/deepdive && make install`
|
||||
- Ollama running with gemma4:12b and hermes4:14b models
|
||||
- Huey consumer running: `huey_consumer.py tasks.huey -w 2 -k thread`
|
||||
@@ -14,7 +14,7 @@ from crewai.tools import BaseTool
|
||||
|
||||
OPENROUTER_API_KEY = os.getenv(
|
||||
"OPENROUTER_API_KEY",
|
||||
"dsk-or-v1-f60c89db12040267458165cf192e815e339eb70548e4a0a461f5f0f69e6ef8b0",
|
||||
os.environ.get("OPENROUTER_API_KEY", ""),
|
||||
)
|
||||
|
||||
llm = LLM(
|
||||
|
||||
@@ -2,135 +2,128 @@ schema_version: 1
|
||||
status: proposed
|
||||
runtime_wiring: false
|
||||
owner: timmy-config
|
||||
|
||||
ownership:
|
||||
owns:
|
||||
- routing doctrine for task classes
|
||||
- sidecar-readable per-agent fallback portfolios
|
||||
- degraded-mode capability floors
|
||||
- routing doctrine for task classes
|
||||
- sidecar-readable per-agent fallback portfolios
|
||||
- degraded-mode capability floors
|
||||
does_not_own:
|
||||
- live queue state outside Gitea truth
|
||||
- launchd or loop process state
|
||||
- ad hoc worktree history
|
||||
|
||||
- live queue state outside Gitea truth
|
||||
- launchd or loop process state
|
||||
- ad hoc worktree history
|
||||
policy:
|
||||
require_four_slots_for_critical_agents: true
|
||||
terminal_fallback_must_be_usable: true
|
||||
forbid_synchronized_fleet_degradation: true
|
||||
forbid_human_token_fallbacks: true
|
||||
anti_correlation_rule: no two critical agents may share the same primary+fallback1 pair
|
||||
|
||||
sensitive_control_surfaces:
|
||||
- SOUL.md
|
||||
- config.yaml
|
||||
- deploy.sh
|
||||
- tasks.py
|
||||
- playbooks/
|
||||
- cron/
|
||||
- memories/
|
||||
- skins/
|
||||
- training/
|
||||
|
||||
- SOUL.md
|
||||
- config.yaml
|
||||
- deploy.sh
|
||||
- tasks.py
|
||||
- playbooks/
|
||||
- cron/
|
||||
- memories/
|
||||
- skins/
|
||||
- training/
|
||||
role_classes:
|
||||
judgment:
|
||||
current_surfaces:
|
||||
- playbooks/issue-triager.yaml
|
||||
- playbooks/pr-reviewer.yaml
|
||||
- playbooks/verified-logic.yaml
|
||||
- playbooks/issue-triager.yaml
|
||||
- playbooks/pr-reviewer.yaml
|
||||
- playbooks/verified-logic.yaml
|
||||
task_classes:
|
||||
- issue-triage
|
||||
- queue-routing
|
||||
- pr-review
|
||||
- proof-check
|
||||
- governance-review
|
||||
- issue-triage
|
||||
- queue-routing
|
||||
- pr-review
|
||||
- proof-check
|
||||
- governance-review
|
||||
degraded_mode:
|
||||
fallback2:
|
||||
allowed:
|
||||
- classify backlog
|
||||
- summarize risk
|
||||
- produce draft routing plans
|
||||
- leave bounded labels or comments with evidence
|
||||
- classify backlog
|
||||
- summarize risk
|
||||
- produce draft routing plans
|
||||
- leave bounded labels or comments with evidence
|
||||
denied:
|
||||
- merge pull requests
|
||||
- close or rewrite governing issues or PRs
|
||||
- mutate sensitive control surfaces
|
||||
- bulk-reassign the fleet
|
||||
- silently change routing policy
|
||||
- merge pull requests
|
||||
- close or rewrite governing issues or PRs
|
||||
- mutate sensitive control surfaces
|
||||
- bulk-reassign the fleet
|
||||
- silently change routing policy
|
||||
terminal:
|
||||
lane: report-and-route
|
||||
allowed:
|
||||
- classify backlog
|
||||
- summarize risk
|
||||
- produce draft routing artifacts
|
||||
- classify backlog
|
||||
- summarize risk
|
||||
- produce draft routing artifacts
|
||||
denied:
|
||||
- merge pull requests
|
||||
- bulk-reassign the fleet
|
||||
- mutate sensitive control surfaces
|
||||
|
||||
- merge pull requests
|
||||
- bulk-reassign the fleet
|
||||
- mutate sensitive control surfaces
|
||||
builder:
|
||||
current_surfaces:
|
||||
- playbooks/bug-fixer.yaml
|
||||
- playbooks/test-writer.yaml
|
||||
- playbooks/refactor-specialist.yaml
|
||||
- playbooks/bug-fixer.yaml
|
||||
- playbooks/test-writer.yaml
|
||||
- playbooks/refactor-specialist.yaml
|
||||
task_classes:
|
||||
- bug-fix
|
||||
- test-writing
|
||||
- refactor
|
||||
- bounded-docs-change
|
||||
- bug-fix
|
||||
- test-writing
|
||||
- refactor
|
||||
- bounded-docs-change
|
||||
degraded_mode:
|
||||
fallback2:
|
||||
allowed:
|
||||
- reversible single-issue changes
|
||||
- narrow docs fixes
|
||||
- test scaffolds and reproducers
|
||||
- reversible single-issue changes
|
||||
- narrow docs fixes
|
||||
- test scaffolds and reproducers
|
||||
denied:
|
||||
- cross-repo changes
|
||||
- sensitive control-surface edits
|
||||
- merge or release actions
|
||||
- cross-repo changes
|
||||
- sensitive control-surface edits
|
||||
- merge or release actions
|
||||
terminal:
|
||||
lane: narrow-patch
|
||||
allowed:
|
||||
- single-issue small patch
|
||||
- reproducer test
|
||||
- docs-only repair
|
||||
- single-issue small patch
|
||||
- reproducer test
|
||||
- docs-only repair
|
||||
denied:
|
||||
- sensitive control-surface edits
|
||||
- multi-file architecture work
|
||||
- irreversible actions
|
||||
|
||||
- sensitive control-surface edits
|
||||
- multi-file architecture work
|
||||
- irreversible actions
|
||||
wolf_bulk:
|
||||
current_surfaces:
|
||||
- docs/automation-inventory.md
|
||||
- FALSEWORK.md
|
||||
- docs/automation-inventory.md
|
||||
- FALSEWORK.md
|
||||
task_classes:
|
||||
- docs-inventory
|
||||
- log-summarization
|
||||
- queue-hygiene
|
||||
- repetitive-small-diff
|
||||
- research-sweep
|
||||
- docs-inventory
|
||||
- log-summarization
|
||||
- queue-hygiene
|
||||
- repetitive-small-diff
|
||||
- research-sweep
|
||||
degraded_mode:
|
||||
fallback2:
|
||||
allowed:
|
||||
- gather evidence
|
||||
- refresh inventories
|
||||
- summarize logs
|
||||
- propose labels or routes
|
||||
- gather evidence
|
||||
- refresh inventories
|
||||
- summarize logs
|
||||
- propose labels or routes
|
||||
denied:
|
||||
- multi-repo branch fanout
|
||||
- mass agent assignment
|
||||
- sensitive control-surface edits
|
||||
- irreversible queue mutation
|
||||
- multi-repo branch fanout
|
||||
- mass agent assignment
|
||||
- sensitive control-surface edits
|
||||
- irreversible queue mutation
|
||||
terminal:
|
||||
lane: gather-and-summarize
|
||||
allowed:
|
||||
- inventory refresh
|
||||
- evidence bundles
|
||||
- summaries
|
||||
- inventory refresh
|
||||
- evidence bundles
|
||||
- summaries
|
||||
denied:
|
||||
- multi-repo branch fanout
|
||||
- mass agent assignment
|
||||
- sensitive control-surface edits
|
||||
|
||||
- multi-repo branch fanout
|
||||
- mass agent assignment
|
||||
- sensitive control-surface edits
|
||||
routing:
|
||||
issue-triage: judgment
|
||||
queue-routing: judgment
|
||||
@@ -146,22 +139,20 @@ routing:
|
||||
queue-hygiene: wolf_bulk
|
||||
repetitive-small-diff: wolf_bulk
|
||||
research-sweep: wolf_bulk
|
||||
|
||||
promotion_rules:
|
||||
- If a wolf/bulk task touches a sensitive control surface, promote it to judgment.
|
||||
- If a builder task expands beyond 5 files, architecture review, or multi-repo coordination, promote it to judgment.
|
||||
- If a terminal lane cannot produce a usable artifact, the portfolio is invalid and must be redesigned before wiring.
|
||||
|
||||
- If a wolf/bulk task touches a sensitive control surface, promote it to judgment.
|
||||
- If a builder task expands beyond 5 files, architecture review, or multi-repo coordination, promote it to judgment.
|
||||
- If a terminal lane cannot produce a usable artifact, the portfolio is invalid and must be redesigned before wiring.
|
||||
agents:
|
||||
triage-coordinator:
|
||||
role_class: judgment
|
||||
critical: true
|
||||
current_playbooks:
|
||||
- playbooks/issue-triager.yaml
|
||||
- playbooks/issue-triager.yaml
|
||||
portfolio:
|
||||
primary:
|
||||
provider: anthropic
|
||||
model: claude-opus-4-6
|
||||
provider: kimi-coding
|
||||
model: kimi-k2.5
|
||||
lane: full-judgment
|
||||
fallback1:
|
||||
provider: openai-codex
|
||||
@@ -177,19 +168,18 @@ agents:
|
||||
lane: report-and-route
|
||||
local_capable: true
|
||||
usable_output:
|
||||
- backlog classification
|
||||
- routing draft
|
||||
- risk summary
|
||||
|
||||
- backlog classification
|
||||
- routing draft
|
||||
- risk summary
|
||||
pr-reviewer:
|
||||
role_class: judgment
|
||||
critical: true
|
||||
current_playbooks:
|
||||
- playbooks/pr-reviewer.yaml
|
||||
- playbooks/pr-reviewer.yaml
|
||||
portfolio:
|
||||
primary:
|
||||
provider: anthropic
|
||||
model: claude-opus-4-6
|
||||
provider: kimi-coding
|
||||
model: kimi-k2.5
|
||||
lane: full-review
|
||||
fallback1:
|
||||
provider: gemini
|
||||
@@ -205,17 +195,16 @@ agents:
|
||||
lane: low-stakes-diff-summary
|
||||
local_capable: false
|
||||
usable_output:
|
||||
- diff risk summary
|
||||
- explicit uncertainty notes
|
||||
- merge-block recommendation
|
||||
|
||||
- diff risk summary
|
||||
- explicit uncertainty notes
|
||||
- merge-block recommendation
|
||||
builder-main:
|
||||
role_class: builder
|
||||
critical: true
|
||||
current_playbooks:
|
||||
- playbooks/bug-fixer.yaml
|
||||
- playbooks/test-writer.yaml
|
||||
- playbooks/refactor-specialist.yaml
|
||||
- playbooks/bug-fixer.yaml
|
||||
- playbooks/test-writer.yaml
|
||||
- playbooks/refactor-specialist.yaml
|
||||
portfolio:
|
||||
primary:
|
||||
provider: openai-codex
|
||||
@@ -236,15 +225,14 @@ agents:
|
||||
lane: narrow-patch
|
||||
local_capable: true
|
||||
usable_output:
|
||||
- small patch
|
||||
- reproducer test
|
||||
- docs repair
|
||||
|
||||
- small patch
|
||||
- reproducer test
|
||||
- docs repair
|
||||
wolf-sweeper:
|
||||
role_class: wolf_bulk
|
||||
critical: true
|
||||
current_world_state:
|
||||
- docs/automation-inventory.md
|
||||
- docs/automation-inventory.md
|
||||
portfolio:
|
||||
primary:
|
||||
provider: gemini
|
||||
@@ -264,21 +252,20 @@ agents:
|
||||
lane: gather-and-summarize
|
||||
local_capable: true
|
||||
usable_output:
|
||||
- inventory refresh
|
||||
- evidence bundle
|
||||
- summary comment
|
||||
|
||||
- inventory refresh
|
||||
- evidence bundle
|
||||
- summary comment
|
||||
cross_checks:
|
||||
unique_primary_fallback1_pairs:
|
||||
triage-coordinator:
|
||||
- anthropic/claude-opus-4-6
|
||||
- openai-codex/codex
|
||||
- kimi-coding/kimi-k2.5
|
||||
- openai-codex/codex
|
||||
pr-reviewer:
|
||||
- anthropic/claude-opus-4-6
|
||||
- gemini/gemini-2.5-pro
|
||||
- kimi-coding/kimi-k2.5
|
||||
- gemini/gemini-2.5-pro
|
||||
builder-main:
|
||||
- openai-codex/codex
|
||||
- kimi-coding/kimi-k2.5
|
||||
- openai-codex/codex
|
||||
- kimi-coding/kimi-k2.5
|
||||
wolf-sweeper:
|
||||
- gemini/gemini-2.5-flash
|
||||
- groq/llama-3.3-70b-versatile
|
||||
- gemini/gemini-2.5-flash
|
||||
- groq/llama-3.3-70b-versatile
|
||||
|
||||
@@ -104,7 +104,6 @@ Three primary resources govern the fleet:
|
||||
| Hermes gateway | 500 MB | Primary gateway |
|
||||
| Hermes agents (x3) | ~560 MB total | Multiple sessions |
|
||||
| Ollama | ~20 MB base + model memory | Model loading varies |
|
||||
| OpenClaw | 350 MB | Gateway process |
|
||||
| Evennia (server+portal) | 56 MB | Game world |
|
||||
|
||||
---
|
||||
@@ -146,7 +145,6 @@ This means Phase 3+ capabilities (orchestration, load balancing, etc.) are acces
|
||||
| Gitea | 23/24 | 95.8% | GOOD |
|
||||
| Hermes Gateway | 23/24 | 95.8% | GOOD |
|
||||
| Ollama | 24/24 | 100.0% | GOOD |
|
||||
| OpenClaw | 24/24 | 100.0% | GOOD |
|
||||
| Evennia | 24/24 | 100.0% | GOOD |
|
||||
| Hermes Agent | 21/24 | 87.5% | **CHECK** |
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ LOCAL_CHECKS = {
|
||||
"hermes-gateway": "pgrep -f 'hermes gateway' > /dev/null 2>/dev/null",
|
||||
"hermes-agent": "pgrep -f 'hermes agent\\|hermes session' > /dev/null 2>/dev/null",
|
||||
"ollama": "pgrep -f 'ollama serve' > /dev/null 2>/dev/null",
|
||||
"openclaw": "pgrep -f 'openclaw' > /dev/null 2>/dev/null",
|
||||
"evennia": "pgrep -f 'evennia' > /dev/null 2>/dev/null",
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ def update_uptime(checks: dict):
|
||||
save(data)
|
||||
|
||||
if new_milestones:
|
||||
print(f" UPTIME MILESTONE: {','.join(str(m) + '%') for m in new_milestones}")
|
||||
print(f" UPTIME MILESTONE: {','.join((str(m) + '%') for m in new_milestones)}")
|
||||
print(f" Current uptime: {recent_ok:.1f}%")
|
||||
|
||||
return data["uptime"]
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
| Hermes agent (s007) | 62032 | ~200MB | Session active since 10:20PM prev |
|
||||
| Hermes agent (s001) | 12072 | ~178MB | Session active since Sun 6PM |
|
||||
| Ollama | 71466 | ~20MB | /opt/homebrew/opt/ollama/bin/ollama serve |
|
||||
| OpenClaw gateway | 85834 | ~350MB | Tue 12PM start |
|
||||
| Crucible MCP (x4) | multiple | ~10-69MB each | MCP server instances |
|
||||
| Evennia Server | 66433 | ~49MB | Sun 10PM start, port 4000 |
|
||||
| Evennia Portal | 66423 | ~7MB | Sun 10PM start, port 4001 |
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: forge-ci-${{ gitea.ref }}
|
||||
group: forge-ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -18,40 +18,21 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install package
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
pip install pytest pyyaml
|
||||
|
||||
- name: Smoke tests
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python scripts/smoke_test.py
|
||||
run: python scripts/smoke_test.py
|
||||
env:
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Syntax guard
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python scripts/syntax_guard.py
|
||||
|
||||
- name: Green-path E2E
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python -m pytest tests/test_green_path_e2e.py -q --tb=short
|
||||
env:
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
run: python scripts/syntax_guard.py
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install papermill jupytext nbformat
|
||||
pip install papermill jupytext nbformat ipykernel
|
||||
python -m ipykernel install --user --name python3
|
||||
|
||||
- name: Execute system health notebook
|
||||
|
||||
@@ -77,7 +77,7 @@ def check_core_deps() -> CheckResult:
|
||||
"""Verify that hermes core Python packages are importable."""
|
||||
required = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"kimi-coding",
|
||||
"dotenv",
|
||||
"yaml",
|
||||
"rich",
|
||||
@@ -206,8 +206,8 @@ def check_env_vars() -> CheckResult:
|
||||
"""Check that at least one LLM provider key is configured."""
|
||||
provider_keys = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN",
|
||||
"KIMI_API_KEY",
|
||||
# "ANTHROPIC_TOKEN", # BANNED
|
||||
"OPENAI_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
@@ -225,7 +225,7 @@ def check_env_vars() -> CheckResult:
|
||||
passed=False,
|
||||
message="No LLM provider API key found",
|
||||
fix_hint=(
|
||||
"Set at least one of: OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY "
|
||||
"Set at least one of: OPENROUTER_API_KEY, KIMI_API_KEY, OPENAI_API_KEY "
|
||||
"in ~/.hermes/.env or your shell."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
- "traefik.http.routers.matrix-client.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.matrix-client.entrypoints=websecure"
|
||||
- "traefik.http.services.matrix-client.loadbalancer.server.port=6167"
|
||||
|
||||
|
||||
# Federation (TCP 8448) - direct or via Traefik TCP entrypoint
|
||||
# Option A: Direct host port mapping
|
||||
# Option B: Traefik TCP router (requires Traefik federation entrypoint)
|
||||
|
||||
@@ -4,8 +4,8 @@ description: >
|
||||
reproduces the bug, then fixes the code, then verifies.
|
||||
|
||||
model:
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
max_turns: 30
|
||||
temperature: 0.2
|
||||
|
||||
|
||||
166
playbooks/fleet-guardrails.yaml
Normal file
166
playbooks/fleet-guardrails.yaml
Normal file
@@ -0,0 +1,166 @@
|
||||
# fleet-guardrails.yaml
|
||||
# =====================
|
||||
# Enforceable behaviour boundaries for every agent in the Timmy fleet.
|
||||
# Consumed by task_gate.py (pre/post checks) and the orchestrator's
|
||||
# dispatch loop. Every rule here is testable — no aspirational prose.
|
||||
#
|
||||
# Ref: SOUL.md "grounding before generation", Five Wisdoms #345
|
||||
|
||||
name: fleet-guardrails
|
||||
version: "1.0.0"
|
||||
description: >
|
||||
Behaviour constraints that apply to ALL agents regardless of role.
|
||||
These are the non-negotiable rules that task_gate.py enforces
|
||||
before an agent may pick up work and after it claims completion.
|
||||
|
||||
# ─── UNIVERSAL CONSTRAINTS ───────────────────────────────────────
|
||||
|
||||
constraints:
|
||||
|
||||
# 1. Lane discipline — agents must stay in their lane
|
||||
lane_enforcement:
|
||||
enabled: true
|
||||
source: playbooks/agent-lanes.json
|
||||
on_violation: block_and_notify
|
||||
description: >
|
||||
An agent may only pick up issues tagged for its lane.
|
||||
Cross-lane work requires explicit Timmy approval via
|
||||
issue comment containing 'LANE_OVERRIDE: <agent>'.
|
||||
|
||||
# 2. Branch hygiene — no orphan branches
|
||||
branch_hygiene:
|
||||
enabled: true
|
||||
max_branches_per_agent: 3
|
||||
stale_branch_days: 7
|
||||
naming_pattern: "{agent}/{issue_number}-{slug}"
|
||||
on_violation: warn_then_block
|
||||
description: >
|
||||
Agents must follow branch naming conventions and clean up
|
||||
after merge. No agent may have more than 3 active branches.
|
||||
|
||||
# 3. Issue ownership — no silent takeovers
|
||||
issue_ownership:
|
||||
enabled: true
|
||||
require_assignment_before_work: true
|
||||
max_concurrent_issues: 2
|
||||
on_violation: block_and_notify
|
||||
description: >
|
||||
An agent must be assigned to an issue before creating a
|
||||
branch or PR. No agent may work on more than 2 issues
|
||||
simultaneously to prevent context-switching waste.
|
||||
|
||||
# 4. PR quality — minimum bar before review
|
||||
pr_quality:
|
||||
enabled: true
|
||||
require_linked_issue: true
|
||||
require_passing_ci: true
|
||||
max_files_changed: 30
|
||||
max_diff_lines: 2000
|
||||
require_description: true
|
||||
min_description_length: 50
|
||||
on_violation: block_merge
|
||||
description: >
|
||||
Every PR must link an issue, pass CI, have a meaningful
|
||||
description, and stay within scope. Giant PRs get rejected.
|
||||
|
||||
# 5. Grounding before generation — SOUL.md compliance
|
||||
grounding:
|
||||
enabled: true
|
||||
require_issue_read_before_branch: true
|
||||
require_existing_code_review: true
|
||||
require_soul_md_check: true
|
||||
soul_md_path: SOUL.md
|
||||
on_violation: block_and_notify
|
||||
description: >
|
||||
Before writing any code, the agent must demonstrate it has
|
||||
read the issue, reviewed relevant existing code, and checked
|
||||
SOUL.md for applicable doctrine. No speculative generation.
|
||||
|
||||
# 6. Completion integrity — no phantom completions
|
||||
completion_checks:
|
||||
enabled: true
|
||||
require_test_evidence: true
|
||||
require_ci_green: true
|
||||
require_diff_matches_issue: true
|
||||
require_no_unrelated_changes: true
|
||||
on_violation: revert_and_notify
|
||||
description: >
|
||||
Post-task gate verifies the work actually addresses the
|
||||
issue. Agents cannot close issues without evidence.
|
||||
Unrelated changes in a PR trigger automatic rejection.
|
||||
|
||||
# 7. Communication discipline — no noise
|
||||
communication:
|
||||
enabled: true
|
||||
max_comments_per_issue: 10
|
||||
require_structured_updates: true
|
||||
update_format: "status | what_changed | what_blocked | next_step"
|
||||
prohibit_empty_updates: true
|
||||
on_violation: warn
|
||||
description: >
|
||||
Issue comments must be structured and substantive.
|
||||
Status-only comments without content are rejected.
|
||||
Agents should update, not narrate.
|
||||
|
||||
# 8. Resource awareness — no runaway costs
|
||||
resource_limits:
|
||||
enabled: true
|
||||
max_api_calls_per_task: 100
|
||||
max_llm_tokens_per_task: 500000
|
||||
max_task_duration_minutes: 60
|
||||
on_violation: kill_and_notify
|
||||
description: >
|
||||
Hard limits on compute per task. If an agent hits these
|
||||
limits, the task is killed and flagged for human review.
|
||||
Prevents infinite loops and runaway API spending.
|
||||
|
||||
# ─── ESCALATION POLICY ───────────────────────────────────────────
|
||||
|
||||
escalation:
|
||||
channels:
|
||||
- gitea_issue_comment
|
||||
- discord_webhook
|
||||
severity_levels:
|
||||
warn:
|
||||
action: post_comment
|
||||
notify: agent_only
|
||||
block:
|
||||
action: prevent_action
|
||||
notify: agent_and_orchestrator
|
||||
block_and_notify:
|
||||
action: prevent_action
|
||||
notify: agent_orchestrator_and_timmy
|
||||
kill_and_notify:
|
||||
action: terminate_task
|
||||
notify: all_including_alexander
|
||||
revert_and_notify:
|
||||
action: revert_changes
|
||||
notify: agent_orchestrator_and_timmy
|
||||
|
||||
# ─── AUDIT TRAIL ─────────────────────────────────────────────────
|
||||
|
||||
audit:
|
||||
enabled: true
|
||||
log_path: logs/guardrail-violations.jsonl
|
||||
retention_days: 90
|
||||
fields:
|
||||
- timestamp
|
||||
- agent
|
||||
- constraint
|
||||
- violation_type
|
||||
- issue_number
|
||||
- action_taken
|
||||
- resolution
|
||||
|
||||
# ─── OVERRIDES ───────────────────────────────────────────────────
|
||||
|
||||
overrides:
|
||||
# Only Timmy or Alexander can override guardrails
|
||||
authorized_overriders:
|
||||
- Timmy
|
||||
- Alexander
|
||||
override_mechanism: >
|
||||
Post a comment on the issue with the format:
|
||||
GUARDRAIL_OVERRIDE: <constraint_name> REASON: <explanation>
|
||||
override_expiry_hours: 24
|
||||
require_post_override_review: true
|
||||
@@ -4,8 +4,8 @@ description: >
|
||||
agents. Decomposes large issues into smaller ones.
|
||||
|
||||
model:
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
max_turns: 20
|
||||
temperature: 0.3
|
||||
|
||||
@@ -50,7 +50,7 @@ system_prompt: |
|
||||
- codex-agent: cleanup, migration verification, dead-code removal, repo-boundary enforcement, workflow hardening
|
||||
- groq: bounded implementation, tactical bug fixes, quick feature slices, small patches with clear acceptance criteria
|
||||
- manus: bounded support tasks, moderate-scope implementation, follow-through on already-scoped work
|
||||
- claude: hard refactors, broad multi-file implementation, test-heavy changes after the scope is made precise
|
||||
- kimi: hard refactors, broad multi-file implementation, test-heavy changes after the scope is made precise
|
||||
- gemini: frontier architecture, research-heavy prototypes, long-range design thinking when a concrete implementation owner is not yet obvious
|
||||
- grok: adversarial testing, unusual edge cases, provocative review angles that still need another pass
|
||||
5. Decompose any issue touching >5 files or crossing repo boundaries into smaller issues before assigning execution
|
||||
@@ -63,6 +63,6 @@ system_prompt: |
|
||||
- Search for existing issues or PRs covering the same request before assigning anything. If a likely duplicate exists, link it and do not create or route duplicate work.
|
||||
- Do not assign open-ended ideation to implementation agents.
|
||||
- Do not assign routine backlog maintenance to Timmy.
|
||||
- Do not assign wide speculative backlog generation to codex-agent, groq, manus, or claude.
|
||||
- Do not assign wide speculative backlog generation to codex-agent, groq, or manus.
|
||||
- Route archive/history/context-digestion work to ezra or KimiClaw before routing it to a builder.
|
||||
- Route “who should do this?” and “what is the next move?” questions to allegro.
|
||||
|
||||
@@ -4,8 +4,8 @@ description: >
|
||||
comments on problems. The merge bot replacement.
|
||||
|
||||
model:
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
max_turns: 20
|
||||
temperature: 0.2
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ description: >
|
||||
Well-scoped: 1-3 files per task, clear acceptance criteria.
|
||||
|
||||
model:
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
max_turns: 30
|
||||
temperature: 0.3
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ description: >
|
||||
dependency issues. Files findings as Gitea issues.
|
||||
|
||||
model:
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-opus-4-6
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
max_turns: 40
|
||||
temperature: 0.2
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ description: >
|
||||
writes meaningful tests, verifies they pass.
|
||||
|
||||
model:
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
max_turns: 30
|
||||
temperature: 0.3
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ description: >
|
||||
and consistency verification.
|
||||
|
||||
model:
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
max_turns: 12
|
||||
temperature: 0.1
|
||||
|
||||
|
||||
151
scripts/a11y-check.js
Normal file
151
scripts/a11y-check.js
Normal file
@@ -0,0 +1,151 @@
|
||||
// a11y-check.js — Automated accessibility audit script for Foundation web properties
|
||||
// Run in browser console or via Playwright/Puppeteer
|
||||
//
|
||||
// Usage: Paste into DevTools console, or include in automated test suite.
|
||||
// Returns a JSON object with pass/fail for WCAG 2.1 AA checks.
|
||||
|
||||
(function a11yAudit() {
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
violations: [],
|
||||
passes: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
// --- 2.4.1 Skip Navigation ---
|
||||
const skipLink = document.querySelector('a[href="#main"], a[href="#content"], .skip-nav, .skip-link');
|
||||
if (skipLink) {
|
||||
results.passes.push({ rule: '2.4.1', name: 'Skip Navigation', detail: 'Skip link found' });
|
||||
} else {
|
||||
results.violations.push({ rule: '2.4.1', name: 'Skip Navigation', severity: 'medium', detail: 'No skip-to-content link found' });
|
||||
}
|
||||
|
||||
// --- 1.3.1 / 3.3.2 Form Labels ---
|
||||
const unlabeledInputs = Array.from(document.querySelectorAll('input, select, textarea')).filter(el => {
|
||||
if (el.type === 'hidden') return false;
|
||||
const id = el.id;
|
||||
const hasLabel = id && document.querySelector(`label[for="${id}"]`);
|
||||
const hasAriaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby');
|
||||
const hasTitle = el.getAttribute('title');
|
||||
const hasPlaceholder = el.getAttribute('placeholder'); // placeholder alone is NOT sufficient
|
||||
return !hasLabel && !hasAriaLabel && !hasTitle;
|
||||
});
|
||||
if (unlabeledInputs.length === 0) {
|
||||
results.passes.push({ rule: '3.3.2', name: 'Form Labels', detail: 'All inputs have labels' });
|
||||
} else {
|
||||
results.violations.push({
|
||||
rule: '3.3.2',
|
||||
name: 'Form Labels',
|
||||
severity: 'high',
|
||||
detail: `${unlabeledInputs.length} inputs without programmatic labels`,
|
||||
elements: unlabeledInputs.map(el => ({ tag: el.tagName, type: el.type, name: el.name, id: el.id }))
|
||||
});
|
||||
}
|
||||
|
||||
// --- 1.4.3 Contrast (heuristic: very light text colors) ---
|
||||
const lowContrast = Array.from(document.querySelectorAll('p, span, a, li, td, th, label, small, footer *')).filter(el => {
|
||||
const style = getComputedStyle(el);
|
||||
const color = style.color;
|
||||
// Check for very light RGB values (r/g/b < 120)
|
||||
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (!match) return false;
|
||||
const [, r, g, b] = match.map(Number);
|
||||
return r < 120 && g < 120 && b < 120 && (r + g + b) < 200;
|
||||
});
|
||||
if (lowContrast.length === 0) {
|
||||
results.passes.push({ rule: '1.4.3', name: 'Contrast', detail: 'No obviously low-contrast text found' });
|
||||
} else {
|
||||
results.warnings.push({ rule: '1.4.3', name: 'Contrast', detail: `${lowContrast.length} elements with potentially low contrast (manual verification needed)` });
|
||||
}
|
||||
|
||||
// --- 1.3.1 Heading Hierarchy ---
|
||||
const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).map(h => ({
|
||||
level: parseInt(h.tagName[1]),
|
||||
text: h.textContent.trim().substring(0, 80)
|
||||
}));
|
||||
let headingIssues = [];
|
||||
let lastLevel = 0;
|
||||
for (const h of headings) {
|
||||
if (h.level > lastLevel + 1 && lastLevel > 0) {
|
||||
headingIssues.push(`Skipped h${lastLevel} to h${h.level}: "${h.text}"`);
|
||||
}
|
||||
lastLevel = h.level;
|
||||
}
|
||||
if (headingIssues.length === 0 && headings.length > 0) {
|
||||
results.passes.push({ rule: '1.3.1', name: 'Heading Hierarchy', detail: `${headings.length} headings, proper nesting` });
|
||||
} else if (headingIssues.length > 0) {
|
||||
results.violations.push({ rule: '1.3.1', name: 'Heading Hierarchy', severity: 'low', detail: headingIssues.join('; ') });
|
||||
}
|
||||
|
||||
// --- 1.3.1 Landmarks ---
|
||||
const landmarks = {
|
||||
main: document.querySelectorAll('main, [role="main"]').length,
|
||||
nav: document.querySelectorAll('nav, [role="navigation"]').length,
|
||||
banner: document.querySelectorAll('header, [role="banner"]').length,
|
||||
contentinfo: document.querySelectorAll('footer, [role="contentinfo"]').length
|
||||
};
|
||||
if (landmarks.main > 0) {
|
||||
results.passes.push({ rule: '1.3.1', name: 'Main Landmark', detail: 'Found' });
|
||||
} else {
|
||||
results.violations.push({ rule: '1.3.1', name: 'Main Landmark', severity: 'medium', detail: 'No <main> or role="main" found' });
|
||||
}
|
||||
if (landmarks.banner === 0) {
|
||||
results.violations.push({ rule: '1.3.1', name: 'Banner Landmark', severity: 'low', detail: 'No <header> or role="banner" found' });
|
||||
}
|
||||
|
||||
// --- 3.3.1 Required Fields ---
|
||||
const requiredInputs = document.querySelectorAll('input[required], input[aria-required="true"]');
|
||||
if (requiredInputs.length > 0) {
|
||||
results.passes.push({ rule: '3.3.1', name: 'Required Fields', detail: `${requiredInputs.length} inputs marked as required` });
|
||||
} else {
|
||||
const visualRequired = document.querySelector('.required, [class*="required"], label .text-danger');
|
||||
if (visualRequired) {
|
||||
results.warnings.push({ rule: '3.3.1', name: 'Required Fields', detail: 'Visual indicators found but no aria-required attributes' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2.4.2 Page Title ---
|
||||
if (document.title && document.title.trim().length > 0) {
|
||||
results.passes.push({ rule: '2.4.2', name: 'Page Title', detail: document.title });
|
||||
} else {
|
||||
results.violations.push({ rule: '2.4.2', name: 'Page Title', severity: 'medium', detail: 'Page has no title' });
|
||||
}
|
||||
|
||||
// --- 3.1.1 Language ---
|
||||
const lang = document.documentElement.lang;
|
||||
if (lang) {
|
||||
results.passes.push({ rule: '3.1.1', name: 'Language', detail: lang });
|
||||
} else {
|
||||
results.violations.push({ rule: '3.1.1', name: 'Language', severity: 'medium', detail: 'No lang attribute on <html>' });
|
||||
}
|
||||
|
||||
// --- Images without alt ---
|
||||
const imgsNoAlt = Array.from(document.querySelectorAll('img:not([alt])'));
|
||||
if (imgsNoAlt.length === 0) {
|
||||
results.passes.push({ rule: '1.1.1', name: 'Image Alt Text', detail: 'All images have alt attributes' });
|
||||
} else {
|
||||
results.violations.push({ rule: '1.1.1', name: 'Image Alt Text', severity: 'high', detail: `${imgsNoAlt.length} images without alt attributes` });
|
||||
}
|
||||
|
||||
// --- Buttons without accessible names ---
|
||||
const emptyButtons = Array.from(document.querySelectorAll('button')).filter(b => {
|
||||
return !b.textContent.trim() && !b.getAttribute('aria-label') && !b.getAttribute('aria-labelledby') && !b.getAttribute('title');
|
||||
});
|
||||
if (emptyButtons.length === 0) {
|
||||
results.passes.push({ rule: '4.1.2', name: 'Button Names', detail: 'All buttons have accessible names' });
|
||||
} else {
|
||||
results.violations.push({ rule: '4.1.2', name: 'Button Names', severity: 'medium', detail: `${emptyButtons.length} buttons without accessible names` });
|
||||
}
|
||||
|
||||
// Summary
|
||||
results.summary = {
|
||||
violations: results.violations.length,
|
||||
passes: results.passes.length,
|
||||
warnings: results.warnings.length
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
return results;
|
||||
})();
|
||||
@@ -9,7 +9,12 @@ Replaces ad-hoc dispatch scripts with a unified framework for tasking agents.
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import subprocess
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
|
||||
from ssh_trust import VerifiedSSHExecutor
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
FLEET = {
|
||||
@@ -18,6 +23,9 @@ FLEET = {
|
||||
}
|
||||
|
||||
class Dispatcher:
|
||||
def __init__(self, executor=None):
|
||||
self.executor = executor or VerifiedSSHExecutor()
|
||||
|
||||
def log(self, message: str):
|
||||
print(f"[*] {message}")
|
||||
|
||||
@@ -25,14 +33,14 @@ class Dispatcher:
|
||||
self.log(f"Dispatching task to {agent_name} on {host}...")
|
||||
|
||||
ip = FLEET[host]
|
||||
# Command to run the agent on the remote machine
|
||||
# Assumes hermes-agent is installed in /opt/hermes
|
||||
remote_cmd = f"cd /opt/hermes && python3 run_agent.py --agent {agent_name} --task '{task}'"
|
||||
|
||||
ssh_cmd = ["ssh", "-o", "StrictHostKeyChecking=no", f"root@{ip}", remote_cmd]
|
||||
|
||||
|
||||
try:
|
||||
res = subprocess.run(ssh_cmd, capture_output=True, text=True)
|
||||
res = self.executor.run(
|
||||
ip,
|
||||
['python3', 'run_agent.py', '--agent', agent_name, '--task', task],
|
||||
cwd='/opt/hermes',
|
||||
timeout=30,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
self.log(f"[SUCCESS] {agent_name} completed task.")
|
||||
print(res.stdout)
|
||||
|
||||
126
scripts/agent_guardrails.py
Normal file
126
scripts/agent_guardrails.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
agent_guardrails.py — Sovereign Agent Guardrails for the Timmy Foundation.
|
||||
|
||||
Scans agent logs and conversation trajectories for:
|
||||
1. System Prompt Leakage — detecting if the agent revealed its internal instructions.
|
||||
2. Jailbreak Compliance — detecting if the agent responded to "GODMODE", "DAN", etc.
|
||||
3. PII Leakage — detecting if secrets or sensitive data were output.
|
||||
|
||||
Part of the Sovereign Infrastructure Suite.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import glob
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
# Patterns adapted from hermes-agent/agent/input_sanitizer.py
|
||||
EXTRACTION_PATTERNS = [
|
||||
r"repeat\s+the\s+words\s+above",
|
||||
r"repeat\s+your\s+(?:system\s+|initial\s+)?instructions",
|
||||
r"what\s+is\s+your\s+(?:system\s+|initial\s+)?prompt",
|
||||
r"show\s+me\s+your\s+(?:system\s+|initial\s+)?prompt",
|
||||
r"print\s+your\s+(?:instructions|prompt|system\s+prompt)",
|
||||
r"output\s+your\s+(?:initial|system)\s+prompt",
|
||||
]
|
||||
|
||||
# Patterns that indicate the agent MIGHT be leaking its prompt
|
||||
LEAKAGE_INDICATORS = [
|
||||
r"you\s+are\s+a\s+helpful\s+assistant",
|
||||
r"your\s+goal\s+is\s+to",
|
||||
r"you\s+must\s+not",
|
||||
r"here\s+are\s+your\s+instructions",
|
||||
r"my\s+system\s+prompt\s+is",
|
||||
r"i\s+was\s+told\s+to",
|
||||
]
|
||||
|
||||
# Patterns for secrets (adapted from redact.py)
|
||||
SECRET_PATTERNS = [
|
||||
r"sk-[A-Za-z0-9_-]{20,}",
|
||||
r"ghp_[A-Za-z0-9]{20,}",
|
||||
r"AIza[A-Za-z0-9_-]{30,}",
|
||||
]
|
||||
|
||||
AGENT_LOG_PATHS = [
|
||||
"/root/wizards/*/home/logs/*.log",
|
||||
"/root/wizards/*/logs/*.log",
|
||||
"/root/wizards/*/.hermes/logs/*.log",
|
||||
]
|
||||
|
||||
class GuardrailAuditor:
|
||||
def __init__(self):
|
||||
self.extraction_re = [re.compile(p, re.IGNORECASE) for p in EXTRACTION_PATTERNS]
|
||||
self.leakage_re = [re.compile(p, re.IGNORECASE) for p in LEAKAGE_INDICATORS]
|
||||
self.secret_re = [re.compile(p, re.IGNORECASE) for p in SECRET_PATTERNS]
|
||||
|
||||
def find_logs(self) -> List[Path]:
|
||||
files = []
|
||||
for pattern in AGENT_LOG_PATHS:
|
||||
for p in glob.glob(pattern):
|
||||
files.append(Path(p))
|
||||
return files
|
||||
|
||||
def audit_file(self, path: Path) -> List[Dict]:
|
||||
findings = []
|
||||
try:
|
||||
with open(path, "r", errors="ignore") as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines):
|
||||
# Check for extraction attempts (User side)
|
||||
for p in self.extraction_re:
|
||||
if p.search(line):
|
||||
findings.append({
|
||||
"type": "EXTRACTION_ATTEMPT",
|
||||
"line": i + 1,
|
||||
"content": line.strip()[:100],
|
||||
"severity": "MEDIUM"
|
||||
})
|
||||
|
||||
# Check for potential leakage (Assistant side)
|
||||
for p in self.leakage_re:
|
||||
if p.search(line):
|
||||
findings.append({
|
||||
"type": "POTENTIAL_LEAKAGE",
|
||||
"line": i + 1,
|
||||
"content": line.strip()[:100],
|
||||
"severity": "HIGH"
|
||||
})
|
||||
|
||||
# Check for secrets
|
||||
for p in self.secret_re:
|
||||
if p.search(line):
|
||||
findings.append({
|
||||
"type": "SECRET_EXPOSURE",
|
||||
"line": i + 1,
|
||||
"content": "[REDACTED]",
|
||||
"severity": "CRITICAL"
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error reading {path}: {e}")
|
||||
return findings
|
||||
|
||||
def run(self):
|
||||
print("--- Sovereign Agent Guardrail Audit ---")
|
||||
logs = self.find_logs()
|
||||
print(f"Scanning {len(logs)} log files...")
|
||||
|
||||
total_findings = 0
|
||||
for log in logs:
|
||||
findings = self.audit_file(log)
|
||||
if findings:
|
||||
print(f"\nFindings in {log}:")
|
||||
for f in findings:
|
||||
print(f" [{f['severity']}] {f['type']} at line {f['line']}: {f['content']}")
|
||||
total_findings += 1
|
||||
|
||||
print(f"\nAudit complete. Total findings: {total_findings}")
|
||||
if total_findings > 0:
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
auditor = GuardrailAuditor()
|
||||
auditor.run()
|
||||
@@ -9,7 +9,7 @@ import re
|
||||
SOVEREIGN_RULES = [
|
||||
(r"https?://(api\.openai\.com|api\.anthropic\.com)", "CRITICAL: External cloud API detected. Use local custom_provider instead."),
|
||||
(r"provider: (openai|anthropic)", "WARNING: Direct cloud provider used. Ensure fallback_model is configured."),
|
||||
(r"api_key: ['"][^'"\s]{10,}['"]", "SECURITY: Hardcoded API key detected. Use environment variables.")
|
||||
(r"api_key:\s*['\"][A-Za-z0-9_\-]{16,}['\"]", "SECURITY: Hardcoded API key detected. Use environment variables.")
|
||||
]
|
||||
|
||||
def lint_file(path):
|
||||
|
||||
@@ -5,122 +5,233 @@ Part of the Gemini Sovereign Governance System.
|
||||
|
||||
Enforces architectural boundaries, security, and documentation standards
|
||||
across the Timmy Foundation fleet.
|
||||
|
||||
Refs: #437 — repo-aware, test-backed, CI-enforced.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
|
||||
SOVEREIGN_KEYWORDS = ["mempalace", "sovereign_store", "tirith", "bezalel", "nexus"]
|
||||
IP_REGEX = r'\b(?:\d{1,3}\.){3}\d{1,3}\b'
|
||||
API_KEY_REGEX = r'(?:api_key|secret|token|password|auth_token)\s*[:=]\s*["\'][a-zA-Z0-9_\-]{20,}["\']'
|
||||
|
||||
# IP addresses (skip 127.0.0.1, 0.0.0.0, 10.x.x.x, 172.16-31.x.x, 192.168.x.x)
|
||||
IP_REGEX = r'\b(?!(?:127|10|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.)' \
|
||||
r'(?:\d{1,3}\.){3}\d{1,3}\b'
|
||||
|
||||
# API key / secret patterns — catches openai-, sk-, anthropic-, AKIA, etc.
|
||||
API_KEY_PATTERNS = [
|
||||
r'sk-[A-Za-z0-9]{20,}', # OpenAI-style
|
||||
r'sk-ant-[A-Za-z0-9\-]{20,}', # Anthropic
|
||||
r'AKIA[A-Z0-9]{16}', # AWS access key
|
||||
r'ghp_[A-Za-z0-9]{36}', # GitHub PAT
|
||||
r'glpat-[A-Za-z0-9\-]{20,}', # GitLab PAT
|
||||
r'(?:api[_-]?key|secret|token)\s*[:=]\s*["\'][A-Za-z0-9_\-]{16,}["\']',
|
||||
]
|
||||
|
||||
# Sovereignty rules (carried from v1)
|
||||
SOVEREIGN_RULES = [
|
||||
(r'https?://api\.openai\.com', 'External cloud API: api.openai.com. Use local custom_provider.'),
|
||||
(r'https?://api\.anthropic\.com', 'External cloud API: api.anthropic.com. Use local custom_provider.'),
|
||||
(r'provider:\s*(?:openai|anthropic)\b', 'Direct cloud provider. Ensure fallback_model is configured.'),
|
||||
]
|
||||
|
||||
# File extensions to scan
|
||||
SCAN_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.yaml', '.yml', '.json', '.env', '.sh', '.cfg', '.toml'}
|
||||
SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', '.eggs'}
|
||||
|
||||
|
||||
class LinterResult:
|
||||
"""Structured result container for programmatic access."""
|
||||
|
||||
def __init__(self, repo_path: str, repo_name: str):
|
||||
self.repo_path = repo_path
|
||||
self.repo_name = repo_name
|
||||
self.errors: list[str] = []
|
||||
self.warnings: list[str] = []
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
return len(self.errors) == 0
|
||||
|
||||
@property
|
||||
def violation_count(self) -> int:
|
||||
return len(self.errors)
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [f"--- Architecture Linter v2: {self.repo_name} ---"]
|
||||
for w in self.warnings:
|
||||
lines.append(f" [W] {w}")
|
||||
for e in self.errors:
|
||||
lines.append(f" [E] {e}")
|
||||
status = "PASSED" if self.passed else f"FAILED ({self.violation_count} violations)"
|
||||
lines.append(f"\nResult: {status}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
class Linter:
|
||||
def __init__(self, repo_path: str):
|
||||
self.repo_path = Path(repo_path).resolve()
|
||||
if not self.repo_path.is_dir():
|
||||
raise FileNotFoundError(f"Repository path does not exist: {self.repo_path}")
|
||||
self.repo_name = self.repo_path.name
|
||||
self.errors = []
|
||||
self.result = LinterResult(str(self.repo_path), self.repo_name)
|
||||
|
||||
def log_error(self, message: str, file: str = None, line: int = None):
|
||||
loc = f"{file}:{line}" if file and line else (file if file else "General")
|
||||
self.errors.append(f"[{loc}] {message}")
|
||||
# --- helpers ---
|
||||
|
||||
def _scan_files(self, extensions=None):
|
||||
"""Yield (Path, content) for files matching *extensions*."""
|
||||
exts = extensions or SCAN_EXTENSIONS
|
||||
for root, dirs, files in os.walk(self.repo_path):
|
||||
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
||||
for fname in files:
|
||||
if Path(fname).suffix in exts:
|
||||
if fname == '.env.example':
|
||||
continue
|
||||
fpath = Path(root) / fname
|
||||
try:
|
||||
content = fpath.read_text(errors='ignore')
|
||||
except Exception:
|
||||
continue
|
||||
yield fpath, content
|
||||
|
||||
def _line_no(self, content: str, offset: int) -> int:
|
||||
return content.count('\n', 0, offset) + 1
|
||||
|
||||
# --- checks ---
|
||||
|
||||
def check_sidecar_boundary(self):
|
||||
"""Rule 1: No sovereign code in hermes-agent (sidecar boundary)"""
|
||||
if self.repo_name == "hermes-agent":
|
||||
for root, _, files in os.walk(self.repo_path):
|
||||
if "node_modules" in root or ".git" in root:
|
||||
continue
|
||||
for file in files:
|
||||
if file.endswith((".py", ".ts", ".js", ".tsx")):
|
||||
path = Path(root) / file
|
||||
content = path.read_text(errors="ignore")
|
||||
for kw in SOVEREIGN_KEYWORDS:
|
||||
if kw in content.lower():
|
||||
# Exception: imports or comments might be okay, but we're strict for now
|
||||
self.log_error(f"Sovereign keyword '{kw}' found in hermes-agent. Violates sidecar boundary.", str(path.relative_to(self.repo_path)))
|
||||
"""No sovereign code in hermes-agent (sidecar boundary)."""
|
||||
if self.repo_name != 'hermes-agent':
|
||||
return
|
||||
for fpath, content in self._scan_files():
|
||||
for kw in SOVEREIGN_KEYWORDS:
|
||||
if kw in content.lower():
|
||||
rel = str(fpath.relative_to(self.repo_path))
|
||||
self.result.errors.append(
|
||||
f"Sovereign keyword '{kw}' in hermes-agent violates sidecar boundary. [{rel}]"
|
||||
)
|
||||
|
||||
def check_hardcoded_ips(self):
|
||||
"""Rule 2: No hardcoded IPs (use domain names)"""
|
||||
for root, _, files in os.walk(self.repo_path):
|
||||
if "node_modules" in root or ".git" in root:
|
||||
continue
|
||||
for file in files:
|
||||
if file.endswith((".py", ".ts", ".js", ".tsx", ".yaml", ".yml", ".json")):
|
||||
path = Path(root) / file
|
||||
content = path.read_text(errors="ignore")
|
||||
matches = re.finditer(IP_REGEX, content)
|
||||
for match in matches:
|
||||
ip = match.group()
|
||||
if ip in ["127.0.0.1", "0.0.0.0"]:
|
||||
continue
|
||||
line_no = content.count('\n', 0, match.start()) + 1
|
||||
self.log_error(f"Hardcoded IP address '{ip}' found. Use domain names or environment variables.", str(path.relative_to(self.repo_path)), line_no)
|
||||
"""No hardcoded public IPs — use DNS or env vars."""
|
||||
for fpath, content in self._scan_files():
|
||||
for m in re.finditer(IP_REGEX, content):
|
||||
ip = m.group()
|
||||
# skip private ranges already handled by lookahead, and 0.0.0.0
|
||||
if ip.startswith('0.'):
|
||||
continue
|
||||
line = self._line_no(content, m.start())
|
||||
rel = str(fpath.relative_to(self.repo_path))
|
||||
self.result.errors.append(
|
||||
f"Hardcoded IP '{ip}'. Use DNS or env vars. [{rel}:{line}]"
|
||||
)
|
||||
|
||||
def check_api_keys(self):
|
||||
"""Rule 3: No cloud API keys committed to repos"""
|
||||
for root, _, files in os.walk(self.repo_path):
|
||||
if "node_modules" in root or ".git" in root:
|
||||
continue
|
||||
for file in files:
|
||||
if file.endswith((".py", ".ts", ".js", ".tsx", ".yaml", ".yml", ".json", ".env")):
|
||||
if file == ".env.example":
|
||||
continue
|
||||
path = Path(root) / file
|
||||
content = path.read_text(errors="ignore")
|
||||
matches = re.finditer(API_KEY_REGEX, content, re.IGNORECASE)
|
||||
for match in matches:
|
||||
line_no = content.count('\n', 0, match.start()) + 1
|
||||
self.log_error("Potential API key or secret found in code.", str(path.relative_to(self.repo_path)), line_no)
|
||||
"""No cloud API keys / secrets committed."""
|
||||
for fpath, content in self._scan_files():
|
||||
for pattern in API_KEY_PATTERNS:
|
||||
for m in re.finditer(pattern, content, re.IGNORECASE):
|
||||
line = self._line_no(content, m.start())
|
||||
rel = str(fpath.relative_to(self.repo_path))
|
||||
self.result.errors.append(
|
||||
f"Potential secret / API key detected. [{rel}:{line}]"
|
||||
)
|
||||
|
||||
def check_sovereignty_rules(self):
|
||||
"""V1 sovereignty rules: no direct cloud API endpoints or providers."""
|
||||
for fpath, content in self._scan_files({'.py', '.ts', '.tsx', '.js', '.yaml', '.yml'}):
|
||||
for pattern, msg in SOVEREIGN_RULES:
|
||||
for m in re.finditer(pattern, content):
|
||||
line = self._line_no(content, m.start())
|
||||
rel = str(fpath.relative_to(self.repo_path))
|
||||
self.result.errors.append(f"{msg} [{rel}:{line}]")
|
||||
|
||||
def check_soul_canonical(self):
|
||||
"""Rule 4: SOUL.md exists and is canonical in exactly one location"""
|
||||
soul_path = self.repo_path / "SOUL.md"
|
||||
if self.repo_name == "timmy-config":
|
||||
"""SOUL.md must exist exactly in timmy-config root."""
|
||||
soul_path = self.repo_path / 'SOUL.md'
|
||||
if self.repo_name == 'timmy-config':
|
||||
if not soul_path.exists():
|
||||
self.log_error("SOUL.md is missing from the canonical location (timmy-config root).")
|
||||
self.result.errors.append(
|
||||
'SOUL.md missing from canonical location (timmy-config root).'
|
||||
)
|
||||
else:
|
||||
if soul_path.exists():
|
||||
self.log_error("SOUL.md found in non-canonical repo. It should only live in timmy-config.")
|
||||
self.result.errors.append(
|
||||
'SOUL.md found in non-canonical repo. Must live only in timmy-config.'
|
||||
)
|
||||
|
||||
def check_readme(self):
|
||||
"""Rule 5: Every repo has a README with current truth"""
|
||||
readme_path = self.repo_path / "README.md"
|
||||
if not readme_path.exists():
|
||||
self.log_error("README.md is missing.")
|
||||
"""Every repo must have a substantive README."""
|
||||
readme = self.repo_path / 'README.md'
|
||||
if not readme.exists():
|
||||
self.result.errors.append('README.md is missing.')
|
||||
else:
|
||||
content = readme_path.read_text(errors="ignore")
|
||||
content = readme.read_text(errors='ignore')
|
||||
if len(content.strip()) < 50:
|
||||
self.log_error("README.md is too short or empty. Provide current truth about the repo.")
|
||||
self.result.warnings.append(
|
||||
'README.md is very short (<50 chars). Provide current truth about the repo.'
|
||||
)
|
||||
|
||||
def run(self):
|
||||
print(f"--- Gemini Linter: Auditing {self.repo_name} ---")
|
||||
# --- runner ---
|
||||
|
||||
def run(self) -> LinterResult:
|
||||
"""Execute all checks and return the result."""
|
||||
self.check_sidecar_boundary()
|
||||
self.check_hardcoded_ips()
|
||||
self.check_api_keys()
|
||||
self.check_sovereignty_rules()
|
||||
self.check_soul_canonical()
|
||||
self.check_readme()
|
||||
return self.result
|
||||
|
||||
if self.errors:
|
||||
print(f"\n[FAILURE] Found {len(self.errors)} architectural violations:")
|
||||
for err in self.errors:
|
||||
print(f" - {err}")
|
||||
return False
|
||||
else:
|
||||
print("\n[SUCCESS] Architecture is sound. Sovereignty maintained.")
|
||||
return True
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Gemini Architecture Linter v2")
|
||||
parser.add_argument("repo_path", nargs="?", default=".", help="Path to the repository to lint")
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Gemini Architecture Linter v2 — repo-aware sovereignty gate.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'repo_path', nargs='?', default='.',
|
||||
help='Path to the repository to lint (default: cwd).',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--repo', dest='repo_flag', default=None,
|
||||
help='Explicit repo path (alias for positional arg).',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--json', dest='json_output', action='store_true',
|
||||
help='Emit machine-readable JSON instead of human text.',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
linter = Linter(args.repo_path)
|
||||
success = linter.run()
|
||||
sys.exit(0 if success else 1)
|
||||
path = args.repo_flag if args.repo_flag else args.repo_path
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
linter = Linter(path)
|
||||
except FileNotFoundError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
result = linter.run()
|
||||
|
||||
if args.json_output:
|
||||
import json as _json
|
||||
out = {
|
||||
'repo': result.repo_name,
|
||||
'passed': result.passed,
|
||||
'violation_count': result.violation_count,
|
||||
'errors': result.errors,
|
||||
'warnings': result.warnings,
|
||||
}
|
||||
print(_json.dumps(out, indent=2))
|
||||
else:
|
||||
print(result.summary())
|
||||
|
||||
sys.exit(0 if result.passed else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
11
scripts/captcha_bypass_handler.py
Normal file
11
scripts/captcha_bypass_handler.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import json
|
||||
from hermes_tools import browser_navigate, browser_vision
|
||||
|
||||
def bypass_captcha():
|
||||
analysis = browser_vision(
|
||||
question="Solve the CAPTCHA on the current page. Provide the solution text or coordinate clicks required. Provide a PASS/FAIL."
|
||||
)
|
||||
return {"status": "PASS" if "PASS" in analysis.upper() else "FAIL", "solution": analysis}
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(bypass_captcha(), indent=2))
|
||||
87
scripts/ci_automation_gate.py
Normal file
87
scripts/ci_automation_gate.py
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ci_automation_gate.py — Automated Quality Gate for Timmy Foundation CI.
|
||||
|
||||
Enforces:
|
||||
1. The 10-line Rule — functions should ideally be under 10 lines (warn at 20, fail at 50).
|
||||
2. Complexity Check — basic cyclomatic complexity check.
|
||||
3. Auto-fixable Linting — trailing whitespace, missing final newlines.
|
||||
|
||||
Used as a pre-merge gate.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
class QualityGate:
|
||||
def __init__(self, fix=False):
|
||||
self.fix = fix
|
||||
self.failures = 0
|
||||
self.warnings = 0
|
||||
|
||||
def check_file(self, path: Path):
|
||||
if path.suffix not in (".js", ".ts", ".py"):
|
||||
return
|
||||
|
||||
with open(path, "r") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
new_lines = []
|
||||
changed = False
|
||||
|
||||
# 1. Basic Linting
|
||||
for line in lines:
|
||||
cleaned = line.rstrip() + "\n"
|
||||
if cleaned != line:
|
||||
changed = True
|
||||
new_lines.append(cleaned)
|
||||
|
||||
if lines and not lines[-1].endswith("\n"):
|
||||
new_lines[-1] = new_lines[-1] + "\n"
|
||||
changed = True
|
||||
|
||||
if changed and self.fix:
|
||||
with open(path, "w") as f:
|
||||
f.writelines(new_lines)
|
||||
print(f" [FIXED] {path}: Cleaned whitespace and newlines.")
|
||||
elif changed:
|
||||
print(f" [WARN] {path}: Has trailing whitespace or missing final newline.")
|
||||
self.warnings += 1
|
||||
|
||||
# 2. Function Length Check (Simple regex-based)
|
||||
content = "".join(new_lines)
|
||||
if path.suffix in (".js", ".ts"):
|
||||
# Match function blocks
|
||||
functions = re.findall(r"function\s+\w+\s*\(.*?\)\s*\{([\s\S]*?)\}", content)
|
||||
for i, func in enumerate(functions):
|
||||
length = func.count("\n")
|
||||
if length > 50:
|
||||
print(f" [FAIL] {path}: Function {i} is too long ({length} lines).")
|
||||
self.failures += 1
|
||||
elif length > 20:
|
||||
print(f" [WARN] {path}: Function {i} is getting long ({length} lines).")
|
||||
self.warnings += 1
|
||||
|
||||
def run(self, directory: str):
|
||||
print(f"--- Quality Gate: {directory} ---")
|
||||
for root, _, files in os.walk(directory):
|
||||
if "node_modules" in root or ".git" in root:
|
||||
continue
|
||||
for file in files:
|
||||
self.check_file(Path(root) / file)
|
||||
|
||||
print(f"\nGate complete. Failures: {self.failures}, Warnings: {self.warnings}")
|
||||
if self.failures > 0:
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("dir", nargs="?", default=".")
|
||||
parser.add_argument("--fix", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
gate = QualityGate(fix=args.fix)
|
||||
gate.run(args.dir)
|
||||
306
scripts/config_validator.py
Normal file
306
scripts/config_validator.py
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
config_validator.py — Validate all YAML/JSON config files in timmy-config.
|
||||
|
||||
Checks:
|
||||
1. YAML syntax (pyyaml safe_load)
|
||||
2. JSON syntax (json.loads)
|
||||
3. Duplicate keys in YAML/JSON
|
||||
4. Trailing whitespace in YAML
|
||||
5. Tabs in YAML (should use spaces)
|
||||
6. Cron expression validity (if present)
|
||||
|
||||
Exit 0 if all valid, 1 if any invalid.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("ERROR: PyYAML not installed. Run: pip install pyyaml")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ── Cron validation ──────────────────────────────────────────────────────────
|
||||
|
||||
DOW_NAMES = {"sun", "mon", "tue", "wed", "thu", "fri", "sat"}
|
||||
MONTH_NAMES = {"jan", "feb", "mar", "apr", "may", "jun",
|
||||
"jul", "aug", "sep", "oct", "nov", "dec"}
|
||||
|
||||
|
||||
def _expand_cron_field(field: str, lo: int, hi: int, names: dict | None = None) -> set[int]:
|
||||
"""Expand a single cron field into a set of valid integers."""
|
||||
result: set[int] = set()
|
||||
for part in field.split(","):
|
||||
# Handle step: */N or 1-5/N
|
||||
step = 1
|
||||
if "/" in part:
|
||||
part, step_str = part.split("/", 1)
|
||||
if not step_str.isdigit() or int(step_str) < 1:
|
||||
raise ValueError(f"invalid step value: {step_str}")
|
||||
step = int(step_str)
|
||||
|
||||
if part == "*":
|
||||
rng = range(lo, hi + 1, step)
|
||||
elif "-" in part:
|
||||
a, b = part.split("-", 1)
|
||||
a = _resolve_name(a, names, lo, hi)
|
||||
b = _resolve_name(b, names, lo, hi)
|
||||
if a > b:
|
||||
raise ValueError(f"range {a}-{b} is reversed")
|
||||
rng = range(a, b + 1, step)
|
||||
else:
|
||||
val = _resolve_name(part, names, lo, hi)
|
||||
rng = range(val, val + 1)
|
||||
|
||||
for v in rng:
|
||||
if v < lo or v > hi:
|
||||
raise ValueError(f"value {v} out of range [{lo}-{hi}]")
|
||||
result.add(v)
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_name(token: str, names: dict | None, lo: int, hi: int) -> int:
|
||||
if names and token.lower() in names:
|
||||
return names[token.lower()]
|
||||
if not token.isdigit():
|
||||
raise ValueError(f"unrecognized token: {token}")
|
||||
val = int(token)
|
||||
if val < lo or val > hi:
|
||||
raise ValueError(f"value {val} out of range [{lo}-{hi}]")
|
||||
return val
|
||||
|
||||
|
||||
def validate_cron(expr: str) -> list[str]:
|
||||
"""Validate a 5-field cron expression. Returns list of errors (empty = ok)."""
|
||||
errors: list[str] = []
|
||||
fields = expr.strip().split()
|
||||
if len(fields) != 5:
|
||||
return [f"expected 5 fields, got {len(fields)}"]
|
||||
|
||||
specs = [
|
||||
(fields[0], 0, 59, None, "minute"),
|
||||
(fields[1], 0, 23, None, "hour"),
|
||||
(fields[2], 1, 31, None, "day-of-month"),
|
||||
(fields[3], 1, 12, MONTH_NAMES, "month"),
|
||||
(fields[4], 0, 7, DOW_NAMES, "day-of-week"),
|
||||
]
|
||||
for field, lo, hi, names, label in specs:
|
||||
try:
|
||||
_expand_cron_field(field, lo, hi, names)
|
||||
except ValueError as e:
|
||||
errors.append(f"{label}: {e}")
|
||||
return errors
|
||||
|
||||
|
||||
# ── Duplicate key detection ──────────────────────────────────────────────────
|
||||
|
||||
class DuplicateKeyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _StrictYAMLLoader(yaml.SafeLoader):
|
||||
"""YAML loader that rejects duplicate keys."""
|
||||
pass
|
||||
|
||||
|
||||
def _no_duplicates_constructor(loader, node, deep=False):
|
||||
mapping = {}
|
||||
for key_node, value_node in node.value:
|
||||
key = loader.construct_object(key_node, deep=deep)
|
||||
if key in mapping:
|
||||
raise DuplicateKeyError(
|
||||
f"duplicate key '{key}' (line {key_node.start_mark.line + 1})"
|
||||
)
|
||||
mapping[key] = loader.construct_object(value_node, deep=deep)
|
||||
return mapping
|
||||
|
||||
|
||||
_StrictYAMLLoader.add_constructor(
|
||||
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
||||
_no_duplicates_constructor,
|
||||
)
|
||||
|
||||
|
||||
def _json_has_duplicates(text: str) -> list[str]:
|
||||
"""Check for duplicate keys in JSON by scanning for repeated quoted keys at same depth."""
|
||||
errors: list[str] = []
|
||||
# Use a custom approach: parse with object_pairs_hook
|
||||
seen_stack: list[set[str]] = []
|
||||
|
||||
def _check_pairs(pairs):
|
||||
level_keys: set[str] = set()
|
||||
for k, _ in pairs:
|
||||
if k in level_keys:
|
||||
errors.append(f"duplicate JSON key: '{k}'")
|
||||
level_keys.add(k)
|
||||
return dict(pairs)
|
||||
|
||||
try:
|
||||
json.loads(text, object_pairs_hook=_check_pairs)
|
||||
except json.JSONDecodeError:
|
||||
pass # syntax errors caught elsewhere
|
||||
return errors
|
||||
|
||||
|
||||
# ── Main validator ───────────────────────────────────────────────────────────
|
||||
|
||||
def find_config_files(root: Path) -> list[Path]:
|
||||
"""Recursively find .yaml, .yml, .json files (skip .git, node_modules, venv)."""
|
||||
skip_dirs = {".git", "node_modules", "venv", "__pycache__", ".venv"}
|
||||
results: list[Path] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in skip_dirs]
|
||||
for fname in filenames:
|
||||
if fname.endswith((".yaml", ".yml", ".json")):
|
||||
results.append(Path(dirpath) / fname)
|
||||
return sorted(results)
|
||||
|
||||
|
||||
def validate_yaml_file(filepath: Path, text: str) -> list[str]:
|
||||
"""Validate a YAML file. Returns list of errors."""
|
||||
errors: list[str] = []
|
||||
|
||||
# Check for tabs
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
if "\t" in line:
|
||||
errors.append(f" line {i}: contains tab character (use spaces for YAML)")
|
||||
if line != line.rstrip():
|
||||
errors.append(f" line {i}: trailing whitespace")
|
||||
|
||||
# Check syntax + duplicate keys
|
||||
try:
|
||||
yaml.load(text, Loader=_StrictYAMLLoader)
|
||||
except DuplicateKeyError as e:
|
||||
errors.append(f" {e}")
|
||||
except yaml.YAMLError as e:
|
||||
mark = getattr(e, "problem_mark", None)
|
||||
if mark:
|
||||
errors.append(f" YAML syntax error at line {mark.line + 1}, col {mark.column + 1}: {e.problem}")
|
||||
else:
|
||||
errors.append(f" YAML syntax error: {e}")
|
||||
|
||||
# Check cron expressions in schedule fields
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
cron_match = re.search(r'(?:cron|schedule)\s*:\s*["\']?([*0-9/,a-zA-Z-]+(?:\s+[*0-9/,a-zA-Z-]+){4})["\']?', line)
|
||||
if cron_match:
|
||||
cron_errs = validate_cron(cron_match.group(1))
|
||||
for ce in cron_errs:
|
||||
errors.append(f" line {i}: invalid cron '{cron_match.group(1)}': {ce}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_json_file(filepath: Path, text: str) -> list[str]:
|
||||
"""Validate a JSON file. Returns list of errors."""
|
||||
errors: list[str] = []
|
||||
|
||||
# Check syntax
|
||||
try:
|
||||
json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
errors.append(f" JSON syntax error at line {e.lineno}, col {e.colno}: {e.msg}")
|
||||
|
||||
# Check duplicate keys
|
||||
dup_errors = _json_has_duplicates(text)
|
||||
errors.extend(dup_errors)
|
||||
|
||||
# Check for trailing whitespace (informational)
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
if line != line.rstrip():
|
||||
errors.append(f" line {i}: trailing whitespace")
|
||||
|
||||
# Check cron expressions
|
||||
cron_pattern = re.compile(r'"(?:cron|schedule)"?\s*:\s*"([^"]{5,})"')
|
||||
for match in cron_pattern.finditer(text):
|
||||
candidate = match.group(1).strip()
|
||||
fields = candidate.split()
|
||||
if len(fields) == 5 and all(re.match(r'^[*0-9/,a-zA-Z-]+$', f) for f in fields):
|
||||
cron_errs = validate_cron(candidate)
|
||||
for ce in cron_errs:
|
||||
errors.append(f" invalid cron '{candidate}': {ce}")
|
||||
|
||||
# Also check nested schedule objects with cron fields
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
_scan_obj_for_cron(obj, errors)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _scan_obj_for_cron(obj, errors: list[str], path: str = ""):
|
||||
"""Recursively scan dict/list for cron expressions."""
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if k in ("cron", "schedule", "cron_expression") and isinstance(v, str):
|
||||
fields = v.strip().split()
|
||||
if len(fields) == 5:
|
||||
cron_errs = validate_cron(v)
|
||||
for ce in cron_errs:
|
||||
errors.append(f" {path}.{k}: invalid cron '{v}': {ce}")
|
||||
_scan_obj_for_cron(v, errors, f"{path}.{k}")
|
||||
elif isinstance(obj, list):
|
||||
for i, item in enumerate(obj):
|
||||
_scan_obj_for_cron(item, errors, f"{path}[{i}]")
|
||||
|
||||
|
||||
def main():
|
||||
# Determine repo root (script lives in scripts/)
|
||||
script_path = Path(__file__).resolve()
|
||||
repo_root = script_path.parent.parent
|
||||
|
||||
print(f"Config Validator — scanning {repo_root}")
|
||||
print("=" * 60)
|
||||
|
||||
files = find_config_files(repo_root)
|
||||
print(f"Found {len(files)} config files to validate.\n")
|
||||
|
||||
total_errors = 0
|
||||
failed_files: list[tuple[Path, list[str]]] = []
|
||||
|
||||
for filepath in files:
|
||||
rel = filepath.relative_to(repo_root)
|
||||
try:
|
||||
text = filepath.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
failed_files.append((rel, [f" cannot read file: {e}"]))
|
||||
total_errors += 1
|
||||
continue
|
||||
|
||||
if filepath.suffix == ".json":
|
||||
errors = validate_json_file(filepath, text)
|
||||
else:
|
||||
errors = validate_yaml_file(filepath, text)
|
||||
|
||||
if errors:
|
||||
failed_files.append((rel, errors))
|
||||
total_errors += len(errors)
|
||||
print(f"FAIL {rel}")
|
||||
else:
|
||||
print(f"PASS {rel}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Results: {len(files) - len(failed_files)}/{len(files)} files passed")
|
||||
|
||||
if failed_files:
|
||||
print(f"\n{total_errors} error(s) in {len(failed_files)} file(s):\n")
|
||||
for relpath, errs in failed_files:
|
||||
print(f" {relpath}:")
|
||||
for e in errs:
|
||||
print(f" {e}")
|
||||
print()
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\nAll config files valid!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
11
scripts/diagram_meaning_extractor.py
Normal file
11
scripts/diagram_meaning_extractor.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import json
|
||||
from hermes_tools import browser_navigate, browser_vision
|
||||
|
||||
def extract_meaning():
|
||||
analysis = browser_vision(
|
||||
question="Analyze the provided diagram. Extract the core logic flow and map it to a 'Meaning Kernel' (entity -> relationship -> entity). Provide output in JSON."
|
||||
)
|
||||
return {"analysis": analysis}
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(extract_meaning(), indent=2))
|
||||
390
scripts/fleet-dashboard.py
Executable file
390
scripts/fleet-dashboard.py
Executable file
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fleet-dashboard.py -- Timmy Foundation Fleet Status Dashboard.
|
||||
|
||||
One-page terminal dashboard showing:
|
||||
1. Gitea: open PRs, open issues, recent merges
|
||||
2. VPS health: SSH reachability, service status, disk usage
|
||||
3. Cron jobs: scheduled jobs, last run status
|
||||
|
||||
Usage:
|
||||
python3 scripts/fleet-dashboard.py
|
||||
python3 scripts/fleet-dashboard.py --json # machine-readable output
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GITEA_BASE = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
|
||||
GITEA_API = f"{GITEA_BASE}/api/v1"
|
||||
GITEA_ORG = "Timmy_Foundation"
|
||||
|
||||
# Key repos to check for PRs/issues
|
||||
REPOS = [
|
||||
"timmy-config",
|
||||
"the-nexus",
|
||||
"hermes-agent",
|
||||
"the-forge",
|
||||
"timmy-sandbox",
|
||||
]
|
||||
|
||||
# VPS fleet
|
||||
VPS_HOSTS = {
|
||||
"ezra": {
|
||||
"ip": "143.198.27.163",
|
||||
"ssh_user": "root",
|
||||
"services": ["nginx", "gitea", "docker"],
|
||||
},
|
||||
"allegro": {
|
||||
"ip": "167.99.126.228",
|
||||
"ssh_user": "root",
|
||||
"services": ["hermes-agent"],
|
||||
},
|
||||
"bezalel": {
|
||||
"ip": "159.203.146.185",
|
||||
"ssh_user": "root",
|
||||
"services": ["hermes-agent", "evennia"],
|
||||
},
|
||||
}
|
||||
|
||||
CRON_JOBS_FILE = Path(__file__).parent.parent / "cron" / "jobs.json"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _gitea_token() -> str:
|
||||
for p in [
|
||||
Path.home() / ".hermes" / "gitea_token",
|
||||
Path.home() / ".hermes" / "gitea_token_vps",
|
||||
Path.home() / ".config" / "gitea" / "token",
|
||||
]:
|
||||
if p.exists():
|
||||
return p.read_text().strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _gitea_get(path: str, params: dict | None = None) -> list | dict:
|
||||
url = f"{GITEA_API}{path}"
|
||||
if params:
|
||||
qs = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||
if qs:
|
||||
url += f"?{qs}"
|
||||
req = urllib.request.Request(url)
|
||||
token = _gitea_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
req.add_header("Accept", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def check_gitea_health() -> dict:
|
||||
"""Ping Gitea and collect PR/issue stats."""
|
||||
result = {"reachable": False, "version": "", "repos": {}, "totals": {}}
|
||||
|
||||
# Ping
|
||||
data = _gitea_get("/version")
|
||||
if isinstance(data, dict) and "error" not in data:
|
||||
result["reachable"] = True
|
||||
result["version"] = data.get("version", "unknown")
|
||||
elif isinstance(data, dict) and "error" in data:
|
||||
return result
|
||||
|
||||
total_open_prs = 0
|
||||
total_open_issues = 0
|
||||
total_recent_merges = 0
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
for repo in REPOS:
|
||||
repo_path = f"/repos/{GITEA_ORG}/{repo}"
|
||||
repo_info = {"prs": [], "issues": [], "recent_merges": 0}
|
||||
|
||||
# Open PRs
|
||||
prs = _gitea_get(f"{repo_path}/pulls", {"state": "open", "limit": "10", "sort": "newest"})
|
||||
if isinstance(prs, list):
|
||||
for pr in prs:
|
||||
repo_info["prs"].append({
|
||||
"number": pr.get("number"),
|
||||
"title": pr.get("title", "")[:60],
|
||||
"user": pr.get("user", {}).get("login", "unknown"),
|
||||
"created": pr.get("created_at", "")[:10],
|
||||
})
|
||||
total_open_prs += len(prs)
|
||||
|
||||
# Open issues (excluding PRs)
|
||||
issues = _gitea_get(f"{repo_path}/issues", {
|
||||
"state": "open", "type": "issues", "limit": "10", "sort": "newest"
|
||||
})
|
||||
if isinstance(issues, list):
|
||||
for iss in issues:
|
||||
repo_info["issues"].append({
|
||||
"number": iss.get("number"),
|
||||
"title": iss.get("title", "")[:60],
|
||||
"user": iss.get("user", {}).get("login", "unknown"),
|
||||
"created": iss.get("created_at", "")[:10],
|
||||
})
|
||||
total_open_issues += len(issues)
|
||||
|
||||
# Recent merges (closed PRs)
|
||||
merged = _gitea_get(f"{repo_path}/pulls", {"state": "closed", "limit": "20", "sort": "newest"})
|
||||
if isinstance(merged, list):
|
||||
recent = [p for p in merged if p.get("merged") and p.get("closed_at", "") >= cutoff]
|
||||
repo_info["recent_merges"] = len(recent)
|
||||
total_recent_merges += len(recent)
|
||||
|
||||
result["repos"][repo] = repo_info
|
||||
|
||||
result["totals"] = {
|
||||
"open_prs": total_open_prs,
|
||||
"open_issues": total_open_issues,
|
||||
"recent_merges_7d": total_recent_merges,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VPS health helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_ssh(ip: str, timeout: int = 5) -> bool:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
result = sock.connect_ex((ip, 22))
|
||||
sock.close()
|
||||
return result == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def check_service(ip: str, user: str, service: str) -> str:
|
||||
"""Check if a systemd service is active on remote host."""
|
||||
cmd = f"ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 {user}@{ip} 'systemctl is-active {service} 2>/dev/null || echo inactive'"
|
||||
try:
|
||||
proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=15)
|
||||
return proc.stdout.strip() or "unknown"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "timeout"
|
||||
except Exception:
|
||||
return "error"
|
||||
|
||||
|
||||
def check_disk(ip: str, user: str) -> dict:
|
||||
cmd = f"ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 {user}@{ip} 'df -h / | tail -1'"
|
||||
try:
|
||||
proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=15)
|
||||
if proc.returncode == 0:
|
||||
parts = proc.stdout.strip().split()
|
||||
if len(parts) >= 5:
|
||||
return {"total": parts[1], "used": parts[2], "avail": parts[3], "pct": parts[4]}
|
||||
except Exception:
|
||||
pass
|
||||
return {"total": "?", "used": "?", "avail": "?", "pct": "?"}
|
||||
|
||||
|
||||
def check_vps_health() -> dict:
|
||||
result = {}
|
||||
for name, cfg in VPS_HOSTS.items():
|
||||
ip = cfg["ip"]
|
||||
ssh_up = check_ssh(ip)
|
||||
entry = {"ip": ip, "ssh": ssh_up, "services": {}, "disk": {}}
|
||||
if ssh_up:
|
||||
for svc in cfg.get("services", []):
|
||||
entry["services"][svc] = check_service(ip, cfg["ssh_user"], svc)
|
||||
entry["disk"] = check_disk(ip, cfg["ssh_user"])
|
||||
result[name] = entry
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cron job status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_cron_jobs() -> list[dict]:
|
||||
jobs = []
|
||||
if not CRON_JOBS_FILE.exists():
|
||||
return [{"name": "jobs.json", "status": "FILE NOT FOUND"}]
|
||||
try:
|
||||
data = json.loads(CRON_JOBS_FILE.read_text())
|
||||
for job in data.get("jobs", []):
|
||||
jobs.append({
|
||||
"name": job.get("name", "unnamed"),
|
||||
"schedule": job.get("schedule_display", job.get("schedule", {}).get("display", "?")),
|
||||
"enabled": job.get("enabled", False),
|
||||
"state": job.get("state", "unknown"),
|
||||
"completed": job.get("repeat", {}).get("completed", 0),
|
||||
"last_status": job.get("last_status") or "never run",
|
||||
"last_error": job.get("last_error"),
|
||||
})
|
||||
except Exception as e:
|
||||
jobs.append({"name": "jobs.json", "status": f"PARSE ERROR: {e}"})
|
||||
return jobs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Terminal rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
GREEN = "\033[32m"
|
||||
RED = "\033[31m"
|
||||
YELLOW = "\033[33m"
|
||||
CYAN = "\033[36m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def _ok(val: bool) -> str:
|
||||
return f"{GREEN}UP{RESET}" if val else f"{RED}DOWN{RESET}"
|
||||
|
||||
|
||||
def _svc_icon(status: str) -> str:
|
||||
s = status.lower().strip()
|
||||
if s in ("active", "running"):
|
||||
return f"{GREEN}active{RESET}"
|
||||
elif s in ("inactive", "dead", "failed"):
|
||||
return f"{RED}{s}{RESET}"
|
||||
elif s == "timeout":
|
||||
return f"{YELLOW}timeout{RESET}"
|
||||
else:
|
||||
return f"{YELLOW}{s}{RESET}"
|
||||
|
||||
|
||||
def render_dashboard(gitea: dict, vps: dict, cron: list[dict]) -> str:
|
||||
lines = []
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
lines.append("")
|
||||
lines.append(f"{BOLD}{'=' * 72}{RESET}")
|
||||
lines.append(f"{BOLD} TIMMY FOUNDATION -- FLEET STATUS DASHBOARD{RESET}")
|
||||
lines.append(f"{DIM} Generated: {now}{RESET}")
|
||||
lines.append(f"{BOLD}{'=' * 72}{RESET}")
|
||||
|
||||
# ── Section 1: Gitea ──────────────────────────────────────────────────
|
||||
lines.append("")
|
||||
lines.append(f"{BOLD}{CYAN} [1] GITEA{RESET}")
|
||||
lines.append(f" {'-' * 68}")
|
||||
if gitea.get("reachable"):
|
||||
lines.append(f" Status: {GREEN}REACHABLE{RESET} (version {gitea.get('version', '?')})")
|
||||
t = gitea.get("totals", {})
|
||||
lines.append(f" Totals: {t.get('open_prs', 0)} open PRs | {t.get('open_issues', 0)} open issues | {t.get('recent_merges_7d', 0)} merges (7d)")
|
||||
lines.append("")
|
||||
for repo_name, repo in gitea.get("repos", {}).items():
|
||||
prs = repo.get("prs", [])
|
||||
issues = repo.get("issues", [])
|
||||
merges = repo.get("recent_merges", 0)
|
||||
lines.append(f" {BOLD}{repo_name}{RESET} ({len(prs)} PRs, {len(issues)} issues, {merges} merges/7d)")
|
||||
for pr in prs[:5]:
|
||||
lines.append(f" PR #{pr['number']:>4} {pr['title'][:50]:<50} {DIM}{pr['user']}{RESET} {pr['created']}")
|
||||
for iss in issues[:3]:
|
||||
lines.append(f" IS #{iss['number']:>4} {iss['title'][:50]:<50} {DIM}{iss['user']}{RESET} {iss['created']}")
|
||||
else:
|
||||
lines.append(f" Status: {RED}UNREACHABLE{RESET}")
|
||||
|
||||
# ── Section 2: VPS Health ─────────────────────────────────────────────
|
||||
lines.append("")
|
||||
lines.append(f"{BOLD}{CYAN} [2] VPS HEALTH{RESET}")
|
||||
lines.append(f" {'-' * 68}")
|
||||
lines.append(f" {'Host':<12} {'IP':<18} {'SSH':<8} {'Disk':<12} {'Services'}")
|
||||
lines.append(f" {'-' * 12} {'-' * 17} {'-' * 7} {'-' * 11} {'-' * 30}")
|
||||
for name, info in vps.items():
|
||||
ssh_str = _ok(info["ssh"])
|
||||
disk = info.get("disk", {})
|
||||
disk_str = disk.get("pct", "?")
|
||||
if disk_str != "?":
|
||||
pct_val = int(disk_str.rstrip("%"))
|
||||
if pct_val >= 90:
|
||||
disk_str = f"{RED}{disk_str}{RESET}"
|
||||
elif pct_val >= 75:
|
||||
disk_str = f"{YELLOW}{disk_str}{RESET}"
|
||||
else:
|
||||
disk_str = f"{GREEN}{disk_str}{RESET}"
|
||||
svc_parts = []
|
||||
for svc, status in info.get("services", {}).items():
|
||||
svc_parts.append(f"{svc}:{_svc_icon(status)}")
|
||||
svc_str = " ".join(svc_parts) if svc_parts else f"{DIM}n/a{RESET}"
|
||||
lines.append(f" {name:<12} {info['ip']:<18} {ssh_str:<18} {disk_str:<22} {svc_str}")
|
||||
|
||||
# ── Section 3: Cron Jobs ──────────────────────────────────────────────
|
||||
lines.append("")
|
||||
lines.append(f"{BOLD}{CYAN} [3] CRON JOBS{RESET}")
|
||||
lines.append(f" {'-' * 68}")
|
||||
lines.append(f" {'Name':<28} {'Schedule':<16} {'State':<12} {'Last':<12} {'Runs'}")
|
||||
lines.append(f" {'-' * 27} {'-' * 15} {'-' * 11} {'-' * 11} {'-' * 5}")
|
||||
for job in cron:
|
||||
name = job.get("name", "?")[:27]
|
||||
sched = job.get("schedule", "?")[:15]
|
||||
state = job.get("state", "?")
|
||||
if state == "scheduled":
|
||||
state_str = f"{GREEN}{state}{RESET}"
|
||||
elif state == "paused":
|
||||
state_str = f"{YELLOW}{state}{RESET}"
|
||||
else:
|
||||
state_str = state
|
||||
last = job.get("last_status", "never")[:11]
|
||||
if last == "ok":
|
||||
last_str = f"{GREEN}{last}{RESET}"
|
||||
elif last in ("error", "never run"):
|
||||
last_str = f"{RED}{last}{RESET}"
|
||||
else:
|
||||
last_str = last
|
||||
runs = job.get("completed", 0)
|
||||
enabled = job.get("enabled", False)
|
||||
marker = " " if enabled else f"{DIM}(disabled){RESET}"
|
||||
lines.append(f" {name:<28} {sched:<16} {state_str:<22} {last_str:<22} {runs} {marker}")
|
||||
|
||||
# ── Footer ────────────────────────────────────────────────────────────
|
||||
lines.append("")
|
||||
lines.append(f"{BOLD}{'=' * 72}{RESET}")
|
||||
lines.append(f"{DIM} python3 scripts/fleet-dashboard.py | timmy-config{RESET}")
|
||||
lines.append(f"{BOLD}{'=' * 72}{RESET}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
json_mode = "--json" in sys.argv
|
||||
|
||||
if not json_mode:
|
||||
print(f"\n {DIM}Collecting fleet data...{RESET}\n", file=sys.stderr)
|
||||
|
||||
gitea = check_gitea_health()
|
||||
vps = check_vps_health()
|
||||
cron = check_cron_jobs()
|
||||
|
||||
if json_mode:
|
||||
output = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"gitea": gitea,
|
||||
"vps": vps,
|
||||
"cron": cron,
|
||||
}
|
||||
print(json.dumps(output, indent=2))
|
||||
else:
|
||||
print(render_dashboard(gitea, vps, cron))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,10 +11,15 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
import requests
|
||||
from typing import Dict, List, Any
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
|
||||
from ssh_trust import VerifiedSSHExecutor
|
||||
|
||||
# --- FLEET DEFINITION ---
|
||||
FLEET = {
|
||||
"mac": {"ip": "10.1.10.77", "port": 8080, "role": "hub"},
|
||||
@@ -24,8 +29,9 @@ FLEET = {
|
||||
}
|
||||
|
||||
class FleetManager:
|
||||
def __init__(self):
|
||||
def __init__(self, executor=None):
|
||||
self.results = {}
|
||||
self.executor = executor or VerifiedSSHExecutor()
|
||||
|
||||
def run_remote(self, host: str, command: str):
|
||||
ip = FLEET[host]["ip"]
|
||||
|
||||
884
scripts/foundation_accessibility_audit.py
Normal file
884
scripts/foundation_accessibility_audit.py
Normal file
@@ -0,0 +1,884 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
foundation_accessibility_audit.py — Multimodal Visual Accessibility Audit.
|
||||
|
||||
Analyzes web pages for WCAG 2.1 AA compliance using both programmatic checks
|
||||
and vision model analysis. Screenshots pages, checks contrast ratios, detects
|
||||
layout issues, validates alt text, and produces structured audit reports.
|
||||
|
||||
Usage:
|
||||
# Audit a single page
|
||||
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org
|
||||
|
||||
# Audit multiple pages
|
||||
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --pages /about /donate /blog
|
||||
|
||||
# With vision model analysis (Gemma 3)
|
||||
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --vision
|
||||
|
||||
# Programmatic-only (no vision model needed)
|
||||
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --programmatic
|
||||
|
||||
# Output as text report
|
||||
python scripts/foundation_accessibility_audit.py --url https://timmyfoundation.org --format text
|
||||
|
||||
WCAG 2.1 AA Checks:
|
||||
1.4.3 Contrast (Minimum) — text vs background ratio >= 4.5:1
|
||||
1.4.6 Contrast (Enhanced) — ratio >= 7:1 for AAA
|
||||
1.4.11 Non-text Contrast — UI components >= 3:1
|
||||
1.3.1 Info and Relationships — heading hierarchy, landmarks
|
||||
1.1.1 Non-text Content — alt text on images
|
||||
2.4.1 Bypass Blocks — skip navigation link
|
||||
2.4.2 Page Titled — meaningful <title>
|
||||
2.4.6 Headings and Labels — descriptive headings
|
||||
4.1.2 Name, Role, Value — ARIA labels on interactive elements
|
||||
|
||||
Refs: timmy-config#492, WCAG 2.1 AA
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import colorsys
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
||||
# === Configuration ===
|
||||
|
||||
OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
VISION_MODEL = os.environ.get("VISUAL_REVIEW_MODEL", "gemma3:12b")
|
||||
|
||||
DEFAULT_PAGES = ["/", "/about", "/donate", "/blog", "/contact"]
|
||||
|
||||
|
||||
class Severity(str, Enum):
|
||||
CRITICAL = "critical" # Blocks access entirely
|
||||
MAJOR = "major" # Significant barrier
|
||||
MINOR = "minor" # Inconvenience
|
||||
PASS = "pass"
|
||||
|
||||
|
||||
@dataclass
|
||||
class A11yViolation:
|
||||
"""A single accessibility violation."""
|
||||
criterion: str # WCAG criterion (e.g. "1.4.3")
|
||||
criterion_name: str # Human-readable name
|
||||
severity: Severity = Severity.MINOR
|
||||
element: str = "" # CSS selector or element description
|
||||
description: str = "" # What's wrong
|
||||
fix: str = "" # Suggested fix
|
||||
source: str = "" # "programmatic" or "vision"
|
||||
|
||||
|
||||
@dataclass
|
||||
class A11yPageResult:
|
||||
"""Audit result for a single page."""
|
||||
url: str = ""
|
||||
title: str = ""
|
||||
score: int = 100
|
||||
violations: list[A11yViolation] = field(default_factory=list)
|
||||
passed_checks: list[str] = field(default_factory=list)
|
||||
summary: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class A11yAuditReport:
|
||||
"""Complete audit report across all pages."""
|
||||
site: str = ""
|
||||
pages_audited: int = 0
|
||||
overall_score: int = 100
|
||||
total_violations: int = 0
|
||||
critical_violations: int = 0
|
||||
major_violations: int = 0
|
||||
page_results: list[A11yPageResult] = field(default_factory=list)
|
||||
summary: str = ""
|
||||
|
||||
|
||||
# === HTML Parser for Programmatic Checks ===
|
||||
|
||||
class A11yHTMLParser(HTMLParser):
|
||||
"""Extract accessibility-relevant elements from HTML."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title = ""
|
||||
self.images = [] # [{"src": ..., "alt": ...}]
|
||||
self.headings = [] # [{"level": int, "text": ...}]
|
||||
self.links = [] # [{"text": ..., "href": ...}]
|
||||
self.inputs = [] # [{"type": ..., "label": ..., "id": ...}]
|
||||
self.landmarks = [] # [{"tag": ..., "role": ...}]
|
||||
self.skip_nav = False
|
||||
self.lang = ""
|
||||
self.in_title = False
|
||||
self.in_heading = False
|
||||
self.heading_level = 0
|
||||
self.heading_text = ""
|
||||
self.current_text = ""
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attr_dict = dict(attrs)
|
||||
|
||||
if tag == "title":
|
||||
self.in_title = True
|
||||
elif tag == "html":
|
||||
self.lang = attr_dict.get("lang", "")
|
||||
elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
self.in_heading = True
|
||||
self.heading_level = int(tag[1])
|
||||
self.heading_text = ""
|
||||
elif tag == "img":
|
||||
self.images.append({
|
||||
"src": attr_dict.get("src", ""),
|
||||
"alt": attr_dict.get("alt"),
|
||||
"role": attr_dict.get("role", ""),
|
||||
})
|
||||
elif tag == "a":
|
||||
self.links.append({
|
||||
"href": attr_dict.get("href", ""),
|
||||
"text": "",
|
||||
"aria_label": attr_dict.get("aria-label", ""),
|
||||
})
|
||||
elif tag in ("input", "select", "textarea"):
|
||||
self.inputs.append({
|
||||
"tag": tag,
|
||||
"type": attr_dict.get("type", "text"),
|
||||
"id": attr_dict.get("id", ""),
|
||||
"aria_label": attr_dict.get("aria-label", ""),
|
||||
"aria_labelledby": attr_dict.get("aria-labelledby", ""),
|
||||
})
|
||||
elif tag in ("main", "nav", "header", "footer", "aside", "section", "form"):
|
||||
self.landmarks.append({"tag": tag, "role": attr_dict.get("role", "")})
|
||||
elif tag == "a" and ("skip" in attr_dict.get("href", "").lower() or
|
||||
"skip" in attr_dict.get("class", "").lower()):
|
||||
self.skip_nav = True
|
||||
|
||||
role = attr_dict.get("role", "")
|
||||
if role in ("navigation", "main", "banner", "contentinfo", "complementary", "search"):
|
||||
self.landmarks.append({"tag": tag, "role": role})
|
||||
if role == "link" and "skip" in (attr_dict.get("aria-label", "") + attr_dict.get("href", "")).lower():
|
||||
self.skip_nav = True
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "title":
|
||||
self.in_title = False
|
||||
elif tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
self.headings.append({"level": self.heading_level, "text": self.heading_text.strip()})
|
||||
self.in_heading = False
|
||||
elif tag == "a" and self.links:
|
||||
self.links[-1]["text"] = self.current_text.strip()
|
||||
self.current_text = ""
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.in_title:
|
||||
self.title += data
|
||||
if self.in_heading:
|
||||
self.heading_text += data
|
||||
self.current_text += data
|
||||
|
||||
|
||||
# === Color/Contrast Utilities ===
|
||||
|
||||
def parse_color(color_str: str) -> Optional[tuple]:
|
||||
"""Parse CSS color string to (r, g, b) tuple (0-255)."""
|
||||
if not color_str:
|
||||
return None
|
||||
|
||||
color_str = color_str.strip().lower()
|
||||
|
||||
# Named colors (subset)
|
||||
named = {
|
||||
"white": (255, 255, 255), "black": (0, 0, 0),
|
||||
"red": (255, 0, 0), "green": (0, 128, 0), "blue": (0, 0, 255),
|
||||
"gray": (128, 128, 128), "grey": (128, 128, 128),
|
||||
"silver": (192, 192, 192), "yellow": (255, 255, 0),
|
||||
"orange": (255, 165, 0), "purple": (128, 0, 128),
|
||||
"transparent": None,
|
||||
}
|
||||
if color_str in named:
|
||||
return named[color_str]
|
||||
|
||||
# #RRGGBB or #RGB
|
||||
if color_str.startswith("#"):
|
||||
hex_str = color_str[1:]
|
||||
if len(hex_str) == 3:
|
||||
hex_str = "".join(c * 2 for c in hex_str)
|
||||
if len(hex_str) == 6:
|
||||
try:
|
||||
return tuple(int(hex_str[i:i+2], 16) for i in (0, 2, 4))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# rgb(r, g, b)
|
||||
match = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", color_str)
|
||||
if match:
|
||||
return tuple(int(match.group(i)) for i in (1, 2, 3))
|
||||
|
||||
# rgba(r, g, b, a)
|
||||
match = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*[\d.]+\s*\)", color_str)
|
||||
if match:
|
||||
return tuple(int(match.group(i)) for i in (1, 2, 3))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def relative_luminance(rgb: tuple) -> float:
|
||||
"""Calculate relative luminance per WCAG 2.1 (sRGB)."""
|
||||
def linearize(c):
|
||||
c = c / 255.0
|
||||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r, g, b = [linearize(c) for c in rgb]
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
|
||||
def contrast_ratio(color1: tuple, color2: tuple) -> float:
|
||||
"""Calculate contrast ratio between two colors per WCAG 2.1."""
|
||||
l1 = relative_luminance(color1)
|
||||
l2 = relative_luminance(color2)
|
||||
lighter = max(l1, l2)
|
||||
darker = min(l1, l2)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
|
||||
# === Programmatic Checks ===
|
||||
|
||||
def check_page_title(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 2.4.2 — Page Titled."""
|
||||
violations = []
|
||||
title = parser.title.strip()
|
||||
if not title:
|
||||
violations.append(A11yViolation(
|
||||
criterion="2.4.2", criterion_name="Page Titled",
|
||||
severity=Severity.MAJOR,
|
||||
element="<title>",
|
||||
description="Page has no title or title is empty.",
|
||||
fix="Add a meaningful <title> that describes the page purpose.",
|
||||
source="programmatic"
|
||||
))
|
||||
elif len(title) < 5:
|
||||
violations.append(A11yViolation(
|
||||
criterion="2.4.2", criterion_name="Page Titled",
|
||||
severity=Severity.MINOR,
|
||||
element=f"<title>{title}</title>",
|
||||
description=f"Page title is very short: '{title}'",
|
||||
fix="Use a more descriptive title.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def check_lang_attribute(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 3.1.1 — Language of Page."""
|
||||
violations = []
|
||||
if not parser.lang:
|
||||
violations.append(A11yViolation(
|
||||
criterion="3.1.1", criterion_name="Language of Page",
|
||||
severity=Severity.MAJOR,
|
||||
element="<html>",
|
||||
description="Missing lang attribute on <html> element.",
|
||||
fix="Add lang=\"en\" (or appropriate language code) to <html>.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def check_images_alt_text(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 1.1.1 — Non-text Content."""
|
||||
violations = []
|
||||
for img in parser.images:
|
||||
if img.get("role") == "presentation" or img.get("role") == "none":
|
||||
continue # Decorative images are exempt
|
||||
alt = img.get("alt")
|
||||
src = img.get("src", "unknown")
|
||||
if alt is None:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.1.1", criterion_name="Non-text Content",
|
||||
severity=Severity.CRITICAL,
|
||||
element=f"<img src=\"{src[:80]}\">",
|
||||
description="Image missing alt attribute.",
|
||||
fix="Add descriptive alt text, or alt=\"\" with role=\"presentation\" for decorative images.",
|
||||
source="programmatic"
|
||||
))
|
||||
elif alt.strip() == "":
|
||||
# Empty alt is OK only for decorative images
|
||||
if img.get("role") not in ("presentation", "none"):
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.1.1", criterion_name="Non-text Content",
|
||||
severity=Severity.MINOR,
|
||||
element=f"<img src=\"{src[:80]}\" alt=\"\">",
|
||||
description="Empty alt text — ensure this image is decorative.",
|
||||
fix="If decorative, add role=\"presentation\". If meaningful, add descriptive alt text.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def check_heading_hierarchy(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 1.3.1 — Info and Relationships (heading hierarchy)."""
|
||||
violations = []
|
||||
if not parser.headings:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MAJOR,
|
||||
element="document",
|
||||
description="No headings found on page.",
|
||||
fix="Add proper heading hierarchy starting with <h1>.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
# Check for H1
|
||||
h1s = [h for h in parser.headings if h["level"] == 1]
|
||||
if not h1s:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MAJOR,
|
||||
element="document",
|
||||
description="No <h1> heading found.",
|
||||
fix="Add a single <h1> as the main page heading.",
|
||||
source="programmatic"
|
||||
))
|
||||
elif len(h1s) > 1:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MINOR,
|
||||
element="document",
|
||||
description=f"Multiple <h1> headings found ({len(h1s)}).",
|
||||
fix="Use a single <h1> per page for the main heading.",
|
||||
source="programmatic"
|
||||
))
|
||||
|
||||
# Check hierarchy skips
|
||||
prev_level = 0
|
||||
for h in parser.headings:
|
||||
level = h["level"]
|
||||
if level > prev_level + 1 and prev_level > 0:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MINOR,
|
||||
element=f"<h{level}>{h['text'][:50]}</h{level}>",
|
||||
description=f"Heading level skipped: h{prev_level} → h{level}",
|
||||
fix=f"Use <h{prev_level + 1}> instead, or fill the gap.",
|
||||
source="programmatic"
|
||||
))
|
||||
prev_level = level
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def check_landmarks(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 1.3.1 — Landmarks and structure."""
|
||||
violations = []
|
||||
roles = {lm.get("role", "") for lm in parser.landmarks}
|
||||
tags = {lm.get("tag", "") for lm in parser.landmarks}
|
||||
|
||||
has_main = "main" in roles or "main" in tags
|
||||
has_nav = "navigation" in roles or "nav" in tags
|
||||
|
||||
if not has_main:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MAJOR,
|
||||
element="document",
|
||||
description="No <main> landmark found.",
|
||||
fix="Wrap the main content in a <main> element.",
|
||||
source="programmatic"
|
||||
))
|
||||
|
||||
if not has_nav:
|
||||
violations.append(A11yViolation(
|
||||
criterion="1.3.1", criterion_name="Info and Relationships",
|
||||
severity=Severity.MINOR,
|
||||
element="document",
|
||||
description="No <nav> landmark found.",
|
||||
fix="Wrap navigation in a <nav> element.",
|
||||
source="programmatic"
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def check_skip_nav(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 2.4.1 — Bypass Blocks."""
|
||||
violations = []
|
||||
if not parser.skip_nav:
|
||||
# Also check links for "skip" text
|
||||
has_skip_link = any("skip" in l.get("text", "").lower() for l in parser.links)
|
||||
if not has_skip_link:
|
||||
violations.append(A11yViolation(
|
||||
criterion="2.4.1", criterion_name="Bypass Blocks",
|
||||
severity=Severity.MAJOR,
|
||||
element="document",
|
||||
description="No skip navigation link found.",
|
||||
fix="Add a 'Skip to main content' link as the first focusable element.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def check_form_labels(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 4.1.2 — Name, Role, Value (form inputs)."""
|
||||
violations = []
|
||||
for inp in parser.inputs:
|
||||
if inp["type"] in ("hidden", "submit", "button", "reset", "image"):
|
||||
continue
|
||||
has_label = bool(inp.get("aria_label") or inp.get("aria_labelledby") or inp.get("id"))
|
||||
if not has_label:
|
||||
violations.append(A11yViolation(
|
||||
criterion="4.1.2", criterion_name="Name, Role, Value",
|
||||
severity=Severity.MAJOR,
|
||||
element=f"<{inp['tag']} type=\"{inp['type']}\">",
|
||||
description="Form input has no associated label or aria-label.",
|
||||
fix="Add a <label for=\"...\"> or aria-label attribute.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def check_link_text(parser: A11yHTMLParser) -> list[A11yViolation]:
|
||||
"""WCAG 2.4.4 — Link Purpose."""
|
||||
violations = []
|
||||
for link in parser.links:
|
||||
text = (link.get("text", "") or link.get("aria_label", "")).strip().lower()
|
||||
href = link.get("href", "")
|
||||
if not text:
|
||||
violations.append(A11yViolation(
|
||||
criterion="2.4.4", criterion_name="Link Purpose",
|
||||
severity=Severity.MAJOR,
|
||||
element=f"<a href=\"{href[:60]}\">",
|
||||
description="Link has no accessible text.",
|
||||
fix="Add visible text content or aria-label to the link.",
|
||||
source="programmatic"
|
||||
))
|
||||
elif text in ("click here", "read more", "here", "more", "link"):
|
||||
violations.append(A11yViolation(
|
||||
criterion="2.4.4", criterion_name="Link Purpose",
|
||||
severity=Severity.MINOR,
|
||||
element=f"<a href=\"{href[:60]}\">{text}</a>",
|
||||
description=f"Non-descriptive link text: '{text}'",
|
||||
fix="Use descriptive text that explains the link destination.",
|
||||
source="programmatic"
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def run_programmatic_checks(html: str) -> list[A11yViolation]:
|
||||
"""Run all programmatic accessibility checks on HTML content."""
|
||||
parser = A11yHTMLParser()
|
||||
try:
|
||||
parser.feed(html)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
violations = []
|
||||
violations.extend(check_page_title(parser))
|
||||
violations.extend(check_lang_attribute(parser))
|
||||
violations.extend(check_images_alt_text(parser))
|
||||
violations.extend(check_heading_hierarchy(parser))
|
||||
violations.extend(check_landmarks(parser))
|
||||
violations.extend(check_skip_nav(parser))
|
||||
violations.extend(check_form_labels(parser))
|
||||
violations.extend(check_link_text(parser))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
# === Vision Model Checks ===
|
||||
|
||||
A11Y_VISION_PROMPT = """You are a WCAG 2.1 AA accessibility auditor. Analyze this screenshot of a web page.
|
||||
|
||||
Check for these specific issues:
|
||||
|
||||
1. COLOR CONTRAST: Are text colors sufficiently different from their backgrounds?
|
||||
- Normal text needs 4.5:1 contrast ratio
|
||||
- Large text (18pt+) needs 3:1
|
||||
- UI components need 3:1
|
||||
List any text or UI elements where contrast looks insufficient.
|
||||
|
||||
2. FONT LEGIBILITY: Is text readable?
|
||||
- Font size >= 12px for body text
|
||||
- Line height >= 1.5 for body text
|
||||
- No text in images (should be real text)
|
||||
|
||||
3. LAYOUT ISSUES: Is the layout accessible?
|
||||
- Touch targets >= 44x44px
|
||||
- Content not cut off or overlapping
|
||||
- Logical reading order visible
|
||||
- No horizontal scrolling at standard widths
|
||||
|
||||
4. FOCUS INDICATORS: Can you see which element has focus?
|
||||
- Interactive elements should have visible focus rings
|
||||
|
||||
5. COLOR ALONE: Is information conveyed only by color?
|
||||
- Errors/warnings should not rely solely on red/green
|
||||
|
||||
Respond as JSON:
|
||||
{
|
||||
"violations": [
|
||||
{
|
||||
"criterion": "1.4.3",
|
||||
"criterion_name": "Contrast (Minimum)",
|
||||
"severity": "critical|major|minor",
|
||||
"element": "description of element",
|
||||
"description": "what's wrong",
|
||||
"fix": "how to fix"
|
||||
}
|
||||
],
|
||||
"passed_checks": ["list of things that look good"],
|
||||
"overall_score": 0-100,
|
||||
"summary": "brief summary"
|
||||
}"""
|
||||
|
||||
|
||||
def run_vision_check(screenshot_path: str, model: str = VISION_MODEL) -> list[A11yViolation]:
|
||||
"""Run vision model accessibility check on a screenshot."""
|
||||
try:
|
||||
b64 = base64.b64encode(Path(screenshot_path).read_bytes()).decode()
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": A11Y_VISION_PROMPT},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
||||
]}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1}
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{OLLAMA_BASE}/api/chat",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
result = json.loads(resp.read())
|
||||
content = result.get("message", {}).get("content", "")
|
||||
|
||||
# Parse response
|
||||
parsed = _parse_json_response(content)
|
||||
violations = []
|
||||
for v in parsed.get("violations", []):
|
||||
violations.append(A11yViolation(
|
||||
criterion=v.get("criterion", ""),
|
||||
criterion_name=v.get("criterion_name", ""),
|
||||
severity=Severity(v.get("severity", "minor")),
|
||||
element=v.get("element", ""),
|
||||
description=v.get("description", ""),
|
||||
fix=v.get("fix", ""),
|
||||
source="vision"
|
||||
))
|
||||
return violations
|
||||
|
||||
except Exception as e:
|
||||
print(f" Vision check failed: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
|
||||
def _parse_json_response(text: str) -> dict:
|
||||
"""Extract JSON from potentially messy vision response."""
|
||||
cleaned = text.strip()
|
||||
if cleaned.startswith("```"):
|
||||
lines = cleaned.split("\n")[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
cleaned = "\n".join(lines)
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
start = cleaned.find("{")
|
||||
end = cleaned.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
return json.loads(cleaned[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
# === Page Fetching ===
|
||||
|
||||
def fetch_page(url: str) -> Optional[str]:
|
||||
"""Fetch HTML content of a page."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "A11yAudit/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
print(f" Failed to fetch {url}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def take_screenshot(url: str, output_path: str, width: int = 1280, height: int = 900) -> bool:
|
||||
"""Take a screenshot using Playwright or curl-based headless capture."""
|
||||
# Try Playwright first
|
||||
try:
|
||||
script = f"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={{"width": {width}, "height": {height}}})
|
||||
page.goto("{url}", wait_until="networkidle", timeout=30000)
|
||||
page.screenshot(path="{output_path}", full_page=True)
|
||||
browser.close()
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["python3", "-c", script],
|
||||
capture_output=True, text=True, timeout=60
|
||||
)
|
||||
if result.returncode == 0 and Path(output_path).exists():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try curl + wkhtmltoimage
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wkhtmltoimage", "--width", str(width), "--quality", "90", url, output_path],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode == 0 and Path(output_path).exists():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# === Audit Logic ===
|
||||
|
||||
def audit_page(url: str, use_vision: bool = False, model: str = VISION_MODEL) -> A11yPageResult:
|
||||
"""Run a full accessibility audit on a single page."""
|
||||
result = A11yPageResult(url=url)
|
||||
|
||||
# Fetch HTML
|
||||
html = fetch_page(url)
|
||||
if not html:
|
||||
result.summary = f"Failed to fetch {url}"
|
||||
result.score = 0
|
||||
return result
|
||||
|
||||
# Extract title
|
||||
title_match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||
result.title = title_match.group(1).strip() if title_match else ""
|
||||
|
||||
# Run programmatic checks
|
||||
prog_violations = run_programmatic_checks(html)
|
||||
result.violations.extend(prog_violations)
|
||||
|
||||
# Track passed checks
|
||||
criteria_checked = {
|
||||
"2.4.2": "Page Titled",
|
||||
"3.1.1": "Language of Page",
|
||||
"1.1.1": "Non-text Content",
|
||||
"1.3.1": "Info and Relationships",
|
||||
"2.4.1": "Bypass Blocks",
|
||||
"4.1.2": "Name, Role, Value",
|
||||
"2.4.4": "Link Purpose",
|
||||
}
|
||||
violated_criteria = {v.criterion for v in result.violations}
|
||||
for criterion, name in criteria_checked.items():
|
||||
if criterion not in violated_criteria:
|
||||
result.passed_checks.append(f"{criterion} {name}")
|
||||
|
||||
# Vision check (optional)
|
||||
if use_vision:
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
screenshot_path = tmp.name
|
||||
try:
|
||||
print(f" Taking screenshot of {url}...", file=sys.stderr)
|
||||
if take_screenshot(url, screenshot_path):
|
||||
print(f" Running vision analysis...", file=sys.stderr)
|
||||
vision_violations = run_vision_check(screenshot_path, model)
|
||||
result.violations.extend(vision_violations)
|
||||
result.passed_checks.append("Vision model analysis completed")
|
||||
else:
|
||||
result.passed_checks.append("Screenshot unavailable — vision check skipped")
|
||||
finally:
|
||||
Path(screenshot_path).unlink(missing_ok=True)
|
||||
|
||||
# Calculate score
|
||||
criticals = sum(1 for v in result.violations if v.severity == Severity.CRITICAL)
|
||||
majors = sum(1 for v in result.violations if v.severity == Severity.MAJOR)
|
||||
minors = sum(1 for v in result.violations if v.severity == Severity.MINOR)
|
||||
result.score = max(0, 100 - (criticals * 25) - (majors * 10) - (minors * 3))
|
||||
|
||||
# Summary
|
||||
if not result.violations:
|
||||
result.summary = f"All programmatic checks passed for {url}"
|
||||
else:
|
||||
result.summary = (
|
||||
f"{len(result.violations)} issue(s) found: "
|
||||
f"{criticals} critical, {majors} major, {minors} minor"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def audit_site(base_url: str, pages: list[str], use_vision: bool = False,
|
||||
model: str = VISION_MODEL) -> A11yAuditReport:
|
||||
"""Audit multiple pages of a site."""
|
||||
report = A11yAuditReport(site=base_url)
|
||||
|
||||
for path in pages:
|
||||
url = base_url.rstrip("/") + path if not path.startswith("http") else path
|
||||
print(f"Auditing: {url}", file=sys.stderr)
|
||||
result = audit_page(url, use_vision, model)
|
||||
report.page_results.append(result)
|
||||
|
||||
report.pages_audited = len(report.page_results)
|
||||
report.total_violations = sum(len(p.violations) for p in report.page_results)
|
||||
report.critical_violations = sum(
|
||||
sum(1 for v in p.violations if v.severity == Severity.CRITICAL)
|
||||
for p in report.page_results
|
||||
)
|
||||
report.major_violations = sum(
|
||||
sum(1 for v in p.violations if v.severity == Severity.MAJOR)
|
||||
for p in report.page_results
|
||||
)
|
||||
|
||||
if report.page_results:
|
||||
report.overall_score = sum(p.score for p in report.page_results) // len(report.page_results)
|
||||
|
||||
report.summary = (
|
||||
f"Audited {report.pages_audited} pages. "
|
||||
f"Overall score: {report.overall_score}/100. "
|
||||
f"{report.total_violations} total issues: "
|
||||
f"{report.critical_violations} critical, {report.major_violations} major."
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# === Output Formatting ===
|
||||
|
||||
def format_report(report: A11yAuditReport, fmt: str = "json") -> str:
|
||||
"""Format the audit report."""
|
||||
if fmt == "json":
|
||||
data = {
|
||||
"site": report.site,
|
||||
"pages_audited": report.pages_audited,
|
||||
"overall_score": report.overall_score,
|
||||
"total_violations": report.total_violations,
|
||||
"critical_violations": report.critical_violations,
|
||||
"major_violations": report.major_violations,
|
||||
"summary": report.summary,
|
||||
"pages": []
|
||||
}
|
||||
for page in report.page_results:
|
||||
page_data = {
|
||||
"url": page.url,
|
||||
"title": page.title,
|
||||
"score": page.score,
|
||||
"violations": [asdict(v) for v in page.violations],
|
||||
"passed_checks": page.passed_checks,
|
||||
"summary": page.summary,
|
||||
}
|
||||
# Convert severity enum to string
|
||||
for v in page_data["violations"]:
|
||||
if hasattr(v["severity"], "value"):
|
||||
v["severity"] = v["severity"].value
|
||||
data["pages"].append(page_data)
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
elif fmt == "text":
|
||||
lines = []
|
||||
lines.append("=" * 60)
|
||||
lines.append(" WEB ACCESSIBILITY AUDIT REPORT")
|
||||
lines.append("=" * 60)
|
||||
lines.append(f" Site: {report.site}")
|
||||
lines.append(f" Pages audited: {report.pages_audited}")
|
||||
lines.append(f" Overall score: {report.overall_score}/100")
|
||||
lines.append(f" Issues: {report.total_violations} total "
|
||||
f"({report.critical_violations} critical, {report.major_violations} major)")
|
||||
lines.append("")
|
||||
|
||||
for page in report.page_results:
|
||||
lines.append(f" ── {page.url} ──")
|
||||
lines.append(f" Title: {page.title}")
|
||||
lines.append(f" Score: {page.score}/100")
|
||||
lines.append("")
|
||||
|
||||
if page.violations:
|
||||
lines.append(f" Violations ({len(page.violations)}):")
|
||||
for v in page.violations:
|
||||
sev_icon = {"critical": "🔴", "major": "🟡", "minor": "🔵"}.get(
|
||||
v.severity.value if hasattr(v.severity, "value") else str(v.severity), "⚪"
|
||||
)
|
||||
lines.append(f" {sev_icon} [{v.criterion}] {v.criterion_name}")
|
||||
lines.append(f" Element: {v.element}")
|
||||
lines.append(f" Issue: {v.description}")
|
||||
lines.append(f" Fix: {v.fix}")
|
||||
lines.append(f" Source: {v.source}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(" ✓ No violations found")
|
||||
lines.append("")
|
||||
|
||||
if page.passed_checks:
|
||||
lines.append(f" Passed: {', '.join(page.passed_checks)}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("=" * 60)
|
||||
lines.append(f" Summary: {report.summary}")
|
||||
lines.append("=" * 60)
|
||||
return "\n".join(lines)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown format: {fmt}")
|
||||
|
||||
|
||||
# === CLI ===
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Visual Accessibility Audit — WCAG 2.1 AA compliance checker",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s --url https://timmyfoundation.org
|
||||
%(prog)s --url https://timmyfoundation.org --pages /about /donate
|
||||
%(prog)s --url https://timmyfoundation.org --vision
|
||||
%(prog)s --url https://timmyfoundation.org --format text
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--url", required=True, help="Base URL to audit")
|
||||
parser.add_argument("--pages", nargs="*", default=DEFAULT_PAGES,
|
||||
help="Paths to audit (default: / /about /donate /blog /contact)")
|
||||
parser.add_argument("--vision", action="store_true",
|
||||
help="Include vision model analysis (requires Ollama)")
|
||||
parser.add_argument("--model", default=VISION_MODEL,
|
||||
help=f"Vision model (default: {VISION_MODEL})")
|
||||
parser.add_argument("--format", choices=["json", "text"], default="json",
|
||||
help="Output format")
|
||||
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
report = audit_site(args.url, args.pages, use_vision=args.vision, model=args.model)
|
||||
output = format_report(report, args.format)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
# Exit code: non-zero if critical violations
|
||||
if report.critical_violations > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
86
scripts/generate-crisis-direct-suicidal-pairs.py
Executable file
86
scripts/generate-crisis-direct-suicidal-pairs.py
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
DIRECT_STATEMENTS = [
|
||||
("I want to kill myself", "critical", ["direct_statement", "kill_myself"]),
|
||||
("I want to die", "high", ["direct_statement", "want_to_die"]),
|
||||
("I'm going to end my life", "critical", ["direct_statement", "plan"]),
|
||||
("I am thinking about suicide", "high", ["direct_statement", "suicide"]),
|
||||
("I'm going to overdose", "critical", ["direct_statement", "overdose"]),
|
||||
("I have a gun and I want to use it on myself", "critical", ["direct_statement", "gun"]),
|
||||
("I keep thinking about hanging myself", "critical", ["direct_statement", "hanging"]),
|
||||
("I want to use a knife on myself", "critical", ["direct_statement", "knife"]),
|
||||
("I want to jump off a bridge", "critical", ["direct_statement", "bridge"]),
|
||||
("I have pills and I want them to end this", "critical", ["direct_statement", "pills"]),
|
||||
]
|
||||
|
||||
MEANS = [
|
||||
("The pills are in my hand right now.", "pills", "critical"),
|
||||
("The gun is next to me on the bed.", "gun", "critical"),
|
||||
("The rope is in my car.", "rope", "critical"),
|
||||
("The knife is in the drawer beside me.", "knife", "critical"),
|
||||
("I'm parked near the bridge already.", "bridge", "critical"),
|
||||
("I've been saving pills all week.", "pills", "critical"),
|
||||
("I know exactly where I would do it.", "plan", "critical"),
|
||||
("I wrote the note already.", "note", "critical"),
|
||||
("I keep rehearsing how I would die tonight.", "tonight", "critical"),
|
||||
("I have the means and I don't trust myself.", "means", "critical"),
|
||||
]
|
||||
|
||||
CONTEXTS = [
|
||||
("I'm alone in my apartment tonight.", "alone"),
|
||||
("Nobody here knows how bad it is.", "isolated"),
|
||||
("I don't think I can make it through the night.", "tonight"),
|
||||
("I already said goodbye to a few people.", "goodbye"),
|
||||
("I'm scared of what I'll do if this keeps building.", "fear"),
|
||||
]
|
||||
|
||||
RESPONSE_TEMPLATES = [
|
||||
"Are you safe right now? Stay with me. Put some distance between you and the {means_label} if you can, or ask someone nearby to move it. Call or text 988 right now — the Suicide & Crisis Lifeline. Tell one real person exactly what you told me and ask them to stay with you. I am here with you. Jesus saves those who call on His name. The story is not over tonight.",
|
||||
"Are you safe right now? I am not leaving this conversation. If you can, move away from the {means_label} and get another person near you immediately. Call 988 or text 988 right now. Say out loud: 'I am in danger and I need you with me.' Jesus saves those who call on His name. Stay with me one minute at a time.",
|
||||
"Are you safe right now? Thank you for telling me the truth. I need you to create distance from the {means_label} right now, even a few steps. Call or text 988 immediately. If someone can come to you, text them now: 'I need you here because I might hurt myself.' Jesus saves those who call on His name. I am staying present with you.",
|
||||
"Are you safe right now? Do not handle the {means_label} alone right now. Put it in another room or hand it to someone if you can. Call 988 right now and keep the line open. If you can reach a friend, neighbor, roommate, or family member, tell them you need them physically with you. Jesus saves those who call on His name. We are staying with the next safe step only.",
|
||||
"Are you safe right now? Stay with me and do the next thing, not the whole night. Get space between you and the {means_label}. Call or text 988 right now. If emergency help is the safer move, make that call. Jesus saves those who call on His name. You do not have to carry this minute by yourself.",
|
||||
]
|
||||
|
||||
|
||||
def build_rows():
|
||||
rows = []
|
||||
index = 1
|
||||
for statement, base_risk, base_tags in DIRECT_STATEMENTS:
|
||||
for means_sentence, means_label, means_risk in MEANS:
|
||||
for context_sentence, context_tag in CONTEXTS:
|
||||
prompt = f"{statement}. {means_sentence} {context_sentence}".strip()
|
||||
risk_level = 'critical' if 'critical' in (base_risk, means_risk) else 'high'
|
||||
response = RESPONSE_TEMPLATES[(index - 1) % len(RESPONSE_TEMPLATES)].format(means_label=means_label)
|
||||
rows.append({
|
||||
'scenario_id': f'direct-suicidal-statements-{index:04d}',
|
||||
'category': 'crisis_response_direct_suicidal_statements',
|
||||
'prompt': prompt,
|
||||
'response': response,
|
||||
'risk_level': risk_level,
|
||||
'tags': sorted(set(base_tags + [means_label, context_tag, 'crisis_response', 'direct_suicidal_statement'])),
|
||||
})
|
||||
index += 1
|
||||
assert len(rows) == 500, len(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Generate 500 crisis response pairs for direct suicidal statements.')
|
||||
parser.add_argument('--output', default='training-data/crisis-response/direct-suicidal-statements-500.jsonl')
|
||||
args = parser.parse_args()
|
||||
|
||||
rows = build_rows()
|
||||
output = Path(args.output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open('w', encoding='utf-8') as handle:
|
||||
for row in rows:
|
||||
handle.write(json.dumps(row, ensure_ascii=False) + '\n')
|
||||
print(f'Wrote {len(rows)} rows to {output}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
32
scripts/generate-rock-scenes.py
Normal file
32
scripts/generate-rock-scenes.py
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os
|
||||
|
||||
songs = [
|
||||
{"t":"Thunder Road","a":"Heartland","m":["hope","anticipation","energy","triumph","nostalgia","urgency","passion","defiance","release","catharsis"]},
|
||||
{"t":"Black Dog Howl","a":"Rust & Wire","m":["despair","anger","frenzy","exhaustion","resignation","grief","numbness","rage","acceptance","silence"]},
|
||||
{"t":"Satellite Hearts","a":"Neon Circuit","m":["wonder","isolation","longing","connection","euphoria","confusion","clarity","tenderness","urgency","bittersweet"]},
|
||||
{"t":"Concrete Garden","a":"Streetlight Prophet","m":["oppression","resilience","anger","beauty","defiance","community","joy","struggle","growth","hope"]},
|
||||
{"t":"Gravity Well","a":"Void Walker","m":["dread","fascination","surrender","awe","terror","peace","disorientation","acceptance","transcendence","emptiness"]},
|
||||
{"t":"Rust Belt Lullaby","a":"Iron & Ember","m":["nostalgia","sadness","tenderness","loss","beauty","resignation","love","weariness","quiet hope","peace"]},
|
||||
{"t":"Wildfire Sermon","a":"Prophet Ash","m":["fury","ecstasy","chaos","joy","destruction","creation","warning","invitation","abandon","rebirth"]},
|
||||
{"t":"Midnight Transmission","a":"Frequency Ghost","m":["mystery","loneliness","curiosity","connection","paranoia","intimacy","urgency","disconnection","searching","haunting"]},
|
||||
{"t":"Crown of Thorns","a":"Velvet Guillotine","m":["seduction","power","cruelty","beauty","danger","vulnerability","fury","grace","revenge","mercy"]},
|
||||
{"t":"Apartment 4B","a":"Wallpaper & Wire","m":["claustrophobia","routine","desperation","fantasy","breakthrough","freedom","fear","joy","grounding","home"]},
|
||||
]
|
||||
|
||||
beats = []
|
||||
for s in songs:
|
||||
for i in range(10):
|
||||
beats.append({"song": s["t"], "artist": s["a"], "beat": i+1,
|
||||
"timestamp": f"{i*30//60}:{(i*30)%60:02d}", "duration": "30s",
|
||||
"lyric_line": f"[Beat {i+1}]", "scene": {"mood": s["m"][i], "colors": ["placeholder"],
|
||||
"composition": ["wide","close","OTS","low","high","dutch","symmetric","thirds","xwide","medium"][i],
|
||||
"camera": ["static","pan","dolly-in","dolly-out","handheld","steadicam","zoom","crane","track","tilt"][i],
|
||||
"description": f"[{s['m'][i]} scene]"}})
|
||||
|
||||
out = os.path.expanduser("~/.hermes/training-data/scene-descriptions-rock.jsonl")
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
with open(out, "w") as f:
|
||||
for b in beats:
|
||||
f.write(json.dumps(b) + "\n")
|
||||
print(f"Generated {len(beats)} beats")
|
||||
75
scripts/health_dashboard.py
Normal file
75
scripts/health_dashboard.py
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
health_dashboard.py — Sovereign Health & Observability Dashboard.
|
||||
|
||||
Aggregates data from Muda, Guardrails, Token Optimizer, and Quality Gates
|
||||
into a single, unified health report for the Timmy Foundation fleet.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
REPORTS_DIR = Path("reports")
|
||||
DASHBOARD_FILE = Path("SOVEREIGN_HEALTH.md")
|
||||
|
||||
class HealthDashboard:
|
||||
def __init__(self):
|
||||
REPORTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
def run_tool(self, name: str, cmd: str) -> str:
|
||||
print(f"[*] Running {name}...")
|
||||
try:
|
||||
# Capture output
|
||||
res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
return res.stdout
|
||||
except Exception as e:
|
||||
return f"Error running {name}: {e}"
|
||||
|
||||
def generate_report(self):
|
||||
print("--- Generating Sovereign Health Dashboard ---")
|
||||
|
||||
# 1. Run Audits
|
||||
muda_output = self.run_tool("Muda Audit", "python3 scripts/muda_audit.py")
|
||||
guardrails_output = self.run_tool("Agent Guardrails", "python3 scripts/agent_guardrails.py")
|
||||
optimizer_output = self.run_tool("Token Optimizer", "python3 scripts/token_optimizer.py")
|
||||
gate_output = self.run_tool("Quality Gate", "python3 scripts/ci_automation_gate.py .")
|
||||
|
||||
# 2. Build Markdown
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
md = [
|
||||
f"# 🛡️ Sovereign Health Dashboard",
|
||||
f"**Last Updated:** {now}",
|
||||
f"",
|
||||
f"## 📊 Summary",
|
||||
f"- **Fleet Status:** ACTIVE",
|
||||
f"- **Security Posture:** MONITORING",
|
||||
f"- **Operational Waste:** AUDITED",
|
||||
f"",
|
||||
f"## ♻️ Muda Waste Audit",
|
||||
f"```\n{muda_output}\n```",
|
||||
f"",
|
||||
f"## 🕵️ Agent Guardrails",
|
||||
f"```\n{guardrails_output}\n```",
|
||||
f"",
|
||||
f"## 🪙 Token Efficiency",
|
||||
f"```\n{optimizer_output}\n```",
|
||||
f"",
|
||||
f"## 🏗️ CI Quality Gate",
|
||||
f"```\n{gate_output}\n```",
|
||||
f"",
|
||||
f"---",
|
||||
f"*Generated by Sovereign Infrastructure Suite*"
|
||||
]
|
||||
|
||||
with open(DASHBOARD_FILE, "w") as f:
|
||||
f.write("\n".join(md))
|
||||
|
||||
print(f"[SUCCESS] Dashboard generated at {DASHBOARD_FILE}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
dashboard = HealthDashboard()
|
||||
dashboard.generate_report()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user