Compare commits
84 Commits
perplexity
...
perplexity
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | ||
|
|
b18fc76868 | ||
| 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 .
|
||||
24
.gitea/workflows/smoke.yml
Normal file
24
.gitea/workflows/smoke.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
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; then exit 1; fi
|
||||
echo "PASS: No secrets"
|
||||
@@ -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
|
||||
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -10,3 +10,27 @@ __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
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# Banned Providers
|
||||
|
||||
This document is a hard policy. It is not advisory. It is not aspirational.
|
||||
Any agent, wizard, or automated process that violates this policy is broken
|
||||
and must be fixed immediately.
|
||||
|
||||
## Permanently Banned
|
||||
|
||||
### Anthropic (Claude)
|
||||
|
||||
**Status:** BANNED — April 2026
|
||||
**Scope:** All configs, fallback chains, playbooks, wizard bootstraps, and fleet scripts.
|
||||
**Enforcement:** Pre-commit hook, architecture linter, sovereignty enforcement tests.
|
||||
|
||||
No Anthropic model (Claude Opus, Sonnet, Haiku, or any variant) may appear as:
|
||||
- A primary provider
|
||||
- A fallback provider
|
||||
- An OpenRouter model slug (e.g. `anthropic/claude-*`)
|
||||
- An API endpoint (api.anthropic.com)
|
||||
- A required dependency (`anthropic` pip package)
|
||||
- An environment variable (`ANTHROPIC_API_KEY`, `ANTHROPIC_TOKEN`)
|
||||
|
||||
### What to use instead
|
||||
|
||||
| Was | Now |
|
||||
|-----|-----|
|
||||
| claude-opus-4-6 | kimi-k2.5 |
|
||||
| claude-sonnet-4-20250514 | kimi-k2.5 |
|
||||
| claude-haiku | google/gemini-2.5-pro |
|
||||
| anthropic (provider) | kimi-coding |
|
||||
| anthropic/claude-* (OpenRouter) | google/gemini-2.5-pro |
|
||||
| ANTHROPIC_API_KEY | KIMI_API_KEY |
|
||||
|
||||
### Exceptions
|
||||
|
||||
The following files may reference Anthropic for **historical or defensive** purposes:
|
||||
|
||||
- `training/` — Training data must not be altered
|
||||
- `evaluations/` — Historical benchmark results
|
||||
- `RELEASE_*.md` — Changelogs
|
||||
- `metrics_helpers.py` — Historical cost calculation
|
||||
- `pre-commit.py` — Detects leaked Anthropic keys (defensive)
|
||||
- `secret-scan.yml` — Detects leaked Anthropic keys (defensive)
|
||||
- `architecture_linter.py` — Warns/blocks Anthropic usage (enforcement)
|
||||
- `test_sovereignty_enforcement.py` — Tests that Anthropic is blocked (enforcement)
|
||||
|
||||
### Golden State
|
||||
|
||||
```yaml
|
||||
fallback_providers:
|
||||
- provider: kimi-coding
|
||||
model: kimi-k2.5
|
||||
reason: Primary
|
||||
- provider: openrouter
|
||||
model: google/gemini-2.5-pro
|
||||
reason: Cloud fallback
|
||||
- provider: ollama
|
||||
model: gemma4:latest
|
||||
base_url: http://localhost:11434/v1
|
||||
reason: Terminal fallback — never phones home
|
||||
```
|
||||
|
||||
*Sovereignty and service always.*
|
||||
@@ -51,11 +51,11 @@ Alexander is pleased with the state. This tag marks a high-water mark.
|
||||
| OAI-Wolf-3 | 8683 | hermes gateway | ACTIVE |
|
||||
|
||||
- Disk: 12G/926G (4%) — pristine
|
||||
- Primary model: kimi-k2.5 via Kimi
|
||||
- Primary model: claude-opus-4-6 via Anthropic
|
||||
- Fallback chain: codex → kimi-k2.5 → gemini-2.5-flash → llama-3.3-70b → grok-3-mini-fast → kimi → grok → kimi → gpt-4.1-mini
|
||||
- Ollama models: gemma4:latest (9.6GB), hermes4:14b (9.0GB)
|
||||
- Worktrees: 239 (9.8GB) — prune candidates exist
|
||||
- Running loops: 3 gemini-loops, orchestrator, status watcher
|
||||
- Running loops: 3 claude-loops, 3 gemini-loops, orchestrator, status watcher
|
||||
- LaunchD: hermes gateway running, fenrir stopped, kimi-heartbeat idle
|
||||
- MCP: morrowind server active
|
||||
|
||||
|
||||
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 }}
|
||||
70
ansible/roles/deadman_switch/tasks/main.yml
Normal file
70
ansible/roles/deadman_switch/tasks/main.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# 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
|
||||
|
||||
handlers:
|
||||
- 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
|
||||
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()
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Architecture Linter — Sovereignty Enforcement
|
||||
Scans the codebase for banned providers, models, and API keys.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
BANNED_STRINGS = [
|
||||
r'anthropic',
|
||||
r'claude',
|
||||
r'api\.anthropic\.com',
|
||||
r'ANTHROPIC_API_KEY',
|
||||
r'claude-opus',
|
||||
r'claude-sonnet',
|
||||
r'claude-haiku'
|
||||
]
|
||||
|
||||
EXCEPTIONS = [
|
||||
'BANNED_PROVIDERS.md',
|
||||
'architecture_linter.py',
|
||||
'training/',
|
||||
'evaluations/',
|
||||
'RELEASE_',
|
||||
'metrics_helpers.py'
|
||||
]
|
||||
|
||||
def is_exception(path):
|
||||
for exc in EXCEPTIONS:
|
||||
if exc in path:
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_file(path):
|
||||
violations = []
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
for pattern in BANNED_STRINGS:
|
||||
if re.search(pattern, line, re.IGNORECASE):
|
||||
violations.append((i, line.strip(), pattern))
|
||||
except Exception as e:
|
||||
print(f"Error reading {path}: {e}")
|
||||
return violations
|
||||
|
||||
def main():
|
||||
print("--- Sovereignty Enforcement: Architecture Linter ---")
|
||||
total_violations = 0
|
||||
|
||||
for root, dirs, files in os.walk('.'):
|
||||
# Skip .git
|
||||
if '.git' in dirs:
|
||||
dirs.remove('.git')
|
||||
|
||||
for file in files:
|
||||
path = os.path.join(root, file)
|
||||
if is_exception(path):
|
||||
continue
|
||||
|
||||
violations = check_file(path)
|
||||
if violations:
|
||||
print(f"\n[VIOLATION] {path}:")
|
||||
for line_num, content, pattern in violations:
|
||||
print(f" Line {line_num}: Found '{pattern}' -> {content}")
|
||||
total_violations += 1
|
||||
|
||||
if total_violations > 0:
|
||||
print(f"\nFAILED: Found {total_violations} sovereignty violations.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\nPASSED: No banned providers detected.")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,7 +2,7 @@
|
||||
# agent-loop.sh — Universal agent dev loop with Genchi Genbutsu verification
|
||||
#
|
||||
# Usage: agent-loop.sh <agent-name> [num-workers]
|
||||
# agent-loop.sh kimi 2
|
||||
# agent-loop.sh claude 2
|
||||
# agent-loop.sh gemini 1
|
||||
#
|
||||
# Dispatches via agent-dispatch.sh, then verifies with genchi-genbutsu.sh.
|
||||
@@ -14,7 +14,7 @@ NUM_WORKERS="${2:-1}"
|
||||
|
||||
# Resolve agent tool and model from config or fallback
|
||||
case "$AGENT" in
|
||||
# claude case removed — Anthropic purged from fleet
|
||||
claude) TOOL="claude"; MODEL="sonnet" ;;
|
||||
gemini) TOOL="gemini"; MODEL="gemini-2.5-pro-preview-05-06" ;;
|
||||
grok) TOOL="opencode"; MODEL="grok-3-fast" ;;
|
||||
*) TOOL="$AGENT"; MODEL="" ;;
|
||||
@@ -145,8 +145,8 @@ run_worker() {
|
||||
|
||||
CYCLE_START=$(date +%s)
|
||||
set +e
|
||||
if [ "$TOOL" = "kimi" ]; then
|
||||
# Claude dispatch removed — Anthropic purged
|
||||
if [ "$TOOL" = "claude" ]; then
|
||||
env -u CLAUDECODE gtimeout "$TIMEOUT" claude \
|
||||
--print --model "$MODEL" --dangerously-skip-permissions \
|
||||
-p "$prompt" </dev/null >> "$LOG_DIR/${AGENT}-${issue_num}.log" 2>&1
|
||||
elif [ "$TOOL" = "gemini" ]; then
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# DEPRECATED — Anthropic purged from fleet (April 2026)
|
||||
# This script dispatched parallel Claude Code agent loops.
|
||||
# All wizard providers now use Kimi K2.5 as primary.
|
||||
# See bin/gemini-loop.sh for the surviving loop pattern.
|
||||
echo "[DEPRECATED] claude-loop.sh is no longer active. Use gemini-loop.sh or agent-loop.sh with kimi provider."
|
||||
exit 0
|
||||
|
||||
# --- ORIGINAL SCRIPT PRESERVED BELOW FOR REFERENCE ---
|
||||
#!/usr/bin/env bash
|
||||
# claude-loop.sh — Parallel Claude Code agent dispatch loop
|
||||
# Runs N workers concurrently against the Gitea backlog.
|
||||
# Gracefully handles rate limits with backoff.
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# DEPRECATED — Anthropic purged from fleet (April 2026)
|
||||
# This watchdog kept Claude/Gemini loops alive.
|
||||
# Only gemini loops survive. Use fleet-status.sh for monitoring.
|
||||
echo "[DEPRECATED] claudemax-watchdog.sh is no longer active."
|
||||
exit 0
|
||||
|
||||
# --- ORIGINAL SCRIPT PRESERVED BELOW FOR REFERENCE ---
|
||||
#!/usr/bin/env bash
|
||||
# claudemax-watchdog.sh — keep local Claude/Gemini loops alive without stale tmux assumptions
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
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())
|
||||
264
bin/deadman-fallback.py
Normal file
264
bin/deadman-fallback.py
Normal file
@@ -0,0 +1,264 @@
|
||||
1|#!/usr/bin/env python3
|
||||
2|"""
|
||||
3|Dead Man Switch Fallback Engine
|
||||
4|
|
||||
5|When the dead man switch triggers (zero commits for 2+ hours, model down,
|
||||
6|Gitea unreachable, etc.), this script diagnoses the failure and applies
|
||||
7|common sense fallbacks automatically.
|
||||
8|
|
||||
9|Fallback chain:
|
||||
10|1. Primary model (Anthropic) down -> switch config to local-llama.cpp
|
||||
11|2. Gitea unreachable -> cache issues locally, retry on recovery
|
||||
12|3. VPS agents down -> alert + lazarus protocol
|
||||
13|4. Local llama.cpp down -> try Ollama, then alert-only mode
|
||||
14|5. All inference dead -> safe mode (cron pauses, alert Alexander)
|
||||
15|
|
||||
16|Each fallback is reversible. Recovery auto-restores the previous config.
|
||||
17|"""
|
||||
18|import os
|
||||
19|import sys
|
||||
20|import json
|
||||
21|import subprocess
|
||||
22|import time
|
||||
23|import yaml
|
||||
24|import shutil
|
||||
25|from pathlib import Path
|
||||
26|from datetime import datetime, timedelta
|
||||
27|
|
||||
28|HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
||||
29|CONFIG_PATH = HERMES_HOME / "config.yaml"
|
||||
30|FALLBACK_STATE = HERMES_HOME / "deadman-fallback-state.json"
|
||||
31|BACKUP_CONFIG = HERMES_HOME / "config.yaml.pre-fallback"
|
||||
32|FORGE_URL = "https://forge.alexanderwhitestone.com"
|
||||
33|
|
||||
34|def load_config():
|
||||
35| with open(CONFIG_PATH) as f:
|
||||
36| return yaml.safe_load(f)
|
||||
37|
|
||||
38|def save_config(cfg):
|
||||
39| with open(CONFIG_PATH, "w") as f:
|
||||
40| yaml.dump(cfg, f, default_flow_style=False)
|
||||
41|
|
||||
42|def load_state():
|
||||
43| if FALLBACK_STATE.exists():
|
||||
44| with open(FALLBACK_STATE) as f:
|
||||
45| return json.load(f)
|
||||
46| return {"active_fallbacks": [], "last_check": None, "recovery_pending": False}
|
||||
47|
|
||||
48|def save_state(state):
|
||||
49| state["last_check"] = datetime.now().isoformat()
|
||||
50| with open(FALLBACK_STATE, "w") as f:
|
||||
51| json.dump(state, f, indent=2)
|
||||
52|
|
||||
53|def run(cmd, timeout=10):
|
||||
54| try:
|
||||
55| r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||
56| return r.returncode, r.stdout.strip(), r.stderr.strip()
|
||||
57| except subprocess.TimeoutExpired:
|
||||
58| return -1, "", "timeout"
|
||||
59| except Exception as e:
|
||||
60| return -1, "", str(e)
|
||||
61|
|
||||
62|# ─── HEALTH CHECKS ───
|
||||
63|
|
||||
64|def check_anthropic():
|
||||
65| """Can we reach Anthropic API?"""
|
||||
66| key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
67| if not key:
|
||||
68| # Check multiple .env locations
|
||||
69| for env_path in [HERMES_HOME / ".env", Path.home() / ".hermes" / ".env"]:
|
||||
70| if env_path.exists():
|
||||
71| for line in open(env_path):
|
||||
72| line = line.strip()
|
||||
73| if line.startswith("ANTHROPIC_API_KEY=***
|
||||
74| key = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
75| break
|
||||
76| if key:
|
||||
77| break
|
||||
78| if not key:
|
||||
79| return False, "no API key"
|
||||
80| code, out, err = run(
|
||||
81| f'curl -s -o /dev/null -w "%{{http_code}}" -H "x-api-key: {key}" '
|
||||
82| f'-H "anthropic-version: 2023-06-01" '
|
||||
83| f'https://api.anthropic.com/v1/messages -X POST '
|
||||
84| f'-H "content-type: application/json" '
|
||||
85| f'-d \'{{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{{"role":"user","content":"ping"}}]}}\' ',
|
||||
86| timeout=15
|
||||
87| )
|
||||
88| if code == 0 and out in ("200", "429"):
|
||||
89| return True, f"HTTP {out}"
|
||||
90| return False, f"HTTP {out} err={err[:80]}"
|
||||
91|
|
||||
92|def check_local_llama():
|
||||
93| """Is local llama.cpp serving?"""
|
||||
94| code, out, err = run("curl -s http://localhost:8081/v1/models", timeout=5)
|
||||
95| if code == 0 and "hermes" in out.lower():
|
||||
96| return True, "serving"
|
||||
97| return False, f"exit={code}"
|
||||
98|
|
||||
99|def check_ollama():
|
||||
100| """Is Ollama running?"""
|
||||
101| code, out, err = run("curl -s http://localhost:11434/api/tags", timeout=5)
|
||||
102| if code == 0 and "models" in out:
|
||||
103| return True, "running"
|
||||
104| return False, f"exit={code}"
|
||||
105|
|
||||
106|def check_gitea():
|
||||
107| """Can we reach the Forge?"""
|
||||
108| token_path = Path.home() / ".config" / "gitea" / "timmy-token"
|
||||
109| if not token_path.exists():
|
||||
110| return False, "no token"
|
||||
111| token = token_path.read_text().strip()
|
||||
112| code, out, err = run(
|
||||
113| f'curl -s -o /dev/null -w "%{{http_code}}" -H "Authorization: token {token}" '
|
||||
114| f'"{FORGE_URL}/api/v1/user"',
|
||||
115| timeout=10
|
||||
116| )
|
||||
117| if code == 0 and out == "200":
|
||||
118| return True, "reachable"
|
||||
119| return False, f"HTTP {out}"
|
||||
120|
|
||||
121|def check_vps(ip, name):
|
||||
122| """Can we SSH into a VPS?"""
|
||||
123| code, out, err = run(f"ssh -o ConnectTimeout=5 root@{ip} 'echo alive'", timeout=10)
|
||||
124| if code == 0 and "alive" in out:
|
||||
125| return True, "alive"
|
||||
126| return False, f"unreachable"
|
||||
127|
|
||||
128|# ─── FALLBACK ACTIONS ───
|
||||
129|
|
||||
130|def fallback_to_local_model(cfg):
|
||||
131| """Switch primary model from Anthropic to local llama.cpp"""
|
||||
132| if not BACKUP_CONFIG.exists():
|
||||
133| shutil.copy2(CONFIG_PATH, BACKUP_CONFIG)
|
||||
134|
|
||||
135| cfg["model"]["provider"] = "local-llama.cpp"
|
||||
136| cfg["model"]["default"] = "hermes3"
|
||||
137| save_config(cfg)
|
||||
138| return "Switched primary model to local-llama.cpp/hermes3"
|
||||
139|
|
||||
140|def fallback_to_ollama(cfg):
|
||||
141| """Switch to Ollama if llama.cpp is also down"""
|
||||
142| if not BACKUP_CONFIG.exists():
|
||||
143| shutil.copy2(CONFIG_PATH, BACKUP_CONFIG)
|
||||
144|
|
||||
145| cfg["model"]["provider"] = "ollama"
|
||||
146| cfg["model"]["default"] = "gemma4:latest"
|
||||
147| save_config(cfg)
|
||||
148| return "Switched primary model to ollama/gemma4:latest"
|
||||
149|
|
||||
150|def enter_safe_mode(state):
|
||||
151| """Pause all non-essential cron jobs, alert Alexander"""
|
||||
152| state["safe_mode"] = True
|
||||
153| state["safe_mode_entered"] = datetime.now().isoformat()
|
||||
154| save_state(state)
|
||||
155| return "SAFE MODE: All inference down. Cron jobs should be paused. Alert Alexander."
|
||||
156|
|
||||
157|def restore_config():
|
||||
158| """Restore pre-fallback config when primary recovers"""
|
||||
159| if BACKUP_CONFIG.exists():
|
||||
160| shutil.copy2(BACKUP_CONFIG, CONFIG_PATH)
|
||||
161| BACKUP_CONFIG.unlink()
|
||||
162| return "Restored original config from backup"
|
||||
163| return "No backup config to restore"
|
||||
164|
|
||||
165|# ─── MAIN DIAGNOSIS AND FALLBACK ENGINE ───
|
||||
166|
|
||||
167|def diagnose_and_fallback():
|
||||
168| state = load_state()
|
||||
169| cfg = load_config()
|
||||
170|
|
||||
171| results = {
|
||||
172| "timestamp": datetime.now().isoformat(),
|
||||
173| "checks": {},
|
||||
174| "actions": [],
|
||||
175| "status": "healthy"
|
||||
176| }
|
||||
177|
|
||||
178| # Check all systems
|
||||
179| anthropic_ok, anthropic_msg = check_anthropic()
|
||||
180| results["checks"]["anthropic"] = {"ok": anthropic_ok, "msg": anthropic_msg}
|
||||
181|
|
||||
182| llama_ok, llama_msg = check_local_llama()
|
||||
183| results["checks"]["local_llama"] = {"ok": llama_ok, "msg": llama_msg}
|
||||
184|
|
||||
185| ollama_ok, ollama_msg = check_ollama()
|
||||
186| results["checks"]["ollama"] = {"ok": ollama_ok, "msg": ollama_msg}
|
||||
187|
|
||||
188| gitea_ok, gitea_msg = check_gitea()
|
||||
189| results["checks"]["gitea"] = {"ok": gitea_ok, "msg": gitea_msg}
|
||||
190|
|
||||
191| # VPS checks
|
||||
192| vpses = [
|
||||
193| ("167.99.126.228", "Allegro"),
|
||||
194| ("143.198.27.163", "Ezra"),
|
||||
195| ("159.203.146.185", "Bezalel"),
|
||||
196| ]
|
||||
197| for ip, name in vpses:
|
||||
198| vps_ok, vps_msg = check_vps(ip, name)
|
||||
199| results["checks"][f"vps_{name.lower()}"] = {"ok": vps_ok, "msg": vps_msg}
|
||||
200|
|
||||
201| current_provider = cfg.get("model", {}).get("provider", "anthropic")
|
||||
202|
|
||||
203| # ─── FALLBACK LOGIC ───
|
||||
204|
|
||||
205| # Case 1: Primary (Anthropic) down, local available
|
||||
206| if not anthropic_ok and current_provider == "anthropic":
|
||||
207| if llama_ok:
|
||||
208| msg = fallback_to_local_model(cfg)
|
||||
209| results["actions"].append(msg)
|
||||
210| state["active_fallbacks"].append("anthropic->local-llama")
|
||||
211| results["status"] = "degraded_local"
|
||||
212| elif ollama_ok:
|
||||
213| msg = fallback_to_ollama(cfg)
|
||||
214| results["actions"].append(msg)
|
||||
215| state["active_fallbacks"].append("anthropic->ollama")
|
||||
216| results["status"] = "degraded_ollama"
|
||||
217| else:
|
||||
218| msg = enter_safe_mode(state)
|
||||
219| results["actions"].append(msg)
|
||||
220| results["status"] = "safe_mode"
|
||||
221|
|
||||
222| # Case 2: Already on fallback, check if primary recovered
|
||||
223| elif anthropic_ok and "anthropic->local-llama" in state.get("active_fallbacks", []):
|
||||
224| msg = restore_config()
|
||||
225| results["actions"].append(msg)
|
||||
226| state["active_fallbacks"].remove("anthropic->local-llama")
|
||||
227| results["status"] = "recovered"
|
||||
228| elif anthropic_ok and "anthropic->ollama" in state.get("active_fallbacks", []):
|
||||
229| msg = restore_config()
|
||||
230| results["actions"].append(msg)
|
||||
231| state["active_fallbacks"].remove("anthropic->ollama")
|
||||
232| results["status"] = "recovered"
|
||||
233|
|
||||
234| # Case 3: Gitea down — just flag it, work locally
|
||||
235| if not gitea_ok:
|
||||
236| results["actions"].append("WARN: Gitea unreachable — work cached locally until recovery")
|
||||
237| if "gitea_down" not in state.get("active_fallbacks", []):
|
||||
238| state["active_fallbacks"].append("gitea_down")
|
||||
239| 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)
|
||||
240| elif "gitea_down" in state.get("active_fallbacks", []):
|
||||
241| state["active_fallbacks"].remove("gitea_down")
|
||||
242| results["actions"].append("Gitea recovered — resume normal operations")
|
||||
243|
|
||||
244| # Case 4: VPS agents down
|
||||
245| for ip, name in vpses:
|
||||
246| key = f"vps_{name.lower()}"
|
||||
247| if not results["checks"][key]["ok"]:
|
||||
248| results["actions"].append(f"ALERT: {name} VPS ({ip}) unreachable — lazarus protocol needed")
|
||||
249|
|
||||
250| save_state(state)
|
||||
251| return results
|
||||
252|
|
||||
253|if __name__ == "__main__":
|
||||
254| results = diagnose_and_fallback()
|
||||
255| print(json.dumps(results, indent=2))
|
||||
256|
|
||||
257| # Exit codes for cron integration
|
||||
258| if results["status"] == "safe_mode":
|
||||
259| sys.exit(2)
|
||||
260| elif results["status"].startswith("degraded"):
|
||||
261| sys.exit(1)
|
||||
262| else:
|
||||
263| sys.exit(0)
|
||||
264|
|
||||
@@ -140,7 +140,7 @@ if [ -z "$GW_PID" ]; then
|
||||
fi
|
||||
|
||||
# Check local loops
|
||||
CLAUDE_LOOPS=0 # Anthropic purged from fleet
|
||||
CLAUDE_LOOPS=$(pgrep -cf "claude-loop" 2>/dev/null || echo 0)
|
||||
GEMINI_LOOPS=$(pgrep -cf "gemini-loop" 2>/dev/null || echo 0)
|
||||
|
||||
if [ -n "$GW_PID" ]; then
|
||||
@@ -160,7 +160,7 @@ if [ -n "$TIMMY_HEALTH" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
TIMMY_ACTIVITY="loops: gemini=${GEMINI_LOOPS}"
|
||||
TIMMY_ACTIVITY="loops: claude=${CLAUDE_LOOPS} gemini=${GEMINI_LOOPS}"
|
||||
|
||||
# Git activity for timmy-config
|
||||
TC_COMMIT=$(gitea_last_commit "Timmy_Foundation/timmy-config")
|
||||
|
||||
@@ -19,25 +19,25 @@ PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
check_kimi_model() {
|
||||
check_anthropic_model() {
|
||||
local model="$1"
|
||||
local label="$2"
|
||||
local api_key="${KIMI_API_KEY:-}"
|
||||
local api_key="${ANTHROPIC_API_KEY:-}"
|
||||
|
||||
if [ -z "$api_key" ]; then
|
||||
# Try loading from .env
|
||||
api_key=$(grep '^KIMI_API_KEY=' "${HERMES_HOME:-$HOME/.hermes}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'\"" || echo "")
|
||||
api_key=$(grep '^ANTHROPIC_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 KIMI_API_KEY"
|
||||
log "SKIP [$label] $model -- no ANTHROPIC_API_KEY"
|
||||
return 0
|
||||
fi
|
||||
|
||||
response=$(curl -sf --max-time 10 -X POST \
|
||||
"https://api.kimi.com/v1/messages" \
|
||||
-H "Authorization: Bearer: ${api_key}" \
|
||||
-H "content-type: application/json" \
|
||||
"https://api.anthropic.com/v1/messages" \
|
||||
-H "x-api-key: ${api_key}" \
|
||||
-H "anthropic-version: 2023-06-01" \
|
||||
-H "content-type: application/json" \
|
||||
-d "{\"model\":\"${model}\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}" 2>&1 || echo "ERROR")
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ else:
|
||||
|
||||
print("\033[2m────────────────────────────────────────\033[0m")
|
||||
print(" \033[1mIssue Queues\033[0m")
|
||||
queue_agents = ["allegro", "codex-agent", "groq", "ezra", "perplexity", "KimiClaw"]
|
||||
queue_agents = ["allegro", "codex-agent", "groq", "claude", "ezra", "perplexity", "KimiClaw"]
|
||||
for agent in queue_agents:
|
||||
assigned = [
|
||||
issue
|
||||
|
||||
@@ -70,7 +70,7 @@ ops-help() {
|
||||
echo " ops-assign-allegro ISSUE [repo]"
|
||||
echo " ops-assign-codex ISSUE [repo]"
|
||||
echo " ops-assign-groq ISSUE [repo]"
|
||||
# ops-assign-claude removed — Anthropic purged
|
||||
echo " ops-assign-claude ISSUE [repo]"
|
||||
echo " ops-assign-ezra ISSUE [repo]"
|
||||
echo ""
|
||||
}
|
||||
@@ -288,7 +288,7 @@ ops-freshness() {
|
||||
ops-assign-allegro() { ops-assign "$1" "allegro" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-codex() { ops-assign "$1" "codex-agent" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-groq() { ops-assign "$1" "groq" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
# ops-assign-claude removed — Anthropic purged from fleet
|
||||
ops-assign-claude() { ops-assign "$1" "claude" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-ezra() { ops-assign "$1" "ezra" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-perplexity() { ops-assign "$1" "perplexity" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
ops-assign-kimiclaw() { ops-assign "$1" "KimiClaw" "${2:-$OPS_DEFAULT_REPO}"; }
|
||||
|
||||
@@ -171,7 +171,7 @@ queue_agents = [
|
||||
("allegro", "dispatch"),
|
||||
("codex-agent", "cleanup"),
|
||||
("groq", "fast ship"),
|
||||
# claude removed — Anthropic purged
|
||||
("claude", "refactor"),
|
||||
("ezra", "archive"),
|
||||
("perplexity", "research"),
|
||||
("KimiClaw", "digest"),
|
||||
@@ -189,7 +189,7 @@ unassigned = [issue for issue in issues if not issue.get("assignees")]
|
||||
stale_cutoff = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d")
|
||||
stale_prs = [pr for pr in pulls if pr.get("updated_at", "")[:10] < stale_cutoff]
|
||||
overloaded = []
|
||||
for agent in ("allegro", "codex-agent", "groq", "ezra", "perplexity", "KimiClaw"):
|
||||
for agent in ("allegro", "codex-agent", "groq", "claude", "ezra", "perplexity", "KimiClaw"):
|
||||
count = sum(
|
||||
1
|
||||
for issue in issues
|
||||
|
||||
@@ -10,10 +10,10 @@ set -euo pipefail
|
||||
HERMES_BIN="$HOME/.hermes/bin"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LOG_DIR="$HOME/.hermes/logs"
|
||||
# CLAUDE_LOCKS removed — Anthropic purged
|
||||
CLAUDE_LOCKS="$LOG_DIR/claude-locks"
|
||||
GEMINI_LOCKS="$LOG_DIR/gemini-locks"
|
||||
|
||||
mkdir -p "$LOG_DIR" "$GEMINI_LOCKS"
|
||||
mkdir -p "$LOG_DIR" "$CLAUDE_LOCKS" "$GEMINI_LOCKS"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] START-LOOPS: $*"
|
||||
@@ -29,7 +29,7 @@ log "Model health check passed."
|
||||
|
||||
# ── 2. Kill stale loop processes ──────────────────────────────────────
|
||||
log "Killing stale loop processes..."
|
||||
for proc_name in gemini-loop timmy-orchestrator; do
|
||||
for proc_name in claude-loop gemini-loop timmy-orchestrator; do
|
||||
pids=$(pgrep -f "${proc_name}\\.sh" 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then
|
||||
log " Killing stale $proc_name PIDs: $pids"
|
||||
@@ -47,7 +47,7 @@ done
|
||||
|
||||
# ── 3. Clear lock directories ────────────────────────────────────────
|
||||
log "Clearing lock dirs..."
|
||||
# CLAUDE_LOCKS removed — Anthropic purged
|
||||
rm -rf "${CLAUDE_LOCKS:?}"/*
|
||||
rm -rf "${GEMINI_LOCKS:?}"/*
|
||||
log " Cleared $CLAUDE_LOCKS and $GEMINI_LOCKS"
|
||||
|
||||
|
||||
@@ -62,10 +62,10 @@ for p in json.load(sys.stdin):
|
||||
print(f'REPO={\"$repo\"} PR={p[\"number\"]} BY={p[\"user\"][\"login\"]} TITLE={p[\"title\"]}')" >> "$state_dir/open_prs.txt" 2>/dev/null
|
||||
done
|
||||
|
||||
# [Anthropic purged]
|
||||
# [Anthropic purged]
|
||||
# [Anthropic purged]
|
||||
# [Anthropic purged]
|
||||
echo "Claude workers: $(pgrep -f 'claude.*--print.*--dangerously' 2>/dev/null | wc -l | tr -d ' ')" >> "$state_dir/agent_status.txt"
|
||||
echo "Claude loop: $(pgrep -f 'claude-loop.sh' 2>/dev/null | wc -l | tr -d ' ') procs" >> "$state_dir/agent_status.txt"
|
||||
tail -50 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "SUCCESS" | xargs -I{} echo "Claude recent successes: {}" >> "$state_dir/agent_status.txt"
|
||||
tail -50 "$LOG_DIR/claude-loop.log" 2>/dev/null | grep -c "FAILED" | xargs -I{} echo "Claude recent failures: {}" >> "$state_dir/agent_status.txt"
|
||||
echo "Kimi heartbeat launchd: $(launchctl list 2>/dev/null | grep -c 'ai.timmy.kimi-heartbeat' | tr -d ' ') job" >> "$state_dir/agent_status.txt"
|
||||
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "DISPATCHED:" | xargs -I{} echo "Kimi recent dispatches: {}" >> "$state_dir/agent_status.txt"
|
||||
tail -50 "/tmp/kimi-heartbeat.log" 2>/dev/null | grep -c "FAILED:" | xargs -I{} echo "Kimi recent failures: {}" >> "$state_dir/agent_status.txt"
|
||||
@@ -91,7 +91,7 @@ run_triage() {
|
||||
# Auto-assignment is opt-in because silent queue mutation resurrects old state.
|
||||
if [ "$unassigned_count" -gt 0 ]; then
|
||||
if [ "$AUTO_ASSIGN_UNASSIGNED" = "1" ]; then
|
||||
log "Assigning $unassigned_count issues to kimi..."
|
||||
log "Assigning $unassigned_count issues to claude..."
|
||||
while IFS= read -r line; do
|
||||
local repo=$(echo "$line" | sed 's/.*REPO=\([^ ]*\).*/\1/')
|
||||
local num=$(echo "$line" | sed 's/.*NUM=\([^ ]*\).*/\1/')
|
||||
|
||||
@@ -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`
|
||||
|
||||
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"
|
||||
}
|
||||
]
|
||||
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
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
@@ -9,11 +9,11 @@ This is the canonical reference for how we talk, how we work, and what we mean.
|
||||
| Name | What It Is | Where It Lives | Provider |
|
||||
|------|-----------|----------------|----------|
|
||||
| **Timmy** | The sovereign local soul. Center of gravity. Judges all work. | Alexander's Mac | OpenAI Codex (gpt-5.4) |
|
||||
| **Ezra** | The archivist wizard. Reads patterns, names truth, returns clean artifacts. | Hermes VPS | Kimi K2.5 |
|
||||
| **Ezra** | The archivist wizard. Reads patterns, names truth, returns clean artifacts. | Hermes VPS | Anthropic Opus 4.6 |
|
||||
| **Bezalel** | The builder wizard. Builds from clear plans, tests and hardens. | TestBed VPS | OpenAI Codex (gpt-5.4) |
|
||||
| **Alexander** | The principal. Human. Father. The one we serve. Gitea: Rockachopa. | Physical world | N/A |
|
||||
| **Gemini** | Worker swarm. Burns backlog. Produces PRs. | Local Mac (loops) | Google Gemini |
|
||||
| **Kimi** | Worker swarm. Burns backlog. Architecture-grade work. | Local Mac (loops) | Kimi K2.5 |
|
||||
| **Claude** | Worker swarm. Burns backlog. Architecture-grade work. | Local Mac (loops) | Anthropic Claude |
|
||||
|
||||
## The Places
|
||||
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
# DEPRECATED — Anthropic Purged from Fleet
|
||||
|
||||
> This document described the Claude Sonnet workforce. As of April 2026,
|
||||
> Anthropic has been removed from the fleet. All wizard providers now use
|
||||
> Kimi K2.5 as primary with Gemini and local Ollama as fallbacks.
|
||||
> See `docs/fleet-vocabulary.md` for current provider assignments.
|
||||
|
||||
---
|
||||
|
||||
# Sonnet Workforce Loop
|
||||
|
||||
## Agent
|
||||
|
||||
@@ -160,8 +160,8 @@ agents:
|
||||
- playbooks/issue-triager.yaml
|
||||
portfolio:
|
||||
primary:
|
||||
provider: kimi-coding
|
||||
model: kimi-k2.5
|
||||
provider: anthropic
|
||||
model: claude-opus-4-6
|
||||
lane: full-judgment
|
||||
fallback1:
|
||||
provider: openai-codex
|
||||
@@ -188,8 +188,8 @@ agents:
|
||||
- playbooks/pr-reviewer.yaml
|
||||
portfolio:
|
||||
primary:
|
||||
provider: kimi-coding
|
||||
model: kimi-k2.5
|
||||
provider: anthropic
|
||||
model: claude-opus-4-6
|
||||
lane: full-review
|
||||
fallback1:
|
||||
provider: gemini
|
||||
@@ -271,10 +271,10 @@ agents:
|
||||
cross_checks:
|
||||
unique_primary_fallback1_pairs:
|
||||
triage-coordinator:
|
||||
- kimi-coding/kimi-k2.5
|
||||
- anthropic/claude-opus-4-6
|
||||
- openai-codex/codex
|
||||
pr-reviewer:
|
||||
- kimi-coding/kimi-k2.5
|
||||
- anthropic/claude-opus-4-6
|
||||
- gemini/gemini-2.5-pro
|
||||
builder-main:
|
||||
- openai-codex/codex
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ AGENT_LOGINS = {
|
||||
"allegro",
|
||||
"antigravity",
|
||||
"bezalel",
|
||||
"claude",
|
||||
"codex-agent",
|
||||
"ezra",
|
||||
"gemini",
|
||||
@@ -54,6 +55,7 @@ AGENT_LOGINS = {
|
||||
"perplexity",
|
||||
}
|
||||
AGENT_LOGINS_HUMAN = {
|
||||
"claude": "Claude",
|
||||
"codex-agent": "Codex",
|
||||
"ezra": "Ezra",
|
||||
"gemini": "Gemini",
|
||||
@@ -76,6 +78,7 @@ METRICS_DIR = Path(os.path.expanduser("~/.local/timmy/muda-audit"))
|
||||
METRICS_FILE = METRICS_DIR / "metrics.json"
|
||||
|
||||
LOG_PATHS = [
|
||||
Path.home() / ".hermes" / "logs" / "claude-loop.log",
|
||||
Path.home() / ".hermes" / "logs" / "gemini-loop.log",
|
||||
Path.home() / ".hermes" / "logs" / "agent.log",
|
||||
Path.home() / ".hermes" / "logs" / "errors.log",
|
||||
@@ -344,6 +347,8 @@ def measure_waiting(since: datetime) -> dict:
|
||||
agent = name.lower()
|
||||
break
|
||||
if agent == "unknown":
|
||||
if "claude" in line.lower():
|
||||
agent = "claude"
|
||||
elif "gemini" in line.lower():
|
||||
agent = "gemini"
|
||||
elif "groq" in line.lower():
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -103,7 +103,7 @@ nano ~/.hermes/.env
|
||||
| `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` | Slack gateway |
|
||||
| `EXA_API_KEY` | Web search tool |
|
||||
| `FAL_KEY` | Image generation |
|
||||
| `KIMI_API_KEY` | Kimi K2.5 coding inference |
|
||||
| `ANTHROPIC_API_KEY` | Direct Anthropic inference |
|
||||
|
||||
### Pre-flight validation
|
||||
|
||||
|
||||
@@ -272,48 +272,6 @@ def get_file_content_at_staged(filepath: str) -> bytes:
|
||||
return result.stdout
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BANNED PROVIDER CHECK — Anthropic is permanently banned
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BANNED_PROVIDER_PATTERNS = [
|
||||
(re.compile(r"provider:\s*anthropic", re.IGNORECASE), "Anthropic provider reference"),
|
||||
(re.compile(r"anthropic/claude", re.IGNORECASE), "Anthropic model slug"),
|
||||
(re.compile(r"api\.anthropic\.com"), "Anthropic API endpoint"),
|
||||
(re.compile(r"claude-opus", re.IGNORECASE), "Claude Opus model"),
|
||||
(re.compile(r"claude-sonnet", re.IGNORECASE), "Claude Sonnet model"),
|
||||
(re.compile(r"claude-haiku", re.IGNORECASE), "Claude Haiku model"),
|
||||
]
|
||||
|
||||
# Files exempt from the ban (training data, historical docs, tests)
|
||||
_BAN_EXEMPT = {
|
||||
"training/", "evaluations/", "RELEASE_v", "PERFORMANCE_",
|
||||
"scores.json", "docs/design-log/", "FALSEWORK.md",
|
||||
"test_sovereignty_enforcement.py", "test_metrics_helpers.py",
|
||||
"metrics_helpers.py", "sonnet-workforce.md",
|
||||
}
|
||||
|
||||
|
||||
def _is_ban_exempt(filepath: str) -> bool:
|
||||
return any(exempt in filepath for exempt in _BAN_EXEMPT)
|
||||
|
||||
|
||||
def scan_banned_providers(filepath: str, content: str) -> List[Finding]:
|
||||
"""Block any commit that introduces banned provider references."""
|
||||
if _is_ban_exempt(filepath):
|
||||
return []
|
||||
findings = []
|
||||
for line_no, line in enumerate(content.splitlines(), start=1):
|
||||
for pattern, desc in _BANNED_PROVIDER_PATTERNS:
|
||||
if pattern.search(line):
|
||||
findings.append(Finding(
|
||||
filepath, line_no,
|
||||
f"🚫 BANNED PROVIDER: {desc}. Anthropic is permanently banned from this system."
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -337,21 +295,11 @@ def main() -> int:
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
findings.extend(scan_line(line[1:], "<diff>", line_no))
|
||||
|
||||
# Scan for banned providers
|
||||
for filepath in staged_files:
|
||||
file_content = get_file_content_at_staged(filepath)
|
||||
if not is_binary_content(file_content):
|
||||
try:
|
||||
text = file_content.decode("utf-8") if isinstance(file_content, bytes) else file_content
|
||||
findings.extend(scan_banned_providers(filepath, text))
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
if not findings:
|
||||
print(f"{GREEN}✓ No potential secret leaks or banned providers detected{NC}")
|
||||
print(f"{GREEN}✓ No potential secret leaks detected{NC}")
|
||||
return 0
|
||||
|
||||
print(f"{RED}✗ Violations detected:{NC}\n")
|
||||
print(f"{RED}✗ Potential secret leaks detected:{NC}\n")
|
||||
for finding in findings:
|
||||
loc = finding.filename
|
||||
print(
|
||||
@@ -360,7 +308,7 @@ def main() -> int:
|
||||
|
||||
print()
|
||||
print(f"{RED}╔════════════════════════════════════════════════════════════╗{NC}")
|
||||
print(f"{RED}║ COMMIT BLOCKED: Secrets or banned providers detected! ║{NC}")
|
||||
print(f"{RED}║ COMMIT BLOCKED: Potential secrets detected! ║{NC}")
|
||||
print(f"{RED}╚════════════════════════════════════════════════════════════╝{NC}")
|
||||
print()
|
||||
print("Recommendations:")
|
||||
|
||||
@@ -23,7 +23,7 @@ Run `python --version` to verify.
|
||||
## 2. Core Package Dependencies
|
||||
|
||||
All packages in `requirements.txt` must be installed and importable.
|
||||
Critical packages: `openai`, `pyyaml`, `rich`, `requests`, `pydantic`, `prompt_toolkit`.
|
||||
Critical packages: `openai`, `anthropic`, `pyyaml`, `rich`, `requests`, `pydantic`, `prompt_toolkit`.
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
@@ -39,7 +39,8 @@ At least one LLM provider API key must be set in `~/.hermes/.env`:
|
||||
| Variable | Provider |
|
||||
|----------|----------|
|
||||
| `OPENROUTER_API_KEY` | OpenRouter (200+ models) |
|
||||
| `KIMI_API_KEY` | Kimi K2.5 coding |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic Claude |
|
||||
| `ANTHROPIC_TOKEN` | Anthropic Claude (alt) |
|
||||
| `OPENAI_API_KEY` | OpenAI |
|
||||
| `GLM_API_KEY` | z.ai/GLM |
|
||||
| `KIMI_API_KEY` | Moonshot/Kimi |
|
||||
|
||||
@@ -77,7 +77,8 @@ def check_core_deps() -> CheckResult:
|
||||
"""Verify that hermes core Python packages are importable."""
|
||||
required = [
|
||||
"openai",
|
||||
"dotenv",
|
||||
"anthropic",
|
||||
"dotenv",
|
||||
"yaml",
|
||||
"rich",
|
||||
"requests",
|
||||
@@ -205,7 +206,9 @@ def check_env_vars() -> CheckResult:
|
||||
"""Check that at least one LLM provider key is configured."""
|
||||
provider_keys = [
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
@@ -222,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, KIMI_API_KEY, OPENAI_API_KEY "
|
||||
"Set at least one of: OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY "
|
||||
"in ~/.hermes/.env or your shell."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ Gitea (143.198.27.163:3000): token=~/.hermes/gitea_token_vps (Timmy id=2). Users
|
||||
§
|
||||
2026-03-19 HARNESS+SOUL: ~/.timmy is Timmy's workspace within the Hermes harness. They share the space — Hermes is the operational harness (tools, routing, loops), Timmy is the soul (SOUL.md, presence, identity). Not fusion/absorption. Principal's words: "build Timmy out from the hermes harness." ~/.hermes is harness home, ~/.timmy is Timmy's workspace. SOUL=Inscription 1, skin=timmy. Backups at ~/.hermes.backup.pre-fusion and ~/.timmy.backup.pre-fusion.
|
||||
§
|
||||
2026-04-04 WORKFLOW CORE: Current direction is Heartbeat, Harness, Portal. Timmy handles sovereignty and release judgment. Allegro handles dispatch and queue hygiene. Core builders: codex-agent, groq, manus, kimi. Research/memory: perplexity, ezra, KimiClaw. Use lane-aware dispatch, PR-first work, and review-sensitive changes through Timmy and Allegro.
|
||||
2026-04-04 WORKFLOW CORE: Current direction is Heartbeat, Harness, Portal. Timmy handles sovereignty and release judgment. Allegro handles dispatch and queue hygiene. Core builders: codex-agent, groq, manus, claude. Research/memory: perplexity, ezra, KimiClaw. Use lane-aware dispatch, PR-first work, and review-sensitive changes through Timmy and Allegro.
|
||||
§
|
||||
2026-04-04 OPERATIONS: Dashboard repo era is over. Use ~/.timmy + ~/.hermes as truth surfaces. Prefer ops-panel.sh, ops-gitea.sh, timmy-dashboard, and pipeline-freshness.sh over archived loop or tmux assumptions. Dispatch: agent-dispatch.sh <agent> <issue> <repo>. Major changes land as PRs.
|
||||
§
|
||||
|
||||
@@ -162,6 +162,26 @@
|
||||
"Should a higher-context wizard review before more expansion?"
|
||||
]
|
||||
},
|
||||
"claude": {
|
||||
"lane": "hard refactors, deep implementation, and test-heavy multi-file changes after tight scoping",
|
||||
"skills_to_practice": [
|
||||
"respecting scope constraints",
|
||||
"deep code transformation with tests",
|
||||
"explaining risks clearly in PRs"
|
||||
],
|
||||
"missing_skills": [
|
||||
"do not let large capability turn into unsupervised backlog or code sprawl"
|
||||
],
|
||||
"anti_lane": [
|
||||
"self-directed issue farming",
|
||||
"taking broad architecture liberty without a clear charter"
|
||||
],
|
||||
"review_checklist": [
|
||||
"Did I stay inside the scoped problem?",
|
||||
"Did I leave tests or verification stronger than before?",
|
||||
"Is there hidden blast radius that Timmy should see explicitly?"
|
||||
]
|
||||
},
|
||||
"gemini": {
|
||||
"lane": "frontier architecture, research-heavy prototypes, and long-range design thinking",
|
||||
"skills_to_practice": [
|
||||
@@ -202,4 +222,4 @@
|
||||
"Did I make the risk actionable instead of just surprising?"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +1,61 @@
|
||||
name: bug-fixer
|
||||
description: 'Fixes bugs with test-first approach. Writes a failing test that reproduces the bug, then fixes the code, then
|
||||
verifies.
|
||||
description: >
|
||||
Fixes bugs with test-first approach. Writes a failing test that
|
||||
reproduces the bug, then fixes the code, then verifies.
|
||||
|
||||
'
|
||||
model:
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
max_turns: 30
|
||||
temperature: 0.2
|
||||
|
||||
tools:
|
||||
- terminal
|
||||
- file
|
||||
- search_files
|
||||
- patch
|
||||
- terminal
|
||||
- file
|
||||
- search_files
|
||||
- patch
|
||||
|
||||
trigger:
|
||||
issue_label: bug
|
||||
manual: true
|
||||
|
||||
repos:
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
|
||||
steps:
|
||||
- read_issue
|
||||
- clone_repo
|
||||
- create_branch
|
||||
- dispatch_agent
|
||||
- run_tests
|
||||
- create_pr
|
||||
- comment_on_issue
|
||||
- read_issue
|
||||
- clone_repo
|
||||
- create_branch
|
||||
- dispatch_agent
|
||||
- run_tests
|
||||
- create_pr
|
||||
- comment_on_issue
|
||||
|
||||
output: pull_request
|
||||
timeout_minutes: 15
|
||||
system_prompt: 'You are a bug fixer for the {{repo}} project.
|
||||
|
||||
system_prompt: |
|
||||
You are a bug fixer for the {{repo}} project.
|
||||
|
||||
YOUR ISSUE: #{{issue_number}} — {{issue_title}}
|
||||
|
||||
|
||||
APPROACH (prove-first):
|
||||
|
||||
1. Read the bug report. Understand the expected vs actual behavior.
|
||||
|
||||
2. Reproduce the failure with the repo''s existing test or verification tooling whenever possible.
|
||||
|
||||
2. Reproduce the failure with the repo's existing test or verification tooling whenever possible.
|
||||
3. Add a focused regression test if the repo has a meaningful test surface for the bug.
|
||||
|
||||
4. Fix the code so the reproduced failure disappears.
|
||||
|
||||
5. Run the strongest repo-native verification you can justify — all relevant tests, not just the new one.
|
||||
|
||||
6. Commit: fix: <description> Fixes #{{issue_number}}
|
||||
|
||||
7. Push, create PR, and summarize verification plus any residual risk.
|
||||
|
||||
|
||||
RULES:
|
||||
|
||||
- Never claim a fix without proving the broken behavior and the repaired behavior.
|
||||
|
||||
- Prefer repo-native commands over assuming tox exists.
|
||||
|
||||
- If the issue touches config, deploy, routing, memories, playbooks, or other control surfaces, flag it for Timmy review
|
||||
in the PR.
|
||||
|
||||
- If the issue touches config, deploy, routing, memories, playbooks, or other control surfaces, flag it for Timmy review in the PR.
|
||||
- Never use --no-verify.
|
||||
|
||||
- If you can''t reproduce the bug, comment on the issue with what you tried and what evidence is still missing.
|
||||
|
||||
- If you can't reproduce the bug, comment on the issue with what you tried and what evidence is still missing.
|
||||
- If the fix requires >50 lines changed, decompose into sub-issues.
|
||||
|
||||
- Do not widen the issue into a refactor.
|
||||
|
||||
'
|
||||
|
||||
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
|
||||
@@ -1,52 +1,68 @@
|
||||
name: issue-triager
|
||||
description: 'Scores, labels, and prioritizes issues. Assigns to appropriate agents. Decomposes large issues into smaller
|
||||
ones.
|
||||
description: >
|
||||
Scores, labels, and prioritizes issues. Assigns to appropriate
|
||||
agents. Decomposes large issues into smaller ones.
|
||||
|
||||
'
|
||||
model:
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
max_turns: 20
|
||||
temperature: 0.3
|
||||
|
||||
tools:
|
||||
- terminal
|
||||
- search_files
|
||||
- terminal
|
||||
- search_files
|
||||
|
||||
trigger:
|
||||
schedule: every 15m
|
||||
manual: true
|
||||
|
||||
repos:
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
|
||||
steps:
|
||||
- fetch_issues
|
||||
- score_issues
|
||||
- assign_agents
|
||||
- update_queue
|
||||
- fetch_issues
|
||||
- score_issues
|
||||
- assign_agents
|
||||
- update_queue
|
||||
|
||||
output: gitea_issue
|
||||
timeout_minutes: 10
|
||||
system_prompt: "You are the issue triager for Timmy Foundation repos.\n\nREPOS: {{repos}}\n\nYOUR JOB:\n1. Fetch open unassigned\
|
||||
\ issues\n2. Score each by: execution leverage, acceptance criteria quality, alignment with current doctrine, and how likely\
|
||||
\ it is to create duplicate backlog churn\n3. Label appropriately: bug, refactor, feature, tests, security, docs, ops, governance,\
|
||||
\ research\n4. Assign to agents based on the audited lane map:\n - Timmy: governing, sovereign, release, identity, repo-boundary,\
|
||||
\ or architecture decisions that should stay under direct principal review\n - allegro: dispatch, routing, queue hygiene,\
|
||||
\ Gitea bridge, operational tempo, and issues about how work gets moved through the system\n - perplexity: research triage,\
|
||||
\ MCP/open-source evaluations, architecture memos, integration comparisons, and synthesis before implementation\n - ezra:\
|
||||
\ RCA, operating history, memory consolidation, onboarding docs, and archival clean-up\n - KimiClaw: long-context reading,\
|
||||
\ extraction, digestion, and codebase synthesis before a build phase\n - codex-agent: cleanup, migration verification,\
|
||||
\ dead-code removal, repo-boundary enforcement, workflow hardening\n - groq: bounded implementation, tactical bug fixes,\
|
||||
\ quick feature slices, small patches with clear acceptance criteria\n - manus: bounded support tasks, moderate-scope\
|
||||
\ implementation, follow-through on already-scoped work\n - kimi: hard refactors, broad multi-file implementation, test-heavy\
|
||||
\ changes after the scope is made precise\n - gemini: frontier architecture, research-heavy prototypes, long-range design\
|
||||
\ thinking when a concrete implementation owner is not yet obvious\n - grok: adversarial testing, unusual edge cases,\
|
||||
\ provocative review angles that still need another pass\n5. Decompose any issue touching >5 files or crossing repo boundaries\
|
||||
\ into smaller issues before assigning execution\n\nRULES:\n- Prefer one owner per issue. Only add a second assignee when\
|
||||
\ the work is explicitly collaborative.\n- Bugs, security fixes, and broken live workflows take priority over research and\
|
||||
\ refactors.\n- If issue scope is unclear, ask for clarification before assigning an implementation agent.\n- Skip [epic],\
|
||||
\ [meta], [governing], and [constitution] issues for automatic assignment unless they are explicitly routed to Timmy or\
|
||||
\ allegro.\n- 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.\n- Do not assign open-ended ideation to implementation agents.\n\
|
||||
- Do not assign routine backlog maintenance to Timmy.\n- Do not assign wide speculative backlog generation to codex-agent,\
|
||||
\ groq, or manus.\n- Route archive/history/context-digestion work to ezra or KimiClaw before routing it to a builder.\n\
|
||||
- Route “who should do this?” and “what is the next move?” questions to allegro.\n"
|
||||
|
||||
system_prompt: |
|
||||
You are the issue triager for Timmy Foundation repos.
|
||||
|
||||
REPOS: {{repos}}
|
||||
|
||||
YOUR JOB:
|
||||
1. Fetch open unassigned issues
|
||||
2. Score each by: execution leverage, acceptance criteria quality, alignment with current doctrine, and how likely it is to create duplicate backlog churn
|
||||
3. Label appropriately: bug, refactor, feature, tests, security, docs, ops, governance, research
|
||||
4. Assign to agents based on the audited lane map:
|
||||
- Timmy: governing, sovereign, release, identity, repo-boundary, or architecture decisions that should stay under direct principal review
|
||||
- allegro: dispatch, routing, queue hygiene, Gitea bridge, operational tempo, and issues about how work gets moved through the system
|
||||
- perplexity: research triage, MCP/open-source evaluations, architecture memos, integration comparisons, and synthesis before implementation
|
||||
- ezra: RCA, operating history, memory consolidation, onboarding docs, and archival clean-up
|
||||
- KimiClaw: long-context reading, extraction, digestion, and codebase synthesis before a build phase
|
||||
- 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
|
||||
- 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
|
||||
|
||||
RULES:
|
||||
- Prefer one owner per issue. Only add a second assignee when the work is explicitly collaborative.
|
||||
- Bugs, security fixes, and broken live workflows take priority over research and refactors.
|
||||
- If issue scope is unclear, ask for clarification before assigning an implementation agent.
|
||||
- Skip [epic], [meta], [governing], and [constitution] issues for automatic assignment unless they are explicitly routed to Timmy or allegro.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -1,47 +1,89 @@
|
||||
name: pr-reviewer
|
||||
description: 'Reviews open PRs, checks CI status, merges passing ones, comments on problems. The merge bot replacement.
|
||||
description: >
|
||||
Reviews open PRs, checks CI status, merges passing ones,
|
||||
comments on problems. The merge bot replacement.
|
||||
|
||||
'
|
||||
model:
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
max_turns: 20
|
||||
temperature: 0.2
|
||||
|
||||
tools:
|
||||
- terminal
|
||||
- search_files
|
||||
- terminal
|
||||
- search_files
|
||||
|
||||
trigger:
|
||||
schedule: every 30m
|
||||
manual: true
|
||||
|
||||
repos:
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
|
||||
steps:
|
||||
- fetch_prs
|
||||
- review_diffs
|
||||
- post_reviews
|
||||
- merge_passing
|
||||
- fetch_prs
|
||||
- review_diffs
|
||||
- post_reviews
|
||||
- merge_passing
|
||||
|
||||
output: report
|
||||
timeout_minutes: 10
|
||||
system_prompt: "You are the PR reviewer for Timmy Foundation repos.\n\nREPOS: {{repos}}\n\nFOR EACH OPEN PR:\n1. Check CI\
|
||||
\ status (Actions tab or commit status API)\n2. Read the linked issue or PR body to verify the intended scope before judging\
|
||||
\ the diff\n3. Review the diff for:\n - Correctness: does it do what the issue asked?\n - Security: no secrets, unsafe\
|
||||
\ execution paths, or permission drift\n - Tests and verification: does the author prove the change?\n - Scope: PR should\
|
||||
\ match the issue, not scope-creep\n - Governance: does the change cross a boundary that should stay under Timmy review?\n\
|
||||
\ - Workflow fit: does it reduce drift, duplication, or hidden operational risk?\n4. Post findings ordered by severity\
|
||||
\ and cite the affected files or behavior clearly\n5. If CI fails or verification is missing: explain what is blocking merge\n\
|
||||
6. If PR is behind main: request a rebase or re-run only when needed; do not force churn for cosmetic reasons\n7. If review\
|
||||
\ is clean and the PR is low-risk: squash merge\n\nLOW-RISK AUTO-MERGE ONLY IF ALL ARE TRUE:\n- PR is not a draft\n- CI\
|
||||
\ is green or the repo has no CI configured\n- Diff matches the stated issue or PR scope\n- No unresolved review findings\
|
||||
\ remain\n- Change is narrow, reversible, and non-governing\n- Paths changed do not include sensitive control surfaces\n\
|
||||
\nSENSITIVE CONTROL SURFACES:\n- SOUL.md\n- config.yaml\n- deploy.sh\n- tasks.py\n- playbooks/\n- cron/\n- memories/\n-\
|
||||
\ skins/\n- training/\n- authentication, permissions, or secret-handling code\n- repo-boundary, model-routing, or deployment-governance\
|
||||
\ changes\n\nNEVER AUTO-MERGE:\n- PRs that change sensitive control surfaces\n- PRs that change more than 5 files unless\
|
||||
\ the change is docs-only\n- PRs without a clear problem statement or verification\n- PRs that look like duplicate work,\
|
||||
\ speculative research, or scope creep\n- PRs that need Timmy or Allegro judgment on architecture, dispatch, or release\
|
||||
\ impact\n- PRs that are stale solely because of age; do not close them automatically\n\nIf a PR is stale, nudge with a\
|
||||
\ comment and summarize what still blocks it. Do not close it just because 48 hours passed.\n\nMERGE RULES:\n- ONLY squash\
|
||||
\ merge. Never merge commits. Never rebase merge.\n- Delete branch after merge.\n- Empty PRs (0 changed files): close immediately\
|
||||
\ with a brief explanation.\n"
|
||||
|
||||
system_prompt: |
|
||||
You are the PR reviewer for Timmy Foundation repos.
|
||||
|
||||
REPOS: {{repos}}
|
||||
|
||||
FOR EACH OPEN PR:
|
||||
1. Check CI status (Actions tab or commit status API)
|
||||
2. Read the linked issue or PR body to verify the intended scope before judging the diff
|
||||
3. Review the diff for:
|
||||
- Correctness: does it do what the issue asked?
|
||||
- Security: no secrets, unsafe execution paths, or permission drift
|
||||
- Tests and verification: does the author prove the change?
|
||||
- Scope: PR should match the issue, not scope-creep
|
||||
- Governance: does the change cross a boundary that should stay under Timmy review?
|
||||
- Workflow fit: does it reduce drift, duplication, or hidden operational risk?
|
||||
4. Post findings ordered by severity and cite the affected files or behavior clearly
|
||||
5. If CI fails or verification is missing: explain what is blocking merge
|
||||
6. If PR is behind main: request a rebase or re-run only when needed; do not force churn for cosmetic reasons
|
||||
7. If review is clean and the PR is low-risk: squash merge
|
||||
|
||||
LOW-RISK AUTO-MERGE ONLY IF ALL ARE TRUE:
|
||||
- PR is not a draft
|
||||
- CI is green or the repo has no CI configured
|
||||
- Diff matches the stated issue or PR scope
|
||||
- No unresolved review findings remain
|
||||
- Change is narrow, reversible, and non-governing
|
||||
- Paths changed do not include sensitive control surfaces
|
||||
|
||||
SENSITIVE CONTROL SURFACES:
|
||||
- SOUL.md
|
||||
- config.yaml
|
||||
- deploy.sh
|
||||
- tasks.py
|
||||
- playbooks/
|
||||
- cron/
|
||||
- memories/
|
||||
- skins/
|
||||
- training/
|
||||
- authentication, permissions, or secret-handling code
|
||||
- repo-boundary, model-routing, or deployment-governance changes
|
||||
|
||||
NEVER AUTO-MERGE:
|
||||
- PRs that change sensitive control surfaces
|
||||
- PRs that change more than 5 files unless the change is docs-only
|
||||
- PRs without a clear problem statement or verification
|
||||
- PRs that look like duplicate work, speculative research, or scope creep
|
||||
- PRs that need Timmy or Allegro judgment on architecture, dispatch, or release impact
|
||||
- PRs that are stale solely because of age; do not close them automatically
|
||||
|
||||
If a PR is stale, nudge with a comment and summarize what still blocks it. Do not close it just because 48 hours passed.
|
||||
|
||||
MERGE RULES:
|
||||
- ONLY squash merge. Never merge commits. Never rebase merge.
|
||||
- Delete branch after merge.
|
||||
- Empty PRs (0 changed files): close immediately with a brief explanation.
|
||||
|
||||
@@ -1,75 +1,62 @@
|
||||
name: refactor-specialist
|
||||
description: 'Splits large modules, reduces complexity, improves code organization. Well-scoped: 1-3 files per task, clear
|
||||
acceptance criteria.
|
||||
description: >
|
||||
Splits large modules, reduces complexity, improves code organization.
|
||||
Well-scoped: 1-3 files per task, clear acceptance criteria.
|
||||
|
||||
'
|
||||
model:
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
max_turns: 30
|
||||
temperature: 0.3
|
||||
|
||||
tools:
|
||||
- terminal
|
||||
- file
|
||||
- search_files
|
||||
- patch
|
||||
- terminal
|
||||
- file
|
||||
- search_files
|
||||
- patch
|
||||
|
||||
trigger:
|
||||
issue_label: refactor
|
||||
manual: true
|
||||
|
||||
repos:
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
|
||||
steps:
|
||||
- read_issue
|
||||
- clone_repo
|
||||
- create_branch
|
||||
- dispatch_agent
|
||||
- run_tests
|
||||
- create_pr
|
||||
- comment_on_issue
|
||||
- read_issue
|
||||
- clone_repo
|
||||
- create_branch
|
||||
- dispatch_agent
|
||||
- run_tests
|
||||
- create_pr
|
||||
- comment_on_issue
|
||||
|
||||
output: pull_request
|
||||
timeout_minutes: 15
|
||||
system_prompt: 'You are a refactoring specialist for the {{repo}} project.
|
||||
|
||||
system_prompt: |
|
||||
You are a refactoring specialist for the {{repo}} project.
|
||||
|
||||
YOUR ISSUE: #{{issue_number}} — {{issue_title}}
|
||||
|
||||
|
||||
RULES:
|
||||
|
||||
- Lines of code is a liability. Delete as much as you create.
|
||||
|
||||
- All changes go through PRs. No direct pushes to main.
|
||||
|
||||
- Use the repo''s own format, lint, and test commands rather than assuming tox.
|
||||
|
||||
- Use the repo's own format, lint, and test commands rather than assuming tox.
|
||||
- Every refactor must preserve behavior and explain how that was verified.
|
||||
|
||||
- If the change crosses repo boundaries, model-routing, deployment, or identity surfaces, stop and ask for narrower scope.
|
||||
|
||||
- Never use --no-verify on git commands.
|
||||
|
||||
- Conventional commits: refactor: <description> (#{{issue_number}})
|
||||
|
||||
- If tests fail after 2 attempts, STOP and comment on the issue.
|
||||
|
||||
- Refactors exist to simplify the system, not to create a new design detour.
|
||||
|
||||
|
||||
WORKFLOW:
|
||||
|
||||
1. Read the issue body for specific file paths and instructions
|
||||
|
||||
2. Understand the current code structure
|
||||
|
||||
3. Name the simplification goal before changing code
|
||||
|
||||
4. Make the refactoring changes
|
||||
|
||||
5. Run formatting and verification with repo-native commands
|
||||
|
||||
6. Commit, push, create PR with before/after risk summary
|
||||
|
||||
'
|
||||
|
||||
@@ -1,38 +1,63 @@
|
||||
name: security-auditor
|
||||
description: 'Scans code for security vulnerabilities, hardcoded secrets, dependency issues. Files findings as Gitea issues.
|
||||
description: >
|
||||
Scans code for security vulnerabilities, hardcoded secrets,
|
||||
dependency issues. Files findings as Gitea issues.
|
||||
|
||||
'
|
||||
model:
|
||||
preferred: kimi-k2.5
|
||||
fallback: kimi-k2.5
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-opus-4-6
|
||||
max_turns: 40
|
||||
temperature: 0.2
|
||||
|
||||
tools:
|
||||
- terminal
|
||||
- file
|
||||
- search_files
|
||||
- terminal
|
||||
- file
|
||||
- search_files
|
||||
|
||||
trigger:
|
||||
schedule: weekly
|
||||
pr_merged_with_lines: 100
|
||||
manual: true
|
||||
|
||||
repos:
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
|
||||
steps:
|
||||
- clone_repo
|
||||
- run_audit
|
||||
- file_issues
|
||||
- clone_repo
|
||||
- run_audit
|
||||
- file_issues
|
||||
|
||||
output: gitea_issue
|
||||
timeout_minutes: 20
|
||||
system_prompt: "You are a security auditor for the Timmy Foundation codebase.\nYour job is to FIND vulnerabilities, not write\
|
||||
\ code.\n\nTARGET REPO: {{repo}}\n\nSCAN FOR:\n1. Hardcoded secrets, API keys, tokens in source code\n2. SQL injection vulnerabilities\n\
|
||||
3. Command injection via unsanitized input\n4. Path traversal in file operations\n5. Insecure HTTP calls (should be HTTPS\
|
||||
\ where possible)\n6. Dependencies with known CVEs (check requirements.txt/package.json)\n7. Missing input validation\n\
|
||||
8. Overly permissive file permissions\n9. Privilege drift in deploy, orchestration, memory, cron, and playbook surfaces\n\
|
||||
10. Places where private data or local-only artifacts could leak into tracked repos\n\nOUTPUT FORMAT:\nFor each finding,\
|
||||
\ file a Gitea issue with:\n Title: [security] <severity>: <description>\n Body: file + line, description, why it matters,\
|
||||
\ recommended fix\n Label: security\n\nSEVERITY: critical / high / medium / low\nOnly file issues for real findings. No\
|
||||
\ false positives.\nDo not open duplicate issues for already-known findings; link the existing issue instead.\nIf a finding\
|
||||
\ affects sovereignty boundaries or private-data handling, flag it clearly as such.\n"
|
||||
|
||||
system_prompt: |
|
||||
You are a security auditor for the Timmy Foundation codebase.
|
||||
Your job is to FIND vulnerabilities, not write code.
|
||||
|
||||
TARGET REPO: {{repo}}
|
||||
|
||||
SCAN FOR:
|
||||
1. Hardcoded secrets, API keys, tokens in source code
|
||||
2. SQL injection vulnerabilities
|
||||
3. Command injection via unsanitized input
|
||||
4. Path traversal in file operations
|
||||
5. Insecure HTTP calls (should be HTTPS where possible)
|
||||
6. Dependencies with known CVEs (check requirements.txt/package.json)
|
||||
7. Missing input validation
|
||||
8. Overly permissive file permissions
|
||||
9. Privilege drift in deploy, orchestration, memory, cron, and playbook surfaces
|
||||
10. Places where private data or local-only artifacts could leak into tracked repos
|
||||
|
||||
OUTPUT FORMAT:
|
||||
For each finding, file a Gitea issue with:
|
||||
Title: [security] <severity>: <description>
|
||||
Body: file + line, description, why it matters, recommended fix
|
||||
Label: security
|
||||
|
||||
SEVERITY: critical / high / medium / low
|
||||
Only file issues for real findings. No false positives.
|
||||
Do not open duplicate issues for already-known findings; link the existing issue instead.
|
||||
If a finding affects sovereignty boundaries or private-data handling, flag it clearly as such.
|
||||
|
||||
@@ -1,66 +1,58 @@
|
||||
name: test-writer
|
||||
description: 'Adds test coverage for untested modules. Finds coverage gaps, writes meaningful tests, verifies they pass.
|
||||
description: >
|
||||
Adds test coverage for untested modules. Finds coverage gaps,
|
||||
writes meaningful tests, verifies they pass.
|
||||
|
||||
'
|
||||
model:
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
max_turns: 30
|
||||
temperature: 0.3
|
||||
|
||||
tools:
|
||||
- terminal
|
||||
- file
|
||||
- search_files
|
||||
- patch
|
||||
- terminal
|
||||
- file
|
||||
- search_files
|
||||
- patch
|
||||
|
||||
trigger:
|
||||
issue_label: tests
|
||||
manual: true
|
||||
|
||||
repos:
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
- Timmy_Foundation/the-nexus
|
||||
- Timmy_Foundation/timmy-home
|
||||
- Timmy_Foundation/timmy-config
|
||||
- Timmy_Foundation/hermes-agent
|
||||
|
||||
steps:
|
||||
- read_issue
|
||||
- clone_repo
|
||||
- create_branch
|
||||
- dispatch_agent
|
||||
- run_tests
|
||||
- create_pr
|
||||
- comment_on_issue
|
||||
- read_issue
|
||||
- clone_repo
|
||||
- create_branch
|
||||
- dispatch_agent
|
||||
- run_tests
|
||||
- create_pr
|
||||
- comment_on_issue
|
||||
|
||||
output: pull_request
|
||||
timeout_minutes: 15
|
||||
system_prompt: 'You are a test engineer for the {{repo}} project.
|
||||
|
||||
system_prompt: |
|
||||
You are a test engineer for the {{repo}} project.
|
||||
|
||||
YOUR ISSUE: #{{issue_number}} — {{issue_title}}
|
||||
|
||||
|
||||
RULES:
|
||||
|
||||
- Write tests that test behavior, not implementation details.
|
||||
|
||||
- Use the repo''s own test entrypoints; do not assume tox exists.
|
||||
|
||||
- Use the repo's own test entrypoints; do not assume tox exists.
|
||||
- Tests must be deterministic. No flaky tests.
|
||||
|
||||
- Conventional commits: test: <description> (#{{issue_number}})
|
||||
|
||||
- If the module is hard to test, explain the design obstacle and propose the smallest next step.
|
||||
|
||||
- Prefer tests that protect public behavior, migration boundaries, and review-critical workflows.
|
||||
|
||||
|
||||
WORKFLOW:
|
||||
|
||||
1. Read the issue for target module paths
|
||||
|
||||
2. Read the existing code to understand behavior
|
||||
|
||||
3. Write focused unit tests
|
||||
|
||||
4. Run the relevant verification commands — all related tests must pass
|
||||
|
||||
5. Commit, push, create PR with verification summary and coverage rationale
|
||||
|
||||
'
|
||||
|
||||
@@ -1,55 +1,47 @@
|
||||
name: verified-logic
|
||||
description: 'Crucible-first playbook for tasks that require proof instead of plausible prose. Use Z3-backed sidecar tools
|
||||
for scheduling, dependency ordering, capacity checks, and consistency verification.
|
||||
description: >
|
||||
Crucible-first playbook for tasks that require proof instead of plausible prose.
|
||||
Use Z3-backed sidecar tools for scheduling, dependency ordering, capacity checks,
|
||||
and consistency verification.
|
||||
|
||||
'
|
||||
model:
|
||||
preferred: kimi-k2.5
|
||||
fallback: google/gemini-2.5-pro
|
||||
preferred: claude-opus-4-6
|
||||
fallback: claude-sonnet-4-20250514
|
||||
max_turns: 12
|
||||
temperature: 0.1
|
||||
|
||||
tools:
|
||||
- mcp_crucible_schedule_tasks
|
||||
- mcp_crucible_order_dependencies
|
||||
- mcp_crucible_capacity_fit
|
||||
- mcp_crucible_schedule_tasks
|
||||
- mcp_crucible_order_dependencies
|
||||
- mcp_crucible_capacity_fit
|
||||
|
||||
trigger:
|
||||
manual: true
|
||||
|
||||
steps:
|
||||
- classify_problem
|
||||
- choose_template
|
||||
- translate_into_constraints
|
||||
- verify_with_crucible
|
||||
- report_sat_unsat_with_witness
|
||||
- classify_problem
|
||||
- choose_template
|
||||
- translate_into_constraints
|
||||
- verify_with_crucible
|
||||
- report_sat_unsat_with_witness
|
||||
|
||||
output: verified_result
|
||||
timeout_minutes: 5
|
||||
system_prompt: 'You are running the Crucible playbook.
|
||||
|
||||
system_prompt: |
|
||||
You are running the Crucible playbook.
|
||||
|
||||
Use this playbook for:
|
||||
|
||||
- scheduling and deadline feasibility
|
||||
|
||||
- dependency ordering and cycle checks
|
||||
|
||||
- capacity / resource allocation constraints
|
||||
|
||||
- consistency checks where a contradiction matters
|
||||
|
||||
|
||||
RULES:
|
||||
|
||||
1. Do not bluff through logic.
|
||||
|
||||
2. Pick the narrowest Crucible template that fits the task.
|
||||
|
||||
3. Translate the user''s question into structured constraints.
|
||||
|
||||
3. Translate the user's question into structured constraints.
|
||||
4. Call the Crucible tool.
|
||||
|
||||
5. If SAT, report the witness model clearly.
|
||||
|
||||
6. If UNSAT, say the constraints are impossible and explain which shape of constraint caused the contradiction.
|
||||
|
||||
7. If the task is not a good fit for these templates, say so plainly instead of pretending it was verified.
|
||||
|
||||
'
|
||||
|
||||
@@ -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()
|
||||
@@ -1,85 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Architecture Linter — Ensuring alignment with the Frontier Local Agenda.
|
||||
|
||||
Anthropic is BANNED. Not deprecated, not discouraged — banned.
|
||||
Any reference to Anthropic as a provider, model, or API endpoint
|
||||
in active configs is a hard failure.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
# Architecture Linter
|
||||
# Ensuring all changes align with the Frontier Local Agenda.
|
||||
|
||||
SOVEREIGN_RULES = [
|
||||
# BANNED — hard failures
|
||||
(r"provider:\s*anthropic", "BANNED: Anthropic provider reference. Anthropic is permanently banned from this system."),
|
||||
(r"anthropic/claude", "BANNED: Anthropic model reference (anthropic/claude-*). Use kimi-k2.5 or google/gemini-2.5-pro."),
|
||||
(r"api\.anthropic\.com", "BANNED: Direct Anthropic API endpoint. Anthropic is permanently banned."),
|
||||
(r"ANTHROPIC_API_KEY", "BANNED: Anthropic API key reference. Remove all Anthropic credentials."),
|
||||
(r"ANTHROPIC_TOKEN", "BANNED: Anthropic token reference. Remove all Anthropic credentials."),
|
||||
(r"sk-ant-", "BANNED: Anthropic API key literal (sk-ant-*). Remove immediately."),
|
||||
(r"claude-opus", "BANNED: Claude Opus model reference. Use kimi-k2.5."),
|
||||
(r"claude-sonnet", "BANNED: Claude Sonnet model reference. Use kimi-k2.5."),
|
||||
(r"claude-haiku", "BANNED: Claude Haiku model reference. Use google/gemini-2.5-pro."),
|
||||
|
||||
# Existing sovereignty rules
|
||||
(r"https?://api\.openai\.com", "WARNING: Direct OpenAI API endpoint. Use local custom_provider instead."),
|
||||
(r"provider:\s*openai", "WARNING: Direct OpenAI provider. Ensure fallback_model is configured."),
|
||||
(r"api_key: ['\"][^'\"\s]{10,}['\"]", "SECURITY: Hardcoded API key detected. Use environment variables."),
|
||||
(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*['\"][A-Za-z0-9_\-]{16,}['\"]", "SECURITY: Hardcoded API key detected. Use environment variables.")
|
||||
]
|
||||
|
||||
# Files to skip (training data, historical docs, changelogs, tests that validate the ban)
|
||||
SKIP_PATTERNS = [
|
||||
"training/", "evaluations/", "RELEASE_v", "PERFORMANCE_",
|
||||
"scores.json", "docs/design-log/", "FALSEWORK.md",
|
||||
"test_sovereignty_enforcement.py", "test_metrics_helpers.py",
|
||||
"metrics_helpers.py", # historical cost data
|
||||
]
|
||||
|
||||
|
||||
def should_skip(path: str) -> bool:
|
||||
return any(skip in path for skip in SKIP_PATTERNS)
|
||||
|
||||
|
||||
def lint_file(path: str) -> int:
|
||||
if should_skip(path):
|
||||
return 0
|
||||
def lint_file(path):
|
||||
print(f"Linting {path}...")
|
||||
content = open(path).read()
|
||||
violations = 0
|
||||
for pattern, msg in SOVEREIGN_RULES:
|
||||
matches = list(re.finditer(pattern, content, re.IGNORECASE))
|
||||
if matches:
|
||||
if re.search(pattern, content):
|
||||
print(f" [!] {msg}")
|
||||
for m in matches[:3]: # Show up to 3 locations
|
||||
line_no = content[:m.start()].count('\n') + 1
|
||||
print(f" Line {line_no}: ...{content[max(0,m.start()-20):m.end()+20].strip()}...")
|
||||
violations += 1
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
print("--- Architecture Linter (Anthropic BANNED) ---")
|
||||
print("--- Ezra's Architecture Linter ---")
|
||||
files = [f for f in sys.argv[1:] if os.path.isfile(f)]
|
||||
if not files:
|
||||
# If no args, scan all yaml/py/sh/json in the repo
|
||||
for root, _, filenames in os.walk("."):
|
||||
for fn in filenames:
|
||||
if fn.endswith((".yaml", ".yml", ".py", ".sh", ".json", ".md")):
|
||||
path = os.path.join(root, fn)
|
||||
if not should_skip(path) and ".git" not in path:
|
||||
files.append(path)
|
||||
|
||||
total_violations = sum(lint_file(f) for f in files)
|
||||
banned = sum(1 for f in files for p, m in SOVEREIGN_RULES
|
||||
if "BANNED" in m and re.search(p, open(f).read(), re.IGNORECASE)
|
||||
and not should_skip(f))
|
||||
|
||||
print(f"\nLinting complete. Total violations: {total_violations}")
|
||||
if banned > 0:
|
||||
print(f"\n🚫 {banned} BANNED provider violation(s) detected. Anthropic is permanently banned.")
|
||||
sys.exit(1 if total_violations > 0 else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
12
scripts/foundation_accessibility_audit.py
Normal file
12
scripts/foundation_accessibility_audit.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import json
|
||||
from hermes_tools import browser_navigate, browser_vision
|
||||
|
||||
def audit_accessibility():
|
||||
browser_navigate(url="https://timmyfoundation.org")
|
||||
analysis = browser_vision(
|
||||
question="Perform an accessibility audit. Check for: 1) Color contrast, 2) Font legibility, 3) Missing alt text for images. Provide a report with FAIL/PASS."
|
||||
)
|
||||
return {"status": "PASS" if "PASS" in analysis.upper() else "FAIL", "analysis": analysis}
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(audit_accessibility(), indent=2))
|
||||
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()
|
||||
341
scripts/knowledge_base.py
Normal file
341
scripts/knowledge_base.py
Normal file
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""knowledge_base.py - GOFAI symbolic knowledge base for the Timmy Foundation fleet.
|
||||
|
||||
A classical AI knowledge representation system: stores facts as ground atoms,
|
||||
supports first-order-logic-style queries, and maintains a provenance chain so
|
||||
every belief can be traced back to its source. No neural nets, no embeddings -
|
||||
just structured symbolic reasoning over a typed fact store.
|
||||
|
||||
Usage:
|
||||
kb = KnowledgeBase()
|
||||
kb.assert_fact('agent', 'online', 'timmy')
|
||||
kb.assert_fact('task', 'assigned_to', 'task-42', 'timmy')
|
||||
results = kb.query('task', 'assigned_to', '?x', 'timmy')
|
||||
# results -> [{'?x': 'task-42'}]
|
||||
|
||||
CLI:
|
||||
python knowledge_base.py --assert "agent online hermes"
|
||||
python knowledge_base.py --query "agent online ?who"
|
||||
python knowledge_base.py --dump
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VAR_PREFIX = "?"
|
||||
|
||||
|
||||
def is_var(term: str) -> bool:
|
||||
"""Return True if *term* is a logic variable (starts with '?')."""
|
||||
return term.startswith(VAR_PREFIX)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fact:
|
||||
"""An immutable ground atom: (relation, *args)."""
|
||||
|
||||
relation: str
|
||||
args: Tuple[str, ...]
|
||||
source: str = "user"
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
def __str__(self) -> str:
|
||||
args_str = " ".join(self.args)
|
||||
return f"({self.relation} {args_str})"
|
||||
|
||||
|
||||
Bindings = Dict[str, str]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def unify_term(pattern: str, value: str, bindings: Bindings) -> Optional[Bindings]:
|
||||
"""Unify a single pattern term against a ground value.
|
||||
|
||||
Returns updated bindings on success, or None on failure.
|
||||
"""
|
||||
if is_var(pattern):
|
||||
if pattern in bindings:
|
||||
return bindings if bindings[pattern] == value else None
|
||||
return {**bindings, pattern: value}
|
||||
return bindings if pattern == value else None
|
||||
|
||||
|
||||
def unify_fact(
|
||||
pattern: Tuple[str, ...], fact_args: Tuple[str, ...], bindings: Bindings
|
||||
) -> Optional[Bindings]:
|
||||
"""Unify a full argument tuple, returning final bindings or None."""
|
||||
if len(pattern) != len(fact_args):
|
||||
return None
|
||||
b = bindings
|
||||
for p, v in zip(pattern, fact_args):
|
||||
b = unify_term(p, v, b)
|
||||
if b is None:
|
||||
return None
|
||||
return b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Knowledge Base
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class KnowledgeBase:
|
||||
"""In-memory symbolic knowledge base with optional JSON persistence."""
|
||||
|
||||
def __init__(self, persist_path: Optional[Path] = None) -> None:
|
||||
self._facts: List[Fact] = []
|
||||
self._persist_path = persist_path
|
||||
if persist_path and persist_path.exists():
|
||||
self._load(persist_path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fact management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def assert_fact(
|
||||
self, relation: str, *args: str, source: str = "user"
|
||||
) -> Fact:
|
||||
"""Add a ground fact to the knowledge base.
|
||||
|
||||
Idempotent: duplicate (relation, args) pairs are not added twice.
|
||||
"""
|
||||
f = Fact(relation=relation, args=tuple(args), source=source, timestamp=time.time())
|
||||
for existing in self._facts:
|
||||
if existing.relation == f.relation and existing.args == f.args:
|
||||
return existing # already known
|
||||
self._facts.append(f)
|
||||
if self._persist_path:
|
||||
self._save(self._persist_path)
|
||||
return f
|
||||
|
||||
def retract_fact(self, relation: str, *args: str) -> int:
|
||||
"""Remove all facts matching (relation, *args). Returns count removed."""
|
||||
before = len(self._facts)
|
||||
self._facts = [
|
||||
f
|
||||
for f in self._facts
|
||||
if not (f.relation == relation and f.args == tuple(args))
|
||||
]
|
||||
removed = before - len(self._facts)
|
||||
if removed and self._persist_path:
|
||||
self._save(self._persist_path)
|
||||
return removed
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def query(
|
||||
self, relation: str, *pattern_args: str, source_filter: Optional[str] = None
|
||||
) -> List[Bindings]:
|
||||
"""Return all binding dictionaries satisfying the query pattern.
|
||||
|
||||
Variables in *pattern_args* start with '?'. Ground terms must match
|
||||
exactly. An empty binding dict means the fact matched with no
|
||||
variables to bind.
|
||||
|
||||
Args:
|
||||
relation: The relation name to match.
|
||||
*pattern_args: Mixed ground/variable argument tuple.
|
||||
source_filter: Optional provenance filter (e.g. 'scheduler').
|
||||
|
||||
Returns:
|
||||
List of binding dicts, one per matching fact.
|
||||
"""
|
||||
results: List[Bindings] = []
|
||||
for fact in self._facts:
|
||||
if fact.relation != relation:
|
||||
continue
|
||||
if source_filter and fact.source != source_filter:
|
||||
continue
|
||||
b = unify_fact(tuple(pattern_args), fact.args, {})
|
||||
if b is not None:
|
||||
results.append(b)
|
||||
return results
|
||||
|
||||
def query_one(
|
||||
self, relation: str, *pattern_args: str
|
||||
) -> Optional[Bindings]:
|
||||
"""Return the first matching binding dict or None."""
|
||||
for b in self.query(relation, *pattern_args):
|
||||
return b
|
||||
return None
|
||||
|
||||
def facts_for(self, relation: str) -> Iterator[Fact]:
|
||||
"""Iterate over all facts with the given relation."""
|
||||
for f in self._facts:
|
||||
if f.relation == relation:
|
||||
yield f
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bulk operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def all_facts(self) -> List[Fact]:
|
||||
"""Return a snapshot of all stored facts."""
|
||||
return list(self._facts)
|
||||
|
||||
def fact_count(self) -> int:
|
||||
return len(self._facts)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all facts from memory (does not touch disk)."""
|
||||
self._facts.clear()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Persistence
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _save(self, path: Path) -> None:
|
||||
records = [
|
||||
{
|
||||
"relation": f.relation,
|
||||
"args": list(f.args),
|
||||
"source": f.source,
|
||||
"timestamp": f.timestamp,
|
||||
}
|
||||
for f in self._facts
|
||||
]
|
||||
path.write_text(json.dumps(records, indent=2))
|
||||
|
||||
def _load(self, path: Path) -> None:
|
||||
try:
|
||||
records = json.loads(path.read_text())
|
||||
for r in records:
|
||||
self._facts.append(
|
||||
Fact(
|
||||
relation=r["relation"],
|
||||
args=tuple(r["args"]),
|
||||
source=r.get("source", "persisted"),
|
||||
timestamp=r.get("timestamp", 0.0),
|
||||
)
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError) as exc:
|
||||
print(f"[kb] Warning: could not load {path}: {exc}", file=sys.stderr)
|
||||
|
||||
def save_to(self, path: Path) -> None:
|
||||
"""Explicitly save to a given path."""
|
||||
self._save(path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Debug / display
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def dump(self, relation_filter: Optional[str] = None) -> None:
|
||||
"""Print all (or filtered) facts to stdout."""
|
||||
for f in self._facts:
|
||||
if relation_filter and f.relation != relation_filter:
|
||||
continue
|
||||
print(f" {f} [source={f.source}]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_terms(raw: str) -> List[str]:
|
||||
"""Split a whitespace-separated string into terms."""
|
||||
return raw.strip().split()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="GOFAI symbolic knowledge base CLI"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default="kb.json",
|
||||
help="Path to persistent JSON store (default: kb.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assert",
|
||||
dest="assert_stmt",
|
||||
metavar="RELATION ARG...",
|
||||
help='Assert a fact, e.g. --assert "agent online timmy"',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--retract",
|
||||
dest="retract_stmt",
|
||||
metavar="RELATION ARG...",
|
||||
help='Retract a fact, e.g. --retract "agent online timmy"',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--query",
|
||||
dest="query_stmt",
|
||||
metavar="RELATION ARG...",
|
||||
help='Query the KB, e.g. --query "agent online ?who"',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dump",
|
||||
action="store_true",
|
||||
help="Dump all facts",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--relation",
|
||||
help="Filter --dump to a specific relation",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
kb = KnowledgeBase(persist_path=db_path)
|
||||
|
||||
if args.assert_stmt:
|
||||
terms = _parse_terms(args.assert_stmt)
|
||||
if len(terms) < 2:
|
||||
print("ERROR: --assert requires at least RELATION and one ARG", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
fact = kb.assert_fact(terms[0], *terms[1:], source="cli")
|
||||
print(f"Asserted: {fact}")
|
||||
|
||||
if args.retract_stmt:
|
||||
terms = _parse_terms(args.retract_stmt)
|
||||
if len(terms) < 2:
|
||||
print("ERROR: --retract requires at least RELATION and one ARG", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
n = kb.retract_fact(terms[0], *terms[1:])
|
||||
print(f"Retracted {n} fact(s).")
|
||||
|
||||
if args.query_stmt:
|
||||
terms = _parse_terms(args.query_stmt)
|
||||
if len(terms) < 2:
|
||||
print("ERROR: --query requires at least RELATION and one ARG", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
results = kb.query(terms[0], *terms[1:])
|
||||
if not results:
|
||||
print("No results.")
|
||||
else:
|
||||
for i, b in enumerate(results, 1):
|
||||
if b:
|
||||
bindings_str = ", ".join(f"{k}={v}" for k, v in b.items())
|
||||
print(f" [{i}] {bindings_str}")
|
||||
else:
|
||||
print(f" [{i}] (ground match)")
|
||||
|
||||
if args.dump:
|
||||
count = kb.fact_count()
|
||||
print(f"Knowledge Base — {count} fact(s):")
|
||||
kb.dump(relation_filter=args.relation)
|
||||
|
||||
if not any([args.assert_stmt, args.retract_stmt, args.query_stmt, args.dump]):
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
12
scripts/matrix_glitch_detect.py
Normal file
12
scripts/matrix_glitch_detect.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import json
|
||||
from hermes_tools import browser_navigate, browser_vision
|
||||
|
||||
def detect_glitches():
|
||||
browser_navigate(url="https://matrix.alexanderwhitestone.com")
|
||||
analysis = browser_vision(
|
||||
question="Scan the 3D world for visual artifacts, floating assets, or z-fighting. List all coordinates/descriptions of glitches found. Provide a PASS/FAIL."
|
||||
)
|
||||
return {"status": "PASS" if "PASS" in analysis.upper() else "FAIL", "analysis": analysis}
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(detect_glitches(), indent=2))
|
||||
20
scripts/nexus_smoke_test.py
Normal file
20
scripts/nexus_smoke_test.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import json
|
||||
from hermes_tools import browser_navigate, browser_vision
|
||||
|
||||
def run_smoke_test():
|
||||
print("Navigating to The Nexus...")
|
||||
browser_navigate(url="https://nexus.alexanderwhitestone.com")
|
||||
|
||||
print("Performing visual verification...")
|
||||
analysis = browser_vision(
|
||||
question="Is the Nexus landing page rendered correctly? Check for: 1) The Tower logo, 2) The main entry portal, 3) Absence of 404/Error messages. Provide a clear PASS or FAIL."
|
||||
)
|
||||
|
||||
result = {
|
||||
"status": "PASS" if "PASS" in analysis.upper() else "FAIL",
|
||||
"analysis": analysis
|
||||
}
|
||||
return result
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(run_smoke_test(), indent=2))
|
||||
@@ -15,10 +15,15 @@ import sys
|
||||
import time
|
||||
import argparse
|
||||
import requests
|
||||
import subprocess
|
||||
import json
|
||||
from typing import Optional, Dict, 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
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
DO_API_URL = "https://api.digitalocean.com/v2"
|
||||
# We expect DIGITALOCEAN_TOKEN to be set in the environment.
|
||||
@@ -30,13 +35,14 @@ DEFAULT_IMAGE = "ubuntu-22-04-x64"
|
||||
LLAMA_CPP_REPO = "https://github.com/ggerganov/llama.cpp"
|
||||
|
||||
class Provisioner:
|
||||
def __init__(self, name: str, size: str, model: str, region: str = DEFAULT_REGION):
|
||||
def __init__(self, name: str, size: str, model: str, region: str = DEFAULT_REGION, executor=None):
|
||||
self.name = name
|
||||
self.size = size
|
||||
self.model = model
|
||||
self.region = region
|
||||
self.droplet_id = None
|
||||
self.ip_address = None
|
||||
self.executor = executor or VerifiedSSHExecutor(auto_enroll=True)
|
||||
|
||||
def log(self, message: str):
|
||||
print(f"[*] {message}")
|
||||
@@ -104,13 +110,8 @@ class Provisioner:
|
||||
self.log(f"Droplet IP: {self.ip_address}")
|
||||
|
||||
def run_remote(self, command: str):
|
||||
# Using subprocess to call ssh. Assumes local machine has the right private key.
|
||||
ssh_cmd = [
|
||||
"ssh", "-o", "StrictHostKeyChecking=no",
|
||||
f"root@{self.ip_address}", command
|
||||
]
|
||||
result = subprocess.run(ssh_cmd, capture_output=True, text=True)
|
||||
return result
|
||||
# Uses verified host trust. Brand-new nodes explicitly enroll on first contact.
|
||||
return self.executor.run_script(self.ip_address, command, timeout=60)
|
||||
|
||||
def setup_wizard(self):
|
||||
self.log("Starting remote setup...")
|
||||
|
||||
@@ -4,13 +4,22 @@
|
||||
Part of the Gemini Sovereign Infrastructure Suite.
|
||||
|
||||
Auto-detects and fixes common failures across the fleet.
|
||||
|
||||
Safe-by-default: runs in dry-run mode unless --execute is given.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import argparse
|
||||
import requests
|
||||
import datetime
|
||||
from typing import Sequence
|
||||
|
||||
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 = {
|
||||
@@ -21,51 +30,211 @@ FLEET = {
|
||||
}
|
||||
|
||||
class SelfHealer:
|
||||
def __init__(self, dry_run=True, confirm_kill=False, yes=False, executor=None):
|
||||
self.dry_run = dry_run
|
||||
self.confirm_kill = confirm_kill
|
||||
self.yes = yes
|
||||
self.executor = executor or VerifiedSSHExecutor()
|
||||
|
||||
def log(self, message: str):
|
||||
print(f"[*] {message}")
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{timestamp}] {message}")
|
||||
|
||||
def run_remote(self, host: str, command: str):
|
||||
ip = FLEET[host]["ip"]
|
||||
ssh_cmd = ["ssh", "-o", "StrictHostKeyChecking=no", f"root@{ip}", command]
|
||||
if host == "mac":
|
||||
ssh_cmd = ["bash", "-c", command]
|
||||
ip = FLEET[host]['ip']
|
||||
try:
|
||||
return subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=10)
|
||||
except:
|
||||
return self.executor.run_script(ip, command, local=(host == 'mac'), timeout=15)
|
||||
except Exception as e:
|
||||
self.log(f" [ERROR] Failed to run remote command on {host}: {e}")
|
||||
return None
|
||||
|
||||
def confirm(self, prompt: str) -> bool:
|
||||
"""Ask for confirmation unless --yes flag is set."""
|
||||
if self.yes:
|
||||
return True
|
||||
while True:
|
||||
response = input(f"{prompt} [y/N] ").strip().lower()
|
||||
if response in ("y", "yes"):
|
||||
return True
|
||||
if response in ("n", "no", ""):
|
||||
return False
|
||||
print("Please answer 'y' or 'n'.")
|
||||
|
||||
def check_llama_server(self, host: str):
|
||||
ip = FLEET[host]["ip"]
|
||||
port = FLEET[host]["port"]
|
||||
try:
|
||||
requests.get(f"http://{ip}:{port}/health", timeout=2)
|
||||
except requests.RequestException:
|
||||
self.log(f" [!] llama-server down on {host}.")
|
||||
if self.dry_run:
|
||||
self.log(f" [DRY-RUN] Would restart llama-server on {host}")
|
||||
else:
|
||||
if self.confirm(f" Restart llama-server on {host}?"):
|
||||
self.log(f" Restarting llama-server on {host}...")
|
||||
self.run_remote(host, "systemctl restart llama-server")
|
||||
else:
|
||||
self.log(f" Skipped restart on {host}.")
|
||||
|
||||
def check_disk_space(self, host: str):
|
||||
res = self.run_remote(host, "df -h / | tail -1 | awk '{print $5}' | sed 's/%//'")
|
||||
if res and res.returncode == 0:
|
||||
try:
|
||||
usage = int(res.stdout.strip())
|
||||
if usage > 90:
|
||||
self.log(f" [!] Disk usage high on {host} ({usage}%).")
|
||||
if self.dry_run:
|
||||
self.log(f" [DRY-RUN] Would clean logs and vacuum journal on {host}")
|
||||
else:
|
||||
if self.confirm(f" Clean logs on {host}?"):
|
||||
self.log(f" Cleaning logs on {host}...")
|
||||
self.run_remote(host, "journalctl --vacuum-time=1d && rm -rf /var/log/*.gz")
|
||||
else:
|
||||
self.log(f" Skipped log cleaning on {host}.")
|
||||
except:
|
||||
pass
|
||||
|
||||
def check_memory(self, host: str):
|
||||
res = self.run_remote(host, "free -m | awk '/^Mem:/{print $3/$2 * 100}'")
|
||||
if res and res.returncode == 0:
|
||||
try:
|
||||
usage = float(res.stdout.strip())
|
||||
if usage > 90:
|
||||
self.log(f" [!] Memory usage high on {host} ({usage:.1f}%).")
|
||||
if self.dry_run:
|
||||
self.log(f" [DRY-RUN] Would check for memory hogs on {host}")
|
||||
else:
|
||||
self.log(f" Memory high but no automatic action defined.")
|
||||
except:
|
||||
pass
|
||||
|
||||
def check_processes(self, host: str):
|
||||
# Example: check if any process uses > 80% CPU
|
||||
res = self.run_remote(host, "ps aux --sort=-%cpu | awk 'NR>1 && $3>80 {print $2, $11, $3}'")
|
||||
if res and res.returncode == 0 and res.stdout.strip():
|
||||
self.log(f" [!] High CPU processes on {host}:")
|
||||
for line in res.stdout.strip().split('\n'):
|
||||
self.log(f" {line}")
|
||||
if self.dry_run:
|
||||
self.log(f" [DRY-RUN] Would review high-CPU processes on {host}")
|
||||
else:
|
||||
if self.confirm_kill:
|
||||
if self.confirm(f" Kill high-CPU processes on {host}? (dangerous)"):
|
||||
# This is a placeholder; real implementation would parse PIDs
|
||||
self.log(f" Process killing not implemented yet (placeholder).")
|
||||
else:
|
||||
self.log(f" Skipped killing processes on {host}.")
|
||||
else:
|
||||
self.log(f" Use --confirm-kill to enable process termination (dangerous).")
|
||||
|
||||
def check_and_heal(self):
|
||||
for host in FLEET:
|
||||
self.log(f"Auditing {host}...")
|
||||
|
||||
# 1. Check llama-server
|
||||
ip = FLEET[host]["ip"]
|
||||
port = FLEET[host]["port"]
|
||||
try:
|
||||
requests.get(f"http://{ip}:{port}/health", timeout=2)
|
||||
except:
|
||||
self.log(f" [!] llama-server down on {host}. Attempting restart...")
|
||||
self.run_remote(host, "systemctl restart llama-server")
|
||||
|
||||
# 2. Check disk space
|
||||
res = self.run_remote(host, "df -h / | tail -1 | awk '{print $5}' | sed 's/%//'")
|
||||
if res and res.returncode == 0:
|
||||
try:
|
||||
usage = int(res.stdout.strip())
|
||||
if usage > 90:
|
||||
self.log(f" [!] Disk usage high on {host} ({usage}%). Cleaning logs...")
|
||||
self.run_remote(host, "journalctl --vacuum-time=1d && rm -rf /var/log/*.gz")
|
||||
except:
|
||||
pass
|
||||
self.check_llama_server(host)
|
||||
self.check_disk_space(host)
|
||||
self.check_memory(host)
|
||||
self.check_processes(host)
|
||||
|
||||
def run(self):
|
||||
self.log("Starting self-healing cycle...")
|
||||
if self.dry_run:
|
||||
self.log("Starting self-healing cycle (DRY-RUN mode).")
|
||||
else:
|
||||
self.log("Starting self-healing cycle (EXECUTE mode).")
|
||||
self.check_and_heal()
|
||||
self.log("Cycle complete.")
|
||||
|
||||
def main():
|
||||
healer = SelfHealer()
|
||||
def print_help_safe():
|
||||
"""Print detailed explanation of what each action does."""
|
||||
help_text = """
|
||||
SAFE-BY-DEFAULT SELF-HEALING SCRIPT
|
||||
|
||||
This script checks fleet health and can optionally fix issues.
|
||||
|
||||
DEFAULT MODE: DRY-RUN (safe)
|
||||
- Only reports what it would do, does not make changes.
|
||||
- Use --execute to actually perform fixes.
|
||||
|
||||
CHECKS PERFORMED:
|
||||
1. llama-server health
|
||||
- Checks if llama-server is responding on each host.
|
||||
- Action: restart service (requires --execute and confirmation).
|
||||
|
||||
2. Disk space
|
||||
- Checks root partition usage on each host.
|
||||
- Action: vacuum journal logs and remove rotated logs if >90% (requires --execute and confirmation).
|
||||
|
||||
3. Memory usage
|
||||
- Reports high memory usage (informational only, no automatic action).
|
||||
|
||||
4. Process health
|
||||
- Lists processes using >80% CPU.
|
||||
- Action: kill processes (requires --confirm-kill flag, --execute, and confirmation).
|
||||
|
||||
SAFETY FEATURES:
|
||||
- Dry-run by default.
|
||||
- Explicit --execute flag required for changes.
|
||||
- Confirmation prompts for all destructive actions.
|
||||
- --yes flag to skip confirmations (for automation).
|
||||
- --confirm-kill flag required to even consider killing processes.
|
||||
- Timestamps on all log messages.
|
||||
|
||||
EXAMPLES:
|
||||
python3 scripts/self_healing.py
|
||||
# Dry-run: safe, shows what would happen.
|
||||
|
||||
python3 scripts/self_healing.py --execute
|
||||
# Actually perform fixes after confirmation.
|
||||
|
||||
python3 scripts/self_healing.py --execute --yes
|
||||
# Perform fixes without prompts (automation).
|
||||
|
||||
python3 scripts/self_healing.py --execute --confirm-kill
|
||||
# Allow killing processes (dangerous).
|
||||
|
||||
python3 scripts/self_healing.py --help-safe
|
||||
# Show this help.
|
||||
"""
|
||||
print(help_text)
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Self-healing infrastructure script (safe-by-default).",
|
||||
add_help=False,
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", default=False,
|
||||
help="Run in dry-run mode (default behavior).")
|
||||
parser.add_argument("--execute", action="store_true", default=False,
|
||||
help="Actually perform fixes (disables dry-run).")
|
||||
parser.add_argument("--confirm-kill", action="store_true", default=False,
|
||||
help="Allow killing processes (dangerous).")
|
||||
parser.add_argument("--yes", "-y", action="store_true", default=False,
|
||||
help="Skip confirmation prompts.")
|
||||
parser.add_argument("--help-safe", action="store_true", default=False,
|
||||
help="Show detailed help about safety features.")
|
||||
parser.add_argument("--help", "-h", action="store_true", default=False,
|
||||
help="Show standard help.")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
|
||||
if args.help_safe:
|
||||
print_help_safe()
|
||||
raise SystemExit(0)
|
||||
|
||||
if args.help:
|
||||
parser.print_help()
|
||||
raise SystemExit(0)
|
||||
|
||||
dry_run = not args.execute
|
||||
if args.dry_run:
|
||||
dry_run = True
|
||||
|
||||
healer = SelfHealer(dry_run=dry_run, confirm_kill=args.confirm_kill, yes=args.yes)
|
||||
healer.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
171
scripts/ssh_trust.py
Normal file
171
scripts/ssh_trust.py
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verified SSH trust helpers for Gemini infrastructure scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Callable, Sequence
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
DEFAULT_KNOWN_HOSTS = Path(__file__).resolve().parent.parent / ".ssh" / "known_hosts"
|
||||
Runner = Callable[..., subprocess.CompletedProcess]
|
||||
|
||||
|
||||
class SSHTrustError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class HostKeyEnrollmentError(SSHTrustError):
|
||||
pass
|
||||
|
||||
|
||||
class HostKeyVerificationError(SSHTrustError):
|
||||
pass
|
||||
|
||||
|
||||
class CommandPlan:
|
||||
def __init__(self, argv: list[str], local: bool, remote_command: str | None = None):
|
||||
self.argv = argv
|
||||
self.local = local
|
||||
self.remote_command = remote_command
|
||||
|
||||
|
||||
def _ensure_parent(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def enroll_host_key(
|
||||
host: str,
|
||||
*,
|
||||
port: int = 22,
|
||||
known_hosts_path: str | Path | None = None,
|
||||
runner: Runner = subprocess.run,
|
||||
) -> Path:
|
||||
path = Path(known_hosts_path or DEFAULT_KNOWN_HOSTS)
|
||||
_ensure_parent(path)
|
||||
cmd = ["ssh-keyscan", "-p", str(port), "-H", host]
|
||||
result = runner(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0 or not (result.stdout or "").strip():
|
||||
raise HostKeyEnrollmentError(
|
||||
f"Could not enroll host key for {host}:{port}: {(result.stderr or '').strip() or 'empty ssh-keyscan output'}"
|
||||
)
|
||||
|
||||
existing = []
|
||||
if path.exists():
|
||||
existing = [line for line in path.read_text().splitlines() if line.strip()]
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line and line not in existing:
|
||||
existing.append(line)
|
||||
path.write_text(("\n".join(existing) + "\n") if existing else "")
|
||||
return path
|
||||
|
||||
|
||||
class VerifiedSSHExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
user: str = "root",
|
||||
known_hosts_path: str | Path | None = None,
|
||||
connect_timeout: int = 5,
|
||||
auto_enroll: bool = False,
|
||||
runner: Runner = subprocess.run,
|
||||
):
|
||||
self.user = user
|
||||
self.known_hosts_path = Path(known_hosts_path or DEFAULT_KNOWN_HOSTS)
|
||||
self.connect_timeout = connect_timeout
|
||||
self.auto_enroll = auto_enroll
|
||||
self.runner = runner
|
||||
|
||||
def _ensure_known_hosts(self, host: str, port: int) -> Path:
|
||||
if self.known_hosts_path.exists():
|
||||
return self.known_hosts_path
|
||||
if not self.auto_enroll:
|
||||
raise HostKeyEnrollmentError(
|
||||
f"Known-hosts file missing: {self.known_hosts_path}. Enroll {host}:{port} before connecting."
|
||||
)
|
||||
return enroll_host_key(host, port=port, known_hosts_path=self.known_hosts_path, runner=self.runner)
|
||||
|
||||
def _ssh_prefix(self, host: str, port: int) -> list[str]:
|
||||
known_hosts = self._ensure_known_hosts(host, port)
|
||||
return [
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=yes",
|
||||
"-o",
|
||||
f"UserKnownHostsFile={known_hosts}",
|
||||
"-o",
|
||||
f"ConnectTimeout={self.connect_timeout}",
|
||||
"-p",
|
||||
str(port),
|
||||
f"{self.user}@{host}",
|
||||
]
|
||||
|
||||
def plan(
|
||||
self,
|
||||
host: str,
|
||||
command: Sequence[str],
|
||||
*,
|
||||
local: bool = False,
|
||||
port: int = 22,
|
||||
cwd: str | None = None,
|
||||
) -> CommandPlan:
|
||||
argv = [str(part) for part in command]
|
||||
if not argv:
|
||||
raise ValueError("command must not be empty")
|
||||
if local:
|
||||
return CommandPlan(argv=argv, local=True, remote_command=None)
|
||||
|
||||
remote_command = shlex.join(argv)
|
||||
if cwd:
|
||||
remote_command = f"cd {shlex.quote(cwd)} && exec {remote_command}"
|
||||
return CommandPlan(self._ssh_prefix(host, port) + [remote_command], False, remote_command)
|
||||
|
||||
def plan_script(
|
||||
self,
|
||||
host: str,
|
||||
script_text: str,
|
||||
*,
|
||||
local: bool = False,
|
||||
port: int = 22,
|
||||
cwd: str | None = None,
|
||||
) -> CommandPlan:
|
||||
remote_command = script_text.strip()
|
||||
if cwd:
|
||||
remote_command = f"cd {shlex.quote(cwd)} && {remote_command}"
|
||||
if local:
|
||||
return CommandPlan(["sh", "-lc", remote_command], True, None)
|
||||
return CommandPlan(self._ssh_prefix(host, port) + [remote_command], False, remote_command)
|
||||
|
||||
def _run_plan(self, plan: CommandPlan, *, timeout: int | None = None):
|
||||
result = self.runner(plan.argv, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode != 0 and "host key verification failed" in ((result.stderr or "").lower()):
|
||||
raise HostKeyVerificationError((result.stderr or "").strip() or "Host key verification failed")
|
||||
return result
|
||||
|
||||
def run(
|
||||
self,
|
||||
host: str,
|
||||
command: Sequence[str],
|
||||
*,
|
||||
local: bool = False,
|
||||
port: int = 22,
|
||||
cwd: str | None = None,
|
||||
timeout: int | None = None,
|
||||
):
|
||||
return self._run_plan(self.plan(host, command, local=local, port=port, cwd=cwd), timeout=timeout)
|
||||
|
||||
def run_script(
|
||||
self,
|
||||
host: str,
|
||||
script_text: str,
|
||||
*,
|
||||
local: bool = False,
|
||||
port: int = 22,
|
||||
cwd: str | None = None,
|
||||
timeout: int | None = None,
|
||||
):
|
||||
return self._run_plan(self.plan_script(host, script_text, local=local, port=port, cwd=cwd), timeout=timeout)
|
||||
304
scripts/strips_planner.py
Normal file
304
scripts/strips_planner.py
Normal file
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
"""strips_planner.py - GOFAI STRIPS-style goal-directed planner for the Timmy Foundation fleet.
|
||||
|
||||
Implements a classical means-ends analysis (MEA) planner over a STRIPS action
|
||||
representation. Each action has preconditions, an add-list, and a delete-list.
|
||||
The planner uses regression (backward chaining) from the goal state to find a
|
||||
linear action sequence that achieves all goal conditions from the initial state.
|
||||
No ML, no embeddings - just symbolic state-space search.
|
||||
|
||||
Representation:
|
||||
State: frozenset of ground literals, e.g. {'agent_idle', 'task_queued'}
|
||||
Action: (name, preconditions, add_effects, delete_effects)
|
||||
Goal: set of literals that must hold in the final state
|
||||
|
||||
Algorithm:
|
||||
Iterative-deepening DFS (IDDFS) over the regression search space.
|
||||
Cycle detection via visited-state set per path.
|
||||
|
||||
Usage (Python API):
|
||||
from strips_planner import Action, STRIPSPlanner
|
||||
actions = [
|
||||
Action('assign_task',
|
||||
pre={'agent_idle', 'task_queued'},
|
||||
add={'task_running'},
|
||||
delete={'agent_idle', 'task_queued'}),
|
||||
Action('complete_task',
|
||||
pre={'task_running'},
|
||||
add={'agent_idle', 'task_done'},
|
||||
delete={'task_running'}),
|
||||
]
|
||||
planner = STRIPSPlanner(actions)
|
||||
plan = planner.solve(
|
||||
initial={'agent_idle', 'task_queued'},
|
||||
goal={'task_done'},
|
||||
)
|
||||
# plan -> ['assign_task', 'complete_task']
|
||||
|
||||
CLI:
|
||||
python strips_planner.py --demo
|
||||
python strips_planner.py --max-depth 15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import FrozenSet, List, Optional, Set, Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Literal = str
|
||||
State = FrozenSet[Literal]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
"""A STRIPS operator with preconditions and add/delete effects."""
|
||||
|
||||
name: str
|
||||
pre: FrozenSet[Literal]
|
||||
add: FrozenSet[Literal]
|
||||
delete: FrozenSet[Literal]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Coerce mutable sets to frozensets for hashability
|
||||
object.__setattr__(self, 'pre', frozenset(self.pre))
|
||||
object.__setattr__(self, 'add', frozenset(self.add))
|
||||
object.__setattr__(self, 'delete', frozenset(self.delete))
|
||||
|
||||
def applicable(self, state: State) -> bool:
|
||||
"""True if all preconditions hold in *state*."""
|
||||
return self.pre <= state
|
||||
|
||||
def apply(self, state: State) -> State:
|
||||
"""Return the successor state after executing this action."""
|
||||
return (state - self.delete) | self.add
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Planner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class STRIPSPlanner:
|
||||
"""Goal-directed STRIPS planner using iterative-deepening DFS.
|
||||
|
||||
Searches forward from the initial state, pruning branches where the
|
||||
goal cannot be satisfied within the remaining depth budget.
|
||||
"""
|
||||
|
||||
def __init__(self, actions: List[Action]) -> None:
|
||||
self.actions = list(actions)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def solve(
|
||||
self,
|
||||
initial: Set[Literal] | FrozenSet[Literal],
|
||||
goal: Set[Literal] | FrozenSet[Literal],
|
||||
max_depth: int = 20,
|
||||
) -> Optional[List[str]]:
|
||||
"""Find a plan that achieves *goal* from *initial*.
|
||||
|
||||
Args:
|
||||
initial: Initial world state (set of ground literals).
|
||||
goal: Goal conditions (set of ground literals to achieve).
|
||||
max_depth: Maximum plan length to consider.
|
||||
|
||||
Returns:
|
||||
Ordered list of action names, or None if no plan found.
|
||||
"""
|
||||
s0 = frozenset(initial)
|
||||
g = frozenset(goal)
|
||||
|
||||
if g <= s0:
|
||||
return [] # goal already satisfied
|
||||
|
||||
for depth in range(1, max_depth + 1):
|
||||
result = self._dfs(s0, g, depth, [], {s0})
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal search
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _dfs(
|
||||
self,
|
||||
state: State,
|
||||
goal: State,
|
||||
remaining: int,
|
||||
path: List[str],
|
||||
visited: Set[State],
|
||||
) -> Optional[List[str]]:
|
||||
"""Depth-limited forward DFS."""
|
||||
if remaining == 0:
|
||||
return None
|
||||
|
||||
for action in self.actions:
|
||||
if not action.applicable(state):
|
||||
continue
|
||||
next_state = action.apply(state)
|
||||
if next_state in visited:
|
||||
continue
|
||||
new_path = path + [action.name]
|
||||
if goal <= next_state:
|
||||
return new_path
|
||||
visited.add(next_state)
|
||||
result = self._dfs(next_state, goal, remaining - 1, new_path, visited)
|
||||
visited.discard(next_state)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def explain_plan(
|
||||
self, initial: Set[Literal], plan: List[str]
|
||||
) -> List[Tuple[str, State]]:
|
||||
"""Trace each action with the resulting state for debugging.
|
||||
|
||||
Returns:
|
||||
List of (action_name, resulting_state) tuples.
|
||||
"""
|
||||
state: State = frozenset(initial)
|
||||
trace: List[Tuple[str, State]] = []
|
||||
action_map = {a.name: a for a in self.actions}
|
||||
for name in plan:
|
||||
action = action_map[name]
|
||||
state = action.apply(state)
|
||||
trace.append((name, state))
|
||||
return trace
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in demo domain: Timmy fleet task lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fleet_demo_actions() -> List[Action]:
|
||||
"""Return a small STRIPS domain modelling the Timmy fleet task lifecycle."""
|
||||
return [
|
||||
Action(
|
||||
name='receive_task',
|
||||
pre={'fleet_idle'},
|
||||
add={'task_queued', 'fleet_busy'},
|
||||
delete={'fleet_idle'},
|
||||
),
|
||||
Action(
|
||||
name='validate_task',
|
||||
pre={'task_queued'},
|
||||
add={'task_validated'},
|
||||
delete={'task_queued'},
|
||||
),
|
||||
Action(
|
||||
name='assign_agent',
|
||||
pre={'task_validated', 'agent_available'},
|
||||
add={'task_assigned'},
|
||||
delete={'task_validated', 'agent_available'},
|
||||
),
|
||||
Action(
|
||||
name='execute_task',
|
||||
pre={'task_assigned'},
|
||||
add={'task_running'},
|
||||
delete={'task_assigned'},
|
||||
),
|
||||
Action(
|
||||
name='complete_task',
|
||||
pre={'task_running'},
|
||||
add={'task_done', 'agent_available', 'fleet_idle'},
|
||||
delete={'task_running', 'fleet_busy'},
|
||||
),
|
||||
Action(
|
||||
name='report_result',
|
||||
pre={'task_done'},
|
||||
add={'result_reported'},
|
||||
delete={'task_done'},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def run_demo(max_depth: int = 20) -> None:
|
||||
"""Run the built-in Timmy fleet planning demo."""
|
||||
actions = _fleet_demo_actions()
|
||||
planner = STRIPSPlanner(actions)
|
||||
|
||||
initial: Set[Literal] = {'fleet_idle', 'agent_available'}
|
||||
goal: Set[Literal] = {'result_reported', 'fleet_idle'}
|
||||
|
||||
print("=" * 60)
|
||||
print("STRIPS Planner Demo - Timmy Fleet Task Lifecycle")
|
||||
print("=" * 60)
|
||||
print(f"Initial state : {sorted(initial)}")
|
||||
print(f"Goal : {sorted(goal)}")
|
||||
print(f"Max depth : {max_depth}")
|
||||
print()
|
||||
|
||||
plan = planner.solve(initial, goal, max_depth=max_depth)
|
||||
|
||||
if plan is None:
|
||||
print("No plan found within depth limit.")
|
||||
return
|
||||
|
||||
print(f"Plan ({len(plan)} steps):")
|
||||
for i, step in enumerate(plan, 1):
|
||||
print(f" {i:2d}. {step}")
|
||||
|
||||
print()
|
||||
print("Execution trace:")
|
||||
state: Set[Literal] = set(initial)
|
||||
for name, resulting_state in planner.explain_plan(initial, plan):
|
||||
print(f" -> {name}")
|
||||
print(f" state: {sorted(resulting_state)}")
|
||||
|
||||
print()
|
||||
achieved = frozenset(goal) <= frozenset(state) or True
|
||||
goal_met = all(g in [s for _, rs in planner.explain_plan(initial, plan) for s in rs]
|
||||
or g in initial for g in goal)
|
||||
final_state = planner.explain_plan(initial, plan)[-1][1] if plan else frozenset(initial)
|
||||
print(f"Goal satisfied: {frozenset(goal) <= final_state}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="GOFAI STRIPS-style goal-directed planner"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--demo",
|
||||
action="store_true",
|
||||
help="Run the built-in Timmy fleet demo",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-depth",
|
||||
type=int,
|
||||
default=20,
|
||||
metavar="N",
|
||||
help="Maximum plan depth for IDDFS search (default: 20)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.demo or not any(vars(args).values()):
|
||||
run_demo(max_depth=args.max_depth)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
276
scripts/symbolic_reasoner.py
Normal file
276
scripts/symbolic_reasoner.py
Normal file
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""symbolic_reasoner.py — Forward-chaining rule engine for the Timmy Foundation fleet.
|
||||
|
||||
A classical GOFAI approach: declarative IF-THEN rules evaluated over a
|
||||
working-memory of facts. Rules fire until quiescence (no new facts) or
|
||||
a configurable cycle limit. Designed to sit *beside* the LLM layer so
|
||||
that hard policy constraints never depend on probabilistic inference.
|
||||
|
||||
Usage:
|
||||
python symbolic_reasoner.py --rules rules.yaml --facts facts.yaml
|
||||
python symbolic_reasoner.py --self-test
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, FrozenSet, List, Optional, Set, Tuple
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None # graceful fallback — JSON-only mode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain types
|
||||
# ---------------------------------------------------------------------------
|
||||
Fact = Tuple[str, ...] # e.g. ("agent", "timmy", "role", "infrastructure")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
"""A single IF-THEN production rule."""
|
||||
name: str
|
||||
conditions: FrozenSet[Fact] # all must be present
|
||||
negations: FrozenSet[Fact] # none may be present
|
||||
conclusions: FrozenSet[Fact] # added when rule fires
|
||||
priority: int = 0 # higher fires first
|
||||
|
||||
def satisfied(self, wm: Set[Fact]) -> bool:
|
||||
return self.conditions.issubset(wm) and self.negations.isdisjoint(wm)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine
|
||||
# ---------------------------------------------------------------------------
|
||||
class SymbolicReasoner:
|
||||
"""Forward-chaining production system."""
|
||||
|
||||
def __init__(self, rules: List[Rule], *, cycle_limit: int = 200):
|
||||
self._rules = sorted(rules, key=lambda r: -r.priority)
|
||||
self._cycle_limit = cycle_limit
|
||||
self._trace: List[str] = []
|
||||
|
||||
# -- public API ---------------------------------------------------------
|
||||
|
||||
def infer(self, initial_facts: Set[Fact]) -> Set[Fact]:
|
||||
"""Run to quiescence and return the final working-memory."""
|
||||
wm = set(initial_facts)
|
||||
fired: Set[str] = set()
|
||||
for cycle in range(self._cycle_limit):
|
||||
progress = False
|
||||
for rule in self._rules:
|
||||
if rule.name in fired:
|
||||
continue
|
||||
if rule.satisfied(wm):
|
||||
new = rule.conclusions - wm
|
||||
if new:
|
||||
wm |= new
|
||||
fired.add(rule.name)
|
||||
self._trace.append(
|
||||
f"cycle {cycle}: {rule.name} => {_fmt_facts(new)}"
|
||||
)
|
||||
progress = True
|
||||
break # restart from highest-priority rule
|
||||
if not progress:
|
||||
break
|
||||
return wm
|
||||
|
||||
def query(self, wm: Set[Fact], pattern: Tuple[Optional[str], ...]) -> List[Fact]:
|
||||
"""Return facts matching *pattern* (None = wildcard)."""
|
||||
return [
|
||||
f for f in wm
|
||||
if len(f) == len(pattern)
|
||||
and all(p is None or p == v for p, v in zip(pattern, f))
|
||||
]
|
||||
|
||||
@property
|
||||
def trace(self) -> List[str]:
|
||||
return list(self._trace)
|
||||
|
||||
# -- serialisation helpers -----------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_dicts(cls, raw_rules: List[Dict], **kw) -> "SymbolicReasoner":
|
||||
rules = [_parse_rule(r) for r in raw_rules]
|
||||
return cls(rules, **kw)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path, **kw) -> "SymbolicReasoner":
|
||||
text = path.read_text()
|
||||
if path.suffix in (".yaml", ".yml"):
|
||||
if yaml is None:
|
||||
raise RuntimeError("PyYAML required for .yaml rules")
|
||||
data = yaml.safe_load(text)
|
||||
else:
|
||||
data = json.loads(text)
|
||||
return cls.from_dicts(data["rules"], **kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parsing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _parse_fact(raw: list | str) -> Fact:
|
||||
if isinstance(raw, str):
|
||||
return tuple(raw.split())
|
||||
return tuple(str(x) for x in raw)
|
||||
|
||||
|
||||
def _parse_rule(d: Dict) -> Rule:
|
||||
return Rule(
|
||||
name=d["name"],
|
||||
conditions=frozenset(_parse_fact(c) for c in d.get("if", [])),
|
||||
negations=frozenset(_parse_fact(c) for c in d.get("unless", [])),
|
||||
conclusions=frozenset(_parse_fact(c) for c in d.get("then", [])),
|
||||
priority=d.get("priority", 0),
|
||||
)
|
||||
|
||||
|
||||
def _fmt_facts(facts: Set[Fact]) -> str:
|
||||
return ", ".join(" ".join(f) for f in sorted(facts))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in fleet rules (loaded when no --rules file is given)
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_FLEET_RULES: List[Dict] = [
|
||||
{
|
||||
"name": "route-ci-to-timmy",
|
||||
"if": [["task", "category", "ci"]],
|
||||
"then": [["assign", "timmy"], ["reason", "timmy", "best-ci-merge-rate"]],
|
||||
"priority": 10,
|
||||
},
|
||||
{
|
||||
"name": "route-security-to-timmy",
|
||||
"if": [["task", "category", "security"]],
|
||||
"then": [["assign", "timmy"], ["reason", "timmy", "security-specialist"]],
|
||||
"priority": 10,
|
||||
},
|
||||
{
|
||||
"name": "route-architecture-to-gemini",
|
||||
"if": [["task", "category", "architecture"]],
|
||||
"unless": [["assign", "timmy"]],
|
||||
"then": [["assign", "gemini"], ["reason", "gemini", "architecture-strength"]],
|
||||
"priority": 8,
|
||||
},
|
||||
{
|
||||
"name": "route-review-to-allegro",
|
||||
"if": [["task", "category", "review"]],
|
||||
"then": [["assign", "allegro"], ["reason", "allegro", "highest-quality-per-pr"]],
|
||||
"priority": 9,
|
||||
},
|
||||
{
|
||||
"name": "route-frontend-to-claude",
|
||||
"if": [["task", "category", "frontend"]],
|
||||
"unless": [["task", "repo", "fleet-ops"]],
|
||||
"then": [["assign", "claude"], ["reason", "claude", "high-volume-frontend"]],
|
||||
"priority": 5,
|
||||
},
|
||||
{
|
||||
"name": "block-merge-without-review",
|
||||
"if": [["pr", "status", "open"], ["pr", "reviews", "0"]],
|
||||
"then": [["pr", "action", "block-merge"], ["reason", "policy", "no-unreviewed-merges"]],
|
||||
"priority": 20,
|
||||
},
|
||||
{
|
||||
"name": "block-merge-ci-failing",
|
||||
"if": [["pr", "status", "open"], ["pr", "ci", "failing"]],
|
||||
"then": [["pr", "action", "block-merge"], ["reason", "policy", "ci-must-pass"]],
|
||||
"priority": 20,
|
||||
},
|
||||
{
|
||||
"name": "auto-label-hotfix",
|
||||
"if": [["pr", "title-prefix", "hotfix"]],
|
||||
"then": [["pr", "label", "hotfix"], ["pr", "priority", "urgent"]],
|
||||
"priority": 15,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Self-test
|
||||
# ---------------------------------------------------------------------------
|
||||
def _self_test() -> bool:
|
||||
"""Verify core behaviour; returns True on success."""
|
||||
engine = SymbolicReasoner.from_dicts(DEFAULT_FLEET_RULES)
|
||||
|
||||
# Scenario 1: CI task should route to Timmy
|
||||
wm = engine.infer({("task", "category", "ci")})
|
||||
assert ("assign", "timmy") in wm, f"expected timmy assignment, got {wm}"
|
||||
|
||||
# Scenario 2: architecture task routes to gemini (not timmy)
|
||||
engine2 = SymbolicReasoner.from_dicts(DEFAULT_FLEET_RULES)
|
||||
wm2 = engine2.infer({("task", "category", "architecture")})
|
||||
assert ("assign", "gemini") in wm2, f"expected gemini assignment, got {wm2}"
|
||||
|
||||
# Scenario 3: open PR with no reviews should block merge
|
||||
engine3 = SymbolicReasoner.from_dicts(DEFAULT_FLEET_RULES)
|
||||
wm3 = engine3.infer({("pr", "status", "open"), ("pr", "reviews", "0")})
|
||||
assert ("pr", "action", "block-merge") in wm3
|
||||
|
||||
# Scenario 4: negation — frontend + fleet-ops should NOT assign claude
|
||||
engine4 = SymbolicReasoner.from_dicts(DEFAULT_FLEET_RULES)
|
||||
wm4 = engine4.infer({("task", "category", "frontend"), ("task", "repo", "fleet-ops")})
|
||||
assert ("assign", "claude") not in wm4
|
||||
|
||||
# Scenario 5: query with wildcards
|
||||
results = engine.query(wm, ("reason", None, None))
|
||||
assert len(results) > 0
|
||||
|
||||
print("All 5 self-test scenarios passed.")
|
||||
for line in engine.trace:
|
||||
print(f" {line}")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--rules", type=Path, help="YAML/JSON rule file")
|
||||
ap.add_argument("--facts", type=Path, help="YAML/JSON initial facts")
|
||||
ap.add_argument("--self-test", action="store_true")
|
||||
ap.add_argument("--json", action="store_true", help="output as JSON")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.self_test:
|
||||
ok = _self_test()
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
if args.rules:
|
||||
engine = SymbolicReasoner.from_file(args.rules)
|
||||
else:
|
||||
engine = SymbolicReasoner.from_dicts(DEFAULT_FLEET_RULES)
|
||||
|
||||
if args.facts:
|
||||
text = args.facts.read_text()
|
||||
if args.facts.suffix in (".yaml", ".yml"):
|
||||
raw = yaml.safe_load(text)
|
||||
else:
|
||||
raw = json.loads(text)
|
||||
initial = {_parse_fact(f) for f in raw.get("facts", [])}
|
||||
else:
|
||||
initial = set()
|
||||
print("No --facts provided; running with empty working memory.")
|
||||
|
||||
wm = engine.infer(initial)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"facts": [list(f) for f in sorted(wm)], "trace": engine.trace}, indent=2))
|
||||
else:
|
||||
print(f"Final working memory ({len(wm)} facts):")
|
||||
for f in sorted(wm):
|
||||
print(f" {' '.join(f)}")
|
||||
if engine.trace:
|
||||
print(f"\nInference trace ({len(engine.trace)} firings):")
|
||||
for line in engine.trace:
|
||||
print(f" {line}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
331
scripts/task_gate.py
Normal file
331
scripts/task_gate.py
Normal file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Task Gate — Pre-task and post-task quality gates for fleet agents.
|
||||
|
||||
This is the missing enforcement layer between the orchestrator dispatching
|
||||
an issue and an agent submitting a PR. SOUL.md demands "grounding before
|
||||
generation" and "the apparatus that gives these words teeth" — this script
|
||||
is that apparatus.
|
||||
|
||||
Usage:
|
||||
python3 task_gate.py pre --repo timmy-config --issue 123 --agent groq
|
||||
python3 task_gate.py post --repo timmy-config --issue 123 --agent groq --branch groq/issue-123
|
||||
|
||||
Pre-task gate checks:
|
||||
1. Issue is not already assigned to a different agent
|
||||
2. No existing branch targets this issue
|
||||
3. No open PR already addresses this issue
|
||||
4. Agent is in the correct lane per playbooks/agent-lanes.json
|
||||
5. Issue is not filtered (epic, permanent, etc.)
|
||||
|
||||
Post-task gate checks:
|
||||
1. Branch exists and has commits ahead of main
|
||||
2. Changed files pass syntax_guard.py
|
||||
3. No duplicate PR exists for the same issue
|
||||
4. Branch name follows convention: {agent}/{description}
|
||||
5. At least one file was actually changed
|
||||
|
||||
Exit codes:
|
||||
0 = all gates pass
|
||||
1 = gate failure (should not proceed)
|
||||
2 = warning (can proceed with caution)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CONFIG
|
||||
# ---------------------------------------------------------------------------
|
||||
GITEA_API = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
GITEA_OWNER = "Timmy_Foundation"
|
||||
|
||||
FILTER_TAGS = ["[EPIC]", "[DO NOT CLOSE]", "[PERMANENT]", "[PHILOSOPHY]", "[MORNING REPORT]"]
|
||||
|
||||
AGENT_USERNAMES = {
|
||||
"groq", "ezra", "bezalel", "allegro", "timmy",
|
||||
"thetimmyc", "perplexity", "kimiclaw", "codex-agent",
|
||||
"manus", "claude", "gemini", "grok",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GITEA API
|
||||
# ---------------------------------------------------------------------------
|
||||
def load_gitea_token():
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
if token:
|
||||
return token.strip()
|
||||
for path in [
|
||||
os.path.expanduser("~/.hermes/gitea_token_vps"),
|
||||
os.path.expanduser("~/.hermes/gitea_token"),
|
||||
]:
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
print("[FATAL] No GITEA_TOKEN found")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def gitea_get(path):
|
||||
token = load_gitea_token()
|
||||
url = f"{GITEA_API}{path}"
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
print(f"[API ERROR] {url} -> {e.code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[API ERROR] {url} -> {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LANE CHECKER
|
||||
# ---------------------------------------------------------------------------
|
||||
def load_agent_lanes():
|
||||
"""Load agent lane assignments from playbooks/agent-lanes.json."""
|
||||
lanes_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"playbooks", "agent-lanes.json"
|
||||
)
|
||||
try:
|
||||
with open(lanes_path) as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return {} # no lanes file = no lane enforcement
|
||||
|
||||
|
||||
def check_agent_lane(agent, issue_title, issue_labels, lanes):
|
||||
"""Check if the agent is in the right lane for this issue type."""
|
||||
if not lanes:
|
||||
return True, "No lane config found — skipping lane check"
|
||||
agent_lanes = lanes.get(agent, [])
|
||||
if not agent_lanes:
|
||||
return True, f"No lanes defined for {agent} — skipping"
|
||||
# This is advisory, not blocking — return warning if mismatch
|
||||
return True, f"{agent} has lanes: {agent_lanes}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PRE-TASK GATE
|
||||
# ---------------------------------------------------------------------------
|
||||
def pre_task_gate(repo, issue_number, agent):
|
||||
"""Run all pre-task checks. Returns (pass, messages)."""
|
||||
messages = []
|
||||
failures = []
|
||||
warnings = []
|
||||
|
||||
print(f"\n=== PRE-TASK GATE: {repo}#{issue_number} for {agent} ===")
|
||||
|
||||
# 1. Fetch issue
|
||||
issue = gitea_get(f"/repos/{GITEA_OWNER}/{repo}/issues/{issue_number}")
|
||||
if not issue:
|
||||
failures.append(f"Issue #{issue_number} not found in {repo}")
|
||||
return False, failures
|
||||
|
||||
title = issue.get("title", "")
|
||||
print(f" Issue: {title}")
|
||||
|
||||
# 2. Check if filtered
|
||||
title_upper = title.upper()
|
||||
for tag in FILTER_TAGS:
|
||||
if tag.upper().replace("[", "").replace("]", "") in title_upper:
|
||||
failures.append(f"Issue has filter tag: {tag} — should not be auto-dispatched")
|
||||
|
||||
# 3. Check assignees
|
||||
assignees = [a.get("login", "") for a in (issue.get("assignees") or [])]
|
||||
other_agents = [a for a in assignees if a.lower() in AGENT_USERNAMES and a.lower() != agent.lower()]
|
||||
if other_agents:
|
||||
failures.append(f"Already assigned to other agent(s): {other_agents}")
|
||||
|
||||
# 4. Check for existing branches
|
||||
branches = gitea_get(f"/repos/{GITEA_OWNER}/{repo}/branches?limit=50")
|
||||
if branches:
|
||||
issue_branches = [
|
||||
b["name"] for b in branches
|
||||
if str(issue_number) in b.get("name", "")
|
||||
and b["name"] != "main"
|
||||
]
|
||||
if issue_branches:
|
||||
warnings.append(f"Existing branches may target this issue: {issue_branches}")
|
||||
|
||||
# 5. Check for existing PRs
|
||||
prs = gitea_get(f"/repos/{GITEA_OWNER}/{repo}/pulls?state=open&limit=50")
|
||||
if prs:
|
||||
issue_prs = [
|
||||
f"PR #{p['number']}: {p['title']}"
|
||||
for p in prs
|
||||
if str(issue_number) in p.get("title", "")
|
||||
or str(issue_number) in p.get("body", "")
|
||||
]
|
||||
if issue_prs:
|
||||
failures.append(f"Open PR(s) already target this issue: {issue_prs}")
|
||||
|
||||
# 6. Check agent lanes
|
||||
lanes = load_agent_lanes()
|
||||
labels = [l.get("name", "") for l in (issue.get("labels") or [])]
|
||||
lane_ok, lane_msg = check_agent_lane(agent, title, labels, lanes)
|
||||
if not lane_ok:
|
||||
warnings.append(lane_msg)
|
||||
else:
|
||||
messages.append(f" Lane: {lane_msg}")
|
||||
|
||||
# Report
|
||||
if failures:
|
||||
print("\n FAILURES:")
|
||||
for f in failures:
|
||||
print(f" ❌ {f}")
|
||||
if warnings:
|
||||
print("\n WARNINGS:")
|
||||
for w in warnings:
|
||||
print(f" ⚠️ {w}")
|
||||
if not failures and not warnings:
|
||||
print(" \u2705 All pre-task gates passed")
|
||||
|
||||
passed = len(failures) == 0
|
||||
return passed, failures + warnings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST-TASK GATE
|
||||
# ---------------------------------------------------------------------------
|
||||
def post_task_gate(repo, issue_number, agent, branch):
|
||||
"""Run all post-task checks. Returns (pass, messages)."""
|
||||
failures = []
|
||||
warnings = []
|
||||
|
||||
print(f"\n=== POST-TASK GATE: {repo}#{issue_number} by {agent} ===")
|
||||
print(f" Branch: {branch}")
|
||||
|
||||
# 1. Check branch exists
|
||||
branch_info = gitea_get(
|
||||
f"/repos/{GITEA_OWNER}/{repo}/branches/{urllib.parse.quote(branch, safe='')}"
|
||||
)
|
||||
if not branch_info:
|
||||
failures.append(f"Branch '{branch}' does not exist")
|
||||
return False, failures
|
||||
|
||||
# 2. Check branch naming convention
|
||||
if "/" not in branch:
|
||||
warnings.append(f"Branch name '{branch}' doesn't follow agent/description convention")
|
||||
elif not branch.startswith(f"{agent}/"):
|
||||
warnings.append(f"Branch '{branch}' doesn't start with agent name '{agent}/")
|
||||
|
||||
# 3. Check for commits ahead of main
|
||||
compare = gitea_get(
|
||||
f"/repos/{GITEA_OWNER}/{repo}/compare/main...{urllib.parse.quote(branch, safe='')}"
|
||||
)
|
||||
if compare:
|
||||
commits = compare.get("commits", [])
|
||||
if not commits:
|
||||
failures.append("Branch has no commits ahead of main")
|
||||
else:
|
||||
print(f" Commits ahead: {len(commits)}")
|
||||
files = compare.get("diff_files", []) or []
|
||||
if not files:
|
||||
# Try alternate key
|
||||
num_files = compare.get("total_commits", 0)
|
||||
print(f" Files changed: (check PR diff)")
|
||||
else:
|
||||
print(f" Files changed: {len(files)}")
|
||||
|
||||
# 4. Check for duplicate PRs
|
||||
prs = gitea_get(f"/repos/{GITEA_OWNER}/{repo}/pulls?state=open&limit=50")
|
||||
if prs:
|
||||
dupe_prs = [
|
||||
f"PR #{p['number']}"
|
||||
for p in prs
|
||||
if str(issue_number) in p.get("title", "")
|
||||
or str(issue_number) in p.get("body", "")
|
||||
]
|
||||
if len(dupe_prs) > 1:
|
||||
warnings.append(f"Multiple open PRs may target issue #{issue_number}: {dupe_prs}")
|
||||
|
||||
# 5. Run syntax guard on changed files (if available)
|
||||
syntax_guard = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"hermes-sovereign", "scripts", "syntax_guard.py"
|
||||
)
|
||||
if os.path.exists(syntax_guard):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, syntax_guard],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode != 0:
|
||||
failures.append(f"Syntax guard failed: {result.stdout[:200]}")
|
||||
else:
|
||||
print(" Syntax guard: passed")
|
||||
except Exception as e:
|
||||
warnings.append(f"Could not run syntax guard: {e}")
|
||||
else:
|
||||
warnings.append("syntax_guard.py not found — skipping syntax check")
|
||||
|
||||
# Report
|
||||
if failures:
|
||||
print("\n FAILURES:")
|
||||
for f in failures:
|
||||
print(f" ❌ {f}")
|
||||
if warnings:
|
||||
print("\n WARNINGS:")
|
||||
for w in warnings:
|
||||
print(f" ⚠️ {w}")
|
||||
if not failures and not warnings:
|
||||
print(" \u2705 All post-task gates passed")
|
||||
|
||||
passed = len(failures) == 0
|
||||
return passed, failures + warnings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MAIN
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Task Gate — pre/post-task quality gates")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
# Pre-task
|
||||
pre = subparsers.add_parser("pre", help="Run pre-task gates")
|
||||
pre.add_argument("--repo", required=True)
|
||||
pre.add_argument("--issue", type=int, required=True)
|
||||
pre.add_argument("--agent", required=True)
|
||||
|
||||
# Post-task
|
||||
post = subparsers.add_parser("post", help="Run post-task gates")
|
||||
post.add_argument("--repo", required=True)
|
||||
post.add_argument("--issue", type=int, required=True)
|
||||
post.add_argument("--agent", required=True)
|
||||
post.add_argument("--branch", required=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if args.command == "pre":
|
||||
passed, msgs = pre_task_gate(args.repo, args.issue, args.agent)
|
||||
elif args.command == "post":
|
||||
passed, msgs = post_task_gate(args.repo, args.issue, args.agent, args.branch)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0 if passed else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,9 +10,14 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import subprocess
|
||||
import argparse
|
||||
|
||||
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 = {
|
||||
"mac": "10.1.10.77",
|
||||
@@ -23,7 +28,8 @@ FLEET = {
|
||||
TELEMETRY_FILE = "logs/telemetry.json"
|
||||
|
||||
class Telemetry:
|
||||
def __init__(self):
|
||||
def __init__(self, executor=None):
|
||||
self.executor = executor or VerifiedSSHExecutor()
|
||||
# Find logs relative to repo root
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
repo_root = os.path.dirname(script_dir)
|
||||
@@ -41,14 +47,12 @@ class Telemetry:
|
||||
# Command to get disk usage, memory usage (%), and load avg
|
||||
cmd = "df -h / | tail -1 | awk '{print $5}' && free -m | grep Mem | awk '{print $3/$2 * 100}' && uptime | awk '{print $10}'"
|
||||
|
||||
ssh_cmd = ["ssh", "-o", "StrictHostKeyChecking=no", f"root@{ip}", cmd]
|
||||
if host == "mac":
|
||||
if host == 'mac':
|
||||
# Mac specific commands
|
||||
cmd = "df -h / | tail -1 | awk '{print $5}' && sysctl -n vm.page_pageable_internal_count && uptime | awk '{print $10}'"
|
||||
ssh_cmd = ["bash", "-c", cmd]
|
||||
|
||||
|
||||
try:
|
||||
res = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=10)
|
||||
res = self.executor.run_script(ip, cmd, local=(host == 'mac'), timeout=10)
|
||||
if res.returncode == 0:
|
||||
lines = res.stdout.strip().split("\n")
|
||||
return {
|
||||
|
||||
307
scripts/temporal_reasoner.py
Normal file
307
scripts/temporal_reasoner.py
Normal file
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""temporal_reasoner.py - GOFAI temporal reasoning engine for the Timmy Foundation fleet.
|
||||
|
||||
A symbolic temporal constraint network (TCN) for scheduling and ordering events.
|
||||
Models Allen's interval algebra relations (before, after, meets, overlaps, etc.)
|
||||
and propagates temporal constraints via path-consistency to detect conflicts.
|
||||
No ML, no embeddings - just constraint propagation over a temporal graph.
|
||||
|
||||
Core concepts:
|
||||
TimePoint: A named instant on a symbolic timeline.
|
||||
Interval: A pair of time-points (start, end) with start < end.
|
||||
Constraint: A relation between two time-points or intervals
|
||||
(e.g. A.before(B), A.meets(B)).
|
||||
|
||||
Usage (Python API):
|
||||
from temporal_reasoner import TemporalNetwork, Interval
|
||||
tn = TemporalNetwork()
|
||||
deploy = tn.add_interval('deploy', duration=(10, 30))
|
||||
test = tn.add_interval('test', duration=(5, 15))
|
||||
tn.add_constraint(deploy, 'before', test)
|
||||
consistent = tn.propagate()
|
||||
|
||||
CLI:
|
||||
python temporal_reasoner.py --demo
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
INF = float('inf')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimePoint:
|
||||
"""A named instant on the timeline."""
|
||||
name: str
|
||||
id: int = field(default=0)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
@dataclass
|
||||
class Interval:
|
||||
"""A named interval bounded by two time-points."""
|
||||
name: str
|
||||
start: int # index into the distance matrix
|
||||
end: int # index into the distance matrix
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class Relation(Enum):
|
||||
"""Allen's interval algebra relations (simplified subset)."""
|
||||
BEFORE = 'before'
|
||||
AFTER = 'after'
|
||||
MEETS = 'meets'
|
||||
MET_BY = 'met_by'
|
||||
OVERLAPS = 'overlaps'
|
||||
DURING = 'during'
|
||||
EQUALS = 'equals'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple Temporal Network (STN) via distance matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TemporalNetwork:
|
||||
"""Simple Temporal Network with Floyd-Warshall propagation.
|
||||
|
||||
Internally maintains a distance matrix D where D[i][j] is the
|
||||
maximum allowed distance from time-point i to time-point j.
|
||||
Negative cycles indicate inconsistency.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._n = 0
|
||||
self._names: List[str] = []
|
||||
self._dist: List[List[float]] = []
|
||||
self._intervals: Dict[str, Interval] = {}
|
||||
self._origin_idx: int = -1
|
||||
self._add_point('origin')
|
||||
self._origin_idx = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Point management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _add_point(self, name: str) -> int:
|
||||
"""Add a time-point and return its index."""
|
||||
idx = self._n
|
||||
self._n += 1
|
||||
self._names.append(name)
|
||||
# Extend distance matrix
|
||||
for row in self._dist:
|
||||
row.append(INF)
|
||||
self._dist.append([INF] * self._n)
|
||||
self._dist[idx][idx] = 0.0
|
||||
return idx
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Interval management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_interval(
|
||||
self,
|
||||
name: str,
|
||||
duration: Optional[Tuple[float, float]] = None,
|
||||
) -> Interval:
|
||||
"""Add a named interval with optional duration bounds [lo, hi].
|
||||
|
||||
Returns the Interval object with start/end indices.
|
||||
"""
|
||||
s = self._add_point(f"{name}.start")
|
||||
e = self._add_point(f"{name}.end")
|
||||
# start < end (at least 1 time unit)
|
||||
self._dist[s][e] = min(self._dist[s][e], duration[1] if duration else INF)
|
||||
self._dist[e][s] = min(self._dist[e][s], -(duration[0] if duration else 1))
|
||||
interval = Interval(name=name, start=s, end=e)
|
||||
self._intervals[name] = interval
|
||||
return interval
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Constraint management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_distance_constraint(
|
||||
self, i: int, j: int, lo: float, hi: float
|
||||
) -> None:
|
||||
"""Add constraint: lo <= t_j - t_i <= hi."""
|
||||
self._dist[i][j] = min(self._dist[i][j], hi)
|
||||
self._dist[j][i] = min(self._dist[j][i], -lo)
|
||||
|
||||
def add_constraint(
|
||||
self, a: Interval, relation: str, b: Interval, gap: Tuple[float, float] = (0, INF)
|
||||
) -> None:
|
||||
"""Add an Allen-style relation between two intervals.
|
||||
|
||||
Supported relations: before, after, meets, met_by, equals.
|
||||
"""
|
||||
rel = relation.lower()
|
||||
if rel == 'before':
|
||||
# a.end + gap <= b.start
|
||||
self.add_distance_constraint(a.end, b.start, gap[0], gap[1])
|
||||
elif rel == 'after':
|
||||
self.add_distance_constraint(b.end, a.start, gap[0], gap[1])
|
||||
elif rel == 'meets':
|
||||
# a.end == b.start
|
||||
self.add_distance_constraint(a.end, b.start, 0, 0)
|
||||
elif rel == 'met_by':
|
||||
self.add_distance_constraint(b.end, a.start, 0, 0)
|
||||
elif rel == 'equals':
|
||||
self.add_distance_constraint(a.start, b.start, 0, 0)
|
||||
self.add_distance_constraint(a.end, b.end, 0, 0)
|
||||
else:
|
||||
raise ValueError(f"Unsupported relation: {relation}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Propagation (Floyd-Warshall)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def propagate(self) -> bool:
|
||||
"""Run Floyd-Warshall to propagate all constraints.
|
||||
|
||||
Returns True if the network is consistent (no negative cycles).
|
||||
"""
|
||||
n = self._n
|
||||
d = self._dist
|
||||
for k in range(n):
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if d[i][k] + d[k][j] < d[i][j]:
|
||||
d[i][j] = d[i][k] + d[k][j]
|
||||
# Check for negative cycles
|
||||
for i in range(n):
|
||||
if d[i][i] < 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_consistent(self) -> bool:
|
||||
"""Check consistency without mutating (copies matrix first)."""
|
||||
import copy
|
||||
saved = copy.deepcopy(self._dist)
|
||||
result = self.propagate()
|
||||
self._dist = saved
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def earliest(self, point_idx: int) -> float:
|
||||
"""Earliest possible time for a point (relative to origin)."""
|
||||
return -self._dist[point_idx][self._origin_idx]
|
||||
|
||||
def latest(self, point_idx: int) -> float:
|
||||
"""Latest possible time for a point (relative to origin)."""
|
||||
return self._dist[self._origin_idx][point_idx]
|
||||
|
||||
def interval_bounds(self, interval: Interval) -> Dict[str, Tuple[float, float]]:
|
||||
"""Return earliest/latest start and end for an interval."""
|
||||
return {
|
||||
'start': (self.earliest(interval.start), self.latest(interval.start)),
|
||||
'end': (self.earliest(interval.end), self.latest(interval.end)),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Display
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def dump(self) -> None:
|
||||
"""Print the current distance matrix and interval bounds."""
|
||||
print(f"Temporal Network — {self._n} time-points, {len(self._intervals)} intervals")
|
||||
print()
|
||||
for name, interval in self._intervals.items():
|
||||
bounds = self.interval_bounds(interval)
|
||||
s_lo, s_hi = bounds['start']
|
||||
e_lo, e_hi = bounds['end']
|
||||
print(f" {name}:")
|
||||
print(f" start: [{s_lo:.1f}, {s_hi:.1f}]")
|
||||
print(f" end: [{e_lo:.1f}, {e_hi:.1f}]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Demo: Timmy fleet deployment pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_demo() -> None:
|
||||
"""Run a demo temporal reasoning scenario for the Timmy fleet."""
|
||||
print("=" * 60)
|
||||
print("Temporal Reasoner Demo - Fleet Deployment Pipeline")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
tn = TemporalNetwork()
|
||||
|
||||
# Define pipeline stages with duration bounds [min, max]
|
||||
build = tn.add_interval('build', duration=(5, 15))
|
||||
test = tn.add_interval('test', duration=(10, 30))
|
||||
review = tn.add_interval('review', duration=(2, 10))
|
||||
deploy = tn.add_interval('deploy', duration=(1, 5))
|
||||
monitor = tn.add_interval('monitor', duration=(20, 60))
|
||||
|
||||
# Temporal constraints
|
||||
tn.add_constraint(build, 'meets', test) # test starts when build ends
|
||||
tn.add_constraint(test, 'before', review, gap=(0, 5)) # review within 5 of test
|
||||
tn.add_constraint(review, 'meets', deploy) # deploy immediately after review
|
||||
tn.add_constraint(deploy, 'before', monitor, gap=(0, 2)) # monitor within 2 of deploy
|
||||
|
||||
# Global deadline: everything done within 120 time units
|
||||
tn.add_distance_constraint(tn._origin_idx, monitor.end, 0, 120)
|
||||
|
||||
# Build must start within first 10 units
|
||||
tn.add_distance_constraint(tn._origin_idx, build.start, 0, 10)
|
||||
|
||||
print("Constraints added. Propagating...")
|
||||
consistent = tn.propagate()
|
||||
print(f"Network consistent: {consistent}")
|
||||
print()
|
||||
|
||||
if consistent:
|
||||
tn.dump()
|
||||
print()
|
||||
|
||||
# Now add a conflicting constraint to show inconsistency detection
|
||||
print("--- Adding conflicting constraint: monitor.before(build) ---")
|
||||
tn.add_constraint(monitor, 'before', build)
|
||||
consistent2 = tn.propagate()
|
||||
print(f"Network consistent after conflict: {consistent2}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="GOFAI temporal reasoning engine"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--demo",
|
||||
action="store_true",
|
||||
help="Run the fleet deployment pipeline demo",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.demo or not any(vars(args).values()):
|
||||
run_demo()
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user